diff --git a/src/compileValueSchema.ts b/src/compileValueSchema.ts index 2dfa09f..9a8db04 100644 --- a/src/compileValueSchema.ts +++ b/src/compileValueSchema.ts @@ -14,6 +14,7 @@ import { OpenAPIOneOfSchema, OpenAPIPropertyNamesSchema, OpenAPIStringSchema, + OpenAPITypeArraySchema, OpenAPIValueSchema, } from './types'; import { ValidationErrorIdentifier } from './error'; @@ -52,6 +53,11 @@ export function compileValueSchema(compiler: Compiler, schema: OpenAPIValueSchem } if ('type' in schema) { + // OpenAPI 3.1: type can be an array, e.g. ["string", "null"] + if (Array.isArray(schema.type)) { + return compileTypeArraySchema(compiler, schema as OpenAPITypeArraySchema); + } + switch (schema.type) { case 'object': return compileObjectSchema(compiler, schema); @@ -64,6 +70,8 @@ export function compileValueSchema(compiler: Compiler, schema: OpenAPIValueSchem return compileBooleanSchema(compiler, schema); case 'array': return compileArraySchema(compiler, schema); + case 'null': + return compileNullTypeSchema(compiler, schema); default: throw new Error(`Unsupported schema: ${JSON.stringify(schema)}`); } @@ -83,6 +91,42 @@ function normalizePropertyNamesSchema(schema: OpenAPIPropertyNamesSchema): OpenA }; } +/** + * OpenAPI 3.1: type: 'null' as a standalone type. + */ +function compileNullTypeSchema(compiler: Compiler, schema: object) { + return compiler.declareValidationFunction(schema, ({ value, error }) => { + return [ + builders.ifStatement( + builders.binaryExpression('!==', value, builders.literal(null)), + builders.blockStatement([builders.returnStatement(error('expected null'))]), + ), + builders.returnStatement(value), + ]; + }); +} + +/** + * OpenAPI 3.1: type as array, e.g. type: ['string', 'null'] + * Converts to an anyOf schema internally. + */ +function compileTypeArraySchema(compiler: Compiler, schema: OpenAPITypeArraySchema) { + const { type, ...rest } = schema; + + const typesWithoutNull = type.filter((t) => t !== 'null'); + const hasNull = type.includes('null'); + + const anyOf: OpenAPIValueSchema[] = typesWithoutNull.map( + (t) => ({ ...rest, type: t }) as OpenAPIValueSchema, + ); + + if (hasNull) { + anyOf.push({ type: 'null' } as OpenAPIValueSchema); + } + + return compileAnyOfSchema(compiler, { anyOf }); +} + function compileAnyOfSchema(compiler: Compiler, schema: OpenAPIAnyOfSchema) { return compiler.declareValidationFunction(schema, ({ value, path, context, error }) => { const nodes: namedTypes.BlockStatement['body'] = []; @@ -646,6 +690,11 @@ function compileArraySchema(compiler: Compiler, schema: OpenAPIArraySchema) { ); } + if (!schema.items) { + nodes.push(builders.returnStatement(value)); + return nodes; + } + const valueSet = builders.identifier('valueSet'); if (schema.uniqueItems) { diff --git a/src/tests/__snapshots__/compileValueSchema.test.ts.snap b/src/tests/__snapshots__/compileValueSchema.test.ts.snap index f831ce6..a7c19be 100644 --- a/src/tests/__snapshots__/compileValueSchema.test.ts.snap +++ b/src/tests/__snapshots__/compileValueSchema.test.ts.snap @@ -1492,6 +1492,88 @@ function obj0(path, value, context) { }" `; +exports[`Array without items 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj0(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + return value; +}" +`; + +exports[`Array without items and uniqueItems 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj0(path, value, context) { + if (!Array.isArray(value)) { + return new ValidationError(path, 'expected an array'); + } + return value; +}" +`; + exports[`anyOf 1`] = ` "/** Validate a request against the OpenAPI spec @@ -1732,3 +1814,224 @@ function obj0(path, value, context) { return result; }" `; + +exports[`OpenAPI 3.1 type: null 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj0(path, value, context) { + if (value !== null) { + return new ValidationError(path, 'expected null'); + } + return value; +}" +`; + +exports[`OpenAPI 3.1 type array type: [string, null] 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj2(path, value, context) { + if (value !== null) { + return new ValidationError(path, 'expected null'); + } + return value; +} +function obj0(path, value, context) { + const value0 = obj1(path, value, context); + if (!(value0 instanceof ValidationError)) { + return value0; + } + const value1 = obj2(path, value, context); + if (!(value1 instanceof ValidationError)) { + return value1; + } + return new ValidationError(path, 'expected one of the anyOf schemas to match'); +}" +`; + +exports[`OpenAPI 3.1 type array type: [string, number] 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + return value; +} +function obj2(path, value, context) { + if (typeof value === 'string') { + value = Number(value); + } + if (typeof value !== 'number' || Number.isNaN(value)) { + return new ValidationError(path, 'expected a number'); + } + return value; +} +function obj0(path, value, context) { + const value0 = obj1(path, value, context); + if (!(value0 instanceof ValidationError)) { + return value0; + } + const value1 = obj2(path, value, context); + if (!(value1 instanceof ValidationError)) { + return value1; + } + return new ValidationError(path, 'expected one of the anyOf schemas to match'); +}" +`; + +exports[`OpenAPI 3.1 type array type: [string, null] with minLength 1`] = ` +"/** +Validate a request against the OpenAPI spec +@param {{ method: string; path: string; body?: any; query: Record; headers: Record; }} request - Input request to validate +@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions +@returns {{ operationId?: string; params: Record; query: Record; body?: any; headers: Record; }} +*/ +export function validateRequest(request, context) { + return new RequestError(404, 'no operation match path'); +} +/** +Map of all components defined in the spec to their validation functions. +{Object.(path: string[], value: T, context: any) => (T | ValidationError)>} +*/ +export const componentSchemas = {}; +export class RequestError extends Error { + /** @param {number} code HTTP code for the error +@param {string} message The error message*/ + constructor(code, message) { + super(message); + /** @type {number} HTTP code for the error*/ + this.code = code; + } +} +export class ValidationError extends RequestError { + /** @param {string[]} path The path that failed validation +@param {string} message The error message*/ + constructor(path, message) { + super(409, message); + /** @type {string[]} The path that failed validation*/ + this.path = path; + } +} +function obj1(path, value, context) { + if (typeof value !== 'string') { + return new ValidationError(path, 'expected a string'); + } + if (value.length < 1) { + return new ValidationError(path, 'expected at least 1 characters'); + } + return value; +} +function obj2(path, value, context) { + if (value !== null) { + return new ValidationError(path, 'expected null'); + } + return value; +} +function obj0(path, value, context) { + const value0 = obj1(path, value, context); + if (!(value0 instanceof ValidationError)) { + return value0; + } + const value1 = obj2(path, value, context); + if (!(value1 instanceof ValidationError)) { + return value1; + } + return new ValidationError(path, 'expected one of the anyOf schemas to match'); +}" +`; diff --git a/src/tests/__snapshots__/compiler.test.ts.snap b/src/tests/__snapshots__/compiler.test.ts.snap index d409c42..3471095 100644 --- a/src/tests/__snapshots__/compiler.test.ts.snap +++ b/src/tests/__snapshots__/compiler.test.ts.snap @@ -1,4 +1,4 @@ -// Bun Snapshot v1, https://goo.gl/fbAQLP +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots exports[`components ref 1`] = ` "/** diff --git a/src/tests/compileValueSchema.test.ts b/src/tests/compileValueSchema.test.ts index 6580efa..2a000ad 100644 --- a/src/tests/compileValueSchema.test.ts +++ b/src/tests/compileValueSchema.test.ts @@ -314,6 +314,23 @@ describe('Array', () => { }); expect(compiler.compile()).toMatchSnapshot(); }); + + test('without items', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'array', + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('without items and uniqueItems', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'array', + uniqueItems: true, + }); + expect(compiler.compile()).toMatchSnapshot(); + }); }); test('anyOf', () => { @@ -366,3 +383,40 @@ test('allOf', () => { }); expect(compiler.compile()).toMatchSnapshot(); }); + +describe('OpenAPI 3.1', () => { + test('type: null', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: 'null', + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + describe('type array', () => { + test('type: [string, null]', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: ['string', 'null'], + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('type: [string, number]', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: ['string', 'number'], + }); + expect(compiler.compile()).toMatchSnapshot(); + }); + + test('type: [string, null] with minLength', () => { + const compiler = new Compiler(); + compileValueSchema(compiler, { + type: ['string', 'null'], + minLength: 1, + } as any); + expect(compiler.compile()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 51df833..27780f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,9 @@ export type OpenAPIValueSchema = | OpenAPIBooleanSchema | OpenAPIObjectSchema | OpenAPIArraySchema - | OpenAPIRef; + | OpenAPIRef + | OpenAPINullSchema + | OpenAPITypeArraySchema; export interface OpenAPIAllOfSchema { allOf: OpenAPIValueSchema[]; @@ -113,12 +115,26 @@ export interface OpenAPIObjectSchema extends OpenAPINullableSchema { export interface OpenAPIArraySchema extends OpenAPINullableSchema { type: 'array'; - items: OpenAPIValueSchema; + items?: OpenAPIValueSchema; minItems?: number; maxItems?: number; uniqueItems?: boolean; } +/** + * OpenAPI 3.1: type: 'null' as a standalone type + */ +export interface OpenAPINullSchema { + type: 'null'; +} + +/** + * OpenAPI 3.1: type as an array of types, e.g. type: ['string', 'null'] + */ +export interface OpenAPITypeArraySchema extends OpenAPINullableSchema, OpenAPIEnumableSchema { + type: ('string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null')[]; +} + export interface OpenAPINullableSchema { nullable?: boolean; }