@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.
Files changed (46) hide show
  1. package/dist/crud-detector.d.ts +35 -0
  2. package/dist/crud-detector.d.ts.map +1 -0
  3. package/dist/crud-detector.js +153 -0
  4. package/dist/generators.d.ts +14 -0
  5. package/dist/generators.d.ts.map +1 -0
  6. package/dist/generators.js +158 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +221 -0
  10. package/dist/normalizer.d.ts +14 -0
  11. package/dist/normalizer.d.ts.map +1 -0
  12. package/dist/normalizer.js +194 -0
  13. package/dist/parser.d.ts +32 -0
  14. package/dist/parser.d.ts.map +1 -0
  15. package/dist/parser.js +282 -0
  16. package/dist/plugin.d.ts +32 -0
  17. package/dist/plugin.d.ts.map +1 -0
  18. package/dist/plugin.js +129 -0
  19. package/dist/seed.d.ts +15 -0
  20. package/dist/seed.d.ts.map +1 -0
  21. package/dist/seed.js +41 -0
  22. package/package.json +45 -0
  23. package/src/__fixtures__/faker-stress-test.openapi.yaml +1030 -0
  24. package/src/__fixtures__/openapi31.json +34 -0
  25. package/src/__fixtures__/petstore-openapi3.json +168 -0
  26. package/src/__fixtures__/petstore-swagger2.json +141 -0
  27. package/src/__fixtures__/scalar-galaxy.yaml +1314 -0
  28. package/src/__fixtures__/stripe-fixtures3.json +6542 -0
  29. package/src/__fixtures__/stripe-spec3.yaml +161621 -0
  30. package/src/__fixtures__/train-travel.yaml +1264 -0
  31. package/src/crud-detector.test.ts +150 -0
  32. package/src/crud-detector.ts +194 -0
  33. package/src/generators.test.ts +214 -0
  34. package/src/generators.ts +212 -0
  35. package/src/index.ts +4 -0
  36. package/src/normalizer.test.ts +253 -0
  37. package/src/normalizer.ts +233 -0
  38. package/src/parser.test.ts +181 -0
  39. package/src/parser.ts +389 -0
  40. package/src/plugin.test.ts +205 -0
  41. package/src/plugin.ts +185 -0
  42. package/src/seed.ts +62 -0
  43. package/src/steps/openapi-crud.steps.ts +132 -0
  44. package/src/steps/openapi-parsing.steps.ts +111 -0
  45. package/src/steps/openapi-seed.steps.ts +94 -0
  46. package/src/stress.test.ts +2814 -0
