@schmock/openapi 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/dist/crud-detector.d.ts +35 -0
- package/dist/crud-detector.d.ts.map +1 -0
- package/dist/crud-detector.js +153 -0
- package/dist/generators.d.ts +14 -0
- package/dist/generators.d.ts.map +1 -0
- package/dist/generators.js +158 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +221 -0
- package/dist/normalizer.d.ts +14 -0
- package/dist/normalizer.d.ts.map +1 -0
- package/dist/normalizer.js +194 -0
- package/dist/parser.d.ts +32 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +282 -0
- package/dist/plugin.d.ts +32 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +129 -0
- package/dist/seed.d.ts +15 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +41 -0
- package/package.json +45 -0
- package/src/__fixtures__/faker-stress-test.openapi.yaml +1030 -0
- package/src/__fixtures__/openapi31.json +34 -0
- package/src/__fixtures__/petstore-openapi3.json +168 -0
- package/src/__fixtures__/petstore-swagger2.json +141 -0
- package/src/__fixtures__/scalar-galaxy.yaml +1314 -0
- package/src/__fixtures__/stripe-fixtures3.json +6542 -0
- package/src/__fixtures__/stripe-spec3.yaml +161621 -0
- package/src/__fixtures__/train-travel.yaml +1264 -0
- package/src/crud-detector.test.ts +150 -0
- package/src/crud-detector.ts +194 -0
- package/src/generators.test.ts +214 -0
- package/src/generators.ts +212 -0
- package/src/index.ts +4 -0
- package/src/normalizer.test.ts +253 -0
- package/src/normalizer.ts +233 -0
- package/src/parser.test.ts +181 -0
- package/src/parser.ts +389 -0
- package/src/plugin.test.ts +205 -0
- package/src/plugin.ts +185 -0
- package/src/seed.ts +62 -0
- package/src/steps/openapi-crud.steps.ts +132 -0
- package/src/steps/openapi-parsing.steps.ts +111 -0
- package/src/steps/openapi-seed.steps.ts +94 -0
- package/src/stress.test.ts +2814 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { schmock } from "@schmock/core";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { openapi } from "./plugin";
|
|
5
|
+
|
|
6
|
+
const fixturesDir = resolve(import.meta.dirname, "__fixtures__");
|
|
7
|
+
|
|
8
|
+
describe("openapi plugin", () => {
|
|
9
|
+
describe("Swagger 2.0 integration", () => {
|
|
10
|
+
it("auto-registers all routes from Petstore spec", async () => {
|
|
11
|
+
const mock = schmock({ state: {} });
|
|
12
|
+
mock.pipe(
|
|
13
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// List (should be empty initially)
|
|
17
|
+
const listResponse = await mock.handle("GET", "/pets");
|
|
18
|
+
expect(listResponse.status).toBe(200);
|
|
19
|
+
expect(listResponse.body).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("CRUD lifecycle with Swagger 2.0", async () => {
|
|
23
|
+
const mock = schmock({ state: {} });
|
|
24
|
+
mock.pipe(
|
|
25
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Create
|
|
29
|
+
const created = await mock.handle("POST", "/pets", {
|
|
30
|
+
body: { name: "Buddy", tag: "dog" },
|
|
31
|
+
});
|
|
32
|
+
expect(created.status).toBe(201);
|
|
33
|
+
expect(created.body).toMatchObject({ name: "Buddy", petId: 1 });
|
|
34
|
+
|
|
35
|
+
// Read
|
|
36
|
+
const read = await mock.handle("GET", "/pets/1");
|
|
37
|
+
expect(read.status).toBe(200);
|
|
38
|
+
expect(read.body).toMatchObject({ name: "Buddy", petId: 1 });
|
|
39
|
+
|
|
40
|
+
// Update
|
|
41
|
+
const updated = await mock.handle("PUT", "/pets/1", {
|
|
42
|
+
body: { name: "Max" },
|
|
43
|
+
});
|
|
44
|
+
expect(updated.status).toBe(200);
|
|
45
|
+
expect(updated.body).toMatchObject({ name: "Max", petId: 1 });
|
|
46
|
+
|
|
47
|
+
// List
|
|
48
|
+
const list = await mock.handle("GET", "/pets");
|
|
49
|
+
expect(list.status).toBe(200);
|
|
50
|
+
expect(list.body).toHaveLength(1);
|
|
51
|
+
|
|
52
|
+
// Delete
|
|
53
|
+
const deleted = await mock.handle("DELETE", "/pets/1");
|
|
54
|
+
expect(deleted.status).toBe(204);
|
|
55
|
+
|
|
56
|
+
// Verify deletion
|
|
57
|
+
const afterDelete = await mock.handle("GET", "/pets/1");
|
|
58
|
+
expect(afterDelete.status).toBe(404);
|
|
59
|
+
expect(afterDelete.body).toMatchObject({
|
|
60
|
+
error: "Not found",
|
|
61
|
+
code: "NOT_FOUND",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles non-CRUD endpoints", async () => {
|
|
66
|
+
const mock = schmock({ state: {} });
|
|
67
|
+
mock.pipe(
|
|
68
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Health endpoint — should return a generated response
|
|
72
|
+
const health = await mock.handle("GET", "/health");
|
|
73
|
+
expect(health.status).toBe(200);
|
|
74
|
+
expect(health.body).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("OpenAPI 3.0 integration", () => {
|
|
79
|
+
it("auto-registers routes from OpenAPI 3.0 spec", async () => {
|
|
80
|
+
const mock = schmock({ state: {} });
|
|
81
|
+
mock.pipe(
|
|
82
|
+
await openapi({ spec: `${fixturesDir}/petstore-openapi3.json` }),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const list = await mock.handle("GET", "/pets");
|
|
86
|
+
expect(list.status).toBe(200);
|
|
87
|
+
expect(Array.isArray(list.body)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("seed data", () => {
|
|
92
|
+
it("seeds inline data", async () => {
|
|
93
|
+
const mock = schmock({ state: {} });
|
|
94
|
+
mock.pipe(
|
|
95
|
+
await openapi({
|
|
96
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
97
|
+
seed: {
|
|
98
|
+
pets: [
|
|
99
|
+
{ petId: 1, name: "Buddy" },
|
|
100
|
+
{ petId: 2, name: "Max" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const list = await mock.handle("GET", "/pets");
|
|
107
|
+
expect(list.status).toBe(200);
|
|
108
|
+
expect(list.body).toHaveLength(2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("seeds auto-generated data from schema", async () => {
|
|
112
|
+
const mock = schmock({ state: {} });
|
|
113
|
+
mock.pipe(
|
|
114
|
+
await openapi({
|
|
115
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
116
|
+
seed: {
|
|
117
|
+
pets: { count: 5 },
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const list = await mock.handle("GET", "/pets");
|
|
123
|
+
expect(list.status).toBe(200);
|
|
124
|
+
expect(list.body).toHaveLength(5);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("seeded data works with read endpoints", async () => {
|
|
128
|
+
const mock = schmock({ state: {} });
|
|
129
|
+
mock.pipe(
|
|
130
|
+
await openapi({
|
|
131
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
132
|
+
seed: {
|
|
133
|
+
pets: [{ petId: 42, name: "Luna" }],
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const read = await mock.handle("GET", "/pets/42");
|
|
139
|
+
expect(read.status).toBe(200);
|
|
140
|
+
expect(read.body).toMatchObject({ petId: 42, name: "Luna" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("continues auto-incrementing after seeded data", async () => {
|
|
144
|
+
const mock = schmock({ state: {} });
|
|
145
|
+
mock.pipe(
|
|
146
|
+
await openapi({
|
|
147
|
+
spec: `${fixturesDir}/petstore-swagger2.json`,
|
|
148
|
+
seed: {
|
|
149
|
+
pets: [
|
|
150
|
+
{ petId: 1, name: "Buddy" },
|
|
151
|
+
{ petId: 2, name: "Max" },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const created = await mock.handle("POST", "/pets", {
|
|
158
|
+
body: { name: "New" },
|
|
159
|
+
});
|
|
160
|
+
expect(created.status).toBe(201);
|
|
161
|
+
// Should get ID 3 since max existing ID is 2
|
|
162
|
+
expect(created.body).toMatchObject({ petId: 3 });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("404 handling", () => {
|
|
167
|
+
it("returns 404 for non-existent items", async () => {
|
|
168
|
+
const mock = schmock({ state: {} });
|
|
169
|
+
mock.pipe(
|
|
170
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const read = await mock.handle("GET", "/pets/999");
|
|
174
|
+
expect(read.status).toBe(404);
|
|
175
|
+
expect(read.body).toMatchObject({
|
|
176
|
+
error: "Not found",
|
|
177
|
+
code: "NOT_FOUND",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("state isolation", () => {
|
|
183
|
+
it("isolates state between separate mock instances", async () => {
|
|
184
|
+
const mock1 = schmock({ state: {} });
|
|
185
|
+
mock1.pipe(
|
|
186
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const mock2 = schmock({ state: {} });
|
|
190
|
+
mock2.pipe(
|
|
191
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await mock1.handle("POST", "/pets", { body: { name: "A" } });
|
|
195
|
+
|
|
196
|
+
const list1 = await mock1.handle("GET", "/pets");
|
|
197
|
+
const list2 = await mock2.handle("GET", "/pets");
|
|
198
|
+
|
|
199
|
+
const body1 = list1.body;
|
|
200
|
+
const body2 = list2.body;
|
|
201
|
+
expect(Array.isArray(body1) && body1.length).toBe(1);
|
|
202
|
+
expect(Array.isArray(body2) && body2.length).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { CrudResource } from "./crud-detector.js";
|
|
4
|
+
import { detectCrudResources } from "./crud-detector.js";
|
|
5
|
+
import {
|
|
6
|
+
createCreateGenerator,
|
|
7
|
+
createDeleteGenerator,
|
|
8
|
+
createListGenerator,
|
|
9
|
+
createReadGenerator,
|
|
10
|
+
createStaticGenerator,
|
|
11
|
+
createUpdateGenerator,
|
|
12
|
+
} from "./generators.js";
|
|
13
|
+
import { parseSpec } from "./parser.js";
|
|
14
|
+
import type { SeedConfig, SeedSource } from "./seed.js";
|
|
15
|
+
import { loadSeed } from "./seed.js";
|
|
16
|
+
|
|
17
|
+
export type { SeedConfig, SeedSource };
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OpenApiOptions {
|
|
24
|
+
/** File path or inline spec object */
|
|
25
|
+
spec: string | object;
|
|
26
|
+
/** Optional seed data per resource */
|
|
27
|
+
seed?: SeedConfig;
|
|
28
|
+
/** Validate request bodies (default: true) */
|
|
29
|
+
validateRequests?: boolean;
|
|
30
|
+
/** Validate response bodies (default: false) */
|
|
31
|
+
validateResponses?: boolean;
|
|
32
|
+
/** Query features for list endpoints */
|
|
33
|
+
queryFeatures?: {
|
|
34
|
+
pagination?: boolean;
|
|
35
|
+
sorting?: boolean;
|
|
36
|
+
filtering?: boolean;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create an OpenAPI plugin that auto-registers CRUD routes from a spec.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const mock = schmock();
|
|
46
|
+
* mock.pipe(await openapi({
|
|
47
|
+
* spec: "./petstore.yaml",
|
|
48
|
+
* seed: { pets: { count: 10 } },
|
|
49
|
+
* }));
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export async function openapi(
|
|
53
|
+
options: OpenApiOptions,
|
|
54
|
+
): Promise<Schmock.Plugin> {
|
|
55
|
+
const spec = await parseSpec(options.spec);
|
|
56
|
+
const { resources, nonCrudPaths } = detectCrudResources(spec.paths);
|
|
57
|
+
const seedData = options.seed
|
|
58
|
+
? loadSeed(options.seed, resources)
|
|
59
|
+
: new Map<string, unknown[]>();
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: "@schmock/openapi",
|
|
63
|
+
version: "1.0.0",
|
|
64
|
+
|
|
65
|
+
install(instance: Schmock.CallableMockInstance) {
|
|
66
|
+
// Seed initial state — we need state to be initialized before registering routes
|
|
67
|
+
// The state is populated via generator context.state on first request,
|
|
68
|
+
// but we need to pre-populate it. We'll use a global config state approach.
|
|
69
|
+
// Since generators use ctx.state (the global config state), we set up seed
|
|
70
|
+
// data through a setup route that runs on first access, or we just seed
|
|
71
|
+
// in the generators themselves.
|
|
72
|
+
|
|
73
|
+
// Register CRUD routes
|
|
74
|
+
for (const resource of resources) {
|
|
75
|
+
registerCrudRoutes(instance, resource, seedData.get(resource.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Register non-CRUD routes with static generators
|
|
79
|
+
for (const parsedPath of nonCrudPaths) {
|
|
80
|
+
const routeKey =
|
|
81
|
+
`${parsedPath.method} ${parsedPath.path}` as Schmock.RouteKey;
|
|
82
|
+
instance(routeKey, createStaticGenerator(parsedPath));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
process(
|
|
87
|
+
context: Schmock.PluginContext,
|
|
88
|
+
response?: unknown,
|
|
89
|
+
): Schmock.PluginResult {
|
|
90
|
+
// Pass through — generators handle everything
|
|
91
|
+
return { context, response };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function registerCrudRoutes(
|
|
97
|
+
instance: Schmock.CallableMockInstance,
|
|
98
|
+
resource: CrudResource,
|
|
99
|
+
seedItems?: unknown[],
|
|
100
|
+
): void {
|
|
101
|
+
// Create a seeded generator wrapper that initializes state on first call
|
|
102
|
+
const ensureSeeded = createSeeder(resource, seedItems);
|
|
103
|
+
|
|
104
|
+
for (const op of resource.operations) {
|
|
105
|
+
switch (op) {
|
|
106
|
+
case "list": {
|
|
107
|
+
const gen = createListGenerator(resource);
|
|
108
|
+
const routeKey = `GET ${resource.basePath}` as Schmock.RouteKey;
|
|
109
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "create": {
|
|
113
|
+
const gen = createCreateGenerator(resource);
|
|
114
|
+
const routeKey = `POST ${resource.basePath}` as Schmock.RouteKey;
|
|
115
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "read": {
|
|
119
|
+
const gen = createReadGenerator(resource);
|
|
120
|
+
const routeKey = `GET ${resource.itemPath}` as Schmock.RouteKey;
|
|
121
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "update": {
|
|
125
|
+
const gen = createUpdateGenerator(resource);
|
|
126
|
+
const putKey = `PUT ${resource.itemPath}` as Schmock.RouteKey;
|
|
127
|
+
const patchKey = `PATCH ${resource.itemPath}` as Schmock.RouteKey;
|
|
128
|
+
instance(putKey, wrapWithSeeder(ensureSeeded, gen));
|
|
129
|
+
instance(patchKey, wrapWithSeeder(ensureSeeded, gen));
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "delete": {
|
|
133
|
+
const gen = createDeleteGenerator(resource);
|
|
134
|
+
const routeKey = `DELETE ${resource.itemPath}` as Schmock.RouteKey;
|
|
135
|
+
instance(routeKey, wrapWithSeeder(ensureSeeded, gen));
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a seeder function that initializes collection state once.
|
|
144
|
+
*/
|
|
145
|
+
function createSeeder(
|
|
146
|
+
resource: CrudResource,
|
|
147
|
+
seedItems?: unknown[],
|
|
148
|
+
): (state: Record<string, unknown>) => void {
|
|
149
|
+
const stateKey = `openapi:collections:${resource.name}`;
|
|
150
|
+
const counterKey = `openapi:counter:${resource.name}`;
|
|
151
|
+
const seededKey = `openapi:seeded:${resource.name}`;
|
|
152
|
+
|
|
153
|
+
return (state: Record<string, unknown>) => {
|
|
154
|
+
if (state[seededKey]) return;
|
|
155
|
+
state[seededKey] = true;
|
|
156
|
+
|
|
157
|
+
if (seedItems && seedItems.length > 0) {
|
|
158
|
+
state[stateKey] = [...seedItems];
|
|
159
|
+
// Set counter to highest existing ID
|
|
160
|
+
let maxId = 0;
|
|
161
|
+
for (const item of seedItems) {
|
|
162
|
+
if (isRecord(item) && resource.idParam in item) {
|
|
163
|
+
const id = item[resource.idParam];
|
|
164
|
+
if (typeof id === "number" && id > maxId) {
|
|
165
|
+
maxId = id;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
state[counterKey] = maxId;
|
|
170
|
+
} else {
|
|
171
|
+
state[stateKey] = [];
|
|
172
|
+
state[counterKey] = 0;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function wrapWithSeeder(
|
|
178
|
+
seeder: (state: Record<string, unknown>) => void,
|
|
179
|
+
generator: Schmock.GeneratorFunction,
|
|
180
|
+
): Schmock.GeneratorFunction {
|
|
181
|
+
return (ctx: Schmock.RequestContext) => {
|
|
182
|
+
seeder(ctx.state);
|
|
183
|
+
return generator(ctx);
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/seed.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/// <reference path="../../../types/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import type { CrudResource } from "./crud-detector.js";
|
|
5
|
+
import { generateSeedItems } from "./generators.js";
|
|
6
|
+
|
|
7
|
+
export type SeedSource = unknown[] | string | { count: number };
|
|
8
|
+
|
|
9
|
+
export type SeedConfig = Record<string, SeedSource>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load seed data for CRUD resources.
|
|
13
|
+
*
|
|
14
|
+
* Sources:
|
|
15
|
+
* - unknown[]: inline array of objects
|
|
16
|
+
* - string: file path to a JSON array
|
|
17
|
+
* - { count: number }: auto-generate N items from resource schema
|
|
18
|
+
*/
|
|
19
|
+
export function loadSeed(
|
|
20
|
+
config: SeedConfig,
|
|
21
|
+
resources: CrudResource[],
|
|
22
|
+
): Map<string, unknown[]> {
|
|
23
|
+
const result = new Map<string, unknown[]>();
|
|
24
|
+
|
|
25
|
+
for (const [resourceName, source] of Object.entries(config)) {
|
|
26
|
+
const resource = resources.find((r) => r.name === resourceName);
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(source)) {
|
|
29
|
+
// Inline array
|
|
30
|
+
result.set(resourceName, [...source]);
|
|
31
|
+
} else if (typeof source === "string") {
|
|
32
|
+
// File path
|
|
33
|
+
const content = readFileSync(source, "utf-8");
|
|
34
|
+
const parsed: unknown = JSON.parse(content);
|
|
35
|
+
if (!Array.isArray(parsed)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Seed file "${source}" for resource "${resourceName}" must contain a JSON array`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
result.set(resourceName, parsed);
|
|
41
|
+
} else if (
|
|
42
|
+
typeof source === "object" &&
|
|
43
|
+
source !== null &&
|
|
44
|
+
"count" in source
|
|
45
|
+
) {
|
|
46
|
+
// Auto-generate from schema
|
|
47
|
+
if (!resource?.schema) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Cannot auto-generate seed for "${resourceName}": no schema found in spec`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const items = generateSeedItems(
|
|
53
|
+
resource.schema,
|
|
54
|
+
source.count,
|
|
55
|
+
resource.idParam,
|
|
56
|
+
);
|
|
57
|
+
result.set(resourceName, items);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
3
|
+
import { schmock } from "@schmock/core";
|
|
4
|
+
import { expect } from "vitest";
|
|
5
|
+
import { openapi } from "../plugin";
|
|
6
|
+
|
|
7
|
+
const feature = await loadFeature("../../features/openapi-crud.feature");
|
|
8
|
+
const fixturesDir = resolve(import.meta.dirname, "../__fixtures__");
|
|
9
|
+
|
|
10
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
11
|
+
let mock: Schmock.CallableMockInstance;
|
|
12
|
+
let response: Schmock.Response;
|
|
13
|
+
let createdId: number;
|
|
14
|
+
|
|
15
|
+
Scenario("Full CRUD lifecycle", ({ Given, When, Then, And }) => {
|
|
16
|
+
Given("a mock with the Petstore spec loaded", async () => {
|
|
17
|
+
mock = schmock({ state: {} });
|
|
18
|
+
mock.pipe(
|
|
19
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
When("I create a pet named {string}", async (_, name: string) => {
|
|
24
|
+
response = await mock.handle("POST", "/pets", {
|
|
25
|
+
body: { name, tag: "dog" },
|
|
26
|
+
});
|
|
27
|
+
const body = response.body as Record<string, unknown>;
|
|
28
|
+
createdId = body.petId as number;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
Then("the create response has status 201", () => {
|
|
32
|
+
expect(response.status).toBe(201);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
And("the created pet has name {string}", (_, name: string) => {
|
|
36
|
+
const body = response.body as Record<string, unknown>;
|
|
37
|
+
expect(body.name).toBe(name);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
When("I read the created pet", async () => {
|
|
41
|
+
response = await mock.handle("GET", `/pets/${createdId}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
Then("the read response has status 200", () => {
|
|
45
|
+
expect(response.status).toBe(200);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
And("the pet has name {string}", (_, name: string) => {
|
|
49
|
+
const body = response.body as Record<string, unknown>;
|
|
50
|
+
expect(body.name).toBe(name);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
When("I update the pet name to {string}", async (_, name: string) => {
|
|
54
|
+
response = await mock.handle("PUT", `/pets/${createdId}`, {
|
|
55
|
+
body: { name },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
Then("the update response has status 200", () => {
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
When("I list all pets", async () => {
|
|
64
|
+
response = await mock.handle("GET", "/pets");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
Then("the list contains {int} item", (_, count: number) => {
|
|
68
|
+
expect(response.body).toHaveLength(count);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
When("I delete the pet", async () => {
|
|
72
|
+
response = await mock.handle("DELETE", `/pets/${createdId}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
Then("the delete response has status 204", () => {
|
|
76
|
+
expect(response.status).toBe(204);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
When("I list all pets after deletion", async () => {
|
|
80
|
+
response = await mock.handle("GET", "/pets");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
Then("the list is empty", () => {
|
|
84
|
+
expect(response.body).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Scenario(
|
|
89
|
+
"Read non-existent resource returns 404",
|
|
90
|
+
({ Given, When, Then, And }) => {
|
|
91
|
+
Given("a mock with the Petstore spec loaded", async () => {
|
|
92
|
+
mock = schmock({ state: {} });
|
|
93
|
+
mock.pipe(
|
|
94
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
When("I read pet with id 999", async () => {
|
|
99
|
+
response = await mock.handle("GET", "/pets/999");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
Then("the response status is 404", () => {
|
|
103
|
+
expect(response.status).toBe(404);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
And("the response has error code {string}", (_, code: string) => {
|
|
107
|
+
const body = response.body as Record<string, unknown>;
|
|
108
|
+
expect(body.code).toBe(code);
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
Scenario(
|
|
114
|
+
"Delete non-existent resource returns 404",
|
|
115
|
+
({ Given, When, Then }) => {
|
|
116
|
+
Given("a mock with the Petstore spec loaded", async () => {
|
|
117
|
+
mock = schmock({ state: {} });
|
|
118
|
+
mock.pipe(
|
|
119
|
+
await openapi({ spec: `${fixturesDir}/petstore-swagger2.json` }),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
When("I delete pet with id 999", async () => {
|
|
124
|
+
response = await mock.handle("DELETE", "/pets/999");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
Then("the response status is 404", () => {
|
|
128
|
+
expect(response.status).toBe(404);
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
3
|
+
import { schmock } from "@schmock/core";
|
|
4
|
+
import { expect } from "vitest";
|
|
5
|
+
import type { ParsedSpec } from "../parser";
|
|
6
|
+
import { parseSpec } from "../parser";
|
|
7
|
+
import { openapi } from "../plugin";
|
|
8
|
+
|
|
9
|
+
const feature = await loadFeature("../../features/openapi-parsing.feature");
|
|
10
|
+
const fixturesDir = resolve(import.meta.dirname, "../__fixtures__");
|
|
11
|
+
|
|
12
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
13
|
+
let parsedSpec: ParsedSpec;
|
|
14
|
+
let mock: Schmock.CallableMockInstance;
|
|
15
|
+
|
|
16
|
+
Scenario("Parse Swagger 2.0 spec", ({ Given, When, Then, And }) => {
|
|
17
|
+
Given("a Swagger 2.0 Petstore spec", () => {
|
|
18
|
+
// Spec will be loaded in the When step
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
When("I create an openapi plugin from the spec", async () => {
|
|
22
|
+
parsedSpec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
Then("the parsed spec has title {string}", (_, title: string) => {
|
|
26
|
+
expect(parsedSpec.title).toBe(title);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
And("the parsed spec has version {string}", (_, version: string) => {
|
|
30
|
+
expect(parsedSpec.version).toBe(version);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
And("the parsed spec has basePath {string}", (_, basePath: string) => {
|
|
34
|
+
expect(parsedSpec.basePath).toBe(basePath);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Scenario("Parse OpenAPI 3.0 spec", ({ Given, When, Then, And }) => {
|
|
39
|
+
Given("an OpenAPI 3.0 Petstore spec", () => {
|
|
40
|
+
// Spec will be loaded in the When step
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
When("I create an openapi plugin from the spec", async () => {
|
|
44
|
+
parsedSpec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
|
|
45
|
+
mock = schmock({ state: {} });
|
|
46
|
+
mock.pipe(
|
|
47
|
+
await openapi({ spec: `${fixturesDir}/petstore-openapi3.json` }),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
Then("the parsed spec has title {string}", (_, title: string) => {
|
|
52
|
+
expect(parsedSpec.title).toBe(title);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
And("the parsed spec has version {string}", (_, version: string) => {
|
|
56
|
+
expect(parsedSpec.version).toBe(version);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
And("routes are auto-registered from the spec", async () => {
|
|
60
|
+
const response = await mock.handle("GET", "/pets");
|
|
61
|
+
expect(response.status).toBe(200);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Scenario("Parse inline spec object", ({ Given, When, Then }) => {
|
|
66
|
+
Given("an inline OpenAPI spec object", () => {
|
|
67
|
+
// Inline spec prepared in When step
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
When("I create an openapi plugin from the inline spec", async () => {
|
|
71
|
+
mock = schmock({ state: {} });
|
|
72
|
+
mock.pipe(
|
|
73
|
+
await openapi({
|
|
74
|
+
spec: {
|
|
75
|
+
openapi: "3.0.3",
|
|
76
|
+
info: { title: "Inline", version: "1.0.0" },
|
|
77
|
+
paths: {
|
|
78
|
+
"/hello": {
|
|
79
|
+
get: {
|
|
80
|
+
responses: {
|
|
81
|
+
"200": {
|
|
82
|
+
description: "Hello",
|
|
83
|
+
content: {
|
|
84
|
+
"application/json": {
|
|
85
|
+
schema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: { msg: { type: "string" } },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
post: {
|
|
95
|
+
responses: {
|
|
96
|
+
"201": { description: "Created" },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
Then("routes are registered from the inline spec", async () => {
|
|
107
|
+
const response = await mock.handle("GET", "/hello");
|
|
108
|
+
expect(response.status).toBe(200);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|