From d5b5d1ae51cf8a2b09fb18e5c6d8de1df8121789 Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 10:08:49 +1100 Subject: [PATCH 1/6] feat: support fine-grained quirks mode --- src/compare/requestBody.ts | 19 +++++++++++-------- src/compare/requestHeader.ts | 7 +++++-- src/compare/requestPath.ts | 7 +++++-- src/compare/requestQuery.ts | 11 +++++++---- src/compare/setup.ts | 5 +++-- src/transform/responseSchema.ts | 3 ++- src/utils/config.ts | 31 +++++++++++++++++++++++++++++++ src/utils/quirks.ts | 2 +- 8 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 src/utils/config.ts diff --git a/src/compare/requestBody.ts b/src/compare/requestBody.ts index abf1831..fc3d5a0 100644 --- a/src/compare/requestBody.ts +++ b/src/compare/requestBody.ts @@ -4,6 +4,7 @@ import type { OpenAPIV2 } from "openapi-types"; import type Router from "find-my-way"; import { get } from "lodash-es"; import qs from "qs"; +import querystring from "node:querystring"; import multipart from "parse-multipart-data"; import type { Interaction } from "../documents/pact"; @@ -15,6 +16,7 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema, transformRequestSchema } from "../transform/index"; +import { config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; @@ -25,11 +27,12 @@ const parseBody = (body: unknown, contentType: string) => { contentType.includes("application/x-www-form-urlencoded") && typeof body === "string" ) { - return qs.parse(body as string, { - allowDots: true, - comma: true, - depth: process.env.QUIRKS ? 0 : undefined, - }); + return config.get("legacyParser") + ? querystring.parse(body as string) + : qs.parse(body as string, { + allowDots: true, + comma: true, + }); } if (contentType.includes("multipart/form-data") && typeof body === "string") { @@ -60,7 +63,7 @@ const canValidate = (contentType: string): boolean => { [ "application/json", "application/x-www-form-urlencoded", - process.env.QUIRKS ? "" : "multipart/form-data", + config.get("disableMultipartFormdata") ? "" : "multipart/form-data", ].filter(Boolean), ); }; @@ -110,7 +113,7 @@ export function* compareReqBody( schema && canValidate(contentType) && isValidRequest(interaction) && - (process.env.QUIRKS + (config.get("noValidateRequestBodyUnlessApplicationJson") ? !!findMatchingType("application/json", availableRequestContentTypes) : true) ) { @@ -147,7 +150,7 @@ export function* compareReqBody( !!body && !schema && isValidRequest(interaction) && - (process.env.QUIRKS + (config.get("noValidateRequestBodyUnlessApplicationJson") ? !!findMatchingType("application/json", availableRequestContentTypes) || availableRequestContentTypes.length === 0 : true) diff --git a/src/compare/requestHeader.ts b/src/compare/requestHeader.ts index f9f2b77..3e8f081 100644 --- a/src/compare/requestHeader.ts +++ b/src/compare/requestHeader.ts @@ -14,7 +14,8 @@ import { } from "../results/index"; import { minimumSchema } from "../transform/index"; import { isValidRequest } from "../utils/interaction"; -import { isQuirky } from "../utils/quirks"; +import { config } from "../utils/config"; +import { isSimpleSchema } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; import { findMatchingType, standardHttpRequestHeaders } from "./utils/content"; @@ -317,7 +318,9 @@ export function* compareReqHeader( if (value !== null && schema && isValidRequest(interaction)) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - process.env.QUIRKS && value && isQuirky(schema) + config.get("noValidateComplexParameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); diff --git a/src/compare/requestPath.ts b/src/compare/requestPath.ts index ae9ea48..cfdbd97 100644 --- a/src/compare/requestPath.ts +++ b/src/compare/requestPath.ts @@ -7,8 +7,9 @@ import type { Interaction } from "../documents/pact"; import type { Result } from "../results/index"; import { baseMockDetails } from "../results/index"; import { minimumSchema } from "../transform/index"; +import { config } from "../utils/config"; import { dereferenceOas } from "../utils/schema"; -import { isQuirky } from "../utils/quirks"; +import { isSimpleSchema } from "../utils/quirks"; import { getValidateFunction } from "../utils/validation"; import { cleanPathParameter } from "./utils/parameters"; import { parseValue } from "./utils/parse"; @@ -44,7 +45,9 @@ export function* compareReqPath( if (schema) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - process.env.QUIRKS && value && isQuirky(schema) + config.get("noValidateComplexParameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); diff --git a/src/compare/requestQuery.ts b/src/compare/requestQuery.ts index c30f257..8e93fd4 100644 --- a/src/compare/requestQuery.ts +++ b/src/compare/requestQuery.ts @@ -14,9 +14,10 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema } from "../transform/index"; +import { config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; -import { isQuirky } from "../utils/quirks"; +import { isSimpleSchema } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; @@ -28,14 +29,14 @@ export function* compareReqQuery( ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; - const searchParamsParsed = process.env.QUIRKS + const searchParamsParsed = config.get("legacyParser") ? querystring.parse(route.searchParams as unknown as string) : qs.parse(route.searchParams, { allowDots: true, comma: true, }); - const searchParamsUnparsed = process.env.QUIRKS + const searchParamsUnparsed = config.get("legacyParser") ? querystring.parse(route.searchParams as unknown as string) : qs.parse(route.searchParams, { allowDots: false, @@ -66,7 +67,9 @@ export function* compareReqQuery( ) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - process.env.QUIRKS && value && isQuirky(schema) + config.get("noValidateComplexParameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); diff --git a/src/compare/setup.ts b/src/compare/setup.ts index cfc0c22..0347f72 100644 --- a/src/compare/setup.ts +++ b/src/compare/setup.ts @@ -5,6 +5,7 @@ import Router, { HTTPMethod } from "find-my-way"; import { uniqWith } from "lodash-es"; import { cleanPathParameter } from "./utils/parameters"; import { dereferenceOas } from "../utils/schema"; +import { config } from "../utils/config"; export function setupAjv(options: Options): Ajv { const ajv = new Ajv(options); @@ -31,8 +32,8 @@ export function setupRouter( oas: OpenAPIV2.Document | OpenAPIV3.Document, ): Router.Instance { const router = Router({ - ignoreDuplicateSlashes: process.env.QUIRKS ? true : false, - ignoreTrailingSlash: process.env.QUIRKS ? true : false, + ignoreDuplicateSlashes: config.get("duplicateSlashes"), + ignoreTrailingSlash: config.get("trailingSlash"), querystringParser: (s: string): string => s, // don't parse query in router }); for (const oasPath in oas.paths) { diff --git a/src/transform/responseSchema.ts b/src/transform/responseSchema.ts index 68e1f14..6dfd958 100644 --- a/src/transform/responseSchema.ts +++ b/src/transform/responseSchema.ts @@ -1,5 +1,6 @@ import { SchemaObject } from "ajv"; import { each, get } from "lodash-es"; +import { config } from "../utils/config"; import { splitPath, traverseWithDereferencing as traverse, @@ -17,7 +18,7 @@ export const transformResponseSchema = (schema: SchemaObject): SchemaObject => { !s.anyOf && s.type && s.type === "object" && - (process.env.QUIRKS ? !s.nullable : true) + (config.get("noTransformNonNullableResponseSchema") ? !s.nullable : true) ) { s.additionalProperties = false; } diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..8a6f890 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,31 @@ +const quirks = !!process.env.QUIRKS; + +export const config = new Map([ + // SMV ignores duplicate slashes in OAS + ["ignoreDuplicateSlashes", quirks], + + // SMV ignores trailing slashes in OAS + ["ignoreTrailingSlash", quirks], + + // SMV had a bug whereby nullable response schemas were not transformed + // correctly. It was missing a transformation to set additionalProperties to + // false. This flag reproduces the bug. + ["noTransformNonNullableResponseSchema", quirks], + + // SMV used node:querystring to parse query strings and + // application/x-www-form-urlencoded form bodies. This had a limitation that + // nested objects/arrays are not parsed correctly + ["legacyParser", quirks], + + // SMV didn't support multipart/form-data + ["disableMultipartFormdata", quirks], + + // SMV had a bug whereby request bodies of *any* content-type are not + // validated unless application/json is in the list of supported request + // content types + ["noValidateRequestBodyUnlessApplicationJson", quirks], + + // SMV only validated schemas of arrays and objects in path, headers, and + // query params + ["noValidateComplexParameters", quirks], +]); diff --git a/src/utils/quirks.ts b/src/utils/quirks.ts index a74a7af..eed35d2 100644 --- a/src/utils/quirks.ts +++ b/src/utils/quirks.ts @@ -1,4 +1,4 @@ import type { SchemaObject } from "ajv"; -export const isQuirky = (s?: SchemaObject): boolean => +export const isSimpleSchema = (s?: SchemaObject): boolean => s === undefined || s.type === "array" || s.type === "object"; From 54354e373fe0af9433ce867b87c25458f849749f Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 13:06:13 +1100 Subject: [PATCH 2/6] feat: support per-comparison configuration --- src/compare/index.ts | 55 ++++++++++++++++++++++++++++----- src/compare/requestBody.ts | 26 +++++++++++----- src/compare/requestHeader.ts | 5 +-- src/compare/requestPath.ts | 3 +- src/compare/requestQuery.ts | 5 +-- src/compare/responseBody.ts | 4 ++- src/compare/responseHeader.ts | 2 ++ src/compare/setup.ts | 2 +- src/transform/responseSchema.ts | 7 +++-- src/utils/config.ts | 26 +++++++++++++++- 10 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/compare/index.ts b/src/compare/index.ts index 747cbba..2624980 100644 --- a/src/compare/index.ts +++ b/src/compare/index.ts @@ -14,15 +14,18 @@ import { compareReqHeader } from "./requestHeader"; import { compareResBody } from "./responseBody"; import { compareResHeader } from "./responseHeader"; import { baseMockDetails } from "../results/index"; +import { defaultConfig, Config } from "../utils/config"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; export class Comparator { #ajvCoerce: Ajv; #ajvNocoerce: Ajv; + #config: Config; #oas: OpenAPIV2.Document | OpenAPIV3.Document; #router?: Router.Instance; constructor(oas: OpenAPIV2.Document | OpenAPIV3.Document) { + this.#config = new Map(defaultConfig); this.#oas = oas; const ajvOptions = { @@ -46,7 +49,7 @@ export class Comparator { async *compare(pact: Pact): AsyncGenerator { if (!this.#router) { await parseOas(this.#oas); - this.#router = setupRouter(this.#oas); + this.#router = setupRouter(this.#oas, this.#config); } const parsedPact = parsePact(pact); @@ -63,7 +66,7 @@ export class Comparator { const { method, path, query } = interaction.request; let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; - if (process.env.QUIRKS) { + if (this.#config.get("noPercentEncoding")) { pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25"); } @@ -106,7 +109,13 @@ export class Comparator { } const results = Array.from( - compareReqPath(this.#ajvCoerce, route, interaction, index), + compareReqPath( + this.#ajvCoerce, + route, + interaction, + index, + this.#config, + ), ); if (results.length) { @@ -114,11 +123,41 @@ export class Comparator { continue; } - yield* compareReqHeader(this.#ajvCoerce, route, interaction, index); - yield* compareReqQuery(this.#ajvCoerce, route, interaction, index); - yield* compareReqBody(this.#ajvNocoerce, route, interaction, index); - yield* compareResHeader(this.#ajvCoerce, route, interaction, index); - yield* compareResBody(this.#ajvNocoerce, route, interaction, index); + yield* compareReqHeader( + this.#ajvCoerce, + route, + interaction, + index, + this.#config, + ); + yield* compareReqQuery( + this.#ajvCoerce, + route, + interaction, + index, + this.#config, + ); + yield* compareReqBody( + this.#ajvNocoerce, + route, + interaction, + index, + this.#config, + ); + yield* compareResHeader( + this.#ajvCoerce, + route, + interaction, + index, + this.#config, + ); + yield* compareResBody( + this.#ajvNocoerce, + route, + interaction, + index, + this.#config, + ); } } } diff --git a/src/compare/requestBody.ts b/src/compare/requestBody.ts index fc3d5a0..b395598 100644 --- a/src/compare/requestBody.ts +++ b/src/compare/requestBody.ts @@ -16,18 +16,22 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema, transformRequestSchema } from "../transform/index"; -import { config } from "../utils/config"; +import type { Config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; import { findMatchingType, getByContentType } from "./utils/content"; -const parseBody = (body: unknown, contentType: string) => { +const parseBody = ( + body: unknown, + contentType: string, + legacyParser: boolean, +) => { if ( contentType.includes("application/x-www-form-urlencoded") && typeof body === "string" ) { - return config.get("legacyParser") + return legacyParser ? querystring.parse(body as string) : qs.parse(body as string, { allowDots: true, @@ -57,13 +61,16 @@ const parseBody = (body: unknown, contentType: string) => { return body; }; -const canValidate = (contentType: string): boolean => { +const canValidate = ( + contentType: string, + disableMultipartFormdata: boolean, +): boolean => { return !!findMatchingType( contentType, [ "application/json", "application/x-www-form-urlencoded", - config.get("disableMultipartFormdata") ? "" : "multipart/form-data", + disableMultipartFormdata ? "" : "multipart/form-data", ].filter(Boolean), ); }; @@ -75,6 +82,7 @@ export function* compareReqBody( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path } = route.store; const { body } = interaction.request; @@ -111,13 +119,17 @@ export function* compareReqBody( if ( schema && - canValidate(contentType) && + canValidate(contentType, config.get("disableMultipartFormdata")!) && isValidRequest(interaction) && (config.get("noValidateRequestBodyUnlessApplicationJson") ? !!findMatchingType("application/json", availableRequestContentTypes) : true) ) { - const value = parseBody(body, requestContentType); + const value = parseBody( + body, + requestContentType, + config.get("legacyParser")!, + ); const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => transformRequestSchema(minimumSchema(schema, oas)), diff --git a/src/compare/requestHeader.ts b/src/compare/requestHeader.ts index 3e8f081..71b2b83 100644 --- a/src/compare/requestHeader.ts +++ b/src/compare/requestHeader.ts @@ -13,8 +13,8 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema } from "../transform/index"; +import type { Config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; -import { config } from "../utils/config"; import { isSimpleSchema } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; @@ -26,6 +26,7 @@ export function* compareReqHeader( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; const { body } = interaction.request; @@ -251,7 +252,7 @@ export function* compareReqHeader( break; } - if (process.env.QUIRKS) { + if (config.get("noAuthorizationSchema")) { isValid = requestHeaders.get("authorization") !== null; } diff --git a/src/compare/requestPath.ts b/src/compare/requestPath.ts index cfdbd97..9916906 100644 --- a/src/compare/requestPath.ts +++ b/src/compare/requestPath.ts @@ -7,7 +7,7 @@ import type { Interaction } from "../documents/pact"; import type { Result } from "../results/index"; import { baseMockDetails } from "../results/index"; import { minimumSchema } from "../transform/index"; -import { config } from "../utils/config"; +import type { Config } from "../utils/config"; import { dereferenceOas } from "../utils/schema"; import { isSimpleSchema } from "../utils/quirks"; import { getValidateFunction } from "../utils/validation"; @@ -19,6 +19,7 @@ export function* compareReqPath( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path } = route.store; diff --git a/src/compare/requestQuery.ts b/src/compare/requestQuery.ts index 8e93fd4..013fec7 100644 --- a/src/compare/requestQuery.ts +++ b/src/compare/requestQuery.ts @@ -14,7 +14,7 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema } from "../transform/index"; -import { config } from "../utils/config"; +import type { Config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; import { isSimpleSchema } from "../utils/quirks"; @@ -26,6 +26,7 @@ export function* compareReqQuery( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; @@ -79,7 +80,7 @@ export function* compareReqQuery( ? value.split(ARRAY_SEPARATOR) : value; - if (process.env.QUIRKS && value === "[object Object]") { + if (config.get("castObjectsInPact") && value === "[object Object]") { convertedValue = {}; } diff --git a/src/compare/responseBody.ts b/src/compare/responseBody.ts index 6058a34..4b2d57f 100644 --- a/src/compare/responseBody.ts +++ b/src/compare/responseBody.ts @@ -12,6 +12,7 @@ import { formatInstancePath, formatSchemaPath, } from "../results/index"; +import type { Config } from "../utils/config"; import { minimumSchema, transformResponseSchema } from "../transform/index"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; @@ -28,6 +29,7 @@ export function* compareResBody( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path } = route.store; const { body, status } = interaction.response; @@ -119,7 +121,7 @@ export function* compareResBody( if (value && canValidate(contentType) && schema) { const schemaId = `[root].paths.${path}.${method}.responses.${status}.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => - transformResponseSchema(minimumSchema(schema, oas)), + transformResponseSchema(minimumSchema(schema, oas), config), ); if (!validate(value)) { for (const error of validate.errors!) { diff --git a/src/compare/responseHeader.ts b/src/compare/responseHeader.ts index 7173f90..7ec3e31 100644 --- a/src/compare/responseHeader.ts +++ b/src/compare/responseHeader.ts @@ -12,6 +12,7 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema } from "../transform/index"; +import type { Config } from "../utils/config"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; import { findMatchingType, standardHttpResponseHeaders } from "./utils/content"; @@ -21,6 +22,7 @@ export function* compareResHeader( route: Router.FindResult, interaction: Interaction, index: number, + _config: Config, ): Iterable { const { method, oas, operation, path } = route.store; diff --git a/src/compare/setup.ts b/src/compare/setup.ts index 0347f72..2c315cf 100644 --- a/src/compare/setup.ts +++ b/src/compare/setup.ts @@ -5,7 +5,6 @@ import Router, { HTTPMethod } from "find-my-way"; import { uniqWith } from "lodash-es"; import { cleanPathParameter } from "./utils/parameters"; import { dereferenceOas } from "../utils/schema"; -import { config } from "../utils/config"; export function setupAjv(options: Options): Ajv { const ajv = new Ajv(options); @@ -30,6 +29,7 @@ const SUPPORTED_METHODS = [ export function setupRouter( oas: OpenAPIV2.Document | OpenAPIV3.Document, + config: Map, ): Router.Instance { const router = Router({ ignoreDuplicateSlashes: config.get("duplicateSlashes"), diff --git a/src/transform/responseSchema.ts b/src/transform/responseSchema.ts index 6dfd958..810cc4c 100644 --- a/src/transform/responseSchema.ts +++ b/src/transform/responseSchema.ts @@ -1,12 +1,15 @@ import { SchemaObject } from "ajv"; import { each, get } from "lodash-es"; -import { config } from "../utils/config"; +import type { Config } from "../utils/config"; import { splitPath, traverseWithDereferencing as traverse, } from "../utils/schema"; -export const transformResponseSchema = (schema: SchemaObject): SchemaObject => { +export const transformResponseSchema = ( + schema: SchemaObject, + config: Config, +): SchemaObject => { // a provider must provide a superset of what the consumer asks for // additionalProperties expected in pact response are disallowed traverse(schema, (s) => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 8a6f890..806aac7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,20 @@ const quirks = !!process.env.QUIRKS; -export const config = new Map([ +export type Config = Map< + | "ignoreDuplicateSlashes" + | "ignoreTrailingSlash" + | "noTransformNonNullableResponseSchema" + | "legacyParser" + | "disableMultipartFormdata" + | "noValidateRequestBodyUnlessApplicationJson" + | "noValidateComplexParameters" + | "castObjectsInPact" + | "noAuthorizationSchema" + | "noPercentEncoding", + boolean +>; + +export const defaultConfig: Config = new Map([ // SMV ignores duplicate slashes in OAS ["ignoreDuplicateSlashes", quirks], @@ -28,4 +42,14 @@ export const config = new Map([ // SMV only validated schemas of arrays and objects in path, headers, and // query params ["noValidateComplexParameters", quirks], + + // SMV casts "[object Object]" queries as objects for validation purposes, + // rather than flag it as a string - suggesting a broken Pact file + ["castObjectsInPact", quirks], + + // SMV only checks presence of Authorization header, it does not check its schema + ["noAuthorizationSchema", quirks], + + // SMV allows percentages in path, even if it is not percent encoded + ["noPercentEncoding", quirks], ]); From afdb9a10d3ab517e1863f54e9de41a30a622a547 Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 13:48:49 +1100 Subject: [PATCH 3/6] refactor: rename config flags to use kebab-case --- src/compare/index.ts | 6 +-- src/compare/requestBody.ts | 8 ++-- src/compare/requestHeader.ts | 4 +- src/compare/requestPath.ts | 2 +- src/compare/requestQuery.ts | 8 ++-- src/compare/responseBody.ts | 5 ++- src/compare/setup.ts | 7 ++-- src/transform/responseSchema.ts | 5 +-- src/utils/config.ts | 70 ++++++++++++++++----------------- 9 files changed, 59 insertions(+), 56 deletions(-) diff --git a/src/compare/index.ts b/src/compare/index.ts index 2624980..4f1583d 100644 --- a/src/compare/index.ts +++ b/src/compare/index.ts @@ -14,7 +14,7 @@ import { compareReqHeader } from "./requestHeader"; import { compareResBody } from "./responseBody"; import { compareResHeader } from "./responseHeader"; import { baseMockDetails } from "../results/index"; -import { defaultConfig, Config } from "../utils/config"; +import { Config, DEFAULT_CONFIG } from "../utils/config"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; export class Comparator { @@ -25,7 +25,7 @@ export class Comparator { #router?: Router.Instance; constructor(oas: OpenAPIV2.Document | OpenAPIV3.Document) { - this.#config = new Map(defaultConfig); + this.#config = new Map(DEFAULT_CONFIG); this.#oas = oas; const ajvOptions = { @@ -66,7 +66,7 @@ export class Comparator { const { method, path, query } = interaction.request; let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; - if (this.#config.get("noPercentEncoding")) { + if (this.#config.get("no-percent-encoding")) { pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25"); } diff --git a/src/compare/requestBody.ts b/src/compare/requestBody.ts index b395598..2bca771 100644 --- a/src/compare/requestBody.ts +++ b/src/compare/requestBody.ts @@ -119,16 +119,16 @@ export function* compareReqBody( if ( schema && - canValidate(contentType, config.get("disableMultipartFormdata")!) && + canValidate(contentType, config.get("disable-multipart-formdata")!) && isValidRequest(interaction) && - (config.get("noValidateRequestBodyUnlessApplicationJson") + (config.get("no-validate-request-body-unless-application-json") ? !!findMatchingType("application/json", availableRequestContentTypes) : true) ) { const value = parseBody( body, requestContentType, - config.get("legacyParser")!, + config.get("legacy-parser")!, ); const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => @@ -162,7 +162,7 @@ export function* compareReqBody( !!body && !schema && isValidRequest(interaction) && - (config.get("noValidateRequestBodyUnlessApplicationJson") + (config.get("no-validate-request-body-unless-application-json") ? !!findMatchingType("application/json", availableRequestContentTypes) || availableRequestContentTypes.length === 0 : true) diff --git a/src/compare/requestHeader.ts b/src/compare/requestHeader.ts index 71b2b83..f6b24f2 100644 --- a/src/compare/requestHeader.ts +++ b/src/compare/requestHeader.ts @@ -252,7 +252,7 @@ export function* compareReqHeader( break; } - if (config.get("noAuthorizationSchema")) { + if (config.get("no-authorization-schema")) { isValid = requestHeaders.get("authorization") !== null; } @@ -319,7 +319,7 @@ export function* compareReqHeader( if (value !== null && schema && isValidRequest(interaction)) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - config.get("noValidateComplexParameters") && + config.get("no-validate-complex-parameters") && isSimpleSchema(schema) && value ? {} diff --git a/src/compare/requestPath.ts b/src/compare/requestPath.ts index 9916906..60e6f56 100644 --- a/src/compare/requestPath.ts +++ b/src/compare/requestPath.ts @@ -46,7 +46,7 @@ export function* compareReqPath( if (schema) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - config.get("noValidateComplexParameters") && + config.get("no-validate-complex-parameters") && isSimpleSchema(schema) && value ? {} diff --git a/src/compare/requestQuery.ts b/src/compare/requestQuery.ts index 013fec7..c221ece 100644 --- a/src/compare/requestQuery.ts +++ b/src/compare/requestQuery.ts @@ -30,14 +30,14 @@ export function* compareReqQuery( ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; - const searchParamsParsed = config.get("legacyParser") + const searchParamsParsed = config.get("legacy-parser") ? querystring.parse(route.searchParams as unknown as string) : qs.parse(route.searchParams, { allowDots: true, comma: true, }); - const searchParamsUnparsed = config.get("legacyParser") + const searchParamsUnparsed = config.get("legacy-parser") ? querystring.parse(route.searchParams as unknown as string) : qs.parse(route.searchParams, { allowDots: false, @@ -68,7 +68,7 @@ export function* compareReqQuery( ) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - config.get("noValidateComplexParameters") && + config.get("no-validate-complex-parameters") && isSimpleSchema(schema) && value ? {} @@ -80,7 +80,7 @@ export function* compareReqQuery( ? value.split(ARRAY_SEPARATOR) : value; - if (config.get("castObjectsInPact") && value === "[object Object]") { + if (config.get("cast-objects-in-pact") && value === "[object Object]") { convertedValue = {}; } diff --git a/src/compare/responseBody.ts b/src/compare/responseBody.ts index 4b2d57f..21e82f5 100644 --- a/src/compare/responseBody.ts +++ b/src/compare/responseBody.ts @@ -121,7 +121,10 @@ export function* compareResBody( if (value && canValidate(contentType) && schema) { const schemaId = `[root].paths.${path}.${method}.responses.${status}.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => - transformResponseSchema(minimumSchema(schema, oas), config), + transformResponseSchema( + minimumSchema(schema, oas), + config.get("no-transform-non-nullable-response-schema")!, + ), ); if (!validate(value)) { for (const error of validate.errors!) { diff --git a/src/compare/setup.ts b/src/compare/setup.ts index 2c315cf..ae50b07 100644 --- a/src/compare/setup.ts +++ b/src/compare/setup.ts @@ -4,6 +4,7 @@ import addFormats from "ajv-formats"; import Router, { HTTPMethod } from "find-my-way"; import { uniqWith } from "lodash-es"; import { cleanPathParameter } from "./utils/parameters"; +import type { Config } from "../utils/config"; import { dereferenceOas } from "../utils/schema"; export function setupAjv(options: Options): Ajv { @@ -29,11 +30,11 @@ const SUPPORTED_METHODS = [ export function setupRouter( oas: OpenAPIV2.Document | OpenAPIV3.Document, - config: Map, + config: Config ): Router.Instance { const router = Router({ - ignoreDuplicateSlashes: config.get("duplicateSlashes"), - ignoreTrailingSlash: config.get("trailingSlash"), + ignoreDuplicateSlashes: config.get("ignore-duplicate-slashes"), + ignoreTrailingSlash: config.get("ignore-trailing-slash"), querystringParser: (s: string): string => s, // don't parse query in router }); for (const oasPath in oas.paths) { diff --git a/src/transform/responseSchema.ts b/src/transform/responseSchema.ts index 810cc4c..5bafb4c 100644 --- a/src/transform/responseSchema.ts +++ b/src/transform/responseSchema.ts @@ -1,6 +1,5 @@ import { SchemaObject } from "ajv"; import { each, get } from "lodash-es"; -import type { Config } from "../utils/config"; import { splitPath, traverseWithDereferencing as traverse, @@ -8,7 +7,7 @@ import { export const transformResponseSchema = ( schema: SchemaObject, - config: Config, + noTransformNonNullableResponseSchema: boolean, ): SchemaObject => { // a provider must provide a superset of what the consumer asks for // additionalProperties expected in pact response are disallowed @@ -21,7 +20,7 @@ export const transformResponseSchema = ( !s.anyOf && s.type && s.type === "object" && - (config.get("noTransformNonNullableResponseSchema") ? !s.nullable : true) + (noTransformNonNullableResponseSchema ? !s.nullable : true) ) { s.additionalProperties = false; } diff --git a/src/utils/config.ts b/src/utils/config.ts index 806aac7..c46a9f7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,55 +1,55 @@ const quirks = !!process.env.QUIRKS; export type Config = Map< - | "ignoreDuplicateSlashes" - | "ignoreTrailingSlash" - | "noTransformNonNullableResponseSchema" - | "legacyParser" - | "disableMultipartFormdata" - | "noValidateRequestBodyUnlessApplicationJson" - | "noValidateComplexParameters" - | "castObjectsInPact" - | "noAuthorizationSchema" - | "noPercentEncoding", + | "cast-objects-in-pact" + | "disable-multipart-formdata" + | "ignore-duplicate-slashes" + | "ignore-trailing-slash" + | "legacy-parser" + | "no-authorization-schema" + | "no-percent-encoding" + | "no-transform-non-nullable-response-schema" + | "no-validate-complex-parameters" + | "no-validate-request-body-unless-application-json", boolean >; -export const defaultConfig: Config = new Map([ +export const DEFAULT_CONFIG: Config = new Map([ + // SMV casts "[object Object]" queries as objects for validation purposes, + // rather than flag it as a string - suggesting a broken Pact file + ["cast-objects-in-pact", quirks], + + // SMV didn't support multipart/form-data + ["disable-multipart-formdata", quirks], + // SMV ignores duplicate slashes in OAS - ["ignoreDuplicateSlashes", quirks], + ["ignore-duplicate-slashes", quirks], // SMV ignores trailing slashes in OAS - ["ignoreTrailingSlash", quirks], - - // SMV had a bug whereby nullable response schemas were not transformed - // correctly. It was missing a transformation to set additionalProperties to - // false. This flag reproduces the bug. - ["noTransformNonNullableResponseSchema", quirks], + ["ignore-trailing-slash", quirks], // SMV used node:querystring to parse query strings and // application/x-www-form-urlencoded form bodies. This had a limitation that // nested objects/arrays are not parsed correctly - ["legacyParser", quirks], + ["legacy-parser", quirks], - // SMV didn't support multipart/form-data - ["disableMultipartFormdata", quirks], + // SMV only checks presence of Authorization header, it does not check its schema + ["no-authorization-schema", quirks], - // SMV had a bug whereby request bodies of *any* content-type are not - // validated unless application/json is in the list of supported request - // content types - ["noValidateRequestBodyUnlessApplicationJson", quirks], + // SMV allows percentages in path, even if it is not percent encoded + ["no-percent-encoding", quirks], + + // SMV had a bug whereby nullable response schemas were not transformed + // correctly. It was missing a transformation to set additionalProperties to + // false. This flag reproduces the bug. + ["no-transform-non-nullable-response-schema", quirks], // SMV only validated schemas of arrays and objects in path, headers, and // query params - ["noValidateComplexParameters", quirks], - - // SMV casts "[object Object]" queries as objects for validation purposes, - // rather than flag it as a string - suggesting a broken Pact file - ["castObjectsInPact", quirks], - - // SMV only checks presence of Authorization header, it does not check its schema - ["noAuthorizationSchema", quirks], + ["no-validate-complex-parameters", quirks], - // SMV allows percentages in path, even if it is not percent encoded - ["noPercentEncoding", quirks], + // SMV had a bug whereby request bodies of *any* content-type are not + // validated unless application/json is in the list of supported request + // content types + ["no-validate-request-body-unless-application-json", quirks], ]); From 774430c6720ee87e08ea316488fec05ef3929a17 Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 13:51:48 +1100 Subject: [PATCH 4/6] chore: prettier --- src/compare/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compare/setup.ts b/src/compare/setup.ts index ae50b07..f4f3f89 100644 --- a/src/compare/setup.ts +++ b/src/compare/setup.ts @@ -30,7 +30,7 @@ const SUPPORTED_METHODS = [ export function setupRouter( oas: OpenAPIV2.Document | OpenAPIV3.Document, - config: Config + config: Config, ): Router.Instance { const router = Router({ ignoreDuplicateSlashes: config.get("ignore-duplicate-slashes"), From 824ab8449541270cbd262aaa3e490fde7928a191 Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 16:58:48 +1100 Subject: [PATCH 5/6] feat: use OAS extensions to toggle quirks --- src/compare/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/compare/index.ts b/src/compare/index.ts index 4f1583d..68aef26 100644 --- a/src/compare/index.ts +++ b/src/compare/index.ts @@ -49,6 +49,11 @@ export class Comparator { async *compare(pact: Pact): AsyncGenerator { if (!this.#router) { await parseOas(this.#oas); + for (const [key, value] of Object.entries(this.#oas.info)) { + if (key.startsWith("x-opc-config-")) { + this.#config.set(key.substring(13), value); + } + } this.#router = setupRouter(this.#oas, this.#config); } From 46cfca6736c7a10da7d2e9e8944f6d87cfcfc1a1 Mon Sep 17 00:00:00 2001 From: Voon Wong Date: Thu, 13 Mar 2025 20:15:20 +1100 Subject: [PATCH 6/6] chore: fix types --- src/compare/index.ts | 4 ++-- src/utils/config.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/compare/index.ts b/src/compare/index.ts index 68aef26..ac7d4dc 100644 --- a/src/compare/index.ts +++ b/src/compare/index.ts @@ -14,7 +14,7 @@ import { compareReqHeader } from "./requestHeader"; import { compareResBody } from "./responseBody"; import { compareResHeader } from "./responseHeader"; import { baseMockDetails } from "../results/index"; -import { Config, DEFAULT_CONFIG } from "../utils/config"; +import { Config, ConfigKeys, DEFAULT_CONFIG } from "../utils/config"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; export class Comparator { @@ -51,7 +51,7 @@ export class Comparator { await parseOas(this.#oas); for (const [key, value] of Object.entries(this.#oas.info)) { if (key.startsWith("x-opc-config-")) { - this.#config.set(key.substring(13), value); + this.#config.set(key.substring(13) as ConfigKeys, value); } } this.#router = setupRouter(this.#oas, this.#config); diff --git a/src/utils/config.ts b/src/utils/config.ts index c46a9f7..0fdfdc1 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,6 @@ const quirks = !!process.env.QUIRKS; -export type Config = Map< +export type ConfigKeys = | "cast-objects-in-pact" | "disable-multipart-formdata" | "ignore-duplicate-slashes" @@ -10,9 +10,9 @@ export type Config = Map< | "no-percent-encoding" | "no-transform-non-nullable-response-schema" | "no-validate-complex-parameters" - | "no-validate-request-body-unless-application-json", - boolean ->; + | "no-validate-request-body-unless-application-json"; + +export type Config = Map; export const DEFAULT_CONFIG: Config = new Map([ // SMV casts "[object Object]" queries as objects for validation purposes,