Skip to content

Commit 4a01774

Browse files
committed
Add OpenAPI 3.1 support: null type, type arrays, and optional array items
- Add support for type: 'null' as a standalone type - Add support for type arrays (e.g. type: ['string', 'null']) by converting to anyOf - Make array schema items optional to handle arrays without item constraints - Add OpenAPINullSchema and OpenAPITypeArraySchema types
1 parent 46fb2a5 commit 4a01774

5 files changed

Lines changed: 244 additions & 3 deletions

File tree

src/compileValueSchema.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
OpenAPIOneOfSchema,
1515
OpenAPIPropertyNamesSchema,
1616
OpenAPIStringSchema,
17+
OpenAPITypeArraySchema,
1718
OpenAPIValueSchema,
1819
} from './types';
1920
import { ValidationErrorIdentifier } from './error';
@@ -52,6 +53,11 @@ export function compileValueSchema(compiler: Compiler, schema: OpenAPIValueSchem
5253
}
5354

5455
if ('type' in schema) {
56+
// OpenAPI 3.1: type can be an array, e.g. ["string", "null"]
57+
if (Array.isArray(schema.type)) {
58+
return compileTypeArraySchema(compiler, schema as OpenAPITypeArraySchema);
59+
}
60+
5561
switch (schema.type) {
5662
case 'object':
5763
return compileObjectSchema(compiler, schema);
@@ -64,6 +70,8 @@ export function compileValueSchema(compiler: Compiler, schema: OpenAPIValueSchem
6470
return compileBooleanSchema(compiler, schema);
6571
case 'array':
6672
return compileArraySchema(compiler, schema);
73+
case 'null':
74+
return compileNullTypeSchema(compiler, schema);
6775
default:
6876
throw new Error(`Unsupported schema: ${JSON.stringify(schema)}`);
6977
}
@@ -83,6 +91,30 @@ function normalizePropertyNamesSchema(schema: OpenAPIPropertyNamesSchema): OpenA
8391
};
8492
}
8593

