Skip to content

Commit c9581a2

Browse files
committed
Support $ref with sibling keywords (OpenAPI 3.1)
In OpenAPI 3.1, $ref can have sibling keywords like maxLength, minLength, pattern, etc. Merge sibling constraints into the resolved schema so they are compiled into the generated validator.
1 parent b279441 commit c9581a2

3 files changed

Lines changed: 173 additions & 1 deletion

File tree

src/compileValueSchema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ export function compileValueSchema(
4040
schema: OpenAPIValueSchema,
4141
): namedTypes.Identifier {
4242
if ('$ref' in schema) {
43-
return compileValueSchema(compiler, compiler.resolveRef(schema));
43+
const resolved = compiler.resolveRef(schema);
44+
45+
// OpenAPI 3.1: $ref can have sibling keywords that apply alongside the resolved schema
46+
const { $ref, ...siblings } = schema;
47+
if (Object.keys(siblings).length > 0) {
48+
return compileValueSchema(compiler, { ...resolved, ...siblings });
49+
}
50+
51+
return compileValueSchema(compiler, resolved);
4452
}
4553

4654
if ('anyOf' in schema) {

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,3 +2007,107 @@ function obj0(path, value, context) {
20072007
return value;
20082008
}"
20092009
`;
2010+
2011+
exports[`OpenAPI 3.1 $ref with sibling keywords $ref with maxLength 1`] = `
2012+
"/**
2013+
Validate a request against the OpenAPI spec
2014+
@param {{ method: string; path: string; body?: any; query: Record<string, string | string[]>; headers: Record<string, string>; }} request - Input request to validate
2015+
@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions
2016+
@returns {{ operationId?: string; params: Record<string, string>; query: Record<string, string | string[]>; body?: any; headers: Record<string, string>; }}
2017+
*/
2018+
export function validateRequest(request, context) {
2019+
return new RequestError(404, 'no operation match path');
2020+
}
2021+
/**
2022+
Map of all components defined in the spec to their validation functions.
2023+
{Object.<string, <T>(path: string[], value: T, context: any) => (T | ValidationError)>}
2024+
*/
2025+
export const componentSchemas = { 'MyString': obj1 };
2026+
export class RequestError extends Error {
2027+
/** @param {number} code HTTP code for the error
2028+
@param {string} message The error message*/
2029+
constructor(code, message) {
2030+
super(message);
2031+
/** @type {number} HTTP code for the error*/
2032+
this.code = code;
2033+
}
2034+
}
2035+
export class ValidationError extends RequestError {
2036+
/** @param {string[]} path The path that failed validation
2037+
@param {string} message The error message*/
2038+
constructor(path, message) {
2039+
super(409, message);
2040+
/** @type {string[]} The path that failed validation*/
2041+
this.path = path;
2042+
}
2043+
}
2044+
function obj0(path, value, context) {
2045+
if (typeof value !== 'string') {
2046+
return new ValidationError(path, 'expected a string');
2047+
}
2048+
if (value.length > 64) {
2049+
return new ValidationError(path, 'expected at most 64 characters');
2050+
}
2051+
return value;
2052+
}
2053+
function obj1(path, value, context) {
2054+
if (typeof value !== 'string') {
2055+
return new ValidationError(path, 'expected a string');
2056+
}
2057+
return value;
2058+
}"
2059+
`;
2060+
2061+
exports[`OpenAPI 3.1 $ref with sibling keywords $ref with minLength and pattern 1`] = `
2062+
"/**
2063+
Validate a request against the OpenAPI spec
2064+
@param {{ method: string; path: string; body?: any; query: Record<string, string | string[]>; headers: Record<string, string>; }} request - Input request to validate
2065+
@param {{ stringFormats?: { [format: string]: (value: string, path: string[]) => ValidationError | string | null } }} [context] - Context object to pass to validation functions
2066+
@returns {{ operationId?: string; params: Record<string, string>; query: Record<string, string | string[]>; body?: any; headers: Record<string, string>; }}
2067+
*/
2068+
export function validateRequest(request, context) {
2069+
return new RequestError(404, 'no operation match path');
2070+
}
2071+
/**
2072+
Map of all components defined in the spec to their validation functions.
2073+
{Object.<string, <T>(path: string[], value: T, context: any) => (T | ValidationError)>}
2074+
*/
2075+
export const componentSchemas = { 'MyString': obj2 };
2076+
export class RequestError extends Error {
2077+
/** @param {number} code HTTP code for the error
2078+
@param {string} message The error message*/
2079+
constructor(code, message) {
2080+
super(message);
2081+
/** @type {number} HTTP code for the error*/
2082+
this.code = code;
2083+
}
2084+
}
2085+
export class ValidationError extends RequestError {
2086+
/** @param {string[]} path The path that failed validation
2087+
@param {string} message The error message*/
2088+
constructor(path, message) {
2089+
super(409, message);
2090+
/** @type {string[]} The path that failed validation*/
2091+
this.path = path;
2092+
}
2093+
}
2094+
const obj1 = new RegExp('^[a-z]+$');
2095+
function obj0(path, value, context) {
2096+
if (typeof value !== 'string') {
2097+
return new ValidationError(path, 'expected a string');
2098+
}
2099+
if (value.length < 1) {
2100+
return new ValidationError(path, 'expected at least 1 characters');
2101+
}
2102+
if (!obj1.test(value)) {
2103+
return new ValidationError(path, 'expected to match the pattern "^[a-z]+$"');
2104+
}
2105+
return value;
2106+
}
2107+
function obj2(path, value, context) {
2108+
if (typeof value !== 'string') {
2109+
return new ValidationError(path, 'expected a string');
2110+
}
2111+
return value;
2112+
}"
2113+
`;

src/tests/compileValueSchema.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,4 +419,64 @@ describe('OpenAPI 3.1', () => {
419419
expect(compiler.compile()).toMatchSnapshot();
420420
});
421421
});
422+
423+
describe('$ref with sibling keywords', () => {
424+
test('$ref with maxLength', () => {
425+
const compiler = new Compiler({
426+
components: {
427+
schemas: {
428+
MyString: { type: 'string' },
429+
},
430+
},
431+
});
432+
compileValueSchema(compiler, {
433+
$ref: '#/components/schemas/MyString',
434+
maxLength: 64,
435+
} as any);
436+
expect(compiler.compile()).toMatchSnapshot();
437+
});
438+
439+
test('$ref with minLength and pattern', () => {
440+
const compiler = new Compiler({
441+
components: {
442+
schemas: {
443+
MyString: { type: 'string' },
444+
},
445+
},
446+
});
447+
compileValueSchema(compiler, {
448+
$ref: '#/components/schemas/MyString',
449+
minLength: 1,
450+
pattern: '^[a-z]+$',
451+
} as any);
452+
expect(compiler.compile()).toMatchSnapshot();
453+
});
454+
455+
test('$ref with only description does not change output', () => {
456+
const compiler = new Compiler({
457+
components: {
458+
schemas: {
459+
MyString: { type: 'string' },
460+
},
461+
},
462+
});
463+
compileValueSchema(compiler, {
464+
$ref: '#/components/schemas/MyString',
465+
description: 'A localized title',
466+
} as any);
467+
468+
const compilerPlain = new Compiler({
469+
components: {
470+
schemas: {
471+
MyString: { type: 'string' },
472+
},
473+
},
474+
});
475+
compileValueSchema(compilerPlain, {
476+
$ref: '#/components/schemas/MyString',
477+
});
478+
479+
expect(compiler.compile()).toEqual(compilerPlain.compile());
480+
});
481+
});
422482
});

0 commit comments

Comments
 (0)