@schmock/openapi 1.2.0 → 1.4.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/content-negotiation.d.ts +7 -0
- package/dist/content-negotiation.d.ts.map +1 -0
- package/dist/content-negotiation.js +46 -0
- package/dist/crud-detector.d.ts +2 -0
- package/dist/crud-detector.d.ts.map +1 -1
- package/dist/crud-detector.js +55 -8
- package/dist/generators.d.ts +27 -7
- package/dist/generators.d.ts.map +1 -1
- package/dist/generators.js +190 -18
- package/dist/index.js +159 -159
- package/dist/normalizer.js +1 -1
- package/dist/parser.d.ts +31 -4
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +208 -6
- package/dist/plugin.d.ts +9 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +506 -47
- package/dist/prefer.d.ts +12 -0
- package/dist/prefer.d.ts.map +1 -0
- package/dist/prefer.js +25 -0
- package/dist/seed.js +1 -1
- package/package.json +5 -3
- package/src/content-negotiation.ts +53 -0
- package/src/crud-detector.ts +65 -8
- package/src/generators.test.ts +270 -0
- package/src/generators.ts +237 -11
- package/src/index.ts +1 -1
- package/src/normalizer.ts +1 -1
- package/src/parser.ts +292 -12
- package/src/plugin.ts +655 -51
- package/src/prefer.ts +37 -0
- package/src/seed.ts +1 -1
- package/src/steps/callback-mocking.steps.ts +164 -0
- package/src/steps/content-negotiation.steps.ts +107 -0
- package/src/steps/errors-mode.steps.ts +95 -0
- package/src/steps/openapi-compliance.steps.ts +427 -0
- package/src/steps/prefer-header.steps.ts +140 -0
- package/src/steps/security-validation.steps.ts +183 -0
- package/src/stress.test.ts +92 -35
package/src/prefer.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/// <reference path="../../core/schmock.d.ts" />
|
|
2
|
+
|
|
3
|
+
interface PreferDirectives {
|
|
4
|
+
code?: number;
|
|
5
|
+
example?: string;
|
|
6
|
+
dynamic?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse the RFC 7240 Prefer header for mock-specific directives.
|
|
11
|
+
* Supports: code=N, example=name, dynamic=true
|
|
12
|
+
*/
|
|
13
|
+
export function parsePreferHeader(value: string): PreferDirectives {
|
|
14
|
+
const result: PreferDirectives = {};
|
|
15
|
+
|
|
16
|
+
for (const part of value.split(",")) {
|
|
17
|
+
const trimmed = part.trim();
|
|
18
|
+
|
|
19
|
+
const codeMatch = trimmed.match(/^code\s*=\s*(\d+)$/);
|
|
20
|
+
if (codeMatch) {
|
|
21
|
+
result.code = Number(codeMatch[1]);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const exampleMatch = trimmed.match(/^example\s*=\s*(.+)$/);
|
|
26
|
+
if (exampleMatch) {
|
|
27
|
+
result.example = exampleMatch[1].trim();
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (trimmed === "dynamic=true" || trimmed === "dynamic") {
|
|
32
|
+
result.dynamic = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
}
|
package/src/seed.ts
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
4
|
+
import { schmock } from "@schmock/core";
|
|
5
|
+
import { expect } from "vitest";
|
|
6
|
+
import { openapi } from "../plugin";
|
|
7
|
+
|
|
8
|
+
const feature = await loadFeature("../../features/callback-mocking.feature");
|
|
9
|
+
|
|
10
|
+
function makeCallbackSpec(receiverPort: number): object {
|
|
11
|
+
return {
|
|
12
|
+
openapi: "3.0.3",
|
|
13
|
+
info: { title: "Test", version: "1.0.0" },
|
|
14
|
+
paths: {
|
|
15
|
+
"/orders": {
|
|
16
|
+
post: {
|
|
17
|
+
requestBody: {
|
|
18
|
+
content: {
|
|
19
|
+
"application/json": {
|
|
20
|
+
schema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
item: { type: "string" },
|
|
24
|
+
callbackUrl: { type: "string" },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
responses: { "201": { description: "Created" } },
|
|
31
|
+
callbacks: {
|
|
32
|
+
orderStatus: {
|
|
33
|
+
"{$request.body#/callbackUrl}": {
|
|
34
|
+
post: {
|
|
35
|
+
requestBody: {
|
|
36
|
+
content: {
|
|
37
|
+
"application/json": {
|
|
38
|
+
schema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
status: { type: "string" },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
responses: { "200": { description: "OK" } },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No-port version for the "missing callback URL" scenario
|
|
59
|
+
const specNoPort = {
|
|
60
|
+
openapi: "3.0.3",
|
|
61
|
+
info: { title: "Test", version: "1.0.0" },
|
|
62
|
+
paths: {
|
|
63
|
+
"/orders": {
|
|
64
|
+
post: {
|
|
65
|
+
requestBody: {
|
|
66
|
+
content: {
|
|
67
|
+
"application/json": {
|
|
68
|
+
schema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
item: { type: "string" },
|
|
72
|
+
callbackUrl: { type: "string" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
responses: { "201": { description: "Created" } },
|
|
79
|
+
callbacks: {
|
|
80
|
+
orderStatus: {
|
|
81
|
+
"{$request.body#/callbackUrl}": {
|
|
82
|
+
post: {
|
|
83
|
+
responses: { "200": { description: "OK" } },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
94
|
+
Scenario("Callback fires after resource creation", ({ Given, When, Then, And }) => {
|
|
95
|
+
let mock: Schmock.CallableMockInstance;
|
|
96
|
+
let receiver: Server;
|
|
97
|
+
let receiverPort: number;
|
|
98
|
+
let receivedCallback: boolean;
|
|
99
|
+
|
|
100
|
+
Given("a mock with a spec defining a callback on POST", () => {
|
|
101
|
+
receivedCallback = false;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
And("a callback receiver is listening", async () => {
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
receiver = createServer((req, res) => {
|
|
107
|
+
if (req.method === "POST") {
|
|
108
|
+
receivedCallback = true;
|
|
109
|
+
}
|
|
110
|
+
res.writeHead(200);
|
|
111
|
+
res.end();
|
|
112
|
+
});
|
|
113
|
+
receiver.listen(0, "127.0.0.1", () => {
|
|
114
|
+
const addr = receiver.address();
|
|
115
|
+
receiverPort = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
mock = schmock({ state: {} });
|
|
121
|
+
const spec = makeCallbackSpec(receiverPort);
|
|
122
|
+
mock.pipe(await openapi({ spec }));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
When("I create a resource with a callback URL", async () => {
|
|
126
|
+
await mock.handle("POST", "/orders", {
|
|
127
|
+
body: {
|
|
128
|
+
item: "widget",
|
|
129
|
+
callbackUrl: `http://127.0.0.1:${receiverPort}/webhook`,
|
|
130
|
+
},
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
});
|
|
133
|
+
// Wait a bit for fire-and-forget callback to arrive
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
Then("the callback receiver gets a POST request", () => {
|
|
138
|
+
receiver.close();
|
|
139
|
+
expect(receivedCallback).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
Scenario("Missing callback URL is silently skipped", ({ Given, When, Then }) => {
|
|
144
|
+
let mock: Schmock.CallableMockInstance;
|
|
145
|
+
let response: Schmock.Response;
|
|
146
|
+
|
|
147
|
+
Given("a mock with a spec defining a callback on POST", async () => {
|
|
148
|
+
mock = schmock({ state: {} });
|
|
149
|
+
mock.pipe(await openapi({ spec: specNoPort }));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
When("I create a resource without a callback URL", async () => {
|
|
153
|
+
response = await mock.handle("POST", "/orders", {
|
|
154
|
+
body: { item: "widget" },
|
|
155
|
+
headers: { "content-type": "application/json" },
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
Then("no error is raised", () => {
|
|
160
|
+
// Should not throw, and status should not be 500
|
|
161
|
+
expect(response.status).not.toBe(500);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { schmock } from "@schmock/core";
|
|
3
|
+
import { expect } from "vitest";
|
|
4
|
+
import { openapi } from "../plugin";
|
|
5
|
+
|
|
6
|
+
const feature = await loadFeature("../../features/content-negotiation.feature");
|
|
7
|
+
|
|
8
|
+
const specWithJson = {
|
|
9
|
+
openapi: "3.0.3",
|
|
10
|
+
info: { title: "Test", version: "1.0.0" },
|
|
11
|
+
paths: {
|
|
12
|
+
"/items": {
|
|
13
|
+
get: {
|
|
14
|
+
responses: {
|
|
15
|
+
"200": {
|
|
16
|
+
description: "OK",
|
|
17
|
+
content: {
|
|
18
|
+
"application/json": {
|
|
19
|
+
schema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { id: { type: "integer" } },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
33
|
+
let mock: Schmock.CallableMockInstance;
|
|
34
|
+
let response: Schmock.Response;
|
|
35
|
+
|
|
36
|
+
Scenario("JSON accepted returns 200", ({ Given, When, Then }) => {
|
|
37
|
+
Given("a mock with a spec defining JSON responses", async () => {
|
|
38
|
+
mock = schmock({ state: {} });
|
|
39
|
+
mock.pipe(await openapi({ spec: specWithJson }));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
When('I request with Accept header "application/json"', async () => {
|
|
43
|
+
response = await mock.handle("GET", "/items", {
|
|
44
|
+
headers: { accept: "application/json" },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
Then("the response status is 200", () => {
|
|
49
|
+
expect(response.status).toBe(200);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
Scenario("Unsupported content type returns 406", ({ Given, When, Then, And }) => {
|
|
54
|
+
Given("a mock with a spec defining JSON responses", async () => {
|
|
55
|
+
mock = schmock({ state: {} });
|
|
56
|
+
mock.pipe(await openapi({ spec: specWithJson }));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
When('I request with Accept header "application/xml"', async () => {
|
|
60
|
+
response = await mock.handle("GET", "/items", {
|
|
61
|
+
headers: { accept: "application/xml" },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Then("the response status is 406", () => {
|
|
66
|
+
expect(response.status).toBe(406);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
And('the error body has an "acceptable" array', () => {
|
|
70
|
+
const body = response.body as Record<string, unknown>;
|
|
71
|
+
expect(Array.isArray(body.acceptable)).toBe(true);
|
|
72
|
+
expect(body.acceptable).toContain("application/json");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
Scenario("Wildcard Accept passes through", ({ Given, When, Then }) => {
|
|
77
|
+
Given("a mock with a spec defining JSON responses", async () => {
|
|
78
|
+
mock = schmock({ state: {} });
|
|
79
|
+
mock.pipe(await openapi({ spec: specWithJson }));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
When('I request with Accept header "*/*"', async () => {
|
|
83
|
+
response = await mock.handle("GET", "/items", {
|
|
84
|
+
headers: { accept: "*/*" },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
Then("the response status is 200", () => {
|
|
89
|
+
expect(response.status).toBe(200);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
Scenario("No Accept header defaults to success", ({ Given, When, Then }) => {
|
|
94
|
+
Given("a mock with a spec defining JSON responses", async () => {
|
|
95
|
+
mock = schmock({ state: {} });
|
|
96
|
+
mock.pipe(await openapi({ spec: specWithJson }));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
When("I request without an Accept header", async () => {
|
|
100
|
+
response = await mock.handle("GET", "/items", { headers: {} });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
Then("the response status is 200", () => {
|
|
104
|
+
expect(response.status).toBe(200);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { schmock } from "@schmock/core";
|
|
3
|
+
import { expect } from "vitest";
|
|
4
|
+
import { openapi } from "../plugin";
|
|
5
|
+
|
|
6
|
+
const feature = await loadFeature("../../features/errors-mode.feature");
|
|
7
|
+
|
|
8
|
+
const specWithRequestBody = {
|
|
9
|
+
openapi: "3.0.3",
|
|
10
|
+
info: { title: "Test", version: "1.0.0" },
|
|
11
|
+
paths: {
|
|
12
|
+
"/items": {
|
|
13
|
+
get: {
|
|
14
|
+
responses: {
|
|
15
|
+
"200": { description: "List" },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
post: {
|
|
19
|
+
requestBody: {
|
|
20
|
+
required: true,
|
|
21
|
+
content: {
|
|
22
|
+
"application/json": {
|
|
23
|
+
schema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
name: { type: "string" },
|
|
27
|
+
price: { type: "number" },
|
|
28
|
+
},
|
|
29
|
+
required: ["name", "price"],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
responses: {
|
|
35
|
+
"201": { description: "Created" },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"/items/{itemId}": {
|
|
40
|
+
get: {
|
|
41
|
+
parameters: [{ name: "itemId", in: "path", required: true }],
|
|
42
|
+
responses: { "200": { description: "OK" } },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describeFeature(feature, ({ Scenario }) => {
|
|
49
|
+
let mock: Schmock.CallableMockInstance;
|
|
50
|
+
let response: Schmock.Response;
|
|
51
|
+
|
|
52
|
+
Scenario("Invalid request body returns 400 with validation errors", ({ Given, When, Then, And }) => {
|
|
53
|
+
Given("a mock with validateRequests enabled", async () => {
|
|
54
|
+
mock = schmock({ state: {} });
|
|
55
|
+
mock.pipe(await openapi({ spec: specWithRequestBody, validateRequests: true }));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
When("I POST an invalid body to a route with a requestBody schema", async () => {
|
|
59
|
+
response = await mock.handle("POST", "/items", {
|
|
60
|
+
body: { name: 123 }, // name should be string, price missing
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Then("the response status is 400", () => {
|
|
66
|
+
expect(response.status).toBe(400);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
And('the error body has a "details" array', () => {
|
|
70
|
+
const body = response.body as Record<string, unknown>;
|
|
71
|
+
expect(body.code).toBe("VALIDATION_ERROR");
|
|
72
|
+
expect(Array.isArray(body.details)).toBe(true);
|
|
73
|
+
const details = body.details as unknown[];
|
|
74
|
+
expect(details.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
Scenario("Valid request body passes through", ({ Given, When, Then }) => {
|
|
79
|
+
Given("a mock with validateRequests enabled", async () => {
|
|
80
|
+
mock = schmock({ state: {} });
|
|
81
|
+
mock.pipe(await openapi({ spec: specWithRequestBody, validateRequests: true }));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
When("I POST a valid body to a route with a requestBody schema", async () => {
|
|
85
|
+
response = await mock.handle("POST", "/items", {
|
|
86
|
+
body: { name: "Widget", price: 9.99 },
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
Then("the response status is 201", () => {
|
|
92
|
+
expect(response.status).toBe(201);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|