api-json-server 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +636 -0
  2. package/dist/behavior.js +44 -0
  3. package/dist/history/historyRecorder.js +66 -0
  4. package/dist/history/types.js +2 -0
  5. package/dist/index.js +126 -5
  6. package/dist/loadSpec.js +32 -0
  7. package/dist/logger/customLogger.js +75 -0
  8. package/dist/logger/formatters.js +82 -0
  9. package/dist/logger/types.js +2 -0
  10. package/dist/openapi.js +152 -0
  11. package/dist/registerEndpoints.js +97 -0
  12. package/dist/requestMatch.js +99 -0
  13. package/dist/responseRenderer.js +98 -0
  14. package/dist/server.js +210 -0
  15. package/dist/spec.js +146 -0
  16. package/dist/stringTemplate.js +55 -0
  17. package/examples/auth-variants.json +31 -0
  18. package/examples/basic-crud.json +46 -0
  19. package/examples/companies-nested.json +47 -0
  20. package/examples/orders-and-matches.json +49 -0
  21. package/examples/users-faker.json +35 -0
  22. package/mock.spec.json +1 -0
  23. package/mockserve.spec.schema.json +7 -0
  24. package/package.json +20 -3
  25. package/scripts/build-schema.ts +21 -0
  26. package/src/behavior.ts +56 -0
  27. package/src/history/historyRecorder.ts +77 -0
  28. package/src/history/types.ts +25 -0
  29. package/src/index.ts +124 -85
  30. package/src/loadSpec.ts +5 -2
  31. package/src/logger/customLogger.ts +85 -0
  32. package/src/logger/formatters.ts +74 -0
  33. package/src/logger/types.ts +30 -0
  34. package/src/openapi.ts +203 -0
  35. package/src/registerEndpoints.ts +94 -162
  36. package/src/requestMatch.ts +104 -0
  37. package/src/responseRenderer.ts +112 -0
  38. package/src/server.ts +236 -14
  39. package/src/spec.ts +108 -8
  40. package/src/stringTemplate.ts +55 -0
  41. package/tests/behavior.test.ts +88 -0
  42. package/tests/cors.test.ts +128 -0
  43. package/tests/faker.test.ts +175 -0
  44. package/tests/fixtures/spec.basic.json +39 -0
  45. package/tests/headers.test.ts +124 -0
  46. package/tests/helpers.ts +28 -0
  47. package/tests/history.test.ts +188 -0
  48. package/tests/matching.test.ts +245 -0
  49. package/tests/server.test.ts +73 -0
  50. package/tests/template.test.ts +90 -0
  51. package/src/template.ts +0 -61
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("behavior settings", () => {
12
+ it("uses error response when errorRate is 1", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: { ...baseSettings, errorRate: 1 },
16
+ endpoints: [
17
+ {
18
+ method: "GET",
19
+ path: "/always-fail",
20
+ response: { ok: true },
21
+ errorStatus: 503,
22
+ errorResponse: { message: "Service down" }
23
+ }
24
+ ]
25
+ });
26
+
27
+ const res = await app.inject({ method: "GET", url: "/always-fail" });
28
+ expect(res.statusCode).toBe(503);
29
+ expect(res.json()).toEqual({ message: "Service down" });
30
+
31
+ await app.close();
32
+ });
33
+
34
+ it("renders templates in error responses", async () => {
35
+ const app = buildTestServer({
36
+ version: 1,
37
+ settings: { ...baseSettings, errorRate: 1 },
38
+ endpoints: [
39
+ {
40
+ method: "GET",
41
+ path: "/users/:id",
42
+ response: { ok: true },
43
+ errorStatus: 400,
44
+ errorResponse: { message: "User {{params.id}} missing" }
45
+ }
46
+ ]
47
+ });
48
+
49
+ const res = await app.inject({ method: "GET", url: "/users/55" });
50
+ expect(res.statusCode).toBe(400);
51
+ expect(res.json()).toEqual({ message: "User 55 missing" });
52
+
53
+ await app.close();
54
+ });
55
+
56
+ it("uses variant-level status codes", async () => {
57
+ const app = buildTestServer({
58
+ version: 1,
59
+ settings: baseSettings,
60
+ endpoints: [
61
+ {
62
+ method: "POST",
63
+ path: "/login",
64
+ response: { ok: true },
65
+ variants: [
66
+ {
67
+ name: "unauthorized",
68
+ match: { body: { password: "wrong" } },
69
+ status: 401,
70
+ response: { ok: false }
71
+ }
72
+ ]
73
+ }
74
+ ]
75
+ });
76
+
77
+ const res = await app.inject({
78
+ method: "POST",
79
+ url: "/login",
80
+ payload: { password: "wrong" }
81
+ });
82
+
83
+ expect(res.statusCode).toBe(401);
84
+ expect(res.json()).toEqual({ ok: false });
85
+
86
+ await app.close();
87
+ });
88
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("CORS configuration", () => {
12
+ it("enables CORS with wildcard origin when configured", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: {
16
+ ...baseSettings,
17
+ cors: {
18
+ origin: "*",
19
+ credentials: false
20
+ }
21
+ },
22
+ endpoints: [
23
+ {
24
+ method: "GET",
25
+ path: "/api/data",
26
+ response: { data: "value" }
27
+ }
28
+ ]
29
+ });
30
+
31
+ const res = await app.inject({
32
+ method: "GET",
33
+ url: "/api/data",
34
+ headers: { origin: "https://example.com" }
35
+ });
36
+
37
+ expect(res.statusCode).toBe(200);
38
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
39
+
40
+ await app.close();
41
+ });
42
+
43
+ it("handles preflight OPTIONS requests", async () => {
44
+ const app = buildTestServer({
45
+ version: 1,
46
+ settings: {
47
+ ...baseSettings,
48
+ cors: {
49
+ origin: "https://example.com",
50
+ methods: ["GET", "POST"],
51
+ credentials: true
52
+ }
53
+ },
54
+ endpoints: [
55
+ {
56
+ method: "POST",
57
+ path: "/api/data",
58
+ response: { ok: true }
59
+ }
60
+ ]
61
+ });
62
+
63
+ const res = await app.inject({
64
+ method: "OPTIONS",
65
+ url: "/api/data",
66
+ headers: {
67
+ origin: "https://example.com",
68
+ "access-control-request-method": "POST"
69
+ }
70
+ });
71
+
72
+ expect(res.statusCode).toBe(204);
73
+ expect(res.headers["access-control-allow-origin"]).toBeTruthy();
74
+
75
+ await app.close();
76
+ });
77
+
78
+ it("allows specific origins when configured", async () => {
79
+ const app = buildTestServer({
80
+ version: 1,
81
+ settings: {
82
+ ...baseSettings,
83
+ cors: {
84
+ origin: "https://trusted.com",
85
+ credentials: true
86
+ }
87
+ },
88
+ endpoints: [
89
+ {
90
+ method: "GET",
91
+ path: "/api/secure",
92
+ response: { data: "secure" }
93
+ }
94
+ ]
95
+ });
96
+
97
+ const res = await app.inject({
98
+ method: "GET",
99
+ url: "/api/secure",
100
+ headers: { origin: "https://trusted.com" }
101
+ });
102
+
103
+ expect(res.statusCode).toBe(200);
104
+ expect(res.headers["access-control-allow-origin"]).toBe("https://trusted.com");
105
+ expect(res.headers["access-control-allow-credentials"]).toBe("true");
106
+
107
+ await app.close();
108
+ });
109
+
110
+ it("works without CORS configuration", async () => {
111
+ const app = buildTestServer({
112
+ version: 1,
113
+ settings: baseSettings,
114
+ endpoints: [
115
+ {
116
+ method: "GET",
117
+ path: "/api/data",
118
+ response: { data: "value" }
119
+ }
120
+ ]
121
+ });
122
+
123
+ const res = await app.inject({ method: "GET", url: "/api/data" });
124
+ expect(res.statusCode).toBe(200);
125
+
126
+ await app.close();
127
+ });
128
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("faker templates", () => {
12
+ it("generates deterministic faker data when fakerSeed is set", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: { ...baseSettings, fakerSeed: 123 },
16
+ endpoints: [
17
+ {
18
+ method: "GET",
19
+ path: "/profile",
20
+ response: {
21
+ firstName: { __faker: "person.firstName" },
22
+ lastName: { __faker: "person.lastName" },
23
+ email: { __faker: "internet.email" }
24
+ }
25
+ }
26
+ ]
27
+ });
28
+
29
+ const res1 = await app.inject({ method: "GET", url: "/profile" });
30
+ const res2 = await app.inject({ method: "GET", url: "/profile" });
31
+
32
+ expect(res1.statusCode).toBe(200);
33
+ expect(res2.statusCode).toBe(200);
34
+ expect(res1.json()).toEqual(res2.json());
35
+
36
+ await app.close();
37
+ });
38
+
39
+ it("supports faker methods with arguments", async () => {
40
+ const app = buildTestServer({
41
+ version: 1,
42
+ settings: { ...baseSettings, fakerSeed: 7 },
43
+ endpoints: [
44
+ {
45
+ method: "GET",
46
+ path: "/token",
47
+ response: {
48
+ token: { __faker: { method: "string.alpha", args: [16] } }
49
+ }
50
+ }
51
+ ]
52
+ });
53
+
54
+ const res = await app.inject({ method: "GET", url: "/token" });
55
+ const body = res.json();
56
+
57
+ expect(res.statusCode).toBe(200);
58
+ expect(typeof body.token).toBe("string");
59
+ expect(body.token).toHaveLength(16);
60
+
61
+ await app.close();
62
+ });
63
+
64
+ it("renders repeat directives with a min/max range", async () => {
65
+ const app = buildTestServer({
66
+ version: 1,
67
+ settings: { ...baseSettings, fakerSeed: 55 },
68
+ endpoints: [
69
+ {
70
+ method: "GET",
71
+ path: "/users",
72
+ response: {
73
+ users: {
74
+ __repeat: {
75
+ min: 10,
76
+ max: 15,
77
+ template: {
78
+ id: { __faker: "string.uuid" },
79
+ firstName: { __faker: "person.firstName" },
80
+ email: { __faker: "internet.email" }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ]
87
+ });
88
+
89
+ const res = await app.inject({ method: "GET", url: "/users" });
90
+ const body = res.json();
91
+
92
+ expect(res.statusCode).toBe(200);
93
+ expect(Array.isArray(body.users)).toBe(true);
94
+ expect(body.users.length).toBeGreaterThanOrEqual(10);
95
+ expect(body.users.length).toBeLessThanOrEqual(15);
96
+ expect(typeof body.users[0].firstName).toBe("string");
97
+
98
+ await app.close();
99
+ });
100
+
101
+ it("renders repeat directives with a fixed count", async () => {
102
+ const app = buildTestServer({
103
+ version: 1,
104
+ settings: { ...baseSettings, fakerSeed: 1 },
105
+ endpoints: [
106
+ {
107
+ method: "GET",
108
+ path: "/companies",
109
+ response: {
110
+ companies: {
111
+ __repeat: {
112
+ count: 3,
113
+ min: 0,
114
+ template: {
115
+ name: { __faker: "company.name" },
116
+ phone: { __faker: "phone.number" }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ ]
123
+ });
124
+
125
+ const res = await app.inject({ method: "GET", url: "/companies" });
126
+ const body = res.json();
127
+
128
+ expect(res.statusCode).toBe(200);
129
+ expect(body.companies).toHaveLength(3);
130
+
131
+ await app.close();
132
+ });
133
+
134
+ it("returns 500 when faker method is invalid", async () => {
135
+ const app = buildTestServer({
136
+ version: 1,
137
+ settings: baseSettings,
138
+ endpoints: [
139
+ {
140
+ method: "GET",
141
+ path: "/bad",
142
+ response: { value: { __faker: "nope.method" } }
143
+ }
144
+ ]
145
+ });
146
+
147
+ const res = await app.inject({ method: "GET", url: "/bad" });
148
+ expect(res.statusCode).toBe(500);
149
+
150
+ await app.close();
151
+ });
152
+
153
+ it("returns 500 when repeat max is below min", async () => {
154
+ const app = buildTestServer({
155
+ version: 1,
156
+ settings: baseSettings,
157
+ endpoints: [
158
+ {
159
+ method: "GET",
160
+ path: "/bad-repeat",
161
+ response: {
162
+ items: {
163
+ __repeat: { min: 5, max: 2, template: { id: 1 } }
164
+ }
165
+ }
166
+ }
167
+ ]
168
+ });
169
+
170
+ const res = await app.inject({ method: "GET", url: "/bad-repeat" });
171
+ expect(res.statusCode).toBe(500);
172
+
173
+ await app.close();
174
+ });
175
+ });
@@ -0,0 +1,39 @@
1
+ {
2
+ "version": 1,
3
+ "settings": {
4
+ "delayMs": 0,
5
+ "errorRate": 0,
6
+ "errorStatus": 500,
7
+ "errorResponse": { "error": "Mock error" }
8
+ },
9
+ "endpoints": [
10
+ {
11
+ "method": "GET",
12
+ "path": "/users/:id",
13
+ "status": 200,
14
+ "response": { "id": "{{params.id}}", "type": "{{query.type}}"}
15
+ },
16
+ {
17
+ "method": "GET",
18
+ "path": "/search",
19
+ "status": 200,
20
+ "match": { "query": { "type": "premium" } },
21
+ "response": { "ok": true, "tier": "premium" }
22
+ },
23
+ {
24
+ "method": "POST",
25
+ "path": "/login",
26
+ "status": 200,
27
+ "response": { "ok": true, "message": "default" },
28
+ "variants": [
29
+ {
30
+ "name": "wrong password",
31
+ "match": { "body": { "password": "wrong" } },
32
+ "status": 401,
33
+ "response": { "ok": false, "error": "Invalid credentials" }
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ }
39
+
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildTestServer } from "./helpers.js";
3
+
4
+ const baseSettings = {
5
+ delayMs: 0,
6
+ errorRate: 0,
7
+ errorStatus: 500,
8
+ errorResponse: { error: "Mock error" }
9
+ };
10
+
11
+ describe("custom response headers", () => {
12
+ it("sets custom headers on endpoint responses", async () => {
13
+ const app = buildTestServer({
14
+ version: 1,
15
+ settings: baseSettings,
16
+ endpoints: [
17
+ {
18
+ method: "GET",
19
+ path: "/api/data",
20
+ response: { data: "value" },
21
+ headers: {
22
+ "X-Custom-Header": "test-value",
23
+ "Cache-Control": "no-cache"
24
+ }
25
+ }
26
+ ]
27
+ });
28
+
29
+ const res = await app.inject({ method: "GET", url: "/api/data" });
30
+ expect(res.statusCode).toBe(200);
31
+ expect(res.headers["x-custom-header"]).toBe("test-value");
32
+ expect(res.headers["cache-control"]).toBe("no-cache");
33
+
34
+ await app.close();
35
+ });
36
+
37
+ it("supports template strings in header values", async () => {
38
+ const app = buildTestServer({
39
+ version: 1,
40
+ settings: baseSettings,
41
+ endpoints: [
42
+ {
43
+ method: "GET",
44
+ path: "/users/:id",
45
+ response: { id: "{{params.id}}" },
46
+ headers: {
47
+ "X-User-ID": "{{params.id}}",
48
+ "X-Query-Type": "{{query.type}}"
49
+ }
50
+ }
51
+ ]
52
+ });
53
+
54
+ const res = await app.inject({ method: "GET", url: "/users/42?type=premium" });
55
+ expect(res.statusCode).toBe(200);
56
+ expect(res.headers["x-user-id"]).toBe("42");
57
+ expect(res.headers["x-query-type"]).toBe("premium");
58
+
59
+ await app.close();
60
+ });
61
+
62
+ it("variant headers override endpoint headers", async () => {
63
+ const app = buildTestServer({
64
+ version: 1,
65
+ settings: baseSettings,
66
+ endpoints: [
67
+ {
68
+ method: "GET",
69
+ path: "/api/data",
70
+ response: { source: "base" },
71
+ headers: {
72
+ "X-Source": "endpoint"
73
+ },
74
+ variants: [
75
+ {
76
+ name: "special",
77
+ match: { query: { mode: "special" } },
78
+ response: { source: "variant" },
79
+ headers: {
80
+ "X-Source": "variant"
81
+ }
82
+ }
83
+ ]
84
+ }
85
+ ]
86
+ });
87
+
88
+ const baseRes = await app.inject({ method: "GET", url: "/api/data" });
89
+ expect(baseRes.headers["x-source"]).toBe("endpoint");
90
+
91
+ const variantRes = await app.inject({ method: "GET", url: "/api/data?mode=special" });
92
+ expect(variantRes.headers["x-source"]).toBe("variant");
93
+
94
+ await app.close();
95
+ });
96
+
97
+ it("handles multiple custom headers", async () => {
98
+ const app = buildTestServer({
99
+ version: 1,
100
+ settings: baseSettings,
101
+ endpoints: [
102
+ {
103
+ method: "GET",
104
+ path: "/api/cors-test",
105
+ response: { ok: true },
106
+ headers: {
107
+ "Access-Control-Allow-Origin": "*",
108
+ "Access-Control-Allow-Methods": "GET, POST",
109
+ "X-RateLimit-Limit": "100",
110
+ "X-RateLimit-Remaining": "95"
111
+ }
112
+ }
113
+ ]
114
+ });
115
+
116
+ const res = await app.inject({ method: "GET", url: "/api/cors-test" });
117
+ expect(res.headers["access-control-allow-origin"]).toBe("*");
118
+ expect(res.headers["access-control-allow-methods"]).toBe("GET, POST");
119
+ expect(res.headers["x-ratelimit-limit"]).toBe("100");
120
+ expect(res.headers["x-ratelimit-remaining"]).toBe("95");
121
+
122
+ await app.close();
123
+ });
124
+ });
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { FastifyInstance } from "fastify";
3
+ import { buildServer } from "../src/server.js";
4
+ import { MockSpecSchema, type MockSpecInferSchema } from "../src/spec.js";
5
+
6
+ /**
7
+ * Load and validate a fixture spec file.
8
+ */
9
+ export async function loadFixture(path: string): Promise<MockSpecInferSchema> {
10
+ const raw = await readFile(path, "utf-8");
11
+ const json = JSON.parse(raw) as unknown;
12
+ return MockSpecSchema.parse(json);
13
+ }
14
+
15
+ /**
16
+ * Build a Fastify instance from a fixture spec file.
17
+ */
18
+ export async function buildTestServerFromFixture(path: string): Promise<FastifyInstance> {
19
+ const spec = await loadFixture(path);
20
+ return buildServer(spec, { specPath: path, loadedAt: "now", logger: false });
21
+ }
22
+
23
+ /**
24
+ * Build a Fastify instance from an in-memory spec object.
25
+ */
26
+ export function buildTestServer(spec: MockSpecInferSchema): FastifyInstance {
27
+ return buildServer(spec, { specPath: "inline", loadedAt: "now", logger: false });
28
+ }