diff --git a/README.md b/README.md index a69ad12ec5..f5d4d566b5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Powertools for AWS Lambda (TypeScript) -![NodeSupport](https://img.shields.io/static/v1?label=node&message=%2016|%2018|%2020&color=green?style=flat-square&logo=node) +![NodeSupport](https://img.shields.io/static/v1?label=node&message=%2018|%2020|%2022&color=green?style=flat-square&logo=node) ![GitHub Release](https://img.shields.io/github/v/release/aws-powertools/powertools-lambda-typescript?style=flat-square) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aws-powertools_powertools-lambda-typescript&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=aws-powertools_powertools-lambda-typescript) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=aws-powertools_powertools-lambda-typescript&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=aws-powertools_powertools-lambda-typescript) @@ -28,6 +28,7 @@ Find the complete project's [documentation here](https://docs.powertools.aws.dev - **[Batch Processing](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/batch/)** - Utility to handle partial failures when processing batches from Amazon SQS, Amazon Kinesis Data Streams, and Amazon DynamoDB Streams. - **[JMESPath Functions](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/jmespath/)** - Built-in JMESPath functions to easily deserialize common encoded JSON payloads in Lambda functions. - **[Parser (Zod)](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/parser/)** - Utility that provides data validation and parsing using Zod, a TypeScript-first schema declaration and validation library. +- **[Validation](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/validation/)** - JSON Schema validation for events and responses, including JMESPath support to unwrap events before validation. ## Install @@ -41,6 +42,7 @@ You can use Powertools for AWS Lambda (TypeScript) by installing it with your fa - **Batch**: `npm install @aws-lambda-powertools/batch` - **JMESPath Functions**: `npm install @aws-lambda-powertools/jmespath` - **Parser**: `npm install @aws-lambda-powertools/parser zod@~3` +- **Validation**: `npm install @aws-lambda-powertools/validation` ### Examples diff --git a/packages/validation/README.md b/packages/validation/README.md index b843d1823b..77a2fdfc68 100644 --- a/packages/validation/README.md +++ b/packages/validation/README.md @@ -2,9 +2,6 @@ This utility provides JSON Schema validation for events and responses, including JMESPath support to unwrap events before validation. -> [!Warning] -> This feature is currently under development. As such it's considered not stable and we might make significant breaking changes before going before its release. You are welcome to [provide feedback](https://github.com/aws-powertools/powertools-lambda-typescript/discussions/3519) and [contribute to its implementation](https://github.com/aws-powertools/powertools-lambda-typescript/milestone/18). - Powertools for AWS Lambda (TypeScript) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.powertools.aws.dev/lambda/typescript/latest/#features). You can use the library in both TypeScript and JavaScript code bases. To get started, install the package by running: @@ -13,8 +10,222 @@ To get started, install the package by running: npm i @aws-lambda-powertools/validation ``` -> [!Note] -> This readme is a work in progress. +## Features + +You can validate inbound and outbound payloads using the `@validator` class method decorator or `validator` Middy.js middleware. + +You can also use the standalone `validate` function, if you want more control over the validation process such as handling a validation error. + +### Validator decorator + +The `@validator` decorator is a TypeScript class method decorator that you can use to validate both the incoming event and the response payload. + +If the validation fails, we will throw a `SchemaValidationError`. + +```typescript +import { validator } from '@aws-lambda-powertools/validation/decorator'; +import type { Context } from 'aws-lambda'; + +const inboundSchema = { + type: 'object', + properties: { + value: { type: 'number' }, + }, + required: ['value'], + additionalProperties: false, +}; + +const outboundSchema = { + type: 'object', + properties: { + result: { type: 'number' }, + }, + required: ['result'], + additionalProperties: false, +}; + +class Lambda { + @validator({ + inboundSchema, + outboundSchema, + }) + async handler(event: { value: number }, _context: Context) { + // Your handler logic here + return { result: event.value * 2 }; + } +} + +const lambda = new Lambda(); +export const handler = lambda.handler.bind(lambda); +``` + +It's not mandatory to validate both the inbound and outbound payloads. You can either use one, the other, or both. + +### Validator middleware + +If you are using Middy.js, you can instead use the `validator` middleware to validate the incoming event and response payload. + +```typescript +import { validator } from '@aws-lambda-powertools/validation/middleware'; +import middy from '@middy/core'; + +const inboundSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + }, + required: ['foo'], + additionalProperties: false, +}; + +const outboundSchema = { + type: 'object', + properties: { + bar: { type: 'number' }, + }, + required: ['bar'], + additionalProperties: false, +}; + +export const handler = middy() + .use(validation({ inboundSchema, outboundSchema })) + .handler(async (event) => { + // Your handler logic here + return { bar: 42 }; + }); +``` + +Like the `@validator` decorator, you can choose to validate only the inbound or outbound payload. + +### Standalone validate function + +The `validate` function gives you more control over the validation process, and is typically used within the Lambda handler, or any other function that needs to validate data. + +When using the standalone function, you can gracefully handle schema validation errors by catching `SchemaValidationError` errors. + +```typescript +import { validate } from '@aws-lambda-powertools/validation'; +import { SchemaValidationError } from '@aws-lambda-powertools/validation/errors'; + +const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + additionalProperties: false, +} as const; + +const payload = { name: 'John', age: 30 }; + +export const handler = async (event: unknown) => { + try { + const validatedData = validate({ + payload, + schema, + }); + + // Your handler logic here + } catch (error) { + if (error instanceof SchemaValidationError) { + // Handle the validation error + return { + statusCode: 400, + body: JSON.stringify({ message: error.message }), + }; + } + // Handle other errors + throw error; + } +} +``` + +### JMESPath support + +In some cases you might want to validate only a portion of the event payload - this is what the `envelope` option is for. + +You can use JMESPath expressions to specify the path to the property you want to validate. The validator will unwrap the event before validating it. + +```typescript +import { validate } from '@aws-lambda-powertools/validation'; + +const schema = { + type: 'object', + properties: { + user: { type: 'string' }, + }, + required: ['user'], + additionalProperties: false, +} as const; + +const payload = { + data: { + user: 'Alice', + }, +}; + +const validatedData = validate({ + payload, + schema, + envelope: 'data', +}); +``` + +### Extending the validator + +Since the validator is built on top of [Ajv](https://ajv.js.org/), you can extend it with custom formats and external schemas, as well as bringing your own `ajv` instance. + +The example below shows how to pass additional options to the `validate` function, but you can also pass them to the `@validator` decorator and `validator` middleware. + +```typescript +import { validate } from '@aws-lambda-powertools/validation'; + +const formats = { + ageRange: (value: number) => return value >= 0 && value <= 120, +}; + +const definitionSchema = { + $id: 'https://example.com/schemas/definitions.json', + definitions: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number', format: 'ageRange' }, + }, + required: ['name', 'age'], + additionalProperties: false, + } + } +} as const; + +const schema = { + $id: 'https://example.com/schemas/user.json', + type: 'object', + properties: { + user: { $ref: 'definitions.json#/definitions/user' }, + }, + required: ['user'], + additionalProperties: false, +} as const; + +const payload = { + user: { + name: 'Alice', + age: 25, + }, +}; + +const validatedData = validate({ + payload, + schema, + externalRefs: [definitionSchema], + formats, +}); +``` + +For more information on how to use the `validate` function, please refer to the [documentation](https://docs.powertools.aws.dev/lambda/typescript/latest/utilities/validation). ## Contribute diff --git a/packages/validation/package.json b/packages/validation/package.json index d842f95518..2231fe1dbb 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -6,7 +6,6 @@ "name": "Amazon Web Services", "url": "https://aws.amazon.com" }, - "private": true, "scripts": { "test": "vitest --run", "test:unit": "vitest --run", @@ -18,7 +17,7 @@ "test:e2e": "echo \"Not implemented\"", "build:cjs": "tsc --build tsconfig.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", "build:esm": "tsc --build tsconfig.esm.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", - "build": "echo \"Not implemented\"", + "build": "npm run build:esm & npm run build:cjs", "lint": "biome lint .", "lint:fix": "biome check --write .", "prepack": "node ../../.github/scripts/release_patch_package_json.js ." @@ -29,12 +28,42 @@ "exports": { ".": { "require": { - "types": "./lib/cjs/index.d.ts", - "default": "./lib/cjs/index.js" + "types": "./lib/cjs/validate.d.ts", + "default": "./lib/cjs/validate.js" }, "import": { - "types": "./lib/esm/index.d.ts", - "default": "./lib/esm/index.js" + "types": "./lib/esm/validate.d.ts", + "default": "./lib/esm/validate.js" + } + }, + "./middleware": { + "require": { + "types": "./lib/cjs/middleware.d.ts", + "default": "./lib/cjs/middleware.js" + }, + "import": { + "types": "./lib/esm/middleware.d.ts", + "default": "./lib/esm/middleware.js" + } + }, + "./errors": { + "require": { + "types": "./lib/cjs/errors.d.ts", + "default": "./lib/cjs/errors.js" + }, + "import": { + "types": "./lib/esm/errors.d.ts", + "default": "./lib/esm/errors.js" + } + }, + "./decorator": { + "require": { + "types": "./lib/cjs/decorator.d.ts", + "default": "./lib/cjs/decorator.js" + }, + "import": { + "types": "./lib/esm/decorator.d.ts", + "default": "./lib/esm/decorator.js" } } }, diff --git a/packages/validation/src/decorator.ts b/packages/validation/src/decorator.ts index 45a9edfb3d..a9020e3469 100644 --- a/packages/validation/src/decorator.ts +++ b/packages/validation/src/decorator.ts @@ -1,15 +1,148 @@ import { SchemaValidationError } from './errors.js'; import type { ValidatorOptions } from './types.js'; +import { getErrorCause } from './utils.js'; import { validate } from './validate.js'; -export function validator(options: ValidatorOptions) { + +/** + * Class method decorator to validate the input and output of a method using JSON Schema. + * + * @example + * ```typescript + * import { validator } from '@aws-lambda-powertools/validation/decorator'; + * import type { Context } from 'aws-lambda'; + * + * const inboundSchema = { + * type: 'object', + * properties: { + * value: { type: 'number' }, + * }, + * required: ['value'], + * additionalProperties: false, + * }; + * + * const outboundSchema = { + * type: 'object', + * properties: { + * result: { type: 'number' }, + * }, + * required: ['result'], + * additionalProperties: false, + * }; + * + * class Lambda { + * ⁣@validator({ + * inboundSchema, + * outboundSchema, + * }) + * async handler(event: { value: number }, _context: Context) { + * // Your handler logic here + * return { result: event.value * 2 }; + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * When validating nested payloads, you can also provide an optional JMESPath expression to extract a specific part of the payload + * before validation using the `envelope` parameter. This is useful when the payload is nested or when you want to validate only a specific part of it. + * + * @example + * ```typescript + * import { validator } from '@aws-lambda-powertools/validation/decorator'; + * + * class Lambda { + * ⁣@validator({ + * inboundSchema: { + * type: 'number', + * }, + * envelope: 'nested', + * }) + * async handler(event: number, _context: Context) { + * return { result: event * 2 }; + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * Since the Validation utility is built on top of Ajv, you can also provide custom formats and external references + * to the validation process. This allows you to extend the validation capabilities of Ajv to suit your specific needs. + * + * @example + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * + * const formats = { + * ageRange: (value: number) => return value >= 0 && value <= 120, + * }; + * + * const definitionSchema = { + * $id: 'https://example.com/schemas/definitions.json', + * definitions: { + * user: { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number', format: 'ageRange' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * } + * } + * } as const; + * + * const schema = { + * $id: 'https://example.com/schemas/user.json', + * type: 'object', + * properties: { + * user: { $ref: 'definitions.json#/definitions/user' }, + * }, + * required: ['user'], + * additionalProperties: false, + * } as const; + * + * const payload = { + * user: { + * name: 'Alice', + * age: 25, + * }, + * }; + * + * class Lambda { + * ⁣@validator({ + * inboundSchema: schema, + * externalRefs: [definitionSchema], + * formats, + * }) + * async handler(event: { value: number }, _context: Context) { + * // Your handler logic here + * return { result: event.value * 2 }; + * } + * } + * + * const lambda = new Lambda(); + * export const handler = lambda.handler.bind(lambda); + * ``` + * + * Additionally, you can provide an existing Ajv instance to reuse the same instance across multiple validations. If + * you don't provide an Ajv instance, a new one will be created for each validation. + * + * @param options - The validation options + * @param options.inboundSchema - The JSON schema for inbound validation. + * @param options.outboundSchema - The JSON schema for outbound validation. + * @param options.envelope - Optional JMESPath expression to use as envelope for the payload. + * @param options.formats - Optional formats for validation. + * @param options.externalRefs - Optional external references for validation. + * @param options.ajv - Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ +function validator(options: ValidatorOptions) { return ( _target: unknown, _propertyKey: string | symbol, descriptor: PropertyDescriptor ) => { - if (!descriptor.value) { - return descriptor; - } const { inboundSchema, outboundSchema, @@ -18,7 +151,7 @@ export function validator(options: ValidatorOptions) { externalRefs, ajv, } = options; - if (!inboundSchema && !outboundSchema) { + if (!options.inboundSchema && !outboundSchema) { return descriptor; } const originalMethod = descriptor.value; @@ -35,7 +168,9 @@ export function validator(options: ValidatorOptions) { ajv: ajv, }); } catch (error) { - throw new SchemaValidationError('Inbound validation failed', error); + throw new SchemaValidationError('Inbound schema validation failed', { + cause: getErrorCause(error), + }); } } const result = await originalMethod.apply(this, [ @@ -52,7 +187,9 @@ export function validator(options: ValidatorOptions) { ajv: ajv, }); } catch (error) { - throw new SchemaValidationError('Outbound Validation failed', error); + throw new SchemaValidationError('Outbound schema validation failed', { + cause: getErrorCause(error), + }); } } return result; @@ -60,3 +197,5 @@ export function validator(options: ValidatorOptions) { return descriptor; }; } + +export { validator }; diff --git a/packages/validation/src/errors.ts b/packages/validation/src/errors.ts index db41e7f7e5..ce925b5465 100644 --- a/packages/validation/src/errors.ts +++ b/packages/validation/src/errors.ts @@ -1,9 +1,85 @@ -export class SchemaValidationError extends Error { - public errors: unknown; +/** + * Base error class for all validation errors. + * + * This error is usually not thrown directly, but it's used as a base class for + * other errors thrown by the Validation utility. You can use it to catch all + * validation errors in a single catch block. + */ +class ValidationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ValidationError'; + } +} - constructor(message: string, errors?: unknown) { - super(message); +/** + * Error thrown when a schema validation fails. + * + * This error is thrown when the validation of a payload against a schema fails, + * the `cause` property contains the original Ajv issues. + * + * @example + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * import { ValidationError } from '@aws-lambda-powertools/validation/errors'; + * + * const schema = { + * type: 'number', + * minimum: 0, + * maximum: 100, + * }; + * + * const payload = -1; + * + * try { + * validate({ payload, schema }); + * } catch (error) { + * if (error instanceof ValidationError) { + * // cause includes the original Ajv issues + * const { message, cause } = error; + * // ... handle the error + * } + * + * // handle other errors + * } + * ``` + */ +class SchemaValidationError extends ValidationError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'SchemaValidationError'; - this.errors = errors; } } + +/** + * Error thrown when a schema compilation fails. + * + * This error is thrown when you pass an invalid schema to the validator. + * + * @example + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * + * const schema = { + * invalid: 'schema', + * }; + * + * try { + * validate({ payload: {}, schema }); + * } catch (error) { + * if (error instanceof SchemaCompilationError) { + * // handle the error + * } + * + * // handle other errors + * } + * ``` + */ +class SchemaCompilationError extends ValidationError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SchemaCompilationError'; + } +} + +export { ValidationError, SchemaValidationError, SchemaCompilationError }; diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts deleted file mode 100644 index 039a9236fa..0000000000 --- a/packages/validation/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { validate } from './validate.js'; -export { SchemaValidationError } from './errors.js'; -export { validator } from './decorator.js'; diff --git a/packages/validation/src/middleware.ts b/packages/validation/src/middleware.ts index e706d6c846..af6d953033 100644 --- a/packages/validation/src/middleware.ts +++ b/packages/validation/src/middleware.ts @@ -1,39 +1,152 @@ +import type { + MiddlewareFn, + MiddyLikeRequest, +} from '@aws-lambda-powertools/commons/types'; import { SchemaValidationError } from './errors.js'; import type { ValidatorOptions } from './types.js'; +import { getErrorCause } from './utils.js'; import { validate } from './validate.js'; -export function validation(options: ValidatorOptions) { - return { - before: async (handler: { event: unknown }) => { - if (options.inboundSchema) { - try { - handler.event = validate({ - payload: handler.event, - schema: options.inboundSchema, - envelope: options.envelope, - formats: options.formats, - externalRefs: options.externalRefs, - ajv: options.ajv, - }); - } catch (error) { - throw new SchemaValidationError('Inbound validation failed', error); - } +/** + * Middy.js middleware to validate your event and response payloads using JSON schema. + * + * Both inbound and outbound schemas are optional. If only one is provided, only that one will be validated. + * + * @example + * ```typescript + * import { validation } from '@aws-lambda-powertools/validation/middleware'; + * import middy from '@middy/core'; + * + * const inboundSchema = { + * type: 'object', + * properties: { + * foo: { type: 'string' }, + * }, + * required: ['foo'], + * additionalProperties: false, + * }; + * + * const outboundSchema = { + * type: 'object', + * properties: { + * bar: { type: 'number' }, + * }, + * required: ['bar'], + * additionalProperties: false, + * }; + * + * export const handler = middy() + * .use(validation({ inboundSchema, outboundSchema })) + * .handler(async (event) => { + * // Your handler logic here + * return { bar: 42 }; + * }); + * ``` + * + * Since the Validation utility is built on top of Ajv, you can also provide custom formats and external references + * to the validation process. This allows you to extend the validation capabilities of Ajv to suit your specific needs. + * + * @example + * ```typescript + * import { validator } from '@aws-lambda-powertools/validation/middleware'; + * import middy from '@middy/core'; + * + * const formats = { + * ageRange: (value: number) => return value >= 0 && value <= 120, + * }; + * + * const definitionSchema = { + * $id: 'https://example.com/schemas/definitions.json', + * definitions: { + * user: { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number', format: 'ageRange' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * } + * } + * } as const; + * + * const schema = { + * $id: 'https://example.com/schemas/user.json', + * type: 'object', + * properties: { + * user: { $ref: 'definitions.json#/definitions/user' }, + * }, + * required: ['user'], + * additionalProperties: false, + * } as const; + * + * export const handler = middy() + * .use(validation({ + * inboundSchema, + * outboundSchema, + * externalRefs: [definitionSchema], + * formats, + * })) + * .handler(async (event) => { + * // Your handler logic here + * return { bar: 42 }; + * }); + * ``` + * + * Additionally, you can provide an existing Ajv instance to reuse the same instance across multiple validations. If + * you don't provide an Ajv instance, a new one will be created for each validation. + * + * @param options - The validation options + * @param options.inboundSchema - The JSON schema for inbound validation. + * @param options.outboundSchema - The JSON schema for outbound validation. + * @param options.envelope - Optional JMESPath expression to use as envelope for the payload. + * @param options.formats - Optional formats for validation. + * @param options.externalRefs - Optional external references for validation. + * @param options.ajv - Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ +const validator = (options: ValidatorOptions) => { + const before: MiddlewareFn = async (request) => { + if (options.inboundSchema) { + const originalEvent = structuredClone(request.event); + try { + request.event = validate({ + payload: originalEvent, + schema: options.inboundSchema, + envelope: options.envelope, + formats: options.formats, + externalRefs: options.externalRefs, + ajv: options.ajv, + }); + } catch (error) { + throw new SchemaValidationError('Inbound schema validation failed', { + cause: getErrorCause(error), + }); } - }, - after: async (handler: { response: unknown }) => { - if (options.outboundSchema) { - try { - handler.response = validate({ - payload: handler.response, - schema: options.outboundSchema, - formats: options.formats, - externalRefs: options.externalRefs, - ajv: options.ajv, - }); - } catch (error) { - throw new SchemaValidationError('Outbound validation failed', error); - } + } + }; + + const after = async (handler: MiddyLikeRequest) => { + if (options.outboundSchema) { + try { + handler.response = validate({ + payload: handler.response, + schema: options.outboundSchema, + formats: options.formats, + externalRefs: options.externalRefs, + ajv: options.ajv, + }); + } catch (error) { + throw new SchemaValidationError('Outbound schema validation failed', { + cause: getErrorCause(error), + }); } - }, + } }; -} + + return { + before, + after, + }; +}; + +export { validator }; diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts index 4543e6ffe9..c5d63d89cc 100644 --- a/packages/validation/src/types.ts +++ b/packages/validation/src/types.ts @@ -1,36 +1,65 @@ -import type { - Ajv, - AnySchema, - AsyncFormatDefinition, - FormatDefinition, -} from 'ajv'; +import type { Ajv, AnySchema, Format } from 'ajv'; type Prettify = { [K in keyof T]: T[K]; } & {}; +/** + * Options to customize the JSON Schema validation. + * + * @param payload - The data to validate. + * @param schema - The JSON schema for validation. + * @param envelope - Optional JMESPATH expression to use as envelope for the payload. + * @param formats - Optional formats for validation. + * @param externalRefs - Optional external references for validation. + * @param ajv - Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ type ValidateParams = { + /** + * The data to validate. + */ payload: unknown; + /** + * The JSON schema for validation. + */ schema: AnySchema; + /** + * Optional JMESPATH expression to use as envelope for the payload. + */ envelope?: string; - formats?: Record< - string, - | string - | RegExp - | FormatDefinition - | FormatDefinition - | AsyncFormatDefinition - | AsyncFormatDefinition - >; - externalRefs?: object[]; + /** + * Optional formats for validation. + */ + formats?: Record; + /** + * Optional external references for validation. + */ + externalRefs?: AnySchema | AnySchema[]; + /** + * Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ ajv?: Ajv; }; -type ValidatorOptions = Prettify< - Omit & { - inboundSchema?: AnySchema; - outboundSchema?: AnySchema; - } ->; +/** + * Options to customize the JSON Schema validation. + * + * @param inboundSchema - The JSON schema for inbound validation. + * @param outboundSchema - The JSON schema for outbound validation. + * @param envelope - Optional JMESPATH expression to use as envelope for the payload. + * @param formats - Optional formats for validation. + * @param externalRefs - Optional external references for validation. + * @param ajv - Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ +interface ValidatorOptions extends Omit { + /** + * The JSON schema for inbound validation. + */ + inboundSchema?: AnySchema; + /** + * The JSON schema for outbound validation. + */ + outboundSchema?: AnySchema; +} export type { ValidateParams, ValidatorOptions }; diff --git a/packages/validation/src/utils.ts b/packages/validation/src/utils.ts new file mode 100644 index 0000000000..dbace4fd00 --- /dev/null +++ b/packages/validation/src/utils.ts @@ -0,0 +1,16 @@ +/** + * Get the original cause of the error if it is a `SchemaValidationError`. + * + * This is useful so that we don't rethrow the same error type. + * + * @param error - The error to extract the cause from. + */ +const getErrorCause = (error: unknown): unknown => { + let cause = error; + if (error instanceof Error && error.name === 'SchemaValidationError') { + cause = error.cause; + } + return cause; +}; + +export { getErrorCause }; diff --git a/packages/validation/src/validate.ts b/packages/validation/src/validate.ts index 36d0fccb0a..b618a32d39 100644 --- a/packages/validation/src/validate.ts +++ b/packages/validation/src/validate.ts @@ -1,9 +1,124 @@ import { search } from '@aws-lambda-powertools/jmespath'; import { Ajv, type ValidateFunction } from 'ajv'; -import { SchemaValidationError } from './errors.js'; +import { SchemaCompilationError, SchemaValidationError } from './errors.js'; import type { ValidateParams } from './types.js'; -export function validate(params: ValidateParams): T { +/** + * Validates a payload against a JSON schema using Ajv. + * + * @example + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * + * const schema = { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * } as const; + * + * const payload = { name: 'John', age: 30 }; + * + * const validatedData = validate({ + * payload, + * schema, + * }); + * ``` + * + * When validating, you can also provide an optional JMESPath expression to extract a specific part of the payload + * before validation using the `envelope` parameter. This is useful when the payload is nested or when you want to + * validate only a specific part of it. + * + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * + * const schema = { + * type: 'object', + * properties: { + * user: { type: 'string' }, + * }, + * required: ['user'], + * additionalProperties: false, + * } as const; + * + * const payload = { + * data: { + * user: 'Alice', + * }, + * }; + * + * const validatedData = validate({ + * payload, + * schema, + * envelope: 'data', + * }); + * ``` + * + * Since the Validation utility is built on top of Ajv, you can also provide custom formats and external references + * to the validation process. This allows you to extend the validation capabilities of Ajv to suit your specific needs. + * + * @example + * ```typescript + * import { validate } from '@aws-lambda-powertools/validation'; + * + * const formats = { + * ageRange: (value: number) => return value >= 0 && value <= 120, + * }; + * + * const definitionSchema = { + * $id: 'https://example.com/schemas/definitions.json', + * definitions: { + * user: { + * type: 'object', + * properties: { + * name: { type: 'string' }, + * age: { type: 'number', format: 'ageRange' }, + * }, + * required: ['name', 'age'], + * additionalProperties: false, + * } + * } + * } as const; + * + * const schema = { + * $id: 'https://example.com/schemas/user.json', + * type: 'object', + * properties: { + * user: { $ref: 'definitions.json#/definitions/user' }, + * }, + * required: ['user'], + * additionalProperties: false, + * } as const; + * + * const payload = { + * user: { + * name: 'Alice', + * age: 25, + * }, + * }; + * + * const validatedData = validate({ + * payload, + * schema, + * externalRefs: [definitionSchema], + * formats, + * }); + * ``` + * + * Additionally, you can provide an existing Ajv instance to reuse the same instance across multiple validations. If + * you don't provide an Ajv instance, a new one will be created for each validation. + * + * @param params.payload - The payload to validate. + * @param params.schema - The JSON schema to validate against. + * @param params.envelope - Optional JMESPath expression to use as envelope for the payload. + * @param params.formats - Optional formats for validation. + * @param params.externalRefs - Optional external references for validation. + * @param params.ajv - Optional Ajv instance to use for validation, if not provided a new instance will be created. + */ +const validate = (params: ValidateParams): T => { const { payload, schema, envelope, formats, externalRefs, ajv } = params; const ajvInstance = ajv || new Ajv({ allErrors: true }); @@ -14,16 +129,16 @@ export function validate(params: ValidateParams): T { } if (externalRefs) { - for (const refSchema of externalRefs) { - ajvInstance.addSchema(refSchema); - } + ajvInstance.addSchema(externalRefs); } let validateFn: ValidateFunction; try { validateFn = ajvInstance.compile(schema); } catch (error) { - throw new SchemaValidationError('Failed to compile schema', error); + throw new SchemaCompilationError('Failed to compile schema', { + cause: error, + }); } const trimmedEnvelope = envelope?.trim(); @@ -33,11 +148,12 @@ export function validate(params: ValidateParams): T { const valid = validateFn(dataToValidate); if (!valid) { - throw new SchemaValidationError( - 'Schema validation failed', - validateFn.errors - ); + throw new SchemaValidationError('Schema validation failed', { + cause: validateFn.errors, + }); } return dataToValidate as T; -} +}; + +export { validate }; diff --git a/packages/validation/tests/unit/decorator.test.ts b/packages/validation/tests/unit/decorator.test.ts index 94ba2f0c40..b2b6df0562 100644 --- a/packages/validation/tests/unit/decorator.test.ts +++ b/packages/validation/tests/unit/decorator.test.ts @@ -20,7 +20,7 @@ const outboundSchema = { additionalProperties: false, }; -describe('validator decorator', () => { +describe('Decorator: validator', () => { it('should validate inbound and outbound successfully', async () => { // Prepare class TestClass { @@ -31,8 +31,10 @@ describe('validator decorator', () => { } const instance = new TestClass(); const input = { value: 5 }; + // Act const output = await instance.multiply(input); + // Assess expect(output).toEqual({ result: 10 }); }); @@ -49,6 +51,7 @@ describe('validator decorator', () => { const invalidInput = { value: 'not a number' } as unknown as { value: number; }; + // Act & Assess await expect(instance.multiply(invalidInput)).rejects.toThrow( SchemaValidationError @@ -59,14 +62,14 @@ describe('validator decorator', () => { // Prepare class TestClassInvalid { @validator({ inboundSchema, outboundSchema }) - async multiply(input: { value: number }): Promise<{ result: number }> { - return { result: 'invalid' } as unknown as { result: number }; + async multiply(_input: { value: number }) { + return { result: 'invalid' }; } } const instance = new TestClassInvalid(); - const input = { value: 5 }; + // Act & Assess - await expect(instance.multiply(input)).rejects.toThrow( + await expect(instance.multiply({ value: 5 })).rejects.toThrow( SchemaValidationError ); }); @@ -81,23 +84,12 @@ describe('validator decorator', () => { } const instance = new TestClassNoOp(); const data = { foo: 'bar' }; + // Act const result = await instance.echo(data); - // Assess - expect(result).toEqual(data); - }); - it('should return descriptor unmodified if descriptor.value is undefined', () => { - // Prepare - const descriptor: PropertyDescriptor = {}; - // Act - const result = validator({ inboundSchema })( - null as unknown as object, - 'testMethod', - descriptor - ); // Assess - expect(result).toEqual(descriptor); + expect(result).toEqual(data); }); it('should validate inbound only', async () => { @@ -110,8 +102,10 @@ describe('validator decorator', () => { } const instance = new TestClassInbound(); const input = { value: 10 }; + // Act const output = await instance.process(input); + // Assess expect(output).toEqual({ data: JSON.stringify(input) }); }); @@ -126,8 +120,10 @@ describe('validator decorator', () => { } const instance = new TestClassOutbound(); const input = { text: 'hello' }; + // Act const output = await instance.process(input); + // Assess expect(output).toEqual({ result: 42 }); }); diff --git a/packages/validation/tests/unit/index.test.ts b/packages/validation/tests/unit/index.test.ts deleted file mode 100644 index e2af63bd92..0000000000 --- a/packages/validation/tests/unit/index.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SchemaValidationError, validate } from '../../src/index.js'; - -describe('Index exports', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should export validate as a function', () => { - // Act & Assess - expect(typeof validate).toBe('function'); - }); - - it('should export SchemaValidationError as a function', () => { - // Act & Assess - expect(typeof SchemaValidationError).toBe('function'); - }); -}); diff --git a/packages/validation/tests/unit/middleware.test.ts b/packages/validation/tests/unit/middleware.test.ts index 204c61aadb..1d2796638a 100644 --- a/packages/validation/tests/unit/middleware.test.ts +++ b/packages/validation/tests/unit/middleware.test.ts @@ -1,7 +1,8 @@ import middy from '@middy/core'; +import type { Context } from 'aws-lambda'; import { describe, expect, it } from 'vitest'; import { SchemaValidationError } from '../../src/errors.js'; -import { validation } from '../../src/middleware.js'; +import { validator } from '../../src/middleware.js'; const inboundSchema = { type: 'object', @@ -21,53 +22,71 @@ const outboundSchema = { additionalProperties: false, }; -const response = { outputValue: 20 }; -const baseHandler = async (event: unknown) => { - return response; +const baseHandler = async (event: { inputValue: unknown }) => { + return { + outputValue: event.inputValue, + }; }; -describe('validation middleware with Middy', () => { - it('should validate inbound and outbound successfully', async () => { +describe('Middleware: validator', () => { + it('validates both inbound and outbound successfully', async () => { // Prepare - const middleware = validation({ inboundSchema, outboundSchema }); - const wrappedHandler = middy(baseHandler).use(middleware); - const event = { inputValue: 10 }; + const handler = middy(baseHandler).use( + validator({ inboundSchema, outboundSchema }) + ); + // Act - const result = await wrappedHandler(event); + const result = await handler({ inputValue: 10 }, {} as Context); + // Assess - expect(result).toEqual(response); + expect(result).toEqual({ outputValue: 10 }); }); - it('should throw error on inbound validation failure', async () => { + it('throws an error on inbound validation failure', async () => { // Prepare - const middleware = validation({ inboundSchema }); - const wrappedHandler = middy(baseHandler).use(middleware); - const invalidEvent = { inputValue: 'invalid' }; + const handler = middy(baseHandler).use(validator({ inboundSchema })); + // Act & Assess - await expect(wrappedHandler(invalidEvent)).rejects.toThrow( - SchemaValidationError + await expect( + handler({ inputValue: 'invalid' }, {} as Context) + ).rejects.toThrow( + new SchemaValidationError('Inbound schema validation failed', { + cause: [ + expect.objectContaining({ + keyword: 'type', + message: 'must be number', + }), + ], + }) ); }); - it('should throw error on outbound validation failure', async () => { - const invalidHandler = async (_event: unknown) => { - return { outputValue: 'invalid' }; - }; - const middleware = validation({ outboundSchema }); - const wrappedHandler = middy(invalidHandler).use(middleware); - const event = { any: 'value' }; + it('throws an error on outbound validation failure', async () => { + const handler = middy(() => { + return 'invalid output'; + }).use(validator({ outboundSchema })); + // Act & Assess - await expect(wrappedHandler(event)).rejects.toThrow(SchemaValidationError); + await expect(handler({ inputValue: 10 }, {} as Context)).rejects.toThrow( + new SchemaValidationError('Outbound schema validation failed', { + cause: [ + expect.objectContaining({ + keyword: 'type', + message: 'must be object', + }), + ], + }) + ); }); - it('should no-op when no schemas are provided', async () => { + it('skips validation when no schemas are provided', async () => { // Prepare - const middleware = validation({}); - const wrappedHandler = middy(baseHandler).use(middleware); - const event = { anyKey: 'anyValue' }; + const handler = middy(baseHandler).use(validator({})); + // Act - const result = await wrappedHandler(event); + const result = await handler({ inputValue: 'bar' }, {} as Context); + // Assess - expect(result).toEqual(response); + expect(result).toEqual({ outputValue: 'bar' }); }); }); diff --git a/packages/validation/tests/unit/validate.test.ts b/packages/validation/tests/unit/validate.test.ts index b4480f580e..c1e46cd1ca 100644 --- a/packages/validation/tests/unit/validate.test.ts +++ b/packages/validation/tests/unit/validate.test.ts @@ -1,6 +1,9 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; -import { SchemaValidationError } from '../../src/errors.js'; +import { + SchemaCompilationError, + SchemaValidationError, +} from '../../src/errors.js'; import type { ValidateParams } from '../../src/types.js'; import { validate } from '../../src/validate.js'; @@ -17,8 +20,7 @@ describe('validate function', () => { required: ['name', 'age'], additionalProperties: false, }; - - const params: ValidateParams = { payload, schema }; + const params: ValidateParams = { payload, schema }; // Act const result = validate(params); @@ -75,22 +77,23 @@ describe('validate function', () => { it('uses provided ajv instance and custom formats', () => { // Prepare - const payload = { email: 'test@example.com' }; + const payload = { email: 'test@example.com', region: 'us-east-1' }; const schema = { type: 'object', properties: { email: { type: 'string', format: 'custom-email' }, + region: { type: 'string', format: 'allowedRegions' }, }, - required: ['email'], + required: ['email', 'region'], additionalProperties: false, }; const ajvInstance = new Ajv({ allErrors: true }); const formats = { 'custom-email': { - type: 'string', validate: (email: string) => email.includes('@'), }, + allowedRegions: /^(us-east-1|us-west-1)$/, }; const params: ValidateParams = { @@ -149,7 +152,7 @@ describe('validate function', () => { expect(result).toEqual(payload); }); - it('throws SchemaValidationError when schema compilation fails', () => { + it('throws the correct error when schema compilation fails', () => { // Prepare const payload = { name: 'John' }; const schema = { @@ -162,6 +165,6 @@ describe('validate function', () => { const params: ValidateParams = { payload, schema }; // Act & Assess - expect(() => validate(params)).toThrow(SchemaValidationError); + expect(() => validate(params)).toThrow(SchemaCompilationError); }); }); diff --git a/packages/validation/typedoc.json b/packages/validation/typedoc.json new file mode 100644 index 0000000000..3032e9d15e --- /dev/null +++ b/packages/validation/typedoc.json @@ -0,0 +1,13 @@ +{ + "extends": [ + "../../typedoc.base.json" + ], + "entryPoints": [ + "./src/types.ts", + "./src/validate.ts", + "./src/middleware.ts", + "./src/decorator.ts", + "./src/errors.ts", + ], + "readme": "README.md" +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index e8056e9a46..5d81a103f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,18 +13,10 @@ export default defineConfig({ include: ['packages/*/src/**'], exclude: [ ...coverageConfigDefaults.exclude, - 'packages/batch/src/types.ts', - 'packages/commons/src/types/**', - 'packages/event-handler/src/types/**', - 'packages/idempotency/src/types/**', - 'packages/jmespath/src/types.ts', - 'packages/logger/src/types/**', - 'packages/metrics/src/types/**', - 'packages/parameters/src/types/**', - 'packages/parser/src/types/**', 'layers/**', + 'packages/*/src/types/**', + 'packages/*/src/types.ts', 'packages/testing/**', - 'packages/tracer/src/types/**', ], }, setupFiles: ['./packages/testing/src/setupEnv.ts'],