diff --git a/src/compare/index.ts b/src/compare/index.ts index 51debb6..747cbba 100644 --- a/src/compare/index.ts +++ b/src/compare/index.ts @@ -61,7 +61,12 @@ export class Comparator { for (const [index, interaction] of parsedPact.interactions.entries()) { const { method, path, query } = interaction.request; - const pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; + let pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; + + if (process.env.QUIRKS) { + pathWithLeadingSlash = pathWithLeadingSlash.replaceAll("%", "%25"); + } + // in pact, query is either a string or an object of only one level deep const stringQuery = typeof query === "string" @@ -80,7 +85,7 @@ export class Comparator { [pathWithLeadingSlash, stringQuery].filter(Boolean).join("?"), ); - if (!route) { + if (!route || pathWithLeadingSlash.includes("?")) { yield { code: "request.path-or-method.unknown", message: `Path or method not defined in spec file: ${method} ${path}`, diff --git a/src/compare/requestBody.ts b/src/compare/requestBody.ts index 004a263..abf1831 100644 --- a/src/compare/requestBody.ts +++ b/src/compare/requestBody.ts @@ -25,7 +25,11 @@ 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 }); + return qs.parse(body as string, { + allowDots: true, + comma: true, + depth: process.env.QUIRKS ? 0 : undefined, + }); } if (contentType.includes("multipart/form-data") && typeof body === "string") { @@ -51,11 +55,14 @@ const parseBody = (body: unknown, contentType: string) => { }; const canValidate = (contentType: string): boolean => { - return !!findMatchingType(contentType, [ - "application/json", - "application/x-www-form-urlencoded", - "multipart/form-data", - ]); + return !!findMatchingType( + contentType, + [ + "application/json", + "application/x-www-form-urlencoded", + process.env.QUIRKS ? "" : "multipart/form-data", + ].filter(Boolean), + ); }; const DEFAULT_CONTENT_TYPE = "application/json"; @@ -99,7 +106,14 @@ export function* compareReqBody( return; } - if (schema && canValidate(contentType) && isValidRequest(interaction)) { + if ( + schema && + canValidate(contentType) && + isValidRequest(interaction) && + (process.env.QUIRKS + ? !!findMatchingType("application/json", availableRequestContentTypes) + : true) + ) { const value = parseBody(body, requestContentType); const schemaId = `[root].paths.${path}.${method}.requestBody.content.${contentType}`; const validate = getValidateFunction(ajv, schemaId, () => @@ -129,7 +143,15 @@ export function* compareReqBody( } } - if (!!body && !schema && isValidRequest(interaction)) { + if ( + !!body && + !schema && + isValidRequest(interaction) && + (process.env.QUIRKS + ? !!findMatchingType("application/json", availableRequestContentTypes) || + availableRequestContentTypes.length === 0 + : true) + ) { yield { code: "request.body.unknown", message: "No matching schema found for request body", diff --git a/src/compare/requestHeader.ts b/src/compare/requestHeader.ts index e5676cb..f9f2b77 100644 --- a/src/compare/requestHeader.ts +++ b/src/compare/requestHeader.ts @@ -14,6 +14,7 @@ import { } from "../results/index"; import { minimumSchema } from "../transform/index"; import { isValidRequest } from "../utils/interaction"; +import { isQuirky } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; import { findMatchingType, standardHttpRequestHeaders } from "./utils/content"; @@ -176,6 +177,8 @@ export function* compareReqHeader( // security headers // ---------------- if (isValidRequest(interaction)) { + let isSecured = false; + const maybeResults: Result[] = []; for (const scheme of operation.security || []) { for (const schemeName of Object.keys(scheme)) { const scheme = securitySchemes[schemeName]; @@ -183,8 +186,10 @@ export function* compareReqHeader( case "apiKey": switch (scheme.in) { case "header": - if (!requestHeaders.has(scheme.name)) { - yield { + if (requestHeaders.has(scheme.name)) { + isSecured = true; + } else { + maybeResults.push({ code: "request.authorization.missing", message: "Request Authorization header is missing but is required by the spec file", @@ -200,21 +205,20 @@ export function* compareReqHeader( value: operation, }, type: "error", - }; + }); } requestHeaders.delete(scheme.name); break; case "cookie": - // FIXME: handle cookies - break; case "query": - // ignore } break; case "basic": { const basicAuth = requestHeaders.get("authorization") || ""; - if (!basicAuth.startsWith("Basic ")) { - yield { + if (basicAuth.startsWith("Basic ")) { + isSecured = true; + } else { + maybeResults.push({ code: "request.authorization.missing", message: "Request Authorization header is missing but is required by the spec file", @@ -230,7 +234,7 @@ export function* compareReqHeader( value: operation, }, type: "error", - }; + }); } break; } @@ -246,8 +250,14 @@ export function* compareReqHeader( break; } - if (!isValid) { - yield { + if (process.env.QUIRKS) { + isValid = requestHeaders.get("authorization") !== null; + } + + if (isValid) { + isSecured = true; + } else { + maybeResults.push({ code: "request.authorization.missing", message: "Request Authorization header is missing but is required by the spec file", @@ -263,7 +273,7 @@ export function* compareReqHeader( value: operation, }, type: "error", - }; + }); } break; } @@ -274,6 +284,10 @@ export function* compareReqHeader( } } } + + if (!isSecured) { + yield* maybeResults; + } } // specified headers @@ -300,10 +314,12 @@ export function* compareReqHeader( ? requestHeaders.get(dereferencedParameter.name) : parseValue(requestHeaders.get(dereferencedParameter.name)); - if (value && schema && isValidRequest(interaction)) { + if (value !== null && schema && isValidRequest(interaction)) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - minimumSchema(schema, oas), + process.env.QUIRKS && value && isQuirky(schema) + ? {} + : minimumSchema(schema, oas), ); if (!validate(value)) { for (const error of validate.errors!) { @@ -330,7 +346,7 @@ export function* compareReqHeader( } if ( - !value && + value === null && dereferencedParameter.required && isValidRequest(interaction) ) { diff --git a/src/compare/requestPath.ts b/src/compare/requestPath.ts index a612627..ae9ea48 100644 --- a/src/compare/requestPath.ts +++ b/src/compare/requestPath.ts @@ -8,6 +8,7 @@ import type { Result } from "../results/index"; import { baseMockDetails } from "../results/index"; import { minimumSchema } from "../transform/index"; import { dereferenceOas } from "../utils/schema"; +import { isQuirky } from "../utils/quirks"; import { getValidateFunction } from "../utils/validation"; import { cleanPathParameter } from "./utils/parameters"; import { parseValue } from "./utils/parse"; @@ -35,10 +36,17 @@ export function* compareReqPath( route.params[cleanPathParameter(dereferencedParameter.name)], ); + // ignore when OAS has unused parameter in the operation parameters + if (!(cleanPathParameter(dereferencedParameter.name) in route.params)) { + continue; + } + if (schema) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - minimumSchema(schema, oas), + process.env.QUIRKS && value && isQuirky(schema) + ? {} + : minimumSchema(schema, oas), ); let separator = ","; diff --git a/src/compare/requestQuery.ts b/src/compare/requestQuery.ts index 38ce0e2..c30f257 100644 --- a/src/compare/requestQuery.ts +++ b/src/compare/requestQuery.ts @@ -3,6 +3,7 @@ import type Ajv from "ajv/dist/2019"; import type Router from "find-my-way"; import { get } from "lodash-es"; import qs from "qs"; +import querystring from "node:querystring"; import type { Result } from "../results/index"; import type { Interaction } from "../documents/pact"; @@ -15,6 +16,7 @@ import { import { minimumSchema } from "../transform/index"; import { isValidRequest } from "../utils/interaction"; import { ARRAY_SEPARATOR } from "../utils/queryParams"; +import { isQuirky } from "../utils/quirks"; import { dereferenceOas, splitPath } from "../utils/schema"; import { getValidateFunction } from "../utils/validation"; @@ -26,14 +28,19 @@ export function* compareReqQuery( ): Iterable { const { method, oas, operation, path, securitySchemes } = route.store; - const searchParamsParsed = qs.parse(route.searchParams, { - allowDots: true, - comma: true, - }); - const searchParamsUnparsed = qs.parse(route.searchParams, { - allowDots: false, - comma: false, - }); + const searchParamsParsed = process.env.QUIRKS + ? querystring.parse(route.searchParams as unknown as string) + : qs.parse(route.searchParams, { + allowDots: true, + comma: true, + }); + + const searchParamsUnparsed = process.env.QUIRKS + ? querystring.parse(route.searchParams as unknown as string) + : qs.parse(route.searchParams, { + allowDots: false, + comma: false, + }); for (const [parameterIndex, parameter] of ( operation.parameters || [] @@ -59,14 +66,20 @@ export function* compareReqQuery( ) { const schemaId = `[root].paths.${path}.${method}.parameters[${parameterIndex}]`; const validate = getValidateFunction(ajv, schemaId, () => - minimumSchema(schema, oas), + process.env.QUIRKS && value && isQuirky(schema) + ? {} + : minimumSchema(schema, oas), ); - const convertedValue = + let convertedValue = schema.type === "array" && typeof value === "string" ? value.split(ARRAY_SEPARATOR) : value; + if (process.env.QUIRKS && value === "[object Object]") { + convertedValue = {}; + } + if (!validate(convertedValue)) { for (const error of validate.errors!) { yield { diff --git a/src/compare/responseHeader.ts b/src/compare/responseHeader.ts index 8ff607b..7173f90 100644 --- a/src/compare/responseHeader.ts +++ b/src/compare/responseHeader.ts @@ -98,7 +98,7 @@ export function* compareResHeader( const schema: SchemaObject = dereferenceOas(headers[headerName], oas).schema || headers[headerName]; const value = responseHeaders.get(headerName); - if (value && schema) { + if (value !== null && schema) { const schemaId = `[root].paths.${path}.${method}.responses.${interaction.response.status}.headers.${headerName}`; const validate = getValidateFunction(ajv, schemaId, () => minimumSchema(schema, oas), diff --git a/src/compare/setup.ts b/src/compare/setup.ts index 5494779..cfc0c22 100644 --- a/src/compare/setup.ts +++ b/src/compare/setup.ts @@ -31,6 +31,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, querystringParser: (s: string): string => s, // don't parse query in router }); for (const oasPath in oas.paths) { diff --git a/src/transform/minimumSchema.ts b/src/transform/minimumSchema.ts index e59170a..1def8c7 100644 --- a/src/transform/minimumSchema.ts +++ b/src/transform/minimumSchema.ts @@ -1,22 +1,7 @@ import type { OpenAPIV3 } from "openapi-types"; import { SchemaObject } from "ajv"; import { cloneDeep, get, set } from "lodash-es"; -import { splitPath, traverse } from "../utils/schema"; - -const handleNullableSchema = (s: SchemaObject) => { - if (s.$ref) { - delete s.nullable; - return; - } - - if (s.nullable && !s.type) { - s.type = "object"; - } - - if (s.nullable && !Array.isArray(s.type)) { - s.type = [s.type, "null"]; - } -}; +import { dereferenceOas, splitPath, traverse } from "../utils/schema"; // draft-06 onwards converts exclusiveMinimum and exclusiveMaximum to numbers const convertExclusiveMinMax = (s: SchemaObject) => { @@ -51,6 +36,23 @@ export const minimumSchema = ( } }; + const handleNullableSchema = (s: SchemaObject) => { + if (s.$ref) { + if (s.nullable && !s.type) { + s.type = dereferenceOas(s, oas).type; + } + return; + } + + if (s.nullable && !s.type) { + s.type = "object"; + } + + if (s.nullable && !Array.isArray(s.type)) { + s.type = [s.type, "null"]; + } + }; + const schema = cloneDeep(originalSchema); delete schema.description; delete schema.example; diff --git a/src/transform/responseSchema.ts b/src/transform/responseSchema.ts index 3807160..68e1f14 100644 --- a/src/transform/responseSchema.ts +++ b/src/transform/responseSchema.ts @@ -16,7 +16,8 @@ export const transformResponseSchema = (schema: SchemaObject): SchemaObject => { !s.allOf && !s.anyOf && s.type && - s.type === "object" + s.type === "object" && + (process.env.QUIRKS ? !s.nullable : true) ) { s.additionalProperties = false; } diff --git a/src/utils/quirks.ts b/src/utils/quirks.ts new file mode 100644 index 0000000..a74a7af --- /dev/null +++ b/src/utils/quirks.ts @@ -0,0 +1,4 @@ +import type { SchemaObject } from "ajv"; + +export const isQuirky = (s?: SchemaObject): boolean => + s === undefined || s.type === "array" || s.type === "object"; diff --git a/src/utils/schema.ts b/src/utils/schema.ts index 5160c30..488ef4c 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -62,6 +62,13 @@ const _traverseWithDereferencing = ( } const dereferencedSchema = dereferenceOas(schema, oas); + if (schema.nullable) { + dereferencedSchema.nullable = schema.nullable; + if (!dereferencedSchema.type) { + dereferencedSchema.type = "object"; + } + } + const traverseSubSchema = (item: SchemaObject) => _traverseWithDereferencing(item, oas, visited, visitor);