@uphold/fastify-openapi-router-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/package.json +56 -0
- package/src/errors/index.js +9 -0
- package/src/errors/scopes-mismatch-error.js +17 -0
- package/src/errors/unauthorized-error.js +16 -0
- package/src/index.js +8 -0
- package/src/index.test.js +93 -0
- package/src/parser/body.js +19 -0
- package/src/parser/body.test.js +95 -0
- package/src/parser/index.js +54 -0
- package/src/parser/params.js +24 -0
- package/src/parser/params.test.js +72 -0
- package/src/parser/response.js +14 -0
- package/src/parser/response.test.js +137 -0
- package/src/parser/security.js +123 -0
- package/src/parser/security.test.js +522 -0
- package/src/parser/spec.js +14 -0
- package/src/parser/spec.test.js +91 -0
- package/src/parser/url.js +4 -0
- package/src/parser/url.test.js +11 -0
- package/src/plugin.js +51 -0
- package/src/utils/constants.js +3 -0
- package/src/utils/schema.js +29 -0
- package/src/utils/schema.test.js +135 -0
- package/src/utils/security.js +80 -0
- package/src/utils/security.test.js +241 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Uphold
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# @uphold/fastify-openapi-router-plugin
|
|
2
|
+
|
|
3
|
+
[](https://github.com/uphold/fastify-openapi-router-plugin/actions/workflows/tests.yaml)
|
|
4
|
+
|
|
5
|
+
A plugin for [Fastify](https://fastify.dev) to connect routes with a OpenAPI 3.x specification. It does so by:
|
|
6
|
+
|
|
7
|
+
- Providing a way to register routes using the `operationId` defined in your specification instead of having to manually call `fastify.route` with the correct URL, method, and schema.
|
|
8
|
+
- Handling `securitySchemes` and `security` keywords defined in your specification, simplifying the implementation of authentication and authorization middleware.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @uphold/fastify-openapi-router-plugin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This plugin is written and exported in ESM only. If you are using CommonJS, consider making a pull-request and we will happily review it.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import Fastify from 'fastify';
|
|
22
|
+
import openApiRouterPlugin from '@uphold/fastify-openapi-router-plugin';
|
|
23
|
+
|
|
24
|
+
const fastify = Fastify();
|
|
25
|
+
|
|
26
|
+
// Register the OpenAPI Router plugin.
|
|
27
|
+
await fastify.register(openApiRouterPlugin, {
|
|
28
|
+
spec: './petstore.json'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Register a route using the 'operationId'.
|
|
32
|
+
fastify.oas.route({
|
|
33
|
+
operationId: 'getPetById',
|
|
34
|
+
handler: async (request, reply) => {
|
|
35
|
+
const { petId } = request.params;
|
|
36
|
+
|
|
37
|
+
const pet = await retrievePetFromDB(petId);
|
|
38
|
+
|
|
39
|
+
return pet;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Options
|
|
45
|
+
|
|
46
|
+
You can pass the following options during the plugin registration:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
50
|
+
spec: './petstore.json',
|
|
51
|
+
securityHandlers: {
|
|
52
|
+
APIAuth: (value, request) => {}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Option | Type | Description |
|
|
58
|
+
| ------ | ---- | ---------- |
|
|
59
|
+
| `spec` | `string` or `object` | **REQUIRED**. A file path or object of your OpenAPI specification. |
|
|
60
|
+
| `securityHandlers` | `object` | An object containing the security handlers that match [Security Schemes](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object) described in your OpenAPI specification. |
|
|
61
|
+
|
|
62
|
+
#### `spec`
|
|
63
|
+
|
|
64
|
+
If you don't provide a valid OpenAPI specification, the plugin will throw an error telling you what's wrong.
|
|
65
|
+
|
|
66
|
+
**Sample using a file path**
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
70
|
+
spec: './petstore.json' // or spec: './petstore.yaml'
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Sample using an object**
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
78
|
+
spec: {
|
|
79
|
+
openapi: '3.1.0',
|
|
80
|
+
...
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### `securityHandlers`
|
|
86
|
+
|
|
87
|
+
If you haven't defined any [Security Schemes](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object) in your OpenAPI specification, this option won't be required. Otherwise, plugin will try to resolve every `securityHandlers.<name>` as an async function that matches `securitySchemes.<name>` in your OpenAPI specification.
|
|
88
|
+
|
|
89
|
+
Security handlers are executed as a `onRequest` hook for every API operation if plugin founds a [Security Requirement Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-requirement-object) defined on the root level or operation level of your OpenAPI specification. According to [Fastify Lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/), it is the most secure way to implement an authentication layer because it avoids parsing the body for unauthorized accesses.
|
|
90
|
+
|
|
91
|
+
If your operation's `security` use repeated security schemes, the plugin will call the associated security handler only once per request and cache its result. Furthermore, the plugin is smart enough to skip `security` blocks that have missing values from the request. For example, if you have a `security` block with `APIKey` and `OAuth2` and the request contains the API key but no bearer token, the plugin will automatically skip the block altogether without calling any security handler.
|
|
92
|
+
|
|
93
|
+
The security handler should either throw an error or return an object with `{ data, scopes }` where `data` becomes available as `request.oas.security.<name>` in your route handler and `scopes` is array of strings that will be used to verify if the scopes defined in the API operation are satisfied.
|
|
94
|
+
|
|
95
|
+
**Sample using OAuth 2.0**
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
99
|
+
spec: {
|
|
100
|
+
openapi: '3.1.0',
|
|
101
|
+
...
|
|
102
|
+
paths: {
|
|
103
|
+
'/pet/{petId}': {
|
|
104
|
+
get: {
|
|
105
|
+
operationId: 'getPetById',
|
|
106
|
+
...
|
|
107
|
+
security: [
|
|
108
|
+
{ OAuth2: ['pets:read'] }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
components: {
|
|
114
|
+
securitySchemes: {
|
|
115
|
+
OAuth2: {
|
|
116
|
+
type: 'oauth2',
|
|
117
|
+
flows: {
|
|
118
|
+
...
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
securityHandlers: {
|
|
125
|
+
OAuth2: async (token, request) => {
|
|
126
|
+
// Validate and decode token.
|
|
127
|
+
const { userId } = verifyToken(token);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
data: { userId },
|
|
131
|
+
scopes: tokenData.scopes
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> [!TIP]
|
|
139
|
+
> The `scopes` returned by the security handler can contain **wildcards**. For example, if the security handler returns `{ scopes: ['pets:*'] }`, the route will be authorized for any security scope that starts with `pets:`.
|
|
140
|
+
|
|
141
|
+
> [!IMPORTANT]
|
|
142
|
+
> If your specification uses `http` security schemes with `in: cookie`, you must register [@fastify/cookie](https://github.com/fastify/fastify-cookie) before this plugin.
|
|
143
|
+
|
|
144
|
+
### Decorators
|
|
145
|
+
|
|
146
|
+
#### `fastify.oas.route(options)`
|
|
147
|
+
|
|
148
|
+
This method is used to register a new route by translating the given `operationId` to a compliant Fastify route.
|
|
149
|
+
|
|
150
|
+
`options` must be an object containing at least the `operationId` and `handler(request, reply)`. All the available [routes options](https://fastify.dev/docs/latest/Reference/Routes/#routes-options) can be used except `method`, `url` and `schema` because those are loaded from your OpenAPI specification.
|
|
151
|
+
|
|
152
|
+
**Example**
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
156
|
+
spec: './petstore.json'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
fastify.oas.route({
|
|
160
|
+
operationId: 'getPetById',
|
|
161
|
+
handler: (request, reply) => {}
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### `fastify.oas.errors`
|
|
166
|
+
|
|
167
|
+
This object contains all error classes that can be thrown by the plugin:
|
|
168
|
+
|
|
169
|
+
- `UnauthorizedError`: Thrown when all security schemes verification failed.
|
|
170
|
+
|
|
171
|
+
#### `request.oas`
|
|
172
|
+
|
|
173
|
+
For your convenience, the object `request.oas` is populated with data related to the request being made. This is an object containing `{ operation, security, securityReport }`, where:
|
|
174
|
+
|
|
175
|
+
- `operation` is the raw API operation that activated the Fastify route.
|
|
176
|
+
- `security` is an object where keys are security scheme names and values the returned `data` field from security handlers.
|
|
177
|
+
- `securityReport`: A detailed report of the security verification process. Check the [Error handler](#error-handler) section for more information.
|
|
178
|
+
|
|
179
|
+
**Example**
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
|
|
183
|
+
spec: './petstore.json',
|
|
184
|
+
securityHandlers: {
|
|
185
|
+
OAuth2: async (request, reply) => {
|
|
186
|
+
// Validate and decode token.
|
|
187
|
+
const { userId, scopes } = verifyToken(token);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
data: { userId },
|
|
191
|
+
scopes,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
fastify.oas.route({
|
|
198
|
+
operationId: 'getPetById',
|
|
199
|
+
handler: (request, reply) => {
|
|
200
|
+
const { petId } = request.params;
|
|
201
|
+
const { userId } = request.oas.security.PetStoreAuth;
|
|
202
|
+
|
|
203
|
+
return getPetById(petId, userId);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Error handler
|
|
209
|
+
|
|
210
|
+
The plugin will throw an `UnauthorizedError` when none of the `security` blocks succeed. By default, this error originates a `401` reply with `{ code: 'FST_OAS_UNAUTHORIZED', 'message': 'Unauthorized' }` as the payload. You can override this behavior by registering a fastify error handler:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
fastify.setErrorHandler((error, request, reply) => {
|
|
214
|
+
if (error instanceof fastify.oas.errors.UnauthorizedError) {
|
|
215
|
+
// Do something with `error.securityReport` and call `reply` accordingly.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ...
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The `securityReport` property contains an array of objects with the following structure:
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
[
|
|
226
|
+
{
|
|
227
|
+
ok: false,
|
|
228
|
+
// Schemes can be an empty object if the security block was skipped due to missing values.
|
|
229
|
+
schemes: {
|
|
230
|
+
OAuth2: {
|
|
231
|
+
ok: false,
|
|
232
|
+
// Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied.
|
|
233
|
+
error: new Error(),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
]
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
[MIT](./LICENSE)
|
|
243
|
+
|
|
244
|
+
## Contributing
|
|
245
|
+
|
|
246
|
+
### Development
|
|
247
|
+
|
|
248
|
+
Install dependencies:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
npm i
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Run tests:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
npm run test
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Run tests and update snapshots:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
npm run test -- -u
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Cutting a release
|
|
267
|
+
|
|
268
|
+
The release process is automated via the [release](https://github.com/uphold/fastify-openapi-router-plugin/actions/workflows/release.yaml) GitHub workflow. Run it by clicking the "Run workflow" button.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uphold/fastify-openapi-router-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A plugin for Fastify to connect routes with a OpenAPI 3.x specification",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"types": "types/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"release": "release-it",
|
|
13
|
+
"start": "node --watch ./examples/petstore/app.js",
|
|
14
|
+
"lint": "eslint .",
|
|
15
|
+
"test": "vitest --coverage"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/uphold/fastify-openapi-router-plugin"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"fastify",
|
|
23
|
+
"openapi"
|
|
24
|
+
],
|
|
25
|
+
"author": "Uphold",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"homepage": "https://github.com/uphold/fastify-openapi-router-plugin#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/uphold/fastify-openapi-router-plugin/issues"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^9.6.0",
|
|
36
|
+
"@vitest/coverage-v8": "^1.6.0",
|
|
37
|
+
"eslint": "^9.6.0",
|
|
38
|
+
"eslint-config-prettier": "^9.1.0",
|
|
39
|
+
"eslint-plugin-prettier": "^5.2.1",
|
|
40
|
+
"eslint-plugin-sort-destructure-keys": "^2.0.0",
|
|
41
|
+
"eslint-plugin-sort-imports-requires": "^1.0.2",
|
|
42
|
+
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
|
43
|
+
"fastify": "^4.28.1",
|
|
44
|
+
"globals": "^15.7.0",
|
|
45
|
+
"prettier": "^3.3.3",
|
|
46
|
+
"release-it": "^17.6.0",
|
|
47
|
+
"vitest": "^1.6.0"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@readme/openapi-parser": "^2.6.0",
|
|
51
|
+
"fastify-plugin": "^4.5.1",
|
|
52
|
+
"lodash-es": "^4.17.21",
|
|
53
|
+
"openapi-types": "^12.1.3",
|
|
54
|
+
"p-props": "^6.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ScopesMismatchError, createScopesMismatchError } from './scopes-mismatch-error.js';
|
|
2
|
+
import { UnauthorizedError, createUnauthorizedError } from './unauthorized-error.js';
|
|
3
|
+
|
|
4
|
+
const errors = {
|
|
5
|
+
ScopesMismatchError,
|
|
6
|
+
UnauthorizedError
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export { createScopesMismatchError, createUnauthorizedError, errors };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import createError from '@fastify/error';
|
|
2
|
+
|
|
3
|
+
const ScopesMismatchError = createError('FST_OAS_SCOPES_MISMATCH', 'Scopes do not match required scopes', 403);
|
|
4
|
+
|
|
5
|
+
const createScopesMismatchError = (providedScopes, requiredScopes, missingScopes) => {
|
|
6
|
+
const err = new ScopesMismatchError();
|
|
7
|
+
|
|
8
|
+
err.scopes = {
|
|
9
|
+
missing: missingScopes,
|
|
10
|
+
provided: providedScopes,
|
|
11
|
+
required: requiredScopes
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return err;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { ScopesMismatchError, createScopesMismatchError };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import createError from '@fastify/error';
|
|
2
|
+
|
|
3
|
+
const UnauthorizedError = createError('FST_OAS_UNAUTHORIZED', 'Unauthorized', 401);
|
|
4
|
+
|
|
5
|
+
const createUnauthorizedError = securityReport => {
|
|
6
|
+
const err = new UnauthorizedError();
|
|
7
|
+
|
|
8
|
+
Object.defineProperty(err, 'securityReport', {
|
|
9
|
+
enumerable: false,
|
|
10
|
+
value: securityReport
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return err;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { UnauthorizedError, createUnauthorizedError };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import OpenAPIRouter from './index.js';
|
|
3
|
+
import fastify from 'fastify';
|
|
4
|
+
|
|
5
|
+
describe('Fastify plugin', () => {
|
|
6
|
+
let spec;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
spec = {
|
|
10
|
+
info: { title: 'Title', version: '1' },
|
|
11
|
+
openapi: '3.1.0',
|
|
12
|
+
paths: {
|
|
13
|
+
'/pets': {
|
|
14
|
+
get: {
|
|
15
|
+
operationId: 'getPets'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should default export', () => {
|
|
23
|
+
expect(OpenAPIRouter).toBeTypeOf('function');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should expose plugin methods', async () => {
|
|
27
|
+
const app = fastify({ logger: false });
|
|
28
|
+
|
|
29
|
+
await app.register(OpenAPIRouter, { spec });
|
|
30
|
+
|
|
31
|
+
expect(app.oas).toBeTypeOf('object');
|
|
32
|
+
expect(app.oas).toMatchObject({
|
|
33
|
+
route: expect.any(Function)
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.oas.route({
|
|
37
|
+
handler: request => request.oas,
|
|
38
|
+
operationId: 'getPets'
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await app.inject({ url: '/pets' });
|
|
42
|
+
|
|
43
|
+
expect(result.json()).toMatchObject({
|
|
44
|
+
operation: { operationId: 'getPets' },
|
|
45
|
+
security: {}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should throw registering a route if `operationId` is not in spec', async () => {
|
|
50
|
+
const app = fastify({ logger: false });
|
|
51
|
+
|
|
52
|
+
await app.register(OpenAPIRouter, { spec });
|
|
53
|
+
|
|
54
|
+
expect(() =>
|
|
55
|
+
app.oas.route({
|
|
56
|
+
handler: () => {},
|
|
57
|
+
operationId: 'getUnknown'
|
|
58
|
+
})
|
|
59
|
+
).toThrowError(`Missing 'getUnknown' in OpenAPI spec.`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should set a route-level onRequest hook', async () => {
|
|
63
|
+
const app = fastify({ logger: false });
|
|
64
|
+
const onRequest = vi.fn(async () => {});
|
|
65
|
+
|
|
66
|
+
await app.register(OpenAPIRouter, { spec });
|
|
67
|
+
|
|
68
|
+
app.oas.route({
|
|
69
|
+
handler: async () => {},
|
|
70
|
+
onRequest,
|
|
71
|
+
operationId: 'getPets'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await app.inject({ url: '/pets' });
|
|
75
|
+
|
|
76
|
+
expect(onRequest).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should not override internal route options', async () => {
|
|
80
|
+
const app = fastify({ logger: false });
|
|
81
|
+
|
|
82
|
+
await app.register(OpenAPIRouter, { spec });
|
|
83
|
+
|
|
84
|
+
expect(() =>
|
|
85
|
+
app.oas.route({
|
|
86
|
+
handler: async () => {},
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
operationId: 'getPets',
|
|
89
|
+
url: '/pets/:id'
|
|
90
|
+
})
|
|
91
|
+
).toThrowError(`Not allowed to override 'method', 'schema' or 'url' for operation 'getPets'.`);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { addPropertyToSchema, removeAttributesFromSchema } from '../utils/schema.js';
|
|
2
|
+
|
|
3
|
+
export const parseBody = (route, operation) => {
|
|
4
|
+
if (!operation.requestBody?.content) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Pick the first body type because fastify only supports one per route.
|
|
9
|
+
const [[contentType, { schema }]] = Object.entries(operation.requestBody.content);
|
|
10
|
+
|
|
11
|
+
// Enforce the correct Content-Type.
|
|
12
|
+
addPropertyToSchema(route.schema.headers, { 'content-type': { const: contentType } }, true);
|
|
13
|
+
|
|
14
|
+
// Sanitize schema.
|
|
15
|
+
removeAttributesFromSchema(schema, ['xml', 'example']);
|
|
16
|
+
|
|
17
|
+
// Add request body schema.
|
|
18
|
+
route.schema.body = schema;
|
|
19
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseBody } from './body.js';
|
|
3
|
+
|
|
4
|
+
describe('parseBody()', () => {
|
|
5
|
+
let route;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
route = {
|
|
9
|
+
schema: { body: {}, headers: { properties: {}, required: [] } }
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should do nothing if operation does not contain a response body', () => {
|
|
14
|
+
parseBody(route, {});
|
|
15
|
+
parseBody(route, { requestBody: {} });
|
|
16
|
+
|
|
17
|
+
expect(route.schema.body).toStrictEqual({});
|
|
18
|
+
expect(route.schema.headers).toStrictEqual({
|
|
19
|
+
properties: {},
|
|
20
|
+
required: []
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should parse a valid OpenAPI requestBody object', () => {
|
|
25
|
+
const requestBody = {
|
|
26
|
+
content: {
|
|
27
|
+
'application/json': {
|
|
28
|
+
schema: {
|
|
29
|
+
foo: { type: 'string' },
|
|
30
|
+
required: ['foo']
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
parseBody(route, { requestBody });
|
|
37
|
+
|
|
38
|
+
expect(route.schema.body).toStrictEqual({
|
|
39
|
+
foo: { type: 'string' },
|
|
40
|
+
required: ['foo']
|
|
41
|
+
});
|
|
42
|
+
expect(route.schema.headers).toStrictEqual({
|
|
43
|
+
properties: { 'content-type': { const: 'application/json' } },
|
|
44
|
+
required: ['content-type']
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should pick the first content-type if requestBody object has more than one', () => {
|
|
49
|
+
const schema = {
|
|
50
|
+
foo: { type: 'string' },
|
|
51
|
+
required: ['foo']
|
|
52
|
+
};
|
|
53
|
+
const requestBody = {
|
|
54
|
+
content: {
|
|
55
|
+
'application/json': { schema },
|
|
56
|
+
'application/xml': { schema }
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
parseBody(route, { requestBody });
|
|
61
|
+
|
|
62
|
+
expect(route.schema.body).toStrictEqual({
|
|
63
|
+
foo: { type: 'string' },
|
|
64
|
+
required: ['foo']
|
|
65
|
+
});
|
|
66
|
+
expect(route.schema.headers).toStrictEqual({
|
|
67
|
+
properties: { 'content-type': { const: 'application/json' } },
|
|
68
|
+
required: ['content-type']
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should sanitize body schema', () => {
|
|
73
|
+
const schema = {
|
|
74
|
+
foo: { example: 'baz', type: 'string', xml: { name: 'foo' } },
|
|
75
|
+
required: ['foo'],
|
|
76
|
+
xml: { name: 'Bar' }
|
|
77
|
+
};
|
|
78
|
+
const requestBody = {
|
|
79
|
+
content: {
|
|
80
|
+
'application/json': { schema }
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
parseBody(route, { requestBody });
|
|
85
|
+
|
|
86
|
+
expect(route.schema.body).toStrictEqual({
|
|
87
|
+
foo: { type: 'string' },
|
|
88
|
+
required: ['foo']
|
|
89
|
+
});
|
|
90
|
+
expect(route.schema.headers).toStrictEqual({
|
|
91
|
+
properties: { 'content-type': { const: 'application/json' } },
|
|
92
|
+
required: ['content-type']
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DECORATOR_NAME } from '../utils/constants.js';
|
|
2
|
+
import { parseBody } from './body.js';
|
|
3
|
+
import { parseParams } from './params.js';
|
|
4
|
+
import { parseResponse } from './response.js';
|
|
5
|
+
import { parseSecurity, validateSecurity } from './security.js';
|
|
6
|
+
import { parseUrl } from './url.js';
|
|
7
|
+
import { validateSpec } from './spec.js';
|
|
8
|
+
|
|
9
|
+
export const parse = async options => {
|
|
10
|
+
const routes = {};
|
|
11
|
+
|
|
12
|
+
const spec = await validateSpec(options);
|
|
13
|
+
|
|
14
|
+
await validateSecurity(spec, options);
|
|
15
|
+
|
|
16
|
+
// Parse all existing paths.
|
|
17
|
+
for (const path in spec.paths) {
|
|
18
|
+
const methods = spec.paths[path];
|
|
19
|
+
|
|
20
|
+
// Parse each path method.
|
|
21
|
+
for (const method in methods) {
|
|
22
|
+
const operation = methods[method];
|
|
23
|
+
|
|
24
|
+
// Build fastify route.
|
|
25
|
+
const route = {
|
|
26
|
+
method: method.toUpperCase(),
|
|
27
|
+
onRequest: [
|
|
28
|
+
async function (request) {
|
|
29
|
+
request[DECORATOR_NAME].operation = operation;
|
|
30
|
+
},
|
|
31
|
+
parseSecurity(operation, spec, options.securityHandlers)
|
|
32
|
+
].filter(Boolean),
|
|
33
|
+
schema: {
|
|
34
|
+
headers: parseParams(operation.parameters, 'header'),
|
|
35
|
+
params: parseParams(operation.parameters, 'path'),
|
|
36
|
+
query: parseParams(operation.parameters, 'query'),
|
|
37
|
+
response: {}
|
|
38
|
+
},
|
|
39
|
+
url: parseUrl(path)
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Parse body and apply its schema to fastify route.
|
|
43
|
+
parseBody(route, operation);
|
|
44
|
+
|
|
45
|
+
// Parse responses.
|
|
46
|
+
parseResponse(route, operation);
|
|
47
|
+
|
|
48
|
+
// Finally, add route to global routes object.
|
|
49
|
+
routes[operation.operationId] = route;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return routes;
|
|
54
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { addPropertyToSchema } from '../utils/schema.js';
|
|
2
|
+
|
|
3
|
+
export const parseParams = (parameters, location) => {
|
|
4
|
+
const schema = {
|
|
5
|
+
properties: {},
|
|
6
|
+
required: [],
|
|
7
|
+
type: 'object'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Parameters is always an array in OpenAPI and we need a 'location' to proceed.
|
|
11
|
+
if (!Array.isArray(parameters) || !location) {
|
|
12
|
+
return schema;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Filter params by location.
|
|
16
|
+
const params = parameters.filter(param => param.in === location);
|
|
17
|
+
|
|
18
|
+
// Add params to schema.
|
|
19
|
+
for (const param of params) {
|
|
20
|
+
addPropertyToSchema(schema, { [param.name]: param.schema }, param.required);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return schema;
|
|
24
|
+
};
|