Skip to content

Commit 15d083d

Browse files
authored
feat: add quirks mode to improve compatibility with SMV (#101)
1 parent 1527f27 commit 15d083d

File tree

11 files changed

+134
-54
lines changed

11 files changed

+134
-54
lines changed

src/compare/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ export class Comparator {
6161

6262
for (const [index, interaction] of parsedPact.interactions.entries()) {
6363
const { method, path, query } = interaction.request;
64-
const pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`;
64+
let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`;
65+
66+
if (process.env.QUIRKS) {
67+
pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25");
68+
}
69+
6570
// in pact, query is either a string or an object of only one level deep
6671
const stringQuery =
6772
typeof query === "string"
@@ -80,7 +85,7 @@ export class Comparator {
8085
[pathWithLeadingSlash, stringQuery].filter(Boolean).join("?"),
8186
);
8287

83-
if (!route) {
88+
if (!route || pathWithLeadingSlash.includes("?")) {
8489
yield {
8590
code: "request.path-or-method.unknown",
8691
message: `Path or method not defined in spec file: ${method} ${path}`,

src/compare/requestBody.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ const parseBody = (body: unknown, contentType: string) => {
2525
contentType.includes("application/x-www-form-urlencoded") &&
2626
typeof body === "string"
2727
) {
28-
return qs.parse(body as string, { allowDots: true, comma: true });
28+
return qs.parse(body as string, {
29+
allowDots: true,
30+
comma: true,
31+
depth: process.env.QUIRKS ? 0 : undefined,
32+
});
2933
}
3034

3135
if (contentType.includes("multipart/form-data") && typeof body === "string") {
@@ -51,11 +55,14 @@ const parseBody = (body: unknown, contentType: string) => {
5155
};
5256

5357
const canValidate = (contentType: string): boolean => {
54-
return !!findMatchingType(contentType, [
55-
"application/json",
56-
"application/x-www-form-urlencoded",
57-
"multipart/form-data",
58-
]);
58+
return !!findMatchingType(
59+
contentType,
60+
[
61+
"application/json",
62+
"application/x-www-form-urlencoded",
63+
process.env.QUIRKS ? "" : "multipart/form-data",
64+
].filter(Boolean),
65+
);
5966
};
6067

6168
const DEFAULT_CONTENT_TYPE = "application/json";
@@ -99,7 +106,14 @@ export function* compareReqBody(
99106
return;
100107
}
101108

102-
if (schema && canValidate(contentType) && isValidRequest(interaction)) {
109+
if (
110+
schema &&
111+
canValidate(contentType) &&
112+
isValidRequest(interaction) &&
113+
(process.env.QUIRKS
114+
? !!findMatchingType("application/json", availableRequestContentTypes)
115+
: true)
116+
) {
103117
const value = parseBody(body, requestContentType);
104118
const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`;
105119
const validate = getValidateFunction(ajv, schemaId, () =>
@@ -129,7 +143,15 @@ export function* compareReqBody(
129143
}
130144
}
131145

132-
if (!!body && !schema && isValidRequest(interaction)) {
146+
if (
147+
!!body &&
148+
!schema &&
149+
isValidRequest(interaction) &&
150+
(process.env.QUIRKS
151+
? !!findMatchingType("application/json", availableRequestContentTypes) ||
152+
availableRequestContentTypes.length === 0
153+
: true)
154+
) {
133155
yield {
134156
code: "request.body.unknown",
135157
message: "No matching schema found for request body",

src/compare/requestHeader.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../results/index";
1515
import { minimumSchema } from "../transform/index";
1616
import { isValidRequest } from "../utils/interaction";
17+
import { isQuirky } from "../utils/quirks";
1718
import { dereferenceOas, splitPath } from "../utils/schema";
1819
import { getValidateFunction } from "../utils/validation";
1920
import { findMatchingType, standardHttpRequestHeaders } from "./utils/content";
@@ -176,15 +177,19 @@ export function* compareReqHeader(
176177
// security headers
177178
// ----------------
178179
if (isValidRequest(interaction)) {
180+
let isSecured = false;
181+
const maybeResults: Result[] = [];
179182
for (const scheme of operation.security || []) {
180183
for (const schemeName of Object.keys(scheme)) {
181184
const scheme = securitySchemes[schemeName];
182185
switch (scheme?.type) {
183186
case "apiKey":
184187
switch (scheme.in) {
185188
case "header":
186-
if (!requestHeaders.has(scheme.name)) {
187-
yield {
189+
if (requestHeaders.has(scheme.name)) {
190+
isSecured = true;
191+
} else {
192+
maybeResults.push({
188193
code: "request.authorization.missing",
189194
message:
190195
"Request Authorization header is missing but is required by the spec file",
@@ -200,21 +205,20 @@ export function* compareReqHeader(
200205
value: operation,
201206
},
202207
type: "error",
203-
};
208+
});
204209
}
205210
requestHeaders.delete(scheme.name);
206211
break;
207212
case "cookie":
208-
// FIXME: handle cookies
209-
break;
210213
case "query":
211-
// ignore
212214
}
213215
break;
214216
case "basic": {
215217
const basicAuth = requestHeaders.get("authorization") || "";
216-
if (!basicAuth.startsWith("Basic ")) {
217-
yield {
218+
if (basicAuth.startsWith("Basic ")) {
219+
isSecured = true;
220+
} else {
221+
maybeResults.push({
218222
code: "request.authorization.missing",
219223
message:
220224
"Request Authorization header is missing but is required by the spec file",
@@ -230,7 +234,7 @@ export function* compareReqHeader(
230234
value: operation,
231235
},
232236
type: "error",
233-
};
237+
});
234238
}
235239
break;
236240
}
@@ -246,8 +250,14 @@ export function* compareReqHeader(
246250
break;
247251
}
248252

249-
if (!isValid) {
250-
yield {
253+
if (process.env.QUIRKS) {
254+
isValid = requestHeaders.get("authorization") !== null;
255+
}
256+
257+
if (isValid) {
258+
isSecured = true;
259+
} else {
260+
maybeResults.push({
251261
code: "request.authorization.missing",
252262
message:
253263
"Request Authorization header is missing but is required by the spec file",
@@ -263,7 +273,7 @@ export function* compareReqHeader(
263273
value: operation,
264274
},
265275
type: "error",
266-
};
276+
});
267277
}
268278
break;
269279
}
@@ -274,6 +284,10 @@ export function* compareReqHeader(
274284
}
275285
}
276286
}
287+
288+
if (!isSecured) {
289+
yield* maybeResults;
290+
}
277291
}
278292

279293
// specified headers
@@ -300,10 +314,12 @@ export function* compareReqHeader(
300314
? requestHeaders.get(dereferencedParameter.name)
301315
: parseValue(requestHeaders.get(dereferencedParameter.name));
302316

303-
if (value && schema && isValidRequest(interaction)) {
317+
if (value !== null && schema && isValidRequest(interaction)) {
304318
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
305319
const validate = getValidateFunction(ajv, schemaId, () =>
306-
minimumSchema(schema, oas),
320+
process.env.QUIRKS && value && isQuirky(schema)
321+
? {}
322+
: minimumSchema(schema, oas),
307323
);
308324
if (!validate(value)) {
309325
for (const error of validate.errors!) {
@@ -330,7 +346,7 @@ export function* compareReqHeader(
330346
}
331347

332348
if (
333-
!value &&
349+
value === null &&
334350
dereferencedParameter.required &&
335351
isValidRequest(interaction)
336352
) {

src/compare/requestPath.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Result } from "../results/index";
88
import { baseMockDetails } from "../results/index";
99
import { minimumSchema } from "../transform/index";
1010
import { dereferenceOas } from "../utils/schema";
11+
import { isQuirky } from "../utils/quirks";
1112
import { getValidateFunction } from "../utils/validation";
1213
import { cleanPathParameter } from "./utils/parameters";
1314
import { parseValue } from "./utils/parse";
@@ -35,10 +36,17 @@ export function* compareReqPath(
3536
route.params[cleanPathParameter(dereferencedParameter.name)],
3637
);
3738

39+
// ignore when OAS has unused parameter in the operation parameters
40+
if (!(cleanPathParameter(dereferencedParameter.name) in route.params)) {
41+
continue;
42+
}
43+
3844
if (schema) {
3945
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
4046
const validate = getValidateFunction(ajv, schemaId, () =>
41-
minimumSchema(schema, oas),
47+
process.env.QUIRKS && value && isQuirky(schema)
48+
? {}
49+
: minimumSchema(schema, oas),
4250
);
4351

4452
let separator = ",";

src/compare/requestQuery.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type Ajv from "ajv/dist/2019";
33
import type Router from "find-my-way";
44
import { get } from "lodash-es";
55
import qs from "qs";
6+
import querystring from "node:querystring";
67

78
import type { Result } from "../results/index";
89
import type { Interaction } from "../documents/pact";
@@ -15,6 +16,7 @@ import {
1516
import { minimumSchema } from "../transform/index";
1617
import { isValidRequest } from "../utils/interaction";
1718
import { ARRAY_SEPARATOR } from "../utils/queryParams";
19+
import { isQuirky } from "../utils/quirks";
1820
import { dereferenceOas, splitPath } from "../utils/schema";
1921
import { getValidateFunction } from "../utils/validation";
2022

@@ -26,14 +28,19 @@ export function* compareReqQuery(
2628
): Iterable<Result> {
2729
const { method, oas, operation, path, securitySchemes } = route.store;
2830

29-
const searchParamsParsed = qs.parse(route.searchParams, {
30-
allowDots: true,
31-
comma: true,
32-
});
33-
const searchParamsUnparsed = qs.parse(route.searchParams, {
34-
allowDots: false,
35-
comma: false,
36-
});
31+
const searchParamsParsed = process.env.QUIRKS
32+
? querystring.parse(route.searchParams as unknown as string)
33+
: qs.parse(route.searchParams, {
34+
allowDots: true,
35+
comma: true,
36+
});
37+
38+
const searchParamsUnparsed = process.env.QUIRKS
39+
? querystring.parse(route.searchParams as unknown as string)
40+
: qs.parse(route.searchParams, {
41+
allowDots: false,
42+
comma: false,
43+
});
3744

3845
for (const [parameterIndex, parameter] of (
3946
operation.parameters || []
@@ -59,14 +66,20 @@ export function* compareReqQuery(
5966
) {
6067
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
6168
const validate = getValidateFunction(ajv, schemaId, () =>
62-
minimumSchema(schema, oas),
69+
process.env.QUIRKS && value && isQuirky(schema)
70+
? {}
71+
: minimumSchema(schema, oas),
6372
);
6473

65-
const convertedValue =
74+
let convertedValue =
6675
schema.type === "array" && typeof value === "string"
6776
? value.split(ARRAY_SEPARATOR)
6877
: value;
6978

79+
if (process.env.QUIRKS && value === "[object Object]") {
80+
convertedValue = {};
81+
}
82+
7083
if (!validate(convertedValue)) {
7184
for (const error of validate.errors!) {
7285
yield {

src/compare/responseHeader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function* compareResHeader(
9898
const schema: SchemaObject =
9999
dereferenceOas(headers[headerName], oas).schema || headers[headerName];
100100
const value = responseHeaders.get(headerName);
101-
if (value && schema) {
101+
if (value !== null && schema) {
102102
const schemaId = `[root].paths.${path}.${method}.responses.${interaction.response.status}.headers.${headerName}`;
103103
const validate = getValidateFunction(ajv, schemaId, () =>
104104
minimumSchema(schema, oas),

src/compare/setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export function setupRouter(
3131
oas: OpenAPIV2.Document | OpenAPIV3.Document,
3232
): Router.Instance<Router.HTTPVersion.V1> {
3333
const router = Router({
34+
ignoreDuplicateSlashes: process.env.QUIRKS ? true : false,
35+
ignoreTrailingSlash: process.env.QUIRKS ? true : false,
3436
querystringParser: (s: string): string => s, // don't parse query in router
3537
});
3638
for (const oasPath in oas.paths) {

src/transform/minimumSchema.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,7 @@
11
import type { OpenAPIV3 } from "openapi-types";
22
import { SchemaObject } from "ajv";
33
import { cloneDeep, get, set } from "lodash-es";
4-
import { splitPath, traverse } from "../utils/schema";
5-
6-
const handleNullableSchema = (s: SchemaObject) => {
7-
if (s.$ref) {
8-
delete s.nullable;
9-
return;
10-
}
11-
12-
if (s.nullable && !s.type) {
13-
s.type = "object";
14-
}
15-
16-
if (s.nullable && !Array.isArray(s.type)) {
17-
s.type = [s.type, "null"];
18-
}
19-
};
4+
import { dereferenceOas, splitPath, traverse } from "../utils/schema";
205

216
// draft-06 onwards converts exclusiveMinimum and exclusiveMaximum to numbers
227
const convertExclusiveMinMax = (s: SchemaObject) => {
@@ -51,6 +36,23 @@ export const minimumSchema = (
5136
}
5237
};
5338

39+
const handleNullableSchema = (s: SchemaObject) => {
40+
if (s.$ref) {
41+
if (s.nullable && !s.type) {
42+
s.type = dereferenceOas(s, oas).type;
43+
}
44+
return;
45+
}
46+
47+
if (s.nullable && !s.type) {
48+
s.type = "object";
49+
}
50+
51+
if (s.nullable && !Array.isArray(s.type)) {
52+
s.type = [s.type, "null"];
53+
}
54+
};
55+
5456
const schema = cloneDeep(originalSchema);
5557
delete schema.description;
5658
delete schema.example;

src/transform/responseSchema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export const transformResponseSchema = (schema: SchemaObject): SchemaObject => {
1616
!s.allOf &&
1717
!s.anyOf &&
1818
s.type &&
19-
s.type === "object"
19+
s.type === "object" &&
20+
(process.env.QUIRKS ? !s.nullable : true)
2021
) {
2122
s.additionalProperties = false;
2223
}

src/utils/quirks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { SchemaObject } from "ajv";
2+
3+
export const isQuirky = (s?: SchemaObject): boolean =>
4+
s === undefined || s.type === "array" || s.type === "object";

0 commit comments

Comments
 (0)