94+
/**
95+
* OpenAPI 3.1: type: 'null' as a standalone type.
96+
*/
97+
function compileNullTypeSchema(compiler: Compiler, schema: object) {
98+
return compiler.declareValidationFunction(schema, ({ value, error }) => {
99+
return [
100+
builders.ifStatement(
101+
builders.binaryExpression('!==', value, builders.literal(null)),
102+
builders.blockStatement([builders.returnStatement(error('expected null'))]),
103+
),
104+
builders.returnStatement(value),
105+
];
106+
});
107+
}
108+
109+
/**
110+
* OpenAPI 3.1: type as array, e.g. type: ['string', 'null']
111+
* Converts to an anyOf schema internally.
112+
*/
113+
function compileTypeArraySchema(compiler: Compiler, schema: OpenAPITypeArraySchema) {
114+
const anyOf = schema.type.map((t) => ({ type: t }) as OpenAPIValueSchema);
115+
return compileAnyOfSchema(compiler, { anyOf });
116+
}
117+
86118
function compileAnyOfSchema(compiler: Compiler, schema: OpenAPIAnyOfSchema) {
87119
return compiler.declareValidationFunction(schema, ({ value, path, context, error }) => {
88120
const nodes: namedTypes.BlockStatement['body'] = [];
@@ -659,6 +691,11 @@ function compileArraySchema(compiler: Compiler, schema: OpenAPIArraySchema) {
659691
);
660692
}
661693

694+
if (!schema.items) {
695+
nodes.push(builders.returnStatement(value));
696+
return nodes;
697+
}
698+
662699
const index = builders.identifier('i');
663700
const itemResult = builders.identifier('itemResult');
664701
const fnSchema = compileValueSchema(compiler, schema.items);

src/tests/__snapshots__/compileValueSchema.test.ts.snap

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1732,3 +1732,163 @@ function obj0(path, value, context) {
17321732
return result;
17331733
}"
17341734
`;
1735+
1736+
exports[`OpenAPI 3.1 type: null 1`] = `
1737+
"/**
1738+
Validate a request against the OpenAPI spec
1739+
@param {{ method: string; path: string; body?: any; query: Record<string, string | string[]>; headers: Record<string, string>; }} request - Input request to validate
1740+
@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions
1741+
@returns {{ operationId?: string; params: Record<string, string>; query: Record<string, string | string[]>; body?: any; headers: Record<string, string>; }}
1742+
*/
1743+
export function validateRequest(request, context) {
1744+
return new RequestError(404, 'no operation match path');
1745+
}
1746+
/**
1747+
Map of all components defined in the spec to their validation functions.
1748+
{Object.<string, <T>(path: string[], value: T, context: any) => (T | ValidationError)>}
1749+
*/
1750+
export const componentSchemas = {};
1751+
export class RequestError extends Error {
1752+
/** @param {number} code HTTP code for the error
1753+
@param {string} message The error message*/
1754+
constructor(code, message) {
1755+
super(message);
1756+
/** @type {number} HTTP code for the error*/
1757+
this.code = code;
1758+
}
1759+
}
1760+
export class ValidationError extends RequestError {
1761+
/** @param {string[]} path The path that failed validation
1762+
@param {string} message The error message*/
1763+
constructor(path, message) {
1764+
super(409, message);
1765+
/** @type {string[]} The path that failed validation*/
1766+
this.path = path;
1767+
}
1768+
}
1769+
function obj0(path, value, context) {
1770+
if (value !== null) {
1771+
return new ValidationError(path, 'expected null');
1772+
}
1773+
return value;
1774+
}"
1775+
`;
1776+
1777+
exports[`OpenAPI 3.1 type array type: [string, null] 1`] = `
1778+
"/**
1779+
Validate a request against the OpenAPI spec
1780+
@param {{ method: string; path: string; body?: any; query: Record<string, string | string[]>; headers: Record<string, string>; }} request - Input request to validate
1781+
@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions
1782+
@returns {{ operationId?: string; params: Record<string, string>; query: Record<string, string | string[]>; body?: any; headers: Record<string, string>; }}
1783+
*/
1784+
export function validateRequest(request, context) {
1785+
return new RequestError(404, 'no operation match path');
1786+
}
1787+
/**
1788+
Map of all components defined in the spec to their validation functions.
1789+
{Object.<string, <T>(path: string[], value: T, context: any) => (T | ValidationError)>}
1790+
*/
1791+
export const componentSchemas = {};
1792+
export class RequestError extends Error {
1793+
/** @param {number} code HTTP code for the error
1794+
@param {string} message The error message*/
1795+
constructor(code, message) {
1796+
super(message);
1797+
/** @type {number} HTTP code for the error*/
1798+
this.code = code;
1799+
}
1800+
}
1801+
export class ValidationError extends RequestError {
1802+
/** @param {string[]} path The path that failed validation
1803+
@param {string} message The error message*/
1804+
constructor(path, message) {
1805+
super(409, message);
1806+
/** @type {string[]} The path that failed validation*/
1807+
this.path = path;
1808+
}
1809+
}
1810+
function obj1(path, value, context) {
1811+
if (typeof value !== 'string') {
1812+
return new ValidationError(path, 'expected a string');
1813+
}
1814+
return value;
1815+
}
1816+
function obj2(path, value, context) {
1817+
if (value !== null) {
1818+
return new ValidationError(path, 'expected null');
1819+
}
1820+
return value;
1821+
}
1822+
function obj0(path, value, context) {
1823+
const value0 = obj1(path, value, context);
1824+
if (!(value0 instanceof ValidationError)) {
1825+
return value0;
1826+
}
1827+
const value1 = obj2(path, value, context);
1828+
if (!(value1 instanceof ValidationError)) {
1829+
return value1;
1830+
}
1831+
return new ValidationError(path, 'expected one of the anyOf schemas to match');
1832+
}"
1833+
`;
1834+
1835+
exports[`OpenAPI 3.1 type array type: [string, number] 1`] = `
1836+
"/**
1837+
Validate a request against the OpenAPI spec
1838+
@param {{ method: string; path: string; body?: any; query: Record<string, string | string[]>; headers: Record<string, string>; }} request - Input request to validate
1839+
@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions
1840+
@returns {{ operationId?: string; params: Record<string, string>; query: Record<string, string | string[]>; body?: any; headers: Record<string, string>; }}
1841+
*/
1842+
export function validateRequest(request, context) {
1843+
return new RequestError(404, 'no operation match path');
1844+
}
1845+
/**
1846+
Map of all components defined in the spec to their validation functions.
1847+
{Object.<string, <T>(path: string[], value: T, context: any) => (T | ValidationError)>}
1848+
*/
1849+
export const componentSchemas = {};
1850+
export class RequestError extends Error {
1851+
/** @param {number} code HTTP code for the error
1852+
@param {string} message The error message*/
1853+
constructor(code, message) {
1854+
super(message);
1855+
/** @type {number} HTTP code for the error*/
1856+
this.code = code;
1857+
}
1858+
}
1859+
export class ValidationError extends RequestError {
1860+
/** @param {string[]} path The path that failed validation
1861+
@param {string} message The error message*/
1862+
constructor(path, message) {
1863+
super(409, message);
1864+
/** @type {string[]} The path that failed validation*/
1865+
this.path = path;
1866+
}
1867+
}
1868+
function obj1(path, value, context) {
1869+
if (typeof value !== 'string') {
1870+
return new ValidationError(path, 'expected a string');
1871+
}
1872+
return value;
1873+
}
1874+
function obj2(path, value, context) {
1875+
if (typeof value === 'string') {
1876+
value = Number(value);
1877+
}
1878+
if (typeof value !== 'number' || Number.isNaN(value)) {
1879+
return new ValidationError(path, 'expected a number');
1880+
}
1881+
return value;
1882+
}
1883+
function obj0(path, value, context) {
1884+
const value0 = obj1(path, value, context);
1885+
if (!(value0 instanceof ValidationError)) {
1886+
return value0;
1887+
}
1888+
const value1 = obj2(path, value, context);
1889+
if (!(value1 instanceof ValidationError)) {
1890+
return value1;
1891+
}
1892+
return new ValidationError(path, 'expected one of the anyOf schemas to match');
1893+
}"
1894+
`;

src/tests/__snapshots__/compiler.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Bun Snapshot v1, https://goo.gl/fbAQLP
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
22

33
exports[`components ref 1`] = `
44
"/**

src/tests/compileValueSchema.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,31 @@ test('allOf', () => {
366366
});
367367
expect(compiler.compile()).toMatchSnapshot();
368368
});
369+
370+
describe('OpenAPI 3.1', () => {
371+
test('type: null', () => {
372+
const compiler = new Compiler();
373+
compileValueSchema(compiler, {
374+
type: 'null',
375+
});
376+
expect(compiler.compile()).toMatchSnapshot();
377+
});
378+
379+
describe('type array', () => {
380+
test('type: [string, null]', () => {
381+
const compiler = new Compiler();
382+
compileValueSchema(compiler, {
383+
type: ['string', 'null'],
384+
});
385+
expect(compiler.compile()).toMatchSnapshot();
386+
});
387+
388+
test('type: [string, number]', () => {
389+
const compiler = new Compiler();
390+
compileValueSchema(compiler, {
391+
type: ['string', 'number'],
392+
});
393+
expect(compiler.compile()).toMatchSnapshot();
394+
});
395+
});
396+
});

