Skip to content

Commit

Permalink
refactor: condition expression matcher & contextual values replacement
Browse files Browse the repository at this point in the history
  • Loading branch information
Hrdtr committed Nov 15, 2024
1 parent 466aa33 commit c62597e
Show file tree
Hide file tree
Showing 14 changed files with 99 additions and 270 deletions.
43 changes: 22 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class Guantr<
Context extends Record<string, unknown> = Record<string, unknown>
> {
private _context: Context = {} as Context;
private _permissions: GuantrAnyPermission[] = [];
private _permissions = new Set<GuantrAnyPermission>();

/**
* Initializes a new instance of the Guantr class with an optional context.
Expand Down Expand Up @@ -58,7 +58,7 @@ export class Guantr<
* @return {ReadonlyArray<GuantrAnyPermission>} The permissions of the Guantr instance.
*/
get permissions(): ReadonlyArray<GuantrAnyPermission> {
return this._permissions;
return [...this._permissions];
}

/**
Expand Down Expand Up @@ -97,15 +97,15 @@ export class Guantr<
) => void,
) => void
): void {
this._permissions = []
this._permissions.clear()
callback(
(action, resource) => this._permissions.push({
(action, resource) => this._permissions.add({
action,
resource: typeof resource === 'string' ? resource : resource[0],
condition: typeof resource === 'string' ? null : resource[1] as GuantrAnyPermission['condition'],
inverted: false
}),
(action, resource) => this._permissions.push({
(action, resource) => this._permissions.add({
action,
resource: typeof resource === 'string' ? resource : resource[0],
condition: typeof resource === 'string' ? null : resource[1] as GuantrAnyPermission['condition'],
Expand All @@ -120,7 +120,7 @@ export class Guantr<
* @param {GuantrPermission<Meta, Context>[]} permissions - The array of permissions to set.
*/
setPermissions(permissions: GuantrPermission<Meta, Context>[]): void {
this._permissions = permissions as GuantrAnyPermission[];
this._permissions = new Set(permissions as GuantrAnyPermission[])
}

/**
Expand Down Expand Up @@ -157,18 +157,21 @@ export class Guantr<
return this.permissions.some(item => item.action === action && item.resource === resource && !item.inverted)
}
const relatedPermissions = this.relatedPermissionsFor(action, resource[0])
if (relatedPermissions.length === 0) return false
.map(permission => ({ ...permission, condition: this.applyContextualOperands(permission.condition) }))

if (relatedPermissions.length === 0) {
return false
}

const passed: boolean[] = []
const passedInverted: boolean[] = []

for (const permission of relatedPermissions) {
if (!permission.condition) {
if (permission.inverted) passedInverted.push(false)
else passed.push(true)
continue
}
const matched = matchPermissionCondition(resource[1], permission.condition, this.context)
const matched = matchPermissionCondition(resource[1], permission.condition)
if (matched) {
if (permission.inverted) passedInverted.push(false)
else passed.push(true)
Expand Down Expand Up @@ -219,21 +222,19 @@ export class Guantr<
resource: ResourceKey,
action?: Meta extends GuantrMeta<infer U> ? U[ResourceKey]['action'] : string
): R {
const relatedPermissions = this.relatedPermissionsFor(
action ?? 'read' as NonNullable<typeof action>,
resource
).map(permission => ({
...permission,
condition: permission.condition
? JSON.parse(JSON.stringify(permission.condition), (_, v) => {
if (isContextualOperand(v)) return getContextValue(this._context, v) ?? v
return v
}) as GuantrAnyPermission['condition']
: null
}))
const relatedPermissions = this.relatedPermissionsFor(action ?? 'read' as NonNullable<typeof action>, resource)
.map(permission => ({ ...permission, condition: this.applyContextualOperands(permission.condition) }))

return transformer(relatedPermissions)
}

private applyContextualOperands(
condition: GuantrAnyPermission['condition']
): GuantrAnyPermission['condition'] {
return condition
? JSON.parse(JSON.stringify(condition), (_, value) => isContextualOperand(value) ? getContextValue(this._context, value) : value)
: null;
}
}

/**
Expand Down
88 changes: 35 additions & 53 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { GuantrAnyConditionExpression, GuantrAnyPermission } from "./types"

/**
* Checks if the given path is a string and starts with either '$context.' or 'context.'.
*
* @param {unknown} path - The path to check.
* @return {boolean} - Returns true if the path is a string and starts with either '$context.' or 'context.', otherwise returns false.
*/
export const isContextualOperand = (path: unknown): path is string => typeof path === 'string' && (path.startsWith('$context.') || path.startsWith('context.'))

/**
* Retrieves the value at the specified path from the given context object.
*
* @template T - The type of the context object.
* @template U - The type of the value to retrieve.
* @param {T} context - The context object to search in.
* @param {string} path - The dot-separated path to the value.
* @return {U | undefined} The value at the specified path, or undefined if not found.
*/
export const getContextValue = <T extends Record<string, unknown>, U>(context: T, path: string): U | undefined => {
return (path.replace(path.startsWith('$') ? '$context.' : 'context.', ''))
.replaceAll('?.', '.')
.split('.')
// eslint-disable-next-line unicorn/no-array-reduce
.reduce((o, k) => (o || {})[k], (context ?? {}) as Record<string, any>) as U | undefined
}

export const isValidConditionExpression = (maybeExpression: unknown): maybeExpression is GuantrAnyConditionExpression => {
if (!Array.isArray(maybeExpression) || maybeExpression.length < 2 || typeof maybeExpression[0] !== 'string') return false
return true
Expand All @@ -9,24 +34,20 @@ export const isValidConditionExpression = (maybeExpression: unknown): maybeExpre
* Checks if the given model matches the permission condition.
*
* @param {Model} model - The model to check against the permission condition.
* @param {GuantrAnyPermission & { condition: NonNullable<GuantrAnyPermission['condition']> }} permission - The permission object containing the condition to match.
* @param {Context} [context] - Optional context object for additional information.
* @param {GuantrAnyPermission & { condition: NonNullable<GuantrAnyPermission['condition']> }} condition - The condition to match.
* @returns {boolean} Returns true if the model matches the permission condition, false otherwise.
*/
export const matchPermissionCondition = <
Model extends Record<string, unknown>,
Context extends Record<string, unknown> | undefined = undefined,
>(
model: Model,
condition: NonNullable<GuantrAnyPermission['condition']>,
context?: Context,
): boolean => {
return Object.entries(condition).every(([key, expressionOrNestedCondition]) => {
if (Array.isArray(expressionOrNestedCondition)) {
return matchConditionExpression({
value: model[key],
expression: expressionOrNestedCondition,
context,
})
}
else if (typeof expressionOrNestedCondition === 'object') {
Expand All @@ -38,70 +59,35 @@ export const matchPermissionCondition = <
}

if ($expr) {
return (
isValidConditionExpression($expr) ? matchConditionExpression({
value: model[key],
expression: $expr,
context
}) : false
) && matchPermissionCondition(nestedModel as Record<string, unknown>, condition, context)
}
return matchPermissionCondition(
nestedModel as Record<string, unknown>,
condition,
context
)
return (isValidConditionExpression($expr) ? matchConditionExpression({ value: model[key], expression: $expr }) : false)
&& matchPermissionCondition(nestedModel as Record<string, unknown>, condition)
}
return matchPermissionCondition(nestedModel as Record<string, unknown>, condition)
}
else {
throw new TypeError(`Unexpected expression value type: ${typeof expressionOrNestedCondition}`)
}
})
}

