From 2512b079c881c722598a0bd4ac3a9e8650343efb Mon Sep 17 00:00:00 2001 From: Hans Pagel Date: Tue, 27 Aug 2024 11:20:37 +0200 Subject: [PATCH] feat: add a migration layer for `@apidevtools/swagger-parser` (#140) * chore: add a simple migration layer (wip) * feat: add dereference to the migration layer * feat: load files, add throwOnError to load * chore: clean up * fix: tests --- .../openapi-parser/src/configuration/index.ts | 1 + .../src/lib/Validator/Validator.ts | 8 ++ packages/openapi-parser/src/types/index.ts | 1 + .../src/utils/load/load.test.ts | 22 +++ .../openapi-parser/src/utils/load/load.ts | 70 +++++++-- .../src/utils/workThroughQueue.test.ts | 6 +- .../openapi-parser/tests/migration-layer.json | 23 +++ .../tests/migration-layer.test.ts | 136 ++++++++++++++++++ 8 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 packages/openapi-parser/tests/migration-layer.json create mode 100644 packages/openapi-parser/tests/migration-layer.test.ts diff --git a/packages/openapi-parser/src/configuration/index.ts b/packages/openapi-parser/src/configuration/index.ts index 76432e1..285a837 100644 --- a/packages/openapi-parser/src/configuration/index.ts +++ b/packages/openapi-parser/src/configuration/index.ts @@ -28,6 +28,7 @@ export const ERRORS = { INVALID_REFERENCE: 'Can’t resolve reference: %s', EXTERNAL_REFERENCE_NOT_FOUND: 'Can’t resolve external reference: %s', FILE_DOES_NOT_EXIST: 'File does not exist: %s', + NO_CONTENT: 'No content found', } as const export type VALIDATOR_ERROR = keyof typeof ERRORS diff --git a/packages/openapi-parser/src/lib/Validator/Validator.ts b/packages/openapi-parser/src/lib/Validator/Validator.ts index 41d5afc..0f75525 100644 --- a/packages/openapi-parser/src/lib/Validator/Validator.ts +++ b/packages/openapi-parser/src/lib/Validator/Validator.ts @@ -89,6 +89,10 @@ export class Validator { // AnyObject is not supported if (!version) { + if (options?.throwOnError) { + throw new Error(ERRORS.OPENAPI_VERSION_NOT_SUPPORTED) + } + return { valid: false, errors: transformErrors( @@ -105,6 +109,10 @@ export class Validator { // Error handling if (validateSchema.errors) { if (validateSchema.errors.length > 0) { + if (options?.throwOnError) { + throw new Error(validateSchema.errors[0]) + } + return { valid: false, errors: transformErrors(entrypoint, validateSchema.errors), diff --git a/packages/openapi-parser/src/types/index.ts b/packages/openapi-parser/src/types/index.ts index be23697..6070e58 100644 --- a/packages/openapi-parser/src/types/index.ts +++ b/packages/openapi-parser/src/types/index.ts @@ -6,6 +6,7 @@ export type AnyObject = Record export type LoadResult = { filesystem: Filesystem + errors?: ErrorObject[] } export type ValidateResult = { diff --git a/packages/openapi-parser/src/utils/load/load.test.ts b/packages/openapi-parser/src/utils/load/load.test.ts index e5b6c1b..9ac4796 100644 --- a/packages/openapi-parser/src/utils/load/load.test.ts +++ b/packages/openapi-parser/src/utils/load/load.test.ts @@ -395,4 +395,26 @@ describe('load', async () => { }, }) }) + + it('returns an error', async () => { + const { errors } = await load('INVALID', { + plugins: [readFiles(), fetchUrls()], + }) + + expect(errors).toMatchObject([ + { + code: 'EXTERNAL_REFERENCE_NOT_FOUND', + message: 'Can’t resolve external reference: INVALID', + }, + ]) + }) + + it('throws an error', async () => { + expect(async () => { + await load('INVALID', { + plugins: [readFiles(), fetchUrls()], + throwOnError: true, + }) + }).rejects.toThrowError('Can’t resolve external reference: INVALID') + }) }) diff --git a/packages/openapi-parser/src/utils/load/load.ts b/packages/openapi-parser/src/utils/load/load.ts index bcd6598..cea341d 100644 --- a/packages/openapi-parser/src/utils/load/load.ts +++ b/packages/openapi-parser/src/utils/load/load.ts @@ -1,4 +1,11 @@ -import type { Filesystem, LoadResult } from '../../types' +import { ERRORS } from '../../configuration' +import type { + AnyObject, + ErrorObject, + Filesystem, + LoadResult, + ThrowOnErrorOption, +} from '../../types' import { getEntrypoint } from '../getEntrypoint' import { getListOfReferences } from '../getListOfReferences' import { makeFilesystem } from '../makeFilesystem' @@ -18,8 +25,10 @@ export async function load( plugins?: LoadPlugin[] filename?: string filesystem?: Filesystem - }, + } & ThrowOnErrorOption, ): Promise { + const errors: ErrorObject[] = [] + // Don’t load a reference twice, check the filesystem before fetching something if ( options?.filesystem && @@ -27,17 +36,53 @@ export async function load( ) { return { filesystem: options.filesystem, + errors, } } // Check whether the value is an URL or file path const plugin = options?.plugins?.find((plugin) => plugin.check(value)) - const content = normalize(plugin ? await plugin.get(value) : value) + + let content: AnyObject + + if (plugin) { + try { + content = normalize(await plugin.get(value)) + } catch (error) { + if (options?.throwOnError) { + throw new Error( + ERRORS.EXTERNAL_REFERENCE_NOT_FOUND.replace('%s', value), + ) + } + + errors.push({ + code: 'EXTERNAL_REFERENCE_NOT_FOUND', + message: ERRORS.EXTERNAL_REFERENCE_NOT_FOUND.replace('%s', value), + }) + + return { + filesystem: [], + errors, + } + } + } else { + content = normalize(value) + } // No content if (content === undefined) { + if (options?.throwOnError) { + throw new Error('No content to load') + } + + errors.push({ + code: 'NO_CONTENT', + message: ERRORS.NO_CONTENT, + }) + return { filesystem: [], + errors, } } @@ -56,6 +101,7 @@ export async function load( if (listOfReferences.length === 0) { return { filesystem, + errors, } } @@ -79,12 +125,17 @@ export async function load( continue } - const { filesystem: referencedFiles } = await load(target, { - ...options, - // Make the filename the exact same value as the $ref - // TODO: This leads to problems, if there are multiple references with the same file name but in different folders - filename: reference, - }) + const { filesystem: referencedFiles, errors: newErrors } = await load( + target, + { + ...options, + // Make the filename the exact same value as the $ref + // TODO: This leads to problems, if there are multiple references with the same file name but in different folders + filename: reference, + }, + ) + + errors.push(...newErrors) filesystem = [ ...filesystem, @@ -99,5 +150,6 @@ export async function load( return { filesystem, + errors, } } diff --git a/packages/openapi-parser/src/utils/workThroughQueue.test.ts b/packages/openapi-parser/src/utils/workThroughQueue.test.ts index 72bb071..bdfd2c8 100644 --- a/packages/openapi-parser/src/utils/workThroughQueue.test.ts +++ b/packages/openapi-parser/src/utils/workThroughQueue.test.ts @@ -25,6 +25,7 @@ describe('workThroughQueue', () => { }) expect(await result).toStrictEqual({ + errors: [], filesystem: [ { dir: './', @@ -65,9 +66,9 @@ describe('workThroughQueue', () => { }) expect(await result).toStrictEqual({ + errors: [], valid: true, version: '3.1', - errors: [], filesystem: [ { dir: './', @@ -124,10 +125,10 @@ describe('workThroughQueue', () => { }) expect(await result).toStrictEqual({ + errors: [], specificationType: 'openapi', specificationVersion: '3.1.0', version: '3.1', - errors: [], filesystem: [ { dir: './', @@ -184,6 +185,7 @@ describe('workThroughQueue', () => { }) expect(await result).toStrictEqual({ + errors: [], version: '3.1', specification: { openapi: '3.1.0', diff --git a/packages/openapi-parser/tests/migration-layer.json b/packages/openapi-parser/tests/migration-layer.json new file mode 100644 index 0000000..b5a6e56 --- /dev/null +++ b/packages/openapi-parser/tests/migration-layer.json @@ -0,0 +1,23 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Hello World", + "version": "1.0.0" + }, + "paths": { + "/foobar": { + "post": { + "requestBody": { + "$ref": "#/components/requestBodies/Foobar" + } + } + } + }, + "components": { + "requestBodies": { + "Foobar": { + "content": {} + } + } + } + } \ No newline at end of file diff --git a/packages/openapi-parser/tests/migration-layer.test.ts b/packages/openapi-parser/tests/migration-layer.test.ts new file mode 100644 index 0000000..730b1df --- /dev/null +++ b/packages/openapi-parser/tests/migration-layer.test.ts @@ -0,0 +1,136 @@ +import OriginalSwaggerParser from '@apidevtools/swagger-parser' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +import { dereference } from '../src/utils/dereference' +import { load } from '../src/utils/load' +import { fetchUrls } from '../src/utils/load/plugins/fetchUrls' +import { readFiles } from '../src/utils/load/plugins/readFiles' +import { validate } from '../src/utils/validate' + +const myAPI = JSON.stringify({ + openapi: '3.1.0', + info: { + title: 'Hello World', + version: '1.0.0', + }, + paths: { + '/foobar': { + post: { + requestBody: { + $ref: '#/components/requestBodies/Foobar', + }, + }, + }, + }, + components: { + requestBodies: { + Foobar: { + content: {}, + }, + }, + }, +}) + +class SwaggerParser { + static async validate(api: string, callback: (err: any, api: any) => void) { + try { + const { filesystem } = await load(api, { + plugins: [fetchUrls(), readFiles()], + throwOnError: true, + }) + + validate(filesystem, { + throwOnError: true, + }).then((result) => { + callback(null, result.schema) + }) + } catch (error) { + callback(error, null) + } + } + + static async dereference(api: string) { + const { filesystem } = await load(api, { + plugins: [fetchUrls(), readFiles()], + throwOnError: true, + }) + + return dereference(filesystem).then((result) => result.schema) + } +} + +// https://github.com/APIDevTools/swagger-parser?tab=readme-ov-file#example +describe('validate', async () => { + it('validates', async () => { + return new Promise((resolve, reject) => { + SwaggerParser.validate(myAPI, (err, api) => { + if (err) { + reject(err) + } else { + expect(api.info.title).toBe('Hello World') + expect(api.info.version).toBe('1.0.0') + + resolve(null) + } + }) + }) + }) + + it('throws an error for invalid documents', async () => { + return new Promise((resolve, reject) => { + SwaggerParser.validate('invalid', (err) => { + if (err) { + resolve(null) + } else { + reject() + } + }) + }) + }) +}) + +// https://apitools.dev/swagger-parser/docs/swagger-parser.html#dereferenceapi-options-callback +describe('dereference', () => { + it('dereferences', async () => { + let api = await SwaggerParser.dereference(myAPI) + + // The `api` object is a normal JavaScript object, + // so you can easily access any part of the API using simple dot notation + expect(api?.paths?.['/foobar']?.post?.requestBody?.content).toEqual({}) + }) + + it('dereferences URLs', async () => { + global.fetch = async (url: string) => + ({ + text: async () => { + if (url === 'http://example.com/specification/openapi.yaml') { + return myAPI + } + + throw new Error('Not found') + }, + }) as Response + + let api = await SwaggerParser.dereference( + 'http://example.com/specification/openapi.yaml', + ) + + // The `api` object is a normal JavaScript object, + // so you can easily access any part of the API using simple dot notation + expect(api?.paths?.['/foobar']?.post?.requestBody?.content).toEqual({}) + }) + + it('dereferences files', async () => { + const EXAMPLE_FILE = path.join( + new URL(import.meta.url).pathname, + '../../tests/migration-layer.json', + ) + + let api = await SwaggerParser.dereference(EXAMPLE_FILE) + + // The `api` object is a normal JavaScript object, + // so you can easily access any part of the API using simple dot notation + expect(api?.paths?.['/foobar']?.post?.requestBody?.content).toEqual({}) + }) +})