@techspikes/fastify-mock-fallback 1.0.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 +204 -0
- package/index.js +370 -0
- package/package.json +60 -0
- package/types/index.d.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Techspikes <https://github.com/techspikes/>
|
|
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,204 @@
|
|
|
1
|
+
# fastify-mock-fallback
|
|
2
|
+
|
|
3
|
+
[](https://github.com/techspikes/fastify-mock-fallback/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/neostandard/neostandard)
|
|
5
|
+
|
|
6
|
+
Fastify plugin that registers fallback mock routes from OpenAPI request and response examples.
|
|
7
|
+
|
|
8
|
+
Use it for spec-first development: keep real Fastify handlers for implemented operations, and let this plugin serve example responses for operations that are not implemented yet.
|
|
9
|
+
|
|
10
|
+
## Scope
|
|
11
|
+
|
|
12
|
+
**This plugin is intentionally short and simple.** It is designed for true agile teams where full-stack engineers develop both the frontend and backend while practicing XP.
|
|
13
|
+
|
|
14
|
+
If you need a full-featured API mocking and testing platform, consider [Microcks](https://microcks.io/), a CNCF Incubating project.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i @techspikes/fastify-mock-fallback
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Compatibility
|
|
23
|
+
|
|
24
|
+
| Plugin version | Fastify version |
|
|
25
|
+
| -------------- | --------------- |
|
|
26
|
+
| `^1.0.x` | `^5.x` |
|
|
27
|
+
|
|
28
|
+
## Supported Specification Versions
|
|
29
|
+
|
|
30
|
+
* Supports OpenAPI 3.0.x documents.
|
|
31
|
+
* OpenAPI 3.1.x is not officially supported.
|
|
32
|
+
* Swagger/OpenAPI 2.0 documents are not supported for mock generation, even though the underlying parser may be able to parse them.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Register the plugin with an OpenAPI 3.0.x YAML or JSON file.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import Fastify from 'fastify'
|
|
40
|
+
import { fastifyMockFallback } from '@techspikes/fastify-mock-fallback'
|
|
41
|
+
|
|
42
|
+
const fastify = Fastify()
|
|
43
|
+
|
|
44
|
+
fastify.get('/implemented', async () => {
|
|
45
|
+
return { source: 'real handler' }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await fastify.register(fastifyMockFallback, {
|
|
49
|
+
specification: './openapi.yaml',
|
|
50
|
+
enable: process.env.NODE_ENV !== 'production',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await fastify.listen({ port: 3000 })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Existing Fastify routes are not replaced. Register implemented routes before this plugin; if a generated mock route conflicts with an already registered route, the existing route wins. Registering an implemented route after a conflicting mock route causes Fastify to reject the duplicate route.
|
|
57
|
+
|
|
58
|
+
### Options
|
|
59
|
+
|
|
60
|
+
| Option | Type | Default | Description |
|
|
61
|
+
| ------ | ---- | ------- | ----------- |
|
|
62
|
+
| `specification` | `string` | required | Path to an OpenAPI YAML or JSON file. |
|
|
63
|
+
| `enable` | `boolean` | `false` | Registers mock routes when set to `true`. |
|
|
64
|
+
|
|
65
|
+
## OpenAPI Loading
|
|
66
|
+
|
|
67
|
+
The plugin reads OpenAPI 3.0.x YAML and JSON files through `@apidevtools/swagger-parser`.
|
|
68
|
+
|
|
69
|
+
Supported before route registration:
|
|
70
|
+
|
|
71
|
+
* YAML anchors and aliases
|
|
72
|
+
* local `$ref`
|
|
73
|
+
* relative file `$ref`
|
|
74
|
+
|
|
75
|
+
Remote `$ref` entries are disabled as a safety precaution and are not fetched.
|
|
76
|
+
|
|
77
|
+
## Matching
|
|
78
|
+
|
|
79
|
+
The plugin builds request examples from an operation's parameters and request body examples, then links responses to those request examples.
|
|
80
|
+
|
|
81
|
+
Request examples can come only from named `examples` entries:
|
|
82
|
+
|
|
83
|
+
| OpenAPI source | Fastify request field |
|
|
84
|
+
| -------------- | --------------------- |
|
|
85
|
+
| `path` parameters | `request.params` |
|
|
86
|
+
| `query` parameters | `request.query` |
|
|
87
|
+
| `header` parameters | lower-case `request.headers` |
|
|
88
|
+
| `cookie` parameters | `request.cookies` |
|
|
89
|
+
| `requestBody` examples | `request.body` |
|
|
90
|
+
|
|
91
|
+
The singular OpenAPI `example` field and `externalValue` are not used for request matching. A request body example must also match the request `Content-Type`; omitting `Content-Type` does not match a request body example.
|
|
92
|
+
|
|
93
|
+
Parameter, header, cookie, and query values are compared as strings. Request bodies are compared with deep strict equality. Cookie matching requires a cookie parser such as `@fastify/cookie`.
|
|
94
|
+
|
|
95
|
+
Path-level parameters are included in request matching. Operation-level parameters override path-level parameters with the same `in` and `name` values.
|
|
96
|
+
|
|
97
|
+
When request body examples with the same name are defined across multiple media types, request matching uses the client `Content-Type` header to select the matching request body media.
|
|
98
|
+
|
|
99
|
+
### Response Matching
|
|
100
|
+
|
|
101
|
+
Responses are linked in this priority order:
|
|
102
|
+
|
|
103
|
+
1. response examples with `x-request-match`
|
|
104
|
+
2. same-name response and request examples
|
|
105
|
+
3. for operations without request examples, the first status `200` media entry with `example`
|
|
106
|
+
4. for operations without request examples, the first status `200` media entry with `examples`
|
|
107
|
+
|
|
108
|
+
Response media is selected from the request `Accept` header by using the `accepts` package. When `Accept` is omitted, `*/*` is assumed. After media selection, the first matching response entry in OpenAPI definition order is used.
|
|
109
|
+
|
|
110
|
+
### Example
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
openapi: "3.0.4"
|
|
114
|
+
info:
|
|
115
|
+
title: Example API
|
|
116
|
+
version: "1.0.0"
|
|
117
|
+
paths:
|
|
118
|
+
/pet/{petId}:
|
|
119
|
+
get:
|
|
120
|
+
operationId: getPetById
|
|
121
|
+
parameters:
|
|
122
|
+
- in: path
|
|
123
|
+
name: petId
|
|
124
|
+
required: true
|
|
125
|
+
schema: { type: integer }
|
|
126
|
+
examples:
|
|
127
|
+
rocky:
|
|
128
|
+
value: 1
|
|
129
|
+
daisy:
|
|
130
|
+
value: 2
|
|
131
|
+
missing:
|
|
132
|
+
value: 3
|
|
133
|
+
responses:
|
|
134
|
+
"200":
|
|
135
|
+
description: successful operation
|
|
136
|
+
content:
|
|
137
|
+
application/json:
|
|
138
|
+
examples:
|
|
139
|
+
default:
|
|
140
|
+
x-request-match: rocky
|
|
141
|
+
value: { id: 1, name: rocky }
|
|
142
|
+
daisy:
|
|
143
|
+
value: { id: 2, name: daisy }
|
|
144
|
+
"404":
|
|
145
|
+
description: not found
|
|
146
|
+
content:
|
|
147
|
+
application/json:
|
|
148
|
+
examples:
|
|
149
|
+
missing:
|
|
150
|
+
x-request-match: missing
|
|
151
|
+
value: { code: 404, message: not found }
|
|
152
|
+
/pets:
|
|
153
|
+
get:
|
|
154
|
+
operationId: listPets
|
|
155
|
+
responses:
|
|
156
|
+
"200":
|
|
157
|
+
description: successful operation
|
|
158
|
+
content:
|
|
159
|
+
application/json:
|
|
160
|
+
example: [{ id: 1, name: rocky }, { id: 2, name: daisy }]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
In this example:
|
|
164
|
+
|
|
165
|
+
* `GET /pet/1` uses a response example with `x-request-match`.
|
|
166
|
+
* `GET /pet/2` uses same-name matching.
|
|
167
|
+
* `GET /pet/3` uses a response example with `x-request-match` and returns `404`.
|
|
168
|
+
* `GET /pets` has no request examples, so it uses the status `200` response `example`.
|
|
169
|
+
|
|
170
|
+
## Validation
|
|
171
|
+
|
|
172
|
+
Explicit references are strict:
|
|
173
|
+
|
|
174
|
+
* A truthy `x-request-match` on a response example must reference an existing request example.
|
|
175
|
+
* `x-request-match` is interpreted only on response examples. It is rejected on parameter objects and examples, request and response media objects, and response objects; it is otherwise ignored.
|
|
176
|
+
* response statuses must be concrete HTTP status codes from `100` to `599`.
|
|
177
|
+
* each operation must define at least one response entry.
|
|
178
|
+
|
|
179
|
+
Unsupported OpenAPI parameter locations throw during plugin registration.
|
|
180
|
+
|
|
181
|
+
## Runtime Behavior
|
|
182
|
+
|
|
183
|
+
* Converts OpenAPI paths such as `/pet/{petId}` to Fastify paths such as `/pet/:petId`.
|
|
184
|
+
* Registers a `501` fallback route for operations without request examples when no usable response example exists. Operations with request examples but no linked response example are not registered and log a warning.
|
|
185
|
+
* Checks `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, and `HEAD` in their OpenAPI definition order. Other methods, including `TRACE`, are not generated. When the same path defines both `HEAD` and `GET`, define `HEAD` first. If `GET` is defined first, Fastify's automatic `HEAD` route is used and the explicit `HEAD` mock is skipped with a warning.
|
|
186
|
+
* Adds `x-mock-response: true` to generated mock responses.
|
|
187
|
+
* Returns the matched response status code and body only when a response example supplies a body.
|
|
188
|
+
* Returns `406` with `x-mock-response: true` when no response media type is acceptable.
|
|
189
|
+
* Returns `501` with `x-mock-response: true` when no example matches.
|
|
190
|
+
* Rejects invalid OpenAPI specs, including specs without `paths`.
|
|
191
|
+
* Registers no routes for specs with empty `paths`.
|
|
192
|
+
|
|
193
|
+
## Exports
|
|
194
|
+
|
|
195
|
+
Both default and named exports are available.
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import mockFallback from '@techspikes/fastify-mock-fallback'
|
|
199
|
+
import { fastifyMockFallback } from '@techspikes/fastify-mock-fallback'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Licensed under [MIT](./LICENSE).
|
package/index.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
|
+
import fp from 'fastify-plugin'
|
|
3
|
+
import SwaggerParser from '@apidevtools/swagger-parser'
|
|
4
|
+
import accepts from 'accepts'
|
|
5
|
+
|
|
6
|
+
const X_MOCK_RESPONSE_HEADER = 'x-mock-response'
|
|
7
|
+
const X_REQUEST_MATCH = 'x-request-match'
|
|
8
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'])
|
|
9
|
+
|
|
10
|
+
// Normalizes media types for Accept and Content-Type comparisons.
|
|
11
|
+
const MediaType = {
|
|
12
|
+
normalize (mediaType) {
|
|
13
|
+
return mediaType.split(';')[0].trim().toLowerCase()
|
|
14
|
+
},
|
|
15
|
+
compare (actual, expected) {
|
|
16
|
+
return MediaType.normalize(actual) === MediaType.normalize(expected)
|
|
17
|
+
},
|
|
18
|
+
negotiate (request, entries) {
|
|
19
|
+
const mediaTypes = [...new Set(entries.map(entry => MediaType.normalize(entry.mediaType)))]
|
|
20
|
+
return accepts({ headers: { ...request.headers } }).type(mediaTypes)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Matches collected request example conditions against Fastify requests.
|
|
25
|
+
const RequestMatcher = {
|
|
26
|
+
sources: {
|
|
27
|
+
path: 'params',
|
|
28
|
+
query: 'query',
|
|
29
|
+
header: 'header',
|
|
30
|
+
cookie: 'cookie',
|
|
31
|
+
},
|
|
32
|
+
params (request, condition) {
|
|
33
|
+
return String(request.params[condition.name]) === String(condition.value)
|
|
34
|
+
},
|
|
35
|
+
query (request, condition) {
|
|
36
|
+
return String(request.query[condition.name]) === String(condition.value)
|
|
37
|
+
},
|
|
38
|
+
header (request, condition) {
|
|
39
|
+
return request.headers[condition.name.toLowerCase()] === String(condition.value)
|
|
40
|
+
},
|
|
41
|
+
cookie (request, condition) {
|
|
42
|
+
return String((request.cookies ?? {})[condition.name]) === String(condition.value)
|
|
43
|
+
},
|
|
44
|
+
body (request, condition) {
|
|
45
|
+
return MediaType.compare(request.headers['content-type'] ?? '', condition.mediaType) &&
|
|
46
|
+
isDeepStrictEqual(request.body, condition.value)
|
|
47
|
+
},
|
|
48
|
+
findEntry (request, entries, acceptedMediaType) {
|
|
49
|
+
return entries.find(entry =>
|
|
50
|
+
MediaType.compare(acceptedMediaType, entry.mediaType) &&
|
|
51
|
+
entry.conditions.every(condition => RequestMatcher[condition.source](request, condition))
|
|
52
|
+
)
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Loads a local OpenAPI document and resolves supported refs before registration.
|
|
57
|
+
async function loadOpenApiSpecification (specification) {
|
|
58
|
+
return await SwaggerParser.dereference(specification, {
|
|
59
|
+
resolve: {
|
|
60
|
+
http: false
|
|
61
|
+
},
|
|
62
|
+
dereference: {
|
|
63
|
+
circular: false,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Rejects response examples that point to a missing request example.
|
|
69
|
+
function rejectMissingRequestExample (requestExamples, matchName, operationName) {
|
|
70
|
+
if (requestExamples.has(matchName)) return
|
|
71
|
+
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Request example "${matchName}" referenced by operation "${operationName}" does not exist`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Keeps x-request-match constrained to response examples.
|
|
78
|
+
function rejectInvalidRequestMatchLocation (target, operationName) {
|
|
79
|
+
if (target?.[X_REQUEST_MATCH] === undefined) return
|
|
80
|
+
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${X_REQUEST_MATCH} is only supported on response examples for operation "${operationName}"`
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Accepts only concrete HTTP response status codes.
|
|
87
|
+
function rejectInvalidResponseStatusCode (status, operationName) {
|
|
88
|
+
if (/^[1-5][0-9]{2}$/.test(status)) return
|
|
89
|
+
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Response status "${status}" in operation "${operationName}" must be a concrete HTTP status code from 100 to 599`
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Limits matching to request locations Fastify can expose.
|
|
96
|
+
function rejectUnsupportedParameterLocation (param, operationName) {
|
|
97
|
+
if (RequestMatcher.sources[param.in]) return
|
|
98
|
+
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Unsupported OpenAPI parameter location "${param.in}" for parameter "${param.name}" in operation "${operationName}"`
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// OpenAPI operations must define at least one response entry.
|
|
105
|
+
function rejectMissingResponses (responses, operationName) {
|
|
106
|
+
if (responses.length !== 0) return
|
|
107
|
+
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Operation "${operationName}" must define at least one response`
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Adds each response example as either an explicit match or a same-name match.
|
|
114
|
+
function pushMatchedEntry (explicitEntries, sameNameEntries, exampleName, example, statusCode, mediaType, requestExamples, operationName) {
|
|
115
|
+
// Resolves a named request example before attaching its condition variants.
|
|
116
|
+
const getRequestExampleConditions = (requestExamples, matchName, operationName) => {
|
|
117
|
+
rejectMissingRequestExample(requestExamples, matchName, operationName)
|
|
118
|
+
return requestExamples.get(matchName)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Explicit links take precedence over response example names.
|
|
122
|
+
if (example[X_REQUEST_MATCH]) {
|
|
123
|
+
getRequestExampleConditions(requestExamples, example[X_REQUEST_MATCH], operationName)
|
|
124
|
+
.forEach(conditions => {
|
|
125
|
+
explicitEntries.push({ conditions, statusCode, mediaType, body: example.value })
|
|
126
|
+
})
|
|
127
|
+
// Otherwise, a shared example name supplies the request conditions.
|
|
128
|
+
} else if (requestExamples.has(exampleName)) {
|
|
129
|
+
getRequestExampleConditions(requestExamples, exampleName, operationName)
|
|
130
|
+
.forEach(conditions => {
|
|
131
|
+
sameNameEntries.push({ conditions, statusCode, mediaType, body: example.value })
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Collects request examples into named condition variants used by responses.
|
|
137
|
+
function collectRequestExamples (pathItem, operation, operationName) {
|
|
138
|
+
const collectParameters = () => {
|
|
139
|
+
const operationParameters = operation.parameters ?? []
|
|
140
|
+
|
|
141
|
+
// Operation parameters override path-level parameters with the same identity.
|
|
142
|
+
const isOverriddenByOperation = (pathParam) =>
|
|
143
|
+
operationParameters.some(operationParam =>
|
|
144
|
+
operationParam.in === pathParam.in &&
|
|
145
|
+
operationParam.name === pathParam.name
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
...(pathItem?.parameters ?? []).filter(pathParam => !isOverriddenByOperation(pathParam)),
|
|
150
|
+
...operationParameters,
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const requestExamples = new Map()
|
|
155
|
+
const parameters = collectParameters()
|
|
156
|
+
|
|
157
|
+
// Parameters with the same example name combine into one condition set.
|
|
158
|
+
parameters.forEach(param => {
|
|
159
|
+
rejectInvalidRequestMatchLocation(param, operationName)
|
|
160
|
+
rejectUnsupportedParameterLocation(param, operationName)
|
|
161
|
+
|
|
162
|
+
const source = RequestMatcher.sources[param.in]
|
|
163
|
+
|
|
164
|
+
// Parameters without examples do not contribute matching conditions.
|
|
165
|
+
Object.entries(param.examples ?? {}).forEach(([exampleName, example]) => {
|
|
166
|
+
rejectInvalidRequestMatchLocation(example, operationName)
|
|
167
|
+
|
|
168
|
+
const requestExampleVariants = requestExamples.get(exampleName) ?? [[]]
|
|
169
|
+
|
|
170
|
+
requestExampleVariants.forEach(conditions => {
|
|
171
|
+
conditions.push({ source, name: param.name, value: example.value })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
requestExamples.set(exampleName, requestExampleVariants)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// Request body examples add body conditions to matching request examples.
|
|
179
|
+
Object.entries(operation.requestBody?.content ?? {}).forEach(([mediaType, media]) => {
|
|
180
|
+
rejectInvalidRequestMatchLocation(media, operationName)
|
|
181
|
+
|
|
182
|
+
Object.entries(media.examples ?? {}).forEach(([exampleName, example]) => {
|
|
183
|
+
rejectInvalidRequestMatchLocation(example, operationName)
|
|
184
|
+
|
|
185
|
+
const existingVariants = requestExamples.get(exampleName) ?? [[]]
|
|
186
|
+
|
|
187
|
+
// Body examples attach to the parameter-only variant for the same name.
|
|
188
|
+
const parameterConditions = existingVariants.find(conditions =>
|
|
189
|
+
conditions.every(condition => condition.source !== 'body')
|
|
190
|
+
) ?? existingVariants[0].filter(condition => condition.source !== 'body')
|
|
191
|
+
|
|
192
|
+
// Preserve existing body variants while adding this media type variant.
|
|
193
|
+
const requestExampleVariants = [
|
|
194
|
+
...existingVariants.filter(conditions => conditions.some(condition => condition.source === 'body')),
|
|
195
|
+
[
|
|
196
|
+
...parameterConditions,
|
|
197
|
+
{ source: 'body', mediaType, value: example.value },
|
|
198
|
+
],
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
requestExamples.set(exampleName, requestExampleVariants)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return requestExamples
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Collects all response examples that can produce mock responses.
|
|
209
|
+
function collectResponseEntries (pathItem, operation, operationName) {
|
|
210
|
+
// Keep each priority tier separate until all response examples are collected.
|
|
211
|
+
const matchedExampleEntries = []
|
|
212
|
+
const matchedSameNameEntries = []
|
|
213
|
+
const parameterlessEntries = []
|
|
214
|
+
|
|
215
|
+
// Read the declared response statuses in OpenAPI definition order.
|
|
216
|
+
const responses = Object.entries(operation.responses ?? {})
|
|
217
|
+
|
|
218
|
+
rejectMissingResponses(responses, operationName)
|
|
219
|
+
|
|
220
|
+
const requestExamples = collectRequestExamples(pathItem, operation, operationName)
|
|
221
|
+
|
|
222
|
+
responses.forEach(([status, response]) => {
|
|
223
|
+
// Validate each status before using it as Fastify's numeric reply code.
|
|
224
|
+
rejectInvalidRequestMatchLocation(response, operationName)
|
|
225
|
+
rejectInvalidResponseStatusCode(status, operationName)
|
|
226
|
+
|
|
227
|
+
const statusCode = Number(status)
|
|
228
|
+
|
|
229
|
+
// Each response media type can contribute independently negotiable entries.
|
|
230
|
+
Object.entries(response.content ?? {}).forEach(([mediaType, media]) => {
|
|
231
|
+
rejectInvalidRequestMatchLocation(media, operationName)
|
|
232
|
+
|
|
233
|
+
// Parameterless operations can use a plain 200 response example.
|
|
234
|
+
if (requestExamples.size === 0 && statusCode === 200) {
|
|
235
|
+
const exampleBody = Object.hasOwn(media, 'example') ? media.example : Object.values(media.examples ?? {})[0]?.value
|
|
236
|
+
|
|
237
|
+
if (exampleBody !== undefined) {
|
|
238
|
+
parameterlessEntries.push({ conditions: [], statusCode, mediaType, body: exampleBody })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Link named response examples to their request example conditions.
|
|
243
|
+
Object.entries(media.examples ?? {}).forEach(([exampleName, example]) => {
|
|
244
|
+
pushMatchedEntry(
|
|
245
|
+
matchedExampleEntries,
|
|
246
|
+
matchedSameNameEntries,
|
|
247
|
+
exampleName,
|
|
248
|
+
example,
|
|
249
|
+
statusCode,
|
|
250
|
+
mediaType,
|
|
251
|
+
requestExamples,
|
|
252
|
+
operationName
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
requestExamples,
|
|
260
|
+
// Explicit matches win over same-name matches, then parameterless fallbacks.
|
|
261
|
+
entries: [...matchedExampleEntries, ...matchedSameNameEntries, ...parameterlessEntries]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Converts OpenAPI operations into Fastify route definitions.
|
|
266
|
+
function buildMocks (api, onSkipOperation) {
|
|
267
|
+
const buildOperationMock = (rawPath, pathItem, httpMethod, operation) => {
|
|
268
|
+
// Use the declared operationId, or a stable method-and-path fallback.
|
|
269
|
+
const operationName = operation.operationId ?? `${httpMethod} ${rawPath}`
|
|
270
|
+
const { requestExamples, entries } = collectResponseEntries(pathItem, operation, operationName)
|
|
271
|
+
|
|
272
|
+
// Parameterized operations without linked responses are skipped.
|
|
273
|
+
if (requestExamples.size !== 0 && entries.length === 0) {
|
|
274
|
+
onSkipOperation(operationName)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Convert OpenAPI path parameters to Fastify route parameters.
|
|
279
|
+
return {
|
|
280
|
+
fastifyPath: rawPath.replace(/\{([^}]+)\}/g, ':$1'),
|
|
281
|
+
httpMethod,
|
|
282
|
+
operationName,
|
|
283
|
+
entries,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Preserve Path Item operation order so explicit HEAD can precede GET.
|
|
288
|
+
return Object.entries(api.paths).flatMap(([rawPath, pathItem]) =>
|
|
289
|
+
Object.entries(pathItem).flatMap(([operationMethod, operation]) => {
|
|
290
|
+
const httpMethod = operationMethod.toUpperCase()
|
|
291
|
+
|
|
292
|
+
// Ignore path-level metadata and unsupported operations.
|
|
293
|
+
if (!HTTP_METHODS.has(httpMethod)) return []
|
|
294
|
+
|
|
295
|
+
const mock = buildOperationMock(rawPath, pathItem, httpMethod, operation)
|
|
296
|
+
|
|
297
|
+
return mock ? [mock] : []
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const fastifyMockFallback = fp(async (app, options) => {
|
|
303
|
+
// Default to disabled so mocks are explicitly opted in per environment.
|
|
304
|
+
const { specification, enable = false } = options
|
|
305
|
+
|
|
306
|
+
// Leave the Fastify instance unchanged when fallback mocks are disabled.
|
|
307
|
+
if (!enable) {
|
|
308
|
+
app.log.info('mock fallback disabled, skipping')
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Resolve the specification before deriving route definitions from it.
|
|
313
|
+
const api = await loadOpenApiSpecification(specification)
|
|
314
|
+
|
|
315
|
+
// Build all mockable operations and report parameterized operations without examples.
|
|
316
|
+
const mocks = buildMocks(api, operationName => {
|
|
317
|
+
app.log.warn(`skip operation "${operationName}", no response example found for parameterized operation`)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// Register each mock unless the application already implements its route.
|
|
321
|
+
mocks.forEach(mock => {
|
|
322
|
+
const { fastifyPath, httpMethod, operationName, entries } = mock
|
|
323
|
+
|
|
324
|
+
// Real routes take precedence over generated fallback mocks.
|
|
325
|
+
if (app.hasRoute({ method: httpMethod, url: fastifyPath })) {
|
|
326
|
+
app.log.warn(`skip operation "${operationName}", already implemented`)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
app.route({
|
|
331
|
+
method: httpMethod,
|
|
332
|
+
url: fastifyPath,
|
|
333
|
+
handler: async (request, reply) => {
|
|
334
|
+
reply.header(X_MOCK_RESPONSE_HEADER, 'true')
|
|
335
|
+
|
|
336
|
+
// Accept negotiation happens before request example matching.
|
|
337
|
+
const acceptedMediaType = MediaType.negotiate(request, entries)
|
|
338
|
+
|
|
339
|
+
if (!acceptedMediaType) {
|
|
340
|
+
return reply.status(406).send({
|
|
341
|
+
error: 'Not Acceptable',
|
|
342
|
+
message: `No acceptable response media type found for operation "${operationName}"`,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A registered mock can still miss if request conditions do not match.
|
|
347
|
+
const matched = RequestMatcher.findEntry(request, entries, acceptedMediaType)
|
|
348
|
+
|
|
349
|
+
if (!matched) {
|
|
350
|
+
return reply.status(501).send({
|
|
351
|
+
error: 'Not Implemented',
|
|
352
|
+
message: `No matching example found for operation "${operationName}"`,
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return reply.status(matched.statusCode).type(matched.mediaType).send(matched.body)
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
app.log.info(`registered mock operation: ${operationName}`)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Signal that all enabled mock route registrations have completed.
|
|
364
|
+
app.log.info('finished registering mock routes')
|
|
365
|
+
}, {
|
|
366
|
+
name: 'fastify-mock-fallback',
|
|
367
|
+
fastify: '5.x',
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
export default fastifyMockFallback
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@techspikes/fastify-mock-fallback",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fastify plugin that registers fallback mock routes from OpenAPI examples.",
|
|
5
|
+
"author": "knzbr <87257088+knzbr@users.noreply.github.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"types": "types/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"lint": "eslint",
|
|
11
|
+
"lint:fix": "eslint --fix",
|
|
12
|
+
"test": "npm run test:typescript && npm run test:unit",
|
|
13
|
+
"test:unit": "c8 --100 node --test test/*.test.js",
|
|
14
|
+
"test:typescript": "tstyche"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@apidevtools/swagger-parser": "^12.1.0",
|
|
18
|
+
"accepts": "^1.3.8",
|
|
19
|
+
"fastify-plugin": "^5.1.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"fastify": "^5.8.5"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@fastify/cookie": "^11.0.2",
|
|
26
|
+
"c8": "^11.0.0",
|
|
27
|
+
"eslint": "^9.39.4",
|
|
28
|
+
"fastify": "^5.8.5",
|
|
29
|
+
"neostandard": "^0.13.0",
|
|
30
|
+
"tstyche": "^7.1.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/techspikes/fastify-mock-fallback.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/techspikes/fastify-mock-fallback/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/techspikes/fastify-mock-fallback#readme",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"fastify",
|
|
42
|
+
"openapi",
|
|
43
|
+
"mock",
|
|
44
|
+
"spec-first"
|
|
45
|
+
],
|
|
46
|
+
"exports": {
|
|
47
|
+
".": {
|
|
48
|
+
"types": "./types/index.d.ts",
|
|
49
|
+
"import": "./index.js"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"index.js",
|
|
54
|
+
"types/index.d.ts"
|
|
55
|
+
],
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.18.0",
|
|
58
|
+
"npm": ">=10.9.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify'
|
|
2
|
+
|
|
3
|
+
export type RequestExampleEntry = {
|
|
4
|
+
source: 'params' | 'query' | 'header' | 'cookie' | 'body' | 'unknown'
|
|
5
|
+
name?: string
|
|
6
|
+
value: unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type RequestExampleMap = Map<string, RequestExampleEntry[]>
|
|
10
|
+
|
|
11
|
+
export type MockEntry = {
|
|
12
|
+
matchName: string
|
|
13
|
+
statusCode: number
|
|
14
|
+
body?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type OperationMock = {
|
|
18
|
+
fastifyPath: string
|
|
19
|
+
httpMethod: string
|
|
20
|
+
operationId: string
|
|
21
|
+
entries: MockEntry[]
|
|
22
|
+
requestExamples: RequestExampleMap
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MockFallbackOptions = {
|
|
26
|
+
specification: string
|
|
27
|
+
enable?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export declare const fastifyMockFallback: FastifyPluginAsync<MockFallbackOptions>
|
|
31
|
+
|
|
32
|
+
export default fastifyMockFallback
|