Skip to content
28 changes: 28 additions & 0 deletions packages/jmespath/src/Expression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { TreeInterpreter } from './TreeInterpreter.js';
import type { JSONObject, Node } from './types.js';

/**
* Apply a JMESPath expression to a JSON value.
*/
class Expression {
readonly #expression: Node;
readonly #interpreter: TreeInterpreter;

public constructor(expression: Node, interpreter: TreeInterpreter) {
this.#expression = expression;
this.#interpreter = interpreter;
}

/**
* Evaluate the expression against a JSON value.
*
* @param value The JSON value to apply the expression to.
* @param node The node to visit.
* @returns The result of applying the expression to the value.
*/
public visit(value: JSONObject, node?: Node): JSONObject {
return this.#interpreter.visit(node ?? this.#expression, value);
}
}

export { Expression };
10 changes: 10 additions & 0 deletions packages/jmespath/src/TreeInterpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Node, JSONObject } from './types.js';

// This is a placeholder for the real class. The actual implementation will be added in a subsequent PR.
export class TreeInterpreter {
public iAmAPlaceholder = true;

public visit(_node: Node, _value: JSONObject): JSONObject | null {
return this.iAmAPlaceholder;
}
}
334 changes: 334 additions & 0 deletions packages/jmespath/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import {
getType,
isIntegerNumber,
isRecord,
isTruthy as isTruthyJS,
isNumber,
} from '@aws-lambda-powertools/commons/typeutils';
import { Expression } from './Expression.js';
import { ArityError, JMESPathTypeError, VariadicArityError } from './errors.js';

/**
* Check if a value is truthy.
*
* In JavaScript, zero is falsy while all other non-zero numbers are truthy.
* In JMESPath however, zero is truthy as well as all other non-zero numbers. For
* this reason we wrap the original isTruthy function from the commons package
* and add a check for numbers.
*
* @param value The value to check
*/
const isTruthy = (value: unknown): boolean => {
if (isNumber(value)) {
return true;
} else {
return isTruthyJS(value);
}
};

/**
* @internal
* Cap a slice range value to the length of an array, taking into account
* negative values and whether the step is negative.
*
* @param arrayLength The length of the array
* @param value The value to cap
* @param isStepNegative Whether the step is negative
*/
const capSliceRange = (
arrayLength: number,
value: number,
isStepNegative: boolean
): number => {
if (value < 0) {
value += arrayLength;
if (value < 0) {
value = isStepNegative ? -1 : 0;
}
} else if (value >= arrayLength) {
value = isStepNegative ? arrayLength - 1 : arrayLength;
}

return value;
};

/**
* Given a start, stop, and step value, the sub elements in an array are extracted as follows:
* * The first element in the extracted array is the index denoted by start.
* * The last element in the extracted array is the index denoted by end - 1.
* * The step value determines how many indices to skip after each element is selected from the array. An array of 1 (the default step) will not skip any indices. A step value of 2 will skip every other index while extracting elements from an array. A step value of -1 will extract values in reverse order from the array.
*
* Slice expressions adhere to the following rules:
* * If a negative start position is given, it is calculated as the total length of the array plus the given start position.
* * If no start position is given, it is assumed to be 0 if the given step is greater than 0 or the end of the array if the given step is less than 0.
* * If a negative stop position is given, it is calculated as the total length of the array plus the given stop position.
* * If no stop position is given, it is assumed to be the length of the array if the given step is greater than 0 or 0 if the given step is less than 0.
* * If the given step is omitted, it it assumed to be 1.
* * If the given step is 0, an invalid-value error MUST be raised (thrown before calling the function)
* * If the element being sliced is not an array, the result is null (returned before calling the function)
* * If the element being sliced is an array and yields no results, the result MUST be an empty array.
*
* @param array The array to slice
* @param start The start index
* @param end The end index
* @param step The step value
*/
const sliceArray = <T>({
array,
start,
end,
step,
}: {
array: T[];
start?: number;
end?: number;
step: number;
}): T[] | null => {
const isStepNegative = step < 0;
const length = array.length;
const defaultStart = isStepNegative ? length - 1 : 0;
const defaultEnd = isStepNegative ? -1 : length;

start = isIntegerNumber(start)
? capSliceRange(length, start, isStepNegative)
: defaultStart;

end = isIntegerNumber(end)
? capSliceRange(length, end, isStepNegative)
: defaultEnd;

const result: T[] = [];
if (step > 0) {
for (let i = start; i < end; i += step) {
result.push(array[i]);
}
} else {
for (let i = start; i > end; i += step) {
result.push(array[i]);
}
}

return result;
};

/**
* Checks if the number of arguments passed to a function matches the expected arity.
* If the number of arguments does not match the expected arity, an ArityError is thrown.
*
* If the function is variadic, then the number of arguments passed to the function must be
* greater than or equal to the expected arity. If the number of arguments passed to the function
* is less than the expected arity, a `VariadicArityError` is thrown.
*
* @param args The arguments passed to the function
* @param argumentsSpecs The expected types for each argument
* @param decoratedFuncName The name of the function being called
* @param variadic Whether the function is variadic
*/
const arityCheck = (
args: unknown[],
argumentsSpecs: Array<Array<string>>,
variadic?: boolean
): void => {
if (variadic) {
if (args.length < argumentsSpecs.length) {
throw new VariadicArityError({
expectedArity: argumentsSpecs.length,
actualArity: args.length,
});
}
} else if (args.length !== argumentsSpecs.length) {
throw new ArityError({
expectedArity: argumentsSpecs.length,
actualArity: args.length,
});
}
};