/**
* Checks if the given path is a string and starts with either '$context.' or 'context.'.
*
* @param {unknown} path - The path to check.
* @return {boolean} - Returns true if the path is a string and starts with either '$context.' or 'context.', otherwise returns false.
*/
export const isContextualOperand = (path: unknown): path is string => typeof path === 'string' && (path.startsWith('$context.') || path.startsWith('context.'))
/**
* Retrieves the value at the specified path from the given context object.
*
* @template T - The type of the context object.
* @template U - The type of the value to retrieve.
* @param {T} context - The context object to search in.
* @param {string} path - The dot-separated path to the value.
* @return {U | undefined} The value at the specified path, or undefined if not found.
*/
export const getContextValue = <T extends Record<string, unknown>, U>(context: T, path: string): U | undefined => {
return (path.replace(path.startsWith('$') ? '$context.' : 'context.', ''))
.replaceAll('?.', '.')
.split('.')
// eslint-disable-next-line unicorn/no-array-reduce
.reduce((o, k) => (o || {})[k], (context ?? {}) as Record<string, any>) as U | undefined
}

/**
* Evaluates a condition expression against a given value and context.
*
* @param {Object} data - The data object containing the value, expression, and optional context.
* @param {unknown} data.value - The value to evaluate the condition against.
* @param {NonNullable<GuantrAnyPermission['condition']>[keyof NonNullable<GuantrAnyPermission['condition']>]} data.expression - The condition expression to evaluate.
* @param {Record<string, unknown>} [data.context] - The optional context object to use for evaluating the condition.
* @return {boolean} The result of evaluating the condition expression against the value and context.
* @throws {TypeError} If the model value type is unexpected or the operand type is invalid.
*/
export const matchConditionExpression = (data: {
value: unknown
expression: Extract<NonNullable<GuantrAnyPermission['condition']>[keyof NonNullable<GuantrAnyPermission['condition']>], Array<any>>
context?: Record<string, unknown>
}): boolean => {
const { value, expression, context, } = data

const [operator, maybeContextualOperand, options] = expression ?? []
let operand = maybeContextualOperand
if (isContextualOperand(operand)) operand = getContextValue(context ?? {}, operand)
const {
value,
expression,
} = data
const [operator, operand, options] = expression ?? []

switch (operator) {
case 'equals': {
Expand Down Expand Up @@ -408,14 +394,12 @@ export const matchConditionExpression = (data: {
return matchConditionExpression({
value: i[key],
expression: expressionOrNestedCondition as any,
context,
})
}
else if (typeof expressionOrNestedCondition === 'object') {
return matchPermissionCondition(
i[key] as Record<string, any>,
expressionOrNestedCondition,
context
)
}
else {
Expand Down Expand Up @@ -461,14 +445,12 @@ export const matchConditionExpression = (data: {
return matchConditionExpression({
value: i[key],
expression: expressionOrNestedCondition as any,
context,
})
}
else if (typeof expressionOrNestedCondition === 'object') {
return matchPermissionCondition(
i[key] as Record<string, any>,
expressionOrNestedCondition,
context
)
}
else {
Expand Down
21 changes: 4 additions & 17 deletions tests/operators/contains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,6 @@ import { describe, expect, it } from "vitest"
import { matchConditionExpression } from "../../src/utils"

describe('matchConditionExpression - contains operator', () => {
const context = {
greeting: 'world',
keyword: 'test',
phrase: 'insensitive',
mixedCaseWord: 'CaseSensitive'
}

const testCases = [
// Valid cases where the value contains the operand
{ value: 'hello world', operand: 'world', expected: true },
Expand All @@ -24,12 +17,6 @@ describe('matchConditionExpression - contains operator', () => {
{ value: 'Case Insensitive Test', operand: 'case', options: { caseInsensitive: true }, expected: true },
{ value: 'JavaScript is Fun', operand: 'javascript', options: { caseInsensitive: true }, expected: true },

// Context usage
{ value: 'hello world', operand: '$context.greeting', expected: true },
{ value: 'unit test coverage', operand: '$context.keyword', expected: true },
{ value: 'case insensitive check', operand: '$context.phrase', options: { caseInsensitive: true }, expected: true },
{ value: 'Checking for CaseSensitive word', operand: '$context.mixedCaseWord', options: { caseInsensitive: true }, expected: true },

// Null and undefined values
{ value: null, operand: 'null', expected: false },
{ value: undefined, operand: 'undefined', expected: false },
Expand All @@ -47,7 +34,7 @@ describe('matchConditionExpression - contains operator', () => {
for (const [idx, { value, operand, options, expected }] of testCases.entries()) {
it(`should return ${expected} for case #${idx + 1}`, () => {
const expression = ['contains', operand, options] as any
const result = matchConditionExpression({ value, expression, context })
const result = matchConditionExpression({ value, expression })
expect(result).toBe(expected)
})
}
Expand All @@ -57,14 +44,14 @@ describe('matchConditionExpression - contains operator', () => {
const value = 123 // Invalid type for 'contains' operator
const operand = 'test'
const expression = ['contains', operand] as any
expect(() => matchConditionExpression({ value, expression, context })).toThrow(TypeError)
expect(() => matchConditionExpression({ value, expression })).toThrow(TypeError)
})

// Edge case: invalid operand type
it('should throw TypeError for invalid operand type', () => {
const value = 'test'
const operand = 123 // Operand must be a string
const expression = ['contains', operand] as any
expect(() => matchConditionExpression({ value, expression, context })).toThrow(TypeError)
expect(() => matchConditionExpression({ value, expression })).toThrow(TypeError)
})
})
})
17 changes: 3 additions & 14 deletions tests/operators/endswith.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ import { describe, expect, it } from "vitest"
import { matchConditionExpression } from "../../src/utils"

describe('matchConditionExpression - endsWith operator', () => {
const context = {
suffix1: 'world!',
suffix2: 'WORLD!',
suffix3: 'test',
}

const testCases = [
// Valid cases where the value ends with the operand
{ value: 'Hello, world!', operand: 'world!', expected: true },
Expand All @@ -21,11 +15,6 @@ describe('matchConditionExpression - endsWith operator', () => {
{ value: 'Hello, world!', operand: 'WORLD!', expected: true, options: { caseInsensitive: true } },
{ value: 'Testing endsWith Operator', operand: 'operator', expected: true, options: { caseInsensitive: true } },

// Context usage
{ value: 'Hello, world!', operand: '$context.suffix1', expected: true },
{ value: 'Hello, world!', operand: '$context.suffix2', expected: true, options: { caseInsensitive: true } },
{ value: 'Hello, test', operand: '$context.suffix3', expected: true },

// Null and undefined values
{ value: null, operand: 'suffix', expected: false },
{ value: undefined, operand: 'suffix', expected: false },
Expand All @@ -38,7 +27,7 @@ describe('matchConditionExpression - endsWith operator', () => {
for (const [idx, { value, operand, expected, options }] of testCases.entries()) {
it(`should return ${expected} for case #${idx + 1}`, () => {
const expression = ['endsWith', operand, options] as any
const result = matchConditionExpression({ value, expression, context })
const result = matchConditionExpression({ value, expression })
expect(result).toBe(expected)
})
}
Expand All @@ -48,14 +37,14 @@ describe('matchConditionExpression - endsWith operator', () => {
const value = { key: 'value' } // Invalid type for 'endsWith' operator
const operand = 'value'
const expression = ['endsWith', operand] as any
expect(() => matchConditionExpression({ value, expression, context })).toThrow(TypeError)
expect(() => matchConditionExpression({ value, expression })).toThrow(TypeError)
})

// Edge case: invalid operand type
it('should throw TypeError for unexpected operand type', () => {
const value = 'string value'
const operand = 123 // Invalid type for 'endsWith' operand
const expression = ['endsWith', operand] as any
expect(() => matchConditionExpression({ value, expression, context })).toThrow(TypeError)
expect(() => matchConditionExpression({ value, expression })).toThrow(TypeError)
})
})
Loading

0 comments on commit c62597e

Please sign in to comment.