diff --git a/.changeset/slimy-chefs-hide.md b/.changeset/slimy-chefs-hide.md new file mode 100644 index 0000000..c4fb860 --- /dev/null +++ b/.changeset/slimy-chefs-hide.md @@ -0,0 +1,5 @@ +--- +"@eventcatalog/generator-openapi": patch +--- + +feat(plugin): added ability to resolve $ref values when saving OpenAPI files to the catalog diff --git a/package.json b/package.json index 59528ad..3f1fc26 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "license": "ISC", "devDependencies": { "@types/fs-extra": "^11.0.4", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.7", "@types/node": "^20.16.1", "prettier": "^3.3.3", @@ -37,6 +38,7 @@ "@changesets/cli": "^2.27.7", "@eventcatalog/sdk": "^0.1.4", "chalk": "^4", + "js-yaml": "^4.1.0", "openapi-types": "^12.1.3", "slugify": "^1.6.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bc6ed2..e9fa294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: chalk: specifier: ^4 version: 4.1.2 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 openapi-types: specifier: ^12.1.3 version: 12.1.3 @@ -30,6 +33,9 @@ importers: '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/lodash': specifier: ^4.17.7 version: 4.17.7 @@ -547,6 +553,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} @@ -625,6 +634,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -963,6 +975,10 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -2000,6 +2016,8 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 20.16.3 + '@types/js-yaml@4.0.9': {} + '@types/jsonfile@6.1.4': dependencies: '@types/node': 20.16.3 @@ -2085,6 +2103,8 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -2469,6 +2489,10 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + json-schema-traverse@1.0.0: {} jsonfile@4.0.0: diff --git a/src/index.ts b/src/index.ts index 020d26f..033a272 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,13 @@ import { Domain, Service } from './types'; import { getMessageTypeUtils } from './utils/catalog-shorthand'; import { OpenAPI } from 'openapi-types'; import checkLicense from './utils/checkLicense'; +import yaml from 'js-yaml'; type Props = { services: Service[]; domain?: Domain; debug?: boolean; + saveParsedSpecFile?: boolean; }; export default async (_: any, options: Props) => { @@ -36,7 +38,7 @@ export default async (_: any, options: Props) => { getSpecificationFilesForService, } = utils(process.env.PROJECT_DIR); - const services = options.services ?? []; + const { services = [], saveParsedSpecFile = false } = options; for (const serviceSpec of services) { console.log(chalk.green(`Processing ${serviceSpec.path}`)); @@ -48,8 +50,7 @@ export default async (_: any, options: Props) => { continue; } - const openAPIFile = await readFile(serviceSpec.path, 'utf-8'); - const document = await SwaggerParser.parse(serviceSpec.path); + const document = await SwaggerParser.dereference(serviceSpec.path); const version = document.info.version; const service = buildService(serviceSpec, document); @@ -138,7 +139,7 @@ export default async (_: any, options: Props) => { // add any previous spec files to the list ...serviceSpecificationsFiles, { - content: openAPIFile, + content: saveParsedSpecFile ? getParsedSpecFile(serviceSpec, document) : await getRawSpecFile(serviceSpec), fileName: service.schemaPath, }, ]; @@ -235,3 +236,10 @@ const processMessagesForOpenAPISpec = async (pathToSpec: string, document: OpenA } return { receives, sends: [] }; }; + +const getParsedSpecFile = (service: Service, document: OpenAPI.Document) => { + const isSpecFileJSON = service.path.endsWith('.json'); + return isSpecFileJSON ? JSON.stringify(document, null, 2) : yaml.dump(document, { noRefs: true }); +}; + +const getRawSpecFile = async (service: Service) => await readFile(service.path, 'utf8'); diff --git a/src/test/openapi-files/ref-example-signup-message.yml b/src/test/openapi-files/ref-example-signup-message.yml new file mode 100644 index 0000000..e6723ff --- /dev/null +++ b/src/test/openapi-files/ref-example-signup-message.yml @@ -0,0 +1,20 @@ +type: object +properties: + id: + type: string + name: + type: string + description: + type: string + price: + type: number + format: float + category: + type: string + imageUrl: + type: string +required: + - id + - name + - price + - category diff --git a/src/test/openapi-files/ref-example-with-resolved-refs.json b/src/test/openapi-files/ref-example-with-resolved-refs.json new file mode 100644 index 0000000..982af77 --- /dev/null +++ b/src/test/openapi-files/ref-example-with-resolved-refs.json @@ -0,0 +1,123 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test Service", + "version": "1.1.0" + }, + "paths": { + "/signup": { + "post": { + "operationId": "usersignup", + "summary": "List all users", + "description": "Returns a list of all users.\nThis operation is **deprecated**.\n", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category": { + "type": "string" + }, + "imageUrl": { + "type": "string" + } + }, + "required": ["id", "name", "price", "category"] + } + } + } + }, + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category": { + "type": "string" + }, + "imageUrl": { + "type": "string" + } + }, + "required": ["id", "name", "price", "category"] + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserSignup": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "price": { + "type": "number", + "format": "float" + }, + "category": { + "type": "string" + }, + "imageUrl": { + "type": "string" + } + }, + "required": ["id", "name", "price", "category"] + }, + "Date": { + "type": "string", + "format": "date" + }, + "DateWithExample": { + "description": "Date schema extended with a `default` value... Or not?", + "default": "2000-01-01T00:00:00.000Z", + "type": "string", + "format": "date" + } + } + } +} diff --git a/src/test/openapi-files/ref-example-with-resolved-refs.yml b/src/test/openapi-files/ref-example-with-resolved-refs.yml new file mode 100644 index 0000000..7839fcb --- /dev/null +++ b/src/test/openapi-files/ref-example-with-resolved-refs.yml @@ -0,0 +1,95 @@ +openapi: 3.0.0 +info: + title: Test Service + version: 1.1.0 +paths: + /signup: + post: + operationId: usersignup + summary: List all users + description: | + Returns a list of all users. + This operation is **deprecated**. + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + price: + type: number + format: float + category: + type: string + imageUrl: + type: string + required: + - id + - name + - price + - category + responses: + '200': + description: A list of users + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + price: + type: number + format: float + category: + type: string + imageUrl: + type: string + required: + - id + - name + - price + - category +components: + schemas: + UserSignup: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + price: + type: number + format: float + category: + type: string + imageUrl: + type: string + required: + - id + - name + - price + - category + Date: + type: string + format: date + DateWithExample: + description: Date schema extended with a `default` value... Or not? + default: 2000-01-01T00:00:00.000Z + type: string + format: date diff --git a/src/test/openapi-files/ref-example.json b/src/test/openapi-files/ref-example.json new file mode 100644 index 0000000..a1ed10e --- /dev/null +++ b/src/test/openapi-files/ref-example.json @@ -0,0 +1,56 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test Service", + "version": "1.1.0" + }, + "paths": { + "/signup": { + "post": { + "operationId": "usersignup", + "summary": "List all users", + "description": "Returns a list of all users.\nThis operation is **deprecated**.\n", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSignup" + } + } + } + }, + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSignup" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserSignup": { + "$ref": "ref-example-signup-message.yml" + }, + "Date": { + "type": "string", + "format": "date" + }, + "DateWithExample": { + "$ref": "#/components/schemas/Date", + "description": "Date schema extended with a `default` value... Or not?", + "default": "2000-01-01T00:00:00.000Z" + } + } + } +} diff --git a/src/test/openapi-files/ref-example.yml b/src/test/openapi-files/ref-example.yml new file mode 100644 index 0000000..162fc87 --- /dev/null +++ b/src/test/openapi-files/ref-example.yml @@ -0,0 +1,42 @@ +openapi: '3.0.0' +info: + title: Test Service + version: 1.1.0 + +paths: + /signup: + post: + operationId: usersignup + summary: List all users + description: | + Returns a list of all users. + This operation is **deprecated**. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSignup' + + responses: + 200: + description: A list of users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserSignup' + +components: + schemas: + UserSignup: + $ref: 'ref-example-signup-message.yml' + + Date: + type: string + format: date + + DateWithExample: + $ref: '#/components/schemas/Date' + description: Date schema extended with a `default` value... Or not? + default: 2000-01-01 diff --git a/src/test/plugin.test.ts b/src/test/plugin.test.ts index 780b5b1..57ec4a6 100644 --- a/src/test/plugin.test.ts +++ b/src/test/plugin.test.ts @@ -276,6 +276,48 @@ describe('OpenAPI EventCatalog Plugin', () => { expect(schema).toBeDefined(); }); + it('the original openapi file is added to the service by default instead of parsed version', async () => { + const { getService } = utils(catalogDir); + await plugin(config, { services: [{ path: join(openAPIExamples, 'petstore.yml'), id: 'swagger-petstore' }] }); + + const service = await getService('swagger-petstore', '1.0.0'); + + expect(service.schemaPath).toEqual('petstore.yml'); + + const schema = await fs.readFile(join(catalogDir, 'services', 'swagger-petstore', 'petstore.yml'), 'utf8'); + expect(schema).toBeDefined(); + }); + + it('the original openapi file is added to the service instead of parsed version', async () => { + const { getService } = utils(catalogDir); + await plugin(config, { + services: [{ path: join(openAPIExamples, 'petstore.yml'), id: 'swagger-petstore' }], + saveParsedSpecFile: false, + }); + + const service = await getService('swagger-petstore', '1.0.0'); + + expect(service.schemaPath).toEqual('petstore.yml'); + + const schema = await fs.readFile(join(catalogDir, 'services', 'swagger-petstore', 'petstore.yml'), 'utf8'); + expect(schema).toBeDefined(); + }); + + it('when savedParsedSpecFile is true, the openapi is parsed and refs are resolved', async () => { + const { getService } = utils(catalogDir); + await plugin(config, { + services: [{ path: join(openAPIExamples, 'petstore.yml'), id: 'swagger-petstore' }], + saveParsedSpecFile: true, + }); + + const service = await getService('swagger-petstore', '1.0.0'); + + expect(service.schemaPath).toEqual('petstore.yml'); + + const schema = await fs.readFile(join(catalogDir, 'services', 'swagger-petstore', 'petstore.yml'), 'utf8'); + expect(schema).toBeDefined(); + }); + it('the openapi file is added to the specifications list in eventcatalog', async () => { const { getService, writeService } = utils(catalogDir); @@ -698,5 +740,69 @@ describe('OpenAPI EventCatalog Plugin', () => { }); }); }); + + describe('$ref', () => { + it('when saveParsedSpecFile is set, the OpenAPI files with $ref are resolved and added to the catalog', async () => { + const { getService, getCommand } = utils(catalogDir); + + await plugin(config, { + services: [{ path: join(openAPIExamples, 'ref-example.yml'), id: 'test-service' }], + saveParsedSpecFile: true, + }); + + const service = await getService('test-service', '1.1.0'); + const event = await getCommand('usersignup', '1.1.0'); + + expect(service).toBeDefined(); + expect(event).toBeDefined(); + expect(event.schemaPath).toEqual('request-body.json'); + }); + + it('when saveParsedSpecFile is set, the OpenApi saved to the service $ref values are resolved', async () => { + await plugin(config, { + services: [{ path: join(openAPIExamples, 'ref-example.yml'), id: 'Test Service' }], + saveParsedSpecFile: true, + }); + + const asyncAPIFile = (await fs.readFile(join(catalogDir, 'services', 'Test Service', 'ref-example.yml'))).toString(); + const expected = (await fs.readFile(join(openAPIExamples, 'ref-example-with-resolved-refs.yml'))).toString(); + + // Normalize line endings + const normalizeLineEndings = (str: string) => str.replace(/\r\n/g, '\n'); + + expect(normalizeLineEndings(asyncAPIFile)).toEqual(normalizeLineEndings(expected)); + }); + + it('when savedParsedSpecFile is set, the OpenAPI files with $ref are resolved and added to the catalog', async () => { + const { getService, getCommand } = utils(catalogDir); + + await plugin(config, { + services: [{ path: join(openAPIExamples, 'ref-example.json'), id: 'test-service' }], + saveParsedSpecFile: true, + }); + + const service = await getService('test-service', '1.1.0'); + const event = await getCommand('usersignup', '1.1.0'); + + expect(service).toBeDefined(); + expect(event).toBeDefined(); + expect(event.schemaPath).toEqual('request-body.json'); + }); + + it('when savedParsedSpecFile is set, the OpenApi has any $ref these are not saved to the service. The servive AsyncAPI is has no $ref', async () => { + await plugin(config, { + services: [{ path: join(openAPIExamples, 'ref-example.json'), id: 'Test Service' }], + saveParsedSpecFile: true, + }); + + const asyncAPIFile = (await fs.readFile(join(catalogDir, 'services', 'Test Service', 'ref-example.json'))).toString(); + const expected = (await fs.readFile(join(openAPIExamples, 'ref-example-with-resolved-refs.json'))).toString(); + + // Normalize line endings + const normalizeLineEndings = (str: string) => str.replace(' ', '').replace(/\r\n/g, '\n').replace(/\s+/g, ''); + + expect(normalizeLineEndings(asyncAPIFile)).toEqual(normalizeLineEndings(expected)); + }); + }); }); });