src/types.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ export type OpenAPIValueSchema =
4848
| OpenAPIBooleanSchema
4949
| OpenAPIObjectSchema
5050
| OpenAPIArraySchema
51-
| OpenAPIRef;
51+
| OpenAPIRef
52+
| OpenAPINullSchema
53+
| OpenAPITypeArraySchema;
5254

5355
export interface OpenAPIAllOfSchema {
5456
allOf: OpenAPIValueSchema[];
@@ -113,12 +115,26 @@ export interface OpenAPIObjectSchema extends OpenAPINullableSchema {
113115

114116
export interface OpenAPIArraySchema extends OpenAPINullableSchema {
115117
type: 'array';
116-
items: OpenAPIValueSchema;
118+
items?: OpenAPIValueSchema;
117119
minItems?: number;
118120
maxItems?: number;
119121
uniqueItems?: boolean;
120122
}
121123

124+
/**
125+
* OpenAPI 3.1: type: 'null' as a standalone type
126+
*/
127+
export interface OpenAPINullSchema {
128+
type: 'null';
129+
}
130+
131+
/**
132+
* OpenAPI 3.1: type as an array of types, e.g. type: ['string', 'null']
133+
*/
134+
export interface OpenAPITypeArraySchema {
135+
type: ('string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null')[];
136+
}
137+
122138
export interface OpenAPINullableSchema {
123139
nullable?: boolean;
124140
}

0 commit comments

Comments
 (0)