diff --git a/src/compare/index.ts b/src/compare/index.ts index 747cbba..ac7d4dc 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 { Config, ConfigKeys, DEFAULT_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(DEFAULT_CONFIG); this.#oas = oas; const ajvOptions = { @@ -46,7 +49,12 @@ export class Comparator { async *compare(pact: Pact): AsyncGenerator { if (!this.#router) { await parseOas(this.#oas); - this.#router = setupRouter(this.#oas); + for (const [key, value] of Object.entries(this.#oas.info)) { + if (key.startsWith("x-opc-config-")) { + this.#config.set(key.substring(13) as ConfigKeys, value); + } + } + this.#router = setupRouter(this.#oas, this.#config); } const parsedPact = parsePact(pact); @@ -63,7 +71,7 @@ export class Comparator { const { method, path, query } = interaction.request; let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; - if (process.env.QUIRKS) { + if (this.#config.get("no-percent-encoding")) { pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25"); } @@ -106,7 +114,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 +128,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 abf1831..2bca771 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,21 +16,27 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema, transformRequestSchema } from "../transform/index"; +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 qs.parse(body as string, { - allowDots: true, - comma: true, - depth: process.env.QUIRKS ? 0 : undefined, - }); + return legacyParser + ? querystring.parse(body as string) + : qs.parse(body as string, { + allowDots: true, + comma: true, + }); } if (contentType.includes("multipart/form-data") && typeof body === "string") { @@ -54,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", - process.env.QUIRKS ? "" : "multipart/form-data", + disableMultipartFormdata ? "" : "multipart/form-data", ].filter(Boolean), ); }; @@ -72,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; @@ -108,13 +119,17 @@ export function* compareReqBody( if ( schema && - canValidate(contentType) && + canValidate(contentType, config.get("disable-multipart-formdata")!) && isValidRequest(interaction) && - (process.env.QUIRKS + (config.get("no-validate-request-body-unless-application-json") ? !!findMatchingType("application/json", availableRequestContentTypes) : true) ) { - const value = parseBody(body, requestContentType); + const value = parseBody( + body, + requestContentType, + config.get("legacy-parser")!, + ); const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => transformRequestSchema(minimumSchema(schema, oas)), @@ -147,7 +162,7 @@ export function* compareReqBody( !!body && !schema && isValidRequest(interaction) && - (process.env.QUIRKS + (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 f9f2b77..f6b24f2 100644 --- a/src/compare/requestHeader.ts +++ b/src/compare/requestHeader.ts @@ -13,8 +13,9 @@ import { formatSchemaPath, } from "../results/index"; import { minimumSchema } from "../transform/index"; +import type { Config } from "../utils/config"; import { isValidRequest } from "../utils/interaction"; -import { isQuirky } from "../utils/quirks"; +import { isSimpleSchema } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; import { findMatchingType, standardHttpRequestHeaders } from "./utils/content"; @@ -25,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; @@ -250,7 +252,7 @@ export function* compareReqHeader( break; } - if (process.env.QUIRKS) { + if (config.get("no-authorization-schema")) { isValid = requestHeaders.get("authorization") !== null; } @@ -317,7 +319,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("no-validate-complex-parameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); diff --git a/src/compare/requestPath.ts b/src/compare/requestPath.ts index ae9ea48..60e6f56 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 type { 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"; @@ -18,6 +19,7 @@ export function* compareReqPath( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path } = route.store; @@ -44,7 +46,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("no-validate-complex-parameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); diff --git a/src/compare/requestQuery.ts b/src/compare/requestQuery.ts index c30f257..c221ece 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 type { 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"; @@ -25,17 +26,18 @@ export function* compareReqQuery( route: Router.FindResult, interaction: Interaction, index: number, + config: Config, ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; - const searchParamsParsed = process.env.QUIRKS + const searchParamsParsed = config.get("legacy-parser") ? 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("legacy-parser") ? querystring.parse(route.searchParams as unknown as string) : qs.parse(route.searchParams, { allowDots: false, @@ -66,7 +68,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("no-validate-complex-parameters") && + isSimpleSchema(schema) && + value ? {} : minimumSchema(schema, oas), ); @@ -76,7 +80,7 @@ export function* compareReqQuery( ? value.split(ARRAY_SEPARATOR) : value; - if (process.env.QUIRKS && 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 6058a34..21e82f5 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,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)), + 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/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 094521c..f90812f 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,10 +30,11 @@ const SUPPORTED_METHODS = [ export function setupRouter( oas: OpenAPIV2.Document | OpenAPIV3.Document, + config: Config, ): Router.Instance { const router = Router({ - ignoreDuplicateSlashes: process.env.QUIRKS ? true : false, - ignoreTrailingSlash: process.env.QUIRKS ? true : false, + 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 68e1f14..5bafb4c 100644 --- a/src/transform/responseSchema.ts +++ b/src/transform/responseSchema.ts @@ -5,7 +5,10 @@ import { traverseWithDereferencing as traverse, } from "../utils/schema"; -export const transformResponseSchema = (schema: SchemaObject): SchemaObject => { +export const transformResponseSchema = ( + schema: SchemaObject, + noTransformNonNullableResponseSchema: boolean, +): SchemaObject => { // a provider must provide a superset of what the consumer asks for // additionalProperties expected in pact response are disallowed traverse(schema, (s) => { @@ -17,7 +20,7 @@ export const transformResponseSchema = (schema: SchemaObject): SchemaObject => { !s.anyOf && s.type && s.type === "object" && - (process.env.QUIRKS ? !s.nullable : true) + (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..0fdfdc1 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,55 @@ +const quirks = !!process.env.QUIRKS; + +export type ConfigKeys = + | "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"; + +export type Config = 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 + ["ignore-duplicate-slashes", quirks], + + // SMV ignores trailing slashes in OAS + ["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 + ["legacy-parser", quirks], + + // SMV only checks presence of Authorization header, it does not check its schema + ["no-authorization-schema", 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 + ["no-validate-complex-parameters", 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], +]); 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";