diff --git a/packages/jmespath/src/Functions.ts b/packages/jmespath/src/Functions.ts new file mode 100644 index 0000000000..5a657b2e9a --- /dev/null +++ b/packages/jmespath/src/Functions.ts @@ -0,0 +1,4 @@ +// This is a placeholder for the real class. The actual implementation will be added in a subsequent PR. +export class Functions { + public iAmAPlaceholder = true; +} diff --git a/packages/jmespath/src/constants.ts b/packages/jmespath/src/constants.ts new file mode 100644 index 0000000000..642e3eb3f9 --- /dev/null +++ b/packages/jmespath/src/constants.ts @@ -0,0 +1,97 @@ +/** + * The binding powers for the various tokens in the JMESPath grammar. + * + * The binding powers are used to determine the order of operations for + * the parser. The higher the binding power, the more tightly the token + * binds to its arguments. + */ +const BINDING_POWER = { + eof: 0, + unquoted_identifier: 0, + quoted_identifier: 0, + literal: 0, + rbracket: 0, + rparen: 0, + comma: 0, + rbrace: 0, + number: 0, + current: 0, + expref: 0, + colon: 0, + pipe: 1, + or: 2, + and: 3, + eq: 5, + gt: 5, + lt: 5, + gte: 5, + lte: 5, + ne: 5, + flatten: 9, + // Everything above stops a projection. + star: 20, + filter: 21, + dot: 40, + not: 45, + lbrace: 50, + lbracket: 55, + lparen: 60, +} as const; + +/** + * The set of ASCII lowercase letters allowed in JMESPath identifiers. + */ +const ASCII_LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'; +/** + * The set of ASCII uppercase letters allowed in JMESPath identifiers. + */ +const ASCII_UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +/** + * The set of ASCII letters allowed in JMESPath identifiers. + */ +const ASCII_LETTERS = ASCII_LOWERCASE + ASCII_UPPERCASE; +/** + * The set of ASCII digits allowed in JMESPath identifiers. + */ +const DIGITS = '0123456789'; +/** + * The set of ASCII letters and digits allowed in JMESPath identifiers. + */ +const START_IDENTIFIER = new Set(ASCII_LETTERS + '_'); +/** + * The set of ASCII letters and digits allowed in JMESPath identifiers. + */ +const VALID_IDENTIFIER = new Set(ASCII_LETTERS + DIGITS + '_'); +/** + * The set of ASCII digits allowed in JMESPath identifiers. + */ +const VALID_NUMBER = new Set(DIGITS); +/** + * The set of ASCII whitespace characters allowed in JMESPath identifiers. + */ +const WHITESPACE = new Set(' \t\n\r'); +/** + * The set of simple tokens in the JMESPath grammar. + */ +const SIMPLE_TOKENS: Map = new Map([ + ['.', 'dot'], + ['*', 'star'], + [':', 'colon'], + [']', 'rbracket'], + [',', 'comma'], + [':', 'colon'], + ['@', 'current'], + ['(', 'lparen'], + [')', 'rparen'], + ['{', 'lbrace'], + ['}', 'rbrace'], +]); + +export { + BINDING_POWER, + SIMPLE_TOKENS, + START_IDENTIFIER, + VALID_IDENTIFIER, + VALID_NUMBER, + WHITESPACE, +}; diff --git a/packages/jmespath/src/errors.ts b/packages/jmespath/src/errors.ts new file mode 100644 index 0000000000..c0d7112539 --- /dev/null +++ b/packages/jmespath/src/errors.ts @@ -0,0 +1,309 @@ +import type { Token } from './types.js'; + +/** + * Base class for errors thrown during expression parsing and evaluation. + */ +class JMESPathError extends Error { + /** + * Expression that was being parsed when the error occurred. + * Can be set by whatever catches the error. + */ + public expression?: string; + + public constructor(message: string) { + super(message); + this.name = 'JMESPathError'; + this.message = message; + } + + /** + * Set the expression that was being parsed when the error occurred. + * + * The separate method allows the expression to be set after the error is + * thrown. In some instances the expression is not known until after the + * error is thrown (i.e. the error is thrown down the call stack). + * + * @param expression The expression that was being parsed when the error occurred. + */ + public setExpression(expression: string): void { + this.expression = expression; + + // Set the message to include the expression. + this.message = `${this.message} in expression: ${this.expression}`; + } +} + +/** + * Error thrown when an unknown token is encountered during the AST construction. + */ +class LexerError extends JMESPathError { + /** + * Position in the expression where the error occurred. + */ + public lexerPosition: number; + /** + * Token value where the error occurred. + */ + public lexerValue: string; + + public constructor(lexerPosition: number, lexerValue: string) { + super('Bad jmespath expression'); + this.name = 'LexerError'; + this.lexerPosition = lexerPosition; + this.lexerValue = lexerValue; + + // Set the message to include the lexer position and value. + this.message = `${this.message}: unknown token "${this.lexerValue}" at column ${this.lexerPosition}`; + } +} + +/** + * Error thrown when an invalid or unexpected token type or value is encountered during parsing. + */ +class ParseError extends JMESPathError { + /** + * Position in the expression where the error occurred. + */ + public lexPosition: number; + /** + * Additional information about the error. + */ + public reason?: string; + /** + * Token type where the error occurred. + */ + public tokenType: Token['type']; + /** + * Token value where the error occurred. + */ + public tokenValue: Token['value']; + + public constructor(options: { + lexPosition: number; + tokenValue: Token['value']; + tokenType: Token['type']; + reason?: string; + }) { + super('Invalid jmespath expression'); + this.name = 'ParseError'; + this.lexPosition = options.lexPosition; + this.tokenValue = options.tokenValue; + this.tokenType = options.tokenType; + this.reason = options.reason; + + // Set the message to include the lexer position and token info. + let issue: string; + if (this.reason) { + issue = this.reason; + } else if (this.tokenType === 'eof') { + issue = 'found unexpected end of expression (EOF)'; + } else { + issue = `found unexpected token "${this.tokenValue}" (${this.tokenType})`; + } + this.message = `${this.message}: parse error at column ${this.lexPosition}, ${issue}`; + } +} + +/** + * Error thrown when an incomplete expression is encountered during parsing. + */ +class IncompleteExpressionError extends ParseError { + /** + * Expression that was being parsed when the error occurred. + * + * Can be set by whatever catches the error. + */ + public expression?: string; + + public constructor(options: { + lexPosition: number; + tokenValue: Token['value']; + tokenType: Token['type']; + reason?: string; + }) { + super(options); + this.name = 'IncompleteExpressionError'; + } +} + +/** + * Error thrown when an empty expression is encountered during parsing. + */ +class EmptyExpressionError extends JMESPathError { + public constructor() { + super('Invalid JMESPath expression: cannot be empty.'); + this.name = 'EmptyExpressionError'; + } +} + +/** + * Base class for errors thrown during function execution. + * + * When writing a JMESPath expression, you can use functions to transform the + * data. For example, the `abs()` function returns the absolute value of a number. + * + * If an error occurs during function execution, the error is thrown as a + * subclass of `FunctionError`. The subclass is determined by the type of error + * that occurred. + * + * Errors can be thrown while validating the arguments passed to a function, or + * while executing the function itself. + */ +class FunctionError extends JMESPathError { + /** + * Function that was being executed when the error occurred. + * Can be set by whatever catches the error. + */ + public functionName?: string; + + public constructor(message: string) { + super(message); + this.name = 'FunctionError'; + } + + /** + * Set the function that was being validated or executed when the error occurred. + * + * The separate method allows the name to be set after the error is + * thrown. In most cases the error is thrown down the call stack, but we want + * to show the actual function name used in the expression rather than an internal + * alias. To avoid passing the function name down the call stack, we set it + * after the error is thrown. + * + * @param functionName The function that was being validated or executed when the error occurred. + */ + public setEvaluatedFunctionName(functionName: string): void { + this.message = this.message.replace( + 'for function undefined', + `for function ${functionName}()` + ); + } +} + +/** + * Error thrown when an unexpected argument is passed to a function. + * + * Function arguments are validated before the function is executed. If an + * invalid argument is passed, the error is thrown. For example, the `abs()` + * function expects exactly one argument. If more than one argument is passed, + * an `ArityError` is thrown. + */ +class ArityError extends FunctionError { + public actualArity: number; + public expectedArity: number; + + public constructor(options: { expectedArity: number; actualArity: number }) { + super('Invalid arity for JMESPath function'); + this.name = 'ArityError'; + this.actualArity = options.actualArity; + this.expectedArity = options.expectedArity; + + const arityParticle = + this.actualArity > this.expectedArity ? 'at most' : 'at least'; + + // Set the message to include the error info. + this.message = `Expected ${arityParticle} ${ + this.expectedArity + } ${this.pluralize('argument', this.expectedArity)} for function ${ + this.functionName + }, received ${this.actualArity}`; + } + + protected pluralize(word: string, count: number): string { + return count === 1 ? word : `${word}s`; + } +} + +/** + * Error thrown when an unexpected number of arguments is passed to a variadic function. + * + * Variadic functions are functions that accept a variable number of arguments. + * For example, the `max()` function accepts any number of arguments and returns + * the largest one. If no arguments are passed, it returns `null`. + * + * If the number of arguments passed to a variadic function is not within the + * expected range, this error is thrown. + */ +class VariadicArityError extends ArityError { + public constructor(options: { expectedArity: number; actualArity: number }) { + super(options); + this.name = 'VariadicArityError'; + + // Set the message to include the error info. + this.message = `Expected ${this.expectedArity} ${this.pluralize( + 'argument', + this.expectedArity + )} for function ${this.functionName}, received ${this.actualArity}`; + } +} + +/** + * Error thrown when an invalid argument type is passed to a built-in function. + * + * Function arguments are validated before the function is executed. If an + * invalid argument type is found, this error is thrown. For example, the + * `abs()` function expects a number as its argument. If a string is passed + * instead, this error is thrown. + */ +class JMESPathTypeError extends FunctionError { + public actualType: string; + public currentValue: unknown; + public expectedTypes: string[]; + + public constructor(options: { + currentValue: unknown; + actualType: string; + expectedTypes: string[]; + }) { + super('Invalid type for JMESPath expression'); + this.name = 'JMESPathTypeError'; + this.currentValue = options.currentValue; + this.actualType = options.actualType; + this.expectedTypes = options.expectedTypes; + + // Set the message to include the error info. + this.message = `Invalid argument type for function ${ + this.functionName + }, expected ${this.serializeExpectedTypes()} but found "${ + this.actualType + }"`; + } + + protected serializeExpectedTypes(): string { + const types: string[] = []; + for (const type of this.expectedTypes) { + types.push(`"${type}"`); + } + + return types.length === 1 ? types[0] : `one of ${types.join(', ')}`; + } +} + +/** + * Error thrown when an unknown function is used in an expression. + * + * When evaluating a JMESPath expression, the interpreter looks up the function + * name in a table of built-in functions, as well as any custom functions + * provided by the user. If the function name is not found, this error is thrown. + */ +class UnknownFunctionError extends FunctionError { + public constructor(funcName: string) { + super('Unknown function'); + this.name = 'UnknownFunctionError'; + + // Set the message to include the error info. + this.message = `Unknown function: ${funcName}()`; + } +} + +export { + ArityError, + EmptyExpressionError, + IncompleteExpressionError, + JMESPathError, + JMESPathTypeError, + LexerError, + ParseError, + UnknownFunctionError, + VariadicArityError, +}; diff --git a/packages/jmespath/src/types.ts b/packages/jmespath/src/types.ts new file mode 100644 index 0000000000..07eef2fb33 --- /dev/null +++ b/packages/jmespath/src/types.ts @@ -0,0 +1,110 @@ +import type { + JSONValue, + JSONArray, +} from '@aws-lambda-powertools/commons/types'; +import type { Functions } from './Functions.js'; +import { BINDING_POWER } from './constants.js'; + +/** + * A token in the JMESPath AST. + */ +type Token = { + type: keyof typeof BINDING_POWER; + value: JSONValue; + start: number; + end: number; +}; + +/** + * A node in the JMESPath AST. + */ +type Node = { + type: string; + children: Node[]; + value?: JSONValue; +}; + +/** + * Options for the tree interpreter. + */ +type TreeInterpreterOptions = { + /** + * The custom functions to use. + * + * By default, the interpreter uses the standard JMESPath functions + * available in the [JMESPath specification](https://jmespath.org/specification.html). + */ + customFunctions?: Functions; +}; + +/** + * Options for parsing. + * + * You can use this type to customize the parsing of JMESPath expressions. + * + * For example, you can use this type to provide custom functions to the parser. + * + * @example + * ```typescript + * import { search } from '@aws-lambda-powertools/jmespath'; + * import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; + * + * const expression = 'powertools_json(@)'; + * + * const result = search(expression, "{\n \"a\": 1\n}", { + * customFunctions: new PowertoolsFunctions(), + * }); + * console.log(result); // { a: 1 } + * ``` + */ +type ParsingOptions = TreeInterpreterOptions; + +/** + * Decorator for function signatures. + */ +type FunctionSignatureDecorator = ( + target: Functions | typeof Functions, + propertyKey: string | symbol, + descriptor: PropertyDescriptor +) => void; + +/** + * Options for a function signature. + * + * @example + * ```typescript + * import { PowertoolsFunctions } from '@aws-lambda-powertools/jmespath/functions'; + * + * class MyFunctions extends Functions { + * ⁣@Functions.signature({ + * argumentsSpecs: [['number'], ['number']], + * variadic: true, + * }) + * public funcMyMethod(args: Array): unknown { + * // ... + * } + * } + * ``` + * + * @param argumentsSpecs The expected arguments for the function. + * @param variadic Whether the function is variadic. + */ +type FunctionSignatureOptions = { + argumentsSpecs: Array>; + variadic?: boolean; +}; + +/** + * A JSON parseable object. + */ +type JSONObject = JSONArray | JSONValue | object; + +export type { + FunctionSignatureDecorator, + FunctionSignatureOptions, + Node, + ParsingOptions, + Token, + TreeInterpreterOptions, + JSONObject, +}; diff --git a/packages/jmespath/typedoc.json b/packages/jmespath/typedoc.json index 737729805a..aa21610cf8 100644 --- a/packages/jmespath/typedoc.json +++ b/packages/jmespath/typedoc.json @@ -3,10 +3,8 @@ "../../typedoc.base.json" ], "entryPoints": [ - "./src/index.ts", "./src/types.ts", - "./src/envelopes.ts", - "./src/PowertoolsFunctions.ts", + "./src/errors.ts" ], "readme": "README.md" } \ No newline at end of file