Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/compare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}`,
Expand Down
38 changes: 30 additions & 8 deletions src/compare/requestBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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";
Expand Down Expand Up @@ -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, () =>
Expand Down Expand Up @@ -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",
Expand Down
44 changes: 30 additions & 14 deletions src/compare/requestHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -176,15 +177,19 @@ 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];
switch (scheme?.type) {
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",
Expand All @@ -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",
Expand All @@ -230,7 +234,7 @@ export function* compareReqHeader(
value: operation,
},
type: "error",
};
});
}
break;
}
Expand All @@ -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",
Expand All @@ -263,7 +273,7 @@ export function* compareReqHeader(
value: operation,
},
type: "error",
};
});
}
break;
}
Expand All @@ -274,6 +284,10 @@ export function* compareReqHeader(
}
}
}

if (!isSecured) {
yield* maybeResults;
}
}

// specified headers
Expand Down Expand Up @@ -303,7 +317,9 @@ export function* compareReqHeader(
if (value && 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!) {
Expand All @@ -330,7 +346,7 @@ export function* compareReqHeader(
}

if (
!value &&
value === null &&
dereferencedParameter.required &&
isValidRequest(interaction)
) {
Expand Down
10 changes: 9 additions & 1 deletion src/compare/requestPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = ",";
Expand Down
13 changes: 11 additions & 2 deletions src/compare/requestQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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";

Expand All @@ -29,10 +30,12 @@ export function* compareReqQuery(
const searchParamsParsed = qs.parse(route.searchParams, {
allowDots: true,
comma: true,
depth: process.env.QUIRKS ? 0 : undefined,
});
const searchParamsUnparsed = qs.parse(route.searchParams, {
allowDots: false,
comma: false,
depth: process.env.QUIRKS ? 0 : undefined,
});

for (const [parameterIndex, parameter] of (
Expand All @@ -59,14 +62,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 {
Expand Down
2 changes: 2 additions & 0 deletions src/compare/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export function setupRouter(
oas: OpenAPIV2.Document | OpenAPIV3.Document,
): Router.Instance<Router.HTTPVersion.V1> {
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) {
Expand Down
34 changes: 18 additions & 16 deletions src/transform/minimumSchema.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/transform/responseSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/utils/quirks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { SchemaObject } from "ajv";

export const isQuirky = (s?: SchemaObject): boolean =>
s === undefined || s.type === "array" || s.type === "object";
7 changes: 7 additions & 0 deletions src/utils/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down