@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 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
+ [![Node.js CI](https://github.com/techspikes/fastify-mock-fallback/actions/workflows/ci.yml/badge.svg)](https://github.com/techspikes/fastify-mock-fallback/actions/workflows/ci.yml)
4
+ [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](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
+ }
@@ -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