@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.
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseParams } from './params.js';
3
+
4
+ describe('parseParams()', () => {
5
+ it('should return an empty schema when passing invalid arguments', () => {
6
+ const emptySchema = {
7
+ properties: {},
8
+ required: [],
9
+ type: 'object'
10
+ };
11
+
12
+ expect(parseParams()).toStrictEqual(emptySchema);
13
+ expect(parseParams([])).toStrictEqual(emptySchema);
14
+ expect(parseParams([], 'foo')).toStrictEqual(emptySchema);
15
+ expect(parseParams([{}], 'foo')).toStrictEqual(emptySchema);
16
+ expect(parseParams([{ in: 'path', name: 'bar', schema: {} }])).toStrictEqual(emptySchema);
17
+ expect(parseParams([{ in: 'path', name: 'bar', schema: {} }], 'foo')).toStrictEqual(emptySchema);
18
+ });
19
+
20
+ it('should parse a single OpenAPI parameter', () => {
21
+ const params = [
22
+ {
23
+ in: 'path',
24
+ name: 'foo',
25
+ required: true,
26
+ schema: {
27
+ type: 'integer'
28
+ }
29
+ }
30
+ ];
31
+
32
+ expect(parseParams(params, 'path')).toStrictEqual({
33
+ properties: { foo: { type: 'integer' } },
34
+ required: ['foo'],
35
+ type: 'object'
36
+ });
37
+ });
38
+
39
+ it('should parse OpenAPI parameters by type', () => {
40
+ const params = [
41
+ { in: 'path', name: 'foo', required: true, schema: { type: 'integer' } },
42
+ { in: 'path', name: 'bar', schema: { type: 'integer' } },
43
+ { in: 'header', name: 'x-foo', schema: { format: 'uuid', type: 'string' } },
44
+ { in: 'header', name: 'x-bar', required: true, schema: { format: 'uuid', type: 'string' } },
45
+ { in: 'query', name: 'qfoo', schema: { type: 'integer' } },
46
+ { in: 'query', name: 'qbar', required: true, schema: { type: 'integer' } },
47
+ { in: 'cookie', name: 'debug', schema: { type: 'integer' } }
48
+ ];
49
+ const pathParamsSchema = {
50
+ properties: { bar: { type: 'integer' }, foo: { type: 'integer' } },
51
+ required: ['foo'],
52
+ type: 'object'
53
+ };
54
+ const headerParamsSchema = {
55
+ properties: {
56
+ 'x-bar': { format: 'uuid', type: 'string' },
57
+ 'x-foo': { format: 'uuid', type: 'string' }
58
+ },
59
+ required: ['x-bar'],
60
+ type: 'object'
61
+ };
62
+ const queryParamsSchema = {
63
+ properties: { qbar: { type: 'integer' }, qfoo: { type: 'integer' } },
64
+ required: ['qbar'],
65
+ type: 'object'
66
+ };
67
+
68
+ expect(parseParams(params, 'path')).toStrictEqual(pathParamsSchema);
69
+ expect(parseParams(params, 'header')).toStrictEqual(headerParamsSchema);
70
+ expect(parseParams(params, 'query')).toStrictEqual(queryParamsSchema);
71
+ });
72
+ });
@@ -0,0 +1,14 @@
1
+ export const parseResponse = (route, operation) => {
2
+ if (!operation.responses) {
3
+ return;
4
+ }
5
+
6
+ for (const httpCode in operation.responses) {
7
+ const response = operation.responses[httpCode];
8
+
9
+ // Pick only responses with content.
10
+ if (response.content) {
11
+ route.schema.response[httpCode] = response;
12
+ }
13
+ }
14
+ };
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { parseResponse } from './response.js';
3
+
4
+ describe('parseResponse()', () => {
5
+ let route;
6
+
7
+ beforeEach(() => {
8
+ route = { schema: { response: {} } };
9
+ });
10
+
11
+ it('should do nothing if operation has no responses', () => {
12
+ parseResponse(route, {});
13
+ parseResponse(route, { responses: {} });
14
+
15
+ expect(route.schema.response).toStrictEqual({});
16
+ });
17
+
18
+ it('should parse a valid OpenAPI responses object', () => {
19
+ const responses = {
20
+ 200: {
21
+ content: {
22
+ 'application/json': {
23
+ schema: {
24
+ foo: { type: 'string' },
25
+ required: ['foo']
26
+ }
27
+ }
28
+ }
29
+ }
30
+ };
31
+
32
+ parseResponse(route, { responses });
33
+
34
+ expect(route.schema.response).toStrictEqual(responses);
35
+ });
36
+
37
+ it('should not parse responses without content', () => {
38
+ const responses = {
39
+ 400: {
40
+ description: 'Invalid ID supplied'
41
+ }
42
+ };
43
+
44
+ parseResponse(route, { responses });
45
+
46
+ expect(route.schema.response).toStrictEqual({});
47
+ });
48
+
49
+ it('should parse multiple content-types in a response', () => {
50
+ const schema = {
51
+ foo: { type: 'string' },
52
+ required: ['foo']
53
+ };
54
+ const responses = {
55
+ 200: {
56
+ content: {
57
+ 'application/json': { schema },
58
+ 'application/xml': { schema }
59
+ }
60
+ }
61
+ };
62
+
63
+ parseResponse(route, { responses });
64
+
65
+ expect(route.schema.response).toStrictEqual(responses);
66
+ });
67
+
68
+ it('should parse multiple responses at once', () => {
69
+ const responses = {
70
+ 200: {
71
+ content: {
72
+ 'application/json': {
73
+ schema: {
74
+ foo: { type: 'string' },
75
+ required: ['foo']
76
+ }
77
+ }
78
+ },
79
+ description: 'OK'
80
+ },
81
+ 202: {
82
+ content: {
83
+ 'application/json': {
84
+ schema: {
85
+ foo: { type: 'string' },
86
+ required: ['foo']
87
+ }
88
+ },
89
+ 'text/plain': {
90
+ schema: {
91
+ type: 'string'
92
+ }
93
+ }
94
+ },
95
+ description: 'Accepted'
96
+ },
97
+ 400: {
98
+ description: 'Invalid ID supplied'
99
+ },
100
+ 401: {
101
+ description: 'Authorization denied'
102
+ }
103
+ };
104
+
105
+ parseResponse(route, { responses });
106
+
107
+ expect(route.schema.response).toStrictEqual({
108
+ 200: {
109
+ content: {
110
+ 'application/json': {
111
+ schema: {
112
+ foo: { type: 'string' },
113
+ required: ['foo']
114
+ }
115
+ }
116
+ },
117
+ description: 'OK'
118
+ },
119
+ 202: {
120
+ content: {
121
+ 'application/json': {
122
+ schema: {
123
+ foo: { type: 'string' },
124
+ required: ['foo']
125
+ }
126
+ },
127
+ 'text/plain': {
128
+ schema: {
129
+ type: 'string'
130
+ }
131
+ }
132
+ },
133
+ description: 'Accepted'
134
+ }
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,123 @@
1
+ import { DECORATOR_NAME } from '../utils/constants.js';
2
+ import { createUnauthorizedError } from '../errors/index.js';
3
+ import { extractSecuritySchemeValueFromRequest, verifyScopes } from '../utils/security.js';
4
+ import _ from 'lodash-es';
5
+ import pProps from 'p-props';
6
+
7
+ export const parseSecurity = (operation, spec, securityHandlers) => {
8
+ // Use the operation security if it's defined, otherwise fallback to the spec global security.
9
+ const operationSecurity = operation.security ?? spec.security ?? [];
10
+
11
+ // Return undefined handler if there's no security for the operation.
12
+ if (operationSecurity.length === 0) {
13
+ return;
14
+ }
15
+
16
+ const securitySchemes = spec.components.securitySchemes;
17
+
18
+ // Return the Fastify 'onRequest' hook.
19
+ return async request => {
20
+ const valuesCache = new Map();
21
+ const promisesCache = new Map();
22
+ const report = [];
23
+
24
+ const readSchemeValue = name => {
25
+ // Values can be cached per security scheme name.
26
+ if (!valuesCache.has(name)) {
27
+ var value = extractSecuritySchemeValueFromRequest(request, securitySchemes[name]);
28
+
29
+ if (value != null) {
30
+ valuesCache.set(name, value);
31
+ }
32
+ }
33
+
34
+ return valuesCache.get(name);
35
+ };
36
+
37
+ const callSecurityHandler = async name => {
38
+ // Handler calls can be cached per security scheme name.
39
+ const value = readSchemeValue(name);
40
+ let promise = promisesCache.get(name);
41
+
42
+ if (!promise) {
43
+ promise = new Promise(resolve => resolve(securityHandlers[name](value, request)));
44
+ promisesCache.set(name, promise);
45
+ }
46
+
47
+ return await promise;
48
+ };
49
+
50
+ // Iterate over each security on the array, calling each one a `block`.
51
+ // Each block is an object with security scheme names as keys and required scopes as values.
52
+ // For example: { apiKey: [], oauth2: ['write'] }
53
+ for (const block of operationSecurity) {
54
+ // Skip the whole block if at least one entry value is missing in the request.
55
+ // Consider this example: { apiKey: [] oauth2: [] }
56
+ // If there's no API key in the request, the whole security block can be skipped.
57
+ const blockHasMissingValues = Object.keys(block).some(name => readSchemeValue(name) == null);
58
+
59
+ if (blockHasMissingValues) {
60
+ report.push({ ok: false, schemes: {} });
61
+ continue;
62
+ }
63
+
64
+ // Iterate over each security scheme in the block and call the security handler.
65
+ // We leverage cache when calling the handler to avoid multiple calls to the same function
66
+ const blockResults = await pProps(block, async (requiredScopes, name) => {
67
+ try {
68
+ const resolved = await callSecurityHandler(name);
69
+ const { data, scopes } = resolved ?? {};
70
+
71
+ // Verify scopes, which throws if scopes are missing.
72
+ verifyScopes(scopes ?? [], requiredScopes);
73
+
74
+ return { data, ok: true };
75
+ } catch (error) {
76
+ return { error, ok: false };
77
+ }
78
+ });
79
+
80
+ // Requirements in a block are AND'd together.
81
+ const ok = Object.values(blockResults).every(result => result.ok);
82
+
83
+ report.push({ ok, schemes: blockResults });
84
+
85
+ // Blocks themselves are OR'd together, so we can break early if one block passes.
86
+ if (ok) {
87
+ break;
88
+ }
89
+ }
90
+
91
+ // If all security blocks have failed, then the last block result will be an error.
92
+ // In this case, throw an unauthorized error which can be mapped in fastify's error handler to something different.
93
+ const lastResult = report[report.length - 1];
94
+
95
+ if (!lastResult.ok) {
96
+ throw createUnauthorizedError(report);
97
+ }
98
+
99
+ // Otherwise, we can safely use the last result to decorate the request.
100
+ request[DECORATOR_NAME].security = _.mapValues(lastResult.schemes, scheme => scheme.data);
101
+ request[DECORATOR_NAME].securityReport = report;
102
+ };
103
+ };
104
+
105
+ export const validateSecurity = (spec, options) => {
106
+ const securitySchemes = spec.components?.securitySchemes;
107
+
108
+ // Check if 'securityHandlers' option is defined as an object.
109
+ if (options.securityHandlers != null && !_.isPlainObject(options.securityHandlers)) {
110
+ throw new TypeError(`Expected 'options.securitySchemes' to be an object.`);
111
+ }
112
+
113
+ // Check if all declared security schemes have a valid handler.
114
+ for (const schemeKey in securitySchemes) {
115
+ const handler = options.securityHandlers?.[schemeKey];
116
+
117
+ if (typeof handler !== 'function') {
118
+ throw new TypeError(
119
+ `Missing or invalid 'options.securityHandlers.${schemeKey}'. Please provide a function for the given security scheme.`
120
+ );
121
+ }
122
+ }
123
+ };