@@ -0,0 +1,181 @@
1
+ import { resolve } from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { parseSpec } from "./parser";
4
+
5
+ const fixturesDir = resolve(import.meta.dirname, "__fixtures__");
6
+
7
+ describe("parseSpec", () => {
8
+ describe("Swagger 2.0", () => {
9
+ it("parses Swagger 2.0 Petstore spec", async () => {
10
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
11
+
12
+ expect(spec.title).toBe("Petstore");
13
+ expect(spec.version).toBe("1.0.0");
14
+ expect(spec.basePath).toBe("/api");
15
+ expect(spec.paths.length).toBeGreaterThan(0);
16
+ });
17
+
18
+ it("extracts path parameters from Swagger 2.0", async () => {
19
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
20
+ const getPet = spec.paths.find(
21
+ (p) => p.method === "GET" && p.path === "/pets/:petId",
22
+ );
23
+
24
+ expect(getPet).toBeDefined();
25
+ expect(getPet?.parameters).toContainEqual(
26
+ expect.objectContaining({
27
+ name: "petId",
28
+ in: "path",
29
+ required: true,
30
+ }),
31
+ );
32
+ });
33
+
34
+ it("extracts query parameters", async () => {
35
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
36
+ const listPets = spec.paths.find(
37
+ (p) => p.method === "GET" && p.path === "/pets",
38
+ );
39
+
40
+ expect(listPets).toBeDefined();
41
+ expect(listPets?.parameters).toContainEqual(
42
+ expect.objectContaining({
43
+ name: "limit",
44
+ in: "query",
45
+ }),
46
+ );
47
+ });
48
+
49
+ it("extracts request body from Swagger 2.0 body parameter", async () => {
50
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
51
+ const createPet = spec.paths.find(
52
+ (p) => p.method === "POST" && p.path === "/pets",
53
+ );
54
+
55
+ expect(createPet).toBeDefined();
56
+ expect(createPet?.requestBody).toBeDefined();
57
+ expect(createPet?.requestBody?.type).toBe("object");
58
+ });
59
+
60
+ it("extracts response schemas with multiple status codes", async () => {
61
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
62
+ const listPets = spec.paths.find(
63
+ (p) => p.method === "GET" && p.path === "/pets",
64
+ );
65
+
66
+ expect(listPets).toBeDefined();
67
+ expect(listPets?.responses.has(200)).toBe(true);
68
+ expect(listPets?.responses.get(200)?.schema?.type).toBe("array");
69
+ });
70
+
71
+ it("converts {param} to :param in paths", async () => {
72
+ const spec = await parseSpec(`${fixturesDir}/petstore-swagger2.json`);
73
+ const getPet = spec.paths.find(
74
+ (p) => p.method === "GET" && p.path === "/pets/:petId",
75
+ );
76
+ expect(getPet).toBeDefined();
77
+ });
78
+ });
79
+
80
+ describe("OpenAPI 3.0", () => {
81
+ it("parses OpenAPI 3.0 spec", async () => {
82
+ const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
83
+
84
+ expect(spec.title).toBe("Petstore");
85
+ expect(spec.version).toBe("2.0.0");
86
+ expect(spec.basePath).toBe("/v2");
87
+ });
88
+
89
+ it("extracts request body from OpenAPI 3.x requestBody", async () => {
90
+ const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
91
+ const createPet = spec.paths.find(
92
+ (p) => p.method === "POST" && p.path === "/pets",
93
+ );
94
+
95
+ expect(createPet).toBeDefined();
96
+ expect(createPet?.requestBody).toBeDefined();
97
+ });
98
+
99
+ it("resolves $ref pointers via dereference", async () => {
100
+ const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
101
+ const getPet = spec.paths.find(
102
+ (p) => p.method === "GET" && p.path === "/pets/:petId",
103
+ );
104
+
105
+ // Should be fully resolved, no $ref
106
+ expect(getPet?.responses.get(200)?.schema).toBeDefined();
107
+ expect(getPet?.responses.get(200)?.schema).not.toHaveProperty("$ref");
108
+ });
109
+
110
+ it("merges path-level and operation-level parameters", async () => {
111
+ const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
112
+ const getPet = spec.paths.find(
113
+ (p) => p.method === "GET" && p.path === "/pets/:petId",
114
+ );
115
+
116
+ // petId is defined at path level, should be merged into operation
117
+ expect(getPet?.parameters).toContainEqual(
118
+ expect.objectContaining({
119
+ name: "petId",
120
+ in: "path",
121
+ }),
122
+ );
123
+ });
124
+
125
+ it("extracts tags", async () => {
126
+ const spec = await parseSpec(`${fixturesDir}/petstore-openapi3.json`);
127
+ const listPets = spec.paths.find(
128
+ (p) => p.method === "GET" && p.path === "/pets",
129
+ );
130
+ expect(listPets?.tags).toContain("pets");
131
+ });
132
+ });
133
+
134
+ describe("OpenAPI 3.1", () => {
135
+ it("parses OpenAPI 3.1 spec", async () => {
136
+ const spec = await parseSpec(`${fixturesDir}/openapi31.json`);
137
+
138
+ expect(spec.title).toBe("Simple API");
139
+ expect(spec.version).toBe("3.1.0");
140
+ });
141
+ });
142
+
143
+ describe("inline spec objects", () => {
144
+ it("accepts inline spec object", async () => {
145
+ const spec = await parseSpec({
146
+ openapi: "3.0.3",
147
+ info: { title: "Inline", version: "1.0.0" },
148
+ paths: {
149
+ "/hello": {
150
+ get: {
151
+ responses: {
152
+ "200": {
153
+ description: "Hello",
154
+ content: {
155
+ "application/json": {
156
+ schema: {
157
+ type: "object",
158
+ properties: { msg: { type: "string" } },
159
+ },
160
+ },
161
+ },
162
+ },
163
+ },
164
+ },
165
+ },
166
+ },
167
+ });
168
+
169
+ expect(spec.title).toBe("Inline");
170
+ expect(spec.paths).toHaveLength(1);
171
+ expect(spec.paths[0].method).toBe("GET");
172
+ expect(spec.paths[0].path).toBe("/hello");
173
+ });
174
+ });
175
+
176
+ describe("error handling", () => {
177
+ it("throws on invalid spec", async () => {
178
+ await expect(parseSpec({ invalid: true })).rejects.toThrow();
179
+ });
180
+ });
181
+ });
package/src/parser.ts ADDED
@@ -0,0 +1,389 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+
3
+ import SwaggerParser from "@apidevtools/swagger-parser";
4
+ import { toHttpMethod } from "@schmock/core";
5
+ import type { JSONSchema7 } from "json-schema";
6
+ import type { OpenAPI } from "openapi-types";
7
+ import { normalizeSchema } from "./normalizer.js";
8
+
9
+ export interface ParsedSpec {
10
+ title: string;
11
+ version: string;
12
+ basePath: string;
13
+ paths: ParsedPath[];
14
+ }
15
+
16
+ export interface ParsedPath {
17
+ /** Express-style path e.g. "/pets/:petId" */
18
+ path: string;
19
+ method: Schmock.HttpMethod;
20
+ operationId?: string;
21
+ parameters: ParsedParameter[];
22
+ requestBody?: JSONSchema7;
23
+ responses: Map<number, { schema?: JSONSchema7; description: string }>;
24
+ tags: string[];
25
+ }
26
+
27
+ export interface ParsedParameter {
28
+ name: string;
29
+ in: "path" | "query" | "header";
30
+ required: boolean;
31
+ schema?: JSONSchema7;
32
+ }
33
+
34
+ const HTTP_METHOD_KEYS = new Set([
35
+ "get",
36
+ "post",
37
+ "put",
38
+ "delete",
39
+ "patch",
40
+ "head",
41
+ "options",
42
+ ]);
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function isOpenApiDocument(value: unknown): value is OpenAPI.Document {
49
+ return isRecord(value) && ("swagger" in value || "openapi" in value);
50
+ }
51
+
52
+ function getString(value: unknown): string | undefined {
53
+ return typeof value === "string" ? value : undefined;
54
+ }
55
+
56
+ function getBoolean(value: unknown, fallback: boolean): boolean {
57
+ return typeof value === "boolean" ? value : fallback;
58
+ }
59
+
60
+ /**
61
+ * Strip root-level x-* extensions from a spec object.
62
+ * These may contain $ref to external docs (e.g. markdown files)
63
+ * that swagger-parser cannot resolve.
64
+ */
65
+ function stripRootExtensions(spec: object): void {
66
+ for (const key of Object.keys(spec)) {
67
+ if (key.startsWith("x-")) {
68
+ Reflect.deleteProperty(spec, key);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Ensure a paths key exists on a spec object (required by swagger-parser validation).
75
+ */
76
+ function ensurePathsKey(spec: object): void {
77
+ if (!("paths" in spec)) {
78
+ Object.assign(spec, { paths: {} });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Parse an OpenAPI/Swagger spec into a normalized internal model.
84
+ * Supports Swagger 2.0, OpenAPI 3.0, and 3.1.
85
+ */
86
+ export async function parseSpec(source: string | object): Promise<ParsedSpec> {
87
+ let api: OpenAPI.Document;
88
+ if (typeof source === "string") {
89
+ // Parse raw YAML/JSON first (no ref resolution)
90
+ const raw = await SwaggerParser.parse(source);
91
+ stripRootExtensions(raw);
92
+ ensurePathsKey(raw);
93
+ api = await SwaggerParser.dereference(raw);
94
+ } else if (isOpenApiDocument(source)) {
95
+ const copy = structuredClone(source);
96
+ stripRootExtensions(copy);
97
+ ensurePathsKey(copy);
98
+ api = await SwaggerParser.dereference(copy);
99
+ } else {
100
+ throw new Error(
101
+ "Invalid OpenAPI spec: must be a string path or an OpenAPI document object",
102
+ );
103
+ }
104
+
105
+ const isSwagger2 = "swagger" in api && typeof api.swagger === "string";
106
+ const title = api.info?.title ?? "Untitled";
107
+ const version = api.info?.version ?? "0.0.0";
108
+
109
+ let basePath = "";
110
+ if (isSwagger2 && "basePath" in api) {
111
+ const bp = api.basePath;
112
+ basePath = typeof bp === "string" ? bp : "";
113
+ } else if (
114
+ "servers" in api &&
115
+ Array.isArray(api.servers) &&
116
+ api.servers.length > 0
117
+ ) {
118
+ const firstServer = api.servers[0];
119
+ if (isRecord(firstServer) && typeof firstServer.url === "string") {
120
+ try {
121
+ const url = new URL(firstServer.url, "http://localhost");
122
+ basePath = url.pathname === "/" ? "" : url.pathname;
123
+ } catch {
124
+ basePath = "";
125
+ }
126
+ }
127
+ }
128
+ // Strip trailing slash from basePath
129
+ if (basePath.endsWith("/") && basePath !== "/") {
130
+ basePath = basePath.slice(0, -1);
131
+ }
132
+
133
+ const paths: ParsedPath[] = [];
134
+ const rawPaths =
135
+ "paths" in api && isRecord(api.paths) ? api.paths : undefined;
136
+
137
+ if (!rawPaths) {
138
+ return { title, version, basePath, paths };
139
+ }
140
+
141
+ for (const [pathTemplate, pathItemRaw] of Object.entries(rawPaths)) {
142
+ if (!isRecord(pathItemRaw)) continue;
143
+ const pathItem = pathItemRaw;
144
+
145
+ // Extract path-level parameters
146
+ const pathLevelParams = extractParameters(
147
+ Array.isArray(pathItem.parameters) ? pathItem.parameters : undefined,
148
+ isSwagger2,
149
+ );
150
+
151
+ for (const methodKey of Object.keys(pathItem)) {
152
+ if (!HTTP_METHOD_KEYS.has(methodKey)) continue;
153
+
154
+ const operation = pathItem[methodKey];
155
+ if (!isRecord(operation)) continue;
156
+
157
+ const method = toHttpMethod(methodKey.toUpperCase());
158
+
159
+ // Merge path-level + operation-level parameters (operation wins)
160
+ const operationParams = extractParameters(
161
+ Array.isArray(operation.parameters) ? operation.parameters : undefined,
162
+ isSwagger2,
163
+ );
164
+ const mergedParams = mergeParameters(pathLevelParams, operationParams);
165
+
166
+ // Extract request body
167
+ let requestBody: JSONSchema7 | undefined;
168
+ if (isSwagger2) {
169
+ requestBody = extractSwagger2RequestBody(mergedParams);
170
+ } else {
171
+ requestBody = extractOpenApi3RequestBody(
172
+ isRecord(operation.requestBody) ? operation.requestBody : undefined,
173
+ );
174
+ }
175
+
176
+ // Extract responses
177
+ const responses = extractResponses(
178
+ isRecord(operation.responses) ? operation.responses : undefined,
179
+ isSwagger2,
180
+ );
181
+
182
+ // Convert path template: {petId} -> :petId
183
+ const expressPath = convertPathTemplate(pathTemplate);
184
+
185
+ const tags = Array.isArray(operation.tags)
186
+ ? operation.tags.filter((t): t is string => typeof t === "string")
187
+ : [];
188
+
189
+ // Filter out body parameters from the final parameter list (Swagger 2.0)
190
+ const filteredParams = mergedParams.filter(isNotBodyParam);
191
+
192
+ paths.push({
193
+ path: expressPath,
194
+ method,
195
+ operationId: getString(operation.operationId),
196
+ parameters: filteredParams,
197
+ requestBody,
198
+ responses,
199
+ tags,
200
+ });
201
+ }
202
+ }
203
+
204
+ return { title, version, basePath, paths };
205
+ }
206
+
207
+ interface InternalParameter {
208
+ name: string;
209
+ in: "path" | "query" | "header" | "body";
210
+ required: boolean;
211
+ schema?: JSONSchema7;
212
+ }
213
+
214
+ function isValidParamLocation(
215
+ location: string,
216
+ isSwagger2: boolean,
217
+ ): location is "path" | "query" | "header" | "body" {
218
+ const validLocations = isSwagger2
219
+ ? ["path", "query", "header", "body"]
220
+ : ["path", "query", "header"];
221
+ return validLocations.includes(location);
222
+ }
223
+
224
+ function isNotBodyParam(param: InternalParameter): param is ParsedParameter {
225
+ return param.in !== "body";
226
+ }
227
+
228
+ function extractParameters(
229
+ params: unknown[] | undefined,
230
+ isSwagger2: boolean,
231
+ ): InternalParameter[] {
232
+ if (!params || !Array.isArray(params)) return [];
233
+
234
+ return params
235
+ .filter((p): p is Record<string, unknown> => isRecord(p))
236
+ .map((p): InternalParameter | null => {
237
+ const location = getString(p.in);
238
+ if (!location || !isValidParamLocation(location, isSwagger2)) {
239
+ return null;
240
+ }
241
+
242
+ let schema: JSONSchema7 | undefined;
243
+ if (isSwagger2) {
244
+ // Swagger 2.0: schema is inline on the parameter (type, format, etc.)
245
+ if (location === "body") {
246
+ schema = isRecord(p.schema)
247
+ ? normalizeSchema(p.schema, "request")
248
+ : undefined;
249
+ } else {
250
+ schema = p.type
251
+ ? normalizeSchema(
252
+ { type: p.type, format: p.format, enum: p.enum },
253
+ "request",
254
+ )
255
+ : undefined;
256
+ }
257
+ } else {
258
+ // OpenAPI 3.x: schema is nested
259
+ schema = isRecord(p.schema)
260
+ ? normalizeSchema(p.schema, "request")
261
+ : undefined;
262
+ }
263
+
264
+ const name = getString(p.name);
265
+ if (!name) return null;
266
+
267
+ return {
268
+ name,
269
+ in: location,
270
+ required: getBoolean(p.required, false),
271
+ schema,
272
+ };
273
+ })
274
+ .filter((p): p is InternalParameter => p !== null);
275
+ }
276
+
277
+ function mergeParameters(
278
+ pathLevel: InternalParameter[],
279
+ operationLevel: InternalParameter[],
280
+ ): InternalParameter[] {
281
+ const merged = new Map<string, InternalParameter>();
282
+
283
+ // Path-level first
284
+ for (const p of pathLevel) {
285
+ merged.set(`${p.in}:${p.name}`, p);
286
+ }
287
+ // Operation-level overwrites
288
+ for (const p of operationLevel) {
289
+ merged.set(`${p.in}:${p.name}`, p);
290
+ }
291
+
292
+ return [...merged.values()];
293
+ }
294
+
295
+ function extractSwagger2RequestBody(
296
+ params: InternalParameter[],
297
+ ): JSONSchema7 | undefined {
298
+ const bodyParam = params.find((p) => p.in === "body");
299
+ return bodyParam?.schema;
300
+ }
301
+
302
+ function extractOpenApi3RequestBody(
303
+ requestBody: Record<string, unknown> | undefined,
304
+ ): JSONSchema7 | undefined {
305
+ if (!requestBody) return undefined;
306
+
307
+ const content = isRecord(requestBody.content)
308
+ ? requestBody.content
309
+ : undefined;
310
+ if (!content) return undefined;
311
+
312
+ const jsonEntry = findJsonContent(content);
313
+ if (!jsonEntry) return undefined;
314
+
315
+ const schema = isRecord(jsonEntry.schema) ? jsonEntry.schema : undefined;
316
+ if (!schema) return undefined;
317
+
318
+ return normalizeSchema(schema, "request");
319
+ }
320
+
321
+ function extractResponses(
322
+ responses: Record<string, unknown> | undefined,
323
+ isSwagger2: boolean,
324
+ ): Map<number, { schema?: JSONSchema7; description: string }> {
325
+ const result = new Map<
326
+ number,
327
+ { schema?: JSONSchema7; description: string }
328
+ >();
329
+
330
+ if (!responses) return result;
331
+
332
+ for (const [statusCode, response] of Object.entries(responses)) {
333
+ if (statusCode === "default") continue;
334
+ if (!isRecord(response)) continue;
335
+
336
+ const code = Number.parseInt(statusCode, 10);
337
+ if (Number.isNaN(code)) continue;
338
+
339
+ const description = getString(response.description) ?? "";
340
+
341
+ let schema: JSONSchema7 | undefined;
342
+ if (isSwagger2) {
343
+ // Swagger 2.0: schema is directly on the response
344
+ if (isRecord(response.schema)) {
345
+ schema = normalizeSchema(response.schema, "response");
346
+ }
347
+ } else {
348
+ // OpenAPI 3.x: schema is nested in content
349
+ const content = isRecord(response.content) ? response.content : undefined;
350
+ if (content) {
351
+ const jsonEntry = findJsonContent(content);
352
+ if (jsonEntry && isRecord(jsonEntry.schema)) {
353
+ schema = normalizeSchema(jsonEntry.schema, "response");
354
+ }
355
+ }
356
+ }
357
+
358
+ result.set(code, { schema, description });
359
+ }
360
+
361
+ return result;
362
+ }
363
+
364
+ /**
365
+ * Find the best JSON-like content type entry from an OpenAPI content map.
366
+ * Prefers application/json, then any *+json or *json* type.
367
+ */
368
+ function findJsonContent(
369
+ content: Record<string, unknown>,
370
+ ): Record<string, unknown> | undefined {
371
+ // Prefer exact application/json
372
+ if (isRecord(content["application/json"])) {
373
+ return content["application/json"];
374
+ }
375
+ // Try any JSON-like content type (application/problem+json, etc.)
376
+ for (const [type, value] of Object.entries(content)) {
377
+ if (type.includes("json") && isRecord(value)) {
378
+ return value;
379
+ }
380
+ }
381
+ // Fallback to first content type
382
+ return Object.values(content).find((v): v is Record<string, unknown> =>
383
+ isRecord(v),
384
+ );
385
+ }
386
+
387
+ function convertPathTemplate(path: string): string {
388
+ return path.replace(/\{([^}]+)\}/g, ":$1");
389
+ }