Skip to content

Commit 0d0f5bf

Browse files
authored
feat: support fine-grained quirks mode (#102)
1 parent 1dc5662 commit 0d0f5bf

File tree

11 files changed

+174
-36
lines changed

11 files changed

+174
-36
lines changed

src/compare/index.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,18 @@ import { compareReqHeader } from "./requestHeader";
1414
import { compareResBody } from "./responseBody";
1515
import { compareResHeader } from "./responseHeader";
1616
import { baseMockDetails } from "../results/index";
17+
import { Config, ConfigKeys, DEFAULT_CONFIG } from "../utils/config";
1718
import { ARRAY_SEPARATOR } from "../utils/queryParams";
1819

1920
export class Comparator {
2021
#ajvCoerce: Ajv;
2122
#ajvNocoerce: Ajv;
23+
#config: Config;
2224
#oas: OpenAPIV2.Document | OpenAPIV3.Document;
2325
#router?: Router.Instance<Router.HTTPVersion.V1>;
2426

2527
constructor(oas: OpenAPIV2.Document | OpenAPIV3.Document) {
28+
this.#config = new Map(DEFAULT_CONFIG);
2629
this.#oas = oas;
2730

2831
const ajvOptions = {
@@ -46,7 +49,12 @@ export class Comparator {
4649
async *compare(pact: Pact): AsyncGenerator<Result> {
4750
if (!this.#router) {
4851
await parseOas(this.#oas);
49-
this.#router = setupRouter(this.#oas);
52+
for (const [key, value] of Object.entries(this.#oas.info)) {
53+
if (key.startsWith("x-opc-config-")) {
54+
this.#config.set(key.substring(13) as ConfigKeys, value);
55+
}
56+
}
57+
this.#router = setupRouter(this.#oas, this.#config);
5058
}
5159

5260
const parsedPact = parsePact(pact);
@@ -63,7 +71,7 @@ export class Comparator {
6371
const { method, path, query } = interaction.request;
6472
let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`;
6573

66-
if (process.env.QUIRKS) {
74+
if (this.#config.get("no-percent-encoding")) {
6775
pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25");
6876
}
6977

@@ -106,19 +114,55 @@ export class Comparator {
106114
}
107115

108116
const results = Array.from(
109-
compareReqPath(this.#ajvCoerce, route, interaction, index),
117+
compareReqPath(
118+
this.#ajvCoerce,
119+
route,
120+
interaction,
121+
index,
122+
this.#config,
123+
),
110124
);
111125

112126
if (results.length) {
113127
yield* results;
114128
continue;
115129
}
116130

117-
yield* compareReqHeader(this.#ajvCoerce, route, interaction, index);
118-
yield* compareReqQuery(this.#ajvCoerce, route, interaction, index);
119-
yield* compareReqBody(this.#ajvNocoerce, route, interaction, index);
120-
yield* compareResHeader(this.#ajvCoerce, route, interaction, index);
121-
yield* compareResBody(this.#ajvNocoerce, route, interaction, index);
131+
yield* compareReqHeader(
132+
this.#ajvCoerce,
133+
route,
134+
interaction,
135+
index,
136+
this.#config,
137+
);
138+
yield* compareReqQuery(
139+
this.#ajvCoerce,
140+
route,
141+
interaction,
142+
index,
143+
this.#config,
144+
);
145+
yield* compareReqBody(
146+
this.#ajvNocoerce,
147+
route,
148+
interaction,
149+
index,
150+
this.#config,
151+
);
152+
yield* compareResHeader(
153+
this.#ajvCoerce,
154+
route,
155+
interaction,
156+
index,
157+
this.#config,
158+
);
159+
yield* compareResBody(
160+
this.#ajvNocoerce,
161+
route,
162+
interaction,
163+
index,
164+
this.#config,
165+
);
122166
}
123167
}
124168
}

src/compare/requestBody.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { OpenAPIV2 } from "openapi-types";
44
import type Router from "find-my-way";
55
import { get } from "lodash-es";
66
import qs from "qs";
7+
import querystring from "node:querystring";
78
import multipart from "parse-multipart-data";
89

910
import type { Interaction } from "../documents/pact";
@@ -15,21 +16,27 @@ import {
1516
formatSchemaPath,
1617
} from "../results/index";
1718
import { minimumSchema, transformRequestSchema } from "../transform/index";
19+
import type { Config } from "../utils/config";
1820
import { isValidRequest } from "../utils/interaction";
1921
import { dereferenceOas, splitPath } from "../utils/schema";
2022
import { getValidateFunction } from "../utils/validation";
2123
import { findMatchingType, getByContentType } from "./utils/content";
2224

23-
const parseBody = (body: unknown, contentType: string) => {
25+
const parseBody = (
26+
body: unknown,
27+
contentType: string,
28+
legacyParser: boolean,
29+
) => {
2430
if (
2531
contentType.includes("application/x-www-form-urlencoded") &&
2632
typeof body === "string"
2733
) {
28-
return qs.parse(body as string, {
29-
allowDots: true,
30-
comma: true,
31-
depth: process.env.QUIRKS ? 0 : undefined,
32-
});
34+
return legacyParser
35+
? querystring.parse(body as string)
36+
: qs.parse(body as string, {
37+
allowDots: true,
38+
comma: true,
39+
});
3340
}
3441

3542
if (contentType.includes("multipart/form-data") && typeof body === "string") {
@@ -54,13 +61,16 @@ const parseBody = (body: unknown, contentType: string) => {
5461
return body;
5562
};
5663

57-
const canValidate = (contentType: string): boolean => {
64+
const canValidate = (
65+
contentType: string,
66+
disableMultipartFormdata: boolean,
67+
): boolean => {
5868
return !!findMatchingType(
5969
contentType,
6070
[
6171
"application/json",
6272
"application/x-www-form-urlencoded",
63-
process.env.QUIRKS ? "" : "multipart/form-data",
73+
disableMultipartFormdata ? "" : "multipart/form-data",
6474
].filter(Boolean),
6575
);
6676
};
@@ -72,6 +82,7 @@ export function* compareReqBody(
7282
route: Router.FindResult<Router.HTTPVersion.V1>,
7383
interaction: Interaction,
7484
index: number,
85+
config: Config,
7586
): Iterable<Result> {
7687
const { method, oas, operation, path } = route.store;
7788
const { body } = interaction.request;
@@ -108,13 +119,17 @@ export function* compareReqBody(
108119

109120
if (
110121
schema &&
111-
canValidate(contentType) &&
122+
canValidate(contentType, config.get("disable-multipart-formdata")!) &&
112123
isValidRequest(interaction) &&
113-
(process.env.QUIRKS
124+
(config.get("no-validate-request-body-unless-application-json")
114125
? !!findMatchingType("application/json", availableRequestContentTypes)
115126
: true)
116127
) {
117-
const value = parseBody(body, requestContentType);
128+
const value = parseBody(
129+
body,
130+
requestContentType,
131+
config.get("legacy-parser")!,
132+
);
118133
const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`;
119134
const validate = getValidateFunction(ajv, schemaId, () =>
120135
transformRequestSchema(minimumSchema(schema, oas)),
@@ -147,7 +162,7 @@ export function* compareReqBody(
147162
!!body &&
148163
!schema &&
149164
isValidRequest(interaction) &&
150-
(process.env.QUIRKS
165+
(config.get("no-validate-request-body-unless-application-json")
151166
? !!findMatchingType("application/json", availableRequestContentTypes) ||
152167
availableRequestContentTypes.length === 0
153168
: true)

src/compare/requestHeader.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
formatSchemaPath,
1414
} from "../results/index";
1515
import { minimumSchema } from "../transform/index";
16+
import type { Config } from "../utils/config";
1617
import { isValidRequest } from "../utils/interaction";
17-
import { isQuirky } from "../utils/quirks";
18+
import { isSimpleSchema } from "../utils/quirks";
1819
import { dereferenceOas, splitPath } from "../utils/schema";
1920
import { getValidateFunction } from "../utils/validation";
2021
import { findMatchingType, standardHttpRequestHeaders } from "./utils/content";
@@ -25,6 +26,7 @@ export function* compareReqHeader(
2526
route: Router.FindResult<Router.HTTPVersion.V1>,
2627
interaction: Interaction,
2728
index: number,
29+
config: Config,
2830
): Iterable<Result> {
2931
const { method, oas, operation, path, securitySchemes } = route.store;
3032
const { body } = interaction.request;
@@ -250,7 +252,7 @@ export function* compareReqHeader(
250252
break;
251253
}
252254

253-
if (process.env.QUIRKS) {
255+
if (config.get("no-authorization-schema")) {
254256
isValid = requestHeaders.get("authorization") !== null;
255257
}
256258

@@ -317,7 +319,9 @@ export function* compareReqHeader(
317319
if (value !== null && schema && isValidRequest(interaction)) {
318320
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
319321
const validate = getValidateFunction(ajv, schemaId, () =>
320-
process.env.QUIRKS && value && isQuirky(schema)
322+
config.get("no-validate-complex-parameters") &&
323+
isSimpleSchema(schema) &&
324+
value
321325
? {}
322326
: minimumSchema(schema, oas),
323327
);

src/compare/requestPath.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import type { Interaction } from "../documents/pact";
77
import type { Result } from "../results/index";
88
import { baseMockDetails } from "../results/index";
99
import { minimumSchema } from "../transform/index";
10+
import type { Config } from "../utils/config";
1011
import { dereferenceOas } from "../utils/schema";
11-
import { isQuirky } from "../utils/quirks";
12+
import { isSimpleSchema } from "../utils/quirks";
1213
import { getValidateFunction } from "../utils/validation";
1314
import { cleanPathParameter } from "./utils/parameters";
1415
import { parseValue } from "./utils/parse";
@@ -18,6 +19,7 @@ export function* compareReqPath(
1819
route: Router.FindResult<Router.HTTPVersion.V1>,
1920
interaction: Interaction,
2021
index: number,
22+
config: Config,
2123
): Iterable<Result> {
2224
const { method, oas, operation, path } = route.store;
2325

@@ -44,7 +46,9 @@ export function* compareReqPath(
4446
if (schema) {
4547
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
4648
const validate = getValidateFunction(ajv, schemaId, () =>
47-
process.env.QUIRKS && value && isQuirky(schema)
49+
config.get("no-validate-complex-parameters") &&
50+
isSimpleSchema(schema) &&
51+
value
4852
? {}
4953
: minimumSchema(schema, oas),
5054
);

src/compare/requestQuery.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import {
1414
formatSchemaPath,
1515
} from "../results/index";
1616
import { minimumSchema } from "../transform/index";
17+
import type { Config } from "../utils/config";
1718
import { isValidRequest } from "../utils/interaction";
1819
import { ARRAY_SEPARATOR } from "../utils/queryParams";
19-
import { isQuirky } from "../utils/quirks";
20+
import { isSimpleSchema } from "../utils/quirks";
2021
import { dereferenceOas, splitPath } from "../utils/schema";
2122
import { getValidateFunction } from "../utils/validation";
2223

@@ -25,17 +26,18 @@ export function* compareReqQuery(
2526
route: Router.FindResult<Router.HTTPVersion.V1>,
2627
interaction: Interaction,
2728
index: number,
29+
config: Config,
2830
): Iterable<Result> {
2931
const { method, oas, operation, path, securitySchemes } = route.store;
3032

31-
const searchParamsParsed = process.env.QUIRKS
33+
const searchParamsParsed = config.get("legacy-parser")
3234
? querystring.parse(route.searchParams as unknown as string)
3335
: qs.parse(route.searchParams, {
3436
allowDots: true,
3537
comma: true,
3638
});
3739

38-
const searchParamsUnparsed = process.env.QUIRKS
40+
const searchParamsUnparsed = config.get("legacy-parser")
3941
? querystring.parse(route.searchParams as unknown as string)
4042
: qs.parse(route.searchParams, {
4143
allowDots: false,
@@ -66,7 +68,9 @@ export function* compareReqQuery(
6668
) {
6769
const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`;
6870
const validate = getValidateFunction(ajv, schemaId, () =>
69-
process.env.QUIRKS && value && isQuirky(schema)
71+
config.get("no-validate-complex-parameters") &&
72+
isSimpleSchema(schema) &&
73+
value
7074
? {}
7175
: minimumSchema(schema, oas),
7276
);
@@ -76,7 +80,7 @@ export function* compareReqQuery(
7680
? value.split(ARRAY_SEPARATOR)
7781
: value;
7882

79-
if (process.env.QUIRKS && value === "[object Object]") {
83+
if (config.get("cast-objects-in-pact") && value === "[object Object]") {
8084
convertedValue = {};
8185
}
8286

src/compare/responseBody.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatInstancePath,
1313
formatSchemaPath,
1414
} from "../results/index";
15+
import type { Config } from "../utils/config";
1516
import { minimumSchema, transformResponseSchema } from "../transform/index";
1617
import { dereferenceOas, splitPath } from "../utils/schema";
1718
import { getValidateFunction } from "../utils/validation";
@@ -28,6 +29,7 @@ export function* compareResBody(
2829
route: Router.FindResult<Router.HTTPVersion.V1>,
2930
interaction: Interaction,
3031
index: number,
32+
config: Config,
3133
): Iterable<Result> {
3234
const { method, oas, operation, path } = route.store;
3335
const { body, status } = interaction.response;
@@ -119,7 +121,10 @@ export function* compareResBody(
119121
if (value && canValidate(contentType) && schema) {
120122
const schemaId = `[root].paths.${path}.${method}.responses.${status}.content.${contentType}`;
121123
const validate = getValidateFunction(ajv, schemaId, () =>
122-
transformResponseSchema(minimumSchema(schema, oas)),
124+
transformResponseSchema(
125+
minimumSchema(schema, oas),
126+
config.get("no-transform-non-nullable-response-schema")!,
127+
),
123128
);
124129
if (!validate(value)) {
125130
for (const error of validate.errors!) {

src/compare/responseHeader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatSchemaPath,
1313
} from "../results/index";
1414
import { minimumSchema } from "../transform/index";
15+
import type { Config } from "../utils/config";
1516
import { dereferenceOas, splitPath } from "../utils/schema";
1617
import { getValidateFunction } from "../utils/validation";
1718
import { findMatchingType, standardHttpResponseHeaders } from "./utils/content";
@@ -21,6 +22,7 @@ export function* compareResHeader(
2122
route: Router.FindResult<Router.HTTPVersion.V1>,
2223
interaction: Interaction,
2324
index: number,
25+
_config: Config,
2426
): Iterable<Result> {
2527
const { method, oas, operation, path } = route.store;
2628

src/compare/setup.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import addFormats from "ajv-formats";
44
import Router, { HTTPMethod } from "find-my-way";
55
import { uniqWith } from "lodash-es";
66
import { cleanPathParameter } from "./utils/parameters";
7+
import type { Config } from "../utils/config";
78
import { dereferenceOas } from "../utils/schema";
89

910
export function setupAjv(options: Options): Ajv {
@@ -29,10 +30,11 @@ const SUPPORTED_METHODS = [
2930

3031
export function setupRouter(
3132
oas: OpenAPIV2.Document | OpenAPIV3.Document,
33+
config: Config,
3234
): Router.Instance<Router.HTTPVersion.V1> {
3335
const router = Router({
34-
ignoreDuplicateSlashes: process.env.QUIRKS ? true : false,
35-
ignoreTrailingSlash: process.env.QUIRKS ? true : false,
36+
ignoreDuplicateSlashes: config.get("ignore-duplicate-slashes"),
37+
ignoreTrailingSlash: config.get("ignore-trailing-slash"),
3638
querystringParser: (s: string): string => s, // don't parse query in router
3739
});
3840
for (const oasPath in oas.paths) {

0 commit comments

Comments
 (0)