├── .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 |
--------------------------------------------------------------------------------