diff --git a/package.json b/package.json index 59528ad..f8378b8 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,7 +38,9 @@ "@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" + "slugify": "^1.6.6", + "zod": "^3.23.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bc6ed2..13d0d46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,16 +20,25 @@ 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 slugify: specifier: ^1.6.6 version: 1.6.6 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@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 +556,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 +637,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 +978,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==} @@ -1549,6 +1568,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2000,6 +2022,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 +2109,8 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + array-union@2.1.0: {} assertion-error@2.0.1: {} @@ -2469,6 +2495,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: @@ -2984,3 +3014,5 @@ snapshots: yallist@2.1.2: {} yocto-queue@0.1.0: {} + + zod@3.23.8: {} diff --git a/src/index.ts b/src/index.ts index 020d26f..766baf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,17 +7,42 @@ import { defaultMarkdown as generateMarkdownForDomain } from './utils/domains'; import { buildService } from './utils/services'; import { buildMessage } from './utils/messages'; import { getOperationsByType } from './utils/openapi'; -import { Domain, Service } from './types'; +import { Service } from './types'; import { getMessageTypeUtils } from './utils/catalog-shorthand'; import { OpenAPI } from 'openapi-types'; import checkLicense from './utils/checkLicense'; - -type Props = { - services: Service[]; - domain?: Domain; - debug?: boolean; +import yaml from 'js-yaml'; +import { z } from 'zod'; + +const optionsSchema = z.object({ + services: z.array( + z.object({ + id: z.string({ required_error: 'The service id is required. please provide the service id' }), + path: z.string({ required_error: 'The service path is required. please provide the path to specification file' }), + name: z.string().optional(), + }), + { message: 'Please provide correct services configuration' } + ), + domain: z + .object({ + id: z.string({ required_error: 'The domain id is required. please provide a domain id' }), + name: z.string({ required_error: 'The domain name is required. please provide a domain name' }), + version: z.string({ required_error: 'The domain version is required. please provide a domain version' }), + }) + .optional(), + debug: z.boolean().optional(), + saveParsedSpecFile: z.boolean({ invalid_type_error: 'The saveParsedSpecFile is not a boolean in options' }).optional(), +}); + +type Props = z.infer; + +const validateOptions = (options: Props) => { + try { + optionsSchema.parse(options); + } catch (error: any) { + if (error instanceof z.ZodError) throw new Error(JSON.stringify(error.issues, null, 2)); + } }; - export default async (_: any, options: Props) => { if (!process.env.PROJECT_DIR) { throw new Error('Please provide catalog url (env variable PROJECT_DIR)'); @@ -36,7 +61,8 @@ export default async (_: any, options: Props) => { getSpecificationFilesForService, } = utils(process.env.PROJECT_DIR); - const services = options.services ?? []; + const { services = [], saveParsedSpecFile = false } = options; + validateOptions(options); for (const serviceSpec of services) { console.log(chalk.green(`Processing ${serviceSpec.path}`)); @@ -48,7 +74,6 @@ export default async (_: any, options: Props) => { continue; } - const openAPIFile = await readFile(serviceSpec.path, 'utf-8'); const document = await SwaggerParser.parse(serviceSpec.path); const version = document.info.version; @@ -138,7 +163,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 +260,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, 4) : yaml.dump(document, { noRefs: true }); +}; + +const getRawSpecFile = async (service: Service) => await readFile(service.path, 'utf8'); diff --git a/src/test/plugin.test.ts b/src/test/plugin.test.ts index 780b5b1..719e845 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('the original openapi file is not added but the parsed version', 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); @@ -510,6 +552,77 @@ describe('OpenAPI EventCatalog Plugin', () => { expect(service).toBeDefined(); }); + + it('[id] if the `id` not provided in the service config options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { + services: [ + { + path: join(openAPIExamples, 'petstore.yml'), + } as any, + ], + }) + ).rejects.toThrow('The service id is required'); + }); + it('[services] if the `services` not provided in options, The generator throw an explicit error', async () => { + await expect(plugin(config, {} as any)).rejects.toThrow('Please provide correct services configuration'); + }); + it('[services] if the `services` is undefiend in options, The generator throw an explicit error', async () => { + await expect(plugin(config, { services: undefined } as any)).rejects.toThrow( + 'Please provide correct services configuration' + ); + }); + it('[services::path] if the `services::path` not provided in options, The generator throw an explicit error', async () => { + await expect(plugin(config, { services: [{ id: 'service_id' }] } as any)).rejects.toThrow( + 'The service path is required. please provide the path to specification file' + ); + }); + it('[services::id] if the `services::id` not provided in options, The generator throw an explicit error', async () => { + await expect(plugin(config, { services: [{ path: 'path/to/spec' }] } as any)).rejects.toThrow( + 'The service id is required. please provide the service id' + ); + }); + it('[path] if the `path` not provided in service config options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { + services: [ + { + name: 'Awesome account service', + id: 'awsome-service', + } as any, + ], + }) + ).rejects.toThrow('The service path is required. please provide the path to specification file'); + }); + it('[services::saveParsedSpecFile] if the `services::saveParsedSpecFile` not a boolean in options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { services: [{ path: 'path/to/spec', id: 'sevice_id' }], saveParsedSpecFile: 'true' } as any) + ).rejects.toThrow('The saveParsedSpecFile is not a boolean in options'); + }); + it('[domain::id] if the `domain::id` not provided in options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { + domain: { name: 'domain_name', version: '1.0.0' }, + services: [{ path: 'path/to/spec', id: 'sevice_id' }], + } as any) + ).rejects.toThrow('The domain id is required. please provide a domain id'); + }); + it('[domain::name] if the `domain::name` not provided in options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { + domain: { id: 'domain_name', version: '1.0.0' }, + services: [{ path: 'path/to/spec', id: 'sevice_id' }], + } as any) + ).rejects.toThrow('The domain name is required. please provide a domain name'); + }); + it('[domain::version] if the `domain::version` not provided in options, The generator throw an explicit error', async () => { + await expect( + plugin(config, { + domain: { id: 'domain_name', name: 'domain_name' }, + services: [{ path: 'path/to/spec', id: 'sevice_id' }], + } as any) + ).rejects.toThrow('The domain version is required. please provide a domain version'); + }); }); }); });