From 2b843258f640f9ce43388e6ac59ad91228a8853e Mon Sep 17 00:00:00 2001 From: Elbaz Date: Wed, 15 May 2024 13:40:51 +0300 Subject: [PATCH 1/7] Add support to webhooks --- package.json | 1 + pnpm-lock.yaml | 82 ++- src/serverless-openapi-typescript.ts | 522 +++++++++--------- test/fixtures/expect-openapi-custom-tags.yml | 1 + test/fixtures/expect-openapi-full.yml | 31 +- .../expect-openapi-hyphenated-functions.yml | 1 + .../expect-openapi-query-param-type.yml | 1 + test/fixtures/expect-openapi-webhooks.yml | 40 ++ test/serverless-full/api.d.ts | 91 +-- test/serverless-full/resources/serverless.yml | 10 + test/serverless-openapi-typescript.spec.ts | 140 ++--- test/serverless-webhooks/api.d.ts | 8 + .../resources/serverless.yml | 30 + 13 files changed, 570 insertions(+), 388 deletions(-) create mode 100644 test/fixtures/expect-openapi-webhooks.yml create mode 100644 test/serverless-webhooks/api.d.ts create mode 100644 test/serverless-webhooks/resources/serverless.yml diff --git a/package.json b/package.json index be82676..6a62401 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "ts-json-schema-generator": "^1.1.2" }, "devDependencies": { + "@types/node": "20.12.11", "@conqa/serverless-openapi-documentation": "^1.1.0", "@types/jest": "^27.0.1", "@types/serverless": "^1.78.35", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52a2b6f..392c3fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,8 +1,9 @@ -lockfileVersion: 5.3 +lockfileVersion: 5.4 specifiers: '@conqa/serverless-openapi-documentation': ^1.1.0 '@types/jest': ^27.0.1 + '@types/node': 20.12.11 '@types/serverless': ^1.78.35 deepdash: ^5.3.9 jest: ^27.0.6 @@ -22,10 +23,11 @@ dependencies: devDependencies: '@conqa/serverless-openapi-documentation': 1.1.0 '@types/jest': 27.5.2 + '@types/node': 20.12.11 '@types/serverless': 1.78.44 jest: 27.5.1 serverless: 2.72.3 - ts-jest: 27.1.5_3e98952c91d0ad38e7beba6fb8181295 + ts-jest: 27.1.5_h2mjkler2cwtrz56xjx3qgassu typescript: 4.8.4 packages: @@ -203,6 +205,8 @@ packages: resolution: {integrity: sha512-qpVT7gtuOLjWeDTKLkJ6sryqLliBaFpAtGeqw5cs5giLldvh+Ch0plqnUMKoVAUS6ZEueQQiZV+p5pxtPitEsA==} engines: {node: '>=6.0.0'} hasBin: true + dependencies: + '@babel/types': 7.19.4 dev: true /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.19.3: @@ -407,7 +411,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 jest-message-util: 27.5.1 jest-util: 27.5.1 @@ -428,7 +432,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.8.1 @@ -465,7 +469,7 @@ packages: dependencies: '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 jest-mock: 27.5.1 dev: true @@ -475,7 +479,7 @@ packages: dependencies: '@jest/types': 27.5.1 '@sinonjs/fake-timers': 8.1.0 - '@types/node': 18.8.4 + '@types/node': 20.12.11 jest-message-util: 27.5.1 jest-mock: 27.5.1 jest-util: 27.5.1 @@ -504,7 +508,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 @@ -588,7 +592,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 '@types/yargs': 16.0.4 chalk: 4.1.2 dev: true @@ -1067,14 +1071,14 @@ packages: dependencies: '@types/http-cache-semantics': 4.0.1 '@types/keyv': 3.1.4 - '@types/node': 18.8.4 + '@types/node': 20.12.11 '@types/responselike': 1.0.0 dev: true /@types/graceful-fs/4.1.5: resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} dependencies: - '@types/node': 18.8.4 + '@types/node': 20.12.11 dev: true /@types/http-cache-semantics/4.0.1: @@ -1111,7 +1115,7 @@ packages: /@types/keyv/3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 18.8.4 + '@types/node': 20.12.11 dev: true /@types/lodash/4.14.186: @@ -1122,8 +1126,10 @@ packages: resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} dev: true - /@types/node/18.8.4: - resolution: {integrity: sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==} + /@types/node/20.12.11: + resolution: {integrity: sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==} + dependencies: + undici-types: 5.26.5 dev: true /@types/prettier/2.7.1: @@ -1133,7 +1139,7 @@ packages: /@types/responselike/1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: - '@types/node': 18.8.4 + '@types/node': 20.12.11 dev: true /@types/serverless/1.78.44: @@ -2104,12 +2110,22 @@ packages: /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.0.0 dev: true /debug/3.1.0: resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: ms: 2.0.0 dev: true @@ -2398,6 +2414,7 @@ packages: yeast: 0.1.2 transitivePeerDependencies: - bufferutil + - supports-color - utf-8-validate dev: true @@ -3104,6 +3121,8 @@ packages: dependencies: '@sindresorhus/is': 0.14.0 '@szmarczak/http-timer': 1.1.2 + '@types/keyv': 3.1.4 + '@types/responselike': 1.0.0 cacheable-request: 6.1.0 decompress-response: 3.3.0 duplexer3: 0.1.5 @@ -3637,7 +3656,7 @@ packages: '@jest/environment': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 @@ -3762,7 +3781,7 @@ packages: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 jest-mock: 27.5.1 jest-util: 27.5.1 jsdom: 16.7.0 @@ -3780,7 +3799,7 @@ packages: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 jest-mock: 27.5.1 jest-util: 27.5.1 dev: true @@ -3796,7 +3815,7 @@ packages: dependencies: '@jest/types': 27.5.1 '@types/graceful-fs': 4.1.5 - '@types/node': 18.8.4 + '@types/node': 20.12.11 anymatch: 3.1.2 fb-watchman: 2.0.2 graceful-fs: 4.2.10 @@ -3818,7 +3837,7 @@ packages: '@jest/source-map': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 co: 4.6.0 expect: 27.5.1 @@ -3873,7 +3892,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 dev: true /jest-pnp-resolver/1.2.2_jest-resolve@27.5.1: @@ -3929,7 +3948,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 emittery: 0.8.1 graceful-fs: 4.2.10 @@ -3986,7 +4005,7 @@ packages: resolution: {integrity: sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: - '@types/node': 18.8.4 + '@types/node': 20.12.11 graceful-fs: 4.2.10 dev: true @@ -4025,7 +4044,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 chalk: 4.1.2 ci-info: 3.5.0 graceful-fs: 4.2.10 @@ -4050,7 +4069,7 @@ packages: dependencies: '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 18.8.4 + '@types/node': 20.12.11 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 27.5.1 @@ -4061,7 +4080,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 18.8.4 + '@types/node': 20.12.11 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -4263,6 +4282,8 @@ packages: uuid: 3.4.0 optionalDependencies: snappy: 6.3.5 + transitivePeerDependencies: + - supports-color dev: true /keyv/3.1.0: @@ -5168,7 +5189,7 @@ packages: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 18.8.4 + '@types/node': 20.12.11 long: 4.0.0 dev: true @@ -5739,6 +5760,7 @@ packages: to-array: 0.1.4 transitivePeerDependencies: - bufferutil + - supports-color - utf-8-validate dev: true @@ -5748,6 +5770,8 @@ packages: component-emitter: 1.3.0 debug: 3.1.0 isarray: 2.0.1 + transitivePeerDependencies: + - supports-color dev: true /sort-keys-length/1.0.1: @@ -6228,7 +6252,7 @@ packages: resolution: {integrity: sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==} dev: true - /ts-jest/27.1.5_3e98952c91d0ad38e7beba6fb8181295: + /ts-jest/27.1.5_h2mjkler2cwtrz56xjx3qgassu: resolution: {integrity: sha512-Xv6jBQPoBEvBq/5i2TeSG9tt/nqkbpcurrEG1b+2yfBrcJelOZF9Ml6dmyMh7bcW9JyFbRYpR5rxROSlBLTZHA==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} hasBin: true @@ -6357,6 +6381,10 @@ packages: through: 2.3.8 dev: true + /undici-types/5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + /uni-global/1.0.0: resolution: {integrity: sha512-WWM3HP+siTxzIWPNUg7hZ4XO8clKi6NoCAJJWnuRL+BAqyFXF8gC03WNyTefGoUXYc47uYgXxpKLIEvo65PEHw==} dependencies: diff --git a/src/serverless-openapi-typescript.ts b/src/serverless-openapi-typescript.ts index 09d9ffb..8f69f78 100644 --- a/src/serverless-openapi-typescript.ts +++ b/src/serverless-openapi-typescript.ts @@ -1,106 +1,123 @@ -import type Serverless from "serverless"; -import fs from "fs"; -import yaml from "js-yaml"; -import {SchemaGenerator, createGenerator} from "ts-json-schema-generator"; -import {upperFirst, camelCase, mergeWith, set, isArray, get, isEmpty, kebabCase} from "lodash" ; -import {ApiGatewayEvent} from "serverless/plugins/aws/package/compile/events/apiGateway/lib/validate"; -import { mapKeysDeep, mapValuesDeep} from 'deepdash/standalone' +import type Serverless from 'serverless'; +import fs from 'fs'; +import yaml from 'js-yaml'; +import { SchemaGenerator, createGenerator } from 'ts-json-schema-generator'; +import { upperFirst, camelCase, mergeWith, set, isArray, get, isEmpty, kebabCase } from 'lodash' ; +import { ApiGatewayEvent } from 'serverless/plugins/aws/package/compile/events/apiGateway/lib/validate'; +import { mapKeysDeep, mapValuesDeep } from 'deepdash/standalone'; interface Options { - typescriptApiPath?: string; - tsconfigPath?: string; + typescriptApiPath?: string; + tsconfigPath?: string; } type HttpEvent = ApiGatewayEvent['http'] & { - documentation?: any; - private?: boolean; + documentation?: any; + private?: boolean; } export default class ServerlessOpenapiTypeScript { - private readonly functionsMissingDocumentation: string[]; - private readonly disable: boolean; - private hooks: { [hook: string]: () => {}}; - private typescriptApiModelPath: string; - private tsconfigPath: string; - private schemaGenerator: SchemaGenerator; - - constructor(private serverless: Serverless, private options: Options) { - this.assertPluginOrder(); - - this.initOptions(options); - this.functionsMissingDocumentation = []; - - if (!this.serverless.service.custom?.documentation) { - this.log( - `Disabling OpenAPI generation for ${this.serverless.service.service} - no 'custom.documentation' attribute found` - ); - this.disable = true; - delete this.serverless.pluginManager.hooks['openapi:generate:serverless']; - } - - if (!this.disable) { - this.hooks = { - 'before:openapi:generate:serverless': this.populateServerlessWithModels.bind(this), - 'after:openapi:generate:serverless': this.postProcessOpenApi.bind(this) - }; - } - } - - initOptions(options) { - this.options = options || {}; - this.typescriptApiModelPath = this.options.typescriptApiPath || 'api.d.ts'; - this.tsconfigPath = this.options.tsconfigPath || 'tsconfig.json'; + private readonly functionsMissingDocumentation: string[]; + private readonly disable: boolean; + private hooks: { [hook: string]: () => {} }; + private webhookEntries: Record = {}; + private typescriptApiModelPath: string; + private tsconfigPath: string; + private schemaGenerator: SchemaGenerator; + + constructor(private serverless: Serverless, private options: Options) { + this.assertPluginOrder(); + + this.initOptions(options); + this.functionsMissingDocumentation = []; + + if (!this.serverless.service.custom?.documentation) { + this.log( + `Disabling OpenAPI generation for ${this.serverless.service.service} - no 'custom.documentation' attribute found` + ); + this.disable = true; + delete this.serverless.pluginManager.hooks['openapi:generate:serverless']; } - assertPluginOrder() { - if (!this.serverless.pluginManager.hooks['openapi:generate:serverless']) { - throw new Error( - 'Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation' - ); - } + if (!this.disable) { + this.hooks = { + 'before:openapi:generate:serverless': this.populateServerlessWithModels.bind(this), + 'after:openapi:generate:serverless': this.postProcessOpenApi.bind(this) + }; } - - get functions() { - return this.serverless.service.functions || {}; + } + + initOptions(options) { + this.options = options || {}; + this.typescriptApiModelPath = this.options.typescriptApiPath || 'api.d.ts'; + this.tsconfigPath = this.options.tsconfigPath || 'tsconfig.json'; + } + + assertPluginOrder() { + if (!this.serverless.pluginManager.hooks['openapi:generate:serverless']) { + throw new Error( + 'Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation' + ); } - - log(msg) { - this.serverless.cli.log(`[serverless-openapi-typescript] ${msg}`); - } - - async populateServerlessWithModels() { - this.log('Scanning functions for documentation attribute'); - Object.keys(this.functions).forEach(functionName => { - this.functions[functionName]?.events?.forEach((event: ApiGatewayEvent) => { - const httpEvent = event.http as HttpEvent; - if (httpEvent) { - if (httpEvent.documentation) { - this.log(`Generating docs for ${functionName}`); - - this.setModels(httpEvent, functionName); - - const paths = get(httpEvent, 'request.parameters.paths', []); - const querystrings = get(httpEvent, 'request.parameters.querystrings', {}); - [ - { params: paths, documentationKey: 'pathParams' }, - { params: querystrings, documentationKey: 'queryParams' } - ].forEach(({ params, documentationKey }) => { - this.setDefaultParamsDocumentation(params, httpEvent, documentationKey); - }); - } else if (httpEvent.documentation !== null && !httpEvent.private) { - this.functionsMissingDocumentation.push(functionName); - } - } + } + + get functions() { + return this.serverless.service.functions || {}; + } + + get webhooks() { + return this.serverless.service.custom.documentation.webhooks || {}; + } + + log(msg) { + this.serverless.cli.log(`[serverless-openapi-typescript] ${msg}`); + } + + async populateServerlessWithModels() { + this.log('Scanning functions for documentation attribute'); + + Object.keys(this.functions).forEach(functionName => { + this.functions[functionName]?.events?.forEach((event: ApiGatewayEvent) => { + const httpEvent = event.http as HttpEvent; + if (httpEvent) { + if (httpEvent.documentation) { + this.log(`Generating docs for ${functionName}`); + + this.setHttpMethodModels(httpEvent, functionName); + + const paths = get(httpEvent, 'request.parameters.paths', []); + const querystrings = get(httpEvent, 'request.parameters.querystrings', {}); + [ + { params: paths, documentationKey: 'pathParams' }, + { params: querystrings, documentationKey: 'queryParams' } + ].forEach(({ params, documentationKey }) => { + this.setDefaultParamsDocumentation(params, httpEvent, documentationKey); }); - }); - - this.assertAllFunctionsDocumented(); - } - - assertAllFunctionsDocumented() { - if (!isEmpty(this.functionsMissingDocumentation)) { - throw new Error( - `Some functions have http events which are not documented: + } else if (httpEvent.documentation !== null && !httpEvent.private) { + this.functionsMissingDocumentation.push(functionName); + } + } + }); + }); + + this.log('Scanning webhooks for documentation attribute'); + Object.keys(this.webhooks).forEach(webhookName => { + const webhook = this.webhooks[webhookName]; + const methodDefinition = webhook['post']; + if (methodDefinition) { + this.setWebhookModels(methodDefinition, webhookName); + this.log(`Generating docs for webhook ${webhookName}`); + } + }); + + + this.assertAllFunctionsDocumented(); + } + + assertAllFunctionsDocumented() { + if (!isEmpty(this.functionsMissingDocumentation)) { + throw new Error( + `Some functions have http events which are not documented: ${this.functionsMissingDocumentation} Please add a documentation attribute. @@ -108,167 +125,178 @@ export default class ServerlessOpenapiTypeScript { documentation: ~ ` - ); - } - } - - setDefaultParamsDocumentation(params, httpEvent, documentationKey) { - Object.entries(params).forEach(([name, required]) => { - httpEvent.documentation[documentationKey] = httpEvent.documentation[documentationKey] || []; - - const documentedParams = httpEvent.documentation[documentationKey]; - const existingDocumentedParam = documentedParams.find(documentedParam => documentedParam.name === name); - - if (existingDocumentedParam && typeof existingDocumentedParam.schema === 'string') { - existingDocumentedParam.schema = this.generateSchema(existingDocumentedParam.schema); - } - - const paramDocumentationFromSls = { - name, - required, - schema: { type: 'string' } - }; - - if (!existingDocumentedParam) { - documentedParams.push(paramDocumentationFromSls); - } else { - Object.assign(paramDocumentationFromSls, existingDocumentedParam); - Object.assign(existingDocumentedParam, paramDocumentationFromSls); - } - }); - } - - setModels(httpEvent, functionName) { - const definitionPrefix = `${this.serverless.service.custom.documentation.apiNamespace}.${upperFirst(camelCase(functionName))}`; - const method = httpEvent.method.toLowerCase(); - switch (method) { - case 'delete': - set(httpEvent, 'documentation.methodResponses', [{ statusCode: 204, responseModels: {} }]); - break; - case 'patch': - case 'put': - case 'post': - const requestModelName = `${definitionPrefix}.Request.Body`; - this.setModel(`${definitionPrefix}.Request.Body`); - set(httpEvent, 'documentation.requestModels', { 'application/json': requestModelName }); - set(httpEvent, 'documentation.requestBody', { description: '' }); - // no-break; - case 'get': - const responseModelName = `${definitionPrefix}.Response`; - this.setModel(`${definitionPrefix}.Response`); - set(httpEvent, 'documentation.methodResponses', [ - { - statusCode: 200, - responseBody: { description: '' }, - responseModels: { 'application/json': responseModelName } - } - ]); - } - const queryParamModel = `${definitionPrefix}.Request.QueryParams`; - try { - this.setModel(queryParamModel); - } catch (e) { - this.log(`Skipped generation of "${queryParamModel}" - model is missing - will be using the default query param of type string`); - } - - const pathParamModel = `${definitionPrefix}.Request.PathParams`; - try { - this.setModel(pathParamModel); - } catch (e) { - this.log(`Skipped generation of "${pathParamModel}" - model is missing - will be using the default path param of type string`); - } + ); } - - postProcessOpenApi() { - // @ts-ignore - const outputFile = this.serverless.processedInput.options.output; - const openApi = yaml.load(fs.readFileSync(outputFile)); - this.patchOpenApiVersion(openApi); - this.enrichMethodsInfo(openApi); - const encodedOpenAPI = this.encodeOpenApiToStandard(openApi); - fs.writeFileSync(outputFile, outputFile.endsWith('json') ? JSON.stringify(encodedOpenAPI, null, 2) : yaml.dump(encodedOpenAPI)); + } + + setDefaultParamsDocumentation(params, httpEvent, documentationKey) { + Object.entries(params).forEach(([name, required]) => { + httpEvent.documentation[documentationKey] = httpEvent.documentation[documentationKey] || []; + + const documentedParams = httpEvent.documentation[documentationKey]; + const existingDocumentedParam = documentedParams.find(documentedParam => documentedParam.name === name); + + if (existingDocumentedParam && typeof existingDocumentedParam.schema === 'string') { + existingDocumentedParam.schema = this.generateSchema(existingDocumentedParam.schema); + } + + const paramDocumentationFromSls = { + name, + required, + schema: { type: 'string' } + }; + + if (!existingDocumentedParam) { + documentedParams.push(paramDocumentationFromSls); + } else { + Object.assign(paramDocumentationFromSls, existingDocumentedParam); + Object.assign(existingDocumentedParam, paramDocumentationFromSls); + } + }); + } + + setHttpMethodModels(httpEvent, functionName) { + const definitionPrefix = `${this.serverless.service.custom.documentation.apiNamespace}.${upperFirst(camelCase(functionName))}`; + const method = httpEvent.method.toLowerCase(); + switch (method) { + case 'delete': + set(httpEvent, 'documentation.methodResponses', [{ statusCode: 204, responseModels: {} }]); + break; + case 'patch': + case 'put': + case 'post': + const requestModelName = `${definitionPrefix}.Request.Body`; + this.setModel(`${definitionPrefix}.Request.Body`); + set(httpEvent, 'documentation.requestModels', { 'application/json': requestModelName }); + set(httpEvent, 'documentation.requestBody', { description: '' }); + // no-break; + case 'get': + const responseModelName = `${definitionPrefix}.Response`; + this.setModel(`${definitionPrefix}.Response`); + set(httpEvent, 'documentation.methodResponses', [ + { + statusCode: 200, + responseBody: { description: '' }, + responseModels: { 'application/json': responseModelName } + } + ]); } - - // OpenApi spec define ^[a-zA-Z0-9\.\-_]+$ for legal fields - https://spec.openapis.org/oas/v3.1.0#components-object - // ts-json-schema-generator - create fields with <,> for generic types and encode them in the ref - encodeOpenApiToStandard(openApi) { - const INVALID_CHARACTERS_KEY = /<|>/g; - const INVALID_CHARACTERS_ENCODED = /%3C|%3E/g; // %3C = <, %3E = > - - const mapObject = mapKeysDeep(openApi, (value, key) => - INVALID_CHARACTERS_KEY.test(key) ? key.replace(INVALID_CHARACTERS_KEY, '_') : key - ); - - return mapValuesDeep(mapObject, (value, key) => - key === '$ref' && INVALID_CHARACTERS_ENCODED.test(value) ? - value.replace(INVALID_CHARACTERS_ENCODED, '_') : value - ); + const queryParamModel = `${definitionPrefix}.Request.QueryParams`; + try { + this.setModel(queryParamModel); + } catch (e) { + this.log(`Skipped generation of "${queryParamModel}" - model is missing - will be using the default query param of type string`); } - patchOpenApiVersion(openApi) { - this.log(`Setting openapi version to 3.1.0`); - openApi.openapi = '3.1.0'; - return openApi; + const pathParamModel = `${definitionPrefix}.Request.PathParams`; + try { + this.setModel(pathParamModel); + } catch (e) { + this.log(`Skipped generation of "${pathParamModel}" - model is missing - will be using the default path param of type string`); } + } + + setWebhookModels(webhook, webhookName: string) { + const webhookModelName = `${this.serverless.service.custom.documentation.apiNamespace}.Webhooks.${upperFirst(camelCase(webhookName))}`; + this.setModel(webhookModelName); + this.webhookEntries[webhookName] = { + post: webhook + }; + // Since the original plugin doesn't read `webhooks` property and handle it we need to help it + set(this.webhookEntries[webhookName], 'post.requestBody.content', { 'application/json': { schema: { '$ref': `#/components/schemas/${webhookModelName}` } } }); + } + + postProcessOpenApi() { + // @ts-ignore + const outputFile = this.serverless.processedInput.options.output; + const openApi = yaml.load(fs.readFileSync(outputFile)); + openApi['webhooks'] = this.webhookEntries; + this.patchOpenApiVersion(openApi); + this.enrichMethodsInfo(openApi); + const encodedOpenAPI = this.encodeOpenApiToStandard(openApi); + fs.writeFileSync(outputFile, outputFile.endsWith('json') ? JSON.stringify(encodedOpenAPI, null, 2) : yaml.dump(encodedOpenAPI)); + } + + // OpenApi spec define ^[a-zA-Z0-9\.\-_]+$ for legal fields - https://spec.openapis.org/oas/v3.1.0#components-object + // ts-json-schema-generator - create fields with <,> for generic types and encode them in the ref + encodeOpenApiToStandard(openApi) { + const INVALID_CHARACTERS_KEY = /<|>/g; + const INVALID_CHARACTERS_ENCODED = /%3C|%3E/g; // %3C = <, %3E = > + + const mapObject = mapKeysDeep(openApi, (value, key) => + INVALID_CHARACTERS_KEY.test(key) ? key.replace(INVALID_CHARACTERS_KEY, '_') : key + ); + + return mapValuesDeep(mapObject, (value, key) => + key === '$ref' && INVALID_CHARACTERS_ENCODED.test(value) ? + value.replace(INVALID_CHARACTERS_ENCODED, '_') : value + ); + } + + patchOpenApiVersion(openApi) { + this.log(`Setting openapi version to 3.1.0`); + openApi.openapi = '3.1.0'; + return openApi; + } + + enrichMethodsInfo(openApi) { + const tagName = openApi.info.title; + openApi.tags = [ + { + name: tagName, + description: openApi.info.description + } + ]; + const customTags = this.serverless.service.custom.documentation?.tags; + if (customTags) openApi.tags = openApi.tags.concat(customTags); + + Object.values(openApi.paths).forEach(path => { + Object.values(path).forEach(method => { + const httpEvent = this.functions[method.operationId]?.events?.find( + (e: ApiGatewayEvent) => e.http + ) as ApiGatewayEvent; + const http: HttpEvent = httpEvent.http; + if (http.documentation?.tag) { + method.tags = [http.documentation.tag]; + } else { + method.tags = [tagName]; + } - enrichMethodsInfo(openApi) { - const tagName = openApi.info.title; - openApi.tags = [ - { - name: tagName, - description: openApi.info.description - } - ]; - const customTags = this.serverless.service.custom.documentation?.tags; - if (customTags) openApi.tags = openApi.tags.concat(customTags) - - Object.values(openApi.paths).forEach(path => { - Object.values(path).forEach(method => { - const httpEvent = this.functions[method.operationId]?.events?.find( - (e: ApiGatewayEvent) => e.http - ) as ApiGatewayEvent; - const http: HttpEvent = httpEvent.http; - if (http.documentation?.tag) { - method.tags = [http.documentation.tag]; - } else { - method.tags = [tagName]; - } - - method.operationId = kebabCase(`${this.serverless.service.custom?.documentation?.title}-${method.operationId}`); - }); - }); - } - - setModel(modelName) { - mergeWith( - this.serverless.service.custom, - { - documentation: { - models: [{ name: modelName, contentType: 'application/json', schema: this.generateSchema(modelName) }] - } - }, - (objValue, srcValue) => { - if (isArray(objValue)) { - return objValue.concat(srcValue); - } - } - ); - } - - generateSchema(modelName) { - this.log(`Generating schema for ${modelName}`); - - this.schemaGenerator = - this.schemaGenerator || - createGenerator({ - path: this.typescriptApiModelPath, - tsconfig: this.tsconfigPath, - type: `*`, - expose: 'export', - skipTypeCheck: true, - topRef: false - }); - - return this.schemaGenerator.createSchema(modelName); - } + method.operationId = kebabCase(`${this.serverless.service.custom?.documentation?.title}-${method.operationId}`); + }); + }); + } + + setModel(modelName) { + mergeWith( + this.serverless.service.custom, + { + documentation: { + models: [{ name: modelName, contentType: 'application/json', schema: this.generateSchema(modelName) }] + } + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue); + } + } + ); + } + + generateSchema(modelName) { + this.log(`Generating schema for ${modelName}`); + + this.schemaGenerator = + this.schemaGenerator || + createGenerator({ + path: this.typescriptApiModelPath, + tsconfig: this.tsconfigPath, + type: `*`, + expose: 'export', + skipTypeCheck: true, + topRef: false + }); + + return this.schemaGenerator.createSchema(modelName); + } } diff --git a/test/fixtures/expect-openapi-custom-tags.yml b/test/fixtures/expect-openapi-custom-tags.yml index 1a7981d..65f2c86 100644 --- a/test/fixtures/expect-openapi-custom-tags.yml +++ b/test/fixtures/expect-openapi-custom-tags.yml @@ -61,6 +61,7 @@ paths: $ref: '#/components/schemas/ProjectApi.GetFunc.Response' tags: - BazTitle +webhooks: {} tags: - name: Project description: DummyDescription diff --git a/test/fixtures/expect-openapi-full.yml b/test/fixtures/expect-openapi-full.yml index ecb38e9..deae1c7 100644 --- a/test/fixtures/expect-openapi-full.yml +++ b/test/fixtures/expect-openapi-full.yml @@ -37,7 +37,7 @@ components: type: string generic: $ref: >- - #/components/schemas/ProjectApi.GenericType_structure-1448918441-633-661-1448918441-620-662-1448918441-599-663-1448918441-547-673-1448918441-515-674-1448918441-283-680-1448918441-250-680-1448918441-104-1213-1448918441-75-1213-1448918441-0-1214_ + #/components/schemas/ProjectApi.GenericType_structure-1448918441-658-687-1448918441-645-688-1448918441-630-689-1448918441-590-695-1448918441-562-696-1448918441-382-700-1448918441-352-700-1448918441-100-1129-1448918441-71-1129-1448918441-0-1130_ required: - id - uuid @@ -70,7 +70,18 @@ components: required: - data additionalProperties: false - ProjectApi.GenericType_structure-1448918441-633-661-1448918441-620-662-1448918441-599-663-1448918441-547-673-1448918441-515-674-1448918441-283-680-1448918441-250-680-1448918441-104-1213-1448918441-75-1213-1448918441-0-1214_: + ProjectApi.Webhooks.OnCreateWebhook: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + ProjectApi.GenericType_structure-1448918441-658-687-1448918441-645-688-1448918441-630-689-1448918441-590-695-1448918441-562-696-1448918441-382-700-1448918441-352-700-1448918441-100-1129-1448918441-71-1129-1448918441-0-1130_: type: array items: type: object @@ -83,6 +94,7 @@ components: - key - name additionalProperties: false + info: title: Project description: > @@ -220,6 +232,21 @@ paths: $ref: '#/components/schemas/ProjectApi.GetFunc.Response' tags: - Project +webhooks: + OnCreateWebhook: + post: + requestBody: + description: | + This is a request body description + content: + application/json: + schema: + $ref: >- + #/components/schemas/ProjectApi.Webhooks.OnCreateWebhook + response: + '200': + description: | + This is a expected response description tags: - name: Project description: > diff --git a/test/fixtures/expect-openapi-hyphenated-functions.yml b/test/fixtures/expect-openapi-hyphenated-functions.yml index 7c5c0a6..24a857c 100644 --- a/test/fixtures/expect-openapi-hyphenated-functions.yml +++ b/test/fixtures/expect-openapi-hyphenated-functions.yml @@ -61,6 +61,7 @@ paths: $ref: '#/components/schemas/ProjectApi.GetFunc.Response' tags: - BazTitle +webhooks: {} tags: - name: Project description: DummyDescription diff --git a/test/fixtures/expect-openapi-query-param-type.yml b/test/fixtures/expect-openapi-query-param-type.yml index 5a77bb0..d5558f1 100644 --- a/test/fixtures/expect-openapi-query-param-type.yml +++ b/test/fixtures/expect-openapi-query-param-type.yml @@ -48,6 +48,7 @@ paths: $ref: '#/components/schemas/ProjectApi.Func.Response' tags: - Project +webhooks: {} tags: - name: Project description: DummyDescription diff --git a/test/fixtures/expect-openapi-webhooks.yml b/test/fixtures/expect-openapi-webhooks.yml new file mode 100644 index 0000000..98b9b3a --- /dev/null +++ b/test/fixtures/expect-openapi-webhooks.yml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +components: + schemas: + ProjectApi.Webhooks.OnCreateWebhook: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false +info: + title: Project + description: DummyDescription +paths: { } +webhooks: + OnCreateWebhook: + post: + requestBody: + description: | + This is a request body description + content: + application/json: + schema: + $ref: >- + #/components/schemas/ProjectApi.Webhooks.OnCreateWebhook + responses: + '200': + description: | + This is a expected response description +tags: + - name: Project + description: DummyDescription + - name: FooBarTitle + description: FooBarDescription + - name: BazTitle + description: BazDescription diff --git a/test/serverless-full/api.d.ts b/test/serverless-full/api.d.ts index 1c2cc84..d72bb4d 100644 --- a/test/serverless-full/api.d.ts +++ b/test/serverless-full/api.d.ts @@ -1,55 +1,60 @@ interface ObjectType { - types?: string[]; - children?: ObjectType[]; + types?: string[]; + children?: ObjectType[]; } export namespace ProjectApi { - export type Bool = 'true' | 'false'; - export type Number = number - export type String = string; - export type GenericType = T[]; - - export namespace CreateFunc { - export namespace Request { - export type Body = { - data: string; - statusCode?: number; - enable: boolean; - object?: ObjectType; - }; - } - - export type Response = { - id: string; - uuid: string; - generic: GenericType<{ key: string, name: number}>; - }; + export type Bool = 'true' | 'false'; + export type Number = number + export type String = string; + export type GenericType = T[]; + export namespace Webhooks { + export type OnCreateWebhook = { + id: string; + name: string; } - - export namespace DeleteFunc { - export namespace Request { - export type Body = { - id: string; - }; - } + } + export namespace CreateFunc { + export namespace Request { + export type Body = { + data: string; + statusCode?: number; + enable: boolean; + object?: ObjectType; + }; } - export namespace UpdateFunc { - export namespace Request { - export type Body = { - id: string; - data: string; - }; - } + export type Response = { + id: string; + uuid: string; + generic: GenericType<{ key: string, name: number }>; + }; + } - export type Response = { - id: string; - }; + export namespace DeleteFunc { + export namespace Request { + export type Body = { + id: string; + }; } + } - export namespace GetFunc { - export type Response = { - data: string; - }; + export namespace UpdateFunc { + export namespace Request { + export type Body = { + id: string; + data: string; + }; } + + export type Response = { + id: string; + }; + } + + export namespace GetFunc { + export type Response = { + data: string; + }; + } } diff --git a/test/serverless-full/resources/serverless.yml b/test/serverless-full/resources/serverless.yml index 23e8bde..a1abce8 100644 --- a/test/serverless-full/resources/serverless.yml +++ b/test/serverless-full/resources/serverless.yml @@ -20,6 +20,16 @@ custom: More on https://google.com apiNamespace: ProjectApi + webhooks: + OnCreateWebhook: + post: + requestBody: + description: | + This is a request body description + response: + 200: + description: | + This is a expected response description functions: createFunc: diff --git a/test/serverless-openapi-typescript.spec.ts b/test/serverless-openapi-typescript.spec.ts index b74e331..1101e13 100644 --- a/test/serverless-openapi-typescript.spec.ts +++ b/test/serverless-openapi-typescript.spec.ts @@ -1,8 +1,8 @@ -import Serverless from "serverless"; -import path from "path"; -import fs from "fs"; -import {promisify} from "util"; -import yaml from "js-yaml"; +import Serverless from 'serverless'; +import path from 'path'; +import fs from 'fs'; +import { promisify } from 'util'; +import yaml from 'js-yaml'; const readFileAsync = promisify(fs.readFile); const deleteFileAsync = promisify(fs.unlink); @@ -11,102 +11,104 @@ const existsAsync = promisify(fs.exists); jest.setTimeout(60000); describe('ServerlessOpenapiTypeScript', () => { - describe.each` - testCase | projectName - ${'Custom Tags'} | ${'custom-tags'} - ${'Hyphenated Functions'} | ${'hyphenated-functions'} - ${'Full Project'} | ${'full'} - `('when using $testCase', ({projectName}) => { - beforeEach(async () => { - await deleteOutputFile(projectName); - }); + describe.each` + testCase | projectName + ${'Custom Tags'} | ${'custom-tags'} + ${'Hyphenated Functions'} | ${'hyphenated-functions'} + ${'Full Project'} | ${'full'} + ${'Webhooks'} | ${'webhooks'} + `('when using $testCase', ({ projectName }) => { + + beforeEach(async () => { + await deleteOutputFile(projectName); + }); - it('should create the expected file', async () => { - await runOpenApiGenerate(projectName); + it('should create the expected file', async () => { + await runOpenApiGenerate(projectName); - await assertYamlFilesEquals(projectName); - }); + await assertYamlFilesEquals(projectName); }); + }); - describe('WithoutOpenAPI', () => { - const projectName = 'without-openapi'; + describe('WithoutOpenAPI', () => { + const projectName = 'without-openapi'; - it('should throw an error when serverless-openapi-documentation not loaded before', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation')); - }); + it('should throw an error when serverless-openapi-documentation not loaded before', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation')); }); + }); - describe('NotDocumented', () => { - const projectName = 'not-documented'; + describe('NotDocumented', () => { + const projectName = 'not-documented'; - it('should throw an error when found function not documented', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(expect.objectContaining({message: expect.stringContaining('deleteFunc')})); - }); + it('should throw an error when found function not documented', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(expect.objectContaining({ message: expect.stringContaining('deleteFunc') })); }); + }); - describe('TypeMissing', () => { - const projectName = 'type-missing'; + describe('TypeMissing', () => { + const projectName = 'type-missing'; - it('should throw an error when type is missing', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('No root type "ProjectApi.CreateFunc.Request.Body" found')); - }); + it('should throw an error when type is missing', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('No root type "ProjectApi.CreateFunc.Request.Body" found')); }); + }); - describe('Disable', () => { - const projectName = 'disable'; + describe('Disable', () => { + const projectName = 'disable'; - it('should not create docs', async () => { - await runOpenApiGenerate(projectName); + it('should not create docs', async () => { + await runOpenApiGenerate(projectName); - await expect(existsAsync(`test/fixtures/expect-openapi-${projectName}.yml`)).resolves.toBeFalsy(); - }); + await expect(existsAsync(`test/fixtures/expect-openapi-${projectName}.yml`)).resolves.toBeFalsy(); }); + }); }); async function assertYamlFilesEquals(projectName: string): Promise { - const outputFile = `openapi-${projectName}.yml`; - const expectFile = `test/fixtures/expect-openapi-${projectName}.yml`; + const outputFile = `openapi-${projectName}.yml`; + const expectFile = `test/fixtures/expect-openapi-${projectName}.yml`; - const [actualOutput, expectOutput] = await Promise.all([processYamlFileForTest(expectFile), processYamlFileForTest(outputFile)]); - expect(expectOutput).toEqual(actualOutput); + const [actualOutput, expectOutput] = await Promise.all([processYamlFileForTest(expectFile), processYamlFileForTest(outputFile)]); + expect(expectOutput).toEqual(actualOutput); } async function processYamlFileForTest(path: string): Promise { - const yamlData = await readYaml(path); - delete yamlData.info.version; - return yaml.dump(yamlData); + const yamlData = await readYaml(path); + delete yamlData.info.version; + return yaml.dump(yamlData); } async function readYaml(path: string) { - const data = await readFileAsync(path); - return yaml.load(data); + const data = await readFileAsync(path); + return yaml.load(data); } async function deleteOutputFile(project) { - try { - await deleteFileAsync(`openapi-${project}.yml`); - } catch { - } + try { + await deleteFileAsync(`openapi-${project}.yml`); + } catch { + } } async function runOpenApiGenerate(projectName) { - const projectPath = path.join(__dirname, `serverless-${projectName}`); - const serverlessYamlPath = path.join(projectPath, "resources/serverless.yml"); - const typescriptApiPath = path.join(projectPath, "api.d.ts"); - const outputFile = `openapi-${projectName}.yml`; - - const config = await readYaml(serverlessYamlPath); - const sls = new Serverless({ - configurationPath: serverlessYamlPath, - configuration: config, - commands: ['openapi', 'generate'], - options: { - typescriptApiPath, - output: outputFile - } - }); + const projectPath = path.join(__dirname, `serverless-${projectName}`); + const serverlessYamlPath = path.join(projectPath, 'resources/serverless.yml'); + const typescriptApiPath = path.join(projectPath, 'api.d.ts'); + const outputFile = `openapi-${projectName}.yml`; + + const config = await readYaml(serverlessYamlPath); + const sls = new Serverless({ + configurationPath: serverlessYamlPath, + configuration: config, + commands: ['openapi', 'generate'], + options: { + typescriptApiPath, + output: outputFile + } + }); - await sls.init(); - await sls.run(); + await sls.init(); + await sls.run(); } diff --git a/test/serverless-webhooks/api.d.ts b/test/serverless-webhooks/api.d.ts new file mode 100644 index 0000000..10fd014 --- /dev/null +++ b/test/serverless-webhooks/api.d.ts @@ -0,0 +1,8 @@ +export namespace ProjectApi { + export namespace Webhooks { + export type OnCreateWebhook = { + id: string; + name: string; + } + } +} diff --git a/test/serverless-webhooks/resources/serverless.yml b/test/serverless-webhooks/resources/serverless.yml new file mode 100644 index 0000000..48070c7 --- /dev/null +++ b/test/serverless-webhooks/resources/serverless.yml @@ -0,0 +1,30 @@ +service: serverless-openapi-typescript-demo +provider: + name: aws + +plugins: + - ../node_modules/@conqa/serverless-openapi-documentation + - ../src/index + +custom: + documentation: + title: 'Project' + description: DummyDescription + apiNamespace: ProjectApi + tags: + - name: FooBarTitle + description: FooBarDescription + - name: BazTitle + description: BazDescription + webhooks: + OnCreateWebhook: + post: + requestBody: + description: | + This is a request body description + responses: + 200: + description: | + This is a expected response description + + From 849e3e06d665da568ec13373dd131fe25391d018 Mon Sep 17 00:00:00 2001 From: Elbaz Date: Wed, 15 May 2024 14:06:39 +0300 Subject: [PATCH 2/7] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6a62401..592164a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-openapi-typescript", - "version": "2.0.0", + "version": "2.1.0", "description": "An extension of @conqa/serverless-openapi-documentation that also generates your OpenAPI models from TypeScript", "main": "dist/index.js", "scripts": { From 6ebcf700d2ca214edc13ef685064f31ce8ccddd6 Mon Sep 17 00:00:00 2001 From: Elbaz Date: Wed, 15 May 2024 15:51:25 +0300 Subject: [PATCH 3/7] remove pretty --- src/serverless-openapi-typescript.ts | 533 +++++++++++++-------------- 1 file changed, 265 insertions(+), 268 deletions(-) diff --git a/src/serverless-openapi-typescript.ts b/src/serverless-openapi-typescript.ts index 8f69f78..d4b126b 100644 --- a/src/serverless-openapi-typescript.ts +++ b/src/serverless-openapi-typescript.ts @@ -1,123 +1,121 @@ -import type Serverless from 'serverless'; -import fs from 'fs'; -import yaml from 'js-yaml'; -import { SchemaGenerator, createGenerator } from 'ts-json-schema-generator'; -import { upperFirst, camelCase, mergeWith, set, isArray, get, isEmpty, kebabCase } from 'lodash' ; -import { ApiGatewayEvent } from 'serverless/plugins/aws/package/compile/events/apiGateway/lib/validate'; -import { mapKeysDeep, mapValuesDeep } from 'deepdash/standalone'; +import type Serverless from "serverless"; +import fs from "fs"; +import yaml from "js-yaml"; +import {SchemaGenerator, createGenerator} from "ts-json-schema-generator"; +import {upperFirst, camelCase, mergeWith, set, isArray, get, isEmpty, kebabCase} from "lodash" ; +import {ApiGatewayEvent} from "serverless/plugins/aws/package/compile/events/apiGateway/lib/validate"; +import { mapKeysDeep, mapValuesDeep} from 'deepdash/standalone' interface Options { - typescriptApiPath?: string; - tsconfigPath?: string; + typescriptApiPath?: string; + tsconfigPath?: string; } type HttpEvent = ApiGatewayEvent['http'] & { - documentation?: any; - private?: boolean; + documentation?: any; + private?: boolean; } export default class ServerlessOpenapiTypeScript { - private readonly functionsMissingDocumentation: string[]; - private readonly disable: boolean; - private hooks: { [hook: string]: () => {} }; - private webhookEntries: Record = {}; - private typescriptApiModelPath: string; - private tsconfigPath: string; - private schemaGenerator: SchemaGenerator; - - constructor(private serverless: Serverless, private options: Options) { - this.assertPluginOrder(); - - this.initOptions(options); - this.functionsMissingDocumentation = []; - - if (!this.serverless.service.custom?.documentation) { - this.log( - `Disabling OpenAPI generation for ${this.serverless.service.service} - no 'custom.documentation' attribute found` - ); - this.disable = true; - delete this.serverless.pluginManager.hooks['openapi:generate:serverless']; + private readonly functionsMissingDocumentation: string[]; + private readonly disable: boolean; + private hooks: { [hook: string]: () => {}}; + private typescriptApiModelPath: string; + private tsconfigPath: string; + private schemaGenerator: SchemaGenerator; + private webhookEntries: Record = {}; + + constructor(private serverless: Serverless, private options: Options) { + this.assertPluginOrder(); + + this.initOptions(options); + this.functionsMissingDocumentation = []; + + if (!this.serverless.service.custom?.documentation) { + this.log( + `Disabling OpenAPI generation for ${this.serverless.service.service} - no 'custom.documentation' attribute found` + ); + this.disable = true; + delete this.serverless.pluginManager.hooks['openapi:generate:serverless']; + } + + if (!this.disable) { + this.hooks = { + 'before:openapi:generate:serverless': this.populateServerlessWithModels.bind(this), + 'after:openapi:generate:serverless': this.postProcessOpenApi.bind(this) + }; + } } - if (!this.disable) { - this.hooks = { - 'before:openapi:generate:serverless': this.populateServerlessWithModels.bind(this), - 'after:openapi:generate:serverless': this.postProcessOpenApi.bind(this) - }; + initOptions(options) { + this.options = options || {}; + this.typescriptApiModelPath = this.options.typescriptApiPath || 'api.d.ts'; + this.tsconfigPath = this.options.tsconfigPath || 'tsconfig.json'; + } + + assertPluginOrder() { + if (!this.serverless.pluginManager.hooks['openapi:generate:serverless']) { + throw new Error( + 'Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation' + ); + } + } + + get functions() { + return this.serverless.service.functions || {}; + } + + get webhooks() { + return this.serverless.service.custom.documentation.webhooks || {}; } - } - - initOptions(options) { - this.options = options || {}; - this.typescriptApiModelPath = this.options.typescriptApiPath || 'api.d.ts'; - this.tsconfigPath = this.options.tsconfigPath || 'tsconfig.json'; - } - - assertPluginOrder() { - if (!this.serverless.pluginManager.hooks['openapi:generate:serverless']) { - throw new Error( - 'Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation' - ); + + log(msg) { + this.serverless.cli.log(`[serverless-openapi-typescript] ${msg}`); } - } - - get functions() { - return this.serverless.service.functions || {}; - } - - get webhooks() { - return this.serverless.service.custom.documentation.webhooks || {}; - } - - log(msg) { - this.serverless.cli.log(`[serverless-openapi-typescript] ${msg}`); - } - - async populateServerlessWithModels() { - this.log('Scanning functions for documentation attribute'); - - Object.keys(this.functions).forEach(functionName => { - this.functions[functionName]?.events?.forEach((event: ApiGatewayEvent) => { - const httpEvent = event.http as HttpEvent; - if (httpEvent) { - if (httpEvent.documentation) { - this.log(`Generating docs for ${functionName}`); - - this.setHttpMethodModels(httpEvent, functionName); - - const paths = get(httpEvent, 'request.parameters.paths', []); - const querystrings = get(httpEvent, 'request.parameters.querystrings', {}); - [ - { params: paths, documentationKey: 'pathParams' }, - { params: querystrings, documentationKey: 'queryParams' } - ].forEach(({ params, documentationKey }) => { - this.setDefaultParamsDocumentation(params, httpEvent, documentationKey); + + async populateServerlessWithModels() { + this.log('Scanning functions for documentation attribute'); + Object.keys(this.functions).forEach(functionName => { + this.functions[functionName]?.events?.forEach((event: ApiGatewayEvent) => { + const httpEvent = event.http as HttpEvent; + if (httpEvent) { + if (httpEvent.documentation) { + this.log(`Generating docs for ${functionName}`); + + this.setHttpMethodModels(httpEvent, functionName); + + const paths = get(httpEvent, 'request.parameters.paths', []); + const querystrings = get(httpEvent, 'request.parameters.querystrings', {}); + [ + { params: paths, documentationKey: 'pathParams' }, + { params: querystrings, documentationKey: 'queryParams' } + ].forEach(({ params, documentationKey }) => { + this.setDefaultParamsDocumentation(params, httpEvent, documentationKey); + }); + } else if (httpEvent.documentation !== null && !httpEvent.private) { + this.functionsMissingDocumentation.push(functionName); + } + } }); - } else if (httpEvent.documentation !== null && !httpEvent.private) { - this.functionsMissingDocumentation.push(functionName); + }); + + this.log('Scanning webhooks for documentation attribute'); + Object.keys(this.webhooks).forEach(webhookName => { + const webhook = this.webhooks[webhookName]; + const methodDefinition = webhook['post']; + if (methodDefinition) { + this.setWebhookModels(methodDefinition, webhookName); + this.log(`Generating docs for webhook ${webhookName}`); } - } - }); - }); - - this.log('Scanning webhooks for documentation attribute'); - Object.keys(this.webhooks).forEach(webhookName => { - const webhook = this.webhooks[webhookName]; - const methodDefinition = webhook['post']; - if (methodDefinition) { - this.setWebhookModels(methodDefinition, webhookName); - this.log(`Generating docs for webhook ${webhookName}`); - } - }); - - - this.assertAllFunctionsDocumented(); - } - - assertAllFunctionsDocumented() { - if (!isEmpty(this.functionsMissingDocumentation)) { - throw new Error( - `Some functions have http events which are not documented: + }); + + this.assertAllFunctionsDocumented(); + } + + assertAllFunctionsDocumented() { + if (!isEmpty(this.functionsMissingDocumentation)) { + throw new Error( + `Some functions have http events which are not documented: ${this.functionsMissingDocumentation} Please add a documentation attribute. @@ -125,178 +123,177 @@ export default class ServerlessOpenapiTypeScript { documentation: ~ ` - ); + ); + } } - } - setDefaultParamsDocumentation(params, httpEvent, documentationKey) { - Object.entries(params).forEach(([name, required]) => { - httpEvent.documentation[documentationKey] = httpEvent.documentation[documentationKey] || []; + setDefaultParamsDocumentation(params, httpEvent, documentationKey) { + Object.entries(params).forEach(([name, required]) => { + httpEvent.documentation[documentationKey] = httpEvent.documentation[documentationKey] || []; + + const documentedParams = httpEvent.documentation[documentationKey]; + const existingDocumentedParam = documentedParams.find(documentedParam => documentedParam.name === name); + + if (existingDocumentedParam && typeof existingDocumentedParam.schema === 'string') { + existingDocumentedParam.schema = this.generateSchema(existingDocumentedParam.schema); + } + + const paramDocumentationFromSls = { + name, + required, + schema: { type: 'string' } + }; + + if (!existingDocumentedParam) { + documentedParams.push(paramDocumentationFromSls); + } else { + Object.assign(paramDocumentationFromSls, existingDocumentedParam); + Object.assign(existingDocumentedParam, paramDocumentationFromSls); + } + }); + } - const documentedParams = httpEvent.documentation[documentationKey]; - const existingDocumentedParam = documentedParams.find(documentedParam => documentedParam.name === name); + setHttpMethodModels(httpEvent, functionName) { + const definitionPrefix = `${this.serverless.service.custom.documentation.apiNamespace}.${upperFirst(camelCase(functionName))}`; + const method = httpEvent.method.toLowerCase(); + switch (method) { + case 'delete': + set(httpEvent, 'documentation.methodResponses', [{ statusCode: 204, responseModels: {} }]); + break; + case 'patch': + case 'put': + case 'post': + const requestModelName = `${definitionPrefix}.Request.Body`; + this.setModel(`${definitionPrefix}.Request.Body`); + set(httpEvent, 'documentation.requestModels', { 'application/json': requestModelName }); + set(httpEvent, 'documentation.requestBody', { description: '' }); + // no-break; + case 'get': + const responseModelName = `${definitionPrefix}.Response`; + this.setModel(`${definitionPrefix}.Response`); + set(httpEvent, 'documentation.methodResponses', [ + { + statusCode: 200, + responseBody: { description: '' }, + responseModels: { 'application/json': responseModelName } + } + ]); + } + const queryParamModel = `${definitionPrefix}.Request.QueryParams`; + try { + this.setModel(queryParamModel); + } catch (e) { + this.log(`Skipped generation of "${queryParamModel}" - model is missing - will be using the default query param of type string`); + } - if (existingDocumentedParam && typeof existingDocumentedParam.schema === 'string') { - existingDocumentedParam.schema = this.generateSchema(existingDocumentedParam.schema); - } + const pathParamModel = `${definitionPrefix}.Request.PathParams`; + try { + this.setModel(pathParamModel); + } catch (e) { + this.log(`Skipped generation of "${pathParamModel}" - model is missing - will be using the default path param of type string`); + } + } - const paramDocumentationFromSls = { - name, - required, - schema: { type: 'string' } + setWebhookModels(webhook, webhookName: string) { + const webhookModelName = `${this.serverless.service.custom.documentation.apiNamespace}.Webhooks.${upperFirst(camelCase(webhookName))}`; + this.setModel(webhookModelName); + this.webhookEntries[webhookName] = { + post: webhook }; + // Since the original plugin doesn't read `webhooks` property and handle it we need to help it + set(this.webhookEntries[webhookName], 'post.requestBody.content', { 'application/json': { schema: { '$ref': `#/components/schemas/${webhookModelName}` } } }); + } - if (!existingDocumentedParam) { - documentedParams.push(paramDocumentationFromSls); - } else { - Object.assign(paramDocumentationFromSls, existingDocumentedParam); - Object.assign(existingDocumentedParam, paramDocumentationFromSls); - } - }); - } - - setHttpMethodModels(httpEvent, functionName) { - const definitionPrefix = `${this.serverless.service.custom.documentation.apiNamespace}.${upperFirst(camelCase(functionName))}`; - const method = httpEvent.method.toLowerCase(); - switch (method) { - case 'delete': - set(httpEvent, 'documentation.methodResponses', [{ statusCode: 204, responseModels: {} }]); - break; - case 'patch': - case 'put': - case 'post': - const requestModelName = `${definitionPrefix}.Request.Body`; - this.setModel(`${definitionPrefix}.Request.Body`); - set(httpEvent, 'documentation.requestModels', { 'application/json': requestModelName }); - set(httpEvent, 'documentation.requestBody', { description: '' }); - // no-break; - case 'get': - const responseModelName = `${definitionPrefix}.Response`; - this.setModel(`${definitionPrefix}.Response`); - set(httpEvent, 'documentation.methodResponses', [ - { - statusCode: 200, - responseBody: { description: '' }, - responseModels: { 'application/json': responseModelName } - } - ]); + postProcessOpenApi() { + // @ts-ignore + const outputFile = this.serverless.processedInput.options.output; + const openApi = yaml.load(fs.readFileSync(outputFile)); + this.patchOpenApiVersion(openApi); + this.enrichMethodsInfo(openApi); + const encodedOpenAPI = this.encodeOpenApiToStandard(openApi); + fs.writeFileSync(outputFile, outputFile.endsWith('json') ? JSON.stringify(encodedOpenAPI, null, 2) : yaml.dump(encodedOpenAPI)); } - const queryParamModel = `${definitionPrefix}.Request.QueryParams`; - try { - this.setModel(queryParamModel); - } catch (e) { - this.log(`Skipped generation of "${queryParamModel}" - model is missing - will be using the default query param of type string`); + + // OpenApi spec define ^[a-zA-Z0-9\.\-_]+$ for legal fields - https://spec.openapis.org/oas/v3.1.0#components-object + // ts-json-schema-generator - create fields with <,> for generic types and encode them in the ref + encodeOpenApiToStandard(openApi) { + const INVALID_CHARACTERS_KEY = /<|>/g; + const INVALID_CHARACTERS_ENCODED = /%3C|%3E/g; // %3C = <, %3E = > + + const mapObject = mapKeysDeep(openApi, (value, key) => + INVALID_CHARACTERS_KEY.test(key) ? key.replace(INVALID_CHARACTERS_KEY, '_') : key + ); + + return mapValuesDeep(mapObject, (value, key) => + key === '$ref' && INVALID_CHARACTERS_ENCODED.test(value) ? + value.replace(INVALID_CHARACTERS_ENCODED, '_') : value + ); } - const pathParamModel = `${definitionPrefix}.Request.PathParams`; - try { - this.setModel(pathParamModel); - } catch (e) { - this.log(`Skipped generation of "${pathParamModel}" - model is missing - will be using the default path param of type string`); + patchOpenApiVersion(openApi) { + this.log(`Setting openapi version to 3.1.0`); + openApi.openapi = '3.1.0'; + return openApi; } - } - - setWebhookModels(webhook, webhookName: string) { - const webhookModelName = `${this.serverless.service.custom.documentation.apiNamespace}.Webhooks.${upperFirst(camelCase(webhookName))}`; - this.setModel(webhookModelName); - this.webhookEntries[webhookName] = { - post: webhook - }; - // Since the original plugin doesn't read `webhooks` property and handle it we need to help it - set(this.webhookEntries[webhookName], 'post.requestBody.content', { 'application/json': { schema: { '$ref': `#/components/schemas/${webhookModelName}` } } }); - } - - postProcessOpenApi() { - // @ts-ignore - const outputFile = this.serverless.processedInput.options.output; - const openApi = yaml.load(fs.readFileSync(outputFile)); - openApi['webhooks'] = this.webhookEntries; - this.patchOpenApiVersion(openApi); - this.enrichMethodsInfo(openApi); - const encodedOpenAPI = this.encodeOpenApiToStandard(openApi); - fs.writeFileSync(outputFile, outputFile.endsWith('json') ? JSON.stringify(encodedOpenAPI, null, 2) : yaml.dump(encodedOpenAPI)); - } - - // OpenApi spec define ^[a-zA-Z0-9\.\-_]+$ for legal fields - https://spec.openapis.org/oas/v3.1.0#components-object - // ts-json-schema-generator - create fields with <,> for generic types and encode them in the ref - encodeOpenApiToStandard(openApi) { - const INVALID_CHARACTERS_KEY = /<|>/g; - const INVALID_CHARACTERS_ENCODED = /%3C|%3E/g; // %3C = <, %3E = > - - const mapObject = mapKeysDeep(openApi, (value, key) => - INVALID_CHARACTERS_KEY.test(key) ? key.replace(INVALID_CHARACTERS_KEY, '_') : key - ); - - return mapValuesDeep(mapObject, (value, key) => - key === '$ref' && INVALID_CHARACTERS_ENCODED.test(value) ? - value.replace(INVALID_CHARACTERS_ENCODED, '_') : value - ); - } - - patchOpenApiVersion(openApi) { - this.log(`Setting openapi version to 3.1.0`); - openApi.openapi = '3.1.0'; - return openApi; - } - - enrichMethodsInfo(openApi) { - const tagName = openApi.info.title; - openApi.tags = [ - { - name: tagName, - description: openApi.info.description - } - ]; - const customTags = this.serverless.service.custom.documentation?.tags; - if (customTags) openApi.tags = openApi.tags.concat(customTags); - - Object.values(openApi.paths).forEach(path => { - Object.values(path).forEach(method => { - const httpEvent = this.functions[method.operationId]?.events?.find( - (e: ApiGatewayEvent) => e.http - ) as ApiGatewayEvent; - const http: HttpEvent = httpEvent.http; - if (http.documentation?.tag) { - method.tags = [http.documentation.tag]; - } else { - method.tags = [tagName]; - } - method.operationId = kebabCase(`${this.serverless.service.custom?.documentation?.title}-${method.operationId}`); - }); - }); - } - - setModel(modelName) { - mergeWith( - this.serverless.service.custom, - { - documentation: { - models: [{ name: modelName, contentType: 'application/json', schema: this.generateSchema(modelName) }] - } - }, - (objValue, srcValue) => { - if (isArray(objValue)) { - return objValue.concat(srcValue); - } - } - ); - } - - generateSchema(modelName) { - this.log(`Generating schema for ${modelName}`); - - this.schemaGenerator = - this.schemaGenerator || - createGenerator({ - path: this.typescriptApiModelPath, - tsconfig: this.tsconfigPath, - type: `*`, - expose: 'export', - skipTypeCheck: true, - topRef: false - }); - - return this.schemaGenerator.createSchema(modelName); - } + enrichMethodsInfo(openApi) { + const tagName = openApi.info.title; + openApi.tags = [ + { + name: tagName, + description: openApi.info.description + } + ]; + const customTags = this.serverless.service.custom.documentation?.tags; + if (customTags) openApi.tags = openApi.tags.concat(customTags) + + Object.values(openApi.paths).forEach(path => { + Object.values(path).forEach(method => { + const httpEvent = this.functions[method.operationId]?.events?.find( + (e: ApiGatewayEvent) => e.http + ) as ApiGatewayEvent; + const http: HttpEvent = httpEvent.http; + if (http.documentation?.tag) { + method.tags = [http.documentation.tag]; + } else { + method.tags = [tagName]; + } + + method.operationId = kebabCase(`${this.serverless.service.custom?.documentation?.title}-${method.operationId}`); + }); + }); + } + + setModel(modelName) { + mergeWith( + this.serverless.service.custom, + { + documentation: { + models: [{ name: modelName, contentType: 'application/json', schema: this.generateSchema(modelName) }] + } + }, + (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue); + } + } + ); + } + + generateSchema(modelName) { + this.log(`Generating schema for ${modelName}`); + + this.schemaGenerator = + this.schemaGenerator || + createGenerator({ + path: this.typescriptApiModelPath, + tsconfig: this.tsconfigPath, + type: `*`, + expose: 'export', + skipTypeCheck: true, + topRef: false + }); + + return this.schemaGenerator.createSchema(modelName); + } } From eb6799ccf6aa3ae0dee0b076c65ae7cb7786c137 Mon Sep 17 00:00:00 2001 From: Elbaz Date: Wed, 15 May 2024 15:52:04 +0300 Subject: [PATCH 4/7] Remove pretty --- test/serverless-openapi-typescript.spec.ts | 141 ++++++++++----------- 1 file changed, 70 insertions(+), 71 deletions(-) diff --git a/test/serverless-openapi-typescript.spec.ts b/test/serverless-openapi-typescript.spec.ts index 1101e13..644eb62 100644 --- a/test/serverless-openapi-typescript.spec.ts +++ b/test/serverless-openapi-typescript.spec.ts @@ -1,8 +1,8 @@ -import Serverless from 'serverless'; -import path from 'path'; -import fs from 'fs'; -import { promisify } from 'util'; -import yaml from 'js-yaml'; +import Serverless from "serverless"; +import path from "path"; +import fs from "fs"; +import {promisify} from "util"; +import yaml from "js-yaml"; const readFileAsync = promisify(fs.readFile); const deleteFileAsync = promisify(fs.unlink); @@ -11,104 +11,103 @@ const existsAsync = promisify(fs.exists); jest.setTimeout(60000); describe('ServerlessOpenapiTypeScript', () => { - - describe.each` + describe.each` testCase | projectName - ${'Custom Tags'} | ${'custom-tags'} - ${'Hyphenated Functions'} | ${'hyphenated-functions'} - ${'Full Project'} | ${'full'} - ${'Webhooks'} | ${'webhooks'} - `('when using $testCase', ({ projectName }) => { - - beforeEach(async () => { - await deleteOutputFile(projectName); - }); + ${'Custom Tags'} | ${'custom-tags'} + ${'Hyphenated Functions'} | ${'hyphenated-functions'} + ${'Full Project'} | ${'full'} + ${'Webhooks'} | ${'webhooks'} + `('when using $testCase', ({projectName}) => { + + beforeEach(async () => { + await deleteOutputFile(projectName); + }); - it('should create the expected file', async () => { - await runOpenApiGenerate(projectName); + it('should create the expected file', async () => { + await runOpenApiGenerate(projectName); - await assertYamlFilesEquals(projectName); + await assertYamlFilesEquals(projectName); + }); }); - }); - describe('WithoutOpenAPI', () => { - const projectName = 'without-openapi'; + describe('WithoutOpenAPI', () => { + const projectName = 'without-openapi'; - it('should throw an error when serverless-openapi-documentation not loaded before', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation')); + it('should throw an error when serverless-openapi-documentation not loaded before', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('Please configure your serverless.plugins list so serverless-openapi-typescript will be listed AFTER @conqa/serverless-openapi-documentation')); + }); }); - }); - describe('NotDocumented', () => { - const projectName = 'not-documented'; + describe('NotDocumented', () => { + const projectName = 'not-documented'; - it('should throw an error when found function not documented', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(expect.objectContaining({ message: expect.stringContaining('deleteFunc') })); + it('should throw an error when found function not documented', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(expect.objectContaining({message: expect.stringContaining('deleteFunc')})); + }); }); - }); - describe('TypeMissing', () => { - const projectName = 'type-missing'; + describe('TypeMissing', () => { + const projectName = 'type-missing'; - it('should throw an error when type is missing', async () => { - await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('No root type "ProjectApi.CreateFunc.Request.Body" found')); + it('should throw an error when type is missing', async () => { + await expect(runOpenApiGenerate(projectName)).rejects.toEqual(new Error('No root type "ProjectApi.CreateFunc.Request.Body" found')); + }); }); - }); - describe('Disable', () => { - const projectName = 'disable'; + describe('Disable', () => { + const projectName = 'disable'; - it('should not create docs', async () => { - await runOpenApiGenerate(projectName); + it('should not create docs', async () => { + await runOpenApiGenerate(projectName); - await expect(existsAsync(`test/fixtures/expect-openapi-${projectName}.yml`)).resolves.toBeFalsy(); + await expect(existsAsync(`test/fixtures/expect-openapi-${projectName}.yml`)).resolves.toBeFalsy(); + }); }); - }); }); async function assertYamlFilesEquals(projectName: string): Promise { - const outputFile = `openapi-${projectName}.yml`; - const expectFile = `test/fixtures/expect-openapi-${projectName}.yml`; + const outputFile = `openapi-${projectName}.yml`; + const expectFile = `test/fixtures/expect-openapi-${projectName}.yml`; - const [actualOutput, expectOutput] = await Promise.all([processYamlFileForTest(expectFile), processYamlFileForTest(outputFile)]); - expect(expectOutput).toEqual(actualOutput); + const [actualOutput, expectOutput] = await Promise.all([processYamlFileForTest(expectFile), processYamlFileForTest(outputFile)]); + expect(expectOutput).toEqual(actualOutput); } async function processYamlFileForTest(path: string): Promise { - const yamlData = await readYaml(path); - delete yamlData.info.version; - return yaml.dump(yamlData); + const yamlData = await readYaml(path); + delete yamlData.info.version; + return yaml.dump(yamlData); } async function readYaml(path: string) { - const data = await readFileAsync(path); - return yaml.load(data); + const data = await readFileAsync(path); + return yaml.load(data); } async function deleteOutputFile(project) { - try { - await deleteFileAsync(`openapi-${project}.yml`); - } catch { - } + try { + await deleteFileAsync(`openapi-${project}.yml`); + } catch { + } } async function runOpenApiGenerate(projectName) { - const projectPath = path.join(__dirname, `serverless-${projectName}`); - const serverlessYamlPath = path.join(projectPath, 'resources/serverless.yml'); - const typescriptApiPath = path.join(projectPath, 'api.d.ts'); - const outputFile = `openapi-${projectName}.yml`; - - const config = await readYaml(serverlessYamlPath); - const sls = new Serverless({ - configurationPath: serverlessYamlPath, - configuration: config, - commands: ['openapi', 'generate'], - options: { - typescriptApiPath, - output: outputFile - } - }); + const projectPath = path.join(__dirname, `serverless-${projectName}`); + const serverlessYamlPath = path.join(projectPath, "resources/serverless.yml"); + const typescriptApiPath = path.join(projectPath, "api.d.ts"); + const outputFile = `openapi-${projectName}.yml`; + + const config = await readYaml(serverlessYamlPath); + const sls = new Serverless({ + configurationPath: serverlessYamlPath, + configuration: config, + commands: ['openapi', 'generate'], + options: { + typescriptApiPath, + output: outputFile + } + }); - await sls.init(); - await sls.run(); + await sls.init(); + await sls.run(); } From a44acdc971236be8916d3ba9fbc3dfc54bb17f63 Mon Sep 17 00:00:00 2001 From: Elbaz Date: Wed, 15 May 2024 15:52:30 +0300 Subject: [PATCH 5/7] remove pretty --- test/serverless-full/api.d.ts | 96 +++++++++++++++++------------------ 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/test/serverless-full/api.d.ts b/test/serverless-full/api.d.ts index d72bb4d..8d54730 100644 --- a/test/serverless-full/api.d.ts +++ b/test/serverless-full/api.d.ts @@ -1,60 +1,60 @@ interface ObjectType { - types?: string[]; - children?: ObjectType[]; + types?: string[]; + children?: ObjectType[]; } export namespace ProjectApi { - export type Bool = 'true' | 'false'; - export type Number = number - export type String = string; - export type GenericType = T[]; - export namespace Webhooks { - export type OnCreateWebhook = { - id: string; - name: string; - } - } - export namespace CreateFunc { - export namespace Request { - export type Body = { - data: string; - statusCode?: number; - enable: boolean; - object?: ObjectType; - }; + export type Bool = 'true' | 'false'; + export type Number = number + export type String = string; + export type GenericType = T[]; + export namespace Webhooks { + export type OnCreateWebhook = { + id: string; + name: string; + } } + export namespace CreateFunc { + export namespace Request { + export type Body = { + data: string; + statusCode?: number; + enable: boolean; + object?: ObjectType; + }; + } - export type Response = { - id: string; - uuid: string; - generic: GenericType<{ key: string, name: number }>; - }; - } - - export namespace DeleteFunc { - export namespace Request { - export type Body = { - id: string; - }; + export type Response = { + id: string; + uuid: string; + generic: GenericType<{ key: string, name: number}>; + }; } - } - export namespace UpdateFunc { - export namespace Request { - export type Body = { - id: string; - data: string; - }; + export namespace DeleteFunc { + export namespace Request { + export type Body = { + id: string; + }; + } } - export type Response = { - id: string; - }; - } + export namespace UpdateFunc { + export namespace Request { + export type Body = { + id: string; + data: string; + }; + } + + export type Response = { + id: string; + }; + } - export namespace GetFunc { - export type Response = { - data: string; - }; - } + export namespace GetFunc { + export type Response = { + data: string; + }; + } } From 01fc82243a50f9142504cbaad92cbaeb9f59f4f2 Mon Sep 17 00:00:00 2001 From: elbaz Date: Wed, 15 May 2024 16:00:11 +0300 Subject: [PATCH 6/7] fix missing from prettier rollbakc --- src/serverless-openapi-typescript.ts | 1 + test/fixtures/expect-openapi-full.yml | 6 +++--- test/serverless-full/resources/serverless.yml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/serverless-openapi-typescript.ts b/src/serverless-openapi-typescript.ts index d4b126b..2474a4e 100644 --- a/src/serverless-openapi-typescript.ts +++ b/src/serverless-openapi-typescript.ts @@ -208,6 +208,7 @@ export default class ServerlessOpenapiTypeScript { // @ts-ignore const outputFile = this.serverless.processedInput.options.output; const openApi = yaml.load(fs.readFileSync(outputFile)); + openApi['webhooks'] = this.webhookEntries; this.patchOpenApiVersion(openApi); this.enrichMethodsInfo(openApi); const encodedOpenAPI = this.encodeOpenApiToStandard(openApi); diff --git a/test/fixtures/expect-openapi-full.yml b/test/fixtures/expect-openapi-full.yml index deae1c7..d2ffd30 100644 --- a/test/fixtures/expect-openapi-full.yml +++ b/test/fixtures/expect-openapi-full.yml @@ -37,7 +37,7 @@ components: type: string generic: $ref: >- - #/components/schemas/ProjectApi.GenericType_structure-1448918441-658-687-1448918441-645-688-1448918441-630-689-1448918441-590-695-1448918441-562-696-1448918441-382-700-1448918441-352-700-1448918441-100-1129-1448918441-71-1129-1448918441-0-1130_ + #/components/schemas/ProjectApi.GenericType_structure-1448918441-758-786-1448918441-745-787-1448918441-724-788-1448918441-672-798-1448918441-640-799-1448918441-408-805-1448918441-376-805-1448918441-104-1338-1448918441-75-1338-1448918441-0-1339_ required: - id - uuid @@ -81,7 +81,7 @@ components: - id - name additionalProperties: false - ProjectApi.GenericType_structure-1448918441-658-687-1448918441-645-688-1448918441-630-689-1448918441-590-695-1448918441-562-696-1448918441-382-700-1448918441-352-700-1448918441-100-1129-1448918441-71-1129-1448918441-0-1130_: + ProjectApi.GenericType_structure-1448918441-758-786-1448918441-745-787-1448918441-724-788-1448918441-672-798-1448918441-640-799-1448918441-408-805-1448918441-376-805-1448918441-104-1338-1448918441-75-1338-1448918441-0-1339_: type: array items: type: object @@ -243,7 +243,7 @@ webhooks: schema: $ref: >- #/components/schemas/ProjectApi.Webhooks.OnCreateWebhook - response: + responses: '200': description: | This is a expected response description diff --git a/test/serverless-full/resources/serverless.yml b/test/serverless-full/resources/serverless.yml index a1abce8..27d3833 100644 --- a/test/serverless-full/resources/serverless.yml +++ b/test/serverless-full/resources/serverless.yml @@ -26,7 +26,7 @@ custom: requestBody: description: | This is a request body description - response: + responses: 200: description: | This is a expected response description From 87c246fa2b898a367c6fc39b451c7aeb4c9f2ace Mon Sep 17 00:00:00 2001 From: elbaz Date: Wed, 15 May 2024 16:40:08 +0300 Subject: [PATCH 7/7] added readme --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index 56bcadd..cc0dc22 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Works well with [Stoplight.io](https://stoplight.io/) - [`cookieParams`](#cookieparams) - [`requestModels`](#requestmodels) - [`methodResponses`](#methodresponses-and-responsemodels) + - [`webhooks`](#webhooks) - [Install](#install) --- @@ -114,6 +115,7 @@ The `documentation` section of the event configuration can contain the following * `pathParams`: a list of path parameters (see [pathParams](#pathparams) below) - **these _can_ be autogenerated for you from TypeScript** * `cookieParams`: a list of cookie parameters (see [cookieParams](#cookieparams) below) * `methodResponses`: an array of response models and applicable status codes (see [methodResponses](#methodresponses-and-responsemodels)) - **these _will_ be autogenerated for you from TypeScript** +* `webhooks`: an object with all the webhooks with descriptions (see [webhooks](#webhooks)) - **these _will_ be autogenerated for you from TypeScript** ```yml functions: @@ -435,6 +437,68 @@ functions: Endpoints that are not attached to a custom tag, are still attached to the title ( which is the default tag ). +#### `webhooks` +OpenAPI have an option to add your application `webhooks`, while this feature isn't supported by `serverless`. + +For those the plugin will look for the webhooks under `custom.documentation.webhooks`. + +For example + +```yaml +custom: + documentation: + apiNamespace: MyApi + webhooks: + WebhookName: + post: + requestBody: + description: | + This is a request body description + responses: + 200: + description: | + This is a expected response description +``` + +this will generate the next OpenAPI file + +```yaml +components: + schemas: + MyApi.Webhooks.WebhookName: + type: object +webhooks: + WebhookName: + post: + requestBody: + description: | + This is a request body description + content: + application/json: + schema: + $ref: '#/components/schemas/MyApi.Webhooks.WebhookName' + responses: + '200': + description: | + This is a expected response description +``` + +With the next `api.d.ts`: + +```typescript +export namespace MyApi { + export namespace Webhooks { + export type WebhookName = { + id: string, + name: string, + age: number + // ... + } + } +} +``` + + ## Install This plugin is **an extension**.