├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ └── resources │ ├── example-pet-api.openapi.yml │ ├── openapi-broken.yml │ ├── openapi-with-internal.yml │ ├── openapi-without-internal-and-unreferenced-components.yml │ ├── openapi-without-internal.yml │ ├── openapi.json │ ├── openapi.yml │ ├── swagger.json │ └── swagger.yml ├── api ├── index.html └── openapi.json ├── bin ├── dev.cmd ├── dev.js ├── run.cmd └── run.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── test-fixtures.ts │ └── test-utils.ts ├── commands │ ├── auth.test.ts │ ├── auth.ts │ ├── call.test.ts │ ├── call.ts │ ├── info.test.ts │ ├── info.ts │ ├── init.test.ts │ ├── init.ts │ ├── load.test.ts │ ├── load.ts │ ├── mock.test.ts │ ├── mock.ts │ ├── read.test.ts │ ├── read.ts │ ├── redoc.test.ts │ ├── redoc.ts │ ├── swagger-editor.test.ts │ ├── swagger-editor.ts │ ├── swagger-ui.test.ts │ ├── swagger-ui.ts │ ├── swagger2openapi.test.ts │ ├── swagger2openapi.ts │ ├── test │ │ ├── add.ts │ │ └── index.ts │ ├── typegen.test.ts │ ├── typegen.ts │ ├── unload.test.ts │ └── unload.ts ├── common │ ├── config.ts │ ├── context.ts │ ├── definition.ts │ ├── flags.ts │ ├── koa.ts │ ├── prompt.ts │ ├── redoc.ts │ ├── security.ts │ ├── strip-definition-presets.test.ts │ ├── strip-definition.test.ts │ ├── strip-definition.ts │ ├── swagger-ui.ts │ └── utils.ts ├── index.ts ├── tests │ ├── jest.config.ts │ ├── run-jest.ts │ └── tests.ts ├── typegen │ ├── typegen.test.ts │ └── typegen.ts └── types │ ├── swagger-parser.d.ts │ └── types.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | root: true, 7 | ignorePatterns: ["lib/*"], 8 | env: { 9 | node: true, 10 | jest: true 11 | }, 12 | rules: { 13 | "ordered-imports": 0, 14 | "object-literal-sort-keys": 0, 15 | "no-string-literal": 0, 16 | "object-literal-key-quotes": 0, 17 | "no-console": 0, 18 | "@typescript-eslint/no-explicit-any": 1, 19 | "@typescript-eslint/no-unused-vars": [1, { "argsIgnorePattern": "^_" }], 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: anttiviljami 2 | open_collective: openapi-stack 3 | custom: 4 | - https://buymeacoff.ee/anttiviljami 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: ["main"] 5 | tags: ["*"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "18" 18 | - run: npm ci 19 | - run: npm run lint 20 | - run: npm run build 21 | - run: ./bin/run.js --version 22 | - run: ./bin/run.js --help 23 | - run: npm test -- --verbose 24 | 25 | publish: 26 | name: Publish 27 | runs-on: ubuntu-latest 28 | if: startsWith(github.ref, 'refs/tags/') 29 | needs: 30 | - test 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v3 34 | with: 35 | node-version: "18" 36 | registry-url: https://registry.npmjs.org/ 37 | - run: npm ci 38 | - run: npm publish || true 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | lib 4 | static 5 | node_modules 6 | .openapiconfig* 7 | tsconfig.tsbuildinfo 8 | oclif.manifest.json 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "arrowParens": "always", 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | viljami@viljami.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | openapicmd is Free and Open Source Software. Issues and pull requests are more than welcome! 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Viljami Kuosmanen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /__tests__/resources/example-pet-api.openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Example API 4 | description: Example CRUD API for pets 5 | version: 1.0.0 6 | tags: 7 | - name: pets 8 | description: Pet operations 9 | servers: 10 | - url: http://localhost:8080 11 | paths: 12 | /pets: 13 | get: 14 | operationId: getPets 15 | summary: List pets 16 | description: Returns all pets in database 17 | tags: 18 | - pets 19 | responses: 20 | '200': 21 | description: List of pets in database 22 | default: 23 | description: unexpected error 24 | parameters: 25 | - name: limit 26 | in: query 27 | description: Number of items to return 28 | required: false 29 | schema: 30 | $ref: '#/components/schemas/QueryLimit' 31 | - name: offset 32 | in: query 33 | description: Starting offset for returning items 34 | required: false 35 | schema: 36 | $ref: '#/components/schemas/QueryOffset' 37 | post: 38 | operationId: createPet 39 | summary: Create a pet 40 | description: Crete a new pet into the database 41 | tags: 42 | - pets 43 | responses: 44 | '201': 45 | description: Pet created succesfully 46 | parameters: [] 47 | requestBody: 48 | $ref: '#/components/requestBodies/PetPayload' 49 | '/pets/{id}': 50 | get: 51 | operationId: getPetById 52 | summary: Get a pet 53 | description: Returns a pet by its id in database 54 | tags: 55 | - pets 56 | responses: 57 | '200': 58 | description: Pet object corresponding to id 59 | '404': 60 | description: Pet not found 61 | parameters: 62 | - name: id 63 | in: path 64 | description: Unique identifier for pet in database 65 | required: true 66 | schema: 67 | $ref: '#/components/schemas/PetId' 68 | put: 69 | operationId: replacePetById 70 | summary: Replace pet 71 | description: Replace an existing pet in the database 72 | tags: 73 | - pets 74 | responses: 75 | '200': 76 | description: Pet replaced succesfully 77 | '404': 78 | description: Pet not found 79 | parameters: 80 | - name: id 81 | in: path 82 | description: Unique identifier for pet in database 83 | required: true 84 | schema: 85 | $ref: '#/components/schemas/PetId' 86 | requestBody: 87 | $ref: '#/components/requestBodies/PetPayload' 88 | patch: 89 | operationId: updatePetById 90 | summary: Update pet 91 | description: Update an existing pet in the database 92 | tags: 93 | - pets 94 | responses: 95 | '200': 96 | description: Pet updated succesfully 97 | '404': 98 | description: Pet not found 99 | parameters: 100 | - name: id 101 | in: path 102 | description: Unique identifier for pet in database 103 | required: true 104 | schema: 105 | $ref: '#/components/schemas/PetId' 106 | requestBody: 107 | $ref: '#/components/requestBodies/PetPayload' 108 | delete: 109 | operationId: deletePetById 110 | summary: Delete a pet 111 | description: Deletes a pet by its id in database 112 | tags: 113 | - pets 114 | responses: 115 | '200': 116 | description: Pet deleted succesfully 117 | '404': 118 | description: Pet not found 119 | parameters: 120 | - name: id 121 | in: path 122 | description: Unique identifier for pet in database 123 | required: true 124 | schema: 125 | $ref: '#/components/schemas/PetId' 126 | '/pets/{id}/owner': 127 | get: 128 | operationId: getOwnerByPetId 129 | summary: Get a pet's owner 130 | description: Get the owner for a pet 131 | tags: 132 | - pets 133 | responses: 134 | '200': 135 | description: Human corresponding pet id 136 | '404': 137 | description: Human or pet not found 138 | parameters: 139 | - name: id 140 | in: path 141 | description: Unique identifier for pet in database 142 | required: true 143 | schema: 144 | $ref: '#/components/schemas/PetId' 145 | '/pets/{petId}/owner/{ownerId}': 146 | get: 147 | operationId: getPetOwner 148 | summary: Get owner by id 149 | description: Get the owner for a pet 150 | tags: 151 | - pets 152 | parameters: 153 | - name: petId 154 | in: path 155 | description: Unique identifier for pet in database 156 | required: true 157 | schema: 158 | $ref: '#/components/schemas/PetId' 159 | - name: ownerId 160 | in: path 161 | description: Unique identifier for owner in database 162 | required: true 163 | schema: 164 | $ref: '#/components/schemas/PetId' 165 | responses: 166 | '200': 167 | description: Human corresponding owner id 168 | '404': 169 | description: Human or pet not found 170 | /pets/meta: 171 | get: 172 | operationId: getPetsMeta 173 | summary: Get pet metadata 174 | description: Returns a list of metadata about pets and their relations in the database 175 | tags: 176 | - pets 177 | responses: 178 | '200': 179 | description: Metadata for pets 180 | /pets/relative: 181 | servers: [{ url: baseURLV2 }] 182 | get: 183 | operationId: 'getPetsRelative' 184 | summary: Get pet metadata 185 | description: Returns a list of metadata about pets and their relations in the database 186 | tags: 187 | - pets 188 | responses: 189 | '200': 190 | description: Metadata for pets 191 | components: 192 | schemas: 193 | PetId: 194 | description: Unique identifier for pet in database 195 | example: 1 196 | title: PetId 197 | type: integer 198 | PetPayload: 199 | type: object 200 | properties: 201 | name: 202 | description: Name of the pet 203 | example: Garfield 204 | title: PetName 205 | type: string 206 | additionalProperties: false 207 | required: 208 | - name 209 | QueryLimit: 210 | description: Number of items to return 211 | example: 25 212 | title: QueryLimit 213 | type: integer 214 | QueryOffset: 215 | description: Starting offset for returning items 216 | example: 0 217 | title: QueryOffset 218 | type: integer 219 | minimum: 0 220 | requestBodies: 221 | PetPayload: 222 | description: 'Request payload containing a pet object' 223 | content: 224 | application/json: 225 | schema: 226 | $ref: '#/components/schemas/PetPayload' 227 | -------------------------------------------------------------------------------- /__tests__/resources/openapi-broken.yml: -------------------------------------------------------------------------------- 1 | openapi: 'not valid version number' 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | paths: 6 | /pets: 7 | get: 8 | operationId: getPets 9 | responses: 10 | '200': 11 | $ref: '#/components/responses/ListPetsRes' 12 | post: 13 | operationId: createPet 14 | requestBody: 15 | description: Pet object to create 16 | content: 17 | application/json: {} 18 | responses: 19 | '201': 20 | $ref: '#/components/responses/PetRes' 21 | '/pets/{id}': 22 | get: 23 | operationId: getPetById 24 | responses: 25 | '200': 26 | $ref: '#/components/responses/PetRes' 27 | parameters: 28 | - name: id 29 | in: path 30 | required: true 31 | schema: 32 | type: integer 33 | components: 34 | responses: 35 | ListPetsRes: 36 | description: ok 37 | content: 38 | application/json: 39 | schema: 40 | type: array 41 | items: 42 | type: object 43 | properties: 44 | id: 45 | type: integer 46 | minimum: 1 47 | name: 48 | type: string 49 | example: Odie 50 | PetRes: 51 | description: ok 52 | content: 53 | application/json: 54 | examples: 55 | garfield: 56 | value: 57 | id: 1 58 | name: Garfield 59 | -------------------------------------------------------------------------------- /__tests__/resources/openapi-with-internal.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | paths: 6 | /pets: 7 | get: 8 | operationId: getPets 9 | x-internal: true 10 | responses: 11 | '200': 12 | $ref: '#/components/responses/ListPetsRes' 13 | post: 14 | operationId: createPet 15 | requestBody: 16 | description: Pet object to create 17 | content: 18 | application/json: {} 19 | responses: 20 | '201': 21 | $ref: '#/components/responses/PetRes' 22 | '/pets/{id}': 23 | get: 24 | operationId: getPetById 25 | responses: 26 | '200': 27 | $ref: '#/components/responses/PetRes' 28 | parameters: 29 | - name: id 30 | in: path 31 | required: true 32 | schema: 33 | type: integer 34 | x-internal: true 35 | components: 36 | schemas: 37 | Pet: 38 | type: object 39 | properties: 40 | id: 41 | type: integer 42 | minimum: 1 43 | name: 44 | type: string 45 | example: Odie 46 | PetInput: 47 | type: object 48 | x-openapicmd-keep: true 49 | properties: 50 | name: 51 | type: string 52 | example: Odie 53 | responses: 54 | ListPetsRes: 55 | description: ok 56 | content: 57 | application/json: 58 | schema: 59 | type: array 60 | items: 61 | $ref: '#/components/schemas/Pet' 62 | PetRes: 63 | description: ok 64 | content: 65 | application/json: 66 | schema: 67 | $ref: '#/components/schemas/Pet' 68 | securitySchemes: 69 | ApiKeyHeaderAuth: 70 | type: apiKey 71 | in: header 72 | name: x-apikey 73 | description: API key sent as a header 74 | BasicAuth: 75 | type: http 76 | scheme: basic 77 | description: Basic username/password authentication sent in Authorization header 78 | BearerAuth: 79 | type: http 80 | scheme: bearer 81 | description: Bearer token sent in Authorization header 82 | ApiKeyCookieAuth: 83 | type: apiKey 84 | in: cookie 85 | name: apikey 86 | description: API key sent as a cookie 87 | ApiKeyQueryAuth: 88 | type: apiKey 89 | in: query 90 | name: apikey 91 | description: API key sent as a query parameter 92 | security: 93 | - BasicAuth: [] 94 | - BearerAuth: [] 95 | - ApiKeyHeaderAuth: [] 96 | - ApiKeyCookieAuth: [] 97 | - ApiKeyQueryAuth: [] 98 | -------------------------------------------------------------------------------- /__tests__/resources/openapi-without-internal-and-unreferenced-components.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | paths: 6 | /pets: 7 | post: 8 | operationId: createPet 9 | requestBody: 10 | description: Pet object to create 11 | content: 12 | application/json: {} 13 | responses: 14 | '201': 15 | $ref: '#/components/responses/PetRes' 16 | components: 17 | schemas: 18 | Pet: 19 | type: object 20 | properties: 21 | id: 22 | type: integer 23 | minimum: 1 24 | name: 25 | type: string 26 | example: Odie 27 | PetInput: 28 | type: object 29 | x-openapicmd-keep: true 30 | properties: 31 | name: 32 | type: string 33 | example: Odie 34 | responses: 35 | PetRes: 36 | description: ok 37 | content: 38 | application/json: 39 | schema: 40 | $ref: '#/components/schemas/Pet' 41 | securitySchemes: 42 | ApiKeyHeaderAuth: 43 | type: apiKey 44 | in: header 45 | name: x-apikey 46 | description: API key sent as a header 47 | BasicAuth: 48 | type: http 49 | scheme: basic 50 | description: Basic username/password authentication sent in Authorization header 51 | BearerAuth: 52 | type: http 53 | scheme: bearer 54 | description: Bearer token sent in Authorization header 55 | ApiKeyCookieAuth: 56 | type: apiKey 57 | in: cookie 58 | name: apikey 59 | description: API key sent as a cookie 60 | ApiKeyQueryAuth: 61 | type: apiKey 62 | in: query 63 | name: apikey 64 | description: API key sent as a query parameter 65 | security: 66 | - BasicAuth: [] 67 | - BearerAuth: [] 68 | - ApiKeyHeaderAuth: [] 69 | - ApiKeyCookieAuth: [] 70 | - ApiKeyQueryAuth: [] 71 | -------------------------------------------------------------------------------- /__tests__/resources/openapi-without-internal.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | paths: 6 | /pets: 7 | post: 8 | operationId: createPet 9 | requestBody: 10 | description: Pet object to create 11 | content: 12 | application/json: {} 13 | responses: 14 | '201': 15 | $ref: '#/components/responses/PetRes' 16 | components: 17 | schemas: 18 | Pet: 19 | type: object 20 | properties: 21 | id: 22 | type: integer 23 | minimum: 1 24 | name: 25 | type: string 26 | example: Odie 27 | PetInput: 28 | type: object 29 | x-openapicmd-keep: true 30 | properties: 31 | name: 32 | type: string 33 | example: Odie 34 | responses: 35 | ListPetsRes: 36 | description: ok 37 | content: 38 | application/json: 39 | schema: 40 | type: array 41 | items: 42 | $ref: '#/components/schemas/Pet' 43 | PetRes: 44 | description: ok 45 | content: 46 | application/json: 47 | schema: 48 | $ref: '#/components/schemas/Pet' 49 | securitySchemes: 50 | ApiKeyHeaderAuth: 51 | type: apiKey 52 | in: header 53 | name: x-apikey 54 | description: API key sent as a header 55 | BasicAuth: 56 | type: http 57 | scheme: basic 58 | description: Basic username/password authentication sent in Authorization header 59 | BearerAuth: 60 | type: http 61 | scheme: bearer 62 | description: Bearer token sent in Authorization header 63 | ApiKeyCookieAuth: 64 | type: apiKey 65 | in: cookie 66 | name: apikey 67 | description: API key sent as a cookie 68 | ApiKeyQueryAuth: 69 | type: apiKey 70 | in: query 71 | name: apikey 72 | description: API key sent as a query parameter 73 | security: 74 | - BasicAuth: [] 75 | - BearerAuth: [] 76 | - ApiKeyHeaderAuth: [] 77 | - ApiKeyCookieAuth: [] 78 | - ApiKeyQueryAuth: [] 79 | -------------------------------------------------------------------------------- /__tests__/resources/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "My API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/pets": { 9 | "get": { 10 | "operationId": "getPets", 11 | "responses": { 12 | "200": { 13 | "$ref": "#/components/responses/ListPetsRes" 14 | } 15 | } 16 | }, 17 | "post": { 18 | "operationId": "createPet", 19 | "requestBody": { 20 | "description": "Pet object to create", 21 | "content": { 22 | "application/json": {} 23 | } 24 | }, 25 | "responses": { 26 | "201": { 27 | "$ref": "#/components/responses/PetRes" 28 | } 29 | } 30 | } 31 | }, 32 | "/pets/{id}": { 33 | "get": { 34 | "operationId": "getPetById", 35 | "responses": { 36 | "200": { 37 | "$ref": "#/components/responses/PetRes" 38 | } 39 | }, 40 | "parameters": [ 41 | { 42 | "name": "id", 43 | "in": "path", 44 | "required": true, 45 | "schema": { 46 | "type": "integer" 47 | } 48 | } 49 | ] 50 | } 51 | } 52 | }, 53 | "components": { 54 | "schemas": { 55 | "Pet": { 56 | "type": "object", 57 | "properties": { 58 | "id": { 59 | "type": "integer", 60 | "minimum": 1 61 | }, 62 | "name": { 63 | "type": "string", 64 | "example": "Odie" 65 | } 66 | } 67 | } 68 | }, 69 | "responses": { 70 | "ListPetsRes": { 71 | "description": "ok", 72 | "content": { 73 | "application/json": { 74 | "schema": { 75 | "type": "array", 76 | "items": { 77 | "$ref": "#/components/schemas/Pet" 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | "PetRes": { 84 | "description": "ok", 85 | "content": { 86 | "application/json": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Pet" 89 | } 90 | } 91 | } 92 | } 93 | }, 94 | "securitySchemes": { 95 | "BasicAuth": { 96 | "type": "http", 97 | "scheme": "basic", 98 | "description": "Basic username/password authentication sent in Authorization header" 99 | }, 100 | "BearerAuth": { 101 | "type": "http", 102 | "scheme": "bearer", 103 | "description": "Bearer token sent in Authorization header" 104 | }, 105 | "ApiKeyHeaderAuth": { 106 | "type": "apiKey", 107 | "in": "header", 108 | "name": "x-apikey", 109 | "description": "API key sent as a header" 110 | }, 111 | "ApiKeyCookieAuth": { 112 | "type": "apiKey", 113 | "in": "cookie", 114 | "name": "apikey", 115 | "description": "API key sent as a cookie" 116 | }, 117 | "ApiKeyQueryAuth": { 118 | "type": "apiKey", 119 | "in": "query", 120 | "name": "apikey", 121 | "description": "API key sent as a query parameter" 122 | } 123 | } 124 | }, 125 | "security": [ 126 | { 127 | "BasicAuth": [] 128 | }, 129 | { 130 | "BearerAuth": [] 131 | }, 132 | { 133 | "ApiKeyHeaderAuth": [] 134 | }, 135 | { 136 | "ApiKeyCookieAuth": [] 137 | }, 138 | { 139 | "ApiKeyQueryAuth": [] 140 | } 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /__tests__/resources/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | paths: 6 | /pets: 7 | get: 8 | operationId: getPets 9 | responses: 10 | '200': 11 | $ref: '#/components/responses/ListPetsRes' 12 | post: 13 | operationId: createPet 14 | requestBody: 15 | description: Pet object to create 16 | content: 17 | application/json: {} 18 | responses: 19 | '201': 20 | $ref: '#/components/responses/PetRes' 21 | '/pets/{id}': 22 | get: 23 | operationId: getPetById 24 | responses: 25 | '200': 26 | $ref: '#/components/responses/PetRes' 27 | parameters: 28 | - name: id 29 | in: path 30 | required: true 31 | schema: 32 | type: integer 33 | components: 34 | schemas: 35 | Pet: 36 | type: object 37 | properties: 38 | id: 39 | type: integer 40 | minimum: 1 41 | name: 42 | type: string 43 | example: Odie 44 | responses: 45 | ListPetsRes: 46 | description: ok 47 | content: 48 | application/json: 49 | schema: 50 | type: array 51 | items: 52 | $ref: '#/components/schemas/Pet' 53 | PetRes: 54 | description: ok 55 | content: 56 | application/json: 57 | schema: 58 | $ref: '#/components/schemas/Pet' 59 | securitySchemes: 60 | BasicAuth: 61 | type: http 62 | scheme: basic 63 | description: Basic username/password authentication sent in Authorization header 64 | BearerAuth: 65 | type: http 66 | scheme: bearer 67 | description: Bearer token sent in Authorization header 68 | ApiKeyHeaderAuth: 69 | type: apiKey 70 | in: header 71 | name: x-apikey 72 | description: API key sent as a header 73 | ApiKeyCookieAuth: 74 | type: apiKey 75 | in: cookie 76 | name: apikey 77 | description: API key sent as a cookie 78 | ApiKeyQueryAuth: 79 | type: apiKey 80 | in: query 81 | name: apikey 82 | description: API key sent as a query parameter 83 | security: 84 | - BasicAuth: [] 85 | - BearerAuth: [] 86 | - ApiKeyHeaderAuth: [] 87 | - ApiKeyCookieAuth: [] 88 | - ApiKeyQueryAuth: [] -------------------------------------------------------------------------------- /__tests__/resources/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "My API", 5 | "version": "1.0.0" 6 | }, 7 | "consumes": [ 8 | "application/json" 9 | ], 10 | "produces": [ 11 | "application/json" 12 | ], 13 | "paths": { 14 | "/pets": { 15 | "get": { 16 | "operationId": "getPets", 17 | "responses": { 18 | "200": { 19 | "description": "ok", 20 | "schema": { 21 | "$ref": "#/definitions/ListPetsRes" 22 | } 23 | } 24 | } 25 | }, 26 | "post": { 27 | "operationId": "createPet", 28 | "parameters": [ 29 | { 30 | "in": "body", 31 | "name": "pet", 32 | "schema": {} 33 | } 34 | ], 35 | "responses": { 36 | "201": { 37 | "description": "ok", 38 | "schema": { 39 | "$ref": "#/definitions/PetRes" 40 | } 41 | } 42 | } 43 | } 44 | }, 45 | "/pets/{id}": { 46 | "get": { 47 | "operationId": "getPetById", 48 | "responses": { 49 | "200": { 50 | "description": "ok", 51 | "schema": { 52 | "$ref": "#/definitions/PetRes" 53 | } 54 | } 55 | }, 56 | "parameters": [ 57 | { 58 | "name": "id", 59 | "in": "path", 60 | "type": "integer", 61 | "required": true 62 | } 63 | ] 64 | } 65 | } 66 | }, 67 | "definitions": { 68 | "ListPetsRes": { 69 | "type": "array", 70 | "items": { 71 | "type": "object", 72 | "properties": { 73 | "id": { 74 | "type": "integer", 75 | "minimum": 1 76 | }, 77 | "name": { 78 | "type": "string", 79 | "example": "Odie" 80 | } 81 | } 82 | } 83 | }, 84 | "PetRes": { 85 | "example": { 86 | "garfield": { 87 | "value": { 88 | "id": 1, 89 | "name": "Garfield" 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /__tests__/resources/swagger.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | title: My API 4 | version: 1.0.0 5 | consumes: 6 | - application/json 7 | produces: 8 | - application/json 9 | paths: 10 | /pets: 11 | get: 12 | operationId: getPets 13 | responses: 14 | '200': 15 | description: ok 16 | schema: 17 | $ref: '#/definitions/ListPetsRes' 18 | post: 19 | operationId: createPet 20 | parameters: 21 | - in: body 22 | name: pet 23 | schema: {} 24 | responses: 25 | '201': 26 | description: ok 27 | schema: 28 | $ref: '#/definitions/PetRes' 29 | '/pets/{id}': 30 | get: 31 | operationId: getPetById 32 | responses: 33 | '200': 34 | description: ok 35 | schema: 36 | $ref: '#/definitions/PetRes' 37 | parameters: 38 | - name: id 39 | in: path 40 | type: integer 41 | required: true 42 | definitions: 43 | ListPetsRes: 44 | type: array 45 | items: 46 | type: object 47 | properties: 48 | id: 49 | type: integer 50 | minimum: 1 51 | name: 52 | type: string 53 | example: Odie 54 | PetRes: 55 | example: 56 | garfield: 57 | value: 58 | id: 1 59 | name: Garfield 60 | 61 | -------------------------------------------------------------------------------- /api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | My API 4 | 5 | 6 | 7 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /api/openapi.json: -------------------------------------------------------------------------------- 1 | {"openapi":"3.0.1","info":{"title":"My API","version":"1.0.0"},"paths":{"/pets":{"get":{"operationId":"getPets","responses":{"200":{"$ref":"#/components/responses/ListPetsRes"}}},"post":{"operationId":"createPet","requestBody":{"description":"Pet object to create","content":{"application/json":{}}},"responses":{"201":{"$ref":"#/components/responses/PetRes"}}}},"/pets/{id}":{"get":{"operationId":"getPetById","responses":{"200":{"$ref":"#/components/responses/PetRes"}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}]}}},"components":{"schemas":{"Pet":{"type":"object","properties":{"id":{"type":"integer","minimum":1},"name":{"type":"string","example":"Odie"}}}},"responses":{"ListPetsRes":{"description":"ok","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}}},"PetRes":{"description":"ok","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}}}}},"securitySchemes":{"BasicAuth":{"type":"http","scheme":"basic","description":"Basic username/password authentication sent in Authorization header"},"BearerAuth":{"type":"http","scheme":"bearer","description":"Bearer token sent in Authorization header"},"ApiKeyHeaderAuth":{"type":"apiKey","in":"header","name":"x-apikey","description":"API key sent as a header"},"ApiKeyCookieAuth":{"type":"apiKey","in":"cookie","name":"apikey","description":"API key sent as a cookie"},"ApiKeyQueryAuth":{"type":"apiKey","in":"query","name":"apikey","description":"API key sent as a query parameter"}}},"security":[{"BasicAuth":[]},{"BearerAuth":[]},{"ApiKeyHeaderAuth":[]},{"ApiKeyCookieAuth":[]},{"ApiKeyQueryAuth":[]}]} -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\dev" %* -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | // eslint-disable-next-line node/shebang, unicorn/prefer-top-level-await 3 | (async () => { 4 | const oclif = await import('@oclif/core') 5 | await oclif.execute({development: true, dir: __dirname}) 6 | })() 7 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // eslint-disable-next-line unicorn/prefer-top-level-await 4 | (async () => { 5 | const oclif = await import('@oclif/core') 6 | await oclif.execute({development: true, dir: __dirname}) 7 | })() -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/?(*.)+(spec|test).ts?(x)'], 5 | testPathIgnorePatterns: ['node_modules', 'examples'], 6 | testTimeout: 15000, 7 | verbose: true, 8 | silent: true, 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openapicmd", 3 | "description": "OpenAPI Command Line Tool", 4 | "version": "2.7.0", 5 | "author": "Viljami Kuosmanen ", 6 | "bin": { 7 | "openapi": "./bin/run.js" 8 | }, 9 | "bugs": "https://github.com/openapistack/openapicmd/issues", 10 | "dependencies": { 11 | "@anttiviljami/dtsgenerator": "^3.20.0", 12 | "@apidevtools/swagger-parser": "^10.1.0", 13 | "@koa/cors": "^5.0.0", 14 | "@oclif/command": "^1.8.36", 15 | "@oclif/config": "^1.18.17", 16 | "@oclif/core": "^3", 17 | "@oclif/errors": "^1.3.6", 18 | "@oclif/plugin-help": "^6.0.2", 19 | "@oclif/plugin-plugins": "^5.4.4", 20 | "@types/inquirer": "^7.3.1", 21 | "ajv": "^8.12.0", 22 | "axios": "^1.3.4", 23 | "chalk": "^4.0.0", 24 | "cli-ux": "^6.0.9", 25 | "common-tags": "^1.8.2", 26 | "debug": "^4.1.1", 27 | "deepmerge": "^4.3.0", 28 | "get-port": "^5.0.0", 29 | "inquirer": "^7.1.0", 30 | "jest": "^29.7.0", 31 | "jest-json-schema": "^6.1.0", 32 | "js-yaml": "^4.1.0", 33 | "klona": "^2.0.6", 34 | "koa": "^2.14.1", 35 | "koa-bodyparser": "^4.3.0", 36 | "koa-logger": "^3.2.1", 37 | "koa-mount": "^4.0.0", 38 | "koa-proxy": "^1.0.0-alpha.3", 39 | "koa-router": "^12.0.0", 40 | "koa-static": "^5.0.0", 41 | "openapi-backend": "^5.10.6", 42 | "openapi-client-axios": "^7.5.5", 43 | "swagger-editor-dist": "^4.11.2", 44 | "swagger-ui-dist": "^5.9.0", 45 | "swagger2openapi": "^7.0.8", 46 | "tslib": "^2.5.0", 47 | "yargs": "^17.7.2" 48 | }, 49 | "devDependencies": { 50 | "@oclif/dev-cli": "^1.26.10", 51 | "@oclif/prettier-config": "^0.2.1", 52 | "@oclif/test": "^3", 53 | "@types/common-tags": "^1.8.2", 54 | "@types/debug": "^4.1.7", 55 | "@types/jest": "^29.5.1", 56 | "@types/jest-json-schema": "^6.1.2", 57 | "@types/js-yaml": "^4.0.7", 58 | "@types/koa": "^2.13.5", 59 | "@types/koa-bodyparser": "^4.3.10", 60 | "@types/koa-logger": "^3.1.2", 61 | "@types/node": "^18.14.1", 62 | "@types/swagger-ui-dist": "^3.30.1", 63 | "@typescript-eslint/eslint-plugin": "^6.7.5", 64 | "chai": "^4.2.0", 65 | "eslint": "^8.51.0", 66 | "globby": "^11.0.0", 67 | "nock": "^13.3.0", 68 | "oclif": "^4.0.2", 69 | "openapi-types": "^12.1.0", 70 | "prettier": "^2.0.4", 71 | "rimraf": "^3.0.2", 72 | "ts-jest": "^29.0.5", 73 | "ts-node": "^10.9.1", 74 | "typescript": "^4.9.5", 75 | "wait-on": "^7.2.0" 76 | }, 77 | "engines": { 78 | "node": ">=16.0.0" 79 | }, 80 | "files": [ 81 | "/bin", 82 | "/lib", 83 | "/oclif.manifest.json" 84 | ], 85 | "homepage": "https://openapistack.co", 86 | "keywords": [ 87 | "oclif" 88 | ], 89 | "license": "MIT", 90 | "main": "lib/index.js", 91 | "oclif": { 92 | "commands": "./lib/commands", 93 | "bin": "openapi", 94 | "topicSeparator": " ", 95 | "topics": { 96 | "test": { 97 | "description": "Run automated tests against APIs" 98 | } 99 | }, 100 | "plugins": [ 101 | "@oclif/plugin-help" 102 | ] 103 | }, 104 | "repository": "openapistack/openapicmd", 105 | "scripts": { 106 | "postpack": "rm -f oclif.manifest.json", 107 | "prepack": "rm -rf lib && npm run build && oclif manifest && oclif readme", 108 | "readme": "npm run build && oclif readme", 109 | "watch-build": "tsc -w", 110 | "build": "tsc -b", 111 | "test": "jest -i", 112 | "lint": "eslint . --ext .ts", 113 | "version": "oclif readme && git add README.md" 114 | }, 115 | "types": "lib/index.d.ts" 116 | } 117 | -------------------------------------------------------------------------------- /src/__tests__/test-fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Definition, Operation, Parameter, RequestBody } from '../types/types'; 2 | 3 | 4 | 5 | export const createDefinition = (overrides?: Partial): Definition => { 6 | return { 7 | openapi: '3.0.0', 8 | servers: [], 9 | paths: {}, 10 | ...overrides, 11 | info: { 12 | title: 'My API', 13 | version: '0.0.1', 14 | ...overrides?.info 15 | }, 16 | } 17 | } 18 | 19 | 20 | export const createOperation = (overrides?: Partial): Operation => { 21 | return { 22 | operationId: 'operationId', 23 | responses: {}, 24 | ...overrides, 25 | } 26 | } 27 | 28 | export const createParameter = (overrides?: Partial): Parameter => { 29 | return { 30 | name: 'name', 31 | in: 'query', 32 | ...overrides, 33 | } 34 | } 35 | 36 | 37 | export const createRequestBody = (overrides?: Partial): RequestBody => { 38 | return { 39 | content: {}, 40 | ...overrides, 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/__tests__/test-utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as YAML from 'js-yaml'; 4 | 5 | export const resourcePath = (...subpath: string[]) => { 6 | return path.join(__dirname, '..', '..', '__tests__', 'resources', ...subpath); 7 | }; 8 | 9 | export const testDefinition = YAML.load(fs.readFileSync(resourcePath('openapi.yml')).toString()); 10 | export const testDefinitionBroken = YAML.load(fs.readFileSync(resourcePath('openapi-broken.yml')).toString()); 11 | export const testDefinitionWithoutInternal = YAML.load(fs.readFileSync(resourcePath('openapi-without-internal.yml')).toString()); 12 | export const testDefinitionWithoutInternalAndUnreferenced = YAML.load(fs.readFileSync(resourcePath('openapi-without-internal-and-unreferenced-components.yml')).toString()); 13 | -------------------------------------------------------------------------------- /src/commands/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | 3 | import * as fs from 'fs'; 4 | import 'chai'; 5 | import { CONFIG_FILENAME } from '../common/config'; 6 | import { resourcePath } from '../__tests__/test-utils'; 7 | 8 | describe('auth', () => { 9 | beforeEach(() => { 10 | fs.unlink(CONFIG_FILENAME, (_err) => null); 11 | }); 12 | 13 | afterEach(() => { 14 | fs.unlink(CONFIG_FILENAME, (_err) => null); 15 | }); 16 | 17 | test 18 | .stdout() 19 | .command(['auth', '--security', 'BearerAuth', '--token', 'asd123', resourcePath('openapi.yml')]) 20 | .it(`writes security config to the ${CONFIG_FILENAME} file`, (_ctx) => { 21 | const config = fs.readFileSync(CONFIG_FILENAME, 'utf8'); 22 | expect(config).to.match(/security/); 23 | expect(config).to.match(/BearerAuth/); 24 | expect(config).to.match(/asd123/); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/commands/auth.ts: -------------------------------------------------------------------------------- 1 | import { Command, Args } from '@oclif/core'; 2 | import * as commonFlags from '../common/flags'; 3 | import { Document } from '@apidevtools/swagger-parser'; 4 | import * as path from 'path'; 5 | import * as fs from 'fs'; 6 | import * as YAML from 'js-yaml'; 7 | import { parseDefinition, resolveDefinition } from '../common/definition'; 8 | import { CONFIG_FILENAME, Config, resolveConfigFile } from '../common/config'; 9 | import { createSecurityRequestConfigForScheme, getActiveSecuritySchemes } from '../common/security'; 10 | import { OpenAPIV3 } from 'openapi-client-axios'; 11 | 12 | export class Auth extends Command { 13 | public static description = 'Authenticate with apis (writes to .openapiconfig)'; 14 | 15 | public static examples = [ 16 | `$ openapi auth`, 17 | '$ openapi auth --token eyJh...', 18 | '$ openapi auth --security ApiKeyAuth --apikey secret123', 19 | '$ openapi auth --security BasicAuth --username admin --password password', 20 | ]; 21 | 22 | public static flags = { 23 | ...commonFlags.help(), 24 | ...commonFlags.validate(), 25 | ...commonFlags.parseOpts(), 26 | ...commonFlags.securityOpts(), 27 | ...commonFlags.inject(), 28 | }; 29 | 30 | public static args = { 31 | definition: Args.string({ 32 | description: 'input definition file' 33 | }) 34 | } 35 | 36 | public async run() { 37 | const { args, flags } = await this.parse(Auth); 38 | const { dereference, validate, bundle, header, inject, token, apikey, username, password } = flags; 39 | const definition = resolveDefinition(args.definition); 40 | if (!definition) { 41 | this.error('Please load a definition file', { exit: 1 }); 42 | } 43 | 44 | let document: Document; 45 | try { 46 | document = await parseDefinition({ 47 | definition, 48 | dereference, 49 | bundle, 50 | validate, 51 | inject, 52 | strip: flags.strip, 53 | servers: flags.server, 54 | header, 55 | }); 56 | } catch (err) { 57 | this.error(err, { exit: 1 }); 58 | } 59 | 60 | // get config file 61 | const configFile = resolveConfigFile(); 62 | const writeTo = path.resolve(configFile || `./${CONFIG_FILENAME}`); 63 | 64 | // write to config file 65 | const oldConfig: Config = configFile ? YAML.load(fs.readFileSync(configFile).toString()) : {}; 66 | const newConfig = { 67 | ...oldConfig, 68 | definition, 69 | security: { ...oldConfig.security }, 70 | }; 71 | 72 | // choose security schemes 73 | const securityScheme = await getActiveSecuritySchemes({ 74 | document, 75 | security: flags.security, 76 | header, 77 | token, 78 | apikey, 79 | username, 80 | password, 81 | }); 82 | 83 | for (const schemeName of securityScheme) { 84 | const schemeDefinition = document.components.securitySchemes[schemeName] as OpenAPIV3.SecuritySchemeObject; 85 | if (schemeDefinition) { 86 | newConfig.security[schemeName] = await createSecurityRequestConfigForScheme({ 87 | schemeName, 88 | schemeDefinition, 89 | token, 90 | apikey, 91 | username, 92 | password, 93 | }); 94 | } 95 | } 96 | 97 | // write as YAML 98 | fs.writeFileSync(writeTo, YAML.dump(newConfig)); 99 | this.log(`Wrote auth config to ${writeTo}. You can now use openapi call with the following auth configs:`); 100 | this.log( 101 | `${Object.keys(newConfig.security) 102 | .map((key) => `- ${key}`) 103 | .join('\n')}`, 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/commands/call.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import { testDefinition } from '../__tests__/test-utils'; 3 | import 'chai'; 4 | 5 | describe('call', () => { 6 | let endpointCalled: boolean; 7 | const setEndpointCalled = (val: boolean) => (endpointCalled = Boolean(val)); 8 | 9 | // silence console.warn during tests 10 | const consoleWarn = console.warn; 11 | beforeEach(() => { 12 | console.warn = () => null; 13 | }); 14 | afterEach(() => { 15 | console.warn = consoleWarn; 16 | }); 17 | 18 | test 19 | .do(() => setEndpointCalled(false)) 20 | .nock('https://myapi.com', (api) => 21 | api 22 | .get('/openapi.json') 23 | .reply(200, testDefinition) 24 | .get('/pets') 25 | .reply(200, () => { 26 | setEndpointCalled(true); 27 | return {}; 28 | }), 29 | ) 30 | .stdout() 31 | .command(['call', 'https://myapi.com/openapi.json', '-o', 'getPets', '--apikey', 'secret']) 32 | .it('calls GET /pets with -o getPets', (_ctx) => { 33 | expect(endpointCalled).to.be.true; 34 | }); 35 | 36 | test 37 | .do(() => setEndpointCalled(false)) 38 | .nock('https://myapi.com', (api) => 39 | api 40 | .get('/openapi.json') 41 | .reply(200, testDefinition) 42 | .get('/pets/1') 43 | .reply(200, () => { 44 | setEndpointCalled(true); 45 | return {}; 46 | }), 47 | ) 48 | .stdout() 49 | .command(['call', 'https://myapi.com/openapi.json', '-o', 'getPetById', '-p', 'id=1', '--apikey', 'secret']) 50 | .it('calls GET /pets/1 with -o getPetById -p id=1', (_ctx) => { 51 | expect(endpointCalled).to.be.true; 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/commands/call.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import { mock } from 'mock-json-schema'; 3 | import * as chalk from 'chalk'; 4 | import * as _ from 'lodash'; 5 | import OpenAPIClientAxios, { OpenAPIV3, AxiosRequestConfig, AxiosResponse } from 'openapi-client-axios'; 6 | import { parseDefinition, resolveDefinition } from '../common/definition'; 7 | import * as commonFlags from '../common/flags'; 8 | import { Document } from '@apidevtools/swagger-parser'; 9 | import d from 'debug'; 10 | import { isValidJson, parseHeaderFlag } from '../common/utils'; 11 | import { createSecurityRequestConfig } from '../common/security'; 12 | import { setContext } from '../common/context'; 13 | import { maybePrompt, maybeSimplePrompt } from '../common/prompt'; 14 | const debug = d('cmd'); 15 | 16 | export class Call extends Command { 17 | public static description = 'Call API endpoints'; 18 | 19 | public static examples = [ 20 | `$ openapi call -o getPets`, 21 | `$ openapi call -o getPet -p id=1`, 22 | `$ openapi call -o createPet -d '{ "name": "Garfield" }'`, 23 | ]; 24 | 25 | public static flags = { 26 | ...commonFlags.help(), 27 | ...commonFlags.parseOpts(), 28 | ...commonFlags.interactive(), 29 | ...commonFlags.apiRoot(), 30 | operation: Flags.string({ char: 'o', description: 'operationId', helpValue: 'operationId' }), 31 | param: Flags.string({ char: 'p', description: 'parameter', helpValue: 'key=value', multiple: true }), 32 | data: Flags.string({ char: 'd', description: 'request body' }), 33 | include: Flags.boolean({ 34 | char: 'i', 35 | description: 'include status code and response headers the output', 36 | default: false, 37 | }), 38 | verbose: Flags.boolean({ 39 | char: 'v', 40 | description: 'verbose mode', 41 | default: false, 42 | }), 43 | ...commonFlags.securityOpts(), 44 | }; 45 | 46 | public static args = { 47 | definition: Args.string({ 48 | description: 'input definition file' 49 | }) 50 | } 51 | 52 | public async run() { 53 | const { args, flags } = await this.parse(Call); 54 | const { dereference, validate, bundle, header } = flags; 55 | 56 | // store flags in context 57 | setContext((ctx) => ({ ...ctx, flags })) 58 | 59 | const definition = resolveDefinition(args.definition); 60 | if (!definition) { 61 | this.error('Please load a definition file', { exit: 1 }); 62 | } 63 | 64 | let document: Document; 65 | try { 66 | document = await parseDefinition({ 67 | definition, 68 | dereference, 69 | bundle, 70 | validate, 71 | servers: flags.server, 72 | inject: flags.inject, 73 | strip: flags.strip, 74 | excludeExt: flags?.['exclude-ext'], 75 | removeUnreferenced: flags?.['remove-unreferenced'], 76 | header, 77 | induceServers: true, 78 | }); 79 | 80 | } catch (err) { 81 | this.error(err, { exit: 1 }); 82 | } 83 | 84 | // make sure we have a server in the document 85 | if (!document.servers?.some((s) => s.url)) { 86 | const res = await maybePrompt({ 87 | name: 'server', 88 | message: 'please enter a server URL', 89 | type: 'input', 90 | default: 'http://localhost:9000', 91 | // must be a valid URL 92 | validate: (value) => { 93 | try { 94 | new URL(value); 95 | return true; 96 | } catch (err) { 97 | return 'must be a valid URL'; 98 | } 99 | } 100 | }); 101 | 102 | if (res.server) { 103 | document.servers = [{ url: res.server }]; 104 | } else { 105 | this.error('no server URL provided, use --server or modify your API spec', { exit: 1 }); 106 | } 107 | } 108 | 109 | // store document in context 110 | setContext((ctx) => ({ ...ctx, document })) 111 | 112 | const api = new OpenAPIClientAxios({ definition: document }); 113 | const client = await api.init(); 114 | 115 | // don't throw on error statuses 116 | client.defaults.validateStatus = () => true; 117 | 118 | // select operation 119 | let operationId = flags.operation; 120 | if (!operationId) { 121 | const res = await maybePrompt([ 122 | { 123 | name: 'operation', 124 | message: 'select operation', 125 | type: 'list', 126 | choices: api.getOperations().map((op) => { 127 | const { operationId: id, summary, description, method, path } = op; 128 | let name = `${method.toUpperCase()} ${path}`; 129 | if (summary) { 130 | name = `${name} - ${summary}`; 131 | } else if (description) { 132 | name = `${name} - ${description}`; 133 | } 134 | if (id) { 135 | name = `${name} (${id})`; 136 | } 137 | return { name, value: id }; 138 | }), 139 | }, 140 | ]); 141 | operationId = res.operation; 142 | } 143 | if (!operationId) { 144 | this.error(`no operationId passed, please specify --operation`, { exit: 1 }); 145 | } 146 | const operation = api.getOperation(operationId); 147 | if (!operation) { 148 | this.error(`operationId ${operationId} not found`, { exit: 1 }); 149 | } 150 | 151 | // fill params 152 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 153 | const params: { [key: string]: any } = {}; 154 | for (const param of flags.param || []) { 155 | const [key, value] = param.split('='); 156 | params[key.trim()] = value; 157 | } 158 | 159 | for (const p of operation.parameters || []) { 160 | const param = p as OpenAPIV3.ParameterObject; 161 | const { name, required, example, schema } = param; 162 | 163 | if (!params[name] && required) { 164 | const mockedValue = schema ? mock(schema as OpenAPIV3.SchemaObject) : undefined; 165 | 166 | const value = await maybeSimplePrompt(name, { required, default: example ?? mockedValue }); 167 | params[name] = value; 168 | } 169 | } 170 | 171 | // handle request body 172 | let data = flags.data; 173 | if ( 174 | !data && 175 | operation.requestBody && 176 | 'content' in operation.requestBody && 177 | (await maybePrompt({ type: 'confirm', default: true, name: 'yes', message: 'add request body?' })).yes 178 | ) { 179 | const contentType = Object.keys(operation.requestBody.content)[0]; 180 | 181 | let defaultValue = operation.requestBody.content?.[contentType]?.example; 182 | if (!defaultValue && operation.requestBody.content?.[contentType]?.schema) { 183 | defaultValue = JSON.stringify( 184 | mock(operation.requestBody.content?.[contentType]?.schema as OpenAPIV3.SchemaObject), 185 | null, 186 | 2, 187 | ); 188 | } 189 | if (!defaultValue && contentType === 'application/json') { 190 | defaultValue = '{}'; 191 | } 192 | 193 | data = ( 194 | await maybePrompt({ 195 | type: 'editor', 196 | message: contentType || '', 197 | name: 'requestBody', 198 | default: defaultValue, 199 | validate: (value) => { 200 | if (contentType === 'application/json' && !isValidJson(value)) { 201 | return 'invalid json'; 202 | } 203 | return true; 204 | }, 205 | }) 206 | ).requestBody; 207 | } 208 | 209 | const securityRequestConfig = await createSecurityRequestConfig({ 210 | document, 211 | operation, 212 | security: flags.security, 213 | header: flags.header, 214 | apikey: flags.apikey, 215 | token: flags.token, 216 | username: flags.username, 217 | password: flags.password, 218 | }); 219 | debug('securityRequestConfig %o', securityRequestConfig); 220 | 221 | // add cookies 222 | const cookies = { 223 | ...securityRequestConfig.cookie, 224 | }; 225 | const cookieHeader = Object.keys(cookies) 226 | .map((key) => `${key}=${cookies[key]}`) 227 | .join('; '); 228 | 229 | // add request headers 230 | const config: AxiosRequestConfig = { 231 | headers: { 232 | ...securityRequestConfig.header, 233 | ...parseHeaderFlag(header), 234 | ...(Boolean(cookieHeader) && { cookie: cookieHeader }), 235 | }, 236 | params: { 237 | ...securityRequestConfig.query, 238 | }, 239 | auth: securityRequestConfig.auth, 240 | }; 241 | 242 | // set content type 243 | if (!config.headers['Content-Type'] && !config.headers['content-type']) { 244 | const operationRequestContentType = Object.keys(operation.requestBody?.['content'] ?? {})[0]; 245 | const defaultContentType = isValidJson(data) ? 'application/json' : 'text/plain'; 246 | config.headers['Content-Type'] = operationRequestContentType ?? defaultContentType; 247 | } 248 | 249 | let res: AxiosResponse; 250 | try { 251 | debug('params %o', params); 252 | debug('data %o', data); 253 | debug('config %o', config); 254 | 255 | const requestConfig = api.getRequestConfigForOperation(operation, [params, data, config]); 256 | const request = api.getAxiosConfigForOperation(operation, [params, data, config]); 257 | 258 | debug('requestConfig %o', requestConfig); 259 | debug('axiosConfig %o', request); 260 | 261 | if (flags.verbose) { 262 | this.log(chalk.gray('REQUEST META:')); 263 | this.logJson({ operationId, ...requestConfig }); 264 | } else { 265 | console.warn(`${chalk.green(request.method.toUpperCase())} ${requestConfig.url}`); 266 | } 267 | 268 | // call operation 269 | res = await client[operationId](params, data, config); 270 | } catch (err) { 271 | if (err.response) { 272 | res = err.response; 273 | } else { 274 | this.error(err.message, { exit: false }); 275 | } 276 | } 277 | 278 | // output response fields 279 | if (flags.include && res?.status) { 280 | this.log(chalk.gray('RESPONSE META:')); 281 | this.logJson({ 282 | code: res.status, 283 | status: res.statusText, 284 | headers: res.headers, 285 | }); 286 | } else if (res?.status) { 287 | if (res.status >= 400) { 288 | console.warn(`${chalk.bgRed(res.status)} – ${res.statusText}`); 289 | } else { 290 | console.warn(`${chalk.bgGreen(res.status)} – ${res.statusText}`); 291 | } 292 | } 293 | 294 | // output response body 295 | if (!_.isNil(res?.data)) { 296 | try { 297 | if (flags.verbose || flags.include) this.log(chalk.gray('RESPONSE BODY:')); 298 | 299 | this.logJson(res.data); 300 | } catch (e) { 301 | this.log(res.data); 302 | } 303 | } else { 304 | console.warn(chalk.gray('(empty response)')); 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/commands/info.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import { resourcePath } from '../__tests__/test-utils'; 3 | import 'chai'; 4 | 5 | describe('info', () => { 6 | test 7 | .stdout() 8 | .command(['info', resourcePath('openapi.yml')]) 9 | .it('prints information about a definition file', (ctx) => { 10 | expect(ctx.stdout).to.contain('title'); 11 | expect(ctx.stdout).to.contain('version'); 12 | expect(ctx.stdout).to.contain('securitySchemes'); 13 | expect(ctx.stdout).to.contain('servers'); 14 | }); 15 | 16 | test 17 | .stdout() 18 | .command(['info', resourcePath('openapi.yml'), '--operations']) 19 | .it('lists api operations', (ctx) => { 20 | expect(ctx.stdout).to.contain('operations'); 21 | }); 22 | 23 | test 24 | .stdout() 25 | .command(['info', resourcePath('openapi.yml'), '--schemas']) 26 | .it('lists api schemas', (ctx) => { 27 | expect(ctx.stdout).to.contain('schemas'); 28 | }); 29 | 30 | test 31 | .stdout() 32 | .command(['info', resourcePath('openapi.yml'), '--security']) 33 | .it('lists security schemes', (ctx) => { 34 | expect(ctx.stdout).to.contain('securitySchemes'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import * as SwaggerParser from '@apidevtools/swagger-parser'; 3 | import { parseDefinition, resolveDefinition, printInfo, getOperations } from '../common/definition'; 4 | import * as commonFlags from '../common/flags'; 5 | import { Document } from '@apidevtools/swagger-parser'; 6 | 7 | export class Info extends Command { 8 | public static description = 'Display API information'; 9 | 10 | public static examples = [ 11 | '$ openapi info https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml', 12 | `$ openapi info ./openapi.yml`, 13 | ]; 14 | 15 | public static flags = { 16 | ...commonFlags.help(), 17 | ...commonFlags.parseOpts(), 18 | security: Flags.boolean({ description: 'list security schemes in document', default: false }), 19 | operations: Flags.boolean({ description: 'list operations in document', default: false }), 20 | schemas: Flags.boolean({ description: 'list schemas in document', default: false }), 21 | }; 22 | 23 | public static args = { 24 | definition: Args.string({ 25 | description: 'input definition file' 26 | }) 27 | } 28 | 29 | public async run() { 30 | const { args, flags } = await this.parse(Info); 31 | const { dereference, bundle, validate, header } = flags; 32 | 33 | const definition = resolveDefinition(args.definition); 34 | if (!definition) { 35 | this.error('Please load a definition file', { exit: 1 }); 36 | } 37 | 38 | let document: Document; 39 | try { 40 | document = await parseDefinition({ 41 | definition, 42 | dereference, 43 | bundle, 44 | validate, 45 | strip: flags.strip, 46 | servers: flags.server, 47 | inject: flags.inject, 48 | excludeExt: flags?.['exclude-ext'], 49 | removeUnreferenced: flags?.['remove-unreferenced'], 50 | header, 51 | }); 52 | } catch (err) { 53 | this.error(err, { exit: 1 }); 54 | } 55 | 56 | this.log(`Loaded: ${definition}`); 57 | this.log(); 58 | printInfo(document, this); 59 | 60 | this.printServers(document); 61 | 62 | if (flags.operations) { 63 | this.log(); 64 | this.printOperations(document); 65 | } else { 66 | this.log(); 67 | this.log(`operations: ${getOperations(document).length}`); 68 | this.log(`tags: ${document.tags ? document.tags.length : 0}`); 69 | } 70 | if (flags.schemas) { 71 | this.log(); 72 | this.printSchemas(document); 73 | } else { 74 | this.log(`schemas: ${document.components?.schemas ? Object.entries(document.components.schemas).length : 0}`); 75 | } 76 | if (flags.security) { 77 | this.log(); 78 | this.printSecuritySchemes(document); 79 | } else { 80 | this.log( 81 | `securitySchemes: ${ 82 | document.components?.securitySchemes ? Object.entries(document.components.securitySchemes).length : 0 83 | }`, 84 | ); 85 | } 86 | } 87 | 88 | private printOperations(document: SwaggerParser.Document) { 89 | const operations: { [tag: string]: { routes: string[]; description?: string } } = {}; 90 | 91 | if (document.tags) { 92 | for (const tag of document.tags) { 93 | const { name, description } = tag; 94 | operations[name] = { 95 | description, 96 | routes: [], 97 | }; 98 | } 99 | } 100 | 101 | for (const path in document.paths) { 102 | if (document.paths[path]) { 103 | for (const method in document.paths[path]) { 104 | if (document.paths[path][method]) { 105 | const { operationId, summary, description, tags } = document.paths[path][method]; 106 | let route = `${method.toUpperCase()} ${path}`; 107 | if (summary) { 108 | route = `${route} - ${summary}`; 109 | } else if (description) { 110 | route = `${route} - ${description}`; 111 | } 112 | if (operationId) { 113 | route = `${route} (${operationId})`; 114 | } 115 | for (const tag of tags || ['default']) { 116 | if (!operations[tag]) { 117 | operations[tag] = { routes: [] }; 118 | } 119 | operations[tag].routes.push(route); 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | this.log(`operations (${getOperations(document).length}):`); 127 | for (const tag in operations) { 128 | if (operations[tag]) { 129 | const routes = operations[tag].routes; 130 | for (const route of routes) { 131 | this.log(`- ${route}`); 132 | } 133 | } 134 | } 135 | } 136 | 137 | private printSchemas(document: SwaggerParser.Document) { 138 | const schemas = (document.components && document.components.schemas) || {}; 139 | const count = Object.entries(schemas).length; 140 | if (count > 0) { 141 | this.log(`schemas (${count}):`); 142 | for (const schema in schemas) { 143 | if (schemas[schema]) { 144 | this.log(`- ${schema}`); 145 | } 146 | } 147 | } 148 | } 149 | 150 | private printServers(document: SwaggerParser.Document) { 151 | const servers = document.servers ?? []; 152 | if (servers.length > 0) { 153 | this.log(`servers:`); 154 | for (const server of servers) { 155 | this.log(`- ${server.url}${server.description ? ` (${server.description})` : ''}`); 156 | } 157 | } else { 158 | this.log('servers: 0'); 159 | } 160 | } 161 | 162 | private printSecuritySchemes(document: SwaggerParser.Document) { 163 | const securitySchemes = document.components?.securitySchemes || {}; 164 | const count = Object.entries(securitySchemes).length; 165 | if (count > 0) { 166 | this.log(`securitySchemes (${count}):`); 167 | for (const scheme in securitySchemes) { 168 | if (securitySchemes[scheme]) { 169 | this.log( 170 | `- ${scheme}: (${[ 171 | securitySchemes[scheme]['type'], 172 | securitySchemes[scheme]['scheme'], 173 | securitySchemes[scheme]['name'], 174 | ] 175 | .filter(Boolean) 176 | .join(', ')}) ${securitySchemes[scheme]['description']}`, 177 | ); 178 | } 179 | } 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/commands/init.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import 'chai'; 3 | 4 | describe('init', () => { 5 | test 6 | .stdout() 7 | .command(['init']) 8 | .it('outputs an openapi file', (ctx) => { 9 | expect(ctx.stdout).to.contain('openapi: 3'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | import { OutputFormat, stringifyDocument } from '../common/definition'; 3 | import * as commonFlags from '../common/flags'; 4 | import { Document } from '@apidevtools/swagger-parser'; 5 | import { OpenAPIV3 } from 'openapi-types'; 6 | import * as deepMerge from 'deepmerge' 7 | 8 | export class Init extends Command { 9 | public static description = 'Initialise a definition file from scratch'; 10 | 11 | public static examples = [`$ openapi init --title 'My API' > openapi.yml`]; 12 | 13 | public static flags = { 14 | ...commonFlags.help(), 15 | title: Flags.string({ char: 'T', description: 'The title for the API', default: 'My API' }), 16 | description: Flags.string({ char: 'd', description: 'Description for the API' }), 17 | version: Flags.string({ char: 'v', description: 'Version of the API', default: '0.0.1' }), 18 | terms: Flags.string({ description: 'A URL to the Terms of Service for the API.' }), 19 | license: Flags.string({ description: 'The license for the API', options: ['mit', 'apache2'] }), 20 | ...commonFlags.servers(), 21 | ...commonFlags.inject(), 22 | ...commonFlags.outputFormat(), 23 | }; 24 | 25 | public async run() { 26 | const { flags } = await this.parse(Init); 27 | const { title, version, server, inject, license, description, terms } = flags; 28 | const OPENAPI_VERSION = '3.0.0'; 29 | 30 | const info: OpenAPIV3.InfoObject = { 31 | title, 32 | version, 33 | }; 34 | if (description) { 35 | info.description = description; 36 | } 37 | if (terms) { 38 | info.termsOfService = terms; 39 | } 40 | if (license) { 41 | switch (license) { 42 | case 'apache2': 43 | info.license = { 44 | name: 'Apache 2.0', 45 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html', 46 | }; 47 | break; 48 | case 'mit': 49 | info.license = { 50 | name: 'MIT', 51 | url: 'https://opensource.org/licenses/MIT', 52 | }; 53 | break; 54 | } 55 | } 56 | 57 | let document: Document = { 58 | openapi: OPENAPI_VERSION, 59 | info, 60 | paths: {}, 61 | }; 62 | 63 | // merge injected JSON 64 | if (inject) { 65 | for (const json of inject) { 66 | try { 67 | const parsed = JSON.parse(json); 68 | document = deepMerge(document, parsed); 69 | } catch (err) { 70 | console.error('Could not parse inject JSON'); 71 | throw err; 72 | } 73 | } 74 | } 75 | 76 | if (server) { 77 | const { paths, ...d } = document; 78 | document = { 79 | ...d, 80 | servers: server.map((url) => ({ url })), 81 | paths, 82 | }; 83 | } 84 | 85 | const format = flags.format === 'json' || flags.json ? OutputFormat.JSON : OutputFormat.YAML; 86 | this.log(stringifyDocument({ document, format })); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/commands/load.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | 3 | import * as fs from 'fs'; 4 | import * as YAML from 'js-yaml'; 5 | import 'chai'; 6 | import { CONFIG_FILENAME, Config } from '../common/config'; 7 | import { resourcePath, testDefinition } from '../__tests__/test-utils'; 8 | 9 | describe('load', () => { 10 | beforeEach(() => { 11 | fs.unlink(CONFIG_FILENAME, (_err) => null); 12 | }); 13 | 14 | afterEach(() => { 15 | fs.unlink(CONFIG_FILENAME, (_err) => null); 16 | }); 17 | 18 | test 19 | .stdout() 20 | .command(['load', resourcePath('openapi.yml')]) 21 | .it('loads local definition definition file', (ctx) => { 22 | expect(ctx.stdout).to.contain('Loaded succesfully!'); 23 | }); 24 | 25 | test 26 | .nock('https://myapi.com', (api) => api.get('/openapi.json').reply(200, testDefinition)) 27 | .stdout() 28 | .command(['load', 'https://myapi.com/openapi.json']) 29 | .it('loads remote definition file', (ctx) => { 30 | expect(ctx.stdout).to.contain('Loaded succesfully!'); 31 | }); 32 | 33 | test 34 | .stdout() 35 | .command(['load', resourcePath('openapi.yml')]) 36 | .it(`creates a ${CONFIG_FILENAME} file`, (_ctx) => { 37 | expect(fs.existsSync(CONFIG_FILENAME)).to.equal(true); 38 | }); 39 | 40 | test 41 | .stdout() 42 | .command(['load', resourcePath('openapi.yml')]) 43 | .it(`writes the definition path to the ${CONFIG_FILENAME} file`, (_ctx) => { 44 | const config: Config = YAML.load(fs.readFileSync(CONFIG_FILENAME).toString()); 45 | expect(config.definition).to.match(new RegExp('openapi.yml')); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/commands/load.ts: -------------------------------------------------------------------------------- 1 | import { Command, Args } from '@oclif/core'; 2 | import * as commonFlags from '../common/flags'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import * as YAML from 'js-yaml'; 6 | import { parseDefinition } from '../common/definition'; 7 | import { CONFIG_FILENAME, Config, resolveConfigFile } from '../common/config'; 8 | 9 | export class Load extends Command { 10 | public static description = 'Set the default definition file for a workspace (writes to .openapiconfig)'; 11 | 12 | public static examples = [ 13 | `$ openapi load ./openapi.yml`, 14 | '$ openapi load https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml', 15 | ]; 16 | 17 | public static flags = { 18 | ...commonFlags.help(), 19 | ...commonFlags.validate(), 20 | ...commonFlags.servers(), 21 | }; 22 | 23 | public static args = { 24 | definition: Args.string({ 25 | description: 'input definition file', 26 | required: true 27 | }) 28 | } 29 | 30 | 31 | public async run() { 32 | const { args, flags } = await this.parse(Load); 33 | const definition = args.definition; 34 | 35 | // check that definition can be parsed 36 | try { 37 | await parseDefinition({ definition, validate: flags.validate }); 38 | } catch (err) { 39 | this.error(err, { exit: 1 }); 40 | } 41 | 42 | const configFile = resolveConfigFile(); 43 | 44 | // write to config file 45 | const oldConfig: Config = configFile ? YAML.load(fs.readFileSync(configFile).toString()) : {}; 46 | const newConfig = { 47 | ...oldConfig, 48 | definition, 49 | }; 50 | 51 | // default to current directory 52 | const writeTo = path.resolve(configFile || `./${CONFIG_FILENAME}`); 53 | 54 | // write as YAML 55 | fs.writeFileSync(writeTo, YAML.dump(newConfig)); 56 | this.log(`Wrote to ${writeTo}`); 57 | this.log(`Loaded succesfully!`); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/mock.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import * as waitOn from 'wait-on'; 3 | import { resourcePath } from '../__tests__/test-utils'; 4 | import 'chai'; 5 | 6 | const TEST_PORT = 5552; 7 | 8 | describe('mock', () => { 9 | test 10 | .stdout() 11 | .command(['mock', resourcePath('openapi.yml'), '-p', `${TEST_PORT}`]) 12 | .it('runs openapi-backend mock server', async (ctx) => { 13 | await waitOn({ resources: [`tcp:localhost:${TEST_PORT}`] }); 14 | expect(ctx.stdout).to.contain('running'); 15 | }); 16 | 17 | afterEach(() => { 18 | // emit disconnect to stop the server 19 | process.emit('disconnect'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/commands/mock.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import * as bodyparser from 'koa-bodyparser'; 3 | import * as cors from '@koa/cors'; 4 | import * as mount from 'koa-mount'; 5 | import OpenAPIBackend, { Document } from 'openapi-backend'; 6 | import * as commonFlags from '../common/flags'; 7 | import { startServer, createServer } from '../common/koa'; 8 | import { serveSwaggerUI } from '../common/swagger-ui'; 9 | import { resolveDefinition, parseDefinition } from '../common/definition'; 10 | 11 | export class Mock extends Command { 12 | public static description = 'Start a local mock API server'; 13 | 14 | public static examples = [ 15 | '$ openapi mock ./openapi.yml', 16 | '$ openapi mock https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml', 17 | ]; 18 | 19 | public static flags = { 20 | ...commonFlags.help(), 21 | ...commonFlags.serverOpts(), 22 | ...commonFlags.servers(), 23 | ...commonFlags.inject(), 24 | ...commonFlags.strip(), 25 | ...commonFlags.excludeExt(), 26 | ...commonFlags.header(), 27 | ...commonFlags.apiRoot(), 28 | 'swagger-ui': Flags.string({ char: 'U', description: 'Swagger UI endpoint', helpValue: 'docs' }), 29 | validate: Flags.boolean({ 30 | description: '[default: true] validate requests according to schema', 31 | default: true, 32 | allowNo: true, 33 | }), 34 | }; 35 | 36 | public static args = { 37 | definition: Args.string({ 38 | description: 'input definition file' 39 | }) 40 | } 41 | 42 | public async run() { 43 | const { args, flags } = await this.parse(Mock); 44 | const { port, logger, 'swagger-ui': swaggerui, validate, header, root } = flags; 45 | 46 | let portRunning = port; 47 | 48 | const definition = resolveDefinition(args.definition); 49 | if (!definition) { 50 | this.error('Please load a definition file', { exit: 1 }); 51 | } 52 | 53 | let document: Document; 54 | try { 55 | document = await parseDefinition({ 56 | definition, 57 | validate, 58 | servers: flags.server, 59 | inject: flags.inject, 60 | strip: flags.strip, 61 | excludeExt: flags?.['exclude-ext'], 62 | removeUnreferenced: flags?.['remove-unreferenced'], 63 | header, 64 | root, 65 | induceServers: true, 66 | }); 67 | } catch (err) { 68 | this.error(err, { exit: 1 }); 69 | } 70 | 71 | const api = new OpenAPIBackend({ 72 | definition: document, 73 | validate, 74 | apiRoot: root, 75 | }); 76 | 77 | api.register({ 78 | validationFail: (c, ctx) => { 79 | ctx.status = 400; 80 | ctx.body = { err: c.validation.errors }; 81 | }, 82 | notFound: (c, ctx) => { 83 | ctx.status = 404; 84 | ctx.body = { err: 'not found' }; 85 | }, 86 | methodNotAllowed: (c, ctx) => { 87 | ctx.status = 405; 88 | ctx.body = { err: 'method not allowed' }; 89 | }, 90 | notImplemented: (c, ctx) => { 91 | const { status, mock } = c.api.mockResponseForOperation(c.operation.operationId); 92 | ctx.status = status; 93 | ctx.body = mock; 94 | }, 95 | }); 96 | await api.init(); 97 | 98 | const app = createServer({ logger }); 99 | app.use(bodyparser()); 100 | app.use(cors({ credentials: true })); 101 | 102 | // serve openapi.json 103 | const openApiFile = 'openapi.json'; 104 | const documentPath = `/${openApiFile}`; 105 | app.use( 106 | mount(documentPath, async (ctx, next) => { 107 | await next(); 108 | ctx.body = api.document; 109 | ctx.status = 200; 110 | }), 111 | ); 112 | 113 | // serve swagger ui 114 | if (swaggerui) { 115 | app.use(mount(`/${swaggerui}`, serveSwaggerUI({ url: documentPath }))); 116 | } 117 | 118 | // serve openapi-backend 119 | app.use((ctx) => 120 | api.handleRequest( 121 | { 122 | method: ctx.request.method, 123 | path: ctx.request.path, 124 | body: ctx.request.body, 125 | query: ctx.request.query, 126 | headers: ctx.request.headers, 127 | }, 128 | ctx, 129 | ), 130 | ); 131 | 132 | // start server 133 | const server = await startServer({ app, port }); 134 | portRunning = server.port; 135 | 136 | if (!document.servers || !document.servers.length) { 137 | api.document.servers = [{ url: `http://localhost:${portRunning}` }]; 138 | } 139 | 140 | this.log(); 141 | this.log(`Mock server running at http://localhost:${portRunning}`); 142 | if (swaggerui) { 143 | this.log(`Swagger UI running at http://localhost:${portRunning}/${swaggerui}`); 144 | } 145 | this.log(`OpenAPI definition at http://localhost:${portRunning}${documentPath}`); 146 | this.log(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/commands/read.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import { resourcePath, testDefinition, testDefinitionWithoutInternal, testDefinitionWithoutInternalAndUnreferenced } from '../__tests__/test-utils'; 3 | import * as SwaggerParser from '@apidevtools/swagger-parser'; 4 | import * as YAML from 'js-yaml'; 5 | import 'chai'; 6 | 7 | describe('read', () => { 8 | describe('output', () => { 9 | test 10 | .stdout() 11 | .command(['read', resourcePath('openapi.yml')]) 12 | .it('reads yaml openapi spec', (ctx) => { 13 | const output = YAML.load(ctx.stdout); 14 | expect(output).to.deep.equal(testDefinition); 15 | }); 16 | 17 | test 18 | .stdout() 19 | .command(['read', resourcePath('openapi-with-internal.yml'), '--exclude-ext', 'x-internal']) 20 | .it('reads yaml openapi spec exluding operations and resources with x-internal', (ctx) => { 21 | const output = YAML.load(ctx.stdout); 22 | expect(output).to.deep.equal(testDefinitionWithoutInternal); 23 | }); 24 | 25 | 26 | test 27 | .stdout() 28 | .command(['read', resourcePath('openapi-with-internal.yml'), '--exclude-ext', 'x-internal', '--remove-unreferenced']) 29 | .it('reads yaml openapi spec exluding operations and resources with x-internal and also remove unreferenced components', (ctx) => { 30 | const output = YAML.load(ctx.stdout); 31 | expect(output).to.deep.equal(testDefinitionWithoutInternalAndUnreferenced); 32 | }); 33 | 34 | test 35 | .stdout() 36 | .command(['read', resourcePath('openapi.json')]) 37 | .it('reads json openapi spec', (ctx) => { 38 | const output = YAML.load(ctx.stdout); 39 | expect(output).to.deep.equal(testDefinition); 40 | }); 41 | 42 | test 43 | .nock('https://myapi.com', (api) => api.get('/openapi.json').reply(200, testDefinition)) 44 | .stdout() 45 | .command(['read', 'https://myapi.com/openapi.json']) 46 | .it('reads remote openapi spec', (ctx) => { 47 | const output = YAML.load(ctx.stdout); 48 | expect(output).to.deep.equal(testDefinition); 49 | }); 50 | 51 | test 52 | .stdout() 53 | .command(['read', resourcePath('openapi.json'), '--server', 'http://localhost:9999']) 54 | .it('can add a server', (ctx) => { 55 | const output = YAML.load(ctx.stdout) as SwaggerParser.Document; 56 | expect(output.servers[0].url).to.equal('http://localhost:9999'); 57 | }); 58 | 59 | test 60 | .stdout() 61 | .command(['read', resourcePath('openapi.json'), '-S', 'http://localhost:9998', '-S', 'http://localhost:9999']) 62 | .it('can add multiple servers', (ctx) => { 63 | const output = YAML.load(ctx.stdout) as SwaggerParser.Document; 64 | expect(output.servers[0].url).to.equal('http://localhost:9998'); 65 | expect(output.servers[1].url).to.equal('http://localhost:9999'); 66 | }); 67 | 68 | test 69 | .stdout() 70 | .command(['read', resourcePath('openapi.yml'), '--json']) 71 | .it('reads openapi spec and outputs json', (ctx) => { 72 | const output = JSON.parse(ctx.stdout); 73 | expect(output).to.deep.equal(testDefinition); 74 | }); 75 | 76 | test 77 | .stdout() 78 | .command(['read', resourcePath('openapi.json'), '--yaml']) 79 | .it('reads openapi spec and outputs yaml', (ctx) => { 80 | const output = YAML.load(ctx.stdout); 81 | expect(output).to.deep.equal(testDefinition); 82 | }); 83 | }); 84 | 85 | describe('--validate', () => { 86 | test 87 | .stdout() 88 | .command(['read', resourcePath('openapi.yml'), '--validate']) 89 | .it('validates correct openapi file', async (ctx) => { 90 | const output = YAML.load(ctx.stdout); 91 | const expected = await SwaggerParser.validate(resourcePath('openapi.yml')); 92 | expect(output).to.deep.equal(expected); 93 | }); 94 | 95 | test 96 | .command(['read', resourcePath('openapi-broken.yml'), '--validate']) 97 | .exit(1) 98 | .it('validates incorrect openapi file, exits with code 1'); 99 | }); 100 | 101 | describe('--dereference', () => { 102 | test 103 | .stdout() 104 | .command(['read', resourcePath('openapi.yml'), '--dereference']) 105 | .it('resolves $ref pointers from an openapi file', async (ctx) => { 106 | const output = YAML.load(ctx.stdout); 107 | const expected = await SwaggerParser.dereference(resourcePath('openapi.yml')); 108 | expect(output).to.deep.equal(expected); 109 | }); 110 | 111 | describe('--bundle', () => { 112 | test 113 | .stdout() 114 | .command(['read', resourcePath('openapi.yml'), '--bundle']) 115 | .it('resolves remote $ref pointers from an openapi file', async (ctx) => { 116 | const output = YAML.load(ctx.stdout); 117 | const expected = await SwaggerParser.bundle(resourcePath('openapi.yml')); 118 | expect(output).to.deep.equal(expected); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/commands/read.ts: -------------------------------------------------------------------------------- 1 | import { Command, Args } from '@oclif/core'; 2 | import { parseDefinition, OutputFormat, stringifyDocument, resolveDefinition } from '../common/definition'; 3 | import * as commonFlags from '../common/flags'; 4 | import { Document } from '@apidevtools/swagger-parser'; 5 | 6 | export class Read extends Command { 7 | public static description = 'Read and manipulate definition files'; 8 | 9 | public static examples = [ 10 | '$ openapi read https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore.yaml', 11 | `$ openapi read ./openapi.yml -f json > openapi.json`, 12 | ]; 13 | 14 | public static flags = { 15 | ...commonFlags.help(), 16 | ...commonFlags.parseOpts(), 17 | ...commonFlags.outputFormat(), 18 | }; 19 | 20 | public static args = { 21 | definition: Args.string({ 22 | description: 'input definition file' 23 | }) 24 | } 25 | 26 | public async run() { 27 | const { args, flags } = await this.parse(Read); 28 | const { dereference, validate, bundle, header, root } = flags; 29 | 30 | const definition = resolveDefinition(args.definition); 31 | if (!definition) { 32 | this.error('Please load a definition file', { exit: 1 }); 33 | } 34 | 35 | let document: Document; 36 | try { 37 | document = await parseDefinition({ 38 | definition, 39 | dereference, 40 | bundle, 41 | validate, 42 | inject: flags.inject, 43 | strip: flags.strip, 44 | excludeExt: flags?.['exclude-ext'], 45 | removeUnreferenced: flags?.['remove-unreferenced'], 46 | servers: flags.server, 47 | header, 48 | root, 49 | }); 50 | } catch (err) { 51 | this.error(err, { exit: 1 }); 52 | } 53 | const format = flags.format === 'json' || flags.json ? OutputFormat.JSON : OutputFormat.YAML; 54 | 55 | if (format === OutputFormat.JSON) { 56 | this.logJson(document) 57 | } else { 58 | this.log(stringifyDocument({ document, format })); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/commands/redoc.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as waitOn from 'wait-on'; 5 | import * as rimraf from 'rimraf'; 6 | import { resourcePath } from '../__tests__/test-utils'; 7 | import 'chai'; 8 | 9 | const TEST_PORT = 5552; 10 | 11 | describe('redoc', () => { 12 | describe('server', () => { 13 | afterEach(() => { 14 | // emit disconnect to stop the server 15 | process.emit('disconnect'); 16 | }); 17 | 18 | test 19 | .stdout() 20 | .command(['redoc', resourcePath('openapi.yml'), '-p', `${TEST_PORT}`]) 21 | .it('runs local redoc server', async (ctx) => { 22 | await waitOn({ resources: [`tcp:localhost:${TEST_PORT}`] }); 23 | expect(ctx.stdout).to.contain('running'); 24 | }); 25 | }); 26 | 27 | describe('--bundle', () => { 28 | const bundleDir = 'static'; 29 | afterEach(() => { 30 | rimraf.sync(bundleDir); 31 | }); 32 | test 33 | .stdout() 34 | .command(['redoc', resourcePath('openapi.yml'), '--bundle', bundleDir]) 35 | .it('bundles redoc', (_ctx) => { 36 | expect(fs.existsSync(path.join(bundleDir))).to.equal(true); 37 | expect(fs.existsSync(path.join(bundleDir, 'index.html'))).to.equal(true); 38 | expect(fs.existsSync(path.join(bundleDir, 'openapi.json'))).to.equal(true); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/commands/redoc.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import { URL } from 'url'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import * as proxy from 'koa-proxy'; 6 | import * as mount from 'koa-mount'; 7 | import * as commonFlags from '../common/flags'; 8 | import { parseDefinition, resolveDefinition } from '../common/definition'; 9 | import { startServer, createServer } from '../common/koa'; 10 | import { Document } from '@apidevtools/swagger-parser'; 11 | import { parseHeaderFlag } from '../common/utils'; 12 | import { RedocOpts, getRedocIndexHTML, serveRedoc } from '../common/redoc'; 13 | 14 | export class Redoc extends Command { 15 | public static description = 'Start or bundle a ReDoc instance'; 16 | 17 | public static examples = [ 18 | '$ openapi redoc', 19 | '$ openapi redoc ./openapi.yml', 20 | '$ openapi redoc ./openapi.yml --bundle outDir', 21 | ]; 22 | 23 | public static flags = { 24 | ...commonFlags.help(), 25 | ...commonFlags.serverOpts(), 26 | ...commonFlags.servers(), 27 | ...commonFlags.inject(), 28 | ...commonFlags.excludeExt(), 29 | ...commonFlags.strip(), 30 | ...commonFlags.header(), 31 | ...commonFlags.apiRoot(), 32 | bundle: Flags.string({ 33 | char: 'B', 34 | description: 'bundle a static site to directory', 35 | helpValue: 'outDir', 36 | }), 37 | }; 38 | 39 | public static args = { 40 | definition: Args.string({ 41 | description: 'input definition file' 42 | }) 43 | } 44 | 45 | public async run() { 46 | const { args, flags } = await this.parse(Redoc); 47 | const { port, logger, bundle, header, root } = flags; 48 | const definition = resolveDefinition(args.definition); 49 | 50 | const app = createServer({ logger }); 51 | 52 | let proxyPath: string; 53 | let documentPath: string; 54 | let document: Document; 55 | 56 | const openApiFile = 'openapi.json'; 57 | if (definition) { 58 | if (definition.match('://') && !flags.server && !flags.proxy) { 59 | // use remote definition 60 | documentPath = definition; 61 | } else { 62 | // parse definition 63 | document = await parseDefinition({ 64 | definition, 65 | servers: flags.server, 66 | inject: flags.inject, 67 | excludeExt: flags?.['exclude-ext'], 68 | removeUnreferenced: flags?.['remove-unreferenced'], 69 | strip: flags.strip, 70 | header, 71 | root, 72 | }); 73 | documentPath = `./${openApiFile}`; 74 | } 75 | } 76 | 77 | const redocOpts: RedocOpts = { 78 | specUrl: documentPath, 79 | title: document?.info?.title, 80 | } 81 | 82 | if (bundle) { 83 | // bundle files to directory 84 | const bundleDir = path.resolve(bundle); 85 | 86 | // create a directory if one does not exist 87 | if (!fs.existsSync(bundleDir)) { 88 | fs.mkdirSync(bundleDir); 89 | } 90 | 91 | // copy openapi definition file 92 | if (document) { 93 | const openApiPath = path.join(bundleDir, openApiFile); 94 | fs.writeFileSync(openApiPath, JSON.stringify(document)); 95 | this.log(`${openApiPath}`); 96 | } 97 | 98 | // copy redoc index.html file 99 | const redocPath = path.join(bundleDir, 'index.html'); 100 | const redocHtml = getRedocIndexHTML(redocOpts) 101 | fs.writeFileSync(redocPath, redocHtml); 102 | this.log(path.join(redocPath)); 103 | } else { 104 | if (flags.proxy) { 105 | // set up a proxy for the api 106 | let serverURL = null; 107 | if (document.servers && document.servers[0]) { 108 | serverURL = document.servers[0].url; 109 | } 110 | if (flags.server && typeof flags.server === 'object') { 111 | serverURL = flags.server[0]; 112 | } 113 | if (flags.server && typeof flags.server === 'string') { 114 | serverURL = flags.server; 115 | } 116 | if (!serverURL) { 117 | this.error('Unable to find server URL from definition, please provide a --server parameter'); 118 | } 119 | const apiUrl = new URL(serverURL); 120 | const proxyOpts = { 121 | host: `${apiUrl.protocol}//${apiUrl.host}`, 122 | map: (path: string) => { 123 | if (flags.root) { 124 | return `${flags.root}${path}`; 125 | } 126 | if (apiUrl.pathname === '/') { 127 | return path; 128 | } 129 | return `${apiUrl.pathname}${path}`; 130 | }, 131 | jar: flags.withcredentials, 132 | }; 133 | proxyPath = '/proxy'; 134 | app.use( 135 | mount(proxyPath, (ctx, next) => { 136 | ctx.request.header = { 137 | ...ctx.request.header, 138 | ...parseHeaderFlag(header), 139 | }; 140 | return proxy(proxyOpts)(ctx, next); 141 | }), 142 | ); 143 | document.servers = [{ url: proxyPath }, ...document.servers]; 144 | } 145 | 146 | if (document) { 147 | // serve the openapi file 148 | app.use( 149 | mount(`/${openApiFile}`, (ctx) => { 150 | ctx.body = JSON.stringify(document); 151 | }), 152 | ); 153 | } 154 | 155 | // serve swagger ui 156 | app.use(mount('/', serveRedoc(redocOpts))); 157 | 158 | // start server 159 | const { port: portRunning } = await startServer({ app, port }); 160 | this.log(`Redoc running at http://localhost:${portRunning}`); 161 | if (document) { 162 | this.log(`OpenAPI definition at http://localhost:${portRunning}/${openApiFile}`); 163 | } 164 | if (proxyPath) { 165 | this.log(`Proxy running at http://localhost:${portRunning}${proxyPath}`); 166 | } 167 | this.log(); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/commands/swagger-editor.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import * as waitOn from 'wait-on'; 3 | import { resourcePath } from '../__tests__/test-utils'; 4 | import 'chai'; 5 | 6 | const TEST_PORT = 5552; 7 | 8 | describe('swagger-editor', () => { 9 | test 10 | .stdout() 11 | .command(['swagger-editor', resourcePath('openapi.yml'), '-p', `${TEST_PORT}`]) 12 | .it('runs swagger-editor', async (ctx) => { 13 | await waitOn({ resources: [`tcp:localhost:${TEST_PORT}`] }); 14 | expect(ctx.stdout).to.contain('running'); 15 | }); 16 | 17 | afterEach(() => { 18 | // emit disconnect to stop the server 19 | process.emit('disconnect'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/commands/swagger-editor.ts: -------------------------------------------------------------------------------- 1 | import { Command, Args } from '@oclif/core'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as Router from 'koa-router'; 5 | import * as serve from 'koa-static'; 6 | import axios from 'axios'; 7 | import { escapeStringTemplateTicks, parseHeaderFlag } from '../common/utils'; 8 | import * as commonFlags from '../common/flags'; 9 | import { startServer, createServer } from '../common/koa'; 10 | import { resolveDefinition } from '../common/definition'; 11 | 12 | function getAbsoluteFSPath() { 13 | return path.dirname(require.resolve('swagger-editor-dist')); 14 | } 15 | 16 | export class SwaggerEditor extends Command { 17 | public static description = 'Start a Swagger Editor instance'; 18 | 19 | public static examples = ['$ openapi swagger-editor', '$ openapi swagger-editor ./openapi.yml']; 20 | 21 | public static flags = { 22 | ...commonFlags.help(), 23 | ...commonFlags.serverOpts(), 24 | ...commonFlags.header(), 25 | }; 26 | 27 | public static args = { 28 | definition: Args.string({ 29 | description: 'input definition file' 30 | }) 31 | } 32 | 33 | public async run() { 34 | const { args, flags } = await this.parse(SwaggerEditor); 35 | const { port, logger, header } = flags; 36 | 37 | const definition = resolveDefinition(args.definition); 38 | 39 | const app = createServer({ logger }); 40 | const router = new Router(); 41 | let document = null; 42 | 43 | if (definition) { 44 | if (definition.match('://')) { 45 | const { data } = await axios.get(definition, { 46 | headers: parseHeaderFlag(header), 47 | responseType: 'text', 48 | // need to set this, unfortunately 49 | // https://github.com/axios/axios/issues/907 50 | transformResponse: [(data) => data.toString()], 51 | }); 52 | try { 53 | // attempt to prettify JSON 54 | document = JSON.stringify(JSON.parse(data), null, 2); 55 | } catch (err) { 56 | document = data; 57 | } 58 | } else { 59 | document = fs.readFileSync(definition).toString(); 60 | } 61 | } 62 | 63 | const swaggerEditorRoot = getAbsoluteFSPath(); 64 | if (document) { 65 | const indexHTML = fs.readFileSync(path.join(swaggerEditorRoot, 'index.html')).toString('utf8'); 66 | router.get('/', (ctx) => { 67 | ctx.body = indexHTML.replace( 68 | 'window.editor = editor', 69 | `editor.specActions.updateSpec(\`${escapeStringTemplateTicks(document)}\`)\n\nwindow.editor = editor`, 70 | ); 71 | }); 72 | } 73 | 74 | app.use(router.routes()); 75 | app.use(serve(swaggerEditorRoot)); 76 | 77 | const { port: portRunning } = await startServer({ app, port }); 78 | this.log(`Swagger Editor running at http://localhost:${portRunning}`); 79 | this.log(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commands/swagger-ui.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as waitOn from 'wait-on'; 5 | import * as rimraf from 'rimraf'; 6 | import { resourcePath } from '../__tests__/test-utils'; 7 | import { testDefinition } from '../__tests__/test-utils'; 8 | import 'chai'; 9 | 10 | const TEST_PORT = 5552; 11 | const TEST_PORT_PROXY = 5553; 12 | 13 | describe('swagger-ui', () => { 14 | describe('server', () => { 15 | afterEach(() => { 16 | // emit disconnect to stop the server 17 | process.emit('disconnect'); 18 | }); 19 | 20 | test 21 | .stdout() 22 | .command(['swagger-ui', resourcePath('openapi.yml'), '-p', `${TEST_PORT}`]) 23 | .it('runs swagger-ui', async (ctx) => { 24 | await waitOn({ resources: [`tcp:localhost:${TEST_PORT}`] }); 25 | expect(ctx.stdout).to.contain('Swagger UI running'); 26 | }) 27 | }); 28 | 29 | describe('--proxy', () => { 30 | afterEach(() => { 31 | // emit disconnect to stop the server 32 | process.emit('disconnect'); 33 | }); 34 | 35 | 36 | let endpointCalled: boolean; 37 | const setEndpointCalled = (val: boolean) => (endpointCalled = Boolean(val)); 38 | 39 | test 40 | .do(() => setEndpointCalled(false)) 41 | .nock('https://myapi.com', (api) => 42 | api 43 | .get('/openapi.json') 44 | .reply(200, testDefinition) 45 | .get('/pets') 46 | .reply(200, () => { 47 | setEndpointCalled(true); 48 | return {}; 49 | }), 50 | ) 51 | .stdout() 52 | .command(['swagger-ui', 'https://myapi.com/openapi.json', '--proxy', '--server', 'https://myapi.com', '-p', `${TEST_PORT_PROXY}`]) 53 | .it('sets up a proxy to the API under /proxy', async (ctx) => { 54 | await waitOn({ resources: [`tcp:localhost:${TEST_PORT_PROXY}`] }); 55 | expect(ctx.stdout).to.contain('Proxy running'); 56 | 57 | const res = await fetch(`http://localhost:${TEST_PORT_PROXY}/proxy/pets`) 58 | 59 | expect(res.status).to.equal(200); 60 | expect(endpointCalled).to.be.true; 61 | }); 62 | }); 63 | 64 | describe('--bundle', () => { 65 | const bundleDir = 'static'; 66 | afterEach(() => { 67 | rimraf.sync(bundleDir); 68 | }); 69 | test 70 | .stdout() 71 | .command(['swagger-ui', resourcePath('openapi.yml'), '--bundle', bundleDir]) 72 | .it('bundles swagger-ui', (_ctx) => { 73 | expect(fs.existsSync(path.join(bundleDir))).to.equal(true); 74 | expect(fs.existsSync(path.join(bundleDir, 'index.html'))).to.equal(true); 75 | expect(fs.existsSync(path.join(bundleDir, 'openapi.json'))).to.equal(true); 76 | expect(fs.existsSync(path.join(bundleDir, 'swagger-ui.js'))).to.equal(true); 77 | }); 78 | }); 79 | 80 | }); 81 | -------------------------------------------------------------------------------- /src/commands/swagger-ui.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import { URL } from 'url'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import * as proxy from 'koa-proxy'; 6 | import * as mount from 'koa-mount'; 7 | import * as commonFlags from '../common/flags'; 8 | import { parseDefinition, resolveDefinition } from '../common/definition'; 9 | import { startServer, createServer } from '../common/koa'; 10 | import { Document } from '@apidevtools/swagger-parser'; 11 | import { 12 | swaggerUIRoot, 13 | serveSwaggerUI, 14 | SwaggerUIOpts, 15 | DocExpansion, 16 | getSwaggerUIInitializerScript, 17 | } from '../common/swagger-ui'; 18 | import { parseHeaderFlag } from '../common/utils'; 19 | 20 | export class SwaggerUI extends Command { 21 | public static description = 'Start or bundle a Swagger UI instance'; 22 | 23 | public static examples = [ 24 | '$ openapi swagger-ui', 25 | '$ openapi swagger-ui ./openapi.yml', 26 | '$ openapi swagger-ui ./openapi.yml --bundle outDir', 27 | ]; 28 | 29 | public static flags = { 30 | ...commonFlags.help(), 31 | ...commonFlags.serverOpts(), 32 | ...commonFlags.servers(), 33 | ...commonFlags.inject(), 34 | ...commonFlags.strip(), 35 | ...commonFlags.excludeExt(), 36 | ...commonFlags.swaggerUIOpts(), 37 | ...commonFlags.header(), 38 | ...commonFlags.apiRoot(), 39 | bundle: Flags.string({ 40 | char: 'B', 41 | description: 'bundle a static site to directory', 42 | helpValue: 'outDir', 43 | }), 44 | proxy: Flags.boolean({ 45 | description: 'set up a proxy for the api to avoid CORS issues', 46 | exclusive: ['bundle'], 47 | }), 48 | }; 49 | 50 | public static args = { 51 | definition: Args.string({ 52 | description: 'input definition file' 53 | }) 54 | } 55 | 56 | public async run() { 57 | const { args, flags } = await this.parse(SwaggerUI); 58 | const { port, logger, bundle, header, root } = flags; 59 | const definition = resolveDefinition(args.definition); 60 | 61 | const app = createServer({ logger }); 62 | 63 | let proxyPath: string; 64 | let documentPath: string; 65 | let document: Document; 66 | 67 | const openApiFile = 'openapi.json'; 68 | if (definition) { 69 | if (definition.match('://') && !flags.server && !flags.proxy) { 70 | // use remote definition 71 | documentPath = definition; 72 | } else { 73 | // parse definition 74 | document = await parseDefinition({ 75 | definition, 76 | servers: flags.server, 77 | inject: flags.inject, 78 | strip: flags.strip, 79 | excludeExt: flags?.['exclude-ext'], 80 | removeUnreferenced: flags?.['remove-unreferenced'], 81 | header, 82 | root, 83 | }); 84 | documentPath = `./${openApiFile}`; 85 | } 86 | } 87 | 88 | // parse opts for Swagger UI from flags 89 | const swaggerUIOpts: SwaggerUIOpts = { 90 | docExpansion: flags.expand as DocExpansion, 91 | displayOperationId: flags.operationids, 92 | filter: flags.filter, 93 | deepLinking: flags.deeplinks, 94 | withCredentials: flags.withcredentials, 95 | displayRequestDuration: flags.requestduration, 96 | }; 97 | 98 | if (bundle) { 99 | // bundle files to directory 100 | const bundleDir = path.resolve(bundle); 101 | 102 | // create a directory if one does not exist 103 | if (!fs.existsSync(bundleDir)) { 104 | fs.mkdirSync(bundleDir); 105 | } 106 | // copy dist files 107 | for (const file of fs.readdirSync(swaggerUIRoot)) { 108 | const src = path.join(swaggerUIRoot, file); 109 | const target = path.join(bundleDir, file); 110 | fs.copyFileSync(src, target); 111 | this.log(`${target}`); 112 | } 113 | 114 | // copy openapi definition file 115 | if (document) { 116 | const openApiPath = path.join(bundleDir, openApiFile); 117 | fs.writeFileSync(openApiPath, JSON.stringify(document)); 118 | this.log(`${openApiPath}`); 119 | } 120 | 121 | // rewrite swagger-initializer.js 122 | const scriptPath = path.join(bundleDir, 'swagger-initializer.js'); 123 | fs.writeFileSync(scriptPath, getSwaggerUIInitializerScript({ url: documentPath, ...swaggerUIOpts })); 124 | this.log(path.join(bundleDir, 'index.html')); 125 | } else { 126 | if (flags.proxy) { 127 | // set up a proxy for the api 128 | let serverURL = null; 129 | if (document.servers && document.servers[0]) { 130 | serverURL = document.servers[0].url; 131 | } 132 | if (flags.server && typeof flags.server === 'object') { 133 | serverURL = flags.server[0]; 134 | } 135 | if (flags.server && typeof flags.server === 'string') { 136 | serverURL = flags.server; 137 | } 138 | if (!serverURL) { 139 | this.error('Unable to find server URL from definition, please provide a --server parameter'); 140 | } 141 | const apiUrl = new URL(serverURL); 142 | const proxyOpts = { 143 | host: `${apiUrl.protocol}//${apiUrl.host}`, 144 | map: (path: string) => { 145 | if (flags.root) { 146 | return `${flags.root}${path}`; 147 | } 148 | if (apiUrl.pathname === '/') { 149 | return path; 150 | } 151 | return `${apiUrl.pathname}${path}`; 152 | }, 153 | jar: flags.withcredentials, 154 | }; 155 | proxyPath = '/proxy'; 156 | app.use( 157 | mount(proxyPath, (ctx, next) => { 158 | ctx.request.header = { 159 | ...ctx.request.header, 160 | ...parseHeaderFlag(header), 161 | }; 162 | return proxy(proxyOpts)(ctx, next); 163 | }), 164 | ); 165 | document.servers = [{ url: proxyPath }, ...document.servers]; 166 | } 167 | 168 | if (document) { 169 | // serve the openapi file 170 | app.use( 171 | mount(`/${openApiFile}`, (ctx) => { 172 | ctx.body = JSON.stringify(document); 173 | }), 174 | ); 175 | } 176 | 177 | // serve swagger ui 178 | app.use(mount(serveSwaggerUI({ url: documentPath, ...swaggerUIOpts }))); 179 | 180 | // start server 181 | const { port: portRunning } = await startServer({ app, port }); 182 | this.log(`Swagger UI running at http://localhost:${portRunning}`); 183 | if (document) { 184 | this.log(`OpenAPI definition at http://localhost:${portRunning}/${openApiFile}`); 185 | } 186 | if (proxyPath) { 187 | this.log(`Proxy running at http://localhost:${portRunning}${proxyPath}`); 188 | } 189 | this.log(); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/commands/swagger2openapi.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import { resourcePath } from '../__tests__/test-utils'; 3 | import 'chai'; 4 | 5 | describe('swagger2openapi', () => { 6 | test 7 | .stdout() 8 | .command(['swagger2openapi', resourcePath('swagger.json')]) 9 | .it('converts json swagger to openapi v3', (ctx) => { 10 | expect(ctx.stdout).to.contain('openapi: 3'); 11 | expect(ctx.stdout).to.contain('My API'); 12 | }); 13 | 14 | test 15 | .stdout() 16 | .command(['swagger2openapi', resourcePath('swagger.yml')]) 17 | .it('converts yaml swagger to openapi v3', (ctx) => { 18 | expect(ctx.stdout).to.contain('openapi: 3'); 19 | expect(ctx.stdout).to.contain('My API'); 20 | }); 21 | 22 | test 23 | .stdout() 24 | .command(['swagger2openapi', resourcePath('swagger.json'), '--json']) 25 | .it('converts swagger to openapi v3 json', (ctx) => { 26 | expect(ctx.stdout).to.contain('{'); 27 | expect(ctx.stdout).to.contain('"openapi": "3'); 28 | expect(ctx.stdout).to.contain('My API'); 29 | expect(ctx.stdout).to.contain('}'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/commands/swagger2openapi.ts: -------------------------------------------------------------------------------- 1 | import { Command, Args } from '@oclif/core'; 2 | import * as SwaggerParser from '@apidevtools/swagger-parser'; 3 | import * as s2o from 'swagger2openapi'; 4 | import { promisify } from 'util'; 5 | import * as commonFlags from '../common/flags'; 6 | import { parseDefinition, OutputFormat, stringifyDocument, resolveDefinition } from '../common/definition'; 7 | 8 | export class Swagger2Openapi extends Command { 9 | public static description = 'Convert Swagger 2.0 definitions to OpenAPI 3.0.x'; 10 | 11 | public static examples = [`$ openapi swagger2openapi --yaml ./swagger.json > openapi.yml`]; 12 | 13 | public static flags = { 14 | ...commonFlags.help(), 15 | ...commonFlags.parseOpts(), 16 | ...commonFlags.outputFormat(), 17 | }; 18 | 19 | public static args = { 20 | definition: Args.string({ 21 | description: 'input definition file' 22 | }) 23 | } 24 | 25 | public async run() { 26 | const { args, flags } = await this.parse(Swagger2Openapi); 27 | const { dereference, bundle, validate, header, root, strip } = flags; 28 | 29 | // parse definition 30 | const definition = resolveDefinition(args.definition); 31 | if (!definition) { 32 | this.error('Please load a definition file', { exit: 1 }); 33 | } 34 | 35 | const swagger = await parseDefinition({ definition, dereference, bundle, validate, header, root, strip }); 36 | 37 | // convert to swagger 38 | let document: SwaggerParser.Document; 39 | try { 40 | const convertOptions = {}; // @TODO: take in some flags? 41 | const converted = await promisify(s2o.convertObj)(swagger, convertOptions); 42 | document = converted.openapi; 43 | } catch (err) { 44 | this.error(err, { exit: 1 }); 45 | } 46 | 47 | // output in correct format 48 | const format = flags.format === 'json' || flags.json ? OutputFormat.JSON : OutputFormat.YAML; 49 | this.log(stringifyDocument({ document, format })); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commands/test/add.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags, Args } from '@oclif/core'; 2 | import { CONFIG_FILENAME, Config, resolveConfigFile } from '../../common/config'; 3 | import { mock } from 'mock-json-schema'; 4 | import * as YAML from 'js-yaml'; 5 | import * as path from 'path'; 6 | import * as fs from 'fs'; 7 | import OpenAPIClientAxios, { OpenAPIV3, AxiosRequestConfig } from 'openapi-client-axios'; 8 | import { parseDefinition, resolveDefinition } from '../../common/definition'; 9 | import * as commonFlags from '../../common/flags'; 10 | import { Document } from '@apidevtools/swagger-parser'; 11 | import d from 'debug'; 12 | import { isValidJson, parseHeaderFlag } from '../../common/utils'; 13 | import { createSecurityRequestConfig } from '../../common/security'; 14 | import { TEST_CHECKS, TestCheck, TestConfig } from '../../tests/tests'; 15 | import { maybePrompt, maybeSimplePrompt } from '../../common/prompt'; 16 | import { setContext } from '../../common/context'; 17 | import _ = require('lodash'); 18 | const debug = d('cmd'); 19 | 20 | export class TestAdd extends Command { 21 | public static description = 'Add automated tests for API operations'; 22 | 23 | public static examples = [ 24 | `$ openapi test add`, 25 | `$ openapi test add -o getPet --checks all`, 26 | ]; 27 | 28 | public static flags = { 29 | ...commonFlags.help(), 30 | ...commonFlags.parseOpts(), 31 | ...commonFlags.apiRoot(), 32 | auto: Flags.boolean({ description: 'auto generate tests for all operations', default: false }), 33 | operation: Flags.string({ char: 'o', description: 'operationId', helpValue: 'operationId' }), 34 | name: Flags.string({ char: 'n', description: 'test name', helpValue: 'my test' }), 35 | checks: Flags.string({ char: 'c', description: 'checks to include in test', helpValue: '2XXStatus', multiple: true, options: TEST_CHECKS }), 36 | param: Flags.string({ char: 'p', description: 'parameter', helpValue: 'key=value', multiple: true }), 37 | data: Flags.string({ char: 'd', description: 'request body' }), 38 | verbose: Flags.boolean({ 39 | char: 'v', 40 | description: 'verbose mode', 41 | default: false, 42 | }), 43 | ...commonFlags.interactive(), 44 | ...commonFlags.securityOpts(), 45 | }; 46 | 47 | public static args = { 48 | definition: Args.string({ 49 | description: 'input definition file' 50 | }) 51 | } 52 | 53 | public async run() { 54 | const { args, flags } = await this.parse(TestAdd); 55 | const { dereference, validate, bundle, header } = flags; 56 | 57 | const definition = resolveDefinition(args.definition); 58 | if (!definition) { 59 | this.error('Please load a definition file', { exit: 1 }); 60 | } 61 | 62 | if (flags.auto) { 63 | // dont prompt in auto mode 64 | flags.interactive = false; 65 | } 66 | 67 | // store flags in context 68 | setContext((ctx) => ({ ...ctx, flags })) 69 | 70 | let document: Document; 71 | try { 72 | document = await parseDefinition({ 73 | definition, 74 | dereference, 75 | bundle, 76 | validate, 77 | servers: flags.server, 78 | inject: flags.inject, 79 | strip: flags.strip, 80 | header, 81 | induceServers: true, 82 | }); 83 | } catch (err) { 84 | this.error(err, { exit: 1 }); 85 | } 86 | 87 | const api = new OpenAPIClientAxios({ definition: document }); 88 | await api.init(); 89 | 90 | // select operation 91 | let operationId = flags.operation; 92 | 93 | if (!flags.auto) { 94 | if (!operationId) { 95 | const res = await maybePrompt([ 96 | { 97 | name: 'operation', 98 | message: 'select operation', 99 | type: 'list', 100 | choices: api.getOperations().map((op) => { 101 | const { operationId: id, summary, description, method, path } = op; 102 | let name = `${method.toUpperCase()} ${path}`; 103 | if (summary) { 104 | name = `${name} - ${summary}`; 105 | } else if (description) { 106 | name = `${name} - ${description}`; 107 | } 108 | if (id) { 109 | name = `${name} (${id})`; 110 | } 111 | return { name, value: id }; 112 | }), 113 | }, 114 | ]); 115 | operationId = res.operation; 116 | } 117 | if (!operationId) { 118 | this.error(`no operationId passed, please specify --operation`, { exit: 1 }); 119 | } 120 | const operation = api.getOperation(operationId); 121 | if (!operation) { 122 | this.error(`operationId ${operationId} not found`, { exit: 1 }); 123 | } 124 | } 125 | 126 | // give test name 127 | let testName = flags.name; 128 | if (!testName) { 129 | testName = (await maybePrompt({ 130 | name: 'testName', 131 | message: 'test name', 132 | default: 'call operation' 133 | })).testName; 134 | } 135 | 136 | // configure checks 137 | let checks = flags.checks as TestCheck[]; 138 | if (!checks?.length && flags.auto) { 139 | // default checks only 140 | checks = ['Success2XX', 'ValidResponseBody'] 141 | } 142 | 143 | if (!checks?.length && !flags.auto) { 144 | checks = await maybePrompt({ 145 | name: 'checks', 146 | message: 'checks to include in test', 147 | type: 'checkbox', 148 | choices: [{ 149 | name: '2XX response', 150 | value: 'Success2XX' as TestCheck, 151 | checked: true, 152 | }, 153 | { 154 | name: 'Validate Response Body', 155 | value: 'ValidResponseBody' as TestCheck, 156 | checked: true, 157 | }] 158 | }).then((res) => res.checks); 159 | } 160 | 161 | const operationsToAddTests = flags.auto ? api.getOperations() : [api.getOperation(operationId)]; 162 | 163 | const testsToAdd: TestConfig = {}; 164 | 165 | for (const operation of operationsToAddTests) { 166 | // fill params 167 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 168 | const params: { [key: string]: any } = {}; 169 | for (const param of flags.param || []) { 170 | const [key, value] = param.split('='); 171 | params[key.trim()] = value; 172 | } 173 | 174 | for (const p of operation.parameters || []) { 175 | const param = p as OpenAPIV3.ParameterObject; 176 | const { name, required, example, schema } = param; 177 | 178 | if (!params[name] && required) { 179 | const mockedValue = schema ? mock(schema as OpenAPIV3.SchemaObject) : undefined; 180 | 181 | const value = await maybeSimplePrompt(name, { required, default: example ?? mockedValue }); 182 | params[name] = value; 183 | } 184 | } 185 | 186 | // handle request body 187 | let data = flags.data; 188 | if ( 189 | !data && 190 | operation.requestBody && 191 | 'content' in operation.requestBody && 192 | (await maybePrompt({ type: 'confirm', default: true, name: 'yes', message: 'add request body?' })).yes 193 | ) { 194 | const contentType = Object.keys(operation.requestBody.content)[0]; 195 | 196 | let defaultValue = operation.requestBody.content?.[contentType]?.example; 197 | if (!defaultValue && operation.requestBody.content?.[contentType]?.schema) { 198 | defaultValue = JSON.stringify( 199 | mock(operation.requestBody.content?.[contentType]?.schema as OpenAPIV3.SchemaObject), 200 | null, 201 | 2, 202 | ); 203 | } 204 | if (!defaultValue && contentType === 'application/json') { 205 | defaultValue = '{}'; 206 | } 207 | 208 | data = ( 209 | await maybePrompt({ 210 | type: 'editor', 211 | message: contentType || '', 212 | name: 'requestBody', 213 | default: defaultValue, 214 | validate: (value) => { 215 | if (contentType === 'application/json' && !isValidJson(value)) { 216 | return 'invalid json'; 217 | } 218 | return true; 219 | }, 220 | }) 221 | ).requestBody; 222 | } 223 | 224 | const securityRequestConfig = await createSecurityRequestConfig({ 225 | document, 226 | operation, 227 | security: flags.security, 228 | header: flags.header, 229 | apikey: flags.apikey, 230 | token: flags.token, 231 | username: flags.username, 232 | password: flags.password, 233 | }); 234 | debug('securityRequestConfig %o', securityRequestConfig); 235 | 236 | const config: AxiosRequestConfig = {}; 237 | 238 | // add cookies 239 | const cookies = { 240 | ...securityRequestConfig.cookie, 241 | }; 242 | const cookieHeader = Object.keys(cookies) 243 | .map((key) => `${key}=${cookies[key]}`) 244 | .join('; '); 245 | 246 | // add request headers 247 | config.headers = { 248 | ...securityRequestConfig.header, 249 | ...parseHeaderFlag(header), 250 | ...(Boolean(cookieHeader) && { cookie: cookieHeader }), 251 | }; 252 | 253 | // add query params 254 | if (Object.keys({ ...securityRequestConfig.query }).length) { 255 | config.params = securityRequestConfig.query; 256 | } 257 | 258 | // add basic auth 259 | if (Object.keys({ ...securityRequestConfig.auth }).length) { 260 | config.auth = securityRequestConfig.auth; 261 | } 262 | 263 | // set content type 264 | if (!config.headers['Content-Type'] && !config.headers['content-type']) { 265 | const operationRequestContentType = Object.keys(operation.requestBody?.['content'] ?? {})[0]; 266 | const defaultContentType = isValidJson(data) ? 'application/json' : undefined; 267 | config.headers['Content-Type'] = operationRequestContentType ?? defaultContentType; 268 | } 269 | 270 | testsToAdd[operation.operationId] = { 271 | ...testsToAdd[operation.operationId], 272 | [testName]: { 273 | checks, 274 | request: { 275 | params, 276 | data, 277 | config, 278 | }, 279 | }, 280 | }; 281 | this.log(`Added ${checks.length === 1 ? `test` : `${checks.length} tests`} for ${operation.operationId} "${testName}"`); 282 | } 283 | 284 | const configFile = resolveConfigFile(); 285 | 286 | // write to config file 287 | const oldConfig: Config = configFile ? YAML.load(fs.readFileSync(configFile).toString()) : {}; 288 | 289 | const newConfig = { 290 | ...oldConfig, 291 | definition, 292 | tests: { 293 | ...oldConfig.tests, 294 | ..._.mapValues(testsToAdd, (tests, operationId) => ({ 295 | ...oldConfig.tests?.[operationId], 296 | ...tests, 297 | })), 298 | } 299 | }; 300 | 301 | // default to current directory 302 | const writeTo = path.resolve(configFile || `./${CONFIG_FILENAME}`); 303 | 304 | // write as YAML 305 | fs.writeFileSync(writeTo, YAML.dump(newConfig, { noRefs: true })); 306 | this.log(`Wrote to ${writeTo}`); 307 | 308 | this.log(`You can now run tests with \`${this.config.bin} test\``); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/commands/test/index.ts: -------------------------------------------------------------------------------- 1 | import { runCLI } from '@jest/core'; 2 | import { Document } from '@apidevtools/swagger-parser'; 3 | import type { Config } from '@jest/types'; 4 | import { Command, Flags } from '@oclif/core'; 5 | import * as commonFlags from '../../common/flags'; 6 | import * as path from 'path'; 7 | import d from 'debug'; 8 | import { getConfigValue } from '../../common/config'; 9 | import { TestConfig } from '../../tests/tests'; 10 | import { parseDefinition, resolveDefinition } from '../../common/definition'; 11 | import { setContext } from '../../common/context'; 12 | import { maybePrompt } from '../../common/prompt'; 13 | const debug = d('cmd'); 14 | 15 | export class Test extends Command { 16 | public static description = 'Run automated tests against APIs'; 17 | 18 | public static examples = [ 19 | `$ openapi test`, 20 | `$ openapi test -o getPets`, 21 | ]; 22 | 23 | public static flags = { 24 | ...commonFlags.help(), 25 | ...commonFlags.parseOpts(), 26 | ...commonFlags.interactive(), 27 | operation: Flags.string({ char: 'o', description: 'filter by operationId', helpValue: 'operationId', multiple: true }), 28 | verbose: Flags.boolean({ 29 | char: 'v', 30 | description: 'verbose mode', 31 | default: false, 32 | }), 33 | ...commonFlags.securityOpts(), 34 | }; 35 | 36 | public async run() { 37 | const { args, flags } = await this.parse(Test); 38 | const { dereference, validate, bundle, header } = flags; 39 | 40 | // store flags in context 41 | setContext((ctx) => ({ ...ctx, flags })) 42 | 43 | const definition = resolveDefinition(args.definition); 44 | if (!definition) { 45 | this.error('Please load a definition file', { exit: 1 }); 46 | } 47 | 48 | let document: Document; 49 | try { 50 | document = await parseDefinition({ 51 | definition, 52 | dereference, 53 | bundle, 54 | validate, 55 | servers: flags.server, 56 | inject: flags.inject, 57 | strip: flags.strip, 58 | header, 59 | induceServers: true, 60 | }); 61 | 62 | } catch (err) { 63 | this.error(err, { exit: 1 }); 64 | } 65 | 66 | // make sure we have a server in the document 67 | if (!document.servers?.some((s) => s.url)) { 68 | const res = await maybePrompt({ 69 | name: 'server', 70 | message: 'please enter a server URL', 71 | type: 'input', 72 | default: 'http://localhost:9000', 73 | // must be a valid URL 74 | validate: (value) => { 75 | try { 76 | new URL(value); 77 | return true; 78 | } catch (err) { 79 | return 'must be a valid URL'; 80 | } 81 | } 82 | }); 83 | 84 | if (res.server) { 85 | document.servers = [{ url: res.server }]; 86 | } else { 87 | this.error('no server URL provided, use --server or modify your API spec', { exit: 1 }); 88 | } 89 | } 90 | 91 | // store document in context 92 | setContext((ctx) => ({ ...ctx, document })) 93 | 94 | const testConfig: TestConfig = getConfigValue('tests'); 95 | 96 | if (!testConfig) { 97 | this.error('No tests configured. Please run `test add` first', { exit: 1 }); 98 | } 99 | 100 | // make sure we have a server in the document 101 | if (!document.servers?.some((s) => s.url)) { 102 | const res = await maybePrompt({ 103 | name: 'server', 104 | message: 'please enter a server URL', 105 | type: 'input', 106 | default: 'http://localhost:9000', 107 | // must be a valid URL 108 | validate: (value) => { 109 | try { 110 | new URL(value); 111 | return true; 112 | } catch (err) { 113 | return 'must be a valid URL'; 114 | } 115 | } 116 | }); 117 | 118 | if (res.server) { 119 | document.servers = [{ url: res.server }]; 120 | } else { 121 | this.error('no server URL provided, use --server or modify your API spec', { exit: 1 }); 122 | } 123 | } 124 | 125 | const jestArgv: Config.Argv = { 126 | ...flags, 127 | $0: 'jest', 128 | _: [], 129 | passWithNoTests: true, 130 | verbose: true, 131 | } 132 | 133 | // filter tests by operation 134 | if (flags.operation) { 135 | jestArgv.testNamePattern = flags.operation.map((o) => `${o} `).join('|'); 136 | } 137 | 138 | const testFile = require.resolve('../../tests/run-jest'); 139 | const testProjectDir = path.dirname(testFile) 140 | 141 | jestArgv.noStackTrace = true; 142 | jestArgv.rootDir = testProjectDir; 143 | jestArgv.runTestsByPath = true; 144 | jestArgv.runInBand = true; 145 | jestArgv._ = [testFile]; 146 | 147 | // set no interactive mode for jest 148 | setContext((ctx) => ({ ...ctx, flags: { ...ctx.flags, interactive: false } })) 149 | 150 | debug('jestArgv', jestArgv); 151 | await runCLI(jestArgv, [testProjectDir]); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/commands/typegen.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import { resourcePath } from '../__tests__/test-utils'; 3 | import 'chai'; 4 | 5 | describe('typegen', () => { 6 | describe('output', () => { 7 | test 8 | .stdout() 9 | .command(['typegen', resourcePath('openapi.yml')]) 10 | .it('generates import statements', (ctx) => { 11 | expect(ctx.stdout).to.match(/import type/); 12 | }); 13 | 14 | test 15 | .stdout() 16 | .command(['typegen', resourcePath('openapi.json')]) 17 | .it('generates schemas', (ctx) => { 18 | expect(ctx.stdout).to.match(/Schemas/); 19 | expect(ctx.stdout).to.match(/Pet/); 20 | }); 21 | 22 | test 23 | .stdout() 24 | .command(['typegen', resourcePath('openapi.json')]) 25 | .it('generates operation paths', (ctx) => { 26 | expect(ctx.stdout).to.match(/Paths/); 27 | expect(ctx.stdout).to.match(/Responses/); 28 | expect(ctx.stdout).to.match(/PetRes/); 29 | expect(ctx.stdout).to.match(/Parameters/); 30 | expect(ctx.stdout).to.match(/ListPetsRes/); 31 | }); 32 | 33 | test 34 | .stdout() 35 | .command(['typegen', resourcePath('openapi.json'), '--client']) 36 | .it('exports operation methods', (ctx) => { 37 | expect(ctx.stdout).to.match(/export interface OperationMethods/); 38 | expect(ctx.stdout).to.match(/getPets/); 39 | expect(ctx.stdout).to.match(/createPet/); 40 | expect(ctx.stdout).to.match(/getPetById/); 41 | }); 42 | 43 | test 44 | .stdout() 45 | .command(['typegen', resourcePath('openapi.json'), '--client']) 46 | .it('exports paths dictionary', (ctx) => { 47 | expect(ctx.stdout).to.match(/export interface PathsDictionary/); 48 | expect(ctx.stdout).to.match(/\/pets/); 49 | expect(ctx.stdout).to.match(/\/pets\/\{id\}/); 50 | }); 51 | 52 | test 53 | .stdout() 54 | .command(['typegen', resourcePath('openapi.json'), '--client']) 55 | .it('exports Client type', (ctx) => { 56 | expect(ctx.stdout).to.match(/export type Client/); 57 | }); 58 | 59 | test 60 | .stdout() 61 | .command(['typegen', resourcePath('openapi.json'), '--backend']) 62 | .it('exports Backend operations', (ctx) => { 63 | expect(ctx.stdout).to.match(/export interface Operations/); 64 | expect(ctx.stdout).to.match(/getPets/); 65 | expect(ctx.stdout).to.match(/createPet/); 66 | expect(ctx.stdout).to.match(/getPetById/); 67 | }); 68 | 69 | test 70 | .stdout() 71 | .command(['typegen', resourcePath('openapi.json'), '--backend']) 72 | .it('exports Backend types', (ctx) => { 73 | expect(ctx.stdout).to.match(/export type OperationContext/); 74 | expect(ctx.stdout).to.match(/export type OperationResponse/); 75 | expect(ctx.stdout).to.match(/export type OperationHandler/); 76 | }); 77 | 78 | test 79 | .stdout() 80 | .command(['typegen', resourcePath('openapi.json'), '-A']) 81 | .it('generates module level schema aliases', (ctx) => { 82 | expect(ctx.stdout).to.match(/export type Pet = Components.Schemas.Pet/); 83 | }); 84 | 85 | test 86 | .stdout() 87 | .command(['typegen', resourcePath('openapi.json'), '-b', '/* Generated by openapicmd */']) 88 | .it('adds file banner', (ctx) => { 89 | expect(ctx.stdout).to.match(/Generated by openapicmd/); 90 | }); 91 | 92 | test 93 | .stdout() 94 | .command(['typegen', resourcePath('openapi.json'), '--client', '--backend']) 95 | .it('exports both client and backend', (ctx) => { 96 | expect(ctx.stdout).to.match(/export type Client/); 97 | expect(ctx.stdout).to.match(/export type OperationHandler/); 98 | expect(ctx.stdout).to.match(/export interface Operations/); 99 | expect(ctx.stdout).to.match(/export interface OperationMethods/); 100 | }) 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/commands/typegen.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { Command, Args } from '@oclif/core'; 3 | import { parseDefinition, resolveDefinition } from '../common/definition'; 4 | import * as commonFlags from '../common/flags'; 5 | import { Document } from '@apidevtools/swagger-parser'; 6 | import { generateTypesForDocument } from '../typegen/typegen'; 7 | 8 | type TypegenMode = 'client' | 'backend' | 'both'; 9 | 10 | export class Typegen extends Command { 11 | public static description = 'Generate types from openapi definition'; 12 | 13 | public static examples = [ 14 | `$ openapi typegen ./openapi.yml > openapi.d.ts`, 15 | ]; 16 | 17 | public static flags = { 18 | ...commonFlags.help(), 19 | ...commonFlags.parseOpts(), 20 | banner: Flags.string({ 21 | char: 'b', 22 | description: 'include a banner comment at the top of the generated file' 23 | }), 24 | client: Flags.boolean({ 25 | description: 'Generate types for openapi-client-axios (default)', 26 | default: false, 27 | }), 28 | backend: Flags.boolean({ 29 | description: 'Generate types for openapi-backend', 30 | default: false, 31 | }), 32 | ['type-aliases']: Flags.boolean({ 33 | char: 'A', 34 | description: 'Generate module level type aliases for schema components defined in spec', 35 | default: true, 36 | allowNo: true, 37 | }), 38 | }; 39 | 40 | public static args = { 41 | definition: Args.string({ 42 | description: 'input definition file' 43 | }) 44 | } 45 | 46 | public async run() { 47 | const { args, flags } = await this.parse(Typegen); 48 | const { dereference, validate, bundle, header, root } = flags; 49 | 50 | const definition = resolveDefinition(args.definition); 51 | if (!definition) { 52 | this.error('Please load a definition file', { exit: 1 }); 53 | } 54 | 55 | let document: Document; 56 | try { 57 | document = await parseDefinition({ 58 | definition, 59 | dereference, 60 | bundle, 61 | validate, 62 | inject: flags.inject, 63 | excludeExt: flags?.['exclude-ext'], 64 | removeUnreferenced: flags?.['remove-unreferenced'], 65 | strip: flags.strip, 66 | servers: flags.server, 67 | header, 68 | root, 69 | }); 70 | } catch (err) { 71 | this.error(err, { exit: 1 }); 72 | } 73 | 74 | const withTypeAliases = flags['type-aliases']; 75 | const mode = this.mode(flags.client, flags.backend); 76 | 77 | await this.outputBanner(flags.banner); 78 | await this.outputTypes(document, mode, withTypeAliases); 79 | } 80 | 81 | private mode(client: boolean, backend: boolean): TypegenMode { 82 | if (client && backend) { 83 | return 'both'; 84 | } else if (backend) { 85 | return 'backend'; 86 | } 87 | 88 | // default to client 89 | return 'client'; 90 | } 91 | 92 | private async outputBanner(banner: string) { 93 | if (banner) { 94 | this.log(banner + '\n'); 95 | } 96 | } 97 | 98 | private async outputTypes(document: Document, mode: TypegenMode, withTypeAliases: boolean) { 99 | const { clientImports, backendImports, schemaTypes, clientOperationTypes, backendOperationTypes, rootLevelAliases } = await generateTypesForDocument(document, { transformOperationName: (name) => name }); 100 | 101 | if (['both', 'client'].includes(mode)) { 102 | this.log(clientImports) 103 | } 104 | 105 | if (['both', 'backend'].includes(mode)) { 106 | this.log(backendImports) 107 | } 108 | 109 | this.log(`\n${schemaTypes}`); 110 | 111 | if (['both', 'client'].includes(mode)) { 112 | this.log(`\n${clientOperationTypes}`); 113 | } 114 | 115 | if (['both', 'backend'].includes(mode)) { 116 | this.log(`\n${backendOperationTypes}`); 117 | } 118 | 119 | if (withTypeAliases && rootLevelAliases) { 120 | this.log(`\n${rootLevelAliases}`); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/commands/unload.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@oclif/test'; 2 | import * as fs from 'fs'; 3 | import * as YAML from 'js-yaml'; 4 | import { resourcePath } from '../__tests__/test-utils'; 5 | import 'chai'; 6 | import { CONFIG_FILENAME, Config } from '../common/config'; 7 | 8 | describe('unload', () => { 9 | beforeEach(() => { 10 | fs.writeFileSync(CONFIG_FILENAME, YAML.dump({ definition: 'openapi.json' })); 11 | }); 12 | 13 | afterEach(() => { 14 | fs.unlink(CONFIG_FILENAME, (_err) => null); 15 | }); 16 | 17 | test 18 | .stdout() 19 | .command(['unload']) 20 | .it('unloads definition from config file', (ctx) => { 21 | expect(ctx.stdout).to.contain('Unloaded succesfully!'); 22 | }); 23 | 24 | test 25 | .stdout() 26 | .command(['unload', resourcePath('openapi.yml')]) 27 | .it(`removes the definition property from the config file`, (_ctx) => { 28 | const config = YAML.load(fs.readFileSync(CONFIG_FILENAME).toString()) as Config; 29 | expect(config.definition).to.not.exist; 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/commands/unload.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core'; 2 | import * as commonFlags from '../common/flags'; 3 | import * as fs from 'fs'; 4 | import * as YAML from 'js-yaml'; 5 | import { Config, resolveConfigFile } from '../common/config'; 6 | 7 | export class Unload extends Command { 8 | public static description = 'Unset the default definition file for a workspace (writes to .openapiconfig)'; 9 | 10 | public static examples = [`$ openapi unload`]; 11 | 12 | public static flags = { 13 | ...commonFlags.help(), 14 | }; 15 | 16 | public async run() { 17 | const configFile = resolveConfigFile(); 18 | if (configFile) { 19 | const oldConfig: Config = YAML.load(fs.readFileSync(configFile).toString()); 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | const { definition, ...newConfig } = oldConfig; 22 | fs.writeFileSync(configFile, YAML.dump(newConfig)); 23 | this.log(`Written to ${configFile}`); 24 | } 25 | this.log('Unloaded succesfully!'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from 'os'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import * as YAML from 'js-yaml'; 5 | import { SecurityConfig } from './security'; 6 | import { TestConfig } from '../tests/tests'; 7 | 8 | export const CONFIG_FILENAME = '.openapiconfig'; 9 | 10 | export interface Config { 11 | definition?: string; 12 | security?: SecurityConfig; 13 | tests?: TestConfig; 14 | } 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | export function getConfigValue(key: string, defaultValue?: T): T { 18 | const configFile = resolveConfigFile(); 19 | if (configFile) { 20 | const config = YAML.load(fs.readFileSync(configFile).toString()); 21 | return config[key] || defaultValue; 22 | } 23 | return defaultValue; 24 | } 25 | 26 | // walk backwards from cwd until homedir and check if CONFIG_FILENAME exists 27 | export function resolveConfigFile() { 28 | let dir = path.resolve(process.cwd()); 29 | while (dir.length >= homedir().length) { 30 | const checks = [ 31 | path.join(dir, CONFIG_FILENAME), 32 | path.join(dir, `${CONFIG_FILENAME}.yml`), 33 | path.join(dir, `${CONFIG_FILENAME}.yaml`), 34 | ]; 35 | for (const check of checks) { 36 | if (fs.existsSync(check)) { 37 | return check; 38 | } 39 | } 40 | // walk backwards 41 | dir = path.resolve(path.join(dir, '..')); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/common/context.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash' 2 | import { Document } from "@apidevtools/swagger-parser"; 3 | import * as path from 'path' 4 | import * as fs from 'fs' 5 | 6 | /** 7 | * Context is a global shared object during the lifecycle of a command 8 | * 9 | * Since we may spawn multiple nodejs processes, we store context in the filesystem 10 | */ 11 | 12 | export interface Context { 13 | document: Document 14 | flags: { 15 | interactive: boolean 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | [key: string]: any 18 | } 19 | } 20 | 21 | export const getContext = (): Partial => { 22 | if (fs.existsSync(getContextFile())) { 23 | return JSON.parse(fs.readFileSync(getContextFile(), 'utf8')) 24 | } 25 | 26 | return {} as Partial; 27 | } 28 | 29 | export type ContextSetter = (prev: Partial) => Partial; 30 | export const setContext = (fn: ContextSetter) => { 31 | const context = getContext(); 32 | 33 | const newContext = fn(cloneDeep(context)); 34 | 35 | fs.writeFileSync(getContextFile(), JSON.stringify(newContext)) 36 | } 37 | 38 | const getContextFile = () => { 39 | // check if parent process has a context file 40 | if (fs.existsSync(getContextFileForPid(process.ppid))) { 41 | return getContextFileForPid(process.ppid) 42 | } 43 | 44 | // otherwise return our own 45 | return getContextFileForPid(process.pid) 46 | } 47 | 48 | const getContextFileForPid = (pid: number) => path.join('/tmp', `openapicmd-ctx-${pid}.json`); -------------------------------------------------------------------------------- /src/common/definition.ts: -------------------------------------------------------------------------------- 1 | import * as SwaggerParser from '@apidevtools/swagger-parser'; 2 | import * as deepMerge from 'deepmerge'; 3 | import { set, uniqBy } from 'lodash'; 4 | import * as YAML from 'js-yaml'; 5 | import { Command } from '@oclif/core'; 6 | import { parseHeaderFlag } from './utils'; 7 | import { getConfigValue, resolveConfigFile } from './config'; 8 | import { PRESETS, StripPreset, stripDefinition } from './strip-definition'; 9 | import path = require('path'); 10 | 11 | interface ParseOpts { 12 | definition: string; 13 | dereference?: boolean; 14 | validate?: boolean; 15 | bundle?: boolean; 16 | servers?: string[]; 17 | inject?: string[]; 18 | strip?: string; 19 | excludeExt?: string; 20 | removeUnreferenced?: boolean; 21 | header?: string[]; 22 | root?: string; 23 | induceServers?: boolean; 24 | } 25 | export async function parseDefinition({ 26 | definition, 27 | dereference, 28 | validate, 29 | bundle, 30 | servers, 31 | inject, 32 | excludeExt, 33 | strip, 34 | header, 35 | root, 36 | induceServers, 37 | removeUnreferenced 38 | }: ParseOpts): Promise { 39 | let method = SwaggerParser.parse; 40 | if (bundle) { 41 | method = SwaggerParser.bundle; 42 | } 43 | if (dereference) { 44 | method = SwaggerParser.dereference; 45 | } 46 | if (validate) { 47 | method = SwaggerParser.validate; 48 | } 49 | 50 | const parserOpts: SwaggerParser.Options = {}; 51 | 52 | // add headers 53 | if (header) { 54 | set(parserOpts, ['resolve', 'http', 'headers'], parseHeaderFlag(header)); 55 | } 56 | 57 | let document = await method.bind(SwaggerParser)(definition, parserOpts); 58 | 59 | // merge injected JSON 60 | if (inject) { 61 | for (const json of inject) { 62 | try { 63 | const parsed = JSON.parse(json); 64 | document = deepMerge(document, parsed); 65 | } catch (err) { 66 | console.error('Could not parse inject JSON'); 67 | throw err; 68 | } 69 | } 70 | } 71 | 72 | if (excludeExt) { 73 | const removeSpecifiedExtensions = (obj, parent = null, parentKey: string = '') => { 74 | if (typeof obj !== 'object' || obj === null) return; 75 | 76 | for (const key in obj) { 77 | if (excludeExt == key && parent) { 78 | // Remove the entire operation (e.g., get, post) if specified extension is found 79 | delete parent[parentKey]; 80 | break; // Exit the loop as the entire operation has been removed 81 | } else if (typeof obj[key] === 'object') { 82 | removeSpecifiedExtensions(obj[key], obj, key); 83 | } 84 | } 85 | }; 86 | 87 | // Start the traversal from the root of the document 88 | removeSpecifiedExtensions(document); 89 | // Remove empty paths 90 | Object.keys(document.paths).forEach(path => { 91 | if (Object.keys(document.paths[path]).length === 0) { 92 | delete document.paths[path]; 93 | } 94 | }); 95 | 96 | } 97 | 98 | if (removeUnreferenced) { 99 | 100 | const collectReferencedComponents = (obj) => { 101 | const referencedComponents = new Set(); 102 | 103 | const collector = (obj) => { 104 | if (obj && typeof obj === 'object') { 105 | for (const key in obj) { 106 | if (key === '$ref' && typeof obj[key] === 'string') { 107 | const ref = obj[key].split('/').pop(); 108 | referencedComponents.add(ref); 109 | } else { 110 | collector(obj[key]); 111 | } 112 | } 113 | } 114 | }; 115 | 116 | collector(obj); 117 | return referencedComponents; 118 | }; 119 | 120 | // Function to remove unreferenced components 121 | const removeUnreferencedComponents = (document, referencedComponents: Set) => { 122 | for (const components of Object.entries(document.components)) { 123 | const componentValue = components[1]; 124 | if (componentValue && typeof componentValue === 'object') { 125 | for (const key in componentValue) { 126 | 127 | const component = componentValue[key]; 128 | const toBeRemoved = (component && typeof component === 'object' && component['x-openapicmd-keep'] !== true && !referencedComponents.has(key)); 129 | 130 | if (toBeRemoved) { 131 | delete componentValue[key]; 132 | } 133 | } 134 | } 135 | } 136 | }; 137 | 138 | // Collect referenced components from the main document 139 | const referencedComponents = collectReferencedComponents(document); 140 | 141 | // Collect security scheme references separately 142 | if (document.security && Array.isArray(document.security)) { 143 | document.security.forEach(securityRequirement => { 144 | for (const securityScheme in securityRequirement) { 145 | if (document.components && document.components.securitySchemes && document.components.securitySchemes[securityScheme]) { 146 | referencedComponents.add(securityScheme); 147 | } 148 | } 149 | }); 150 | } 151 | 152 | // Removing unreferenced components 153 | removeUnreferencedComponents(document, referencedComponents); 154 | } 155 | 156 | // strip optional metadata 157 | if (strip) { 158 | let preset: StripPreset = 'default' 159 | if (Object.keys(PRESETS).includes(strip)) { 160 | preset = strip as StripPreset; 161 | } else { 162 | throw new Error(`Unknown strip preset "${strip}"`); 163 | } 164 | 165 | document = stripDefinition(document, { preset }); 166 | } 167 | 168 | // add servers 169 | if (servers) { 170 | const serverObjects = servers.map((url) => ({ url })); 171 | document.servers = document.servers ? [...serverObjects, ...document.servers] : serverObjects; 172 | } 173 | 174 | // induce the remote server from the definition parameter if needed 175 | if ((induceServers && definition.startsWith('http')) || definition.startsWith('//')) { 176 | document.servers = document.servers || []; 177 | const inputURL = new URL(definition); 178 | const server = document.servers[0]; 179 | if (!server) { 180 | document.servers[0] = { url: `${inputURL.protocol}//${inputURL.host}` }; 181 | } else if (!server.url.startsWith('http') && !server.url.startsWith('//')) { 182 | document.servers[0] = { url: `${inputURL.protocol}//${inputURL.host}${server.url}` }; 183 | } 184 | } 185 | 186 | // override the api root for servers 187 | if (root) { 188 | if (!root.startsWith('/')) { 189 | root = `$/{root}`; 190 | } 191 | if (document.servers) { 192 | document.servers = document.servers.map((server) => { 193 | try { 194 | const serverURL = new URL(server.url); 195 | return { 196 | ...server, 197 | url: `${serverURL.protocol}//${serverURL.host}${root}`, 198 | }; 199 | } catch { 200 | return { 201 | ...server, 202 | url: root, 203 | }; 204 | } 205 | }); 206 | } else { 207 | document.servers = { url: root }; 208 | } 209 | } 210 | 211 | return document; 212 | } 213 | 214 | export enum OutputFormat { 215 | JSON = 'json', 216 | YAML = 'yaml', 217 | } 218 | 219 | interface OutputOpts { 220 | document: SwaggerParser.Document; 221 | format?: OutputFormat; 222 | } 223 | export function stringifyDocument({ document, format }: OutputOpts): string { 224 | if (format === OutputFormat.JSON) { 225 | // JSON output 226 | return JSON.stringify(document, null, 2); 227 | } else { 228 | // YAML output 229 | return YAML.dump(document, { noRefs: true, lineWidth: 240, noArrayIndent: true }); 230 | } 231 | } 232 | 233 | export function resolveDefinition(definitionArg: string) { 234 | // check definitionArg 235 | if (definitionArg && definitionArg !== 'CURRENT') { 236 | return definitionArg; 237 | } 238 | 239 | if (process.env.OPENAPI_DEFINITION && definitionArg !== 'CURRENT') { 240 | return process.env.OPENAPI_DEFINITION; 241 | } 242 | 243 | const definitionConfig = getConfigValue('definition'); 244 | 245 | if (definitionConfig) { 246 | // if config value is a relative path, resolve it relative to the config directory 247 | const isUrl = definitionConfig.startsWith('http'); 248 | const isAbsolute = definitionConfig.startsWith('/'); 249 | const isRelative = !isAbsolute && !isUrl; 250 | 251 | if (isRelative) { 252 | const configFilePath = resolveConfigFile(); 253 | const configDir = path.dirname(configFilePath); 254 | 255 | return path.join(configDir, definitionConfig); 256 | } 257 | 258 | return definitionConfig; 259 | } 260 | } 261 | 262 | export function printInfo(document: SwaggerParser.Document, ctx: Command) { 263 | const { info, externalDocs } = document; 264 | if (info) { 265 | const { title, version, description, contact } = info; 266 | ctx.log(`title: ${title}`); 267 | ctx.log(`version: ${version}`); 268 | if (description) { 269 | ctx.log(`description:`); 270 | ctx.log(`${description}`); 271 | } 272 | if (contact) { 273 | if (contact.email && contact.name) { 274 | ctx.log(`contact: ${contact.name} <${contact.email}>`); 275 | } else if (contact.name) { 276 | ctx.log(`contact: ${contact.name}`); 277 | } else if (contact.email) { 278 | ctx.log(`contact: ${contact.email}`); 279 | } 280 | if (contact.url) { 281 | ctx.log(`website: ${contact.url}`); 282 | } 283 | } 284 | } 285 | if (externalDocs) { 286 | ctx.log(`docs: ${externalDocs.url}`); 287 | } 288 | } 289 | 290 | export function getOperations(document: SwaggerParser.Document) { 291 | const operations = []; 292 | for (const path in document.paths) { 293 | if (document.paths[path]) { 294 | for (const method in document.paths[path]) { 295 | if (document.paths[path][method]) { 296 | operations.push(document.paths[path][method]); 297 | } 298 | } 299 | } 300 | } 301 | return uniqBy(operations, 'operationId'); 302 | } 303 | 304 | export function printOperations(document: SwaggerParser.Document, ctx: Command) { 305 | const operations: { [tag: string]: { routes: string[]; description?: string } } = {}; 306 | 307 | if (document.tags) { 308 | for (const tag of document.tags) { 309 | const { name, description } = tag; 310 | operations[name] = { 311 | description, 312 | routes: [], 313 | }; 314 | } 315 | } 316 | 317 | for (const path in document.paths) { 318 | if (document.paths[path]) { 319 | for (const method in document.paths[path]) { 320 | if (document.paths[path][method]) { 321 | const { operationId, summary, description, tags } = document.paths[path][method]; 322 | let route = `${method.toUpperCase()} ${path}`; 323 | if (summary) { 324 | route = `${route} - ${summary}`; 325 | } else if (description) { 326 | route = `${route} - ${description}`; 327 | } 328 | if (operationId) { 329 | route = `${route} (${operationId})`; 330 | } 331 | for (const tag of tags || ['default']) { 332 | if (!operations[tag]) { 333 | operations[tag] = { routes: [] }; 334 | } 335 | operations[tag].routes.push(route); 336 | } 337 | } 338 | } 339 | } 340 | } 341 | 342 | ctx.log('operations:'); 343 | for (const tag in operations) { 344 | if (operations[tag]) { 345 | const routes = operations[tag].routes; 346 | for (const route of routes) { 347 | ctx.log(`- ${route}`); 348 | } 349 | } 350 | } 351 | } 352 | 353 | export function printSchemas(document: SwaggerParser.Document, ctx: Command) { 354 | const schemas = (document.components && document.components.schemas) || {}; 355 | const count = Object.entries(schemas).length; 356 | if (count > 0) { 357 | ctx.log(`schemas (${count}):`); 358 | for (const schema in schemas) { 359 | if (schemas[schema]) { 360 | ctx.log(`- ${schema}`); 361 | } 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/common/flags.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { BooleanFlag } from '@oclif/core/lib/interfaces'; 3 | 4 | export const help = (overrides: Partial> = {}) => ({ 5 | help: Flags.help({ char: 'h', ...overrides }), 6 | }); 7 | 8 | export const interactive = () => ({ 9 | interactive: Flags.boolean({ 10 | description: '[default: true] enable CLI interactive mode', 11 | default: true, 12 | allowNo: true, 13 | }), 14 | }) 15 | 16 | export const servers = () => ({ 17 | server: Flags.string({ 18 | char: 'S', 19 | description: 'override servers definition', 20 | helpValue: 'http://localhost:9000', 21 | multiple: true, 22 | }), 23 | }); 24 | 25 | export const inject = () => ({ 26 | inject: Flags.string({ 27 | char: 'I', 28 | description: 'inject JSON to definition with deep merge', 29 | helpValue: '{"info":{"version":"1.0.0"}}', 30 | multiple: true, 31 | }), 32 | }); 33 | 34 | export const excludeExt = () => ({ 35 | 'exclude-ext': Flags.string({ 36 | char: 'E', 37 | description: 'Specify an openapi extension to exclude parts of the spec', 38 | helpValue: 'x-internal', 39 | multiple: false, 40 | }), 41 | }); 42 | 43 | export const removeUnreferencedComponents = () => ({ 44 | 'remove-unreferenced': Flags.boolean({ 45 | char: 'U', 46 | description: 'Remove unreferenced components, you can skip individual component being removed by setting x-openapicmd-keep to true', 47 | default: false, 48 | allowNo: true, 49 | }), 50 | }); 51 | 52 | export const strip = () => ({ 53 | strip: Flags.string({ 54 | char: 'C', 55 | description: 'Strip optional metadata such as examples and descriptions from definition', 56 | helpValue: 'default|all|openapi_client_axios|openapi_backend', 57 | }), 58 | }); 59 | 60 | export const validate = () => ({ 61 | validate: Flags.boolean({ char: 'V', description: 'validate against openapi schema' }), 62 | }); 63 | 64 | export const header = () => ({ 65 | header: Flags.string({ char: 'H', description: 'add request headers when calling remote urls', multiple: true }), 66 | }); 67 | 68 | export const apiRoot = () => ({ 69 | root: Flags.string({ char: 'R', description: 'override API root path', helpValue: '/' }), 70 | }); 71 | 72 | export const parseOpts = () => ({ 73 | dereference: Flags.boolean({ char: 'D', description: 'resolve $ref pointers' }), 74 | bundle: Flags.boolean({ char: 'B', description: 'resolve remote $ref pointers' }), 75 | ...apiRoot(), 76 | ...header(), 77 | ...validate(), 78 | ...servers(), 79 | ...inject(), 80 | ...excludeExt(), 81 | ...strip(), 82 | ...removeUnreferencedComponents(), 83 | }); 84 | 85 | export const serverOpts = () => ({ 86 | port: Flags.integer({ 87 | char: 'p', 88 | description: 'port', 89 | default: 9000, 90 | helpValue: '9000', 91 | }), 92 | logger: Flags.boolean({ 93 | description: '[default: true] log requests', 94 | default: true, 95 | allowNo: true, 96 | }), 97 | }); 98 | 99 | export const outputFormat = () => ({ 100 | format: Flags.string({ 101 | char: 'f', 102 | description: '[default: yaml] output format', 103 | options: ['json', 'yaml', 'yml'], 104 | exclusive: ['json', 'yaml'], 105 | }), 106 | json: Flags.boolean({ description: 'format as json (short for -f json)', exclusive: ['format', 'yaml'] }), 107 | yaml: Flags.boolean({ description: 'format as yaml (short for -f yaml)', exclusive: ['format', 'json'] }), 108 | }); 109 | 110 | export const swaggerUIOpts = () => ({ 111 | expand: Flags.string({ 112 | description: '[default: list] default expansion setting for the operations and tags', 113 | options: ['full', 'list', 'none'], 114 | }), 115 | operationids: Flags.boolean({ description: '[default: true] display operationIds', default: true, allowNo: true }), 116 | filter: Flags.boolean({ description: '[default: true] enable filtering by tag', default: true, allowNo: true }), 117 | deeplinks: Flags.boolean({ description: '[default: true] allow deep linking', default: true, allowNo: true }), 118 | withcredentials: Flags.boolean({ 119 | description: '[default: true] send cookies in "try it now"', 120 | default: true, 121 | allowNo: true, 122 | }), 123 | requestduration: Flags.boolean({ 124 | description: '[default: true] display request durations in "try it now"', 125 | default: true, 126 | allowNo: true, 127 | }), 128 | }); 129 | 130 | export const securityOpts = () => ({ 131 | security: Flags.string({ char: 's', description: 'use security scheme', multiple: true }), 132 | apikey: Flags.string({ char: 'k', description: 'set api key' }), 133 | token: Flags.string({ char: 't', description: 'set bearer token' }), 134 | username: Flags.string({ char: 'u', description: 'set basic auth username' }), 135 | password: Flags.string({ char: 'P', description: 'set basic auth password' }), 136 | }); 137 | -------------------------------------------------------------------------------- /src/common/koa.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa'; 2 | import * as logger from 'koa-logger'; 3 | import cli from 'cli-ux'; 4 | import * as getPort from 'get-port'; 5 | 6 | interface CreateServerOpts { 7 | logger?: boolean; 8 | } 9 | export function createServer(opts: CreateServerOpts = {}) { 10 | const app = new Koa(); 11 | 12 | // set up logging 13 | if (opts.logger || opts.logger === undefined) { 14 | app.use(logger()); 15 | } 16 | return app; 17 | } 18 | 19 | interface StartServerOpts { 20 | app: Koa; 21 | port: number; 22 | } 23 | export async function startServer(opts: StartServerOpts) { 24 | const port = await getPort({ port: getPort.makeRange(opts.port, opts.port + 1000) }); 25 | if (opts.port !== port) { 26 | if ( 27 | !process.stdin.isTTY || 28 | !(await cli.confirm(`Something else is running on port ${opts.port}. Use another port instead? (y/n)`)) 29 | ) { 30 | process.exit(1); 31 | } 32 | } 33 | const { app } = opts; 34 | const server = app.listen(port); 35 | process.on('disconnect', () => server.close()); 36 | return { server, port }; 37 | } 38 | -------------------------------------------------------------------------------- /src/common/prompt.ts: -------------------------------------------------------------------------------- 1 | import type { QuestionCollection } from 'inquirer' 2 | import type { cli } from 'cli-ux' 3 | import { getContext } from './context' 4 | 5 | export const maybePrompt = async (questions: QuestionCollection, initialAnswers?: Partial): Promise => { 6 | const context = getContext() 7 | 8 | if (context.flags?.interactive !== false) { 9 | const inquirer = await import('inquirer') 10 | return inquirer.prompt(questions, initialAnswers) 11 | } 12 | 13 | // instead return default values without prompting 14 | const defaultValues = {} as T; 15 | 16 | const questionsArray = Array.isArray(questions) ? questions : [questions] 17 | questionsArray.forEach((question) => { 18 | if (question.name && question.default !== undefined) { 19 | defaultValues[question.name] = question.default 20 | } 21 | }) 22 | 23 | return defaultValues; 24 | } 25 | 26 | export const maybeSimplePrompt = async (...args: Parameters) => { 27 | const context = getContext() 28 | 29 | if (context.flags?.interactive !== false) { 30 | const cli = await import('cli-ux') 31 | return cli.cli.prompt(...args) 32 | } 33 | 34 | return args[1]?.default ?? undefined 35 | } -------------------------------------------------------------------------------- /src/common/redoc.ts: -------------------------------------------------------------------------------- 1 | import * as URL from 'url'; 2 | import * as Koa from 'koa'; 3 | import * as Router from 'koa-router'; 4 | import { html } from 'common-tags'; 5 | 6 | const CURRENT_REDOC_VERSION = '2.1.2' 7 | 8 | export interface RedocOpts { 9 | title?: string; 10 | redocVersion?: string; 11 | specUrl?: string; 12 | } 13 | export function serveRedoc(opts: RedocOpts = {}) { 14 | const app = new Koa(); 15 | const router = new Router(); 16 | 17 | const indexHTML = getRedocIndexHTML(opts) 18 | 19 | // serve index.html 20 | router.get('/', (ctx) => { 21 | const url = ctx.originalUrl || ctx.url; 22 | const { pathname, query, hash } = URL.parse(url); 23 | // append trailing slash so relative paths work 24 | if (!pathname.endsWith('/')) { 25 | ctx.status = 302; 26 | return ctx.redirect( 27 | URL.format({ 28 | pathname: `${pathname}/`, 29 | query, 30 | hash, 31 | }), 32 | ); 33 | } 34 | ctx.body = indexHTML; 35 | ctx.status = 200; 36 | }); 37 | 38 | app.use(router.routes()); 39 | 40 | return app; 41 | } 42 | 43 | export function getRedocIndexHTML(opts: RedocOpts = {}) { 44 | return html` 45 | 46 | ${opts.title || 'ReDoc documentation'} 47 | 48 | 49 | 50 | 53 | 59 | 60 | 61 | 62 | 63 | 64 | ` 65 | } 66 | -------------------------------------------------------------------------------- /src/common/security.ts: -------------------------------------------------------------------------------- 1 | import d from 'debug'; 2 | import * as deepMerge from 'deepmerge'; 3 | import { OpenAPIV3, Operation } from 'openapi-client-axios'; 4 | import { getConfigValue } from './config'; 5 | import { Document } from '@apidevtools/swagger-parser'; 6 | import { parseHeaderFlag } from './utils'; 7 | import { maybePrompt } from './prompt'; 8 | 9 | const debug = d('cmd'); 10 | 11 | export interface SecurityConfig { 12 | [securityScheme: string]: RequestSecurityConfig; 13 | } 14 | 15 | export interface RequestSecurityConfig { 16 | header?: { 17 | [header: string]: string; 18 | }; 19 | cookie?: { 20 | [cookie: string]: string; 21 | }; 22 | query?: { 23 | [key: string]: string; 24 | }; 25 | auth?: { 26 | username: string; 27 | password: string; 28 | }; 29 | } 30 | 31 | export const createSecurityRequestConfig = async (params: { 32 | document: Document; 33 | operation?: Operation; 34 | security: string[]; 35 | header: string[]; 36 | token?: string; 37 | apikey?: string; 38 | username?: string; 39 | password?: string; 40 | }): Promise => { 41 | let requestSecurityConfig: RequestSecurityConfig = { 42 | header: {}, 43 | cookie: {}, 44 | query: {}, 45 | }; 46 | 47 | if ( 48 | Object.keys(parseHeaderFlag(params.header)).find((key) => 49 | ['authorization', 'x-api-key', 'x-apikey', 'x-api-secret', 'x-secret'].includes(key.toLowerCase()), 50 | ) 51 | ) { 52 | // if an authorization header is already set, just return that 53 | return requestSecurityConfig; 54 | } 55 | 56 | const securityScheme = await getActiveSecuritySchemes(params); 57 | debug('securityScheme %o', securityScheme); 58 | 59 | // read stored security config 60 | const securityConfig = getConfigValue('security', {}) as SecurityConfig; 61 | debug('securityConfig %o', securityConfig); 62 | 63 | for (const schemeName of securityScheme) { 64 | const stored = securityConfig[schemeName]; 65 | if (stored) { 66 | // apply stored config 67 | requestSecurityConfig.header = { ...requestSecurityConfig.header, ...stored.header }; 68 | requestSecurityConfig.cookie = { ...requestSecurityConfig.cookie, ...stored.cookie }; 69 | requestSecurityConfig.query = { ...requestSecurityConfig.query, ...stored.query }; 70 | if (stored.auth) { 71 | requestSecurityConfig.auth = stored.auth; 72 | } 73 | } else { 74 | const schemeDefinition = params.document.components.securitySchemes[schemeName] as OpenAPIV3.SecuritySchemeObject; 75 | 76 | // create new config 77 | requestSecurityConfig = deepMerge( 78 | requestSecurityConfig, 79 | await createSecurityRequestConfigForScheme({ 80 | schemeName, 81 | schemeDefinition, 82 | token: params.token, 83 | apikey: params.apikey, 84 | username: params.apikey, 85 | password: params.password, 86 | }), 87 | ); 88 | } 89 | } 90 | 91 | return applyFlagOverrides({ requestSecurityConfig, ...params }); 92 | }; 93 | 94 | export const getActiveSecuritySchemes = async (params: { 95 | document: Document; 96 | operation?: Operation; 97 | security: string[]; 98 | header: string[]; 99 | token?: string; 100 | apikey?: string; 101 | username?: string; 102 | password?: string; 103 | noInteractive?: boolean 104 | }) => { 105 | // choose security scheme 106 | const availableSecuritySchemes = getAvailableSecuritySchemes(params.document, params.operation); 107 | debug('availableSecuritySchemes %o', availableSecuritySchemes); 108 | 109 | const securitySchemes = new Set(); 110 | params.security?.forEach?.((scheme) => securitySchemes.add(scheme)); 111 | 112 | if (!securitySchemes.size && availableSecuritySchemes.length === 1) { 113 | securitySchemes.add(availableSecuritySchemes[0].name); 114 | } 115 | 116 | // infer basic scheme if username + password is set 117 | if (params.username && params.password) { 118 | const basicScheme = availableSecuritySchemes.find( 119 | (s) => s.schemeDefinition?.type === 'http' && s.schemeDefinition?.scheme === 'basic', 120 | ); 121 | if (basicScheme) { 122 | securitySchemes.add(basicScheme.name); 123 | } 124 | } 125 | 126 | // infer apikey scheme if apikey is set 127 | if (params.apikey) { 128 | const apikeyScheme = availableSecuritySchemes.find((s) => s.schemeDefinition?.type === 'apiKey'); 129 | if (apikeyScheme) { 130 | securitySchemes.add(apikeyScheme.name); 131 | } 132 | } 133 | 134 | // infer bearer scheme if token is set 135 | if (params.token) { 136 | const bearerScheme = availableSecuritySchemes.find( 137 | (s) => s.schemeDefinition?.type === 'http' && s.schemeDefinition?.scheme === 'bearer', 138 | ); 139 | if (bearerScheme) { 140 | securitySchemes.add(bearerScheme.name); 141 | } 142 | } 143 | 144 | // prompt security scheme choice unless it's obvious 145 | if (securitySchemes.has('PROMPT') || (securitySchemes.size !== 1 && availableSecuritySchemes.length > 1)) { 146 | const explicitSecurityScheme = ( 147 | await maybePrompt({ 148 | name: 'securityScheme', 149 | message: 'use security scheme', 150 | type: 'checkbox', 151 | choices: availableSecuritySchemes.map((s, idx) => ({ 152 | name: [s.name, s.schemeDefinition?.['description']].filter(Boolean).join(': '), 153 | value: s.name, 154 | checked: idx === 0, 155 | })), 156 | }) 157 | ).securityScheme; 158 | 159 | if (explicitSecurityScheme) { 160 | return explicitSecurityScheme; 161 | } 162 | } 163 | 164 | return [...securitySchemes]; 165 | }; 166 | 167 | export const createSecurityRequestConfigForScheme = async (params: { 168 | schemeName: string; 169 | schemeDefinition: OpenAPIV3.SecuritySchemeObject; 170 | token?: string; 171 | apikey?: string; 172 | username?: string; 173 | password?: string; 174 | noInteractive?: boolean 175 | }): Promise => { 176 | let requestSecurityConfig: RequestSecurityConfig = {}; 177 | 178 | // prompt for api key 179 | if (params.schemeDefinition?.type === 'apiKey') { 180 | const apiKey = 181 | params.apikey ?? 182 | params.token ?? 183 | ( 184 | await maybePrompt({ 185 | name: 'key', 186 | message: `${params.schemeName}: Set API key (${params.schemeDefinition.name})`, 187 | type: 'input', 188 | }) 189 | )?.['key']; 190 | 191 | requestSecurityConfig = { 192 | [params.schemeDefinition.in]: { 193 | [params.schemeDefinition.name]: apiKey, 194 | }, 195 | }; 196 | } 197 | 198 | // prompt for bearer token 199 | if (params.schemeDefinition?.type === 'http' && params.schemeDefinition?.scheme === 'bearer') { 200 | const token = 201 | params.token ?? 202 | ( 203 | await maybePrompt({ 204 | name: 'token', 205 | message: `${params.schemeName}: Set auth token`, 206 | type: 'input', 207 | }) 208 | )?.['token']; 209 | 210 | requestSecurityConfig = { 211 | header: { 212 | Authorization: `Bearer ${token}`, 213 | }, 214 | }; 215 | } 216 | 217 | // prompt for basic auth credentials 218 | if (params.schemeDefinition?.type === 'http' && params.schemeDefinition?.scheme === 'basic') { 219 | const username = 220 | params.username ?? 221 | ( 222 | await maybePrompt({ 223 | name: 'username', 224 | message: `${params.schemeName}: username`, 225 | type: 'input', 226 | }) 227 | )?.['username']; 228 | const password = 229 | params.password ?? 230 | ( 231 | await maybePrompt({ 232 | name: 'password', 233 | message: `${params.schemeName}: password`, 234 | type: 'password', 235 | }) 236 | ) ?.['password']; 237 | 238 | requestSecurityConfig = { 239 | auth: { username, password }, 240 | }; 241 | } 242 | 243 | return applyFlagOverrides({ requestSecurityConfig, ...params }); 244 | }; 245 | 246 | export const applyFlagOverrides = (params: { 247 | requestSecurityConfig: RequestSecurityConfig; 248 | token?: string; 249 | apikey?: string; 250 | username?: string; 251 | password?: string; 252 | }) => { 253 | const { requestSecurityConfig } = params; 254 | 255 | // apply flag overrides 256 | if (params.username) { 257 | requestSecurityConfig.auth = { ...requestSecurityConfig.auth, username: params.username }; 258 | } 259 | if (params.password) { 260 | requestSecurityConfig.auth = { ...requestSecurityConfig.auth, password: params.password }; 261 | } 262 | if (params.token) { 263 | requestSecurityConfig.header = { ...requestSecurityConfig.header, Authorization: `Bearer ${params.token}` }; 264 | } 265 | 266 | return requestSecurityConfig; 267 | }; 268 | 269 | export const getAvailableSecuritySchemes = (document: Document, operation: Operation) => { 270 | if (operation) { 271 | const availableSecuritySchemeNames = new Set(); 272 | for (const requirementObject of operation.security ?? []) { 273 | const securitySchemes = Object.keys(requirementObject); 274 | securitySchemes?.forEach((scheme) => availableSecuritySchemeNames.add(scheme)); 275 | } 276 | 277 | return [...availableSecuritySchemeNames].map((name) => ({ 278 | name, 279 | schemeDefinition: document.components?.securitySchemes?.[name] as OpenAPIV3.SecuritySchemeObject, 280 | })); 281 | } else { 282 | return Object.keys(document.components?.securitySchemes ?? {}).map((name) => ({ 283 | name, 284 | schemeDefinition: document.components?.securitySchemes?.[name] as OpenAPIV3.SecuritySchemeObject, 285 | })); 286 | } 287 | }; 288 | -------------------------------------------------------------------------------- /src/common/strip-definition-presets.test.ts: -------------------------------------------------------------------------------- 1 | import { PRESETS, stripDefinition } from './strip-definition'; 2 | import * as testFixtures from '../__tests__/test-fixtures'; 3 | 4 | describe('presets', () => { 5 | it.each([ 6 | ['default', PRESETS.default,{ 7 | "components": { 8 | "schemas": { 9 | "Response": { 10 | "type": "object" 11 | } 12 | } 13 | }, 14 | "info": { 15 | "title": "", 16 | "version": "" 17 | }, 18 | "openapi": "3.0.3", 19 | "paths": { 20 | "/path1": { 21 | "post": { 22 | "operationId": "operationId", 23 | "responses": { 24 | "201": { 25 | "content": { 26 | "application/json": { 27 | "schema": { 28 | "type": "object" 29 | } 30 | } 31 | }, 32 | "description": "" 33 | }, 34 | "400": { 35 | "description": "" 36 | } 37 | } 38 | } 39 | }, 40 | "/path2": { 41 | "get": { 42 | "operationId": "operationId", 43 | "responses": { 44 | "200": { 45 | "content": { 46 | "application/json": { 47 | "schema": { 48 | "$ref": "#/components/schemas/Response" 49 | } 50 | } 51 | }, 52 | "description": "" 53 | }, 54 | "400": { 55 | "description": "" 56 | } 57 | } 58 | } 59 | } 60 | }, 61 | "servers": [ 62 | { 63 | "url": "/test1" 64 | }, 65 | { 66 | "url": "/test2" 67 | }, 68 | { 69 | "url": "/test3" 70 | } 71 | ] 72 | }], 73 | ['all', PRESETS.all,{ 74 | "components": {}, 75 | "info": { 76 | "title": "", 77 | "version": "" 78 | }, 79 | "openapi": "3.0.3", 80 | "paths": { 81 | "/path1": { 82 | "post": { 83 | "operationId": "operationId", 84 | "responses": {} 85 | } 86 | }, 87 | "/path2": { 88 | "get": { 89 | "operationId": "operationId", 90 | "responses": {} 91 | } 92 | } 93 | } 94 | }], 95 | ['openapi_client_axios', PRESETS.openapi_client_axios,{ 96 | "components": {}, 97 | "info": { 98 | "title": "", 99 | "version": "" 100 | }, 101 | "openapi": "3.0.3", 102 | "paths": { 103 | "/path1": { 104 | "post": { 105 | "operationId": "operationId", 106 | "responses": {} 107 | } 108 | }, 109 | "/path2": { 110 | "get": { 111 | "operationId": "operationId", 112 | "responses": {} 113 | } 114 | } 115 | }, 116 | "servers": [{ 117 | "url": "/test1" 118 | }] 119 | }], 120 | ['openapi_backend', PRESETS.openapi_backend,{ 121 | "components": { 122 | "schemas": { 123 | "Response": { 124 | "type": "object" 125 | } 126 | } 127 | }, 128 | "info": { 129 | "title": "", 130 | "version": "" 131 | }, 132 | "openapi": "3.0.3", 133 | "paths": { 134 | "/path1": { 135 | "post": { 136 | "operationId": "operationId", 137 | "responses": { 138 | "201": { 139 | "content": { 140 | "application/json": { 141 | "schema": { 142 | "type": "object" 143 | } 144 | } 145 | }, 146 | "description": "" 147 | }, 148 | "400": { 149 | "description": "" 150 | } 151 | } 152 | } 153 | }, 154 | "/path2": { 155 | "get": { 156 | "operationId": "operationId", 157 | "responses": { 158 | "200": { 159 | "content": { 160 | "application/json": { 161 | "schema": { 162 | "$ref": "#/components/schemas/Response" 163 | } 164 | } 165 | }, 166 | "description": "" 167 | }, 168 | "400": { 169 | "description": "" 170 | } 171 | } 172 | } 173 | } 174 | } 175 | }] 176 | ]) ('should strip for %s preset', (label, preset, expected) => { 177 | // given 178 | const document = testFixtures.createDefinition({ 179 | openapi: '3.0.3', 180 | info: { 181 | title: 'title', 182 | description: 'description', 183 | version: '1.0.0', 184 | contact: { 185 | name: 'test', 186 | email: 'test@example.com', 187 | } 188 | }, 189 | servers: [ 190 | { url: '/test1', description: 'description' }, 191 | { url: '/test2', description: 'description' }, 192 | { url: '/test3' } 193 | ], 194 | paths: { 195 | '/path1': { 196 | description: 'description', 197 | post: testFixtures.createOperation({ 198 | responses: { 199 | '201': { 200 | description: 'Created', 201 | content: { 202 | 'application/json': { 203 | schema: { 204 | type: 'object', 205 | }, 206 | }, 207 | }, 208 | }, 209 | '400': { 210 | description: 'Bad Request', 211 | }, 212 | }, 213 | }), 214 | }, 215 | '/path2': { 216 | get: testFixtures.createOperation({ 217 | responses: { 218 | '200': { 219 | description: 'Created', 220 | content: { 221 | 'application/json': { 222 | schema: { 223 | $ref: '#/components/schemas/Response', 224 | }, 225 | }, 226 | }, 227 | }, 228 | '400': { 229 | description: 'Bad Request', 230 | }, 231 | }, 232 | }), 233 | }, 234 | }, 235 | components: { 236 | schemas: { 237 | Response: { 238 | type: 'object', 239 | } 240 | } 241 | } 242 | }) 243 | 244 | // when 245 | const output = stripDefinition(document, preset); 246 | 247 | // then 248 | expect(output).toEqual(expected) 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /src/common/strip-definition.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { klona } from 'klona'; 3 | import { Definition } from '../types/types'; 4 | 5 | interface StripOptions { 6 | /** 7 | * Replace info with required fields only 8 | * @default true 9 | */ 10 | replaceInfo?: boolean; 11 | /** 12 | * Remove tags from document 13 | * @default true 14 | */ 15 | removeTags?: boolean; 16 | /** 17 | * Remove descriptions and summaries from document 18 | * @default true 19 | */ 20 | removeDescriptions?: boolean; 21 | /** 22 | * Remove examples from document 23 | * @default true 24 | */ 25 | removeExamples?: boolean; 26 | /** 27 | * Remove all openapi extensions (x-) from document 28 | * @default true 29 | */ 30 | removeExtensions?: boolean; 31 | /** 32 | * Remove readOnly from document 33 | * @default true 34 | */ 35 | removeReadOnly?: boolean; 36 | /** 37 | * Remove all schemas from document 38 | * @default false 39 | */ 40 | removeSchemas?: boolean; 41 | /** 42 | * Remove all security schemes from document 43 | * @default false 44 | */ 45 | removeSecuritySchemes?: boolean; 46 | /** 47 | * Remove servers from document 48 | * @default false 49 | */ 50 | removeServers?: boolean; 51 | /** 52 | * Only include first server from servers array 53 | * @default false 54 | */ 55 | firstServerOnly?: boolean; 56 | /** 57 | * Replace responses with minimal valid default response 58 | * @default false 59 | */ 60 | replaceResponses?: boolean; 61 | /** 62 | * Remove responses entirely (warning: this will break validation) 63 | * @default false 64 | */ 65 | removeResponses?: boolean; 66 | } 67 | 68 | const ALL: StripOptions = { 69 | replaceInfo: true, 70 | removeTags: true, 71 | removeDescriptions: true, 72 | removeExamples: true, 73 | removeExtensions: true, 74 | removeReadOnly: true, 75 | removeSchemas: true, 76 | removeSecuritySchemes: true, 77 | removeServers: true, 78 | replaceResponses: true, 79 | removeResponses: true, 80 | } 81 | 82 | const METADATA_ONLY: StripOptions = { 83 | replaceInfo: true, 84 | removeTags: true, 85 | removeDescriptions: true, 86 | removeExamples: true, 87 | removeExtensions: true, 88 | removeReadOnly: false, 89 | removeSchemas: false, 90 | removeSecuritySchemes: false, 91 | removeServers: false, 92 | replaceResponses: false, 93 | removeResponses: false, 94 | } 95 | 96 | export const PRESETS = { 97 | all: ALL, 98 | openapi_client_axios: { 99 | ...ALL, 100 | removeServers: false, // openapi-client-axios uses servers 101 | firstServerOnly: true, // openapi-client-axios only uses first server 102 | }, 103 | openapi_backend: { 104 | ...METADATA_ONLY, 105 | removeExamples: false, // openapi-backend uses examples for mock responses 106 | removeServers: true, // openapi-backend does not use servers 107 | }, 108 | default: METADATA_ONLY, 109 | } 110 | 111 | export type StripPreset = keyof typeof PRESETS; 112 | 113 | /** 114 | * Strips optional metadata from definition 115 | */ 116 | export const stripDefinition = (document: Definition, options: StripOptions & { preset?: StripPreset } = {}): Definition => { 117 | const output = klona(document) 118 | 119 | const opts = { ...PRESETS[options.preset ?? 'default'], ...options } 120 | 121 | // replace info to required fields only 122 | if (opts.replaceInfo) { 123 | output.info = { 124 | title: '', 125 | version: '' 126 | } 127 | } 128 | 129 | // remove tags 130 | if (opts.removeTags) { 131 | // remove tags from root 132 | delete output.tags 133 | 134 | // remove tags from operations 135 | for (const path in output.paths) { 136 | if (output.paths[path]) { 137 | // path level tags 138 | delete output.paths[path]['tags'] 139 | for (const method in output.paths[path]) { 140 | if (output.paths[path][method]) { 141 | delete output.paths[path][method].tags 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | // remove schemas 149 | if (opts.removeSchemas) { 150 | // remove components.schemas 151 | if (output.components?.schemas) { 152 | delete output.components.schemas 153 | } 154 | 155 | // recursively remove schemas 156 | const removeSchemas = (obj: any) => { 157 | if (typeof obj !== 'object') return 158 | 159 | for (const key in obj) { 160 | if (key === 'schema') { 161 | delete obj[key] 162 | } else { 163 | removeSchemas(obj[key]) 164 | } 165 | } 166 | } 167 | 168 | // remove schemas from operations 169 | removeSchemas(output.paths) 170 | 171 | // remove schemas from requestBodies 172 | if (output.components?.requestBodies) { 173 | removeSchemas(output.components.requestBodies) 174 | } 175 | 176 | // remove schemas from requestBodies 177 | if (output.components?.responses) { 178 | removeSchemas(output.components.responses) 179 | } 180 | 181 | // remove schemas from parameters 182 | if (output.components?.parameters) { 183 | removeSchemas(output.components.parameters) 184 | } 185 | } 186 | 187 | // remove security schemes 188 | if (opts.removeSecuritySchemes) { 189 | // remove components.securitySchemes 190 | if (output.components?.securitySchemes) { 191 | delete output.components.securitySchemes 192 | } 193 | 194 | // remove security from root 195 | delete output.security; 196 | 197 | // remove security from paths 198 | for (const path in output.paths) { 199 | if (output.paths[path]) { 200 | // path level security 201 | delete output.paths[path]['security'] 202 | for (const method in output.paths[path]) { 203 | if (output.paths[path][method]) { 204 | // operation level security 205 | delete output.paths[path][method].security 206 | } 207 | } 208 | } 209 | } 210 | } 211 | 212 | // remove servers 213 | if (opts.removeServers) { 214 | // remove servers from root 215 | delete output.servers; 216 | 217 | // remove servers from paths 218 | for (const path in output.paths) { 219 | if (output.paths[path]) { 220 | // path level servers 221 | delete output.paths[path].servers 222 | for (const method in output.paths[path]) { 223 | if (output.paths[path][method]) { 224 | // operation level servers 225 | delete output.paths[path][method].servers 226 | } 227 | } 228 | } 229 | } 230 | } 231 | 232 | // only keep first server 233 | if (opts.firstServerOnly && Array.isArray(output.servers) && output.servers.length > 1) { 234 | output.servers = [output.servers[0]] 235 | } 236 | 237 | // replace responses with minimal default response 238 | if (opts.replaceResponses) { 239 | for (const path in output.paths) { 240 | if (output.paths[path]) { 241 | for (const method in output.paths[path]) { 242 | if (output.paths[path][method]) { 243 | if (output.paths[path][method].responses) { 244 | output.paths[path][method].responses = { 245 | '2XX': { // prevents breaking validation 246 | description: '', 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | 256 | // remove responses completely 257 | if (opts.removeResponses) { 258 | for (const path in output.paths) { 259 | if (output.paths[path]) { 260 | for (const method in output.paths[path]) { 261 | if (output.paths[path][method]) { 262 | if(output.paths[path][method].responses) { 263 | output.paths[path][method].responses = {} 264 | } 265 | } 266 | } 267 | } 268 | } 269 | } 270 | 271 | // remove all descriptions and summaries 272 | if (opts.removeDescriptions) { 273 | // recursively remove nested description fields 274 | const removeDescriptions = (obj: any) => { 275 | if (typeof obj !== 'object') return 276 | 277 | for (const key in obj) { 278 | if (key === 'description' && typeof obj[key] === 'string') { 279 | delete obj[key] 280 | } else if (typeof obj[key] === 'object') { 281 | removeDescriptions(obj[key]) 282 | } 283 | } 284 | } 285 | 286 | // remove descriptions from info 287 | delete output.info.description 288 | 289 | // remove descriptions and summaries from operations 290 | for (const path in output.paths) { 291 | if (output.paths[path]) { 292 | delete output.paths[path].description 293 | delete output.paths[path].summary 294 | 295 | // remove descriptions from path level servers 296 | if (output.paths[path].servers) { 297 | removeDescriptions(output.paths[path].servers) 298 | } 299 | 300 | for (const method in output.paths[path]) { 301 | if (output.paths[path][method]) { 302 | // operation summary and description 303 | delete output.paths[path][method].summary 304 | delete output.paths[path][method].description 305 | 306 | // remove descriptions from parameters 307 | if (output.paths[path][method].parameters) { 308 | removeDescriptions(output.paths[path][method].parameters) 309 | } 310 | 311 | // truncate descriptions from responses 312 | if (output.paths[path][method].responses) { 313 | for (const response in output.paths[path][method].responses) { 314 | if (output.paths[path][method].responses[response]) { 315 | output.paths[path][method].responses[response].description = '' 316 | // remove descriptions from content 317 | removeDescriptions(output.paths[path][method].responses[response].content) 318 | } 319 | } 320 | } 321 | 322 | // remove descriptions from request bodies 323 | if (output.paths[path][method].requestBody) { 324 | removeDescriptions(output.paths[path][method].requestBody) 325 | } 326 | 327 | // remove descriptions from operation level servers 328 | if (output.paths[path][method].servers) { 329 | removeDescriptions(output.paths[path][method].servers) 330 | } 331 | } 332 | } 333 | } 334 | } 335 | 336 | // remove all description fields from components 337 | if (output.components) { 338 | removeDescriptions(output.components) 339 | } 340 | 341 | // remove description fields from servers 342 | if (output.servers) { 343 | removeDescriptions(output.servers) 344 | } 345 | } 346 | 347 | // remove all examples 348 | if (opts.removeExamples) { 349 | // recursively remove nested example fields 350 | const removeExamples = (obj: any) => { 351 | if (typeof obj !== 'object') return 352 | 353 | for (const key in obj) { 354 | if (['example', 'examples', 'x-example', 'x-examples'].includes(key)) { 355 | delete obj[key] 356 | } else if (typeof obj[key] === 'object') { 357 | removeExamples(obj[key]) 358 | } 359 | } 360 | } 361 | 362 | // remove examples from operations 363 | removeExamples(output.paths) 364 | 365 | // remove examples from components 366 | if (output.components) { 367 | removeExamples(output.components) 368 | } 369 | } 370 | 371 | // remove all openapi extensions 372 | if (opts.removeExtensions) { 373 | // recursively remove nested x- fields 374 | const removeExtensions = (obj: any) => { 375 | if (typeof obj !== 'object') return 376 | 377 | for (const key in obj) { 378 | if (key.startsWith('x-')) { 379 | delete obj[key] 380 | } else if (typeof obj[key] === 'object') { 381 | removeExtensions(obj[key]) 382 | } 383 | } 384 | } 385 | 386 | // remove extensions form the whole document 387 | removeExtensions(output) 388 | } 389 | 390 | // remove readOnly properties from document 391 | if (opts.removeReadOnly) { 392 | // recursively remove readOnly fields 393 | const removeReadOnly = (obj: any) => { 394 | if (typeof obj !== 'object') return 395 | 396 | for (const key in obj) { 397 | if (key === 'readOnly' && typeof obj[key] === 'boolean') { 398 | delete obj[key] 399 | } else if (typeof obj[key] === 'object') { 400 | removeReadOnly(obj[key]) 401 | } 402 | } 403 | } 404 | 405 | // remove readOnly from operations 406 | for (const path in output.paths) { 407 | if (output.paths[path]) { 408 | for (const method in output.paths[path]) { 409 | if (output.paths[path][method]) { 410 | removeReadOnly(output.paths[path][method]) 411 | } 412 | } 413 | } 414 | } 415 | 416 | // remove readOnly from components 417 | if (output.components) { 418 | removeReadOnly(output.components) 419 | } 420 | } 421 | 422 | 423 | return output 424 | } 425 | -------------------------------------------------------------------------------- /src/common/swagger-ui.ts: -------------------------------------------------------------------------------- 1 | import * as URL from 'url'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as Koa from 'koa'; 5 | import * as Router from 'koa-router'; 6 | import * as serve from 'koa-static'; 7 | import * as SwaggerUIDist from 'swagger-ui-dist'; 8 | import { Document } from '@apidevtools/swagger-parser'; 9 | import { html } from 'common-tags'; 10 | 11 | export const swaggerUIRoot = SwaggerUIDist.getAbsoluteFSPath(); 12 | 13 | export enum DocExpansion { 14 | Full = 'full', // expand averything 15 | List = 'list', // expand only only tags 16 | None = 'none', // expand nothing 17 | } 18 | 19 | export interface SwaggerUIOpts { 20 | url?: string; // remote URL 21 | spec?: Document; // use a definition object instead of URL 22 | deepLinking?: boolean; // allow deep linking 23 | docExpansion?: DocExpansion; // default expansion setting for the operations and tags 24 | displayOperationId?: boolean; // display operationIds 25 | displayRequestDuration?: boolean; // display request durations in "try it out" 26 | showExtensions?: boolean; // display extensions 27 | showCommonExtensions?: boolean; // display common extensions 28 | withCredentials?: boolean; // send cookies with requests 29 | filter?: boolean | string; // enable filtering by tag 30 | layout?: string; // which layout to use (need to register plugins for this) 31 | } 32 | export function serveSwaggerUI(opts: SwaggerUIOpts = {}) { 33 | const app = new Koa(); 34 | const router = new Router(); 35 | 36 | const indexHTML = getSwaggerUIIndexHTML(); 37 | const initializerScript = getSwaggerUIInitializerScript(opts); 38 | 39 | // serve index.html 40 | router.get('/', (ctx) => { 41 | const url = ctx.originalUrl || ctx.url; 42 | const { pathname, query, hash } = URL.parse(url); 43 | // append trailing slash so relative paths work 44 | if (!pathname.endsWith('/')) { 45 | ctx.status = 302; 46 | return ctx.redirect( 47 | URL.format({ 48 | pathname: `${pathname}/`, 49 | query, 50 | hash, 51 | }), 52 | ); 53 | } 54 | ctx.body = indexHTML; 55 | ctx.status = 200; 56 | }); 57 | 58 | // serve swagger-initializer.js 59 | router.get('/swagger-initializer.js', (ctx) => { 60 | ctx.body = initializerScript; 61 | ctx.status = 200; 62 | }) 63 | 64 | app.use(router.routes()); 65 | app.use(serve(swaggerUIRoot)); 66 | 67 | return app; 68 | } 69 | 70 | export function getSwaggerUIIndexHTML() { 71 | return fs 72 | .readFileSync(path.join(swaggerUIRoot, 'index.html')) 73 | .toString('utf8'); 74 | } 75 | 76 | export function getSwaggerUIInitializerScript(opts: SwaggerUIOpts = {}) { 77 | const config: SwaggerUIOpts = { 78 | layout: 'StandaloneLayout', 79 | deepLinking: true, 80 | displayOperationId: true, 81 | displayRequestDuration: true, 82 | showExtensions: true, 83 | showCommonExtensions: true, 84 | withCredentials: true, 85 | filter: true, 86 | ...opts, 87 | }; 88 | 89 | return html` 90 | const config = JSON.parse(\`${JSON.stringify(config)}\`); 91 | 92 | window.onload = function() { 93 | // 94 | 95 | // the following lines will be replaced by docker/configurator, when it runs in a docker-container 96 | window.ui = SwaggerUIBundle({ 97 | url: "https://petstore.swagger.io/v2/swagger.json", 98 | dom_id: '#swagger-ui', 99 | deepLinking: true, 100 | presets: [ 101 | SwaggerUIBundle.presets.apis, 102 | SwaggerUIStandalonePreset 103 | ], 104 | plugins: [ 105 | SwaggerUIBundle.plugins.DownloadUrl 106 | ], 107 | layout: "StandaloneLayout", 108 | ...config 109 | }); 110 | 111 | // 112 | };` 113 | } 114 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | export const escapeStringTemplateTicks = (str: string) => str.replace(/`/g, `\\\``); // lgtm [js/incomplete-sanitization] 2 | 3 | export const parseHeaderFlag = (headerFlag: string[]) => { 4 | const headers = {}; 5 | for (const header of headerFlag || []) { 6 | const [name, value] = header.split(':'); 7 | headers[name.trim()] = value.trim(); 8 | } 9 | return headers; 10 | }; 11 | 12 | export const isValidJson = (jsonString: string) => { 13 | try { 14 | JSON.parse(jsonString) 15 | return true 16 | } catch { 17 | return false 18 | } 19 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/core'; 2 | -------------------------------------------------------------------------------- /src/tests/jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = __filename.endsWith('.ts') ? { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['/run-jest.ts'], 5 | testTimeout: 30000, 6 | } : { 7 | testEnvironment: 'node', 8 | testMatch: ['/run-jest.js'], 9 | testPathIgnorePatterns: [], 10 | testTimeout: 30000, 11 | } -------------------------------------------------------------------------------- /src/tests/run-jest.ts: -------------------------------------------------------------------------------- 1 | import OpenAPIClientAxios, { AxiosRequestConfig, AxiosResponse, OpenAPIClient, Operation } from "openapi-client-axios"; 2 | import * as SwaggerParser from "@apidevtools/swagger-parser"; 3 | import { matchers as jsonSchemaMatchers } from 'jest-json-schema'; 4 | 5 | import { getConfigValue } from "../common/config"; 6 | import { TestCheck, TestConfig } from "./tests"; 7 | import { createSecurityRequestConfig } from '../common/security'; 8 | import { parseHeaderFlag } from '../common/utils'; 9 | import { getContext } from "../common/context"; 10 | import d from 'debug'; 11 | import chalk = require("chalk"); 12 | 13 | const debug = d('cmd'); 14 | 15 | expect.extend(jsonSchemaMatchers); 16 | 17 | const context = getContext() 18 | let api: OpenAPIClientAxios 19 | 20 | beforeAll(async () => { 21 | const definition = await SwaggerParser.dereference(context.document); 22 | api = new OpenAPIClientAxios({ definition }); 23 | await api.init() 24 | }); 25 | 26 | const testConfig: TestConfig = getConfigValue('tests'); 27 | for (const operationId of Object.keys(testConfig)) { 28 | describe(operationId, () => { 29 | for (const testName of Object.keys(testConfig[operationId])) { 30 | describe(testName, () => { 31 | const testDefinition = testConfig[operationId][testName]; 32 | 33 | let request: AxiosRequestConfig; 34 | let response: AxiosResponse; 35 | let operation: Operation; 36 | let client: OpenAPIClient; 37 | let failed = false; 38 | 39 | beforeAll(async () => { 40 | operation = api.getOperation(operationId); 41 | client = await getClientForTest({ operationId, requestConfig: testDefinition.request.config }) 42 | request = api.getAxiosConfigForOperation(operation, [testDefinition.request.params, testDefinition.request.data]); 43 | }) 44 | 45 | afterEach(() => { 46 | const currentTest = expect.getState(); 47 | debug('currentTest %o', currentTest) 48 | 49 | if (!failed && currentTest.assertionCalls > currentTest.numPassingAsserts) { 50 | failed = true; 51 | 52 | verboseLog(`${chalk.bgRed(' FAILED ')} ${chalk.bold(operationId)} › ${testName}\n`); 53 | verboseLog(`${chalk.green(request.method.toUpperCase())} ${request.url}`); 54 | verboseLog(request); 55 | 56 | verboseLog(chalk.gray('RESPONSE META:')); 57 | verboseLog({ 58 | code: response.status, 59 | status: response.statusText, 60 | headers: response.headers, 61 | }); 62 | verboseLog(chalk.gray('RESPONSE BODY:')); 63 | verboseLog(response.data || chalk.gray('(empty response)'), '\n'); 64 | } 65 | }) 66 | 67 | test(`request ${operationId}`, async () => { 68 | debug('request %o', request); 69 | if (context.flags.verbose) { 70 | verboseLog(`${chalk.bold(operationId)} › ${testName}\n`); 71 | verboseLog(`${chalk.green(request.method.toUpperCase())} ${request.url}`); 72 | verboseLog(request); 73 | } 74 | 75 | response = await client[operationId](testDefinition.request.params, testDefinition.request.data); 76 | 77 | debug('res %o', { code: response.status, headers: response.headers, data: response.data }); 78 | if (context.flags.verbose) { 79 | verboseLog(chalk.gray('RESPONSE META:')); 80 | verboseLog({ 81 | code: response.status, 82 | status: response.statusText, 83 | headers: response.headers, 84 | }); 85 | verboseLog(chalk.gray('RESPONSE BODY:')); 86 | verboseLog(response.data || chalk.gray('(empty response)'), '\n'); 87 | } 88 | }) 89 | 90 | if ((['Success2XX', 'default', 'all'] satisfies TestCheck[]).some((check) => testDefinition.checks.includes(check))) { 91 | test('should return 2XX response', async () => { 92 | expect(`${response.status}`).toMatch(/2\d\d/) 93 | }) 94 | } 95 | 96 | if ((['ValidResponseBody', 'default', 'all'] satisfies TestCheck[]).some((check) => testDefinition.checks.includes(check))) { 97 | test('response body should match schema', async () => { 98 | const operation = api.getOperation(operationId); 99 | const responseObject = 100 | operation.responses[response.status] || 101 | operation.responses[`${response.status}`] || 102 | operation.responses.default || 103 | operation.responses[Object.keys(operation.responses)[0]]; 104 | const schema = responseObject?.['content']?.['application/json']?.schema; 105 | expect(response.data).toMatchSchema(schema) 106 | }) 107 | } 108 | }) 109 | } 110 | }) 111 | } 112 | 113 | const getClientForTest = async (params: { operationId: string, requestConfig: AxiosRequestConfig }) => { 114 | const client = await api.init(); 115 | 116 | const securityRequestConfig = await createSecurityRequestConfig({ 117 | document: context.document, 118 | operation: api.getOperation(params.operationId), 119 | security: context.flags.security, 120 | header: context.flags.header, 121 | apikey: context.flags.apikey, 122 | token: context.flags.token, 123 | username: context.flags.username, 124 | password: context.flags.password, 125 | }); 126 | debug('securityRequestConfig %o', securityRequestConfig); 127 | 128 | // add cookies 129 | const cookies = { 130 | ...securityRequestConfig.cookie, 131 | }; 132 | const cookieHeader = Object.keys(cookies) 133 | .map((key) => `${key}=${cookies[key]}`) 134 | .join('; '); 135 | 136 | // add request headers 137 | const headers = { 138 | ...params.requestConfig.headers, 139 | ...securityRequestConfig.header, 140 | ...parseHeaderFlag(context.flags.header), 141 | ...(Boolean(cookieHeader) && { cookie: cookieHeader }), 142 | }; 143 | if (Object.keys(headers).length) { 144 | client.defaults.headers.common = headers; 145 | } 146 | 147 | // add query params 148 | const queryParams = { 149 | ...params.requestConfig.params, 150 | ...securityRequestConfig.query, 151 | } 152 | if (Object.keys(params).length) { 153 | client.defaults.params = queryParams; 154 | } 155 | 156 | // add basic auth 157 | const auth = { 158 | ...params.requestConfig.auth, 159 | ...securityRequestConfig.auth, 160 | }; 161 | if (Object.keys(auth).length) { 162 | client.defaults.auth = auth; 163 | } 164 | 165 | // don't throw on error statuses 166 | client.defaults.validateStatus = () => true; 167 | 168 | return client; 169 | } 170 | 171 | const verboseLog = (...messages: any[]) => { 172 | const message = messages.map((m) => (typeof m === 'string' ? m : JSON.stringify(m, null, 2))).join(' '); 173 | 174 | process.stderr.write(`${message}\n`); 175 | } -------------------------------------------------------------------------------- /src/tests/tests.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { AxiosRequestConfig } from "axios"; 3 | 4 | export const TEST_CHECKS = [ 5 | 'all', 6 | 'default', 7 | 'Success2XX', 8 | 'ValidResponseBody' 9 | ] as const; 10 | export type TestCheck = typeof TEST_CHECKS[number]; 11 | 12 | export interface TestConfig { 13 | [operationId: string]: { 14 | [testName: string]: { 15 | checks: TestCheck[]; 16 | request: { 17 | params?: { [key: string]: any }; 18 | data?: any; 19 | config?: AxiosRequestConfig; 20 | } 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/typegen/typegen.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { generateTypesForDocument } from './typegen'; 3 | import { parseDefinition } from '../common/definition'; 4 | 5 | const examplePetAPIYAML = path.join(__dirname, '..', '..', '__tests__', 'resources', 'example-pet-api.openapi.yml'); 6 | 7 | describe('generateTypesForDocument', () => { 8 | let clientImports: string; 9 | let schemaTypes: string; 10 | let clientOperationTypes: string; 11 | let aliases: string; 12 | 13 | beforeAll(async () => { 14 | const document = await parseDefinition({ definition: examplePetAPIYAML }); 15 | const types = await generateTypesForDocument(document, { 16 | transformOperationName: (operationId: string) => operationId, 17 | disableOptionalPathParameters: true, 18 | }); 19 | clientImports = types.clientImports; 20 | schemaTypes = types.schemaTypes; 21 | clientOperationTypes = types.clientOperationTypes; 22 | aliases = types.rootLevelAliases; 23 | }); 24 | 25 | describe('schema types', () => { 26 | it('should generate namespaces from valid v3 specification', async () => { 27 | expect(schemaTypes).toMatch('namespace Components') 28 | expect(schemaTypes).toMatch('namespace Schemas') 29 | expect(schemaTypes).toMatch('namespace Paths') 30 | }); 31 | }); 32 | 33 | describe('client imports', () => { 34 | it('should generate client imports for openapi-client-axios', () => { 35 | expect(clientImports).toMatch("from 'openapi-client-axios'"); 36 | expect(clientImports).toMatch('OperationResponse,'); 37 | }); 38 | }); 39 | 40 | describe('client operation types', () => { 41 | test('exports methods named after the operationId', async () => { 42 | expect(clientOperationTypes).toMatch('export interface OperationMethods'); 43 | expect(clientOperationTypes).toMatch('getPets'); 44 | expect(clientOperationTypes).toMatch('createPet'); 45 | expect(clientOperationTypes).toMatch('getPetById'); 46 | expect(clientOperationTypes).toMatch('replacePetById'); 47 | expect(clientOperationTypes).toMatch('updatePetById'); 48 | expect(clientOperationTypes).toMatch('deletePetById'); 49 | expect(clientOperationTypes).toMatch('getOwnerByPetId'); 50 | expect(clientOperationTypes).toMatch('getPetOwner'); 51 | expect(clientOperationTypes).toMatch('getPetsMeta'); 52 | expect(clientOperationTypes).toMatch('getPetsRelative'); 53 | }); 54 | 55 | test('types parameters', () => { 56 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 57 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 58 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 59 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 60 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 61 | expect(clientOperationTypes).toMatch(`parameters: Parameters`); 62 | }); 63 | 64 | test('types responses', () => { 65 | expect(clientOperationTypes).toMatch(`OperationResponse`); 66 | expect(clientOperationTypes).toMatch('OperationResponse'); 67 | expect(clientOperationTypes).toMatch('OperationResponse'); 68 | expect(clientOperationTypes).toMatch('OperationResponse'); 69 | expect(clientOperationTypes).toMatch('OperationResponse'); 70 | expect(clientOperationTypes).toMatch('OperationResponse'); 71 | expect(clientOperationTypes).toMatch('OperationResponse'); 72 | expect(clientOperationTypes).toMatch('OperationResponse'); 73 | expect(clientOperationTypes).toMatch('OperationResponse'); 74 | }); 75 | 76 | test('exports PathsDictionary', async () => { 77 | expect(clientOperationTypes).toMatch('export interface PathsDictionary'); 78 | expect(clientOperationTypes).toMatch(`['/pets']`); 79 | expect(clientOperationTypes).toMatch(`['/pets/{id}']`); 80 | expect(clientOperationTypes).toMatch(`['/pets/{id}/owner']`); 81 | expect(clientOperationTypes).toMatch(`['/pets/{petId}/owner/{ownerId}']`); 82 | expect(clientOperationTypes).toMatch(`['/pets/meta']`); 83 | expect(clientOperationTypes).toMatch(`['/pets/relative']`); 84 | }); 85 | 86 | test('exports a Client', async () => { 87 | expect(clientOperationTypes).toMatch('export type Client ='); 88 | }); 89 | }); 90 | 91 | describe('root level aliases', () => { 92 | test('exports type aliases for components defined in spec', async () => { 93 | expect(aliases).toMatch('export type PetId = Components.Schemas.PetId;'); 94 | expect(aliases).toMatch('export type PetPayload = Components.Schemas.PetPayload;'); 95 | expect(aliases).toMatch('export type QueryLimit = Components.Schemas.QueryLimit;'); 96 | expect(aliases).toMatch('export type QueryOffset = Components.Schemas.QueryOffset;'); 97 | }); 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /src/typegen/typegen.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as indent from 'indent-string'; 3 | import OpenAPIClientAxios, { Document, HttpMethod, Operation } from 'openapi-client-axios'; 4 | import DTSGenerator from '@anttiviljami/dtsgenerator/dist/core/dtsGenerator'; 5 | import { JsonSchema, parseSchema } from '@anttiviljami/dtsgenerator'; 6 | 7 | interface TypegenOptions { 8 | transformOperationName?: (operation: string) => string; 9 | disableOptionalPathParameters?: boolean; 10 | } 11 | 12 | interface ExportedType { 13 | name: string; 14 | path: string; 15 | schemaRef: string; 16 | } 17 | 18 | // rule from 'dts-generator' jsonSchema.ts 19 | function convertKeyToTypeName(key: string): string { 20 | key = key.replace(/\/(.)/g, (_match: string, p1: string) => { 21 | return p1.toUpperCase(); 22 | }); 23 | return key 24 | .replace(/}/g, '') 25 | .replace(/{/g, '$') 26 | .replace(/^\//, '') 27 | .replace(/[^0-9A-Za-z_$]+/g, '_'); 28 | } 29 | 30 | export async function generateTypesForDocument(definition: Document, opts: TypegenOptions) { 31 | const normalizedSchema = normalizeSchema(definition); 32 | 33 | const schema = parseSchema(normalizedSchema as JsonSchema); 34 | 35 | const generator = new DTSGenerator([schema]); 36 | 37 | const schemaTypes = await generator.generate(); 38 | const exportedTypes: ExportedType[] = generator.getExports(); 39 | 40 | const api = new OpenAPIClientAxios({ definition: normalizedSchema as Document }); 41 | await api.init(); 42 | 43 | const rootLevelAliases = generateRootLevelAliases(exportedTypes); 44 | 45 | const clientOperationTypes = generateClientOperationMethodTypes(api, exportedTypes, opts); 46 | const backendOperationTypes = generateBackendOperationMethodTypes(api, exportedTypes); 47 | 48 | const clientImports = [ 49 | 'import type {', 50 | ' OpenAPIClient,', 51 | ' Parameters,', 52 | ' UnknownParamsObject,', 53 | ' OperationResponse,', 54 | ' AxiosRequestConfig,', 55 | `} from 'openapi-client-axios';`, 56 | ].join('\n'); 57 | 58 | const backendImports = [ 59 | 'import type {', 60 | ' Context,', 61 | ' UnknownParams,', 62 | `} from 'openapi-backend';`, 63 | ].join('\n'); 64 | 65 | return { clientImports, backendImports, schemaTypes, rootLevelAliases, clientOperationTypes, backendOperationTypes}; 66 | } 67 | 68 | function generateBackendOperationMethodTypes( 69 | api: OpenAPIClientAxios, 70 | exportTypes: ExportedType[], 71 | ) { 72 | const operations = api.getOperations(); 73 | 74 | const operationTypes = operations 75 | .map((op) => { 76 | return op.operationId 77 | ? generateHandlerOperationTypeForOperation(op, exportTypes) 78 | : null; 79 | }) 80 | .filter((op) => Boolean(op)); 81 | 82 | 83 | return [ 84 | 'export interface Operations {', 85 | ...operationTypes.map((op) => indent(op, 2)), 86 | '}', 87 | '', 88 | // evil typescript magic for nice typing of openapi-backend operation handlers 89 | 'export type OperationContext = Operations[operationId]["context"];', 90 | 'export type OperationResponse = Operations[operationId]["response"];', 91 | 'export type HandlerResponse> = ResponseModel & { _t?: ResponseBody };', 92 | 'export type OperationHandlerResponse = HandlerResponse>;', 93 | 'export type OperationHandler = (...params: [OperationContext, ...HandlerArgs]) => Promise>;', 94 | ].join('\n'); 95 | } 96 | 97 | function generateClientOperationMethodTypes( 98 | api: OpenAPIClientAxios, 99 | exportTypes: ExportedType[], 100 | opts: TypegenOptions, 101 | ) { 102 | const operations = api.getOperations(); 103 | 104 | const operationTypings = operations 105 | .map((op) => { 106 | return op.operationId 107 | ? generateMethodForClientOperation(opts.transformOperationName(op.operationId), op, exportTypes, opts) 108 | : null; 109 | }) 110 | .filter((op) => Boolean(op)); 111 | 112 | const pathOperationTypes = _.entries(api.definition.paths).map(([path, pathItem]) => { 113 | const methodTypings: string[] = []; 114 | for (const m in pathItem) { 115 | if (pathItem[m as HttpMethod] && _.includes(Object.values(HttpMethod), m)) { 116 | const method = m as HttpMethod; 117 | const operation = _.find(operations, { path, method }); 118 | if (operation.operationId) { 119 | const methodForOperation = generateMethodForClientOperation(method, operation, exportTypes, opts); 120 | methodTypings.push(methodForOperation); 121 | } 122 | } 123 | } 124 | return [`['${path}']: {`, ...methodTypings.map((m) => indent(m, 2)), '}'].join('\n'); 125 | }); 126 | 127 | return [ 128 | 'export interface OperationMethods {', 129 | ...operationTypings.map((op) => indent(op, 2)), 130 | '}', 131 | '', 132 | 'export interface PathsDictionary {', 133 | ...pathOperationTypes.map((p) => indent(p, 2)), 134 | '}', 135 | '', 136 | 'export type Client = OpenAPIClient', 137 | ].join('\n'); 138 | } 139 | 140 | function generateHandlerOperationTypeForOperation( 141 | operation: Operation, 142 | exportTypes: ExportedType[], 143 | ) { 144 | const operationId = operation.operationId; 145 | const normalizedOperationId = convertKeyToTypeName(operationId); 146 | 147 | const requestBodyType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/requestBody` })?.path || 'any'; 148 | const pathParameterType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/pathParameters` })?.path || 'UnknownParams'; 149 | const queryParameterType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/queryParameters` })?.path || 'UnknownParams'; 150 | const headerParameterType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/headerParameters` })?.path || 'UnknownParams'; 151 | const cookieParameterType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/cookieParameters` })?.path || 'UnknownParams'; 152 | 153 | const responseTypePaths = exportTypes 154 | .filter(({ schemaRef }) => schemaRef.startsWith(`#/paths/${normalizedOperationId}/responses/`)) 155 | .map(({ path }) => path) 156 | const responseType = !_.isEmpty(responseTypePaths) ? responseTypePaths.join(' | ') : 'any'; 157 | 158 | return [ 159 | `/**`, 160 | ` * ${operation.method.toUpperCase()} ${operation.path}`, 161 | ` */`, 162 | `['${normalizedOperationId}']: {`, 163 | indent(`requestBody: ${requestBodyType};`, 2), 164 | indent(`params: ${pathParameterType};`, 2), 165 | indent(`query: ${queryParameterType};`, 2), 166 | indent(`headers: ${headerParameterType};`, 2), 167 | indent(`cookies: ${cookieParameterType};`, 2), 168 | indent(`context: Context<${requestBodyType}, ${pathParameterType}, ${queryParameterType}, ${headerParameterType}, ${cookieParameterType}>;`, 2), 169 | indent(`response: ${responseType};`, 2), 170 | '}', 171 | ].join('\n'); 172 | } 173 | 174 | function generateMethodForClientOperation( 175 | methodName: string, 176 | operation: Operation, 177 | exportTypes: ExportedType[], 178 | opts: TypegenOptions, 179 | ) { 180 | const { operationId, summary, description } = operation; 181 | 182 | // parameters arg 183 | const normalizedOperationId = convertKeyToTypeName(operationId); 184 | const normalizedPath = convertKeyToTypeName(operation.path); 185 | 186 | const pathParameterTypePaths = _.chain([ 187 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/pathParameters` }), 188 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedPath}/pathParameters` }), 189 | ]) 190 | .filter() 191 | .map('path') 192 | .value(); 193 | 194 | const parameterTypePaths = _.chain([ 195 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/queryParameters` }), 196 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedPath}/queryParameters` }), 197 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/headerParameters` }), 198 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedPath}/headerParameters` }), 199 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/cookieParameters` }), 200 | _.find(exportTypes, { schemaRef: `#/paths/${normalizedPath}/cookieParameters` }), 201 | ]) 202 | .filter() 203 | .map('path') 204 | .value() 205 | .concat(pathParameterTypePaths); 206 | 207 | const parametersType = !_.isEmpty(parameterTypePaths) ? parameterTypePaths.join(' & ') : 'UnknownParamsObject'; 208 | let parametersArg = `parameters?: Parameters<${parametersType}> | null`; 209 | 210 | if (opts.disableOptionalPathParameters && !_.isEmpty(pathParameterTypePaths)) { 211 | parametersArg = `parameters: Parameters<${parametersType}>`; 212 | } 213 | 214 | // payload arg 215 | const requestBodyType = _.find(exportTypes, { schemaRef: `#/paths/${normalizedOperationId}/requestBody` }); 216 | const dataArg = `data?: ${requestBodyType ? requestBodyType.path : 'any'}`; 217 | 218 | // return type 219 | const responseTypePaths = _.chain(exportTypes) 220 | .filter(({ schemaRef }) => schemaRef.startsWith(`#/paths/${normalizedOperationId}/responses/2`) || schemaRef.startsWith(`#/paths/${normalizedOperationId}/responses/default`)) 221 | .map(({ path }) => path) 222 | .value(); 223 | const responseType = !_.isEmpty(responseTypePaths) ? responseTypePaths.join(' | ') : 'any'; 224 | const returnType = `OperationResponse<${responseType}>`; 225 | 226 | const operationArgs = [parametersArg, dataArg, 'config?: AxiosRequestConfig']; 227 | const operationMethod = `'${methodName}'(\n${operationArgs 228 | .map((arg) => indent(arg, 2)) 229 | .join(',\n')} \n): ${returnType}`; 230 | 231 | // comment for type 232 | const content = _.filter([summary, description]).join('\n\n'); 233 | const comment = 234 | '/**\n' + 235 | indent(content === '' ? operationId : `${operationId} - ${content}`, 1, { 236 | indent: ' * ', 237 | includeEmptyLines: true, 238 | }) + 239 | '\n */'; 240 | 241 | return [comment, operationMethod].join('\n'); 242 | } 243 | 244 | const generateRootLevelAliases = (exportedTypes: ExportedType[]) => { 245 | const aliases: string[] = []; 246 | 247 | for (const exportedType of exportedTypes) { 248 | if (exportedType.schemaRef.startsWith('#/components/schemas/')) { 249 | const name = exportedType.schemaRef.replace('#/components/schemas/', ''); 250 | aliases.push([ 251 | `export type ${name} = ${exportedType.path};`, 252 | ].join('\n')); 253 | } 254 | } 255 | 256 | return '\n'+aliases.join('\n'); 257 | }; 258 | 259 | const normalizeSchema = (schema: Document): Document => { 260 | const clonedSchema: Document = _.cloneDeep(schema); 261 | 262 | // dtsgenerator doesn't generate parameters correctly if they are $refs to Parameter Objects 263 | // so we resolve them here 264 | for (const path in clonedSchema.paths ?? {}) { 265 | const pathItem = clonedSchema.paths[path]; 266 | for (const method in pathItem) { 267 | const operation = pathItem[method as HttpMethod]; 268 | if (operation.parameters) { 269 | operation.parameters = operation.parameters.map((parameter) => { 270 | if ('$ref' in parameter) { 271 | const refPath = parameter.$ref.replace('#/', '').replace(/\//g, '.'); 272 | const resolvedParameter = _.get(clonedSchema, refPath); 273 | return resolvedParameter ?? parameter; 274 | } 275 | return parameter; 276 | }); 277 | } 278 | } 279 | } 280 | 281 | // make sure schema is plain JSON with no metadata 282 | return JSON.parse(JSON.stringify(clonedSchema)); 283 | }; 284 | -------------------------------------------------------------------------------- /src/types/swagger-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@apidevtools/swagger-parser' { 2 | type Document = import('openapi-types').OpenAPIV3.Document; 3 | interface Options { 4 | allow?: { 5 | json?: boolean; 6 | yaml?: boolean; 7 | empty?: boolean; 8 | unknown?: boolean; 9 | }; 10 | $ref?: { 11 | internal?: boolean; 12 | external?: boolean; 13 | circular?: boolean | 'ignore'; 14 | }; 15 | validate?: { 16 | schema?: boolean; 17 | spec?: boolean; 18 | }; 19 | cache?: { 20 | fs?: number; 21 | http?: number; 22 | https?: number; 23 | }; 24 | } 25 | function parse(api: string | Document, options?: Options): Promise; 26 | function validate(api: string | Document, options?: Options): Promise; 27 | function dereference(api: string | Document, options?: Options): Promise; 28 | function bundle(api: string | Document, options?: Options): Promise; 29 | } 30 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3 } from 'openapi-types'; 2 | 3 | 4 | export type Definition = OpenAPIV3.Document 5 | export type Operation = OpenAPIV3.OperationObject 6 | export type Parameter = OpenAPIV3.ParameterObject 7 | export type RequestBody = OpenAPIV3.RequestBodyObject -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "lib", 7 | "rootDir": "src", 8 | "target": "es2019", 9 | "skipLibCheck": true 10 | }, 11 | "include": [ 12 | "src/**/*", 13 | "__tests__/**/*" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------