@uphold/fastify-openapi-router-plugin 0.1.1 → 0.2.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/README.md CHANGED
@@ -58,6 +58,7 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
58
58
  | ------ | ---- | ---------- |
59
59
  | `spec` | `string` or `object` | **REQUIRED**. A file path or object of your OpenAPI specification. |
60
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
+ | `securityErrorMapper` | `function` | A function that allows mapping the default `UnauthorizedError` to a custom error |
61
62
 
62
63
  #### `spec`
63
64
 
@@ -141,6 +142,45 @@ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
141
142
  > [!IMPORTANT]
142
143
  > 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
 
145
+ #### `securityErrorMapper`
146
+
147
+ 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 leveraging the `securityErrorMapper` option:
148
+
149
+ ```js
150
+ await fastify.register(import('@fastify/fastify-openapi-router-plugin'), {
151
+ spec: './petstore.json',
152
+ securityHandlers: {
153
+ OAuth2: async (request, reply) => {
154
+ // ...
155
+ }
156
+ },
157
+ securityErrorMapper: (unauthorizedError) => {
158
+ // Use `unauthorizedError.securityReport` to perform logic and return a custom error.
159
+ return MyUnauthorizedError();
160
+ },
161
+ });
162
+ ```
163
+
164
+ The `securityReport` property of the unauthorized error contains an array of objects with the following structure:
165
+
166
+ ```js
167
+ [
168
+ {
169
+ ok: false,
170
+ // Schemes can be an empty object if the security block was skipped due to missing values.
171
+ schemes: {
172
+ OAuth2: {
173
+ ok: false,
174
+ // Error thrown by the security handler or fastify.oas.errors.ScopesMismatchError if the scopes were not satisfied.
175
+ error: new Error(),
176
+ }
177
+ }
178
+ }
179
+ ]
180
+ ```
181
+
182
+ If you don't define a `securityErrorMapper`, you can still catch the `UnauthorizedError` in your fastify error handler.
183
+
144
184
  ### Decorators
145
185
 
146
186
  #### `fastify.oas.route(options)`
@@ -167,6 +207,7 @@ fastify.oas.route({
167
207
  This object contains all error classes that can be thrown by the plugin:
168
208
 
169
209
  - `UnauthorizedError`: Thrown when all security schemes verification failed.
210
+ - `ScopesMismatchError`: Thrown when the scopes returned by the security handler do not satisfy the scopes defined in the API operation.
170
211
 
171
212
  #### `request.oas`
172
213
 
@@ -174,7 +215,7 @@ For your convenience, the object `request.oas` is populated with data related to
174
215
 
175
216
  - `operation` is the raw API operation that activated the Fastify route.
176
217
  - `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.
218
+ - `securityReport`: A detailed report of the security verification process. Check the [`securityErrorMapper`](#security-error-mapper) section for more information.
178
219
 
179
220
  **Example**
180
221
 
@@ -205,38 +246,6 @@ fastify.oas.route({
205
246
  });
206
247
  ```
207
248
 
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
249
  ## License
241
250
 
242
251
  [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uphold/fastify-openapi-router-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A plugin for Fastify to connect routes with a OpenAPI 3.x specification",
5
5
  "main": "./src/index.js",
6
6
  "types": "types/index.d.ts",
package/src/index.js CHANGED
@@ -2,6 +2,8 @@ import { PLUGIN_NAME } from './utils/constants.js';
2
2
  import fp from 'fastify-plugin';
3
3
  import plugin from './plugin.js';
4
4
 
5
+ export { errors } from './errors/index.js';
6
+
5
7
  export default fp(plugin, {
6
8
  fastify: '4.x',
7
9
  name: PLUGIN_NAME
@@ -28,7 +28,7 @@ export const parse = async options => {
28
28
  async function (request) {
29
29
  request[DECORATOR_NAME].operation = operation;
30
30
  },
31
- parseSecurity(operation, spec, options.securityHandlers)
31
+ parseSecurity(operation, spec, options.securityHandlers, options.securityErrorMapper)
32
32
  ].filter(Boolean),
33
33
  schema: {
34
34
  headers: parseParams(operation.parameters, 'header'),
@@ -4,7 +4,7 @@ import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/se
4
4
  import _ from 'lodash-es';
5
5
  import pProps from 'p-props';
6
6
 
7
- export const parseSecurity = (operation, spec, securityHandlers) => {
7
+ export const parseSecurity = (operation, spec, securityHandlers, securityErrorMapper) => {
8
8
  // Use the operation security if it's defined, otherwise fallback to the spec global security.
9
9
  const operationSecurity = operation.security ?? spec.security ?? [];
10
10
 
@@ -93,7 +93,9 @@ export const parseSecurity = (operation, spec, securityHandlers) => {
93
93
  const lastResult = report[report.length - 1];
94
94
 
95
95
  if (!lastResult.ok) {
96
- throw createUnauthorizedError(report);
96
+ const error = createUnauthorizedError(report);
97
+
98
+ throw securityErrorMapper?.(error) ?? error;
97
99
  }
98
100
 
99
101
  // Otherwise, we can safely use the last result to decorate the request.
@@ -519,4 +519,42 @@ describe('parseSecurity()', () => {
519
519
  `);
520
520
  }
521
521
  });
522
+
523
+ it('should map security errors by running the supplied mapper', async () => {
524
+ const request = {
525
+ [DECORATOR_NAME]: {},
526
+ headers: {
527
+ authorization: 'Bearer bearer token'
528
+ }
529
+ };
530
+ const operation = {
531
+ security: [{ OAuth2: [] }]
532
+ };
533
+ const spec = {
534
+ components: {
535
+ securitySchemes: {
536
+ OAuth2: { type: 'oauth2' }
537
+ }
538
+ }
539
+ };
540
+ const securityHandlers = {
541
+ OAuth2: vi.fn(() => {
542
+ throw new Error('OAuth2 error');
543
+ })
544
+ };
545
+ const customError = new Error('Mapped error');
546
+ const securityErrorMapper = vi.fn(() => customError);
547
+
548
+ const onRequest = parseSecurity(operation, spec, securityHandlers, securityErrorMapper);
549
+
550
+ expect.assertions(3);
551
+
552
+ try {
553
+ await onRequest(request);
554
+ } catch (err) {
555
+ expect(err).toBe(customError);
556
+ expect(securityErrorMapper).toHaveBeenCalledTimes(1);
557
+ expect(securityErrorMapper.mock.calls[0][0]).toBeInstanceOf(errors.UnauthorizedError);
558
+ }
559
+ });
522
560
  });