api-json-server 1.0.1 → 1.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/README.md +286 -0
- package/dist/behavior.js +32 -0
- package/dist/index.js +115 -5
- package/dist/loadSpec.js +32 -0
- package/dist/openapi.js +152 -0
- package/dist/registerEndpoints.js +80 -0
- package/dist/requestMatch.js +60 -0
- package/dist/responseRenderer.js +98 -0
- package/dist/server.js +150 -0
- package/dist/spec.js +110 -0
- package/dist/stringTemplate.js +55 -0
- package/examples/auth-variants.json +31 -0
- package/examples/basic-crud.json +46 -0
- package/examples/companies-nested.json +47 -0
- package/examples/orders-and-matches.json +49 -0
- package/examples/users-faker.json +35 -0
- package/mock.spec.json +1 -0
- package/mockserve.spec.schema.json +7 -0
- package/package.json +15 -3
- package/scripts/build-schema.ts +21 -0
- package/src/behavior.ts +42 -0
- package/src/index.ts +108 -82
- package/src/loadSpec.ts +5 -2
- package/src/openapi.ts +203 -0
- package/src/registerEndpoints.ts +77 -163
- package/src/requestMatch.ts +62 -0
- package/src/responseRenderer.ts +112 -0
- package/src/server.ts +164 -15
- package/src/spec.ts +70 -8
- package/src/stringTemplate.ts +55 -0
- package/tests/behavior.test.ts +88 -0
- package/tests/faker.test.ts +175 -0
- package/tests/fixtures/spec.basic.json +39 -0
- package/tests/helpers.ts +28 -0
- package/tests/matching.test.ts +136 -0
- package/tests/server.test.ts +73 -0
- package/tests/template.test.ts +90 -0
- package/src/template.ts +0 -61
package/src/registerEndpoints.ts
CHANGED
|
@@ -1,183 +1,97 @@
|
|
|
1
|
-
import type { FastifyInstance, FastifyRequest } from
|
|
2
|
-
import type { MockSpecInferSchema, EndpointSpecInferSchema } from
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const errorStatus = endpoint.errorStatus ?? settings.errorStatus;
|
|
28
|
-
const errorResponse = endpoint.errorResponse ?? settings.errorResponse;
|
|
29
|
-
|
|
30
|
-
return { delayMs, errorRate, errorStatus, errorResponse };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
function asRecord(value: unknown): Record<string, unknown> {
|
|
35
|
-
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
36
|
-
return {};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function queryMatches(req: FastifyRequest, endpoint: EndpointSpecInferSchema): boolean {
|
|
40
|
-
const required = endpoint.match?.query;
|
|
41
|
-
if (!required) return true;
|
|
42
|
-
|
|
43
|
-
const q = asRecord(req.query);
|
|
44
|
-
|
|
45
|
-
for (const [key, expected] of Object.entries(required)) {
|
|
46
|
-
const actual = q[key];
|
|
47
|
-
|
|
48
|
-
if (Array.isArray(actual)) return false;
|
|
49
|
-
|
|
50
|
-
if (String(actual ?? "") !== String(expected)) return false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function bodyMatches(req: FastifyRequest, expected?: Record<string, string | number | boolean>): boolean {
|
|
57
|
-
if (!expected) return true;
|
|
58
|
-
|
|
59
|
-
const b = req.body;
|
|
60
|
-
if (!b || typeof b !== "object" || Array.isArray(b)) return false;
|
|
61
|
-
|
|
62
|
-
const body = b as Record<string, unknown>;
|
|
63
|
-
|
|
64
|
-
for (const [key, exp] of Object.entries(expected)) {
|
|
65
|
-
const actual = body[key];
|
|
66
|
-
if (String(actual ?? "") !== String(exp)) return false;
|
|
67
|
-
}
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function matchRequest(req: FastifyRequest, match?: { query?: Record<string, any>; body?: Record<string, any> }): boolean {
|
|
72
|
-
if (!match) return true;
|
|
73
|
-
|
|
74
|
-
// query exact match
|
|
75
|
-
const requiredQuery = match.query;
|
|
76
|
-
if (requiredQuery) {
|
|
77
|
-
const q = asRecord(req.query);
|
|
78
|
-
for (const [key, expected] of Object.entries(requiredQuery)) {
|
|
79
|
-
const actual = q[key];
|
|
80
|
-
if (Array.isArray(actual)) return false;
|
|
81
|
-
if (String(actual ?? "") !== String(expected)) return false;
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import type { MockSpecInferSchema, EndpointSpecInferSchema, TemplateValue } from "./spec.js";
|
|
3
|
+
import { matchRequest, toRecord } from "./requestMatch.js";
|
|
4
|
+
import { createRenderContext, renderTemplateValue } from "./responseRenderer.js";
|
|
5
|
+
import { resolveBehavior, shouldFail, sleep, type BehaviorOverrides } from "./behavior.js";
|
|
6
|
+
|
|
7
|
+
type ResponseSource = {
|
|
8
|
+
status?: number;
|
|
9
|
+
response: TemplateValue;
|
|
10
|
+
} & BehaviorOverrides;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Select a response source from the first matching variant.
|
|
14
|
+
*/
|
|
15
|
+
function selectVariant(req: FastifyRequest, endpoint: EndpointSpecInferSchema): ResponseSource | null {
|
|
16
|
+
if (!endpoint.variants || endpoint.variants.length === 0) return null;
|
|
17
|
+
for (const variant of endpoint.variants) {
|
|
18
|
+
if (matchRequest(req, variant.match)) {
|
|
19
|
+
return {
|
|
20
|
+
status: variant.status,
|
|
21
|
+
response: variant.response,
|
|
22
|
+
delayMs: variant.delayMs,
|
|
23
|
+
errorRate: variant.errorRate,
|
|
24
|
+
errorStatus: variant.errorStatus,
|
|
25
|
+
errorResponse: variant.errorResponse
|
|
26
|
+
};
|
|
82
27
|
}
|
|
83
28
|
}
|
|
84
|
-
|
|
85
|
-
// body exact match (top-level)
|
|
86
|
-
if (!bodyMatches(req, match.body)) return false;
|
|
87
|
-
|
|
88
|
-
return true;
|
|
29
|
+
return null;
|
|
89
30
|
}
|
|
90
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Build a response source from the endpoint itself.
|
|
34
|
+
*/
|
|
35
|
+
function selectEndpointSource(endpoint: EndpointSpecInferSchema): ResponseSource {
|
|
36
|
+
return {
|
|
37
|
+
status: endpoint.status,
|
|
38
|
+
response: endpoint.response,
|
|
39
|
+
delayMs: endpoint.delayMs,
|
|
40
|
+
errorRate: endpoint.errorRate,
|
|
41
|
+
errorStatus: endpoint.errorStatus,
|
|
42
|
+
errorResponse: endpoint.errorResponse
|
|
43
|
+
};
|
|
44
|
+
}
|
|
91
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Register all endpoints defined in a mock spec.
|
|
48
|
+
*/
|
|
92
49
|
export function registerEndpoints(app: FastifyInstance, spec: MockSpecInferSchema): void {
|
|
93
50
|
for (const endpoint of spec.endpoints) {
|
|
94
51
|
app.route({
|
|
95
52
|
method: endpoint.method,
|
|
96
53
|
url: endpoint.path,
|
|
97
|
-
handler:
|
|
98
|
-
|
|
99
|
-
let chosen:
|
|
100
|
-
| {
|
|
101
|
-
status?: number;
|
|
102
|
-
response: unknown;
|
|
103
|
-
delayMs?: number;
|
|
104
|
-
errorRate?: number;
|
|
105
|
-
errorStatus?: number;
|
|
106
|
-
errorResponse?: unknown;
|
|
107
|
-
}
|
|
108
|
-
| null = null;
|
|
109
|
-
|
|
110
|
-
// 1) Try variants first (first match wins)
|
|
111
|
-
if (endpoint.variants && endpoint.variants.length > 0) {
|
|
112
|
-
for (const v of endpoint.variants) {
|
|
113
|
-
if (matchRequest(req, v.match)) {
|
|
114
|
-
chosen = {
|
|
115
|
-
status: v.status,
|
|
116
|
-
response: v.response,
|
|
117
|
-
delayMs: v.delayMs,
|
|
118
|
-
errorRate: v.errorRate,
|
|
119
|
-
errorStatus: v.errorStatus,
|
|
120
|
-
errorResponse: v.errorResponse
|
|
121
|
-
};
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// 2) If no variant matched, fall back to endpoint-level match/response
|
|
128
|
-
if (!chosen) {
|
|
129
|
-
// Backward compatible: if your endpoint only has query match, this still works
|
|
130
|
-
// If you've upgraded schema to endpoint.match (query/body), this uses it
|
|
131
|
-
// If you haven't, you can replace endpoint.match with: { query: endpoint.match?.query }
|
|
132
|
-
const endpointMatch = (endpoint as any).match ?? { query: (endpoint as any).match?.query };
|
|
133
|
-
|
|
134
|
-
if (!matchRequest(req, endpointMatch)) {
|
|
135
|
-
reply.code(404);
|
|
136
|
-
return { error: "No matching mock for request" };
|
|
137
|
-
}
|
|
54
|
+
handler: buildEndpointHandler(spec, endpoint)
|
|
55
|
+
});
|
|
138
56
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
errorStatus: (endpoint as any).errorStatus,
|
|
145
|
-
errorResponse: (endpoint as any).errorResponse
|
|
146
|
-
};
|
|
147
|
-
}
|
|
57
|
+
app.log.info(
|
|
58
|
+
`Registered ${endpoint.method} ${endpoint.path} -> ${endpoint.status} (delay=${endpoint.delayMs ?? spec.settings.delayMs}ms, errorRate=${endpoint.errorRate ?? spec.settings.errorRate})`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
148
62
|
|
|
149
|
-
|
|
150
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Build a Fastify handler for a single endpoint definition.
|
|
65
|
+
*/
|
|
66
|
+
function buildEndpointHandler(spec: MockSpecInferSchema, endpoint: EndpointSpecInferSchema) {
|
|
67
|
+
return async (req: FastifyRequest, reply: FastifyReply) => {
|
|
68
|
+
const variant = selectVariant(req, endpoint);
|
|
151
69
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
70
|
+
if (!variant && !matchRequest(req, endpoint.match)) {
|
|
71
|
+
reply.code(404);
|
|
72
|
+
return { error: "No matching mock for request" };
|
|
73
|
+
}
|
|
156
74
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
75
|
+
const source = variant ?? selectEndpointSource(endpoint);
|
|
76
|
+
const behavior = resolveBehavior(spec.settings, endpoint, source);
|
|
160
77
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
78
|
+
if (behavior.delayMs > 0) {
|
|
79
|
+
await sleep(behavior.delayMs);
|
|
80
|
+
}
|
|
165
81
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
82
|
+
const params = toRecord(req.params);
|
|
83
|
+
const query = toRecord(req.query);
|
|
84
|
+
const body = req.body;
|
|
85
|
+
const renderContext = createRenderContext({ params, query, body }, spec.settings.fakerSeed);
|
|
170
86
|
|
|
171
|
-
|
|
87
|
+
if (shouldFail(behavior.errorRate)) {
|
|
88
|
+
reply.code(behavior.errorStatus);
|
|
89
|
+
return renderTemplateValue(behavior.errorResponse, renderContext);
|
|
90
|
+
}
|
|
172
91
|
|
|
173
|
-
|
|
174
|
-
reply.code(chosen.status ?? endpoint.status ?? 200);
|
|
175
|
-
return rendered;
|
|
176
|
-
}
|
|
177
|
-
});
|
|
92
|
+
const rendered = renderTemplateValue(source.response, renderContext);
|
|
178
93
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
94
|
+
reply.code(source.status ?? endpoint.status ?? 200);
|
|
95
|
+
return rendered;
|
|
96
|
+
};
|
|
183
97
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FastifyRequest } from "fastify";
|
|
2
|
+
import type { Primitive } from "./spec.js";
|
|
3
|
+
|
|
4
|
+
export type MatchRule = {
|
|
5
|
+
query?: Record<string, Primitive>;
|
|
6
|
+
body?: Record<string, Primitive>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safely coerce a value to a plain object record.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Safely coerce a value to a plain object record.
|
|
14
|
+
*/
|
|
15
|
+
export function toRecord(value: unknown): Record<string, unknown> {
|
|
16
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if the request query matches the expected values.
|
|
22
|
+
*/
|
|
23
|
+
function queryMatches(req: FastifyRequest, expected?: Record<string, Primitive>): boolean {
|
|
24
|
+
if (!expected) return true;
|
|
25
|
+
const query = toRecord(req.query);
|
|
26
|
+
|
|
27
|
+
for (const [key, exp] of Object.entries(expected)) {
|
|
28
|
+
const actual = query[key];
|
|
29
|
+
if (Array.isArray(actual)) return false;
|
|
30
|
+
if (String(actual ?? "") !== String(exp)) return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if the request body matches the expected top-level values.
|
|
38
|
+
*/
|
|
39
|
+
function bodyMatches(req: FastifyRequest, expected?: Record<string, Primitive>): boolean {
|
|
40
|
+
if (!expected) return true;
|
|
41
|
+
|
|
42
|
+
const body = req.body;
|
|
43
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
44
|
+
|
|
45
|
+
const record = body as Record<string, unknown>;
|
|
46
|
+
for (const [key, exp] of Object.entries(expected)) {
|
|
47
|
+
const actual = record[key];
|
|
48
|
+
if (String(actual ?? "") !== String(exp)) return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a request matches query/body rules.
|
|
56
|
+
*/
|
|
57
|
+
export function matchRequest(req: FastifyRequest, match?: MatchRule): boolean {
|
|
58
|
+
if (!match) return true;
|
|
59
|
+
if (!queryMatches(req, match.query)) return false;
|
|
60
|
+
if (!bodyMatches(req, match.body)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { faker as baseFaker, type Faker } from "@faker-js/faker";
|
|
2
|
+
import type { FakerTemplate, RepeatTemplate, TemplateValue } from "./spec.js";
|
|
3
|
+
import { renderStringTemplate, type TemplateContext } from "./stringTemplate.js";
|
|
4
|
+
|
|
5
|
+
export type RenderContext = TemplateContext & {
|
|
6
|
+
faker: Faker;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a render context with an optional faker seed applied.
|
|
11
|
+
*/
|
|
12
|
+
export function createRenderContext(ctx: TemplateContext, fakerSeed?: number): RenderContext {
|
|
13
|
+
if (typeof fakerSeed === "number") {
|
|
14
|
+
baseFaker.seed(fakerSeed);
|
|
15
|
+
}
|
|
16
|
+
return { ...ctx, faker: baseFaker };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a template value is a faker directive.
|
|
21
|
+
*/
|
|
22
|
+
function isFakerTemplate(value: TemplateValue): value is FakerTemplate {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "__faker" in value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a template value is a repeat directive.
|
|
28
|
+
*/
|
|
29
|
+
function isRepeatTemplate(value: TemplateValue): value is RepeatTemplate {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "__repeat" in value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a faker method path (e.g. "person.firstName") to a callable function.
|
|
35
|
+
*/
|
|
36
|
+
function resolveFakerMethod(faker: Faker, methodPath: string): (...args: unknown[]) => unknown {
|
|
37
|
+
const parts = methodPath.split(".");
|
|
38
|
+
let current: unknown = faker;
|
|
39
|
+
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
if (current === null || current === undefined) {
|
|
42
|
+
throw new Error(`Faker method not found: ${methodPath}`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof current !== "object" && typeof current !== "function") {
|
|
45
|
+
throw new Error(`Faker method not found: ${methodPath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const record = current as Record<string, unknown>;
|
|
49
|
+
current = record[part];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof current !== "function") {
|
|
53
|
+
throw new Error(`Faker method is not callable: ${methodPath}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return current as (...args: unknown[]) => unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render a faker directive to a concrete value.
|
|
61
|
+
*/
|
|
62
|
+
function renderFakerTemplate(template: FakerTemplate, ctx: RenderContext): unknown {
|
|
63
|
+
const faker = ctx.faker;
|
|
64
|
+
const raw = template.__faker;
|
|
65
|
+
const methodPath = typeof raw === "string" ? raw : raw.method;
|
|
66
|
+
const args = typeof raw === "string" ? [] : raw.args ?? [];
|
|
67
|
+
const renderedArgs = args.map((arg) => renderTemplateValue(arg, ctx));
|
|
68
|
+
const method = resolveFakerMethod(faker, methodPath);
|
|
69
|
+
return method(...renderedArgs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render a repeat directive to an array of rendered items.
|
|
74
|
+
*/
|
|
75
|
+
function renderRepeatTemplate(template: RepeatTemplate, ctx: RenderContext): unknown[] {
|
|
76
|
+
const faker = ctx.faker;
|
|
77
|
+
const { count, min, max, template: itemTemplate } = template.__repeat;
|
|
78
|
+
const minValue = typeof min === "number" ? min : 0;
|
|
79
|
+
|
|
80
|
+
if (typeof count === "number") {
|
|
81
|
+
return Array.from({ length: count }, () => renderTemplateValue(itemTemplate, ctx));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const upper = typeof max === "number" ? max : minValue;
|
|
85
|
+
if (upper < minValue) {
|
|
86
|
+
throw new Error(`Repeat max must be >= min (min=${minValue}, max=${upper})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const total = faker.number.int({ min: minValue, max: upper });
|
|
90
|
+
return Array.from({ length: total }, () => renderTemplateValue(itemTemplate, ctx));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render a template value into a concrete JSON-compatible value.
|
|
95
|
+
*/
|
|
96
|
+
export function renderTemplateValue(value: TemplateValue, ctx: RenderContext): unknown {
|
|
97
|
+
if (typeof value === "string") return renderStringTemplate(value, ctx);
|
|
98
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
return value.map((item) => renderTemplateValue(item, ctx));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isFakerTemplate(value)) return renderFakerTemplate(value, ctx);
|
|
105
|
+
if (isRepeatTemplate(value)) return renderRepeatTemplate(value, ctx);
|
|
106
|
+
|
|
107
|
+
const output: Record<string, unknown> = {};
|
|
108
|
+
for (const [key, item] of Object.entries(value)) {
|
|
109
|
+
output[key] = renderTemplateValue(item, ctx);
|
|
110
|
+
}
|
|
111
|
+
return output;
|
|
112
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,29 +1,178 @@
|
|
|
1
|
-
import Fastify, { FastifyInstance } from "fastify";
|
|
2
|
-
import { MockSpecInferSchema } from "./spec.js";
|
|
1
|
+
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
2
|
+
import type { MockSpecInferSchema } from "./spec.js";
|
|
3
3
|
import { registerEndpoints } from "./registerEndpoints.js";
|
|
4
|
+
import swaggerUiDist from "swagger-ui-dist";
|
|
5
|
+
import { generateOpenApi } from "./openapi.js";
|
|
6
|
+
import fastifyStatic from "@fastify/static";
|
|
7
|
+
import YAML from "yaml";
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
type SwaggerUiDistModule = {
|
|
10
|
+
getAbsoluteFSPath?: () => string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the path to swagger-ui-dist assets.
|
|
15
|
+
*/
|
|
16
|
+
function getSwaggerUiRoot(): string {
|
|
17
|
+
// swagger-ui-dist can be CJS or ESM depending on environment.
|
|
18
|
+
// CJS: require("swagger-ui-dist").getAbsoluteFSPath()
|
|
19
|
+
// ESM: default export may itself be the function.
|
|
20
|
+
const mod: unknown = swaggerUiDist;
|
|
21
|
+
|
|
22
|
+
if (typeof mod === "function") return mod();
|
|
23
|
+
if (mod && typeof (mod as SwaggerUiDistModule).getAbsoluteFSPath === "function") {
|
|
24
|
+
return (mod as SwaggerUiDistModule).getAbsoluteFSPath!();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
throw new Error("swagger-ui-dist: cannot determine absolute FS path to dist assets");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the server URL for OpenAPI output.
|
|
32
|
+
*/
|
|
33
|
+
function resolveServerUrl(req: FastifyRequest, baseUrl?: string): string {
|
|
34
|
+
if (baseUrl && baseUrl.trim().length > 0) return baseUrl.trim();
|
|
35
|
+
|
|
36
|
+
const host = req.headers.host ?? "localhost";
|
|
37
|
+
const protocol = req.protocol ?? "http";
|
|
38
|
+
return `${protocol}://${host}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a Fastify server with mock endpoints and docs.
|
|
43
|
+
*/
|
|
44
|
+
export function buildServer(
|
|
45
|
+
spec: MockSpecInferSchema,
|
|
46
|
+
meta?: { specPath?: string; loadedAt?: string; baseUrl?: string }
|
|
47
|
+
): FastifyInstance {
|
|
6
48
|
const app = Fastify({
|
|
7
|
-
logger: true
|
|
49
|
+
logger: true,
|
|
50
|
+
trustProxy: true
|
|
8
51
|
});
|
|
9
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Handler for the /__spec route with bound metadata.
|
|
55
|
+
*/
|
|
56
|
+
function specRouteHandler() {
|
|
57
|
+
return specHandler(spec, meta);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handler for the /__openapi.json route.
|
|
62
|
+
*/
|
|
63
|
+
function openApiJsonRouteHandler(req: FastifyRequest) {
|
|
64
|
+
return openApiJsonHandler(req, spec, meta?.baseUrl);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handler for the /__openapi.yaml route.
|
|
69
|
+
*/
|
|
70
|
+
function openApiYamlRouteHandler(req: FastifyRequest, reply: FastifyReply): void {
|
|
71
|
+
return openApiYamlHandler(req, reply, spec, meta?.baseUrl);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Handler for the /docs route.
|
|
76
|
+
*/
|
|
77
|
+
function docsRouteHandler(req: FastifyRequest, reply: FastifyReply): void {
|
|
78
|
+
return docsHandler(req, reply);
|
|
79
|
+
}
|
|
80
|
+
|
|
10
81
|
// Basic sanity route
|
|
11
|
-
app.get("/health",
|
|
12
|
-
return { ok: true };
|
|
13
|
-
});
|
|
82
|
+
app.get("/health", healthHandler);
|
|
14
83
|
|
|
15
84
|
// Internal inspection endpoint
|
|
16
|
-
app.get("/__spec",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
85
|
+
app.get("/__spec", specRouteHandler);
|
|
86
|
+
|
|
87
|
+
app.get("/__openapi.json", openApiJsonRouteHandler);
|
|
88
|
+
|
|
89
|
+
app.get("/__openapi.yaml", openApiYamlRouteHandler);
|
|
90
|
+
|
|
91
|
+
app.register(fastifyStatic, {
|
|
92
|
+
root: getSwaggerUiRoot(),
|
|
93
|
+
prefix: "/docs/assets/",
|
|
94
|
+
decorateReply: false
|
|
24
95
|
});
|
|
25
96
|
|
|
97
|
+
app.get("/docs", docsRouteHandler);
|
|
98
|
+
|
|
26
99
|
registerEndpoints(app, spec);
|
|
27
100
|
|
|
28
101
|
return app;
|
|
29
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Health check handler.
|
|
106
|
+
*/
|
|
107
|
+
function healthHandler(): { ok: true } {
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Debug handler that exposes the current spec and metadata.
|
|
113
|
+
*/
|
|
114
|
+
function specHandler(spec: MockSpecInferSchema, meta?: { specPath?: string; loadedAt?: string }) {
|
|
115
|
+
return {
|
|
116
|
+
meta: {
|
|
117
|
+
specPath: meta?.specPath ?? null,
|
|
118
|
+
loadedAt: meta?.loadedAt ?? null
|
|
119
|
+
},
|
|
120
|
+
spec
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Serve the OpenAPI document as JSON.
|
|
126
|
+
*/
|
|
127
|
+
function openApiJsonHandler(req: FastifyRequest, spec: MockSpecInferSchema, baseUrl?: string) {
|
|
128
|
+
const serverUrl = resolveServerUrl(req, baseUrl);
|
|
129
|
+
return generateOpenApi(spec, serverUrl);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Serve the OpenAPI document as YAML.
|
|
134
|
+
*/
|
|
135
|
+
function openApiYamlHandler(
|
|
136
|
+
req: FastifyRequest,
|
|
137
|
+
reply: FastifyReply,
|
|
138
|
+
spec: MockSpecInferSchema,
|
|
139
|
+
baseUrl?: string
|
|
140
|
+
): void {
|
|
141
|
+
const serverUrl = resolveServerUrl(req, baseUrl);
|
|
142
|
+
const doc = generateOpenApi(spec, serverUrl);
|
|
143
|
+
const yaml = YAML.stringify(doc);
|
|
144
|
+
reply.type("application/yaml; charset=utf-8").send(yaml);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Serve the Swagger UI HTML page.
|
|
149
|
+
*/
|
|
150
|
+
function docsHandler(_req: FastifyRequest, reply: FastifyReply): void {
|
|
151
|
+
const html = `<!DOCTYPE html>
|
|
152
|
+
<html lang="en">
|
|
153
|
+
<head>
|
|
154
|
+
<meta charset="UTF-8" />
|
|
155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
156
|
+
<title>mockserve docs</title>
|
|
157
|
+
<link rel="stylesheet" href="/docs/assets/swagger-ui.css" />
|
|
158
|
+
<style>
|
|
159
|
+
html, body { margin: 0; padding: 0; }
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<div id="swagger-ui"></div>
|
|
164
|
+
|
|
165
|
+
<script src="/docs/assets/swagger-ui-bundle.js"></script>
|
|
166
|
+
<script src="/docs/assets/swagger-ui-standalone-preset.js"></script>
|
|
167
|
+
<script>
|
|
168
|
+
window.ui = SwaggerUIBundle({
|
|
169
|
+
url: "/__openapi.json",
|
|
170
|
+
dom_id: "#swagger-ui",
|
|
171
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
172
|
+
layout: "BaseLayout"
|
|
173
|
+
});
|
|
174
|
+
</script>
|
|
175
|
+
</body>
|
|
176
|
+
</html>`;
|
|
177
|
+
reply.type("text/html; charset=utf-8").send(html);
|
|
178
|
+
}
|