Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(openapi): support OpenAPI version 3.1 #882

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4f2ceaa
feat(openapi): support version 3.1
SF97 Dec 31, 2023
1894d84
test(openapi_3.1): ensure that an API with webhooks and no routes is …
SF97 Feb 5, 2024
bb53f78
feat(openapi_3.1): adds open api 3.1 type
SF97 Feb 5, 2024
0c4f24c
chore(test-scripts): run mocha with --extension instead of glob to pi…
SF97 Feb 5, 2024
289bd03
test(openapi-3.1): adds test to ensure an API with only components is…
SF97 Feb 29, 2024
0e6ae31
test(openapi-3.1): remove unnecessary import
SF97 Feb 29, 2024
3161312
test(openapi-3.1): add support for summary in info object
SF97 Feb 29, 2024
7698f02
test(openapi-3.1): add support for identifier in license
SF97 Feb 29, 2024
15d9049
test(openapi_3.1): ensure API with type set to null works correctly
SF97 Mar 31, 2024
0587415
test(open_api3.1): ensure that methods with non-explicit semantics al…
SF97 Mar 31, 2024
415de44
test(open_api3.1): ensure 500 is returned when server variable has no…
SF97 Mar 31, 2024
7bcf518
feat(openapi_3.1): ensure API supports an endpoint without response
SF97 Mar 31, 2024
782a70a
Merge remote-tracking branch 'upstream/master' into feat/openapi-3.1-…
SF97 Apr 28, 2024
4584261
feat(openapi_3.1): add full type support for open api 3.1
SF97 Apr 28, 2024
9c58166
test(openapi_3.1): adds test for path item support in components
SF97 Apr 28, 2024
5897589
fix(openapi_3.1_schema): update schema to fix bug
SF97 May 13, 2024
9654417
feat(openapi_3.1): support reusable path items
SF97 May 16, 2024
ddc0ebc
style(linting): fix linting issues
SF97 May 20, 2024
2cae4b5
style(openapi): improve readability of version validation
SF97 May 20, 2024
81b45ee
docs(schema-validator): clearly state why media-range attribute is no…
SF97 May 20, 2024
ffd7468
Merge branch 'master' into feat/openapi-3.1-support
SF97 Jun 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"scripts": {
"compile": "rimraf dist && tsc",
"compile:release": "rimraf dist && tsc --sourceMap false",
"test": "mocha -r source-map-support/register -r ts-node/register --files --recursive -R spec test/**/*.spec.ts",
"test:debug": "mocha -r source-map-support/register -r ts-node/register --inspect-brk --files --recursive test/**/*.spec.ts",
"test:coverage": "nyc mocha -r source-map-support/register -r ts-node/register --recursive test/**/*.spec.ts",
"test": "mocha -r source-map-support/register -r ts-node/register --files --recursive -R spec --extension .spec.ts test",
"test:debug": "mocha -r source-map-support/register -r ts-node/register --inspect-brk --files --recursive --extension .spec.ts test",
"test:coverage": "nyc mocha -r source-map-support/register -r ts-node/register --recursive --extension .spec.ts test",
"test:reset": "rimraf node_modules && npm i && npm run compile && npm t",
"coveralls": "cat coverage/lcov.info | coveralls -v",
"codacy": "bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage/lcov.info",
Expand Down
6 changes: 3 additions & 3 deletions src/framework/ajv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ interface SerDesSchema extends Partial<SerDes> {
}

export function createRequestAjv(
openApiSpec: OpenAPIV3.Document,
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
): AjvDraft4 {
return createAjv(openApiSpec, options);
}

export function createResponseAjv(
openApiSpec: OpenAPIV3.Document,
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
): AjvDraft4 {
return createAjv(openApiSpec, options, false);
}

function createAjv(
openApiSpec: OpenAPIV3.Document,
openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1,
options: Options = {},
request = true,
): AjvDraft4 {
Expand Down
8 changes: 4 additions & 4 deletions src/framework/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class OpenAPIFramework {
private loadSpec(
filePath: string | object,
$refParser: { mode: 'bundle' | 'dereference' } = { mode: 'bundle' },
): Promise<OpenAPIV3.Document> {
): Promise<OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1> {
// Because of this issue ( https://github.com/APIDevTools/json-schema-ref-parser/issues/101#issuecomment-421755168 )
// We need this workaround ( use '$RefParser.dereference' instead of '$RefParser.bundle' ) if asked by user
if (typeof filePath === 'string') {
Expand All @@ -87,7 +87,7 @@ export class OpenAPIFramework {
$refParser.mode === 'dereference'
? $RefParser.dereference(absolutePath)
: $RefParser.bundle(absolutePath);
return doc as Promise<OpenAPIV3.Document>;
return doc as Promise<OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1>;
} else {
throw new Error(
`${this.loggingPrefix}spec could not be read at ${filePath}`,
Expand All @@ -98,10 +98,10 @@ export class OpenAPIFramework {
$refParser.mode === 'dereference'
? $RefParser.dereference(filePath)
: $RefParser.bundle(filePath);
return doc as Promise<OpenAPIV3.Document>;
return doc as Promise<OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1>;
}

private sortApiDocTags(apiDoc: OpenAPIV3.Document): void {
private sortApiDocTags(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1): void {
if (apiDoc && Array.isArray(apiDoc.tags)) {
apiDoc.tags.sort((a, b): number => {
return a.name < b.name ? -1 : 1;
Expand Down
2 changes: 1 addition & 1 deletion src/framework/openapi.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface RoutePair {
openApiRoute: string;
}
export class OpenApiContext {
public readonly apiDoc: OpenAPIV3.Document;
public readonly apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1;
public readonly expressRouteMap = {};
public readonly openApiRouteMap = {};
public readonly routes: RouteMetadata[] = [];
Expand Down
34 changes: 25 additions & 9 deletions src/framework/openapi.schema.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import AjvDraft4, {
import addFormats from 'ajv-formats';
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json
import * as openapi3Schema from './openapi.v3.schema.json';
// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.1/schema.json with dynamic refs replaced due to AJV bug - https://github.com/ajv-validator/ajv/issues/1745
import * as openapi31Schema from './openapi.v3_1.modified.schema.json';
import { OpenAPIV3 } from './types.js';

import Ajv2020 from 'ajv/dist/2020';

export interface OpenAPISchemaValidatorOpts {
version: string;
validateApiSpec: boolean;
Expand All @@ -17,7 +21,6 @@ export class OpenAPISchemaValidator {
private validator: ValidateFunction;
constructor(opts: OpenAPISchemaValidatorOpts) {
const options: Options = {
schemaId: 'id',
allErrors: true,
validateFormats: true,
coerceTypes: false,
Expand All @@ -29,18 +32,31 @@ export class OpenAPISchemaValidator {
options.validateSchema = false;
}

const v = new AjvDraft4(options);
addFormats(v, ['email', 'regex', 'uri', 'uri-reference']);

const ver = opts.version && parseInt(String(opts.version), 10);
const ver = opts.version && parseFloat(String(opts.version));
SF97 marked this conversation as resolved.
Show resolved Hide resolved
if (!ver) throw Error('version missing from OpenAPI specification');
if (ver != 3) throw Error('OpenAPI v3 specification version is required');
if (parseInt(ver.toString()) != 3) throw Error('OpenAPI v3 specification version is required');

let ajvInstance;
let schema;

if (ver === 3) {
schema = openapi3Schema;
ajvInstance = new AjvDraft4(options);
} else if (ver === 3.1) {
schema = openapi31Schema;
ajvInstance = new Ajv2020(options);
ajvInstance.addFormat('media-range', true); // TODO: Validate media-range format as defined in https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf
SF97 marked this conversation as resolved.
Show resolved Hide resolved
} else {
throw new Error('OpenAPI v3 specification 3.0 and 3.1 supported');
}

addFormats(ajvInstance, ['email', 'regex', 'uri', 'uri-reference']);

v.addSchema(openapi3Schema);
this.validator = v.compile(openapi3Schema);
ajvInstance.addSchema(schema);
this.validator = ajvInstance.compile(schema);
}

public validate(openapiDoc: OpenAPIV3.Document): {
public validate(openapiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1): {
errors: Array<ErrorObject> | null;
} {
const valid = this.validator(openapiDoc);
Expand Down
60 changes: 31 additions & 29 deletions src/framework/openapi.spec.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from './types';

export interface Spec {
apiDoc: OpenAPIV3.Document;
apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1;
basePaths: string[];
routes: RouteMetadata[];
serial: number;
Expand All @@ -21,7 +21,7 @@ export interface RouteMetadata {
}

interface DiscoveredRoutes {
apiDoc: OpenAPIV3.Document;
apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1;
basePaths: string[];
routes: RouteMetadata[];
serial: number;
Expand Down Expand Up @@ -52,42 +52,44 @@ export class OpenApiSpecLoader {
const routes: RouteMetadata[] = [];
const toExpressParams = this.toExpressParams;
// const basePaths = this.framework.basePaths;
// let apiDoc: OpenAPIV3.Document = null;
// let apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1 = null;
// let basePaths: string[] = null;
const { apiDoc, basePaths } = await this.framework.initialize({
visitApi(ctx: OpenAPIFrameworkAPIContext): void {
const apiDoc = ctx.getApiDoc();
const basePaths = ctx.basePaths;
for (const bpa of basePaths) {
const bp = bpa.replace(/\/$/, '');
for (const [path, methods] of Object.entries(apiDoc.paths)) {
for (const [method, schema] of Object.entries(methods)) {
if (
method.startsWith('x-') ||
['parameters', 'summary', 'description'].includes(method)
) {
continue;
}
const pathParams = new Set<string>();
const parameters = [...schema.parameters ?? [], ...methods.parameters ?? []]
for (const param of parameters) {
if (param.in === 'path') {
pathParams.add(param.name);
if (apiDoc.paths) {
for (const [path, methods] of Object.entries(apiDoc.paths)) {
for (const [method, schema] of Object.entries(methods)) {
if (
method.startsWith('x-') ||
['parameters', 'summary', 'description'].includes(method)
) {
continue;
}
const pathParams = new Set<string>();
const parameters = [...schema.parameters ?? [], ...methods.parameters ?? []]
for (const param of parameters) {
if (param.in === 'path') {
pathParams.add(param.name);
}
}
const openApiRoute = `${bp}${path}`;
const expressRoute = `${openApiRoute}`
.split(':')
.map(toExpressParams)
.join('\\:');

routes.push({
basePath: bp,
expressRoute,
openApiRoute,
method: method.toUpperCase(),
pathParams: Array.from(pathParams),
});
}
const openApiRoute = `${bp}${path}`;
const expressRoute = `${openApiRoute}`
.split(':')
.map(toExpressParams)
.join('\\:');

routes.push({
basePath: bp,
expressRoute,
openApiRoute,
method: method.toUpperCase(),
pathParams: Array.from(pathParams),
});
}
}
}
Expand Down
Loading