/**
* Type checks the arguments passed to a function against the expected types.
*
* Type checking at runtime involves checking the top level type,
* and in the case of arrays, potentially checking the types of
* the elements in the array.
*
* If the list of types includes 'any', then the type check is a
* no-op.
*
* If the list of types includes more than one type, then the
* argument is checked against each type in the list. If the
* argument matches any of the types, then the type check
* passes. If the argument does not match any of the types, then
* a JMESPathTypeError is thrown.
*
* @param args The arguments passed to the function
* @param argumentsSpecs The expected types for each argument
*/
const typeCheck = (
args: unknown[],
argumentsSpecs: Array<Array<string>>
): void => {
for (const [index, argumentSpec] of argumentsSpecs.entries()) {
if (argumentSpec[0] === 'any') continue;
typeCheckArgument(args[index], argumentSpec);
}
};

/**
* Type checks an argument against a list of types.
*
* If the list of types includes more than one type, then the
* argument is checked against each type in the list. If the
* argument matches any of the types, then the type check
* passes. If the argument does not match any of the types, then
* a JMESPathTypeError is thrown.
*
* @param arg
* @param argumentSpec
*/
const typeCheckArgument = (arg: unknown, argumentSpec: Array<string>): void => {
let valid = false;
argumentSpec.forEach((type, index) => {
if (valid) return;
valid = checkIfArgumentTypeIsValid(arg, type, index, argumentSpec);
});
};

/**
* Check if the argument is of the expected type.
*
* @param arg The argument to check
* @param type The expected type
* @param index The index of the type we are checking
* @param argumentSpec The list of types to check against
*/
const checkIfArgumentTypeIsValid = (
arg: unknown,
type: string,
index: number,
argumentSpec: string[]
): boolean => {
const hasMoreTypesToCheck = index < argumentSpec.length - 1;
if (type.startsWith('array')) {
if (!Array.isArray(arg)) {
if (hasMoreTypesToCheck) {
return false;
}
throw new JMESPathTypeError({
currentValue: arg,
expectedTypes: argumentSpec,
actualType: getType(arg),
});
}
checkComplexArrayType(arg, type, hasMoreTypesToCheck);

return true;
}
if (type === 'expression') {
checkExpressionType(arg, argumentSpec, hasMoreTypesToCheck);

return true;
} else if (['string', 'number', 'boolean'].includes(type)) {
typeCheckType(arg, type, argumentSpec, hasMoreTypesToCheck);
if (typeof arg === type) return true;
} else if (type === 'object') {
checkObjectType(arg, argumentSpec, hasMoreTypesToCheck);

return true;
}

return false;
};

/**
* Check if the argument is of the expected type.
*
* @param arg The argument to check
* @param type The type to check against
* @param argumentSpec The list of types to check against
* @param hasMoreTypesToCheck Whether there are more types to check
*/
const typeCheckType = (
arg: unknown,
type: string,
argumentSpec: string[],
hasMoreTypesToCheck: boolean
): void => {
if (typeof arg !== type && !hasMoreTypesToCheck) {
throw new JMESPathTypeError({
currentValue: arg,
expectedTypes: argumentSpec,
actualType: getType(arg),
});
}
};

/**
* Check if the argument is an array of complex types.
*
* @param arg The argument to check
* @param type The type to check against
* @param hasMoreTypesToCheck Whether there are more types to check
*/
const checkComplexArrayType = (
arg: unknown[],
type: string,
hasMoreTypesToCheck: boolean
): void => {
if (!type.includes('-')) return;
const arrayItemsType = type.slice(6);
let actualType: string | undefined;
for (const element of arg) {
try {
typeCheckArgument(element, [arrayItemsType]);
actualType = arrayItemsType;
} catch (error) {
if (!hasMoreTypesToCheck || actualType !== undefined) {
throw error;
}
}
}
};

/**
* Check if the argument is an expression.
*
* @param arg The argument to check
* @param type The type to check against
* @param hasMoreTypesToCheck Whether there are more types to check
*/
const checkExpressionType = (
arg: unknown,
type: string[],
hasMoreTypesToCheck: boolean
): void => {
if (!(arg instanceof Expression) && !hasMoreTypesToCheck) {
throw new JMESPathTypeError({
currentValue: arg,
expectedTypes: type,
actualType: getType(arg),
});
}
};

/**
* Check if the argument is an object.
*
* @param arg The argument to check
* @param type The type to check against
* @param hasMoreTypesToCheck Whether there are more types to check
*/
const checkObjectType = (
arg: unknown,
type: string[],
hasMoreTypesToCheck: boolean
): void => {
if (!isRecord(arg) && !hasMoreTypesToCheck) {
throw new JMESPathTypeError({
currentValue: arg,
expectedTypes: type,
actualType: getType(arg),
});
}
};

export { isTruthy, arityCheck, sliceArray, typeCheck, typeCheckArgument };