├── .nvmrc ├── .node-version ├── .prettierrc ├── templates ├── sdk │ ├── .npmignore.njk │ ├── index.ts.njk │ ├── tsconfig.json.njk │ └── package.json.njk ├── model.ts.njk └── client.ts.njk ├── .eslint.project.json ├── e2e ├── src │ ├── teardown.ts │ ├── config.ts │ ├── setup.ts │ ├── server.ts │ └── __tests__ │ │ ├── be │ │ ├── definitions.test.ts │ │ ├── client.test.ts │ │ └── request-types.test.ts │ │ ├── test-api-v3 │ │ ├── request-types.test.ts │ │ └── definitions.test.ts │ │ └── test-api │ │ └── request-types.test.ts ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── CODEOWNERS ├── src ├── index.ts ├── commands │ ├── gen-api-sdk │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ ├── types.ts │ │ ├── index.ts │ │ └── cli.ts │ ├── bundle-api-spec │ │ ├── index.ts │ │ └── cli.ts │ └── gen-api-models │ │ ├── __tests__ │ │ ├── utils │ │ │ └── parser.utils.ts │ │ ├── render.test.ts │ │ └── __snapshots__ │ │ │ └── render.test.ts.snap │ │ ├── cli.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── templateEnvironment.ts │ │ ├── parse.utils.ts │ │ └── render.ts └── lib │ ├── templating │ ├── __tests__ │ │ └── filters.test.ts │ ├── index.ts │ └── filters.ts │ └── utils.ts ├── .auto-changelog.json ├── .gitignore ├── AUTHORS ├── __mocks__ ├── response.ts ├── definitions.yaml ├── openapi_v3 │ └── definitions.yaml └── be.yaml ├── tsconfig.json ├── preview.hbs ├── .eslintrc.js ├── .devops ├── azure-templates │ └── setup-project.yml ├── code-review-pipelines.yml └── deploy-pipelines.yml ├── azure-templates └── make-build-steps.yml ├── PULL_REQUEST_TEMPLATE.md ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── pr-title-linter-and-linker.yaml ├── package.json ├── README.md └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13.0 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.13.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # .prettierrc 2 | parser: typescript 3 | -------------------------------------------------------------------------------- /templates/sdk/.npmignore.njk: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | tsconfig.json 4 | -------------------------------------------------------------------------------- /.eslint.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*", 4 | "e2e/**/*" 5 | ] 6 | } -------------------------------------------------------------------------------- /templates/sdk/index.ts.njk: -------------------------------------------------------------------------------- 1 | import { Client } from "./client"; 2 | 3 | export default Client; 4 | -------------------------------------------------------------------------------- /e2e/src/teardown.ts: -------------------------------------------------------------------------------- 1 | import { stopAllServers } from "./server"; 2 | 3 | export default stopAllServers; 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # see https://help.github.com/en/articles/about-code-owners#example-of-a-codeowners-file 2 | 3 | * @pagopa/io-backend-contributors -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/node_modules/ 3 | **/generated/ 4 | npm-debug.log 5 | yarn-error.log 6 | local* 7 | .vscode 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { generateApi } from "./commands/gen-api-models"; 2 | import { generateSdk } from "./commands/gen-api-sdk"; 3 | 4 | export { generateApi, generateSdk }; 5 | -------------------------------------------------------------------------------- /.auto-changelog.json: -------------------------------------------------------------------------------- 1 | { 2 | "issuePattern": "\\[#(\\d+)\\]", 3 | "issueUrl": "https://www.pivotaltracker.com/story/show/{id}", 4 | "breakingPattern": "BREAKING CHANGE:" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | npm-debug.log 4 | yarn-error.log 5 | local* 6 | .vscode 7 | coverage 8 | .npmrc 9 | 10 | # eslint section 11 | !.eslintrc.js 12 | .eslintcache 13 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2018 Presidenza del Consiglio dei Ministri 2 | Copyright (c) 2017-2018 Agenzia per l'Italia Digitale 3 | 4 | The version control system provides attribution for specific lines of code. 5 | -------------------------------------------------------------------------------- /__mocks__/response.ts: -------------------------------------------------------------------------------- 1 | export default function mockResponse( 2 | status: number, 3 | body?: any, 4 | headers?: any 5 | ) { 6 | return { 7 | status, 8 | json: async () => body, 9 | headers, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /templates/sdk/tsconfig.json.njk: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noImplicitAny": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "lib": ["es2015", "dom"] 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es6"], 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "declaration": true, 11 | "sourceMap": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["**/__tests__/*", "node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "lib": ["es2015", "dom"] 13 | }, 14 | "include": ["src/**/*", "generated/**/*"], 15 | "exclude": ["node_modules", "src/**/__tests__/*"] 16 | } 17 | -------------------------------------------------------------------------------- /preview.hbs: -------------------------------------------------------------------------------- 1 | {{#each releases}} 2 | {{#if @first}} 3 | {{#each merges}} 4 | - {{{message}}}{{#if href}} [`#{{id}}`]({{href}}){{/if}} 5 | {{/each}} 6 | {{#each fixes}} 7 | - {{{commit.subject}}}{{#each fixes}}{{#if href}} [`#{{id}}`]({{href}}){{/if}}{{/each}} 8 | {{/each}} 9 | {{#each commits}} 10 | - {{#if breaking}}**Breaking change:** {{/if}}{{{subject}}}{{#if href}} [`{{shorthash}}`]({{href}}){{/if}} 11 | {{/each}} 12 | {{/if}} 13 | {{/each}} 14 | -------------------------------------------------------------------------------- /templates/model.ts.njk: -------------------------------------------------------------------------------- 1 | {%- import "macros.njk" as macro -%} 2 | 3 | /** 4 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 5 | * See https://github.com/pagopa/io-utils 6 | */ 7 | /* eslint-disable */ 8 | 9 | {{- null | resetImports -}} 10 | {{- null | resetTypeAliases -}} 11 | 12 | {% set definition %} 13 | {{ macro.defineObject(definitionName, definition, strictInterfaces, camelCasedPropNames) }} 14 | {% endset %} 15 | 16 | {{ null | getImports | safe }} 17 | {{ null | getTypeAliases | safe }} 18 | {{ definition | safe }} 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "ignorePatterns": [ 8 | "node_modules", 9 | "generated", 10 | "**/__tests__/*", 11 | "**/__mocks__/*", 12 | "*.d.ts" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "project": ".eslint.project.json", 17 | "sourceType": "module" 18 | }, 19 | "extends": [ 20 | "@pagopa/eslint-config/strong", 21 | ], 22 | "rules": {} 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/gen-api-sdk/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderAll } from "../index"; 2 | import { 3 | IGeneratorParams, 4 | IPackageAttributes 5 | } from "../types"; 6 | 7 | describe("gen-api-skd", () => { 8 | it("should render multiple templates", async () => { 9 | const result = await renderAll( 10 | ["tsconfig.json.njk", "index.ts.njk"], 11 | {} as IPackageAttributes & IGeneratorParams 12 | ); 13 | 14 | expect(result).toEqual({ 15 | "tsconfig.json.njk": expect.any(String), 16 | "index.ts.njk": expect.any(String) 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /templates/sdk/package.json.njk: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ name }}", 3 | "version": "{{ version }}", 4 | "description": "{{ description }}", 5 | "author": "{{ author }}", 6 | "license": "{{ license }}", 7 | "main": "index.js", 8 | "types": "index.d.js", 9 | "scripts": { 10 | "prepublishOnly": "npm run build", 11 | "build": "tsc --skipLibCheck" 12 | }, 13 | "dependencies": { 14 | "@pagopa/ts-commons": "^10.0.0", 15 | "fp-ts": "^2.10.5", 16 | "io-ts": "^2.2.16" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^4.3.0" 20 | }{% if registry %}, 21 | "publishConfig": { 22 | "registry": "{{ registry }}", 23 | "access": "{{ access }}" 24 | }{% endif %} 25 | } 26 | -------------------------------------------------------------------------------- /__mocks__/definitions.yaml: -------------------------------------------------------------------------------- 1 | Person: 2 | type: object 3 | properties: 4 | name: 5 | type: string 6 | description: |- 7 | name of the person 8 | address: 9 | $ref: "#/Address" 10 | Address: 11 | type: object 12 | properties: 13 | location: 14 | type: string 15 | city: 16 | type: string 17 | zipCode: 18 | $ref: "#/ZipCode" 19 | ZipCode: 20 | type: string 21 | pattern: '^\d{5}$' 22 | Author: 23 | type: object 24 | properties: 25 | isDead: 26 | type: boolean 27 | info: 28 | $ref: "#/Person" 29 | Book: 30 | type: object 31 | properties: 32 | title: 33 | type: string 34 | description: |- 35 | title of the book 36 | author: 37 | $ref: "#/Author" 38 | -------------------------------------------------------------------------------- /__mocks__/openapi_v3/definitions.yaml: -------------------------------------------------------------------------------- 1 | Person: 2 | type: object 3 | properties: 4 | name: 5 | type: string 6 | description: |- 7 | name of the person 8 | address: 9 | $ref: "#/Address" 10 | Address: 11 | type: object 12 | properties: 13 | location: 14 | type: string 15 | city: 16 | type: string 17 | zipCode: 18 | $ref: "#/ZipCode" 19 | ZipCode: 20 | type: string 21 | pattern: '^\d{5}$' 22 | Author: 23 | type: object 24 | properties: 25 | isDead: 26 | type: boolean 27 | info: 28 | $ref: "#/Person" 29 | Book: 30 | type: object 31 | properties: 32 | title: 33 | type: string 34 | description: |- 35 | title of the book 36 | author: 37 | $ref: "#/Author" 38 | -------------------------------------------------------------------------------- /src/commands/gen-api-sdk/types.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV2 } from "openapi-types"; 2 | 3 | export interface IRegistryAttributes { 4 | readonly registry?: string; 5 | readonly access?: string; 6 | } 7 | 8 | export interface IGeneratorParams { 9 | readonly specFilePath: string | OpenAPIV2.Document; 10 | readonly outPath: string; 11 | readonly defaultSuccessType?: string; 12 | readonly defaultErrorType?: string; 13 | readonly camelCasedPropNames: boolean; 14 | } 15 | 16 | export interface IPackageAttributes { 17 | readonly name: string; 18 | readonly version: string; 19 | readonly description: string; 20 | readonly author: string; 21 | readonly license: string; 22 | } 23 | 24 | export type IGenerateSdkOptions = IGeneratorParams & 25 | IRegistryAttributes & 26 | ( 27 | | ({ 28 | readonly inferAttr: false; 29 | } & IPackageAttributes) 30 | | ({ 31 | readonly inferAttr: true; 32 | } & Partial) 33 | ); 34 | -------------------------------------------------------------------------------- /src/commands/bundle-api-spec/index.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV2 } from "openapi-types"; 2 | import * as SwaggerParser from "swagger-parser"; 3 | import * as writeYamlFile from "write-yaml-file"; 4 | 5 | export interface IBundleApiSpecOptions { 6 | readonly specFilePath: string | OpenAPIV2.Document; 7 | readonly outPath: string; 8 | readonly version?: string; 9 | } 10 | 11 | /** 12 | * Takes an OpenAPI spec and writes a new one with only inner references 13 | * 14 | * @param param0 15 | */ 16 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 17 | export async function bundleApiSpec({ 18 | outPath, 19 | specFilePath, 20 | version 21 | }: IBundleApiSpecOptions) { 22 | const bundled = await SwaggerParser.bundle(specFilePath); 23 | // overwrite version 24 | const edited = version 25 | ? { ...bundled, info: { ...bundled.info, version } } 26 | : bundled; 27 | 28 | return writeYamlFile(outPath, edited); 29 | } 30 | -------------------------------------------------------------------------------- /.devops/azure-templates/setup-project.yml: -------------------------------------------------------------------------------- 1 | # Azure DevOps pipeline template used to setup the Node project: 2 | # 1. checkout code 3 | # 2. setup correct node version 4 | # 3. install node dependencies 5 | 6 | parameters: 7 | # the branch, tag or commit to deploy 8 | - name: 'gitReference' 9 | type: string 10 | default: '$(Build.SourceVersion)' 11 | 12 | steps: 13 | - checkout: self 14 | displayName: 'Checkout' 15 | 16 | # This is needed because the pipeline may point to a different commit than expected 17 | # The common case is when the previous stage pushed another commit 18 | - ${{ if ne(parameters.gitReference, variables['Build.SourceVersion']) }}: 19 | - script: | 20 | git fetch && git checkout ${{ parameters.gitReference }} 21 | displayName: 'Checkout reference' 22 | 23 | - task: UseNode@1 24 | inputs: 25 | version: $(NODE_VERSION) 26 | displayName: 'Set up Node.js' 27 | 28 | - script: | 29 | yarn install --frozen-lockfile --no-progress --non-interactive --network-concurrency 1 30 | displayName: 'Install node modules' 31 | -------------------------------------------------------------------------------- /azure-templates/make-build-steps.yml: -------------------------------------------------------------------------------- 1 | # Azure DevOps pipeline template used to checkout, install node dependencies 2 | # and build the code. 3 | 4 | parameters: 5 | - name: 'make' 6 | type: string 7 | default: install_dependencies 8 | values: 9 | - install_dependencies 10 | - build 11 | 12 | - name: 'cache_version_id' 13 | type: string 14 | default: $(CACHE_VERSION_ID) 15 | 16 | steps: 17 | - checkout: self 18 | persistCredentials: true 19 | displayName: 'Checkout' 20 | 21 | - task: Cache@2 22 | inputs: 23 | key: 'yarn-${{ parameters.cache_version_id }} | "$(Agent.OS)" | yarn.lock' 24 | restoreKeys: | 25 | yarn-${{ parameters.cache_version_id }} | "$(Agent.OS)" 26 | path: $(YARN_CACHE_FOLDER) 27 | displayName: 'Cache yarn packages' 28 | 29 | - task: UseNode@1 30 | inputs: 31 | version: $(NODE_VERSION) 32 | displayName: 'Set up Node.js' 33 | 34 | - script: | 35 | yarn install --frozen-lockfile --ignore-scripts 36 | displayName: 'Install yarn dependencies' 37 | 38 | - ${{ if eq(parameters.make, 'build') }}: 39 | - bash: | 40 | yarn build 41 | displayName: 'Build code' 42 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pagopa/openapi-codegen-ts-e2e", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "jest --verbose", 7 | "lint": "tslint --project ." 8 | }, 9 | "devDependencies": { 10 | "@stoplight/prism-http-server": "^3.3.3", 11 | "@types/jest": "^25.2.1", 12 | "@types/node": "^13.11.0", 13 | "@types/node-fetch": "^2.5.5", 14 | "fs-extra": "^9.0.0", 15 | "jest": "^25.2.7", 16 | "leaked-handles": "^5.2.0", 17 | "node-fetch": "^2.6.7", 18 | "rimraf": "^2.6.2", 19 | "ts-jest": "^25.3.1", 20 | "typescript": "^4.3.5" 21 | }, 22 | "dependencies": { 23 | "@pagopa/openapi-codegen-ts": "../", 24 | "@pagopa/ts-commons": "^10.15.0", 25 | "fp-ts": "^2.10.5", 26 | "io-ts": "^2.2.16" 27 | }, 28 | "jest": { 29 | "testEnvironment": "node", 30 | "collectCoverage": false, 31 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 32 | "moduleFileExtensions": [ 33 | "js", 34 | "json", 35 | "jsx", 36 | "node", 37 | "ts", 38 | "tsx" 39 | ], 40 | "preset": "ts-jest", 41 | "testMatch": null, 42 | "globalSetup": "./src/setup.ts", 43 | "globalTeardown": "./src/teardown.ts" 44 | }, 45 | "resolutions": { 46 | "y18n": "^4.0.1", 47 | "ansi-regex": "^5.0.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/templating/__tests__/filters.test.ts: -------------------------------------------------------------------------------- 1 | import { safeIdentifier, safeDestruct } from "../filters"; 2 | 3 | describe("safeIdentifier", () => { 4 | it.each` 5 | name | value | expected 6 | ${"valid identifier"} | ${"validId"} | ${"validId"} 7 | ${"with dashes"} | ${"my-identifier"} | ${"myIdentifier"} 8 | ${"with spaces"} | ${"my identifier"} | ${"myIdentifier"} 9 | ${"with leading number"} | ${"999myIdentifier"} | ${"myIdentifier"} 10 | ${"array of values"} | ${["validId", "my-identifier"]} | ${["validId", "myIdentifier"]} 11 | `("should safe '$name'", ({ value, expected }) => { 12 | const result = safeIdentifier(value); 13 | expect(result).toEqual(expected); 14 | }); 15 | }); 16 | 17 | describe("safeDestruct", () => { 18 | it.each` 19 | name | value | expected 20 | ${"valid identifier"} | ${"validId"} | ${'["validId"]: validId'} 21 | ${"with dashes"} | ${"my-identifier"} | ${'["my-identifier"]: myIdentifier'} 22 | ${"array of values"} | ${["validId", "my-identifier"]} | ${['["validId"]: validId', '["my-identifier"]: myIdentifier']} 23 | `("should safe '$name'", ({ value, expected }) => { 24 | const result = safeDestruct(value); 25 | expect(result).toEqual(expected); 26 | }); 27 | }); -------------------------------------------------------------------------------- /src/commands/bundle-api-spec/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs = require("yargs"); 4 | import { bundleApiSpec } from "."; 5 | 6 | // 7 | // parse command line 8 | // 9 | 10 | const CODE_GROUP = "Code generation options:"; 11 | 12 | const argv = yargs 13 | .option("api-spec", { 14 | alias: "i", 15 | demandOption: true, 16 | description: "Path to input OpenAPI spec file", 17 | group: CODE_GROUP, 18 | normalize: true, 19 | // eslint-disable-next-line id-blacklist 20 | string: true 21 | }) 22 | .option("out-path", { 23 | alias: "o", 24 | demandOption: true, 25 | description: "Output path of the spec file", 26 | group: CODE_GROUP, 27 | normalize: true, 28 | // eslint-disable-next-line id-blacklist 29 | string: true 30 | }) 31 | .option("api-version", { 32 | alias: "V", 33 | description: 34 | "Version of the api. If provided, override the version in the original spec file", 35 | group: CODE_GROUP, 36 | // eslint-disable-next-line id-blacklist 37 | string: true 38 | }) 39 | .help().argv; 40 | 41 | // 42 | // BUNDLE APIs 43 | // 44 | bundleApiSpec({ 45 | outPath: argv["out-path"], 46 | specFilePath: argv["api-spec"], 47 | version: argv["api-version"] 48 | }).then( 49 | // eslint-disable-next-line no-console 50 | () => console.log("done"), 51 | err => { 52 | // eslint-disable-next-line no-console 53 | console.log(`Error: ${err}`); 54 | process.exit(1); 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #### Description 7 | 8 | 9 | #### Motivation and Context 10 | 11 | 12 | #### How Has This Been Tested? 13 | 14 | 15 | 16 | 17 | #### Screenshots (if appropriate): 18 | 19 | #### Types of changes 20 | 21 | - [ ] Bug fix (non-breaking change which fixes an issue) 22 | - [ ] New feature (non-breaking change which adds functionality) 23 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 24 | 25 | #### Checklist: 26 | 27 | 28 | - [ ] My change requires a change to the documentation. 29 | - [ ] I have updated the documentation accordingly. 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #### Description 7 | 8 | 9 | #### Motivation and Context 10 | 11 | 12 | #### How Has This Been Tested? 13 | 14 | 15 | 16 | 17 | #### Screenshots (if appropriate): 18 | 19 | #### Types of changes 20 | 21 | - [ ] Chore (improvement with no change in the behaviour) 22 | - [ ] Bug fix (non-breaking change which fixes an issue) 23 | - [ ] New feature (non-breaking change which adds functionality) 24 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 25 | 26 | #### Checklist: 27 | 28 | 29 | - [ ] My change requires a change to the documentation. 30 | - [ ] I have updated the documentation accordingly. 31 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/__tests__/utils/parser.utils.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPI, OpenAPIV2, OpenAPIV3 } from "openapi-types"; 2 | 3 | import * as ParseOpenapiV2 from "../../parse.v2"; 4 | import * as ParseOpenapiV3 from "../../parse.v3"; 5 | 6 | const parserV2 = { 7 | getAuthHeaders: ParseOpenapiV2.getAuthHeaders, 8 | parseOperation: ParseOpenapiV2.parseOperation, 9 | parseAllOperations: ParseOpenapiV2.parseAllOperations, 10 | parseDefinition: ParseOpenapiV2.parseDefinition, 11 | parseSpecMeta: ParseOpenapiV2.parseSpecMeta 12 | }; 13 | const parserV3 = { 14 | getAuthHeaders: ParseOpenapiV3.getAuthHeaders, 15 | parseOperation: ParseOpenapiV3.parseOperation, 16 | parseAllOperations: ParseOpenapiV3.parseAllOperations, 17 | parseDefinition: ParseOpenapiV3.parseDefinition, 18 | parseSpecMeta: ParseOpenapiV3.parseSpecMeta 19 | }; 20 | 21 | // TODO: 22 | // Use ../parse.utils.ts getParser instead 23 | 24 | export const getParser = ( 25 | spec: D 26 | ): D extends OpenAPIV2.Document ? typeof parserV2 : typeof parserV3 => 27 | ParseOpenapiV2.isOpenAPIV2(spec) 28 | ? parserV2 29 | : ( 30 | parserV3 31 | ); 32 | 33 | // util to ensure a defintion is defined 34 | export const getDefinitionOrFail = ( 35 | spec: OpenAPIV2.Document | OpenAPIV3.Document, 36 | definitionName: string 37 | ) => { 38 | const definition = ParseOpenapiV2.isOpenAPIV2(spec) 39 | ? spec.definitions?.[definitionName] 40 | : spec.components?.schemas?.[definitionName]; 41 | if (typeof definition === "undefined") { 42 | fail(`Unable to find definition ${definitionName}`); 43 | } 44 | return definition; 45 | }; 46 | -------------------------------------------------------------------------------- /.devops/code-review-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Azure DevOps pipeline to build, check source codes and run tests. 2 | 3 | variables: 4 | NODE_VERSION: '12.19.1' 5 | YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn 6 | 7 | # Automatically triggered on PR 8 | # https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema%2Cparameter-schema#pr-trigger 9 | trigger: none 10 | 11 | pool: 12 | vmImage: 'ubuntu-latest' 13 | 14 | stages: 15 | - stage: Build 16 | dependsOn: [] 17 | jobs: 18 | - job: make_build 19 | steps: 20 | - template: azure-templates/setup-project.yml 21 | - script: | 22 | yarn build 23 | displayName: 'Build' 24 | 25 | - stage: Static_analysis 26 | dependsOn: [] 27 | jobs: 28 | - job: lint 29 | steps: 30 | - template: azure-templates/setup-project.yml 31 | - script: | 32 | yarn lint 33 | displayName: 'Lint' 34 | - script: | 35 | yarn e2e:lint 36 | displayName: 'Lint e2e project' 37 | 38 | - stage: Test 39 | dependsOn: [] 40 | jobs: 41 | - job: unit_tests 42 | steps: 43 | - template: azure-templates/setup-project.yml 44 | - script: | 45 | yarn test 46 | displayName: 'Unit tests exec' 47 | 48 | - bash: | 49 | bash <(curl -s https://codecov.io/bash) 50 | displayName: 'Code coverage' 51 | 52 | - stage: Test_e2e 53 | dependsOn: [] 54 | jobs: 55 | - job: e2e_tests 56 | steps: 57 | - template: azure-templates/setup-project.yml 58 | # e2e script builds the package 59 | - script: | 60 | yarn e2e 61 | displayName: 'e2e tests exec' 62 | -------------------------------------------------------------------------------- /e2e/src/config.ts: -------------------------------------------------------------------------------- 1 | const ROOT_DIRECTORY_FOR_E2E = `${process.cwd()}`; 2 | const GENERATED_BASE_DIR = `${ROOT_DIRECTORY_FOR_E2E}/src/generated`; 3 | 4 | /** 5 | * parse a string value into a boolean 6 | * 7 | * @param v string value to be parsed 8 | * 9 | * @returns true or false 10 | */ 11 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 12 | const truthy = (v: string | undefined) => 13 | v === "true" || v === "TRUE" || v === "1"; 14 | 15 | /** 16 | * 17 | * @param list a comma-separated list. Can be undefined or empty 18 | * @param item an item to find 19 | * @param defaultIfUndefined wheater a search on an undefined/empty list is to be considered as a successful search 20 | * 21 | * @returns true of false wheater list include item 22 | */ 23 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 24 | const includeInList = ( 25 | list: string | undefined, 26 | item: string, 27 | defaultIfUndefined: boolean = true 28 | ) => (list ? list.split(",").includes(item) : defaultIfUndefined); 29 | 30 | export default { 31 | generatedFilesBaseDir: GENERATED_BASE_DIR, 32 | skipClient: truthy(process.env.SKIP_CLIENT), 33 | skipGeneration: truthy(process.env.SKIP_GENERATION), 34 | specs: { 35 | be: { 36 | generatedFilesDir: `${GENERATED_BASE_DIR}/be`, 37 | isSpecEnabled: includeInList(process.env.INCLUDE_SPECS, "be"), 38 | mockPort: 4102, 39 | url: `${ROOT_DIRECTORY_FOR_E2E}/../__mocks__/be.yaml` 40 | }, 41 | testapi: { 42 | generatedFilesDir: `${GENERATED_BASE_DIR}/testapi`, 43 | isSpecEnabled: includeInList(process.env.INCLUDE_SPECS, "testapi"), 44 | mockPort: 4101, 45 | url: `${ROOT_DIRECTORY_FOR_E2E}/../__mocks__/api.yaml` 46 | }, 47 | testapiV3: { 48 | generatedFilesDir: `${GENERATED_BASE_DIR}/testapiV3`, 49 | isSpecEnabled: includeInList(process.env.INCLUDE_SPECS, "testapiV3"), 50 | mockPort: 4103, 51 | url: `${ROOT_DIRECTORY_FOR_E2E}/../__mocks__/openapi_v3/api.yaml` 52 | } 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/lib/templating/index.ts: -------------------------------------------------------------------------------- 1 | import * as nunjucks from "nunjucks"; 2 | import * as defaultFilters from "./filters"; 3 | 4 | export const DEFAULT_TEMPLATE_DIR = `${__dirname}/../../../templates`; 5 | 6 | /** 7 | * Create an instance of teh template engine. 8 | * Default filters are included along side custom filters 9 | * 10 | * @param templateDir base directory for templates 11 | * @param customFilters list of custom filters to apply to the environment 12 | */ 13 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 14 | export const createTemplateEnvironment = ({ 15 | templateDir = DEFAULT_TEMPLATE_DIR, 16 | customFilters = {} 17 | }: { 18 | readonly templateDir?: string; 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | readonly customFilters?: Record) => any>; 21 | } = {}) => { 22 | nunjucks.configure({ 23 | trimBlocks: true 24 | }); 25 | const env = new nunjucks.Environment( 26 | new nunjucks.FileSystemLoader(templateDir) 27 | ); 28 | 29 | // make custom filters available in the rendered templates 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | const filters: Record) => any> = { 32 | ...defaultFilters, 33 | ...customFilters 34 | }; 35 | Object.keys(filters).forEach(filterName => { 36 | const filter = filters[filterName]; 37 | env.addFilter(filterName, filter); 38 | }); 39 | 40 | /** 41 | * Override the default render function to return a Promise 42 | * 43 | * @param templateName file name of the template to render 44 | * @param context optional object of data to pass to the template 45 | * 46 | * @return a promise of the rendered template 47 | */ 48 | // eslint-disable-next-line @typescript-eslint/ban-types 49 | const render = (templateName: string, context?: object): Promise => 50 | new Promise((accept, reject) => { 51 | env.render(templateName, context, (err, res) => { 52 | if (err) { 53 | return reject(err); 54 | } 55 | accept(res || ""); 56 | }); 57 | }); 58 | 59 | return { 60 | ...env, 61 | render 62 | }; 63 | }; 64 | 65 | export const defaultTemplateEnvironment = createTemplateEnvironment(); 66 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Uppercase on first letter of a string 3 | * 4 | * @param s string to be capitalized 5 | */ 6 | 7 | import { 8 | IAuthHeaderParameterInfo, 9 | IHeaderParameterInfo, 10 | IParameterInfo 11 | } from "../commands/gen-api-models/types"; 12 | 13 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 14 | export function capitalize(s: string): string { 15 | return `${s[0].toUpperCase()}${s.slice(1)}`; 16 | } 17 | 18 | /** 19 | * Lowercase on first letter of a string 20 | * 21 | * @param s string to be uncapitalized 22 | */ 23 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 24 | export function uncapitalize(s: string): string { 25 | return `${s[0].toLowerCase()}${s.slice(1)}`; 26 | } 27 | 28 | /** 29 | * Wrap a string in doublequote 30 | * 31 | * @param s string to be wrapped 32 | */ 33 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 34 | export const doubleQuote = (s: string) => `"${s}"`; 35 | 36 | /** 37 | * Converts an array of terms into a string representing a union of literals 38 | * 39 | * @param arr array of literals to be converted 40 | * @param onEmpty what to return in case of empty union 41 | */ 42 | export const toUnionOfLiterals = ( 43 | arr: ReadonlyArray, 44 | onEmpty = "never" 45 | ): string => (arr.length ? arr.map(doubleQuote).join(" | ") : onEmpty); 46 | 47 | /** 48 | * Renders a type or variable name extended with its generics, if any 49 | * Examples: 50 | * ("MyType") -> "MyType" 51 | * ("MyType", []) -> "MyType" 52 | * ("MyType", ["T1", "T2"]) -> "MyType" 53 | * 54 | * @param name type or variable to name to render 55 | * @param generics list of generics 56 | * 57 | * @returns rendered name 58 | */ 59 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 60 | export function withGenerics( 61 | name: string, 62 | generics: ReadonlyArray = [] 63 | ): string { 64 | return generics.length ? `${name}<${generics.join(", ")}>` : name; 65 | } 66 | 67 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-function-return-type 68 | export const pipe = (...fns: ReadonlyArray<(a: any) => any>) => (value: any) => 69 | fns.reduce((p, f) => f(p), value); 70 | 71 | export const isAuthHeaderParameter = ( 72 | parameter: IHeaderParameterInfo | IParameterInfo 73 | ): parameter is IAuthHeaderParameterInfo => "authScheme" in parameter; 74 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # IO-UTILS E2E (FP-TS v2) TEST SUITE 2 | In this folder is defined a barebone project used to test `gen-api-models`' generated files in real-world scenarios. 3 | 4 | ## Usage 5 | 6 | ```sh 7 | $ yarn install --frozen-lockfile 8 | $ yarn start 9 | $ yarn start --verbose # for a detailed list of executed test cases 10 | ``` 11 | 12 | Please be sure that the `@pagopa/openapi-codegen-ts` module has been compiled first. 13 | 14 | ## How it works 15 | 16 | This project installs `@pagopa/openapi-codegen-ts` and try to reproduce user interactions with the module. It ships several OpenApi specifications and for each it performs the relative code generation. At each specification is associated a name and a set of test suites that load generated modules and execute them in their intended scenarios. In order to test generated http clients, a http server for each specification is instantiated, serving a mock representation of the intendend api. 17 | 18 | ## Global configuration 19 | 20 | The `src/config.ts` file contains global values shared over all the suites. 21 | 22 | #### config object 23 | | name | description 24 | |-|-| 25 | | `generatedFilesBaseDir` | directory in which generated files are saved 26 | | `skipClient` | if true, test suites regarding generated clients are skipped and mock servers aren't executed. Default: `false` 27 | | `skipGeneration` | if true, files aren't generated before test suites are executed. Files already in `generatedFilesBaseDir` are considered. Default: `false` 28 | | `specs` | key-value set of OpenApi specification to test. Pairs are in the form _(specName, specInfo)_. See below for _specInfo_ documentation. 29 | 30 | #### specInfo object 31 | | name | description 32 | |-|-| 33 | |`generatedFilesDir`| directory in which generated files for this specification are stored. Is subdirectory of `generatedFilesBaseDir` | 34 | |`isSpecEnabled`| wheater test suites for this specification have to be executed or not. Default: `true` 35 | |`mockPort`| port the mock server exposing this specification is listening at | 36 | |`url`| path of the OpenApi spcification, being local or remote | 37 | 38 | 39 | 40 | ## Env Variables 41 | 42 | For development purpose, it might be useful to avoid some steps and have a quicker response. The following environment variables may assist: 43 | 44 | | name | type | default | descriptions | 45 | |-|-|-|-| 46 | | SKIP_CLIENT | boolean| false | Skip tests on generated http clients. This mean no mock server has to be run 47 | | SKIP_GENERATION| boolean | false | Skip file generation 48 | | INCLUDE_SPECS| string | ∅ | Comma-separated list of specification names on which execute tests. Specification names are the ones included in `config.specs` configuration object. Empty means all. 49 | 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pagopa/openapi-codegen-ts", 3 | "version": "14.1.0", 4 | "description": "Tools and utilities for the IO project", 5 | "repository": "https://github.com/pagopa/io-utils", 6 | "author": "https://www.pagopa.gov.it/", 7 | "license": "MIT", 8 | "main": "dist/index.js", 9 | "bin": { 10 | "gen-api-models": "dist/commands/gen-api-models/cli.js", 11 | "gen-api-sdk": "dist/commands/gen-api-sdk/cli.js", 12 | "bundle-api-spec": "dist/commands/bundle-api-spec/cli.js" 13 | }, 14 | "files": [ 15 | "dist/", 16 | "templates/" 17 | ], 18 | "scripts": { 19 | "clean": "rimraf dist", 20 | "build": "yarn clean && tsc", 21 | "e2e": "yarn build && cd ./e2e && rimraf node_modules && yarn install --frozen-lockfile && yarn start", 22 | "e2e:lint": "eslint ./e2e -c .eslintrc.js --ext .ts,.tsx", 23 | "pretest": "jest --clearCache", 24 | "test": "jest", 25 | "lint": "eslint . -c .eslintrc.js --ext .ts,.tsx", 26 | "test:coverage": "jest --coverage", 27 | "preversion": "auto-changelog --config .auto-changelog.json --unreleased --commit-limit false --stdout --template preview.hbs", 28 | "version": "auto-changelog -p --config .auto-changelog.json --unreleased && git add CHANGELOG.md" 29 | }, 30 | "dependencies": { 31 | "@pagopa/ts-commons": "^10.15.0", 32 | "fs-extra": "^6.0.0", 33 | "nunjucks": "^3.2.3", 34 | "openapi-types": "^10.0.0", 35 | "prettier": "^1.12.1", 36 | "safe-identifier": "^0.4.2", 37 | "swagger-parser": "^10.0.3", 38 | "write-yaml-file": "^4.1.3", 39 | "yargs": "^15.0.1" 40 | }, 41 | "devDependencies": { 42 | "@pagopa/eslint-config": "^1.3.1", 43 | "@types/fs-extra": "^5.0.2", 44 | "@types/jest": "^25.2.1", 45 | "@types/node": "^13.11.0", 46 | "@types/nunjucks": "^3.0.0", 47 | "@types/prettier": "^1.12.0", 48 | "@types/yargs": "^11.1.0", 49 | "auto-changelog": "^2.2.1", 50 | "crlf": "^1.1.1", 51 | "eslint-plugin-prettier": "^3.3.1", 52 | "jest": "^25.2.7", 53 | "rimraf": "^2.6.2", 54 | "ts-jest": "^25.3.1", 55 | "ts-node": "^6.1.0", 56 | "tslint-config-prettier": "^1.13.0", 57 | "tslint-plugin-prettier": "^1.3.0", 58 | "tslint-sonarts": "^1.7.0", 59 | "typescript": "^4.3.5", 60 | "typestrict": "^0.0.9" 61 | }, 62 | "peerDependencies": { 63 | "fp-ts": "^2.16.5", 64 | "io-ts": "^2.2.21" 65 | }, 66 | "jest": { 67 | "preset": "ts-jest", 68 | "testEnvironment": "node", 69 | "moduleFileExtensions": [ 70 | "ts", 71 | "js" 72 | ], 73 | "testMatch": [ 74 | "**/__tests__/*.ts" 75 | ] 76 | }, 77 | "publishConfig": { 78 | "registry": "https://registry.npmjs.org/", 79 | "access": "public" 80 | }, 81 | "resolutions": { 82 | "y18n": "^4.0.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /e2e/src/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is executed once before every test suite. For each specification to test: 3 | // eslint-disable-next-line jsdoc/check-indentation 4 | * - it generates files 5 | * - it runs mock servers 6 | */ 7 | 8 | import { generateApi } from "@pagopa/openapi-codegen-ts"; 9 | import { sequence } from "fp-ts/lib/Array"; 10 | import { pipe } from "fp-ts/lib/function"; 11 | import { tryCatch } from "fp-ts/lib/TaskEither"; 12 | 13 | import * as TE from "fp-ts/lib/TaskEither"; 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | import config from "./config"; 16 | import { startMockServer } from "./server"; 17 | 18 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 19 | const noopTE = TE.right(undefined); 20 | 21 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 22 | const tsGenerateApi = (...p: Parameters) => 23 | tryCatch( 24 | () => generateApi(...p), 25 | reason => { 26 | // eslint-disable-next-line no-console 27 | console.error(reason); 28 | return new Error(`cannot create api `); 29 | } 30 | ); 31 | 32 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 33 | const tsStartServer = (...p: Parameters) => 34 | tryCatch( 35 | () => startMockServer(...p), 36 | reason => { 37 | // eslint-disable-next-line no-console 38 | console.error(reason); 39 | return new Error(`cannot start mock server`); 40 | } 41 | ); 42 | 43 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 44 | export default async () => { 45 | // eslint-disable-next-line no-console 46 | console.log("Running e2e tests with config:", config); 47 | 48 | const { specs, skipClient, skipGeneration } = config; 49 | const tasks = Object.values(specs) 50 | .filter(({ isSpecEnabled }) => isSpecEnabled) 51 | .map(({ url, mockPort, generatedFilesDir }) => { 52 | // eslint-disable-next-line sonarjs/prefer-immediate-return 53 | const p = pipe( 54 | skipGeneration 55 | ? noopTE 56 | : tsGenerateApi({ 57 | camelCasedPropNames: false, 58 | definitionsDirPath: generatedFilesDir, 59 | generateClient: true, 60 | specFilePath: url, 61 | strictInterfaces: true 62 | }), 63 | TE.chain(() => (skipClient ? noopTE : tsStartServer(url, mockPort))) 64 | ); 65 | 66 | return p; 67 | }); 68 | 69 | const startedAt = Date.now(); 70 | return pipe( 71 | sequence(TE.taskEither)(tasks), 72 | TE.orElse(e => { 73 | throw e; 74 | }) 75 | )().then(_ => 76 | // eslint-disable-next-line no-console 77 | console.log(`setup completed after ${Date.now() - startedAt}ms`) 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/__tests__/render.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-duplicate-string */ 2 | 3 | import { OpenAPIV2 } from "openapi-types"; 4 | import * as SwaggerParser from "swagger-parser"; 5 | 6 | import { renderDefinitionCode, renderAllOperations } from "../render"; 7 | import { getDefinitionOrFail, getParser } from "./utils/parser.utils"; 8 | 9 | import { getfirstSuccessType } from "../render"; 10 | 11 | let spec: OpenAPIV2.Document; 12 | 13 | describe.each` 14 | version | specPath 15 | ${2} | ${`${process.cwd()}/__mocks__/api.yaml`} 16 | ${3} | ${`${process.cwd()}/__mocks__/openapi_v3/api.yaml`} 17 | `("Openapi V$version |> renderDefinitionCode", ({ version, specPath }) => { 18 | beforeAll( 19 | async () => 20 | (spec = (await SwaggerParser.bundle(specPath)) as OpenAPIV2.Document) 21 | ); 22 | 23 | it.each` 24 | case | definitionName 25 | ${"1"} | ${"Message"} 26 | ${"2"} | ${"DefinitionFieldWithDash"} 27 | `("should render $case", async ({ definitionName }) => { 28 | const definition = getDefinitionOrFail(spec, definitionName); 29 | 30 | const code = await renderDefinitionCode( 31 | definitionName, 32 | getParser(spec).parseDefinition( 33 | // @ts-ignore 34 | definition 35 | ), 36 | true, 37 | false 38 | ); 39 | 40 | expect(code).toMatchSnapshot(); 41 | }); 42 | 43 | it("should render RequestTypes for octet-stream", () => { 44 | const operationInfo1 = getParser(spec).parseOperation( 45 | // @ts-ignore 46 | spec, 47 | "/test-binary-file-download", 48 | [], 49 | "undefined", 50 | "undefined" 51 | )("get"); 52 | const code = renderAllOperations([operationInfo1], true) 53 | expect(code).toMatchSnapshot(); 54 | }) 55 | }); 56 | 57 | describe("getfirstSuccessType", () => { 58 | it("should return the first successful response", () => { 59 | const responses = [ 60 | { e1: "200", e2: "SuccessType", e3: [] }, 61 | { e1: "301", e2: "RedirectType", e3: [] } 62 | ]; 63 | const result = getfirstSuccessType(responses); 64 | expect(result).toEqual({ e1: "200", e2: "SuccessType", e3: [] }); 65 | }); 66 | 67 | it("should return the first redirection response if no successful response is found", () => { 68 | const responses = [ 69 | { e1: "301", e2: "RedirectType", e3: [] }, 70 | { e1: "302", e2: "AnotherRedirectType", e3: [] } 71 | ]; 72 | const result = getfirstSuccessType(responses); 73 | expect(result).toEqual({ e1: "301", e2: "RedirectType", e3: [] }); 74 | }); 75 | 76 | it("should return undefined if no successful or redirection responses are found", () => { 77 | const responses = [ 78 | { e1: "400", e2: "ClientErrorType", e3: [] }, 79 | { e1: "500", e2: "ServerErrorType", e3: [] } 80 | ]; 81 | const result = getfirstSuccessType(responses); 82 | expect(result).toBeUndefined(); 83 | }); 84 | }); -------------------------------------------------------------------------------- /e2e/src/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/immutable-data */ 2 | /** 3 | * This module abstracts the start and stop of a Prism mock server (https://github.com/stoplightio/prism). 4 | * 5 | */ 6 | 7 | import { once } from "events"; 8 | import { createServer as createServerWithHttp, Server } from "http"; 9 | import { createLogger } from "@stoplight/prism-core"; 10 | import { getHttpOperationsFromResource } from "@stoplight/prism-http"; 11 | import { createServer } from "@stoplight/prism-http-server"; 12 | 13 | const servers = new Map>(); 14 | 15 | const startServer = async ( 16 | port: number, 17 | mockGetUserSession: jest.Mock 18 | ): Promise => { 19 | const server = createServerWithHttp((request, response) => { 20 | if (request.url?.startsWith("/test-parameter-with-body-ref")) { 21 | mockGetUserSession(request, response); 22 | } else { 23 | response.statusCode = 500; 24 | response.end(); 25 | } 26 | }).listen(port); 27 | 28 | await once(server, "listening"); 29 | 30 | return server; 31 | }; 32 | 33 | /** 34 | * Starts a mock server for a given specification 35 | * 36 | * @param apiSpecUrl path to the OpenApi specification document 37 | * @param port port on which start the mock server 38 | * 39 | * @returns {Promise} a resolving promise if the server is started correctly 40 | */ 41 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 42 | function startMockServer(apiSpecUrl: string, port: number = 4100) { 43 | return getHttpOperationsFromResource(apiSpecUrl) 44 | .then(operations => 45 | createServer(operations, { 46 | components: { logger: createLogger("test", { level: "silent" }) }, 47 | config: { 48 | checkSecurity: true, 49 | validateRequest: true, 50 | validateResponse: true, 51 | // eslint-disable-next-line sort-keys 52 | errors: false, 53 | mock: { dynamic: false } 54 | }, 55 | cors: true 56 | }) 57 | ) 58 | .then(async server => { 59 | await server.listen(port); 60 | return server; 61 | }) 62 | .then(server => servers.set(port, server)); 63 | } 64 | 65 | const closeServer = (server: Server): Promise => 66 | new Promise(done => server.close(done)).then(_ => void 0); 67 | 68 | /** 69 | * Stop all the servers previously started 70 | * 71 | * @returns {Promise} a resolving promise if the servers have been stopped correctly 72 | */ 73 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 74 | function stopAllServers() { 75 | // eslint-disable-next-line no-console 76 | console.log(`stopping servers on ports ${[...servers.keys()].join(", ")}`); 77 | return Promise.all( 78 | [...servers.entries()].map(([port, server]) => { 79 | // eslint-disable-next-line no-console 80 | console.log("shutting down server on port", port); 81 | return server.close(); 82 | }) 83 | ).then(() => servers.clear()); 84 | } 85 | 86 | export { startMockServer, stopAllServers, closeServer, startServer }; 87 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs = require("yargs"); 4 | import { generateApi } from "."; 5 | 6 | // 7 | // parse command line 8 | // 9 | 10 | const argv = yargs 11 | .option("api-spec", { 12 | demandOption: true, 13 | description: "Path to input OpenAPI spec file", 14 | normalize: true, 15 | // eslint-disable-next-line id-blacklist 16 | string: true 17 | }) 18 | .option("strict", { 19 | // eslint-disable-next-line id-blacklist 20 | boolean: true, 21 | default: true, 22 | description: "Generate strict interfaces (default: true)" 23 | }) 24 | .option("out-dir", { 25 | demandOption: true, 26 | description: "Output directory to store generated definition files", 27 | normalize: true, 28 | // eslint-disable-next-line id-blacklist 29 | string: true 30 | }) 31 | .option("ts-spec-file", { 32 | description: 33 | "If defined, converts the OpenAPI specs to TypeScript source and writes it to this file", 34 | normalize: true, 35 | // eslint-disable-next-line id-blacklist 36 | string: true 37 | }) 38 | .option("request-types", { 39 | // eslint-disable-next-line id-blacklist 40 | boolean: true, 41 | default: false, 42 | description: "Generate request types (experimental, default: false)" 43 | }) 44 | .option("response-decoders", { 45 | // eslint-disable-next-line id-blacklist 46 | boolean: true, 47 | default: false, 48 | description: 49 | "Generate response decoders (experimental, default: false, implies --request-types)" 50 | }) 51 | .option("client", { 52 | // eslint-disable-next-line id-blacklist 53 | boolean: true, 54 | default: false, 55 | description: "Generate request client SDK" 56 | }) 57 | .option("default-success-type", { 58 | default: "undefined", 59 | description: 60 | "Default type for success responses (experimental, default: 'undefined')", 61 | normalize: true, 62 | // eslint-disable-next-line id-blacklist 63 | string: true 64 | }) 65 | .option("default-error-type", { 66 | default: "undefined", 67 | description: 68 | "Default type for error responses (experimental, default: 'undefined')", 69 | normalize: true, 70 | // eslint-disable-next-line id-blacklist 71 | string: true 72 | }) 73 | .option("camel-cased", { 74 | // eslint-disable-next-line id-blacklist 75 | boolean: true, 76 | default: false, 77 | description: "Generate camelCased properties name (default: false)" 78 | }) 79 | .help().argv; 80 | 81 | // 82 | // Generate APIs 83 | // 84 | 85 | generateApi({ 86 | camelCasedPropNames: argv["camel-cased"], 87 | defaultErrorType: argv["default-error-type"], 88 | defaultSuccessType: argv["default-success-type"], 89 | definitionsDirPath: argv["out-dir"], 90 | generateClient: argv.client, 91 | generateRequestTypes: argv["request-types"], 92 | generateResponseDecoders: argv["response-decoders"], 93 | specFilePath: argv["api-spec"], 94 | strictInterfaces: argv.strict, 95 | tsSpecFilePath: argv["ts-spec-file"] 96 | }).then( 97 | // eslint-disable-next-line no-console 98 | () => console.log("done"), 99 | err => { 100 | // eslint-disable-next-line no-console 101 | console.log(`Error: ${err}`); 102 | process.exit(1); 103 | } 104 | ); 105 | -------------------------------------------------------------------------------- /.github/workflows/pr-title-linter-and-linker.yaml: -------------------------------------------------------------------------------- 1 | name: "Lint and Link PR title" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | - synchronize 10 | 11 | jobs: 12 | lint: 13 | name: Validate PR title And link Jira Issue 14 | runs-on: ubuntu-22.04 15 | env: 16 | JIRA_COMMENT_REGEX: "^.*Jira.*" 17 | steps: 18 | - uses: Slashgear/action-check-pr-title@860e8dc639f8e60335a6f5e8936ba67ed2536890 19 | id: lint 20 | with: 21 | regexp: "\\[(#?[A-Z]*-[0-9]*( |, )?){1,}\\]" # Regex the title should match. 22 | continue-on-error: true 23 | 24 | - name: Find Jira Comment 25 | uses: peter-evans/find-comment@81e2da3af01c92f83cb927cf3ace0e085617c556 26 | id: fc 27 | with: 28 | issue-number: ${{ github.event.pull_request.number }} 29 | comment-author: 'github-actions[bot]' 30 | body-regex: "${{ env.JIRA_COMMENT_REGEX }}" 31 | 32 | - name: Extract Jira Issue to Link 33 | id: extract_jira_issue 34 | if: steps.lint.outcome == 'success' 35 | run: | 36 | PR_TITLE=$(echo "${{ github.event.pull_request.title }}") 37 | ISSUES_STR=$(awk -F'\\[|\\]' '{print $2}' <<< "$PR_TITLE" | sed "s/#//g" | sed "s/,//g") 38 | ISSUES=($ISSUES_STR) 39 | JIRA_ISSUE=$(echo ${ISSUES_STR##* }) 40 | MARKDOWN_CARRIAGE_RETURN="
" 41 | MARKDOWN_PREFIX="- Link to" 42 | JIRA_COMMENT_MARKDOWN="This Pull Request refers to Jira issues:
" 43 | if [[ ${#ISSUES[@]} -eq 1 ]] 44 | then 45 | JIRA_COMMENT_MARKDOWN="This Pull Request refers to the following Jira issue" 46 | MARKDOWN_PREFIX="" 47 | fi 48 | 49 | for ISSUE in "${ISSUES[@]}" 50 | do 51 | JIRA_COMMENT_MARKDOWN+="$MARKDOWN_PREFIX [$ISSUE](https://pagopa.atlassian.net/browse/$ISSUE) $MARKDOWN_CARRIAGE_RETURN" 52 | done 53 | 54 | echo "JIRA_ISSUE=$JIRA_ISSUE" >> $GITHUB_ENV 55 | echo "JIRA_COMMENT_MARKDOWN=$JIRA_COMMENT_MARKDOWN" >> $GITHUB_ENV 56 | 57 | - name: Create Jira Link comment 58 | if: steps.lint.outcome == 'success' 59 | uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 60 | with: 61 | comment-id: ${{ steps.fc.outputs.comment-id }} 62 | issue-number: ${{ github.event.pull_request.number }} 63 | body: | 64 | ## Jira Pull Request Link ## 65 | ${{ env.JIRA_COMMENT_MARKDOWN }} 66 | edit-mode: replace 67 | - name: Create Empty Jira Link comment 68 | if: steps.lint.outcome != 'success' 69 | uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 70 | with: 71 | comment-id: ${{ steps.fc.outputs.comment-id }} 72 | issue-number: ${{ github.event.pull_request.number }} 73 | body: | 74 | ## Jira Pull request Link ## 75 | It seems this Pull Request has no issues that refers to Jira!!! 76 | Please check it out. 77 | edit-mode: replace 78 | - name: Failure message 79 | if: steps.lint.outcome != 'success' 80 | run: | 81 | echo "Pull request title (${{ github.event.pull_request.title }}) is not properly formatted or it is not related to any Jira issue" 82 | exit 1 83 | -------------------------------------------------------------------------------- /e2e/src/__tests__/be/definitions.test.ts: -------------------------------------------------------------------------------- 1 | import { isLeft, isRight } from "fp-ts/lib/Either"; 2 | import config from "../../config"; 3 | 4 | const { generatedFilesDir, isSpecEnabled } = config.specs.be; 5 | 6 | // if there's no need for this suite in this particular run, just skip it 7 | const describeSuite = isSpecEnabled ? describe : describe.skip; 8 | 9 | describeSuite("Decoders generated from BE API spec defintions", () => { 10 | const loadModule = (name: string) => 11 | import(`${generatedFilesDir}/${name}.ts`).then(mod => { 12 | if (!mod) { 13 | fail(`Cannot load module ${generatedFilesDir}/${name}.ts`); 14 | } 15 | return mod; 16 | }); 17 | 18 | describe("ServicePublic definition", () => { 19 | it("should expose ServicePublic decoder", async () => { 20 | const { ServicePublic } = await loadModule("ServicePublic"); 21 | expect(ServicePublic).toBeDefined(); 22 | }); 23 | 24 | const validService = { 25 | service_id: "sid1234", 26 | service_name: "my service", 27 | organization_name: "my org", 28 | department_name: "my dep", 29 | organization_fiscal_code: "12345678901", 30 | version: 123 31 | }; 32 | const withInvalidFiscalCode = { 33 | service_id: "sid1234", 34 | service_name: "my service", 35 | organization_name: "my org", 36 | department_name: "my dep", 37 | organization_fiscal_code: "invalid", 38 | version: 123 39 | }; 40 | 41 | it.each` 42 | title | example | expectedRight 43 | ${"should fail decoding empty"} | ${undefined} | ${false} 44 | ${"should fail decoding non-object"} | ${"INVALID"} | ${false} 45 | ${"should fail decoding invalid service"} | ${withInvalidFiscalCode} | ${false} 46 | ${"should decode valid service"} | ${validService} | ${true} 47 | `("$title", async ({ example, expectedRight }) => { 48 | const { ServicePublic } = await loadModule("ServicePublic"); 49 | const result = isRight(ServicePublic.decode(example)); 50 | expect(result).toEqual(expectedRight); 51 | }); 52 | }); 53 | 54 | describe("PaginatedServiceTupleCollection definition", () => { 55 | it("should expose PaginatedServiceTupleCollection decoder", async () => { 56 | const { PaginatedServiceTupleCollection } = await loadModule( 57 | "PaginatedServiceTupleCollection" 58 | ); 59 | expect(PaginatedServiceTupleCollection).toBeDefined(); 60 | }); 61 | 62 | const paginatedServices = { 63 | items: [{ service_id: "foo123", version: 789 }], 64 | next: "http://example.com/next", 65 | page_size: 1 66 | }; 67 | 68 | it.each` 69 | title | example | expectedRight 70 | ${"should fail decoding empty"} | ${undefined} | ${false} 71 | ${"should fail decoding non-object"} | ${"INVALID"} | ${false} 72 | ${"should decode valid paginated service list"} | ${paginatedServices} | ${true} 73 | `("$title", async ({ example, expectedRight }) => { 74 | const { PaginatedServiceTupleCollection } = await loadModule( 75 | "PaginatedServiceTupleCollection" 76 | ); 77 | const result = isRight( PaginatedServiceTupleCollection.decode(example)); 78 | expect(result).toEqual(expectedRight); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable no-console 2 | import * as fs from "fs-extra"; 3 | import * as E from "fp-ts/Either"; 4 | import * as SwaggerParser from "swagger-parser"; 5 | 6 | import { getParser } from "./parse.utils"; 7 | import { 8 | renderAllOperations, 9 | renderClientCode, 10 | renderDefinitionCode, 11 | renderSpecCode 12 | } from "./render"; 13 | import { IGenerateApiOptions } from "./types"; 14 | 15 | /** 16 | * Wraps file writing to expose a common interface and log consistently 17 | * 18 | * @param name name of the piece of code to render 19 | * @param outPath path of the file 20 | * @param code code to be saved 21 | * 22 | */ 23 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 24 | function writeGeneratedCodeFile(name: string, outPath: string, code: string) { 25 | // eslint-disable-next-line no-console 26 | console.log(`${name} -> ${outPath}`); 27 | return fs.writeFile(outPath, code); 28 | } 29 | 30 | /** 31 | * Module's main method. It generates files based on a given specification url 32 | * 33 | * @param options 34 | * 35 | * 36 | */ 37 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 38 | export async function generateApi(options: IGenerateApiOptions): Promise { 39 | const { 40 | specFilePath, 41 | tsSpecFilePath, 42 | generateClient = false, 43 | definitionsDirPath, 44 | strictInterfaces = false, 45 | defaultSuccessType = "undefined", 46 | defaultErrorType = "undefined", 47 | camelCasedPropNames = false 48 | } = options; 49 | 50 | const { 51 | generateRequestTypes = generateClient, 52 | generateResponseDecoders = generateClient 53 | } = options; 54 | 55 | const api = await SwaggerParser.bundle(specFilePath); 56 | 57 | const maybeParser = getParser(api); 58 | 59 | if (E.isLeft(maybeParser)) { 60 | throw maybeParser.left; 61 | } 62 | 63 | const parser = maybeParser.right; 64 | 65 | await fs.ensureDir(definitionsDirPath); 66 | 67 | if (tsSpecFilePath) { 68 | await writeGeneratedCodeFile( 69 | "TS Spec", 70 | tsSpecFilePath, 71 | renderSpecCode(api) 72 | ); 73 | } 74 | 75 | const parsedDefinitions = parser.getAllDefinitions(api); 76 | if (Object.keys(parsedDefinitions).length === 0) { 77 | // eslint-disable-next-line no-console 78 | console.log("No definitions found, skipping generation of model code."); 79 | return; 80 | } 81 | 82 | await Promise.all( 83 | Object.keys(parsedDefinitions).map(definitionName => 84 | renderDefinitionCode( 85 | definitionName, 86 | parsedDefinitions[definitionName], 87 | strictInterfaces, 88 | camelCasedPropNames 89 | ).then((code: string) => 90 | writeGeneratedCodeFile( 91 | definitionName, 92 | `${definitionsDirPath}/${definitionName}.ts`, 93 | code 94 | ) 95 | ) 96 | ) 97 | ); 98 | 99 | const needToParseOperations = generateClient || generateRequestTypes; 100 | 101 | if (needToParseOperations) { 102 | const specMeta = parser.parseSpecMeta(api); 103 | const allOperationInfos = parser.parseAllOperations( 104 | api, 105 | defaultSuccessType, 106 | defaultErrorType 107 | ); 108 | 109 | if (generateRequestTypes) { 110 | await writeGeneratedCodeFile( 111 | "request types", 112 | `${definitionsDirPath}/requestTypes.ts`, 113 | renderAllOperations(allOperationInfos, generateResponseDecoders) 114 | ); 115 | } 116 | 117 | if (generateClient) { 118 | const code = await renderClientCode(specMeta, allOperationInfos); 119 | await writeGeneratedCodeFile( 120 | "client", 121 | `${definitionsDirPath}/client.ts`, 122 | code 123 | ); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/types.ts: -------------------------------------------------------------------------------- 1 | import { ITuple3 } from "@pagopa/ts-commons/lib/tuples"; 2 | import { OpenAPIV2, OpenAPIV3 } from "openapi-types"; 3 | 4 | /** 5 | * Defines the set of parameters for the code generation 6 | */ 7 | export interface IGenerateApiOptions { 8 | readonly specFilePath: string | OpenAPIV2.Document; 9 | readonly definitionsDirPath: string; 10 | readonly tsSpecFilePath?: string; 11 | readonly strictInterfaces?: boolean; 12 | readonly generateRequestTypes?: boolean; 13 | readonly generateResponseDecoders?: boolean; 14 | readonly generateClient?: boolean; 15 | readonly defaultSuccessType?: string; 16 | readonly defaultErrorType?: string; 17 | readonly camelCasedPropNames: boolean; 18 | } 19 | 20 | /** 21 | * Supported http methods 22 | */ 23 | export type SupportedMethod = "get" | "patch" | "post" | "put" | "delete"; 24 | 25 | export type SupportedAuthScheme = "bearer" | "digest" | "none"; 26 | 27 | /** 28 | * Define the shape of a parsed parameter 29 | */ 30 | export interface IParameterInfo { 31 | readonly name: string; 32 | readonly type: string; 33 | readonly in: string; 34 | readonly headerName?: string; 35 | } 36 | /** 37 | * Define the shape of a parameter of type header 38 | */ 39 | export interface IHeaderParameterInfo extends IParameterInfo { 40 | readonly in: "header"; 41 | readonly headerName: string; 42 | } 43 | 44 | /** 45 | * Define the shape of a parameter of type header which is also an auth parameter 46 | */ 47 | export interface IAuthHeaderParameterInfo extends IHeaderParameterInfo { 48 | readonly tokenType: "basic" | "apiKey" | "oauth2"; 49 | readonly authScheme: SupportedAuthScheme; 50 | } 51 | 52 | /** 53 | * Define the shape of a parsed operation 54 | */ 55 | export interface IOperationInfo { 56 | readonly method: SupportedMethod; 57 | readonly operationId: string; 58 | readonly parameters: ReadonlyArray; 59 | readonly responses: ReadonlyArray< 60 | ITuple3> 61 | >; 62 | readonly headers: ReadonlyArray; 63 | readonly importedTypes: ReadonlySet; 64 | readonly path: string; 65 | readonly consumes?: string; 66 | readonly produces?: string; 67 | } 68 | 69 | /** 70 | * Define the shape of an object containing the specification meta info 71 | */ 72 | export interface ISpecMetaInfo { 73 | readonly basePath?: string; 74 | readonly version?: string; 75 | readonly title?: string; 76 | } 77 | 78 | export type ExtendedOpenAPIV2SecuritySchemeApiKey = OpenAPIV2.SecuritySchemeApiKey & { 79 | readonly "x-auth-scheme": SupportedAuthScheme; 80 | }; 81 | 82 | type ReferenceObject = OpenAPIV3.ReferenceObject; 83 | 84 | /** 85 | * Define the shape of a schema definition as it supported by the generator, 86 | * regardless of OpenAPI version. 87 | */ 88 | export interface IDefinition { 89 | readonly title?: string; 90 | readonly type?: string; 91 | readonly description?: string; 92 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 93 | readonly default?: any; 94 | readonly format?: string; 95 | readonly items?: IDefinition; 96 | readonly maximum?: number; 97 | readonly exclusiveMaximum?: boolean; 98 | readonly minimum?: number; 99 | readonly exclusiveMinimum?: boolean; 100 | readonly maxLength?: number; 101 | readonly minLength?: number; 102 | readonly pattern?: string; 103 | readonly required?: ReadonlyArray; 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 105 | readonly enum?: ReadonlyArray; 106 | readonly properties?: { 107 | readonly [name: string]: ReferenceObject | IDefinition; 108 | }; 109 | readonly additionalProperties?: boolean | ReferenceObject | IDefinition; 110 | readonly allOf?: ReadonlyArray; 111 | readonly oneOf?: ReadonlyArray; 112 | readonly ["x-import"]?: string; 113 | } 114 | -------------------------------------------------------------------------------- /.devops/deploy-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Azure DevOps pipeline to release a new version and publish the package to the registry 2 | # 3 | # The following variables must be configured in the pipeline settings 4 | # 5 | # GIT_EMAIL - when bumping the package version, is the email we author the commit with 6 | # GIT_USERNAME - when bumping the package version, is the name we author the commit with 7 | # GITHUB_CONNECTION - name of the Github service connection used to create a new release; be sure that the related user has appopriate right 8 | # NPM_CONNECTION - name of the NPM service connection used to publish the package; be sure that the related user has appopriate right 9 | # 10 | 11 | variables: 12 | NODE_VERSION: '12.19.1' 13 | YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn 14 | 15 | parameters: 16 | - name: 'RELEASE_SEMVER' 17 | displayName: 'When packing a release, define the version bump to apply' 18 | type: string 19 | values: 20 | - major 21 | - minor 22 | - patch 23 | default: minor 24 | 25 | # Only manual activations are intended 26 | trigger: none 27 | pr: none 28 | 29 | pool: 30 | vmImage: 'ubuntu-latest' 31 | 32 | resources: 33 | repositories: 34 | - repository: pagopaCommons 35 | type: github 36 | name: pagopa/azure-pipeline-templates 37 | ref: refs/tags/v4 38 | endpoint: 'pagopa' 39 | 40 | stages: 41 | 42 | # Create a relase 43 | # Activated when ONE OF these are met: 44 | # - is on branch master 45 | # - is a tag in the form v{version}-RELEASE 46 | - stage: Release 47 | condition: 48 | and( 49 | succeeded(), 50 | or( 51 | eq(variables['Build.SourceBranch'], 'refs/heads/master'), 52 | and( 53 | startsWith(variables['Build.SourceBranch'], 'refs/tags'), 54 | endsWith(variables['Build.SourceBranch'], '-RELEASE') 55 | ) 56 | ) 57 | ) 58 | jobs: 59 | - job: make_release 60 | steps: 61 | - ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/master') }}: 62 | - template: templates/node-github-release/template.yaml@pagopaCommons 63 | parameters: 64 | semver: '${{ parameters.RELEASE_SEMVER }}' 65 | gitEmail: $(GIT_EMAIL) 66 | gitUsername: $(GIT_USERNAME) 67 | gitHubConnection: $(GITHUB_CONNECTION) 68 | nodeVersion: $(NODE_VERSION) 69 | pkg_cache_version_id: $(CACHE_VERSION_ID) 70 | pkg_cache_folder: $(YARN_CACHE_FOLDER) 71 | 72 | - ${{ if ne(variables['Build.SourceBranch'], 'refs/heads/master') }}: 73 | - script: | 74 | echo "We assume this reference to be a valid release: $(Build.SourceBranch). Therefore, there is no need to bundle a new release." 75 | displayName: 'Skip release bundle' 76 | 77 | # Prepare Artifact 78 | - stage: Prepare_artifact 79 | dependsOn: 80 | - Release 81 | jobs: 82 | - job: make_build 83 | steps: 84 | - template: azure-templates/setup-project.yml 85 | parameters: 86 | # On the assumption that this stage is executed only when Relase stage is, 87 | # with this parameter we set the reference the deploy script must pull changes from. 88 | # The branch/tag name is calculated from the source branch 89 | # ex: Build.SourceBranch=refs/heads/master --> master 90 | # ex: Build.SourceBranch=refs/tags/v1.2.3-RELEASE --> v1.2.3-RELEASE 91 | gitReference: ${{ replace(replace(variables['Build.SourceBranch'], 'refs/tags/', ''), 'refs/heads/', '') }} 92 | 93 | - script: | 94 | yarn build 95 | displayName: 'Build' 96 | 97 | - task: CopyFiles@2 98 | inputs: 99 | SourceFolder: '$(System.DefaultWorkingDirectory)' 100 | TargetFolder: '$(System.DefaultWorkingDirectory)/bundle' 101 | # The list of files to be considered is determined by the "files" entry in package.json 102 | Contents: | 103 | README.md 104 | LICENSE 105 | CHANGELOG.md 106 | package.json 107 | dist/**/* 108 | templates/**/* 109 | displayName: 'Copy bundle files' 110 | 111 | - publish: $(System.DefaultWorkingDirectory)/bundle 112 | artifact: Bundle 113 | 114 | # Publish 115 | - stage: Publish 116 | dependsOn: 117 | - Prepare_artifact 118 | jobs: 119 | - job: publish 120 | steps: 121 | - checkout: none 122 | 123 | - download: current 124 | artifact: Bundle 125 | 126 | - task: Npm@1 127 | inputs: 128 | command: custom 129 | customCommand: publish --access public 130 | customEndpoint: $(NPM_CONNECTION) 131 | verbose: true 132 | workingDir: '$(Pipeline.Workspace)/Bundle' 133 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/templateEnvironment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Exposes a template environment instantiated with custom template functionalities defined specifically for gen-api-models command 3 | */ 4 | 5 | import { createTemplateEnvironment } from "../../lib/templating"; 6 | 7 | /** 8 | * Factory method to create a set of filters bound to a common storage. 9 | * The storage is in the form (key, true) where the presence of a kye indicates the flag is true 10 | */ 11 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 12 | const createFlagStorageFilters = () => { 13 | // eslint-disable-next-line functional/no-let, functional/prefer-readonly-type 14 | let store: { [key: string]: true } = {}; 15 | return { 16 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 17 | reset() { 18 | store = {}; 19 | }, 20 | // eslint-disable-next-line sort-keys, @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 21 | add(subject: string) { 22 | // eslint-disable-next-line functional/immutable-data 23 | store[subject] = true; 24 | }, 25 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 26 | get() { 27 | return Object.keys(store).join("\n"); 28 | } 29 | }; 30 | }; 31 | 32 | // filters to handle the import list 33 | const { 34 | reset: resetImports, 35 | add: addImport, 36 | get: getImports 37 | } = createFlagStorageFilters(); 38 | 39 | // filters to handle the type alias list 40 | const { 41 | reset: resetTypeAliases, 42 | add: addTypeAlias, 43 | get: getTypeAliases 44 | } = createFlagStorageFilters(); 45 | 46 | /** 47 | * Given an array of parameter in the form { name: "value" }, it renders a function argument declaration with destructuring 48 | * example: [{ name: 'foo' }, { name: 'bar' }] -> '({ foo, bar })' 49 | * 50 | * @param subject the array of parameters 51 | * 52 | * @return the function argument declaration 53 | */ 54 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 55 | const toFnArgs = ( 56 | subject: ReadonlyArray<{ readonly name: string }> | undefined 57 | ) => 58 | typeof subject === "undefined" 59 | ? "()" 60 | : `({${subject.map(({ name }) => name).join(", ")}})`; 61 | 62 | /** 63 | * Given an array of parameter in the form { in: "value" }, filter the items based on the provided value 64 | * 65 | * @param item 66 | * @param where 67 | * 68 | * @return filtered items 69 | */ 70 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 71 | const paramIn = ( 72 | item: ReadonlyArray<{ readonly in: string }> | undefined, 73 | where: string 74 | ) => (item ? item.filter((e: { readonly in: string }) => e.in === where) : []); 75 | 76 | /** 77 | * Filter an array based on a paramenter and a list of values to match 78 | */ 79 | const filterByParameterIn = ( 80 | array: ReadonlyArray>, 81 | parameterName: string, 82 | value: ReadonlyArray 83 | ): ReadonlyArray> | undefined => 84 | array === undefined 85 | ? undefined 86 | : array.filter(a => value.includes(a[parameterName])); 87 | 88 | /** 89 | * Filter an array based on a paramenter and a list of values to exclude 90 | */ 91 | const filterByParameterNotIn = ( 92 | array: ReadonlyArray>, 93 | parameterName: string, 94 | value: ReadonlyArray 95 | ): ReadonlyArray> | undefined => 96 | array === undefined 97 | ? undefined 98 | : array.filter(a => !value.includes(a[parameterName])); 99 | 100 | /** 101 | * Removes decorator character from a variable name 102 | * example: "arg?" -> "arg" 103 | * example: "arg" -> "arg" 104 | * example: ["arg1?", "arg2"] -> ["arg1", "arg2"] 105 | * 106 | * @param subject 107 | * 108 | * @returns 109 | */ 110 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 111 | const stripQuestionMark = (subject: ReadonlyArray | string) => { 112 | // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type 113 | const strip_base = (str: string) => 114 | str[str.length - 1] === "?" ? str.substring(0, str.length - 1) : str; 115 | return !subject 116 | ? undefined 117 | : typeof subject === "string" 118 | ? strip_base(subject) 119 | : subject.map(strip_base); 120 | }; 121 | 122 | const splitBy = (value: string, character: string): ReadonlyArray => 123 | value.split(character); 124 | /** 125 | * Debug utility for printing a json object within nunjuk templates 126 | */ 127 | const jsonToString = (obj: unknown): string => JSON.stringify(obj, null, "\t"); 128 | 129 | export default createTemplateEnvironment({ 130 | customFilters: { 131 | resetImports, 132 | // eslint-disable-next-line sort-keys 133 | addImport, 134 | getImports, 135 | resetTypeAliases, 136 | // eslint-disable-next-line sort-keys 137 | addTypeAlias, 138 | getTypeAliases, 139 | toFnArgs, 140 | // eslint-disable-next-line sort-keys 141 | paramIn, 142 | // eslint-disable-next-line sort-keys 143 | filterByParameterIn, 144 | filterByParameterNotIn, 145 | stripQuestionMark, 146 | // eslint-disable-next-line sort-keys 147 | splitBy, 148 | // eslint-disable-next-line sort-keys 149 | jsonToString 150 | } 151 | }); 152 | -------------------------------------------------------------------------------- /src/commands/gen-api-sdk/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs-extra"; 2 | import { generateApi } from "../../index"; 3 | import { 4 | createTemplateEnvironment, 5 | DEFAULT_TEMPLATE_DIR 6 | } from "../../lib/templating"; 7 | import { bundleApiSpec } from "../bundle-api-spec"; 8 | import { 9 | IGenerateSdkOptions, 10 | IGeneratorParams, 11 | IPackageAttributes, 12 | IRegistryAttributes 13 | } from "./types"; 14 | 15 | const { render } = createTemplateEnvironment({ 16 | templateDir: `${DEFAULT_TEMPLATE_DIR}/sdk` 17 | }); 18 | 19 | const inferAttributesFromPackage = async (): Promise => { 21 | const pkg = await fs 22 | .readFile(`${process.cwd()}/package.json`) 23 | .then(String) 24 | .then(JSON.parse) 25 | .catch(ex => { 26 | throw new Error( 27 | `Failed to read package.json from the current directory: ${ex}` 28 | ); 29 | }); 30 | return { 31 | access: pkg.publishConfig?.access, 32 | author: pkg.author, 33 | description: `Generated SDK for ${pkg.name}. ${pkg.description}`, 34 | license: pkg.license, 35 | name: `${pkg.name}-sdk`, 36 | registry: pkg.publishConfig?.registry, 37 | version: pkg.version 38 | }; 39 | }; 40 | 41 | // eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any 42 | const mergeParams = (a: A, b: B): any => 43 | Object.keys({ ...a, ...b }).reduce( 44 | // eslint-disable-next-line @typescript-eslint/ban-types 45 | (p: object, k: string) => ({ 46 | ...p, 47 | [k]: b[k as keyof B] || a[k as keyof A] 48 | }), 49 | {} 50 | ); 51 | 52 | /** 53 | * Renders all templates and return a hashmap in the form (filepath, renderedCode) 54 | * 55 | * @param options 56 | */ 57 | export const renderAll = async ( 58 | files: ReadonlyArray, 59 | options: IPackageAttributes & IRegistryAttributes & IGeneratorParams 60 | ): Promise> => { 61 | const allContent = await Promise.all( 62 | files.map(file => render(file, options)) 63 | ); 64 | return allContent.reduce( 65 | (p: Record, rendered: string, i) => ({ 66 | ...p, 67 | [files[i]]: rendered 68 | }), 69 | {} 70 | ); 71 | }; 72 | 73 | /** 74 | * Wraps file writing to expose a common interface and log consistently 75 | * 76 | * @param name name of the piece of code to render 77 | * @param outPath path of the file 78 | * @param code code to be saved 79 | * 80 | */ 81 | const writeGeneratedCodeFile = ( 82 | name: string, 83 | outPath: string, 84 | code: string 85 | ): Promise => { 86 | // eslint-disable-next-line no-console 87 | console.log(`${name} -> ${outPath}`); 88 | return fs.writeFile(outPath, code); 89 | }; 90 | 91 | const writeAllGeneratedCodeFiles = ( 92 | outPath: string, 93 | files: Record 94 | ): Promise> => 95 | Promise.all( 96 | Object.keys(files).map((filepath: string) => 97 | writeGeneratedCodeFile( 98 | filepath, 99 | `${outPath}/${filepath}`.replace(".njk", ""), 100 | files[filepath] 101 | ) 102 | ) 103 | ); 104 | 105 | const listTemplates = (): ReadonlyArray => [ 106 | ".npmignore.njk", 107 | "package.json.njk", 108 | "tsconfig.json.njk", 109 | "index.ts.njk" 110 | ]; 111 | 112 | // generate and save files requires for the package to be published 113 | const generatePackageFiles = async ( 114 | options: IGenerateSdkOptions 115 | ): Promise => { 116 | const { inferAttr, ...params } = options; 117 | const templateParams: IPackageAttributes & 118 | IRegistryAttributes & 119 | IGeneratorParams = inferAttr 120 | ? mergeParams(await inferAttributesFromPackage(), params) 121 | : params; 122 | 123 | const renderedFiles = await renderAll(listTemplates(), templateParams); 124 | 125 | await writeAllGeneratedCodeFiles(options.outPath, renderedFiles); 126 | }; 127 | 128 | /** 129 | * Generate models as well as package scaffolding for a sdk that talks to a provided api spec 130 | * 131 | * @param options 132 | */ 133 | export const generateSdk = async ( 134 | options: IGenerateSdkOptions 135 | ): Promise => { 136 | // ensure target directories to exist 137 | await fs.ensureDir(options.outPath); 138 | const outPathNoStrict = `${options.outPath}/no-strict`; 139 | await fs.ensureDir(outPathNoStrict); 140 | 141 | await generatePackageFiles(options); 142 | 143 | // generate definitions both strict and no-strict 144 | // so that the users can choose which version to include in their code 145 | await Promise.all( 146 | [ 147 | { 148 | definitionsDirPath: options.outPath, 149 | strictInterfaces: true 150 | }, 151 | { 152 | definitionsDirPath: outPathNoStrict, 153 | strictInterfaces: false 154 | } 155 | ].map(({ strictInterfaces, definitionsDirPath }) => 156 | generateApi({ 157 | camelCasedPropNames: options.camelCasedPropNames, 158 | defaultErrorType: options.defaultErrorType, 159 | defaultSuccessType: options.defaultSuccessType, 160 | definitionsDirPath, 161 | generateClient: true, 162 | specFilePath: options.specFilePath, 163 | strictInterfaces 164 | }) 165 | ) 166 | ); 167 | 168 | // resolve references in spec file and bundle 169 | await bundleApiSpec({ 170 | outPath: `${options.outPath}/openapi.yaml`, 171 | specFilePath: options.specFilePath, 172 | version: options.version 173 | }); 174 | }; 175 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/parse.utils.ts: -------------------------------------------------------------------------------- 1 | import { IJsonSchema, OpenAPI, OpenAPIV2, OpenAPIV3 } from "openapi-types"; 2 | 3 | import * as E from "fp-ts/Either"; 4 | 5 | import * as ParseOpenapiV2 from "./parse.v2"; 6 | import * as ParseOpenapiV3 from "./parse.v3"; 7 | import { 8 | IAuthHeaderParameterInfo, 9 | IDefinition, 10 | IOperationInfo, 11 | ISpecMetaInfo 12 | } from "./types"; 13 | 14 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 15 | export type Parser = { 16 | readonly getAuthHeaders: ( 17 | spec: D, 18 | security?: D["security"] 19 | ) => ReadonlyArray; 20 | readonly parseAllOperations: ( 21 | spec: D, 22 | defaultSuccessType: string, 23 | defaultErrorType: string 24 | ) => ReadonlyArray; 25 | readonly parseDefinition: ( 26 | source: 27 | | OpenAPIV2.SchemaObject 28 | | OpenAPIV3.SchemaObject 29 | | OpenAPIV3.ReferenceObject 30 | ) => IDefinition; 31 | readonly parseSpecMeta: (api: OpenAPI.Document) => ISpecMetaInfo; 32 | readonly getAllDefinitions: ( 33 | api: OpenAPI.Document 34 | ) => Record; 35 | }; 36 | 37 | const parserV2: Parser = { 38 | getAllDefinitions: api => { 39 | const apiV2 = api as OpenAPIV2.Document; 40 | const definitions = apiV2.definitions ?? {}; 41 | 42 | return Object.keys(definitions).reduce((prev, definitionName: string) => { 43 | // eslint-disable-next-line functional/immutable-data 44 | prev[definitionName] = ParseOpenapiV2.parseDefinition( 45 | definitions[definitionName] 46 | ); 47 | return prev; 48 | }, {} as Record); 49 | }, 50 | getAuthHeaders: (spec, security) => 51 | ParseOpenapiV2.getAuthHeaders( 52 | // We expect spec to be an OpenAPI V3 document 53 | // since it's retrieved from `getParser` factory 54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 | // @ts-ignore 56 | spec.securityDefinitions, 57 | security 58 | ), 59 | parseAllOperations: (spec, defaultSuccessType, defaultErrorType) => 60 | ParseOpenapiV2.parseAllOperations( 61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 62 | // @ts-ignore 63 | spec, 64 | defaultSuccessType, 65 | defaultErrorType 66 | ), 67 | parseDefinition: source => 68 | ParseOpenapiV2.parseDefinition(source as OpenAPIV2.SchemaObject), 69 | parseSpecMeta: api => 70 | ParseOpenapiV2.parseSpecMeta( 71 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 72 | // @ts-ignore 73 | api 74 | ) 75 | }; 76 | 77 | const parserV3: Parser = { 78 | getAllDefinitions: api => { 79 | const apiV3 = api as OpenAPIV3.Document; 80 | const definitions = apiV3.components?.schemas ?? {}; 81 | 82 | return Object.keys(definitions).reduce((prev, definitionName: string) => { 83 | // eslint-disable-next-line functional/immutable-data 84 | prev[definitionName] = ParseOpenapiV3.parseDefinition( 85 | definitions[definitionName] 86 | ); 87 | return prev; 88 | }, {} as Record); 89 | }, 90 | getAuthHeaders: (spec, security) => 91 | ParseOpenapiV3.getAuthHeaders( 92 | // We expect spec to be an OpenAPI V3 document 93 | // since it's retrieved from `getParser` factory 94 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 95 | // @ts-ignore 96 | spec.components?.securitySchemes, 97 | security 98 | ), 99 | parseAllOperations: (spec, defaultSuccessType, defaultErrorType) => 100 | ParseOpenapiV3.parseAllOperations( 101 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 102 | // @ts-ignore 103 | spec, 104 | defaultSuccessType, 105 | defaultErrorType 106 | ), 107 | parseDefinition: source => 108 | ParseOpenapiV3.parseDefinition( 109 | source as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject 110 | ), 111 | parseSpecMeta: api => 112 | ParseOpenapiV3.parseSpecMeta( 113 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 114 | // @ts-ignore 115 | api 116 | ) 117 | }; 118 | 119 | export const getParser = ( 120 | spec: D 121 | ): E.Either> => 122 | ParseOpenapiV2.isOpenAPIV2(spec) 123 | ? E.of(parserV2) 124 | : ParseOpenapiV3.isOpenAPIV3(spec) 125 | ? E.of(parserV3) 126 | : E.left(Error("The specification must be of type OpenAPI 2 or 3")); 127 | 128 | export const inferDefinitionType = ( 129 | source: IJsonSchema 130 | ): string | undefined => { 131 | // We expect type to be a single string 132 | if (typeof source.type === "string") { 133 | return source.type; 134 | } 135 | // As for JSON Schema, "type" might be an array of string 136 | // Anyway, this is not permitted by OpenAPI 137 | // https://swagger.io/specification/#schema-object 138 | else if (Array.isArray(source.type)) { 139 | new Error( 140 | `field type: Value MUST be a string. Multiple types via an array are not supported. Received: Array` 141 | ); 142 | } 143 | // If source contains a "property" or "additionalProperties" field, we assume is an object even if "type" is not defined 144 | // This to be allow specification to work even when they are less then perfect 145 | else if ("properties" in source || "additionalProperties" in source) { 146 | return "object"; 147 | } 148 | // Some definitions expect an undefined type 149 | // For example, allOf, oneOf, etc 150 | else { 151 | return source.type; 152 | } 153 | }; 154 | -------------------------------------------------------------------------------- /src/commands/gen-api-sdk/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable sonarjs/no-duplicate-string */ 4 | 5 | import yargs = require("yargs"); 6 | import { generateSdk } from "."; 7 | 8 | // 9 | // parse command line 10 | // 11 | 12 | const PACKAGE_GROUP = "Package options:"; 13 | const CODE_GROUP = "Code generation options:"; 14 | 15 | const argv = yargs 16 | 17 | .option("no-infer-attrs", { 18 | alias: ["N"], 19 | // eslint-disable-next-line id-blacklist 20 | boolean: true, 21 | description: 22 | "Infer package attributes from a package.json file present in the current directory", 23 | group: PACKAGE_GROUP, 24 | // eslint-disable-next-line sort-keys 25 | default: false 26 | }) 27 | .option("package-name", { 28 | alias: ["n", "name"], 29 | description: "Name of the generated package", 30 | normalize: true, 31 | // eslint-disable-next-line id-blacklist 32 | string: true, 33 | // eslint-disable-next-line sort-keys 34 | group: PACKAGE_GROUP 35 | }) 36 | .option("package-version", { 37 | alias: "V", 38 | description: "Version of the generated package", 39 | // eslint-disable-next-line id-blacklist 40 | string: true, 41 | // eslint-disable-next-line sort-keys 42 | group: PACKAGE_GROUP 43 | }) 44 | .option("package-description", { 45 | alias: ["d", "desc"], 46 | description: "Description of the package", 47 | // eslint-disable-next-line id-blacklist 48 | string: true, 49 | // eslint-disable-next-line sort-keys 50 | group: PACKAGE_GROUP 51 | }) 52 | .option("package-author", { 53 | alias: ["a", "author"], 54 | description: "The author of the API exposed", 55 | // eslint-disable-next-line id-blacklist 56 | string: true, 57 | // eslint-disable-next-line sort-keys 58 | group: PACKAGE_GROUP 59 | }) 60 | .option("package-license", { 61 | alias: ["L", "license"], 62 | description: "The license of the API Exposed", 63 | // eslint-disable-next-line id-blacklist 64 | string: true, 65 | // eslint-disable-next-line sort-keys 66 | group: PACKAGE_GROUP 67 | }) 68 | .implies("no-infer-attrs", [ 69 | "package-name", 70 | "package-version", 71 | "package-description", 72 | "package-author", 73 | "package-license" 74 | ]) 75 | .option("package-registry", { 76 | alias: ["r", "registry"], 77 | description: "Url of the registry the package is published in", 78 | // eslint-disable-next-line id-blacklist 79 | string: true, 80 | // eslint-disable-next-line sort-keys 81 | group: PACKAGE_GROUP 82 | }) 83 | .option("package-access", { 84 | alias: ["x", "access"], 85 | description: 86 | "Either 'public' or 'private', depending of the accessibility of the package in the registry", 87 | // eslint-disable-next-line sort-keys 88 | choices: ["public", "private"], 89 | // eslint-disable-next-line id-blacklist 90 | string: true, 91 | // eslint-disable-next-line sort-keys 92 | group: PACKAGE_GROUP 93 | }) 94 | .implies("package-registry", "package-access") 95 | .option("api-spec", { 96 | alias: "i", 97 | demandOption: true, 98 | description: "Path to input OpenAPI spec file", 99 | normalize: true, 100 | // eslint-disable-next-line id-blacklist 101 | string: true, 102 | // eslint-disable-next-line sort-keys 103 | group: CODE_GROUP 104 | }) 105 | .option("out-dir", { 106 | alias: "o", 107 | demandOption: true, 108 | description: "Output directory to store generated definition files", 109 | normalize: true, 110 | // eslint-disable-next-line id-blacklist 111 | string: true, 112 | // eslint-disable-next-line sort-keys 113 | group: CODE_GROUP 114 | }) 115 | .option("default-success-type", { 116 | default: "undefined", 117 | description: 118 | "Default type for success responses (experimental, default: 'undefined')", 119 | normalize: true, 120 | // eslint-disable-next-line id-blacklist 121 | string: true, 122 | // eslint-disable-next-line sort-keys 123 | group: CODE_GROUP 124 | }) 125 | .option("default-error-type", { 126 | default: "undefined", 127 | description: 128 | "Default type for error responses (experimental, default: 'undefined')", 129 | normalize: true, 130 | // eslint-disable-next-line id-blacklist 131 | string: true, 132 | // eslint-disable-next-line sort-keys 133 | group: CODE_GROUP 134 | }) 135 | .option("camel-cased", { 136 | // eslint-disable-next-line id-blacklist 137 | boolean: true, 138 | default: false, 139 | description: "Generate camelCased properties name (default: false)", 140 | group: CODE_GROUP 141 | }) 142 | .help().argv; 143 | 144 | // 145 | // Generate APIs 146 | // 147 | generateSdk({ 148 | camelCasedPropNames: argv["camel-cased"], 149 | inferAttr: !argv["no-infer-attr"], 150 | name: argv["package-name"], 151 | version: argv["package-version"], 152 | // eslint-disable-next-line sort-keys 153 | description: argv["package-description"], 154 | // eslint-disable-next-line sort-keys 155 | author: argv["package-author"], 156 | license: argv["package-license"], 157 | registry: argv["package-registry"], 158 | // eslint-disable-next-line sort-keys 159 | access: argv["package-access"], 160 | defaultErrorType: argv["default-error-type"], 161 | defaultSuccessType: argv["default-success-type"], 162 | outPath: argv["out-dir"], 163 | specFilePath: argv["api-spec"] 164 | }).then( 165 | // eslint-disable-next-line no-console 166 | () => console.log("done"), 167 | err => { 168 | // eslint-disable-next-line no-console 169 | console.log(`Error: ${err}`); 170 | process.exit(1); 171 | } 172 | ); 173 | -------------------------------------------------------------------------------- /e2e/src/__tests__/be/client.test.ts: -------------------------------------------------------------------------------- 1 | import nodeFetch from "node-fetch"; 2 | import { pipe } from "fp-ts/lib/function"; 3 | 4 | import * as TE from "fp-ts/lib/TaskEither"; 5 | import * as E from "fp-ts/lib/Either"; 6 | 7 | import config from "../../config"; 8 | import { createClient, WithDefaultsT } from "../../generated/be/client"; 9 | 10 | const { skipClient } = config; 11 | const { mockPort, isSpecEnabled } = config.specs.be; 12 | 13 | // if there's no need for this suite in this particular run, just skip it 14 | const describeSuite = skipClient || !isSpecEnabled ? describe.skip : describe; 15 | 16 | const VALID_TOKEN = "Bearer valid-token"; 17 | const INVALID_TOKEN = undefined; 18 | 19 | describeSuite("Http client generated from BE API spec", () => { 20 | it("should be a valid module", () => { 21 | expect(createClient).toBeDefined(); 22 | expect(createClient).toEqual(expect.any(Function)); 23 | }); 24 | 25 | describe("getService", () => { 26 | it("should retrieve a single service", async () => { 27 | const { getService } = createClient({ 28 | baseUrl: `http://localhost:${mockPort}`, 29 | fetchApi: (nodeFetch as any) as typeof fetch, 30 | basePath: "" 31 | }); 32 | 33 | const result = await getService({ 34 | Bearer: VALID_TOKEN, 35 | service_id: "service123" 36 | }); 37 | 38 | pipe( 39 | result, 40 | E.fold( 41 | (e: any) => fail(e), 42 | response => { 43 | expect(response.status).toBe(200); 44 | expect(response.value).toEqual(expect.any(Object)); 45 | } 46 | ) 47 | ); 48 | }); 49 | 50 | it("should use a common token", async () => { 51 | const withBearer: WithDefaultsT<"Bearer"> = op => params => { 52 | return op({ 53 | ...params, 54 | Bearer: VALID_TOKEN 55 | }); 56 | }; 57 | 58 | const { getService } = createClient<"Bearer">({ 59 | baseUrl: `http://localhost:${mockPort}`, 60 | basePath: "", 61 | fetchApi: (nodeFetch as any) as typeof fetch, 62 | withDefaults: withBearer 63 | }); 64 | 65 | // please not we're not passing Bearer 66 | const result = await getService({ 67 | service_id: "service123" 68 | }); 69 | 70 | pipe( 71 | result, 72 | E.fold( 73 | (e: any) => fail(e), 74 | response => { 75 | expect(response.status).toBe(200); 76 | expect(response.value).toEqual(expect.any(Object)); 77 | } 78 | ) 79 | ); 80 | }); 81 | }); 82 | 83 | describe("getVisibleServices", () => { 84 | it("should retrieve a list of visible services", async () => { 85 | const { getVisibleServices } = createClient({ 86 | baseUrl: `http://localhost:${mockPort}`, 87 | fetchApi: (nodeFetch as any) as typeof fetch, 88 | basePath: "" 89 | }); 90 | 91 | const result = await getVisibleServices({ 92 | Bearer: VALID_TOKEN 93 | }); 94 | 95 | pipe( 96 | result, 97 | E.fold( 98 | (e: any) => fail(e), 99 | response => { 100 | expect(response.status).toBe(200); 101 | if (response.status === 200) { 102 | expect(response.value).toEqual(expect.any(Object)); 103 | expect(response.value.items).toEqual(expect.any(Array)); 104 | expect(response.value.page_size).toEqual(expect.any(Number)); 105 | } 106 | } 107 | ) 108 | ); 109 | }); 110 | 111 | it("should accept pagination", async () => { 112 | const { getVisibleServices } = createClient({ 113 | baseUrl: `http://localhost:${mockPort}`, 114 | fetchApi: (nodeFetch as any) as typeof fetch, 115 | basePath: "" 116 | }); 117 | 118 | const result = await getVisibleServices({ 119 | Bearer: VALID_TOKEN, 120 | cursor: "my cursor" 121 | }); 122 | 123 | pipe( 124 | result, 125 | E.fold( 126 | (e: any) => fail(e), 127 | response => { 128 | expect(response.status).toBe(200); 129 | if (response.status === 200) { 130 | expect(response.value).toEqual(expect.any(Object)); 131 | expect(response.value.items).toEqual(expect.any(Array)); 132 | expect(response.value.page_size).toEqual(expect.any(Number)); 133 | } 134 | } 135 | ) 136 | ); 137 | }); 138 | 139 | it("should pass parameters correctly to fetch", async () => { 140 | const spiedFetch = jest.fn(() => ({ 141 | status: 200, 142 | json: async () => ({}), 143 | headers: {} 144 | })); 145 | const { getVisibleServices } = createClient({ 146 | baseUrl: `http://localhost:${mockPort}`, 147 | fetchApi: (spiedFetch as any) as typeof fetch, 148 | basePath: "" 149 | }); 150 | 151 | await getVisibleServices({ 152 | Bearer: VALID_TOKEN, 153 | cursor: "my_cursor" 154 | }); 155 | 156 | expect(spiedFetch).toBeCalledWith( 157 | expect.stringContaining("cursor=my_cursor"), 158 | expect.objectContaining({ 159 | headers: { Authorization: expect.stringContaining(VALID_TOKEN) } 160 | }) 161 | ); 162 | }); 163 | 164 | it("should pass parameters correctly to fetch (using dafault parameters adapter)", async () => { 165 | const spiedFetch = jest.fn(() => ({ 166 | status: 200, 167 | json: async () => ({}), 168 | headers: {} 169 | })); 170 | 171 | const withBearer: WithDefaultsT<"Bearer"> = op => params => { 172 | return op({ 173 | ...params, 174 | Bearer: VALID_TOKEN 175 | }); 176 | }; 177 | 178 | // please note we're not passing a K type to createClient, is being inferred from witBearer 179 | const { getVisibleServices } = createClient({ 180 | baseUrl: `http://localhost:${mockPort}`, 181 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 182 | fetchApi: (spiedFetch as any) as typeof fetch, 183 | basePath: "", 184 | withDefaults: withBearer 185 | }); 186 | 187 | await getVisibleServices({ 188 | cursor: "my_cursor" 189 | }); 190 | 191 | expect(spiedFetch).toBeCalledWith( 192 | expect.stringContaining("cursor=my_cursor"), 193 | expect.objectContaining({ 194 | headers: { Authorization: expect.stringContaining(VALID_TOKEN) } 195 | }) 196 | ); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/lib/templating/filters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is a collection of custom filters to be used in Nunjucks templates 3 | */ 4 | 5 | import { identifier } from "safe-identifier"; 6 | import { pipe } from "../utils"; 7 | 8 | /** 9 | * Apply a filter function indiscriminately to a single subject or to an array of subjects 10 | * In most cases nunjucks filters work for both strings or array of strings, so it's worth to handle this mechanism once forever 11 | * 12 | * @param subject 13 | */ 14 | 15 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 16 | const oneOrMany = (filterFn: (str: string) => string) => ( 17 | subject: ReadonlyArray | string 18 | ) => 19 | !subject 20 | ? undefined 21 | : typeof subject === "string" 22 | ? filterFn(subject) 23 | : subject.map(filterFn); 24 | 25 | /** 26 | * Wheater or not an array contains an item 27 | * 28 | * @param subject the provided array 29 | * @param item item to search 30 | * 31 | * @return true if item is found 32 | */ 33 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 34 | export const contains = (subject: ReadonlyArray, item: T) => 35 | subject.indexOf(item) !== -1; 36 | 37 | /** 38 | * Wheater or not the first caracter of the string is the provided item 39 | * 40 | * @param subject 41 | * @param item 42 | * 43 | * @returns true or false 44 | */ 45 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 46 | export const startsWith = (subject: string, item: string) => 47 | subject.indexOf(item) === 0; 48 | 49 | /** 50 | * First letter to uppercase 51 | * 52 | * @param subject string to capitalize 53 | */ 54 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 55 | export const capitalizeFirst = (subject: string) => 56 | `${subject[0].toUpperCase()}${subject.slice(1)}`; 57 | 58 | /** 59 | * Wraps given text in a block comment 60 | * 61 | * @param subject the given text 62 | * 63 | * @returns the wrapped comment 64 | */ 65 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 66 | export const comment = (subject: string) => 67 | `/**\n * ${subject.split("\n").join("\n * ")} \n */`; 68 | 69 | /** 70 | * Converts a string to camel cased 71 | * 72 | * @param subject provided string 73 | * 74 | * @returns camel cased string 75 | * 76 | */ 77 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 78 | export const camelCase = (subject: string) => 79 | // eslint-disable-next-line no-useless-escape 80 | subject.replace(/(\_\w)/g, ([, firstLetter]: string) => 81 | typeof firstLetter === "undefined" ? "" : firstLetter.toUpperCase() 82 | ); 83 | 84 | /** 85 | * Sanitise a string to be used as a javascript identifier 86 | * see https://developer.mozilla.org/en-US/docs/Glossary/Identifier#:~:text=An%20identifier%20is%20a%20sequence,not%20start%20with%20a%20digit. 87 | * 88 | * @param subject provided string or array of strings 89 | * 90 | * @returns Sanitised string or array of sanitised strings 91 | * 92 | * @example 93 | * ("9-my invalid_id1") -> "myInvalidId1" 94 | */ 95 | export const safeIdentifier = oneOrMany(subject => 96 | pipe((v: string) => v.replace(/^[0-9]+/, ""), identifier, camelCase)(subject) 97 | ); 98 | 99 | /** 100 | * Sanitise a string to be used as an object field name when destructuring. 101 | * The use case is when the template is composing a function declaration and the parameter is destructured 102 | * 103 | * @param subject provided string or array of strings 104 | * 105 | * @returns Sanitised string or array of sanitised strings 106 | * 107 | * @example 108 | * ("9-my invalid_id1") -> "[\"9-my invalid_id1\"]: myInvalidId1" 109 | */ 110 | export const safeDestruct = oneOrMany( 111 | (subject: string) => `["${subject}"]: ${safeIdentifier(subject)}` 112 | ); 113 | 114 | /** 115 | * Object.keys 116 | * 117 | * @param subject provided object 118 | * 119 | * @returns a list of keys 120 | */ 121 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/ban-types 122 | export const keys = (subject: object) => (subject ? Object.keys(subject) : []); 123 | 124 | /** 125 | * The first element of an array, if defined 126 | * 127 | * @param subject 128 | * 129 | * @return the first element if present, undefined otherwise 130 | */ 131 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 132 | export const first = (subject: ReadonlyArray | undefined) => 133 | subject ? subject[0] : undefined; 134 | 135 | /** 136 | * Array.join 137 | * 138 | * @param subject 139 | * @param separator 140 | * 141 | * @return the joined string 142 | */ 143 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 144 | export const join = (subject: ReadonlyArray, separator: string) => 145 | subject.join(separator); 146 | 147 | /** 148 | * Given an array, returns all the elements except the first 149 | * example: [1,2,3] -> [2,3] 150 | * example: [] -> [] 151 | * 152 | * @param subject provided array 153 | * 154 | * @returns the array tail 155 | */ 156 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 157 | export const tail = (subject: ReadonlyArray) => 158 | // eslint-disable-next-line @typescript-eslint/prefer-optional-chain 159 | subject && subject.length ? subject.slice(1) : []; 160 | 161 | /** 162 | * Returns an array containing all the elements of a given array plus the element to push 163 | * 164 | * @param subject provided array 165 | * @param toPush element to push 166 | */ 167 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 168 | export const push = ( 169 | subject: ReadonlyArray | undefined, 170 | toPush: T | R 171 | // eslint-disable-next-line @typescript-eslint/prefer-optional-chain 172 | ) => (subject && subject.length ? [...subject, toPush] : [toPush]); 173 | 174 | /** 175 | * Given an hash set, returns the value of a given key. If an array of hash sets is given, an array of values os returned 176 | * 177 | * @param subject a hash set or an array of hash set 178 | * @param key the key to pick 179 | * 180 | * @returns a value or an array of value 181 | */ 182 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any 183 | export const pick = >( 184 | // eslint-disable-next-line functional/prefer-readonly-type 185 | subject: T[] | T | undefined, 186 | key: keyof T 187 | ) => 188 | !subject 189 | ? [] 190 | : Array.isArray(subject) 191 | ? subject.map(({ [key]: value }) => value) 192 | : subject[key]; 193 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/__tests__/__snapshots__/render.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Openapi V2 |> renderDefinitionCode should render 1 1`] = ` 4 | "/** 5 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 6 | * See https://github.com/pagopa/io-utils 7 | */ 8 | /* eslint-disable */ 9 | 10 | import { MessageContent } from \\"./MessageContent\\"; 11 | import * as t from \\"io-ts\\"; 12 | 13 | // required attributes 14 | const MessageR = t.interface({ 15 | id: t.string, 16 | 17 | content: MessageContent 18 | }); 19 | 20 | // optional attributes 21 | const MessageO = t.partial({ 22 | sender_service_id: t.string 23 | }); 24 | 25 | export const Message = t.exact(t.intersection([MessageR, MessageO], \\"Message\\")); 26 | 27 | export type Message = t.TypeOf; 28 | " 29 | `; 30 | 31 | exports[`Openapi V2 |> renderDefinitionCode should render 2 1`] = ` 32 | "/** 33 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 34 | * See https://github.com/pagopa/io-utils 35 | */ 36 | /* eslint-disable */ 37 | 38 | import * as t from \\"io-ts\\"; 39 | 40 | // required attributes 41 | const DefinitionFieldWithDashR = t.interface({}); 42 | 43 | // optional attributes 44 | const DefinitionFieldWithDashO = t.partial({ 45 | \\"id-field\\": t.string 46 | }); 47 | 48 | export const DefinitionFieldWithDash = t.exact( 49 | t.intersection( 50 | [DefinitionFieldWithDashR, DefinitionFieldWithDashO], 51 | \\"DefinitionFieldWithDash\\" 52 | ) 53 | ); 54 | 55 | export type DefinitionFieldWithDash = t.TypeOf; 56 | " 57 | `; 58 | 59 | exports[`Openapi V2 |> renderDefinitionCode should render RequestTypes for octet-stream 1`] = ` 60 | "// DO NOT EDIT THIS FILE 61 | // This file has been generated by gen-api-models 62 | // eslint-disable sonar/max-union-size 63 | // eslint-disable sonarjs/no-identical-functions 64 | 65 | import * as t from \\"io-ts\\"; 66 | 67 | import * as r from \\"@pagopa/ts-commons/lib/requests\\"; 68 | 69 | /**************************************************************** 70 | * testBinaryFileDownload 71 | */ 72 | 73 | // Request type definition 74 | export type TestBinaryFileDownloadT = r.IGetApiRequestType< 75 | {}, 76 | never, 77 | never, 78 | r.IResponseType<200, Buffer, never> 79 | >; 80 | 81 | export const testBinaryFileDownloadDefaultResponses = { 82 | 200: Buffer 83 | }; 84 | 85 | export type TestBinaryFileDownloadResponsesT = { 86 | 200: t.Type; 87 | }; 88 | 89 | export function testBinaryFileDownloadDecoder( 90 | overrideTypes: 91 | | Partial> 92 | | t.Type 93 | | undefined = {} 94 | ): r.ResponseDecoder> { 95 | const isDecoder = (d: any): d is t.Type => 96 | typeof d[\\"_A\\"] !== \\"undefined\\"; 97 | 98 | const type = { 99 | ...((testBinaryFileDownloadDefaultResponses as unknown) as TestBinaryFileDownloadResponsesT< 100 | A0, 101 | C0 102 | >), 103 | ...(isDecoder(overrideTypes) ? { 200: overrideTypes } : overrideTypes) 104 | }; 105 | 106 | const d200 = (r.bufferArrayResponseDecoder(200) as any) as r.ResponseDecoder< 107 | r.IResponseType<200, A0, never> 108 | >; 109 | 110 | return d200; 111 | } 112 | 113 | // Decodes the success response with the type defined in the specs 114 | export const testBinaryFileDownloadDefaultDecoder = () => 115 | testBinaryFileDownloadDecoder(); 116 | " 117 | `; 118 | 119 | exports[`Openapi V3 |> renderDefinitionCode should render 1 1`] = ` 120 | "/** 121 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 122 | * See https://github.com/pagopa/io-utils 123 | */ 124 | /* eslint-disable */ 125 | 126 | import { MessageContent } from \\"./MessageContent\\"; 127 | import * as t from \\"io-ts\\"; 128 | 129 | // required attributes 130 | const MessageR = t.interface({ 131 | id: t.string, 132 | 133 | content: MessageContent 134 | }); 135 | 136 | // optional attributes 137 | const MessageO = t.partial({ 138 | sender_service_id: t.string 139 | }); 140 | 141 | export const Message = t.exact(t.intersection([MessageR, MessageO], \\"Message\\")); 142 | 143 | export type Message = t.TypeOf; 144 | " 145 | `; 146 | 147 | exports[`Openapi V3 |> renderDefinitionCode should render 2 1`] = ` 148 | "/** 149 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 150 | * See https://github.com/pagopa/io-utils 151 | */ 152 | /* eslint-disable */ 153 | 154 | import * as t from \\"io-ts\\"; 155 | 156 | // required attributes 157 | const DefinitionFieldWithDashR = t.interface({}); 158 | 159 | // optional attributes 160 | const DefinitionFieldWithDashO = t.partial({ 161 | \\"id-field\\": t.string 162 | }); 163 | 164 | export const DefinitionFieldWithDash = t.exact( 165 | t.intersection( 166 | [DefinitionFieldWithDashR, DefinitionFieldWithDashO], 167 | \\"DefinitionFieldWithDash\\" 168 | ) 169 | ); 170 | 171 | export type DefinitionFieldWithDash = t.TypeOf; 172 | " 173 | `; 174 | 175 | exports[`Openapi V3 |> renderDefinitionCode should render RequestTypes for octet-stream 1`] = ` 176 | "// DO NOT EDIT THIS FILE 177 | // This file has been generated by gen-api-models 178 | // eslint-disable sonar/max-union-size 179 | // eslint-disable sonarjs/no-identical-functions 180 | 181 | import * as t from \\"io-ts\\"; 182 | 183 | import * as r from \\"@pagopa/ts-commons/lib/requests\\"; 184 | 185 | /**************************************************************** 186 | * testBinaryFileDownload 187 | */ 188 | 189 | // Request type definition 190 | export type TestBinaryFileDownloadT = r.IGetApiRequestType< 191 | {}, 192 | never, 193 | never, 194 | r.IResponseType<200, Buffer, never> 195 | >; 196 | 197 | export const testBinaryFileDownloadDefaultResponses = { 198 | 200: Buffer 199 | }; 200 | 201 | export type TestBinaryFileDownloadResponsesT = { 202 | 200: t.Type; 203 | }; 204 | 205 | export function testBinaryFileDownloadDecoder( 206 | overrideTypes: 207 | | Partial> 208 | | t.Type 209 | | undefined = {} 210 | ): r.ResponseDecoder> { 211 | const isDecoder = (d: any): d is t.Type => 212 | typeof d[\\"_A\\"] !== \\"undefined\\"; 213 | 214 | const type = { 215 | ...((testBinaryFileDownloadDefaultResponses as unknown) as TestBinaryFileDownloadResponsesT< 216 | A0, 217 | C0 218 | >), 219 | ...(isDecoder(overrideTypes) ? { 200: overrideTypes } : overrideTypes) 220 | }; 221 | 222 | const d200 = (r.bufferArrayResponseDecoder(200) as any) as r.ResponseDecoder< 223 | r.IResponseType<200, A0, never> 224 | >; 225 | 226 | return d200; 227 | } 228 | 229 | // Decodes the success response with the type defined in the specs 230 | export const testBinaryFileDownloadDefaultDecoder = () => 231 | testBinaryFileDownloadDecoder(); 232 | " 233 | `; 234 | -------------------------------------------------------------------------------- /templates/client.ts.njk: -------------------------------------------------------------------------------- 1 | {%- import "macros.njk" as macro -%} 2 | 3 | /** 4 | * Do not edit this file it is auto-generated by io-utils / gen-api-models. 5 | * See https://github.com/pagopa/io-utils 6 | */ 7 | /* eslint-disable */ 8 | 9 | import { withoutUndefinedValues } from "@pagopa/ts-commons/lib/types"; 10 | import { 11 | RequestParams, 12 | TypeofApiCall, 13 | TypeofApiParams, 14 | createFetchRequestForApi, 15 | ReplaceRequestParams 16 | } from "@pagopa/ts-commons/lib/requests"; 17 | import { identity } from "fp-ts/lib/function"; 18 | 19 | {% if operations | length %} 20 | import { 21 | {% for operation in operations %} 22 | {{ macro.requestTypeName(operation) }}, 23 | {{ macro.responseDecoderName(operation) }}, 24 | {% endfor %} 25 | } from "./requestTypes"; 26 | {% endif%} 27 | 28 | // This is a placeholder for undefined when dealing with object keys 29 | // Typescript doesn't perform well when narrowing a union type which includes string and undefined 30 | // (example: "foo" | "bar" | undefined) 31 | // We use this as a placeholder for type parameters indicating "no key" 32 | type __UNDEFINED_KEY = "_____"; 33 | 34 | export type ApiOperation = {% for operation in operations %} 35 | {{ (" & " if loop.index0 else "") | safe }} 36 | TypeofApiCall<{{ macro.requestTypeName(operation) }}> 37 | {% endfor %}; 38 | 39 | export type ParamKeys = keyof({% for operation in operations %} 40 | {{ (" & " if loop.index0 else "") | safe }} 41 | TypeofApiParams<{{ macro.requestTypeName(operation) }}> 42 | {% endfor %}); 43 | 44 | /** 45 | * Defines an adapter for TypeofApiCall which omit one or more parameters in the signature 46 | * @param ApiT the type which defines the operation to expose 47 | * @param K the parameter to omit. undefined means no parameters will be omitted 48 | */ 49 | export type OmitApiCallParams = ( 50 | op: TypeofApiCall 51 | ) => K extends __UNDEFINED_KEY 52 | ? TypeofApiCall 53 | : TypeofApiCall, K>>>; 54 | 55 | /** 56 | * Defines an adapter for TypeofApiCall which omit one or more parameters in the signature 57 | * @param ApiT the type which defines the operation to expose 58 | * @param K the parameter to omit. undefined means no parameters will be omitted 59 | */ 60 | export type WithDefaultsT< 61 | K extends ParamKeys | __UNDEFINED_KEY = __UNDEFINED_KEY 62 | > = OmitApiCallParams<{% for operation in operations %}{{ " | " if loop.index0 else "" }}{{ macro.requestTypeName(operation) }}{% endfor %}, K> 63 | 64 | 65 | 66 | /** 67 | * Defines a collection of api operations 68 | * @param K name of the parameters that the Clients masks from the operations 69 | */ 70 | export type Client = K extends __UNDEFINED_KEY 71 | ? { 72 | {% for operation in operations %} 73 | readonly {{ operation.operationId}}: TypeofApiCall<{{ macro.requestTypeName(operation) }}>; 74 | {% endfor %} 75 | } 76 | : { 77 | {% for operation in operations %} 78 | readonly {{ operation.operationId}}: TypeofApiCall< 79 | ReplaceRequestParams<{{ macro.requestTypeName(operation) }}, Omit, K>> 80 | >; 81 | {% endfor %} 82 | }; 83 | 84 | /** 85 | * Create an instance of a client 86 | * @param params hash map of parameters thata define the client: 87 | * - baseUrl: the base url for every api call (required) 88 | * - fetchApi: an implementation of the fetch() web API, depending on the platform (required) 89 | * - basePath: optional path to be appended to the baseUrl 90 | * - withDefaults: optional adapter to be applied to every operation, to omit some paramenters 91 | * @returns a collection of api operations 92 | */ 93 | export function createClient(params: { 94 | baseUrl: string; 95 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 | fetchApi: typeof fetch; 97 | withDefaults: WithDefaultsT; 98 | basePath?: string; 99 | }): Client; 100 | export function createClient(params: { 101 | baseUrl: string; 102 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 103 | fetchApi: typeof fetch; 104 | withDefaults?: undefined; 105 | basePath?: string; 106 | }): Client; 107 | export function createClient({ 108 | baseUrl, 109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | fetchApi, 111 | withDefaults, 112 | basePath = "{{ spec.basePath }}", 113 | }: { 114 | baseUrl: string; 115 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 116 | fetchApi: typeof fetch; 117 | withDefaults?: WithDefaultsT; 118 | basePath?: string; 119 | }) { 120 | const options = { 121 | baseUrl, 122 | fetchApi, 123 | }; 124 | 125 | {% for operation in operations %} 126 | {% set headerParams = operation.parameters | paramIn("header") %} 127 | {% set bodyParams = operation.parameters | paramIn("body") %} 128 | {% set queryParams = operation.parameters | paramIn("query") %} 129 | {% set pathParams = operation.parameters | paramIn("path") %} 130 | {% set formParams = operation.parameters | paramIn("formData") | filterByParameterNotIn("type", ["File"]) %} 131 | {% set formParamsBinary = operation.parameters | paramIn("formData") | filterByParameterIn("type", ["File"]) %} 132 | 133 | const {{ operation.operationId }}T: {{ macro.requestParamsType(operation) }} = { 134 | method: "{{ operation.method }}", 135 | {% if headerParams | length or operation.consumes %} 136 | {%if formParamsBinary | length %} 137 | // There is a well-known issue about fetch and Content-Type header when it comes to add multipart/form-data files. 138 | // reference: https://github.com/github/fetch/issues/505#issuecomment-293064470 139 | // The solution is to skip the Content-Type header and let fetch add it for us. 140 | // @ts-ignore as IRequestType would require something 141 | headers: {{ macro.composedHeaderProducers(headerParams, undefined) }}, 142 | {% else %} 143 | headers: {{ macro.composedHeaderProducers(headerParams, operation.consumes) }}, 144 | {% endif %} 145 | {% else %} 146 | headers: () => ({}), 147 | {% endif %} 148 | response_decoder: {{ macro.responseDecoderName(operation) }}(), 149 | url: ({ {{ pathParams | pick("name") | stripQuestionMark | safeDestruct | join(', ') | safe }} }) => `{{ macro.$("basePath") }}{{ macro.applyPathParams(operation.path, pathParams | pick("name") | stripQuestionMark) }}`, 150 | {% if bodyParams | length %} 151 | {% set bodyParamName = bodyParams | pick("name") | stripQuestionMark | safeIdentifier | join(", ") | safe %} 152 | body: ({ {{ bodyParams | pick("name") | stripQuestionMark | safeDestruct | join(", ") | safe }} }) => 153 | {{ bodyParamName }}?.constructor?.name === "Readable" || {{ bodyParamName }}?.constructor?.name === "ReadableStream" 154 | ? ({{ bodyParamName }} as ReadableStream) 155 | : {{ bodyParamName }}?.constructor?.name === "Buffer" 156 | ? ({{ bodyParamName }} as Buffer) 157 | : JSON.stringify({{ bodyParamName }}), 158 | {% elif formParams | length %} 159 | body: ({ {{ formParams | pick("name") | stripQuestionMark | safeDestruct | join(", ") | safe }} }) => {{ formParams | pick("name") | safeIdentifier | first }}.uri, 160 | {% elif formParamsBinary | length %} 161 | {%set paramName = formParamsBinary | pick("name") | safeIdentifier | first %} 162 | body: ({ {{ formParamsBinary | pick("name") | stripQuestionMark | safeDestruct | join(", ") | safe }} }) => 163 | { 164 | if(typeof window === "undefined") 165 | throw new Error("File upload is only support inside a browser runtime envoronment"); 166 | const formData = new FormData(); 167 | formData.append("{{paramName}}", {{paramName}}); 168 | return formData; 169 | }, 170 | {% elif operation.method === "post" or operation.method === "put" or operation.method === "patch" %} 171 | body: () => "{}", 172 | {% endif %} 173 | {% if queryParams | length %} 174 | query: ({ {{ queryParams | pick("name") | stripQuestionMark | safeDestruct | join(', ') | safe }} }) => withoutUndefinedValues({ {{ queryParams | pick("name") | stripQuestionMark | safeDestruct | join(", ") | safe }} }), 175 | {% else %} 176 | query: () => withoutUndefinedValues({}), 177 | {% endif %} 178 | }; 179 | const {{ operation.operationId }}: TypeofApiCall<{{ macro.requestTypeName(operation) }}> = createFetchRequestForApi({{ operation.operationId }}T, options); 180 | {% endfor %} 181 | 182 | return { 183 | {% for operation in operations %}{{ operation.operationId }}: (withDefaults || identity)({{ operation.operationId }}),{% endfor %} 184 | }; 185 | } 186 | 187 | 188 | -------------------------------------------------------------------------------- /e2e/src/__tests__/be/request-types.test.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import mockResponse from "../../../../__mocks__/response"; 3 | import config from "../../config"; 4 | import * as requestTypes from "../../generated/be/requestTypes"; 5 | import * as E from "fp-ts/lib/Either" 6 | 7 | const { isSpecEnabled } = config.specs.be; 8 | 9 | // if there's no need for this suite in this particular run, just skip it 10 | const describeSuite = isSpecEnabled ? describe : describe.skip; 11 | 12 | describeSuite("Request types generated from BE API spec", () => { 13 | describe("getServicesByRecipientDecoder", () => { 14 | it("should be a function", async () => { 15 | const { getServicesByRecipientDecoder } = requestTypes; 16 | expect(getServicesByRecipientDecoder).toBeDefined(); 17 | expect(getServicesByRecipientDecoder).toEqual(expect.any(Function)); 18 | }); 19 | 20 | const paginatedServices = { 21 | items: [{ service_id: "foo123", version: 789 }], 22 | next: "http://example.com/next", 23 | page_size: 1 24 | }; 25 | 26 | it.each` 27 | title | response | expectedRight | expectedLeft 28 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 29 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 30 | ${"should decode 200 with valid body"} | ${mockResponse(200, paginatedServices)} | ${{ status: 200, value: paginatedServices }} | ${undefined} 31 | ${"should decode 200 with invalid body"} | ${mockResponse(200, { invalid: "body" })} | ${undefined} | ${expect.any(Object)} 32 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${undefined} | ${expect.any(Object)} 33 | ${"should decode 400 with any value/string"} | ${mockResponse(400, "any value")} | ${undefined} | ${expect.any(Object)} 34 | ${"should decode 400 with any value/object"} | ${mockResponse(400, { foo: "bar" })} | ${{ status: 400, value: { type: expect.any(String) } }} | ${undefined} 35 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 36 | `( 37 | "$title", 38 | async ({ 39 | response, 40 | expectedRight, 41 | expectedLeft, 42 | cannotDecode = !expectedRight && !expectedLeft 43 | }) => { 44 | const { getServicesByRecipientDecoder } = requestTypes; 45 | const decoder = getServicesByRecipientDecoder(); 46 | const result = await decoder(response); 47 | if (cannotDecode) { 48 | expect(result).not.toBeDefined(); 49 | } else if (result) { 50 | E.fold( 51 | // in case the decoding gives a left, it checks the result against the expected value 52 | (l: any) => expect(l).toEqual(expectedLeft), 53 | // in case the decoding gives a right, it checks the result against the expected value 54 | (r: any) => expect(r).toEqual(expectedRight) 55 | )(result); 56 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 57 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 58 | } else { 59 | fail("result should be defined"); 60 | } 61 | } 62 | ); 63 | }); 64 | 65 | describe("startEmailValidationProcessDecoder", () => { 66 | it("should be a function", async () => { 67 | const { startEmailValidationProcessDecoder } = requestTypes; 68 | expect(startEmailValidationProcessDecoder).toBeDefined(); 69 | expect(startEmailValidationProcessDecoder).toEqual(expect.any(Function)); 70 | }); 71 | 72 | it.each` 73 | title | response | expectedRight | expectedLeft 74 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 75 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 76 | ${"should decode 202 with non-empty body"} | ${mockResponse(202, { foo: "bar" })} | ${{ status: 202, value: undefined }} | ${undefined} 77 | ${"should decode 202 with empty body"} | ${mockResponse(202 /*, undefined */)} | ${{ status: 202, value: undefined }} | ${undefined} 78 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 79 | `( 80 | "$title", 81 | async ({ 82 | response, 83 | expectedRight, 84 | expectedLeft, 85 | cannotDecode = !expectedRight && !expectedLeft 86 | }) => { 87 | const { startEmailValidationProcessDecoder } = requestTypes; 88 | const decoder = startEmailValidationProcessDecoder(); 89 | const result = await decoder(response); 90 | if (cannotDecode) { 91 | expect(result).not.toBeDefined(); 92 | } else if (result) { 93 | E.fold( 94 | // in case the decoding gives a left, it checks the result against the expected value 95 | (l: any) => expect(l).toEqual(expectedLeft), 96 | // in case the decoding gives a right, it checks the result against the expected value 97 | (r: any) => expect(r).toEqual(expectedRight) 98 | )(result); 99 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 100 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 101 | } else { 102 | fail("result should be defined"); 103 | } 104 | } 105 | ); 106 | }); 107 | 108 | describe("getUserMetadataDecoder", () => { 109 | it("should be a function", async () => { 110 | const { getUserMetadataDecoder } = requestTypes; 111 | expect(getUserMetadataDecoder).toBeDefined(); 112 | expect(getUserMetadataDecoder).toEqual(expect.any(Function)); 113 | }); 114 | 115 | const validUserMetadata = { version: 123, metadata: "meta:data" }; 116 | const invalidUserMetadata = { invalid: "body" }; 117 | 118 | it.each` 119 | title | response | expectedRight | expectedLeft 120 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 121 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 122 | ${"should decode 204 with non-empty body"} | ${mockResponse(204, { foo: "bar" })} | ${{ status: 204, value: undefined }} | ${undefined} 123 | ${"should decode 204 with empty body"} | ${mockResponse(204 /*, undefined */)} | ${{ status: 204, value: undefined }} | ${undefined} 124 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${undefined} | ${expect.any(Object)} 125 | ${"should decode 200 with invalid body"} | ${mockResponse(200, invalidUserMetadata)} | ${undefined} | ${expect.any(Object)} 126 | ${"should decode 200 with valid body"} | ${mockResponse(200, validUserMetadata)} | ${{ status: 200, value: validUserMetadata }} | ${undefined} 127 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 128 | `( 129 | "$title", 130 | async ({ 131 | response, 132 | expectedRight, 133 | expectedLeft, 134 | cannotDecode = !expectedRight && !expectedLeft 135 | }) => { 136 | const { getUserMetadataDecoder } = requestTypes; 137 | const decoder = getUserMetadataDecoder(); 138 | const result = await decoder(response); 139 | if (cannotDecode) { 140 | expect(result).not.toBeDefined(); 141 | } else if (result) { 142 | E.fold( 143 | // in case the decoding gives a left, it checks the result against the expected value 144 | (l: any) => expect(l).toEqual(expectedLeft), 145 | // in case the decoding gives a right, it checks the result against the expected value 146 | (r: any) => expect(r).toEqual(expectedRight) 147 | )(result); 148 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 149 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 150 | } else { 151 | fail("result should be defined"); 152 | } 153 | } 154 | ); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /e2e/src/__tests__/test-api-v3/request-types.test.ts: -------------------------------------------------------------------------------- 1 | import mockResponse from "../../../../__mocks__/response"; 2 | import config from "../../config"; 3 | import * as requestTypes from "../../generated/testapiV3/requestTypes"; 4 | 5 | import * as E from "fp-ts/lib/Either"; 6 | 7 | // @ts-ignore because leaked-handles doesn't ship type defintions 8 | import * as leaked from "leaked-handles"; 9 | leaked.set({ debugSockets: true }); 10 | 11 | const { isSpecEnabled } = config.specs.testapiV3; 12 | 13 | // if there's no need for this suite in this particular run, just skip it 14 | const describeSuite = isSpecEnabled ? describe : describe.skip; 15 | 16 | describeSuite("Request types generated from Test API spec", () => { 17 | describe("testAuthBearerDecoder", () => { 18 | it("should be a function", async () => { 19 | const { testAuthBearerDecoder } = requestTypes; 20 | expect(testAuthBearerDecoder).toBeDefined(); 21 | expect(testAuthBearerDecoder).toEqual(expect.any(Function)); 22 | }); 23 | 24 | it.each` 25 | title | response | expectedRight | expectedLeft 26 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 27 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 28 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 29 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 30 | ${"should decode 403 with any value/string"} | ${mockResponse(403, "any value")} | ${{ status: 403, value: undefined }} | ${undefined} 31 | ${"should decode 403 with any value/object"} | ${mockResponse(403, { foo: "bar" })} | ${{ status: 403, value: undefined }} | ${undefined} 32 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 33 | `( 34 | "$title", 35 | async ({ 36 | response, 37 | expectedRight, 38 | expectedLeft, 39 | cannotDecode = !expectedRight && !expectedLeft 40 | }) => { 41 | const { testAuthBearerDecoder } = requestTypes; 42 | const decoder = testAuthBearerDecoder(); 43 | const result = await decoder(response); 44 | if (cannotDecode) { 45 | expect(result).not.toBeDefined(); 46 | } else if (result) { 47 | E.fold( 48 | // in case the decoding gives a left, it checks the result against the expected value 49 | (l: any) => expect(l).toEqual(expectedLeft), 50 | // in case the decoding gives a right, it checks the result against the expected value 51 | (r: any) => expect(r).toEqual(expectedRight) 52 | )(result); 53 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 54 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 55 | } else { 56 | fail("result should be defined"); 57 | } 58 | } 59 | ); 60 | }); 61 | 62 | describe("testAuthBearerDefaultDecoder", () => { 63 | it("should be a function", async () => { 64 | const { testAuthBearerDefaultDecoder } = requestTypes; 65 | expect(testAuthBearerDefaultDecoder).toBeDefined(); 66 | expect(testAuthBearerDefaultDecoder).toEqual(expect.any(Function)); 67 | }); 68 | 69 | it.each` 70 | title | response | expectedRight | expectedLeft 71 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 72 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 73 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 74 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 75 | ${"should decode 403 with any value/string"} | ${mockResponse(403, "any value")} | ${{ status: 403 }} | ${undefined} 76 | ${"should decode 403 with any value/object"} | ${mockResponse(403, { foo: "bar" })} | ${{ status: 403 }} | ${undefined} 77 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 78 | `( 79 | "$title", 80 | async ({ 81 | response, 82 | expectedRight, 83 | expectedLeft, 84 | cannotDecode = !expectedRight && !expectedLeft 85 | }) => { 86 | const { testAuthBearerDefaultDecoder } = requestTypes; 87 | const decoder = testAuthBearerDefaultDecoder(); 88 | const result = await decoder(response); 89 | if (cannotDecode) { 90 | expect(result).not.toBeDefined(); 91 | } else if (result) { 92 | E.fold( 93 | // in case the decoding gives a left, it checks the result against the expected value 94 | (l: any) => expect(l).toEqual(expectedLeft), 95 | // in case the decoding gives a right, it checks the result against the expected value 96 | (r: any) => expect(r).toEqual(expectedRight) 97 | )(result); 98 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 99 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 100 | } else { 101 | fail("result should be defined"); 102 | } 103 | } 104 | ); 105 | }); 106 | 107 | describe("testFileUploadDecoder", () => { 108 | it("should be a function", async () => { 109 | const { testFileUploadDecoder } = requestTypes; 110 | expect(testFileUploadDecoder).toBeDefined(); 111 | expect(testFileUploadDecoder).toEqual(expect.any(Function)); 112 | }); 113 | 114 | it.each` 115 | title | response | expectedRight | expectedLeft 116 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 117 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 118 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 119 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 120 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 121 | `( 122 | "$title", 123 | async ({ 124 | response, 125 | expectedRight, 126 | expectedLeft, 127 | cannotDecode = !expectedRight && !expectedLeft 128 | }) => { 129 | const { testFileUploadDecoder } = requestTypes; 130 | const decoder = testFileUploadDecoder(); 131 | const result = await decoder(response); 132 | if (cannotDecode) { 133 | expect(result).not.toBeDefined(); 134 | } else if (result) { 135 | E.fold( 136 | // in case the decoding gives a left, it checks the result against the expected value 137 | (l: any) => expect(l).toEqual(expectedLeft), 138 | // in case the decoding gives a right, it checks the result against the expected value 139 | (r: any) => expect(r).toEqual(expectedRight) 140 | )(result); 141 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 142 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 143 | } else { 144 | fail("result should be defined"); 145 | } 146 | } 147 | ); 148 | }); 149 | 150 | describe("testFileUploadDefaultDecoder", () => { 151 | it("should be a function", async () => { 152 | const { testFileUploadDefaultDecoder } = requestTypes; 153 | expect(testFileUploadDefaultDecoder).toBeDefined(); 154 | expect(testFileUploadDefaultDecoder).toEqual(expect.any(Function)); 155 | }); 156 | 157 | it.each` 158 | title | response | expectedRight | expectedLeft 159 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 160 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 161 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 162 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 163 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 164 | `( 165 | "$title", 166 | async ({ 167 | response, 168 | expectedRight, 169 | expectedLeft, 170 | cannotDecode = !expectedRight && !expectedLeft 171 | }) => { 172 | const { testFileUploadDefaultDecoder } = requestTypes; 173 | const decoder = testFileUploadDefaultDecoder(); 174 | const result = await decoder(response); 175 | if (cannotDecode) { 176 | expect(result).not.toBeDefined(); 177 | } else if (result) { 178 | E.fold( 179 | // in case the decoding gives a left, it checks the result against the expected value 180 | (l: any) => expect(l).toEqual(expectedLeft), 181 | // in case the decoding gives a right, it checks the result against the expected value 182 | (r: any) => expect(r).toEqual(expectedRight) 183 | )(result); 184 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 185 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 186 | } else { 187 | fail("result should be defined"); 188 | } 189 | } 190 | ); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /e2e/src/__tests__/test-api/request-types.test.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import mockResponse from "../../../../__mocks__/response"; 3 | import config from "../../config"; 4 | import * as requestTypes from "../../generated/testapi/requestTypes"; 5 | 6 | import * as E from "fp-ts/lib/Either"; 7 | 8 | // @ts-ignore because leaked-handles doesn't ship type defintions 9 | import * as leaked from "leaked-handles"; 10 | leaked.set({ debugSockets: true }); 11 | 12 | const { isSpecEnabled } = config.specs.testapi; 13 | 14 | // if there's no need for this suite in this particular run, just skip it 15 | const describeSuite = isSpecEnabled ? describe : describe.skip; 16 | 17 | describeSuite("Request types generated from Test API spec", () => { 18 | describe("testAuthBearerDecoder", () => { 19 | it("should be a function", async () => { 20 | const { testAuthBearerDecoder } = requestTypes; 21 | expect(testAuthBearerDecoder).toBeDefined(); 22 | expect(testAuthBearerDecoder).toEqual(expect.any(Function)); 23 | }); 24 | 25 | it.each` 26 | title | response | expectedRight | expectedLeft 27 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 28 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 29 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 30 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 31 | ${"should decode 403 with any value/string"} | ${mockResponse(403, "any value")} | ${{ status: 403, value: undefined }} | ${undefined} 32 | ${"should decode 403 with any value/object"} | ${mockResponse(403, { foo: "bar" })} | ${{ status: 403, value: undefined }} | ${undefined} 33 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 34 | `( 35 | "$title", 36 | async ({ 37 | response, 38 | expectedRight, 39 | expectedLeft, 40 | cannotDecode = !expectedRight && !expectedLeft 41 | }) => { 42 | const { testAuthBearerDecoder } = requestTypes; 43 | const decoder = testAuthBearerDecoder(); 44 | const result = await decoder(response); 45 | if (cannotDecode) { 46 | expect(result).not.toBeDefined(); 47 | } else if (result) { 48 | E.fold( 49 | // in case the decoding gives a left, it checks the result against the expected value 50 | (l: any) => expect(l).toEqual(expectedLeft), 51 | // in case the decoding gives a right, it checks the result against the expected value 52 | (r: any) => expect(r).toEqual(expectedRight) 53 | )(result); 54 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 55 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 56 | } else { 57 | fail("result should be defined"); 58 | } 59 | } 60 | ); 61 | }); 62 | 63 | describe("testAuthBearerDefaultDecoder", () => { 64 | it("should be a function", async () => { 65 | const { testAuthBearerDefaultDecoder } = requestTypes; 66 | expect(testAuthBearerDefaultDecoder).toBeDefined(); 67 | expect(testAuthBearerDefaultDecoder).toEqual(expect.any(Function)); 68 | }); 69 | 70 | it.each` 71 | title | response | expectedRight | expectedLeft 72 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 73 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 74 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 75 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 76 | ${"should decode 403 with any value/string"} | ${mockResponse(403, "any value")} | ${{ status: 403 }} | ${undefined} 77 | ${"should decode 403 with any value/object"} | ${mockResponse(403, { foo: "bar" })} | ${{ status: 403 }} | ${undefined} 78 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 79 | `( 80 | "$title", 81 | async ({ 82 | response, 83 | expectedRight, 84 | expectedLeft, 85 | cannotDecode = !expectedRight && !expectedLeft 86 | }) => { 87 | const { testAuthBearerDefaultDecoder } = requestTypes; 88 | const decoder = testAuthBearerDefaultDecoder(); 89 | const result = await decoder(response); 90 | if (cannotDecode) { 91 | expect(result).not.toBeDefined(); 92 | } else if (result) { 93 | E.fold( 94 | // in case the decoding gives a left, it checks the result against the expected value 95 | (l: any) => expect(l).toEqual(expectedLeft), 96 | // in case the decoding gives a right, it checks the result against the expected value 97 | (r: any) => expect(r).toEqual(expectedRight) 98 | )(result); 99 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 100 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 101 | } else { 102 | fail("result should be defined"); 103 | } 104 | } 105 | ); 106 | }); 107 | 108 | describe("testFileUploadDecoder", () => { 109 | it("should be a function", async () => { 110 | const { testFileUploadDecoder } = requestTypes; 111 | expect(testFileUploadDecoder).toBeDefined(); 112 | expect(testFileUploadDecoder).toEqual(expect.any(Function)); 113 | }); 114 | 115 | it.each` 116 | title | response | expectedRight | expectedLeft 117 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 118 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 119 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 120 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 121 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 122 | `( 123 | "$title", 124 | async ({ 125 | response, 126 | expectedRight, 127 | expectedLeft, 128 | cannotDecode = !expectedRight && !expectedLeft 129 | }) => { 130 | const { testFileUploadDecoder } = requestTypes; 131 | const decoder = testFileUploadDecoder(); 132 | const result = await decoder(response); 133 | if (cannotDecode) { 134 | expect(result).not.toBeDefined(); 135 | } else if (result) { 136 | E.fold( 137 | // in case the decoding gives a left, it checks the result against the expected value 138 | (l: any) => expect(l).toEqual(expectedLeft), 139 | // in case the decoding gives a right, it checks the result against the expected value 140 | (r: any) => expect(r).toEqual(expectedRight) 141 | )(result); 142 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 143 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 144 | } else { 145 | fail("result should be defined"); 146 | } 147 | } 148 | ); 149 | }); 150 | 151 | describe("testFileUploadDefaultDecoder", () => { 152 | it("should be a function", async () => { 153 | const { testFileUploadDefaultDecoder } = requestTypes; 154 | expect(testFileUploadDefaultDecoder).toBeDefined(); 155 | expect(testFileUploadDefaultDecoder).toEqual(expect.any(Function)); 156 | }); 157 | 158 | it.each` 159 | title | response | expectedRight | expectedLeft 160 | ${"shoudln't decode scalar value/number"} | ${200} | ${undefined} | ${undefined} 161 | ${"shoudln't decode scalar value/string"} | ${"any value"} | ${undefined} | ${undefined} 162 | ${"should decode 200 with non-empty body"} | ${mockResponse(200, { foo: "bar" })} | ${{ status: 200, value: undefined }} | ${undefined} 163 | ${"should decode 200 with empty body"} | ${mockResponse(200 /*, undefined */)} | ${{ status: 200, value: undefined }} | ${undefined} 164 | ${"shoudln't decode unhandled http code"} | ${mockResponse(418, { foo: "bar" })} | ${undefined} | ${undefined} 165 | `( 166 | "$title", 167 | async ({ 168 | response, 169 | expectedRight, 170 | expectedLeft, 171 | cannotDecode = !expectedRight && !expectedLeft 172 | }) => { 173 | const { testFileUploadDefaultDecoder } = requestTypes; 174 | const decoder = testFileUploadDefaultDecoder(); 175 | const result = await decoder(response); 176 | if (cannotDecode) { 177 | expect(result).not.toBeDefined(); 178 | } else if (result) { 179 | E.fold( 180 | // in case the decoding gives a left, it checks the result against the expected value 181 | (l: any) => expect(l).toEqual(expectedLeft), 182 | // in case the decoding gives a right, it checks the result against the expected value 183 | (r: any) => expect(r).toEqual(expectedRight) 184 | )(result); 185 | expect(E.isRight(result)).toBe(typeof expectedRight !== "undefined"); 186 | expect(E.isLeft(result)).toBe(typeof expectedLeft !== "undefined"); 187 | } else { 188 | fail("result should be defined"); 189 | } 190 | } 191 | ); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /__mocks__/be.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: 1.0.0 4 | title: Proxy API 5 | description: Mobile and web proxy API gateway. 6 | host: localhost 7 | basePath: /api/v1 8 | schemes: 9 | - https 10 | security: 11 | - Bearer: [] 12 | paths: 13 | "/services/{service_id}": 14 | x-swagger-router-controller: ServicesController 15 | parameters: 16 | - name: service_id 17 | in: path 18 | type: string 19 | required: true 20 | description: The ID of an existing Service. 21 | get: 22 | operationId: getService 23 | summary: Get Service 24 | description: A previously created service with the provided service ID is returned. 25 | responses: 26 | '200': 27 | description: Service found. 28 | schema: 29 | "$ref": "#/definitions/ServicePublic" 30 | examples: 31 | application/json: 32 | department_name: "IO" 33 | organization_fiscal_code: "00000000000" 34 | organization_name: "IO" 35 | service_id: "5a563817fcc896087002ea46c49a" 36 | service_name: "App IO" 37 | version: 1 38 | "400": 39 | description: Bad request 40 | schema: 41 | $ref: "#/definitions/ProblemJson" 42 | "401": 43 | description: Bearer token null or expired. 44 | "404": 45 | description: No service found for the provided ID. 46 | schema: 47 | $ref: "#/definitions/ProblemJson" 48 | "429": 49 | description: Too many requests 50 | schema: 51 | $ref: "#/definitions/ProblemJson" 52 | "500": 53 | description: There was an error in retrieving the service. 54 | schema: 55 | $ref: "#/definitions/ProblemJson" 56 | parameters: [] 57 | "/profile/sender-services": 58 | x-swagger-router-controller: ServicesController 59 | get: 60 | operationId: getServicesByRecipient 61 | summary: Get Services by recipient 62 | description: |- 63 | Returns the service IDs of all the services that have contacted the recipient, 64 | identified by the provided fiscal code, at least once. 65 | responses: 66 | "200": 67 | description: Found. 68 | schema: 69 | $ref: "#/definitions/PaginatedServiceTupleCollection" 70 | examples: 71 | application/json: 72 | items: 73 | - service_id: 2b3e728c1a5d1efa035c 74 | page_size: 1 75 | "400": 76 | description: Bad request 77 | schema: 78 | $ref: "#/definitions/ProblemJson" 79 | "401": 80 | description: Bearer token null or expired. 81 | "429": 82 | description: Too many requests 83 | schema: 84 | $ref: "#/definitions/ProblemJson" 85 | "500": 86 | description: There was an error in retrieving the services. 87 | schema: 88 | $ref: "#/definitions/ProblemJson" 89 | parameters: 90 | - $ref: "#/parameters/PaginationRequest" 91 | "/services": 92 | x-swagger-router-controller: ServicesController 93 | get: 94 | operationId: getVisibleServices 95 | summary: Get all visible services 96 | description: |- 97 | Returns the description of all visible services. 98 | responses: 99 | "200": 100 | description: Found. 101 | schema: 102 | $ref: "#/definitions/PaginatedServiceTupleCollection" 103 | examples: 104 | application/json: 105 | items: 106 | - service_id: "AzureDeployc49a" 107 | version: 1 108 | - service_id: "5a25abf4fcc89605c082f042c49a" 109 | version: 0 110 | page_size: 1 111 | "401": 112 | description: Bearer token null or expired. 113 | "429": 114 | description: Too many requests 115 | schema: 116 | $ref: "#/definitions/ProblemJson" 117 | "500": 118 | description: There was an error in retrieving the services. 119 | schema: 120 | $ref: "#/definitions/ProblemJson" 121 | parameters: 122 | - $ref: "#/parameters/PaginationRequest" 123 | "/email-validation-process": 124 | x-swagger-router-controller: ProfileController 125 | post: 126 | operationId: startEmailValidationProcess 127 | summary: Start the Email Validation Process 128 | description: |- 129 | Start the email validation process that create the validation token 130 | and send the validation email 131 | responses: 132 | "202": 133 | description: Accepted 134 | "400": 135 | description: Bad request 136 | schema: 137 | $ref: "#/definitions/ProblemJson" 138 | "401": 139 | description: Bearer token null or expired. 140 | "404": 141 | description: Profile not found 142 | schema: 143 | $ref: "#/definitions/ProblemJson" 144 | "429": 145 | description: Too many requests 146 | schema: 147 | $ref: "#/definitions/ProblemJson" 148 | "500": 149 | description: There was an error in retrieving the user profile. 150 | schema: 151 | $ref: "#/definitions/ProblemJson" 152 | "/user-metadata": 153 | x-swagger-router-controller: userMetadataController 154 | get: 155 | operationId: getUserMetadata 156 | summary: Get user's metadata 157 | description: Returns metadata for the current authenticated user. 158 | responses: 159 | "200": 160 | description: Found. 161 | schema: 162 | $ref: "#/definitions/UserMetadata" 163 | "204": 164 | description: No Content. 165 | "401": 166 | description: Bearer token null or expired. 167 | "500": 168 | description: There was an error in retrieving the user metadata. 169 | schema: 170 | $ref: "#/definitions/ProblemJson" 171 | definitions: 172 | UserMetadata: 173 | type: object 174 | title: User Metadata information 175 | properties: 176 | version: 177 | type: number 178 | metadata: 179 | type: string 180 | required: 181 | - version 182 | - metadata 183 | DepartmentName: 184 | type: string 185 | description: |- 186 | The department inside the organization that runs the service. Will 187 | be added to the content of sent messages. 188 | minLength: 1 189 | OrganizationName: 190 | type: string 191 | description: |- 192 | The organization that runs the service. Will be added to the content 193 | of sent messages to identify the sender. 194 | minLength: 1 195 | PaginationResponse: 196 | type: object 197 | description: Pagination response parameters. 198 | properties: 199 | page_size: 200 | type: integer 201 | minimum: 1 202 | description: Number of items returned for each page. 203 | example: 2 204 | next: 205 | type: string 206 | description: |- 207 | Contains an URL to GET the next results page in the 208 | retrieved collection of items. 209 | format: uri 210 | example: https://example.com/?p=0XXX2 211 | ProblemJson: 212 | type: object 213 | properties: 214 | type: 215 | type: string 216 | format: uri 217 | description: |- 218 | An absolute URI that identifies the problem type. When dereferenced, 219 | it SHOULD provide human-readable documentation for the problem type 220 | (e.g., using HTML). 221 | default: about:blank 222 | example: https://example.com/problem/constraint-violation 223 | title: 224 | type: string 225 | description: |- 226 | A short, summary of the problem type. Written in english and readable 227 | for engineers (usually not suited for non technical stakeholders and 228 | not localized); example: Service Unavailable 229 | status: 230 | $ref: "#/definitions/HttpStatusCode" 231 | detail: 232 | type: string 233 | description: |- 234 | A human readable explanation specific to this occurrence of the 235 | problem. 236 | example: There was an error processing the request 237 | instance: 238 | type: string 239 | format: uri 240 | description: |- 241 | An absolute URI that identifies the specific occurrence of the problem. 242 | It may or may not yield further information if dereferenced. 243 | ServiceId: 244 | type: string 245 | description: |- 246 | The ID of the Service. Equals the subscriptionId of a registered 247 | API user. 248 | minLength: 1 249 | ServiceName: 250 | type: string 251 | description: The name of the service. Will be added to the content of sent messages. 252 | minLength: 1 253 | ServicePublic: 254 | title: Service (public) 255 | description: A Service associated to an user's subscription. 256 | type: object 257 | properties: 258 | service_id: 259 | $ref: "#/definitions/ServiceId" 260 | service_name: 261 | $ref: "#/definitions/ServiceName" 262 | organization_name: 263 | $ref: "#/definitions/OrganizationName" 264 | department_name: 265 | $ref: "#/definitions/DepartmentName" 266 | organization_fiscal_code: 267 | $ref: "#/definitions/OrganizationFiscalCode" 268 | available_notification_channels: 269 | $ref: "#/definitions/AvailableNotificationChannels" 270 | version: 271 | type: integer 272 | service_metadata: 273 | $ref: "#/definitions/ServiceMetadata" 274 | required: 275 | - service_id 276 | - service_name 277 | - organization_name 278 | - department_name 279 | - organization_fiscal_code 280 | - version 281 | AvailableNotificationChannels: 282 | description: |- 283 | All the notification channels available for a service. 284 | type: array 285 | items: 286 | $ref: "#/definitions/NotificationChannel" 287 | NotificationChannel: 288 | type: string 289 | description: |- 290 | All notification channels. 291 | x-extensible-enum: 292 | - EMAIL 293 | - WEBHOOK 294 | example: EMAIL 295 | ServiceMetadata: 296 | type: object 297 | properties: 298 | description: 299 | type: string 300 | minLength: 1 301 | web_url: 302 | type: string 303 | minLength: 1 304 | app_ios: 305 | type: string 306 | minLength: 1 307 | app_android: 308 | type: string 309 | minLength: 1 310 | tos_url: 311 | type: string 312 | minLength: 1 313 | privacy_url: 314 | type: string 315 | minLength: 1 316 | address: 317 | type: string 318 | minLength: 1 319 | phone: 320 | type: string 321 | minLength: 1 322 | email: 323 | type: string 324 | minLength: 1 325 | pec: 326 | type: string 327 | minLength: 1 328 | scope: 329 | type: string 330 | x-extensible-enum: 331 | - NATIONAL 332 | - LOCAL 333 | required: 334 | - scope 335 | OrganizationFiscalCode: 336 | type: string 337 | description: Organization fiscal code. 338 | format: OrganizationFiscalCode 339 | x-import: '@pagopa/ts-commons/lib/strings' 340 | example: "12345678901" 341 | ServiceTuple: 342 | type: object 343 | properties: 344 | service_id: 345 | $ref: "#/definitions/ServiceId" 346 | version: 347 | type: integer 348 | required: 349 | - service_id 350 | - version 351 | PaginatedServiceTupleCollection: 352 | description: A paginated collection of services tuples 353 | allOf: 354 | - $ref: "#/definitions/ServiceTupleCollection" 355 | - $ref: "#/definitions/PaginationResponse" 356 | ServiceTupleCollection: 357 | description: A collection of services tuples (service and version) 358 | type: object 359 | properties: 360 | items: 361 | type: array 362 | items: 363 | $ref: "#/definitions/ServiceTuple" 364 | required: 365 | - items 366 | HttpStatusCode: 367 | type: integer 368 | format: int32 369 | description: |- 370 | The HTTP status code generated by the origin server for this occurrence 371 | of the problem. 372 | minimum: 100 373 | maximum: 600 374 | exclusiveMaximum: true 375 | example: 200 376 | responses: {} 377 | parameters: 378 | PaginationRequest: 379 | type: string 380 | name: cursor 381 | in: query 382 | minimum: 1 383 | description: An opaque identifier that points to the next item in the collection. 384 | consumes: 385 | - application/json 386 | produces: 387 | - application/json 388 | securityDefinitions: 389 | Bearer: 390 | type: apiKey 391 | name: Authorization 392 | in: header 393 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utilities and tools for the Digital Citizenship initiative 2 | 3 | This package provide some tools that are used across the projects of the 4 | Digital Citizenship initiative. 5 | 6 | To add the tools to a project: 7 | 8 | ```sh 9 | $ yarn add -D @pagopa/openapi-codegen-ts 10 | ``` 11 | 12 | ## Commands 13 | 14 | ### gen-api-models 15 | 16 | This tool generates TypeScript definitions of OpenAPI specs. 17 | 18 | In simple terms it converts an OpenAPI spec like [this one](https://github.com/teamdigitale/digital-citizenship-functions/blob/f04666c8b7f2d4bebde19676b49b19119b03ef17/api/public_api_v1.yaml) into: 19 | 20 | * A TypeScript [representation of the specs](https://github.com/teamdigitale/digital-citizenship-functions/blob/6798225bd725a42753b16375ce18a954a268f9b6/lib/api/public_api_v1.ts). 21 | * An [io-ts](https://github.com/gcanti/io-ts) type definitions for [each API definition](https://github.com/teamdigitale/digital-citizenship-functions/tree/6798225bd725a42753b16375ce18a954a268f9b6/lib/api/definitions) that provides compile time types and runtime validation. 22 | * A http client exposing API operations as a collection of Typescript functions 23 | 24 | Note: the generated models requires the runtime dependency [`@pagopa/ts-commons`](https://www.npmjs.com/package/@pagopa/ts-commons). 25 | 26 | #### About string pattern definition 27 | 28 | Up until version 12.x, when handling string pattern definitions given in the OpenAPI specifications, the production of runtime types has a bug: when using a backslash (`\`) for escaping regular expression digits (e.g., `\d`), the generator drops it. Double backslashes (`\\`) can be used in the pattern description as a fix for this issue. Starting from version 12.x the codegen will notify you whenever it detects a `\\` inside a pattern definition as this bug has been resolved for OpenAPI 3.x. 29 | 30 | 31 | #### Usage 32 | 33 | ```sh 34 | $ gen-api-models --help 35 | Options: 36 | --version Show version number [boolean] 37 | --api-spec Path to input OpenAPI spec file [string] [required] 38 | --strict Generate strict interfaces (default: true) 39 | [default: true] 40 | --out-dir Output directory to store generated definition files 41 | [string] [required] 42 | --ts-spec-file If defined, converts the OpenAPI specs to TypeScript 43 | source and writes it to this file [string] 44 | --request-types Generate request types (default: false) 45 | [default: false] 46 | --response-decoders Generate response decoders (default: 47 | false, implies --request-types) [default: false] 48 | --client Generate request client SDK [default: false] 49 | --default-success-type Default type for success responses ( 50 | default: 'undefined') [string] [default: "undefined"] 51 | --default-error-type Default type for error responses ( 52 | default: 'undefined') [string] [default: "undefined"] 53 | --help Show help [boolean] 54 | ``` 55 | 56 | Example: 57 | 58 | ```sh 59 | $ gen-api-models --api-spec ./api/public_api_v1.yaml --out-dir ./lib/api/definitions --ts-spec-file ./lib/api/public_api_v1.ts 60 | Writing TS Specs to lib/api/public_api_v1.ts 61 | ProblemJson -> lib/api/definitions/ProblemJson.ts 62 | NotificationChannel -> lib/api/definitions/NotificationChannel.ts 63 | NotificationChannelStatusValue -> lib/api/definitions/NotificationChannelStatusValue.ts 64 | ... 65 | done 66 | ``` 67 | 68 | #### Generated client 69 | The http client is defined in `client.ts` module file. It exports the following: 70 | * a type `Client` which define the set of operations 71 | * * `K` a union of keys that represent the parameters omitted by the operations (see `withDefaults` below) 72 | * a function `createClient(parameters): Client` accepting the following parameters: 73 | * * `baseUrl` the base hostname of the api, including protocol and port 74 | * * `fetchApi` an implementation of the [fetch-api](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) as defined in your platform (for example: `node-fetch` if you are in node) 75 | * * `basePath` (optional) if defined, is appended to `baseUrl` for every operations. Its default is the basePath value defined in the specification 76 | * * `withDefaults` (optional) an adapter function that wraps every operations. It may shadow some parameters to the wrapped operations. The use case is: you have a parameter which is common to many operations and you want it to be fixed (example: a session token). 77 | * a type `WithDefaultsT` that defines an adapter function to be used as `withDefaults` 78 | * * `K` the set of parameters that the adapter will shadow 79 | 80 | ##### Example 81 | ```typescript 82 | import { createClient, WithDefaultsT } from "my-api/client"; 83 | 84 | 85 | // Without withDefaults 86 | const simpleClient: Client = createClient({ 87 | baseUrl: `http://localhost:8080`, 88 | fetchApi: (nodeFetch as any) as typeof fetch 89 | }); 90 | 91 | // myOperation is defined to accept { id: string; Bearer: string; } 92 | const result = await simpleClient.myOperation({ 93 | id: "id123", 94 | Bearer: "VALID_TOKEN" 95 | }); 96 | 97 | 98 | // with withDefaults 99 | const withBearer: WithDefaultsT<"Bearer"> = 100 | wrappedOperation => 101 | params => { // wrappedOperation and params are correctly inferred 102 | return wrappedOperation({ 103 | ...params, 104 | Bearer: "VALID_TOKEN" 105 | }); 106 | }; 107 | // this is the same of using createClient<"Bearer">. K type is being inferred from withBearer 108 | const clientWithGlobalToken: Client<"Bearer"> = createClient({ 109 | baseUrl: `http://localhost:8080`, 110 | fetchApi: (nodeFetch as any) as typeof fetch, 111 | withDefaults: withBearer 112 | }); 113 | 114 | // myOperation doesn't require "Bearer" anymore, as it's defined in "withBearer" adapter 115 | const result = await clientWithGlobalToken.myOperation({ 116 | id: "id123" 117 | }); 118 | ``` 119 | 120 | 121 | ### gen-api-sdk 122 | Bundles a generated api models and clients into a node package ready to be published into a registry. 123 | The script is expected to be executed in the root of an application exposing an API, thus it infers package attributes from the expected `./package.json` file. Values can be still overridden by provinding the respective CLI argument. To avoid this behavior, use `--no-infer-attrs` or `-N`. 124 | 125 | #### Usage 126 | ```sh 127 | $ gen-api-sdk --help 128 | Package options: 129 | --no-infer-attr, -N Infer package attributes from a 130 | package.json file present in the current 131 | directory [boolean] [default: false] 132 | --package-name, -n, --name Name of the generated package [string] 133 | --package-version, -V Version of the generated package [string] 134 | --package-description, -d, --desc Description of the package [string] 135 | --package-author, -a, --author The author of the API exposed [string] 136 | --package-license, -L, --license The license of the API Exposed [string] 137 | --package-registry, -r, --registry Url of the registry the package is 138 | published in [string] 139 | --package-access, -x, --access Either 'public' or 'private', depending of 140 | the accessibility of the package in the 141 | registry 142 | [string] [choices: "public", "private"] 143 | 144 | Code generation options: 145 | --api-spec, -i Path to input OpenAPI spec file [string] [required] 146 | --strict Generate strict interfaces (default: true) 147 | [default: true] 148 | --out-dir, -o Output directory to store generated definition files 149 | [string] [required] 150 | --default-success-type Default type for success responses (experimental, 151 | default: 'undefined') [string] [default: "undefined"] 152 | --default-error-type Default type for error responses (experimental, 153 | default: 'undefined') [string] [default: "undefined"] 154 | --camel-cased Generate camelCased properties name (default: false) 155 | [default: false] 156 | 157 | Options: 158 | --version Show version number [boolean] 159 | --help Show help [boolean] 160 | ``` 161 | 162 | 163 | ### bundle-api-spec 164 | Takes a given api spec file and resolves its esternal references by creating a new file with only internal refereces 165 | 166 | ```sh 167 | $ bundle-api-spec --help 168 | Code generation options: 169 | --api-spec, -i Path to input OpenAPI spec file [string] [required] 170 | --out-path, -o Output path of the spec file [string] [required] 171 | --api-version, -V Version of the api. If provided, override the version in 172 | the original spec file [string] 173 | 174 | Options: 175 | --version Show version number [boolean] 176 | --help Show help [boolean] 177 | ``` 178 | 179 | 180 | ## Requirements 181 | 182 | * `node` version >= 10.8.0 183 | 184 | 185 | ## TEST 186 | 187 | ### Unit test 188 | Run test over utils' implementation 189 | 190 | ```sh 191 | yarn test 192 | ``` 193 | 194 | ### End-to-end test 195 | Run test over generated files 196 | 197 | ```sh 198 | yarn e2e 199 | ``` 200 | 201 | ## Known issues, tradeoffs and throubleshooting 202 | 203 | ### A model file for a definition is not generated 204 | When using `gen-api-models` against a specification file which references an external definition file, some of such remote definitions do not result in a dedicated model file. This is somehow intended and the rationale is explained [here](https://github.com/pagopa/io-utils/pull/197). Quick takeaway is that to have a definition to result in a model file, it must be explicitly referenced by the specification file. 205 | In short: if you need to keep the references between the generated classes, the specification file must contain all the schema definitions. See example below. 206 | ##### example: 207 | if the `Pets` schema uses the `Pet`, import both into the main document 208 | ```yaml 209 | components: 210 | schemas: 211 | Pets: 212 | $ref: "animal.yaml#/Pets" 213 | Pet: 214 | $ref: "animal.yaml#/Pet" 215 | ``` 216 | *animal.yaml* 217 | ```yaml 218 | Pets: 219 | type: array 220 | items: 221 | $ref: '#/definitions/Pet' 222 | Pet: 223 | type: "object" 224 | required: 225 | - name 226 | properties: 227 | name: 228 | type: string 229 | ``` 230 | 231 | 232 | ## Migration from old versions 233 | Generated code is slightly different from `v4` as it implements some bug fixes that result in breaking changes. Here's a list of what to be aware of: 234 | #### from 4.3.0 to 5.x 235 | * On request type definitions, parameters are named after the `name` field in the spec. This applies to both local and global parameters. In the previous version, this used to be true only for local ones, while global parameters were named after the parameter's definition name. 236 | * The above rule doesn't apply to headers: in case of a security definition or a global parameter which has `in: header`, the definition name is considered, as the `name` attribute refers to the actual header name to be used as for OpenApi specification. 237 | * Generated decoders now support multiple success codes (i.e. 200 and 202), so we don't need to write custom decoders for such case as [we used to do](https://github.com/pagopa/io-backend/compare/174376802-experiment-with-sdk?expand=1#diff-cf7a83babfaf6e5babe84dffe22f64e4L81). 238 | * When using `gen-api-models` command, `--request-types` flag must be used explicitly in order to have `requestTypes` file generated. 239 | * Parameters that has a schema reference (like [this](https://github.com/pagopa/io-utils/blob/491d927ff263863bda9038fffa26442050b788e7/__mocks__/api.yaml#L87)) now use the `name` attribute as the parameter name. It used to be the lower-cased reference's name instead. 240 | * The script creates the destination folder automatically, there is no need to `mkdir` anymore. 241 | * Both ranged number and integers now correctly include upper bound values. This is achieved without using the _add 1_ trick implemented in [#182], which is reverted. Breaking changes may arise in target application if values are assigned to variables of type `WithinRangeInteger` or `WithinRangeNumber`. See [#205]. 242 | #### from 4.0.0 to 4.3.0 243 | * Attributes with `type: string` and `format: date` used to result in a `String` definition, while now produce `Date`. [#184](https://github.com/pagopa/io-utils/pull/184) 244 | * Allow camel-cased prop names. [#183](https://github.com/pagopa/io-utils/pull/183) 245 | * Numeric attributes with maximum value now produce a `WithinRangeInteger` which maximum is the next integer to solve off-by-one comparison error. [#182](https://github.com/pagopa/io-utils/pull/182) 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | European Union Public Licence 2 | V. 1.2 3 | 4 | EUPL © the European Union 2007, 2016 5 | 6 | This European Union Public Licence (the ‘EUPL’) applies to the Work (as 7 | defined below) which is provided under the terms of this Licence. Any use of 8 | the Work, other than as authorised under this Licence is prohibited (to the 9 | extent such use is covered by a right of the copyright holder of the Work). 10 | 11 | The Work is provided under the terms of this Licence when the Licensor (as 12 | defined below) has placed the following notice immediately following the 13 | copyright notice for the Work: “Licensed under the EUPL”, or has expressed by 14 | any other means his willingness to license under the EUPL. 15 | 16 | 1. Definitions 17 | 18 | In this Licence, the following terms have the following meaning: 19 | — ‘The Licence’: this Licence. 20 | — ‘The Original Work’: the work or software distributed or communicated by the 21 | ‘Licensor under this Licence, available as Source Code and also as 22 | ‘Executable Code as the case may be. 23 | — ‘Derivative Works’: the works or software that could be created by the 24 | ‘Licensee, based upon the Original Work or modifications thereof. This 25 | ‘Licence does not define the extent of modification or dependence on the 26 | ‘Original Work required in order to classify a work as a Derivative Work; 27 | ‘this extent is determined by copyright law applicable in the country 28 | ‘mentioned in Article 15. 29 | — ‘The Work’: the Original Work or its Derivative Works. 30 | — ‘The Source Code’: the human-readable form of the Work which is the most 31 | convenient for people to study and modify. 32 | 33 | — ‘The Executable Code’: any code which has generally been compiled and which 34 | is meant to be interpreted by a computer as a program. 35 | — ‘The Licensor’: the natural or legal person that distributes or communicates 36 | the Work under the Licence. 37 | — ‘Contributor(s)’: any natural or legal person who modifies the Work under 38 | the Licence, or otherwise contributes to the creation of a Derivative Work. 39 | — ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of 40 | the Work under the terms of the Licence. 41 | — ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, 42 | renting, distributing, communicating, transmitting, or otherwise making 43 | available, online or offline, copies of the Work or providing access to its 44 | essential functionalities at the disposal of any other natural or legal 45 | person. 46 | 47 | 2. Scope of the rights granted by the Licence 48 | 49 | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 50 | sublicensable licence to do the following, for the duration of copyright 51 | vested in the Original Work: 52 | 53 | — use the Work in any circumstance and for all usage, 54 | — reproduce the Work, 55 | — modify the Work, and make Derivative Works based upon the Work, 56 | — communicate to the public, including the right to make available or display 57 | the Work or copies thereof to the public and perform publicly, as the case 58 | may be, the Work, 59 | — distribute the Work or copies thereof, 60 | — lend and rent the Work or copies thereof, 61 | — sublicense rights in the Work or copies thereof. 62 | 63 | Those rights can be exercised on any media, supports and formats, whether now 64 | known or later invented, as far as the applicable law permits so. 65 | 66 | In the countries where moral rights apply, the Licensor waives his right to 67 | exercise his moral right to the extent allowed by law in order to make 68 | effective the licence of the economic rights here above listed. 69 | 70 | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights 71 | to any patents held by the Licensor, to the extent necessary to make use of 72 | the rights granted on the Work under this Licence. 73 | 74 | 3. Communication of the Source Code 75 | 76 | The Licensor may provide the Work either in its Source Code form, or as 77 | Executable Code. If the Work is provided as Executable Code, the Licensor 78 | provides in addition a machine-readable copy of the Source Code of the Work 79 | along with each copy of the Work that the Licensor distributes or indicates, 80 | in a notice following the copyright notice attached to the Work, a repository 81 | where the Source Code is easily and freely accessible for as long as the 82 | Licensor continues to distribute or communicate the Work. 83 | 84 | 4. Limitations on copyright 85 | 86 | Nothing in this Licence is intended to deprive the Licensee of the benefits 87 | from any exception or limitation to the exclusive rights of the rights owners 88 | in the Work, of the exhaustion of those rights or of other applicable 89 | limitations thereto. 90 | 91 | 5. Obligations of the Licensee 92 | 93 | The grant of the rights mentioned above is subject to some restrictions and 94 | obligations imposed on the Licensee. Those obligations are the following: 95 | 96 | Attribution right: The Licensee shall keep intact all copyright, patent or 97 | trademarks notices and all notices that refer to the Licence and to the 98 | disclaimer of warranties. The Licensee must include a copy of such notices and 99 | a copy of the Licence with every copy of the Work he/she distributes or 100 | communicates. The Licensee must cause any Derivative Work to carry prominent 101 | notices stating that the Work has been modified and the date of modification. 102 | 103 | Copyleft clause: If the Licensee distributes or communicates copies of the 104 | Original Works or Derivative Works, this Distribution or Communication will be 105 | done under the terms of this Licence or of a later version of this Licence 106 | unless the Original Work is expressly distributed only under this version of 107 | the Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee 108 | (becoming Licensor) cannot offer or impose any additional terms or conditions 109 | on the Work or Derivative Work that alter or restrict the terms of the 110 | Licence. 111 | 112 | Compatibility clause: If the Licensee Distributes or Communicates Derivative 113 | Works or copies thereof based upon both the Work and another work licensed 114 | under a Compatible Licence, this Distribution or Communication can be done 115 | under the terms of this Compatible Licence. For the sake of this clause, 116 | ‘Compatible Licence’ refers to the licences listed in the appendix attached to 117 | this Licence. Should the Licensee's obligations under the Compatible Licence 118 | conflict with his/her obligations under this Licence, the obligations of the 119 | Compatible Licence shall prevail. 120 | 121 | Provision of Source Code: When distributing or communicating copies of the 122 | Work, the Licensee will provide a machine-readable copy of the Source Code or 123 | indicate a repository where this Source will be easily and freely available 124 | for as long as the Licensee continues to distribute or communicate the Work. 125 | 126 | Legal Protection: This Licence does not grant permission to use the trade 127 | names, trademarks, service marks, or names of the Licensor, except as required 128 | for reasonable and customary use in describing the origin of the Work and 129 | reproducing the content of the copyright notice. 130 | 131 | 6. Chain of Authorship 132 | 133 | The original Licensor warrants that the copyright in the Original Work granted 134 | hereunder is owned by him/her or licensed to him/her and that he/she has the 135 | power and authority to grant the Licence. 136 | 137 | Each Contributor warrants that the copyright in the modifications he/she 138 | brings to the Work are owned by him/her or licensed to him/her and that he/she 139 | has the power and authority to grant the Licence. 140 | 141 | Each time You accept the Licence, the original Licensor and subsequent 142 | Contributors grant You a licence to their contributions to the Work, under the 143 | terms of this Licence. 144 | 145 | 7. Disclaimer of Warranty 146 | 147 | The Work is a work in progress, which is continuously improved by numerous 148 | Contributors. It is not a finished work and may therefore contain defects or 149 | ‘bugs’ inherent to this type of development. 150 | 151 | For the above reason, the Work is provided under the Licence on an ‘as is’ 152 | basis and without warranties of any kind concerning the Work, including 153 | without limitation merchantability, fitness for a particular purpose, absence 154 | of defects or errors, accuracy, non-infringement of intellectual property 155 | rights other than copyright as stated in Article 6 of this Licence. 156 | 157 | This disclaimer of warranty is an essential part of the Licence and a 158 | condition for the grant of any rights to the Work. 159 | 160 | 8. Disclaimer of Liability 161 | 162 | Except in the cases of wilful misconduct or damages directly caused to natural 163 | persons, the Licensor will in no event be liable for any direct or indirect, 164 | material or moral, damages of any kind, arising out of the Licence or of the 165 | use of the Work, including without limitation, damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, loss of data or any commercial 167 | damage, even if the Licensor has been advised of the possibility of such 168 | damage. However, the Licensor will be liable under statutory product liability 169 | laws as far such laws apply to the Work. 170 | 171 | 9. Additional agreements 172 | 173 | While distributing the Work, You may choose to conclude an additional 174 | agreement, defining obligations or services consistent with this Licence. 175 | However, if accepting obligations, You may act only on your own behalf and on 176 | your sole responsibility, not on behalf of the original Licensor or any other 177 | Contributor, and only if You agree to indemnify, defend, and hold each 178 | Contributor harmless for any liability incurred by, or claims asserted against 179 | such Contributor by the fact You have accepted any warranty or additional 180 | liability. 181 | 182 | 10. Acceptance of the Licence 183 | 184 | The provisions of this Licence can be accepted by clicking on an icon ‘I 185 | agree’ placed under the bottom of a window displaying the text of this Licence 186 | or by affirming consent in any other similar way, in accordance with the rules 187 | of applicable law. Clicking on that icon indicates your clear and irrevocable 188 | acceptance of this Licence and all of its terms and conditions. 189 | 190 | Similarly, you irrevocably accept this Licence and all of its terms and 191 | conditions by exercising any rights granted to You by Article 2 of this 192 | Licence, such as the use of the Work, the creation by You of a Derivative Work 193 | or the Distribution or Communication by You of the Work or copies thereof. 194 | 195 | 11. Information to the public 196 | 197 | In case of any Distribution or Communication of the Work by means of 198 | electronic communication by You (for example, by offering to download the Work 199 | from a remote location) the distribution channel or media (for example, a 200 | website) must at least provide to the public the information requested by the 201 | applicable law regarding the Licensor, the Licence and the way it may be 202 | accessible, concluded, stored and reproduced by the Licensee. 203 | 204 | 12. Termination of the Licence 205 | 206 | The Licence and the rights granted hereunder will terminate automatically upon 207 | any breach by the Licensee of the terms of the Licence. Such a termination 208 | will not terminate the licences of any person who has received the Work from 209 | the Licensee under the Licence, provided such persons remain in full 210 | compliance with the Licence. 211 | 212 | 13. Miscellaneous 213 | 214 | Without prejudice of Article 9 above, the Licence represents the complete 215 | agreement between the Parties as to the Work. 216 | 217 | If any provision of the Licence is invalid or unenforceable under applicable 218 | law, this will not affect the validity or enforceability of the Licence as a 219 | whole. Such provision will be construed or reformed so as necessary to make it 220 | valid and enforceable. 221 | 222 | The European Commission may publish other linguistic versions or new versions 223 | of this Licence or updated versions of the Appendix, so far this is required 224 | and reasonable, without reducing the scope of the rights granted by the 225 | Licence. New versions of the Licence will be published with a unique version 226 | number. 227 | 228 | All linguistic versions of this Licence, approved by the European Commission, 229 | have identical value. Parties can take advantage of the linguistic version of 230 | their choice. 231 | 232 | 14. Jurisdiction 233 | 234 | Without prejudice to specific agreement between parties, 235 | — any litigation resulting from the interpretation of this License, arising 236 | between the European Union institutions, bodies, offices or agencies, as a 237 | Licensor, and any Licensee, will be subject to the jurisdiction of the Court 238 | of Justice of the European Union, as laid down in article 272 of the Treaty 239 | on the Functioning of the European Union, 240 | — any litigation arising between other parties and resulting from the 241 | interpretation of this License, will be subject to the exclusive 242 | jurisdiction of the competent court where the Licensor resides or conducts 243 | its primary business. 244 | 245 | 15. Applicable Law 246 | 247 | Without prejudice to specific agreement between parties, 248 | — this Licence shall be governed by the law of the European Union Member State 249 | where the Licensor has his seat, resides or has his registered office, 250 | — this licence shall be governed by Belgian law if the Licensor has no seat, 251 | residence or registered office inside a European Union Member State. 252 | 253 | Appendix 254 | 255 | ‘Compatible Licences’ according to Article 5 EUPL are: 256 | — GNU General Public License (GPL) v. 2, v. 3 257 | — GNU Affero General Public License (AGPL) v. 3 258 | — Open Software License (OSL) v. 2.1, v. 3.0 259 | — Eclipse Public License (EPL) v. 1.0 260 | — CeCILL v. 2.0, v. 2.1 261 | — Mozilla Public Licence (MPL) v. 2 262 | — GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 263 | — Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for 264 | works other than software 265 | — European Union Public Licence (EUPL) v. 1.1, v. 1.2 266 | — Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or 267 | Strong Reciprocity (LiLiQ-R+) 268 | 269 | — The European Commission may update this Appendix to later versions of the 270 | above licences without producing a new version of the EUPL, as long as they 271 | provide the rights granted in Article 2 of this Licence and protect the 272 | covered Source Code from exclusive appropriation. 273 | — All other changes or additions to this Appendix require the production of a 274 | new EUPL version. 275 | 276 | -------------------------------------------------------------------------------- /e2e/src/__tests__/test-api-v3/definitions.test.ts: -------------------------------------------------------------------------------- 1 | import config from "../../config"; 2 | 3 | // @ts-ignore because leaked-handles doesn't ship type defintions 4 | import * as leaked from "leaked-handles"; 5 | leaked.set({ debugSockets: true }); 6 | 7 | import { WithinRangeExclusiveMaximumIntegerTest } from "../../generated/testapiV3/WithinRangeExclusiveMaximumIntegerTest"; 8 | import { WithinRangeExclusiveMaximumNumberTest } from "../../generated/testapiV3/WithinRangeExclusiveMaximumNumberTest"; 9 | import { WithinRangeExclusiveMinimumIntegerTest } from "../../generated/testapiV3/WithinRangeExclusiveMinimumIntegerTest"; 10 | import { WithinRangeExclusiveMinimumNumberTest } from "../../generated/testapiV3/WithinRangeExclusiveMinimumNumberTest"; 11 | import { WithinRangeExclusiveMinMaxNumberTest } from "../../generated/testapiV3/WithinRangeExclusiveMinMaxNumberTest"; 12 | 13 | import { ConstantIntegerTest } from "../../generated/testapiV3/ConstantIntegerTest"; 14 | 15 | import { WithinRangeIntegerTest } from "../../generated/testapiV3/WithinRangeIntegerTest"; 16 | import { WithinRangeNumberTest } from "../../generated/testapiV3/WithinRangeNumberTest"; 17 | import { WithinRangeStringTest } from "../../generated/testapiV3/WithinRangeStringTest"; 18 | 19 | import { DisabledUserTest } from "../../generated/testapiV3/DisabledUserTest"; 20 | import { DisjointUnionsUserTest } from "../../generated/testapiV3/DisjointUnionsUserTest"; 21 | import { EnabledUserTest } from "../../generated/testapiV3/EnabledUserTest"; 22 | import { EnumFalseTest } from "../../generated/testapiV3/EnumFalseTest"; 23 | import { EnumTrueTest } from "../../generated/testapiV3/EnumTrueTest"; 24 | import { AllOfWithOneElementTest } from "../../generated/testapiV3/AllOfWithOneElementTest"; 25 | import { AllOfWithOneRefElementTest } from "../../generated/testapiV3/AllOfWithOneRefElementTest"; 26 | 27 | import * as E from "fp-ts/lib/Either"; 28 | 29 | const { generatedFilesDir, isSpecEnabled } = config.specs.testapiV3; 30 | 31 | // if there's no need for this suite in this particular run, just skip it 32 | 33 | const loadModule = (name: string) => 34 | import(`${generatedFilesDir}/${name}.ts`).then(mod => { 35 | if (!mod) { 36 | fail(`Cannot load module ${generatedFilesDir}/${name}.ts`); 37 | } 38 | return mod; 39 | }); 40 | 41 | describe("FiscalCode definition", () => { 42 | it("should generate FiscalCode decoder", async () => { 43 | const { FiscalCode } = await loadModule("FiscalCode"); 44 | expect(FiscalCode).toBeDefined(); 45 | }); 46 | 47 | it.each` 48 | title | example | expected 49 | ${"should fail decoding empty"} | ${""} | ${false} 50 | ${"should decode valid cf"} | ${"RSSMRA80A01F205X"} | ${true} 51 | ${"should fail decoding invalid cf"} | ${"INVALIDCFFORMAT"} | ${false} 52 | `("$title", async ({ example, expected }) => { 53 | const { FiscalCode } = await loadModule("FiscalCode"); 54 | const result = E.isRight(FiscalCode.decode(example)); 55 | expect(result).toEqual(expected); 56 | }); 57 | }); 58 | 59 | describe("Profile defintion", () => { 60 | it("should generate Profile decoder", async () => { 61 | const { Profile } = await loadModule("Profile"); 62 | expect(Profile).toBeDefined(); 63 | }); 64 | 65 | const basicProfile = { 66 | family_name: "Rossi", 67 | fiscal_code: "RSSMRA80A01F205X", 68 | has_profile: true, 69 | is_email_set: false, 70 | name: "Mario", 71 | version: 123 72 | }; 73 | const completeProfile = { 74 | family_name: "Rossi", 75 | fiscal_code: "RSSMRA80A01F205X", 76 | has_profile: true, 77 | is_email_set: false, 78 | name: "Mario", 79 | version: 123, 80 | email: "fake@email.com" 81 | }; 82 | const profileWithPayload = { 83 | family_name: "Rossi", 84 | fiscal_code: "RSSMRA80A01F205X", 85 | has_profile: true, 86 | is_email_set: false, 87 | name: "Mario", 88 | version: 123, 89 | payload: { foo: "bar" } 90 | }; 91 | 92 | it.each` 93 | title | example | expected 94 | ${"should fail decoding empty"} | ${""} | ${false} 95 | ${"should fail decoding non-object"} | ${"value"} | ${false} 96 | ${"should decode basic profile"} | ${basicProfile} | ${true} 97 | ${"should decode complete profile"} | ${completeProfile} | ${true} 98 | ${"should decode profile with payload"} | ${profileWithPayload} | ${true} 99 | `("$title", async ({ example, expected }) => { 100 | const { Profile } = await loadModule("Profile"); 101 | const result = E.isRight(Profile.decode(example)); 102 | expect(result).toEqual(expected); 103 | }); 104 | }); 105 | 106 | describe("ConstantIntegerTest definition", () => { 107 | it.each` 108 | value | expected 109 | ${100} | ${true} 110 | ${99} | ${false} 111 | ${101} | ${false} 112 | ${199} | ${false} 113 | `("should decode $value with ConstantIntegerTest", ({ value, expected }) => { 114 | const result = ConstantIntegerTest.decode(value); 115 | expect(E.isRight(result)).toEqual(expected); 116 | }); 117 | }); 118 | 119 | describe("WithinRangeIntegerTest definition", () => { 120 | // WithinRangeIntegerTest is defined min=0 max=10 in the spec 121 | it.each` 122 | value | expected 123 | ${0} | ${true /* lower bound */} 124 | ${-1} | ${false} 125 | ${5} | ${true} 126 | ${9.9999} | ${false /* not an integer */} 127 | ${10} | ${true /* upper bound */} 128 | ${10.0001} | ${false /* not an integer */} 129 | ${11} | ${false} 130 | ${100} | ${false} 131 | ${undefined} | ${false} 132 | `( 133 | "should decode $value with WithinRangeIntegerTest", 134 | ({ value, expected }) => { 135 | const result = WithinRangeIntegerTest.decode(value); 136 | expect(E.isRight(result)).toEqual(expected); 137 | } 138 | ); 139 | }); 140 | 141 | describe("WithinRangeNumberTest definition", () => { 142 | // WithinRangeNumberTest is defined min=0 max=10 in the spec 143 | it.each` 144 | value | expected 145 | ${0} | ${true /* lower bound */} 146 | ${-1} | ${false} 147 | ${5} | ${true} 148 | ${9.9999999} | ${true} 149 | ${10} | ${true /* upper bound */} 150 | ${10.000001} | ${false} 151 | ${11} | ${false} 152 | ${100} | ${false} 153 | ${undefined} | ${false} 154 | `( 155 | "should decode $value with WithinRangeNumberTest", 156 | ({ value, expected }) => { 157 | const result = WithinRangeNumberTest.decode(value); 158 | expect(E.isRight(result)).toEqual(expected); 159 | } 160 | ); 161 | 162 | describe("WithinRangeExclusiveMinimumNumberTest definition", () => { 163 | // WithinRangeExclusiveMinimumNumberTest is defined min=0 max=10 exclusiveMinimum: true in the spec 164 | it.each` 165 | value | expected 166 | ${-1} | ${false} 167 | ${0} | ${false} 168 | ${0.1} | ${true} 169 | ${0.5} | ${true} 170 | ${1} | ${true} 171 | ${9.9999999} | ${true} 172 | ${10} | ${true /* upper bound */} 173 | ${10.000001} | ${false} 174 | ${11} | ${false} 175 | ${100} | ${false} 176 | ${undefined} | ${false} 177 | `( 178 | "should decode $value with WithinRangeExclusiveMinimumNumberTest", 179 | ({ value, expected }) => { 180 | const result = WithinRangeExclusiveMinimumNumberTest.decode(value); 181 | expect(E.isRight(result)).toEqual(expected); 182 | } 183 | ); 184 | }); 185 | describe("WithinRangeExclusiveMaximumNumberTest definition", () => { 186 | // WithinRangeExclusiveMaximumNumberTest is defined min=0 max=10 exclusiveMaximum: true in the spec 187 | it.each` 188 | value | expected 189 | ${-1} | ${false} 190 | ${0} | ${true /* lower bound */} 191 | ${1.5} | ${true} 192 | ${5.5} | ${true} 193 | ${9} | ${true} 194 | ${9.5} | ${true} 195 | ${9.999} | ${true} 196 | ${10} | ${false} 197 | ${11} | ${false} 198 | ${100} | ${false} 199 | ${undefined} | ${false} 200 | `( 201 | "should decode $value with WithinRangeExclusiveMaximumNumberTest", 202 | ({ value, expected }) => { 203 | const result = WithinRangeExclusiveMaximumNumberTest.decode(value); 204 | expect(E.isRight(result)).toEqual(expected); 205 | } 206 | ); 207 | }); 208 | 209 | describe("WithinRangeExclusiveMinMaxNumberTest definition", () => { 210 | // WithinRangeExclusiveMinMaxNumberTest is defined min=0 max=10 exclusiveMaximum: true exclusiveMinimum: true in the spec 211 | it.each` 212 | value | expected 213 | ${-1} | ${false} 214 | ${0} | ${false} 215 | ${0.1} | ${true} 216 | ${1.5} | ${true} 217 | ${5.5} | ${true} 218 | ${9} | ${true} 219 | ${9.5} | ${true} 220 | ${9.999} | ${true} 221 | ${10} | ${false} 222 | ${11} | ${false} 223 | ${100} | ${false} 224 | ${undefined} | ${false} 225 | `( 226 | "should decode $value with WithinRangeExclusiveMinMaxNumberTest", 227 | ({ value, expected }) => { 228 | const result = WithinRangeExclusiveMinMaxNumberTest.decode(value); 229 | expect(E.isRight(result)).toEqual(expected); 230 | } 231 | ); 232 | }); 233 | /* it("should have correct ts types", () => { 234 | // value is actually "any" 235 | const value1: WithinRangeNumberTest = WithinRangeNumberTest.decode(10).getOrElseL(err => { 236 | throw new Error(readableReport(err)) 237 | }); 238 | // should this be ok? value1 can be 10 and it's not in [0, 10) 239 | const asRangedValue: IWithinRangeNumberTag<0, 10> = value1; 240 | const asRangedValue3: IWithinRangeNumberTag<0, 10> = value1; 241 | // should this be ok? value1 can be in [0, 10) and it's not 10 242 | const asRangedValue2: 10 = value1; 243 | const asRangedValue5: WithinRangeNumberTest = 10; 244 | }) */ 245 | }); 246 | 247 | describe("WithinRangeExclusiveMinimumIntegerTest definition", () => { 248 | // WithinRangeExclusiveMinimumIntegerTest is defined min=0 max=10 exclusiveMinimum: true in the spec 249 | it.each` 250 | value | expected 251 | ${0} | ${false} 252 | ${-1} | ${false} 253 | ${1} | ${true /* lower bound */} 254 | ${5} | ${true} 255 | ${9} | ${true} 256 | ${10} | ${true /* upper bound */} 257 | ${11} | ${false} 258 | ${100} | ${false} 259 | ${undefined} | ${false} 260 | `( 261 | "should decode $value with WithinRangeExclusiveMinimumIntegerTest", 262 | ({ value, expected }) => { 263 | const result = WithinRangeExclusiveMinimumIntegerTest.decode(value); 264 | expect(E.isRight(result)).toEqual(expected); 265 | } 266 | ); 267 | }); 268 | 269 | describe("WithinRangeExclusiveMaximumIntegerTest definition", () => { 270 | // WithinRangeExclusiveMaximumIntegerTest is defined min=0 max=10 exclusiveMaximum: true in the spec 271 | it.each` 272 | value | expected 273 | ${0} | ${true /* lower bound */} 274 | ${-1} | ${false} 275 | ${1} | ${true} 276 | ${5} | ${true} 277 | ${9} | ${true} 278 | ${10} | ${false /* upper bound */} 279 | ${11} | ${false} 280 | ${100} | ${false} 281 | ${undefined} | ${false} 282 | `( 283 | "should decode $value with WithinRangeExclusiveMaximumIntegerTest", 284 | ({ value, expected }) => { 285 | const result = WithinRangeExclusiveMaximumIntegerTest.decode(value); 286 | expect(E.isRight(result)).toEqual(expected); 287 | } 288 | ); 289 | }); 290 | 291 | describe("WithinRangeStringTest defintion", () => { 292 | // WithinRangeStringTest is defined min=8 max=10 in the spec 293 | it.each` 294 | value | expected 295 | ${"a".repeat(7)} | ${false} 296 | ${"a".repeat(8)} | ${true /* lower bound */} 297 | ${"a".repeat(9)} | ${true} 298 | ${"a".repeat(10)} | ${true /* upper bound */} 299 | ${"a".repeat(11)} | ${false} 300 | ${undefined} | ${false} 301 | `( 302 | "should decode $value with WithinRangeStringTest", 303 | ({ value, expected }) => { 304 | const result = WithinRangeStringTest.decode(value); 305 | expect(E.isRight(result)).toEqual(expected); 306 | } 307 | ); 308 | }); 309 | 310 | describe("EnumTrueTest definition", () => { 311 | const statusOk = { flag: true }; 312 | const statusKo = { flag: false }; 313 | 314 | it("should decode statusOk with EnumTrueTest", () => { 315 | const result = EnumTrueTest.decode(statusOk); 316 | expect(E.isRight(result)).toBe(true); 317 | }); 318 | 319 | it("should not decode statusKo with EnumTrueTest", () => { 320 | const result = EnumTrueTest.decode(statusKo); 321 | 322 | expect(E.isLeft(result)).toBe(true); 323 | }); 324 | }); 325 | 326 | describe("EnumFalseTest definition", () => { 327 | const statusOk = { flag: false }; 328 | const statusKo = { flag: true }; 329 | 330 | it("should decode statusOk with EnumFalseTest", () => { 331 | const result = EnumFalseTest.decode(statusOk); 332 | expect(E.isRight(result)).toBe(true); 333 | }); 334 | 335 | it("should not decode statusKo with EnumFalseTest", () => { 336 | const result = EnumFalseTest.decode(statusKo); 337 | 338 | expect(E.isLeft(result)).toBe(true); 339 | }); 340 | }); 341 | 342 | describe("AllOfWithOneElementTest definition", () => { 343 | const okElement = { key: "string" }; 344 | const notOkElement = { key: 1 }; 345 | 346 | it("Should return a right", () => { 347 | expect(E.isRight(AllOfWithOneElementTest.decode(okElement))).toBeTruthy(); 348 | }); 349 | 350 | it("Should return a left", () => { 351 | expect(E.isLeft(AllOfWithOneElementTest.decode(notOkElement))).toBeTruthy(); 352 | }); 353 | }); 354 | 355 | describe("AllOfWithOneRefElementTest", () => { 356 | const basicProfile = { 357 | family_name: "Rossi", 358 | fiscal_code: "RSSMRA80A01F205X", 359 | has_profile: true, 360 | is_email_set: false, 361 | name: "Mario", 362 | version: 123 363 | }; 364 | 365 | it("Should return a right", () => { 366 | expect( 367 | E.isRight(AllOfWithOneRefElementTest.decode(basicProfile)) 368 | ).toBeTruthy(); 369 | }); 370 | }); 371 | 372 | describe("DisjointUnionsUserTest definition", () => { 373 | const enabledUser = { 374 | description: "Description for the user", 375 | enabled: true, 376 | username: "user" 377 | }; 378 | const disabledUser = { 379 | enabled: false, 380 | reason: "reason for the user", 381 | username: "user" 382 | }; 383 | 384 | const invalidUser = { 385 | description: "Description for the user", 386 | enabled: false, 387 | username: "user" 388 | }; 389 | 390 | it("should decode enabledUser with DisjointUnionsUserTest", () => { 391 | const userTest = DisjointUnionsUserTest.decode(enabledUser); 392 | const enabledUserTest = EnabledUserTest.decode(enabledUser); 393 | const disabledUserTest = DisabledUserTest.decode(enabledUser); 394 | 395 | expect(E.isRight(userTest)).toBe(true); 396 | expect(E.isRight(enabledUserTest)).toBe(true); 397 | expect(E.isLeft(disabledUserTest)).toBe(true); 398 | }); 399 | 400 | it("should decode disabledUser with DisjointUnionsUserTest", () => { 401 | const userTest = DisjointUnionsUserTest.decode(disabledUser); 402 | const enabledUserTest = EnabledUserTest.decode(disabledUser); 403 | const disabledUserTest = DisabledUserTest.decode(disabledUser); 404 | 405 | expect(E.isRight(userTest)).toBe(true); 406 | expect(E.isRight(disabledUserTest)).toBe(true); 407 | expect(E.isLeft(enabledUserTest)).toBe(true); 408 | }); 409 | 410 | it("should not decode invalidUser with DisjointUnionsUserTest", () => { 411 | const userTest = DisjointUnionsUserTest.decode(invalidUser); 412 | const enabledUserTest = EnabledUserTest.decode(invalidUser); 413 | const disabledUserTest = DisabledUserTest.decode(invalidUser); 414 | 415 | expect(E.isLeft(userTest)).toBe(true); 416 | expect(E.isLeft(disabledUserTest)).toBe(true); 417 | expect(E.isLeft(enabledUserTest)).toBe(true); 418 | }); 419 | }); 420 | -------------------------------------------------------------------------------- /src/commands/gen-api-models/render.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module collects pure utility functions that render a code text based of a data structure. 3 | * Each function return a string containing formatted code, but does not write the code file. 4 | * Code generation might use template string literals or nunjucks templates. 5 | * Data structure might be a parsed structure or directly a OpenAPI object (in which case, the parsing logic is demanded to the template macros). 6 | */ 7 | 8 | import { ITuple3, Tuple2 } from "@pagopa/ts-commons/lib/tuples"; 9 | import { OpenAPI } from "openapi-types"; 10 | import * as prettier from "prettier"; 11 | import { 12 | capitalize, 13 | toUnionOfLiterals, 14 | uncapitalize, 15 | withGenerics 16 | } from "../../lib/utils"; 17 | import templateEnvironment from "./templateEnvironment"; 18 | import { IDefinition, IOperationInfo, ISpecMetaInfo } from "./types"; 19 | 20 | const { render } = templateEnvironment; 21 | 22 | /** 23 | * Render a code block which exports an object literal representing the api specification 24 | * 25 | * @param spec the original api specification 26 | */ 27 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions, @typescript-eslint/explicit-function-return-type 28 | export function renderSpecCode(spec: OpenAPI.Document) { 29 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 30 | return formatCode(` 31 | /* eslint-disable sort-keys */ 32 | /* eslint-disable sonarjs/no-duplicate-string */ 33 | added this line 34 | // DO NOT EDIT 35 | 36 | export const specs = ${JSON.stringify(spec)}; 37 | `); 38 | } 39 | 40 | /** 41 | * Given a OpenAPI definition object, it renders the code which describes the correspondent typescript model 42 | * 43 | * @param definitionName the name of the definition 44 | * @param definition the definition data 45 | * @param strictInterfaces wheater requires strict interfaces or not 46 | * @param camelCasedPropNames wheater model properties must me camel-cased. 47 | * 48 | * @returns the formatted code for the model's typescript definition 49 | */ 50 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 51 | export async function renderDefinitionCode( 52 | definitionName: string, 53 | definition: IDefinition, 54 | strictInterfaces: boolean, 55 | camelCasedPropNames: boolean = false 56 | ): Promise { 57 | return render("model.ts.njk", { 58 | camelCasedPropNames, 59 | definition, 60 | definitionName, 61 | strictInterfaces 62 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 63 | }).then(formatCode); 64 | } 65 | 66 | /** 67 | * Given a list of parsed operations, it renders the code for an opinionated http client module that imlements each operation as an async method 68 | * 69 | * @param specMeta meta info of the api specification 70 | * @param operations the list of parsed operations 71 | * 72 | * @returns the code of a http client 73 | */ 74 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 75 | export async function renderClientCode( 76 | specMeta: ISpecMetaInfo, 77 | operations: ReadonlyArray 78 | ) { 79 | return render("client.ts.njk", { 80 | operations, 81 | spec: specMeta 82 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 83 | }).then(formatCode); 84 | } 85 | 86 | /** 87 | * Renders the code that includes every operation definition 88 | * 89 | * @param allOperationInfos collection of parsed operations 90 | * @param generateResponseDecoders true to include decoders 91 | * 92 | * @return the rendered code 93 | */ 94 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 95 | export function renderAllOperations( 96 | allOperationInfos: ReadonlyArray, 97 | generateResponseDecoders: boolean 98 | ) { 99 | const operationsTypes = allOperationInfos 100 | .filter( 101 | (operationInfo): operationInfo is IOperationInfo => 102 | typeof operationInfo !== "undefined" 103 | ) 104 | .map(operationInfo => 105 | // the code of an operation associated with its needed imported types 106 | Tuple2( 107 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 108 | renderOperation(operationInfo, generateResponseDecoders), 109 | operationInfo.importedTypes 110 | ) 111 | ); 112 | 113 | // the set of referenced definitions 114 | const operationsImports = new Set( 115 | operationsTypes.reduce( 116 | (p, { e2 }) => [...p, ...e2], 117 | [] as ReadonlyArray 118 | ) 119 | ); 120 | 121 | // the concatenated generated code 122 | const operationTypesCode = operationsTypes.map(({ e1 }) => e1).join("\n"); 123 | 124 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 125 | return formatCode(` 126 | // DO NOT EDIT THIS FILE 127 | // This file has been generated by gen-api-models 128 | // eslint-disable sonar/max-union-size 129 | // eslint-disable sonarjs/no-identical-functions 130 | 131 | ${generateResponseDecoders ? 'import * as t from "io-ts";' : ""} 132 | 133 | import * as r from "@pagopa/ts-commons/lib/requests"; 134 | 135 | ${Array.from(operationsImports.values()) 136 | .map(i => `import { ${i} } from "./${i}";`) 137 | .join("\n\n")} 138 | 139 | ${operationTypesCode} 140 | `); 141 | } 142 | 143 | /** 144 | * Render the code of decoders and request types of a single operation 145 | * 146 | * @param operationInfo 147 | * @param generateResponseDecoders true if decoders have to be added 148 | * 149 | * @returns a tuple containing the generated code and the 150 | */ 151 | export const renderOperation = ( 152 | operationInfo: IOperationInfo, 153 | generateResponseDecoders: boolean 154 | ): string => { 155 | const { method, operationId, headers, responses, parameters } = operationInfo; 156 | 157 | const requestType = `r.I${capitalize(method)}ApiRequestType`; 158 | 159 | const headersCode = 160 | headers.length > 0 ? headers.map(_ => `"${_}"`).join("|") : "never"; 161 | 162 | const responsesType = responses 163 | .map( 164 | ({ e1: statusCode, e2: typeName, e3: headerNames }) => 165 | `r.IResponseType<${statusCode}, ${typeName}, ${toUnionOfLiterals( 166 | headerNames 167 | )}>` 168 | ) 169 | .join("|"); 170 | 171 | // wraps an identifiler with doublequotes 172 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 173 | const escapeIdentifier = (id: string) => 174 | id.includes("?") ? `"${id.replace("?", "")}"?` : `"${id}"`; 175 | 176 | const paramsCode = parameters 177 | .map(param => `readonly ${escapeIdentifier(param.name)}: ${param.type}`) 178 | .join(","); 179 | 180 | const responsesDecoderCode = generateResponseDecoders 181 | ? // eslint-disable-next-line @typescript-eslint/no-use-before-define 182 | renderDecoderCode(operationInfo) 183 | : ""; 184 | 185 | const requestTypeDefinition = `export type ${capitalize( 186 | operationId 187 | )}T = ${requestType}<{${paramsCode}}, ${headersCode}, never, ${responsesType}>; 188 | `; 189 | 190 | return ` 191 | /**************************************************************** 192 | * ${operationId} 193 | */ 194 | 195 | // Request type definition 196 | ${requestTypeDefinition}${responsesDecoderCode}`; 197 | }; 198 | 199 | /** 200 | * Retrieves the first successful response type from a list of operation responses. 201 | * 202 | * This function iterates through the provided responses and checks the status code. 203 | * If a response has a status code starting with "2" (indicating success), it returns that response. 204 | * If no such response is found, it checks for responses with status codes starting with "3" (indicating redirection). 205 | * If any redirection responses are found, it returns the first one. 206 | * If neither successful nor redirection responses are found, it returns `undefined`. 207 | * 208 | * @param responses - An array of operation responses to evaluate. 209 | * @returns The first successful response, the first redirection response if no successful response is found, or `undefined` if neither are found. 210 | */ 211 | export const getfirstSuccessType = ( 212 | responses: IOperationInfo["responses"] 213 | ): IOperationInfo["responses"][number] | undefined => { 214 | const redirectResponses = []; 215 | for (const response of responses) { 216 | if (response.e1.length === 3) { 217 | if (response.e1[0] === "2") { 218 | return response; 219 | } 220 | if (response.e1[0] === "3") { 221 | // eslint-disable-next-line functional/immutable-data 222 | redirectResponses.push(response); 223 | } 224 | } 225 | } 226 | return redirectResponses.length > 0 ? redirectResponses[0] : undefined; 227 | }; 228 | 229 | /** 230 | * Compose the code for response decoder of an operation 231 | * 232 | * @param operationInfo the operation 233 | * 234 | * @returns {string} the composed code 235 | */ 236 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, prefer-arrow/prefer-arrow-functions 237 | export function renderDecoderCode({ responses, operationId }: IOperationInfo) { 238 | // use the first 2xx type as "success type" that we allow to be overridden 239 | const firstSuccessType = getfirstSuccessType(responses); 240 | if (!firstSuccessType) { 241 | return ""; 242 | } 243 | 244 | // the name of the var holding the set of decoders 245 | const typeVarName = "type"; 246 | 247 | const decoderFunctionName = `${operationId}Decoder`; 248 | const defaultDecoderFunctionName = `${operationId}DefaultDecoder`; 249 | 250 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 251 | const decoderName = (statusCode: string) => `d${statusCode}`; 252 | const decoderDefinitions = responses 253 | .map( 254 | /* eslint-disable @typescript-eslint/no-use-before-define */ 255 | ({ e1: statusCode, e2: typeName, e3: headerNames }, i) => ` 256 | const ${decoderName(statusCode)} = (${getDecoderForResponse( 257 | { e1: statusCode, e2: typeName, e3: headerNames }, 258 | typeVarName 259 | )}) as r.ResponseDecoder>; 262 | ` 263 | /* eslint-enable @typescript-eslint/no-use-before-define */ 264 | ) 265 | .join(""); 266 | const composedDecoders = responses.reduce( 267 | (acc, { e1: statusCode }) => 268 | acc === "" 269 | ? decoderName(statusCode) 270 | : `r.composeResponseDecoders(${acc}, ${decoderName(statusCode)})`, 271 | "" 272 | ); 273 | 274 | // a string with a concatenated pair of type variables for each decoder/encoder 275 | // ex: A0, C0, A1, C1, ... 276 | const responsesTGenerics = responses.reduce( 277 | (p: ReadonlyArray, r, i) => [...p, `A${i}`, `C${i}`], 278 | [] as ReadonlyArray 279 | ); 280 | // a string with a concatenated pair of type variables for each decoder/encoder 281 | // with defaults as defined in the parsed responses 282 | // ex: A0=X, C0=X, A1=Y, C1=Y, ... 283 | const responsesTGenericsWithDefaultTypes = responses.reduce( 284 | (p: ReadonlyArray, r, i) => [ 285 | ...p, 286 | `A${i} = ${r.e2}`, 287 | `C${i} = ${r.e2}` 288 | ], 289 | [] as ReadonlyArray 290 | ); 291 | 292 | // ex: MyOperationResponseT 293 | const responsesTypeName = withGenerics( 294 | `${capitalize(operationId)}ResponsesT`, 295 | responsesTGenerics 296 | ); 297 | 298 | // ex: MyOperationResponseT 299 | const responsesTypeNameWithDefaultTypes = withGenerics( 300 | `${capitalize(operationId)}ResponsesT`, 301 | responsesTGenericsWithDefaultTypes 302 | ); 303 | 304 | // ex: myOperationResponseT 305 | const decoderDefinitionName = withGenerics( 306 | decoderFunctionName, 307 | responsesTGenericsWithDefaultTypes 308 | ); 309 | 310 | const responsesTContent = responses.map( 311 | ({ e1: statusCode }, i) => `${statusCode}: t.Type` 312 | ); 313 | 314 | // Then we create the whole type definition 315 | // 316 | // 200: t.Type 317 | // 202: t.UndefinedC 318 | const responsesT = ` 319 | export type ${responsesTypeNameWithDefaultTypes} = { 320 | ${responsesTContent.join(", ")} 321 | }; 322 | `; 323 | 324 | // This is the type of the first success type 325 | // We need it to keep retro-compatibility 326 | const responsesSuccessTContent = responses.reduce( 327 | (p: string, r, i) => 328 | r.e1 !== firstSuccessType.e1 ? p : `t.Type`, 329 | "" 330 | ); 331 | 332 | const defaultResponsesVarName = `${uncapitalize( 333 | operationId 334 | )}DefaultResponses`; 335 | 336 | // Create an object with the default type for each response code: 337 | // 338 | // export const ${defaultResponsesVarName} = { 339 | // 200: MyType, 340 | // 202: t.undefined, 341 | // 400: t.undefined 342 | // }; 343 | const defaultResponses = ` 344 | export const ${defaultResponsesVarName} = { 345 | ${responses 346 | .map(r => `${r.e1}: ${r.e2 === "undefined" ? "t.undefined" : r.e2}`) 347 | .join(", ")} 348 | }; 349 | `; 350 | 351 | // a type in the form 352 | // r.ResponseDecoder< 353 | // | r.IResponseType<200, A0, never> 354 | // | r.IResponseType<202, A1, never> 355 | // >; 356 | const returnType = `r.ResponseDecoder< 357 | ${responses 358 | .map( 359 | ({ e1: statusCode, e3: headerNames }, i) => 360 | `r.IResponseType<${statusCode}, A${i}, ${toUnionOfLiterals( 361 | headerNames 362 | )}>` 363 | ) 364 | .join("|")} 365 | >`; 366 | 367 | return ` 368 | ${defaultResponses} 369 | ${responsesT} 370 | export function ${decoderDefinitionName}(overrideTypes: Partial<${responsesTypeName}> | ${responsesSuccessTContent} | undefined = {}): ${returnType} { 371 | const isDecoder = (d: any): d is ${responsesSuccessTContent} => 372 | typeof d["_A"] !== "undefined"; 373 | 374 | const ${typeVarName} = { 375 | ...(${defaultResponsesVarName} as unknown as ${responsesTypeName}), 376 | ...(isDecoder(overrideTypes) ? { ${firstSuccessType.e1}: overrideTypes } : overrideTypes) 377 | }; 378 | 379 | ${decoderDefinitions} 380 | return ${composedDecoders} 381 | } 382 | 383 | // Decodes the success response with the type defined in the specs 384 | export const ${defaultDecoderFunctionName} = () => ${decoderFunctionName}();`; 385 | } 386 | 387 | /** 388 | * Renders the response decoder associated to the given type. 389 | * Response types refer to io-ts-commons (https://github.com/pagopa/io-ts-commons/blob/master/src/requests.ts) 390 | * 391 | * @param param0.status http status code the decoder is associated with 392 | * @param param0.type type to be decoded 393 | * @param param0.headers headers of the response 394 | * @param varName the name of the variables that holds the type decoder 395 | * 396 | * @returns a string which represents a decoder declaration 397 | */ 398 | // eslint-disable-next-line prefer-arrow/prefer-arrow-functions 399 | function getDecoderForResponse( 400 | { 401 | e1: status, 402 | e2: type, 403 | e3: headers 404 | }: ITuple3>, 405 | varName: string 406 | ): string { 407 | return type === "Buffer" 408 | ? `r.bufferArrayResponseDecoder(${status}) as any` 409 | : type === "Error" 410 | ? `r.basicErrorResponseDecoder<${status}>(${status})` 411 | : // checks at runtime if the provided decoder is t.undefined 412 | `${varName}[${status}].name === "undefined" 413 | ? r.constantResponseDecoder(${status}, undefined) 416 | : r.ioResponseDecoder<${status}, (typeof ${varName}[${status}])["_A"], (typeof ${varName}[${status}])["_O"], ${toUnionOfLiterals( 417 | headers 418 | )}>(${status}, ${varName}[${status}])`; 419 | } 420 | 421 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 422 | const formatCode = (code: string) => 423 | prettier.format(code, { 424 | parser: "typescript" 425 | }); 426 | --------------------------------------------------------------------------------