@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,150 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectCrudResources } from "./crud-detector";
3
+ import type { ParsedPath } from "./parser";
4
+
5
+ function makePath(
6
+ method: Schmock.HttpMethod,
7
+ path: string,
8
+ responseSchema?: Record<string, unknown>,
9
+ ): ParsedPath {
10
+ const responses = new Map<number, { schema?: any; description: string }>();
11
+ if (responseSchema) {
12
+ responses.set(200, { schema: responseSchema, description: "OK" });
13
+ }
14
+ return {
15
+ path,
16
+ method,
17
+ parameters: [],
18
+ responses,
19
+ tags: [],
20
+ };
21
+ }
22
+
23
+ describe("detectCrudResources", () => {
24
+ it("detects a standard CRUD resource", () => {
25
+ const paths: ParsedPath[] = [
26
+ makePath("GET", "/pets", {
27
+ type: "array",
28
+ items: { type: "object", properties: { petId: { type: "integer" } } },
29
+ }),
30
+ makePath("POST", "/pets"),
31
+ makePath("GET", "/pets/:petId", {
32
+ type: "object",
33
+ properties: { petId: { type: "integer" } },
34
+ }),
35
+ makePath("PUT", "/pets/:petId"),
36
+ makePath("DELETE", "/pets/:petId"),
37
+ ];
38
+
39
+ const result = detectCrudResources(paths);
40
+
41
+ expect(result.resources).toHaveLength(1);
42
+ expect(result.nonCrudPaths).toHaveLength(0);
43
+
44
+ const resource = result.resources[0];
45
+ expect(resource.name).toBe("pets");
46
+ expect(resource.basePath).toBe("/pets");
47
+ expect(resource.itemPath).toBe("/pets/:petId");
48
+ expect(resource.idParam).toBe("petId");
49
+ expect(resource.operations).toContain("list");
50
+ expect(resource.operations).toContain("create");
51
+ expect(resource.operations).toContain("read");
52
+ expect(resource.operations).toContain("update");
53
+ expect(resource.operations).toContain("delete");
54
+ });
55
+
56
+ it("detects a read-only API", () => {
57
+ const paths: ParsedPath[] = [
58
+ makePath("GET", "/articles", {
59
+ type: "array",
60
+ items: {
61
+ type: "object",
62
+ properties: { articleId: { type: "integer" } },
63
+ },
64
+ }),
65
+ makePath("GET", "/articles/:articleId"),
66
+ ];
67
+
68
+ const result = detectCrudResources(paths);
69
+
70
+ expect(result.resources).toHaveLength(1);
71
+ expect(result.resources[0].operations).toEqual(["list", "read"]);
72
+ });
73
+
74
+ it("handles non-CRUD endpoints", () => {
75
+ const paths: ParsedPath[] = [
76
+ makePath("GET", "/health"),
77
+ makePath("POST", "/login"),
78
+ ];
79
+
80
+ const result = detectCrudResources(paths);
81
+
82
+ expect(result.resources).toHaveLength(0);
83
+ expect(result.nonCrudPaths).toHaveLength(2);
84
+ });
85
+
86
+ it("handles mixed CRUD and non-CRUD endpoints", () => {
87
+ const paths: ParsedPath[] = [
88
+ makePath("GET", "/pets"),
89
+ makePath("POST", "/pets"),
90
+ makePath("GET", "/health"),
91
+ ];
92
+
93
+ const result = detectCrudResources(paths);
94
+
95
+ expect(result.resources).toHaveLength(1);
96
+ expect(result.resources[0].name).toBe("pets");
97
+ expect(result.nonCrudPaths).toHaveLength(1);
98
+ expect(result.nonCrudPaths[0].path).toBe("/health");
99
+ });
100
+
101
+ it("handles nested resources", () => {
102
+ const paths: ParsedPath[] = [
103
+ makePath("GET", "/owners/:ownerId/pets"),
104
+ makePath("POST", "/owners/:ownerId/pets"),
105
+ ];
106
+
107
+ const result = detectCrudResources(paths);
108
+
109
+ expect(result.resources).toHaveLength(1);
110
+ expect(result.resources[0].name).toBe("pets");
111
+ expect(result.resources[0].basePath).toBe("/owners/:ownerId/pets");
112
+ });
113
+
114
+ it("extracts schema from list response items", () => {
115
+ const itemSchema = {
116
+ type: "object",
117
+ properties: {
118
+ petId: { type: "integer" },
119
+ name: { type: "string" },
120
+ },
121
+ };
122
+
123
+ const paths: ParsedPath[] = [
124
+ makePath("GET", "/pets", {
125
+ type: "array",
126
+ items: itemSchema,
127
+ }),
128
+ makePath("POST", "/pets"),
129
+ ];
130
+
131
+ const result = detectCrudResources(paths);
132
+
133
+ expect(result.resources).toHaveLength(1);
134
+ expect(result.resources[0].schema).toEqual(itemSchema);
135
+ });
136
+
137
+ it("does not duplicate update operation for PUT and PATCH", () => {
138
+ const paths: ParsedPath[] = [
139
+ makePath("GET", "/pets"),
140
+ makePath("PUT", "/pets/:petId"),
141
+ makePath("PATCH", "/pets/:petId"),
142
+ ];
143
+
144
+ const result = detectCrudResources(paths);
145
+ const updateCount = result.resources[0].operations.filter(
146
+ (op) => op === "update",
147
+ ).length;
148
+ expect(updateCount).toBe(1);
149
+ });
150
+ });
@@ -0,0 +1,194 @@
1
+ /// <reference path="../../../types/schmock.d.ts" />
2
+
3
+ import type { JSONSchema7 } from "json-schema";
4
+ import type { ParsedPath } from "./parser.js";
5
+
6
+ export type CrudOperation = "list" | "create" | "read" | "update" | "delete";
7
+
8
+ export interface CrudResource {
9
+ /** Resource name e.g. "pets" */
10
+ name: string;
11
+ /** Collection path e.g. "/pets" */
12
+ basePath: string;
13
+ /** Item path e.g. "/pets/:petId" */
14
+ itemPath: string;
15
+ /** ID parameter name e.g. "petId" */
16
+ idParam: string;
17
+ /** Detected CRUD operations */
18
+ operations: CrudOperation[];
19
+ /** Response schema for the resource item */
20
+ schema?: JSONSchema7;
21
+ }
22
+
23
+ interface DetectionResult {
24
+ resources: CrudResource[];
25
+ /** Paths that didn't match any CRUD pattern */
26
+ nonCrudPaths: ParsedPath[];
27
+ }
28
+
29
+ /**
30
+ * Detect CRUD resource patterns from parsed OpenAPI paths.
31
+ *
32
+ * Patterns:
33
+ * - GET /resources → list
34
+ * - POST /resources → create
35
+ * - GET /resources/:id → read
36
+ * - PUT/PATCH /resources/:id → update
37
+ * - DELETE /resources/:id → delete
38
+ */
39
+ export function detectCrudResources(paths: ParsedPath[]): DetectionResult {
40
+ // Group paths by their base path (strip trailing /:param)
41
+ const groups = new Map<string, ParsedPath[]>();
42
+ const nonCrudPaths: ParsedPath[] = [];
43
+
44
+ for (const p of paths) {
45
+ const basePath = getCollectionPath(p.path);
46
+ if (!basePath) {
47
+ nonCrudPaths.push(p);
48
+ continue;
49
+ }
50
+ const existing = groups.get(basePath) ?? [];
51
+ existing.push(p);
52
+ groups.set(basePath, existing);
53
+ }
54
+
55
+ const resources: CrudResource[] = [];
56
+
57
+ for (const [basePath, groupPaths] of groups) {
58
+ const resource = buildResource(basePath, groupPaths);
59
+ if (resource) {
60
+ resources.push(resource);
61
+ } else {
62
+ // If no CRUD pattern detected, treat as non-CRUD
63
+ nonCrudPaths.push(...groupPaths);
64
+ }
65
+ }
66
+
67
+ return { resources, nonCrudPaths };
68
+ }
69
+
70
+ /**
71
+ * Extract the collection base path from a path.
72
+ * "/pets" → "/pets"
73
+ * "/pets/:petId" → "/pets"
74
+ * "/owners/:ownerId/pets" → "/owners/:ownerId/pets"
75
+ * "/owners/:ownerId/pets/:petId" → "/owners/:ownerId/pets"
76
+ */
77
+ function getCollectionPath(path: string): string | undefined {
78
+ const segments = path.split("/").filter(Boolean);
79
+ if (segments.length === 0) return undefined;
80
+
81
+ // If last segment is a param (:xyz), remove it to get collection path
82
+ const last = segments[segments.length - 1];
83
+ if (last.startsWith(":")) {
84
+ return `/${segments.slice(0, -1).join("/")}`;
85
+ }
86
+
87
+ // Otherwise the path itself is a potential collection path
88
+ return `/${segments.join("/")}`;
89
+ }
90
+
91
+ function buildResource(
92
+ basePath: string,
93
+ paths: ParsedPath[],
94
+ ): CrudResource | undefined {
95
+ const operations: CrudOperation[] = [];
96
+ let itemPath = "";
97
+ let idParam = "";
98
+ let schema: JSONSchema7 | undefined;
99
+
100
+ for (const p of paths) {
101
+ const isCollection = p.path === basePath;
102
+ const isItem = !isCollection && p.path.startsWith(basePath);
103
+
104
+ if (isCollection) {
105
+ if (p.method === "GET") {
106
+ operations.push("list");
107
+ // Try to extract item schema from list response (array items)
108
+ const listSchema = getSuccessResponseSchema(p);
109
+ if (listSchema && listSchema.type === "array" && listSchema.items) {
110
+ const items = Array.isArray(listSchema.items)
111
+ ? listSchema.items[0]
112
+ : listSchema.items;
113
+ if (typeof items === "object" && items !== null) {
114
+ schema = schema ?? (items as JSONSchema7);
115
+ }
116
+ }
117
+ } else if (p.method === "POST") {
118
+ operations.push("create");
119
+ }
120
+ } else if (isItem) {
121
+ // Extract ID param from the item path
122
+ const paramSegments = p.path
123
+ .slice(basePath.length)
124
+ .split("/")
125
+ .filter(Boolean);
126
+ if (paramSegments.length === 1 && paramSegments[0].startsWith(":")) {
127
+ const param = paramSegments[0].slice(1);
128
+ if (!idParam) {
129
+ idParam = param;
130
+ itemPath = p.path;
131
+ }
132
+
133
+ if (p.method === "GET") {
134
+ operations.push("read");
135
+ schema = schema ?? getSuccessResponseSchema(p);
136
+ } else if (p.method === "PUT" || p.method === "PATCH") {
137
+ if (!operations.includes("update")) {
138
+ operations.push("update");
139
+ }
140
+ } else if (p.method === "DELETE") {
141
+ operations.push("delete");
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ if (operations.length === 0) return undefined;
148
+
149
+ // Require evidence of a genuine CRUD collection:
150
+ // either item-level operations (read/update/delete) exist,
151
+ // or both list AND create exist on the collection path.
152
+ // Single GET /health or POST /login don't qualify.
153
+ const hasItemOps = operations.some(
154
+ (op) => op === "read" || op === "update" || op === "delete",
155
+ );
156
+ const hasList = operations.includes("list");
157
+ const hasCreate = operations.includes("create");
158
+ if (!hasItemOps && !(hasList && hasCreate)) return undefined;
159
+
160
+ // If we only have collection operations, infer item path
161
+ if (!itemPath) {
162
+ const resourceName = basePath.split("/").filter(Boolean).pop() ?? "";
163
+ const singular = resourceName.endsWith("s")
164
+ ? resourceName.slice(0, -1)
165
+ : resourceName;
166
+ idParam = `${singular}Id`;
167
+ itemPath = `${basePath}/:${idParam}`;
168
+ }
169
+
170
+ const name = basePath.split("/").filter(Boolean).pop() ?? basePath;
171
+
172
+ return {
173
+ name,
174
+ basePath,
175
+ itemPath,
176
+ idParam,
177
+ operations,
178
+ schema,
179
+ };
180
+ }
181
+
182
+ function getSuccessResponseSchema(p: ParsedPath): JSONSchema7 | undefined {
183
+ // Try 200, then 201, then first 2xx
184
+ for (const code of [200, 201]) {
185
+ const resp = p.responses.get(code);
186
+ if (resp?.schema) return resp.schema;
187
+ }
188
+
189
+ for (const [code, resp] of p.responses) {
190
+ if (code >= 200 && code < 300 && resp.schema) return resp.schema;
191
+ }
192
+
193
+ return undefined;
194
+ }
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { CrudResource } from "./crud-detector";
3
+ import {
4
+ createCreateGenerator,
5
+ createDeleteGenerator,
6
+ createListGenerator,
7
+ createReadGenerator,
8
+ createUpdateGenerator,
9
+ generateSeedItems,
10
+ } from "./generators";
11
+
12
+ function makeResource(overrides?: Partial<CrudResource>): CrudResource {
13
+ return {
14
+ name: "pets",
15
+ basePath: "/pets",
16
+ itemPath: "/pets/:petId",
17
+ idParam: "petId",
18
+ operations: ["list", "create", "read", "update", "delete"],
19
+ schema: {
20
+ type: "object",
21
+ properties: {
22
+ petId: { type: "integer" },
23
+ name: { type: "string" },
24
+ },
25
+ required: ["petId", "name"],
26
+ },
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function makeContext(
32
+ overrides?: Partial<Schmock.RequestContext>,
33
+ ): Schmock.RequestContext {
34
+ return {
35
+ method: "GET",
36
+ path: "/pets",
37
+ params: {},
38
+ query: {},
39
+ headers: {},
40
+ state: {},
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ describe("generators", () => {
46
+ describe("CRUD lifecycle", () => {
47
+ it("creates, reads, updates, lists, and deletes", () => {
48
+ const resource = makeResource();
49
+ const state: Record<string, unknown> = {};
50
+
51
+ // Seed the collection
52
+ state["openapi:collections:pets"] = [];
53
+ state["openapi:counter:pets"] = 0;
54
+
55
+ const create = createCreateGenerator(resource);
56
+ const read = createReadGenerator(resource);
57
+ const update = createUpdateGenerator(resource);
58
+ const list = createListGenerator(resource);
59
+ const del = createDeleteGenerator(resource);
60
+
61
+ // Create
62
+ const createResult = create(
63
+ makeContext({
64
+ method: "POST",
65
+ path: "/pets",
66
+ body: { name: "Buddy" },
67
+ state,
68
+ }),
69
+ );
70
+ expect(createResult).toEqual([201, { name: "Buddy", petId: 1 }]);
71
+
72
+ // Read
73
+ const readResult = read(
74
+ makeContext({
75
+ path: "/pets/1",
76
+ params: { petId: "1" },
77
+ state,
78
+ }),
79
+ );
80
+ expect(readResult).toEqual({ name: "Buddy", petId: 1 });
81
+
82
+ // Update
83
+ const updateResult = update(
84
+ makeContext({
85
+ method: "PUT",
86
+ path: "/pets/1",
87
+ params: { petId: "1" },
88
+ body: { name: "Max" },
89
+ state,
90
+ }),
91
+ );
92
+ expect(updateResult).toEqual({ name: "Max", petId: 1 });
93
+
94
+ // List
95
+ const listResult = list(makeContext({ state }));
96
+ expect(listResult).toEqual([{ name: "Max", petId: 1 }]);
97
+
98
+ // Delete
99
+ const deleteResult = del(
100
+ makeContext({
101
+ method: "DELETE",
102
+ path: "/pets/1",
103
+ params: { petId: "1" },
104
+ state,
105
+ }),
106
+ );
107
+ expect(deleteResult).toEqual([204, undefined]);
108
+
109
+ // Verify deletion
110
+ const afterDelete = list(makeContext({ state }));
111
+ expect(afterDelete).toEqual([]);
112
+ });
113
+ });
114
+
115
+ describe("read generator", () => {
116
+ it("returns 404 for missing resources", () => {
117
+ const resource = makeResource();
118
+ const state: Record<string, unknown> = {
119
+ "openapi:collections:pets": [],
120
+ };
121
+
122
+ const read = createReadGenerator(resource);
123
+ const result = read(
124
+ makeContext({
125
+ path: "/pets/999",
126
+ params: { petId: "999" },
127
+ state,
128
+ }),
129
+ );
130
+ expect(result).toEqual([404, { error: "Not found", code: "NOT_FOUND" }]);
131
+ });
132
+ });
133
+
134
+ describe("update generator", () => {
135
+ it("returns 404 for missing resources", () => {
136
+ const resource = makeResource();
137
+ const state: Record<string, unknown> = {
138
+ "openapi:collections:pets": [],
139
+ };
140
+
141
+ const update = createUpdateGenerator(resource);
142
+ const result = update(
143
+ makeContext({
144
+ method: "PUT",
145
+ path: "/pets/999",
146
+ params: { petId: "999" },
147
+ body: { name: "Ghost" },
148
+ state,
149
+ }),
150
+ );
151
+ expect(result).toEqual([404, { error: "Not found", code: "NOT_FOUND" }]);
152
+ });
153
+ });
154
+
155
+ describe("delete generator", () => {
156
+ it("returns 404 for missing resources", () => {
157
+ const resource = makeResource();
158
+ const state: Record<string, unknown> = {
159
+ "openapi:collections:pets": [],
160
+ };
161
+
162
+ const del = createDeleteGenerator(resource);
163
+ const result = del(
164
+ makeContext({
165
+ method: "DELETE",
166
+ path: "/pets/999",
167
+ params: { petId: "999" },
168
+ state,
169
+ }),
170
+ );
171
+ expect(result).toEqual([404, { error: "Not found", code: "NOT_FOUND" }]);
172
+ });
173
+ });
174
+
175
+ describe("create generator", () => {
176
+ it("auto-increments IDs", () => {
177
+ const resource = makeResource();
178
+ const state: Record<string, unknown> = {
179
+ "openapi:collections:pets": [],
180
+ "openapi:counter:pets": 0,
181
+ };
182
+
183
+ const create = createCreateGenerator(resource);
184
+ create(makeContext({ method: "POST", body: { name: "A" }, state }));
185
+ create(makeContext({ method: "POST", body: { name: "B" }, state }));
186
+
187
+ const collection = state["openapi:collections:pets"] as Record<
188
+ string,
189
+ unknown
190
+ >[];
191
+ expect(collection[0].petId).toBe(1);
192
+ expect(collection[1].petId).toBe(2);
193
+ });
194
+ });
195
+
196
+ describe("generateSeedItems", () => {
197
+ it("generates items with auto-assigned IDs", () => {
198
+ const schema = {
199
+ type: "object" as const,
200
+ properties: {
201
+ name: { type: "string" as const },
202
+ },
203
+ required: ["name" as const],
204
+ };
205
+
206
+ const items = generateSeedItems(schema, 3, "petId");
207
+ expect(items).toHaveLength(3);
208
+ for (let i = 0; i < 3; i++) {
209
+ const item = items[i] as Record<string, unknown>;
210
+ expect(item.petId).toBe(i + 1);
211
+ }
212
+ });
213
+ });
214
+ });