UNPKG

@common-grants/cli

Version:
319 lines (318 loc) 12.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.checkSchemaCompatibility = checkSchemaCompatibility; const error_utils_1 = require("./error-utils"); const ERROR_TYPE = "ROUTE_CONFLICT"; // ############################################################ // Main function // ############################################################ /** * Checks whether `implSchema` is a valid subset of `baseSchema`. * * We treat `baseSchema` as more "generic". That means: * 1) If baseSchema.type is undefined, that is "any" => allow impl any type * 2) If baseSchema defines a schema for `additionalProperties`, any extra fields * in impl must conform to that schema * 3) Required fields in baseSchema must be present in impl * 4) If baseSchema has a typed property (e.g. 'string', 'object'), then * impl must match that type (unless base has no type). * 5) If baseSchema has an enum, impl must include all base enum values */ function checkSchemaCompatibility(location, baseSchema, implSchema, ctx) { let errors = new error_utils_1.ErrorCollection(); // 1) Skip if base has no type if (!baseSchema.type) { return errors; } // 2) Check type conflict errors = checkTypeConflict(location, baseSchema, implSchema, ctx, errors); // 3) If the schema is object-typed, compare properties if (baseSchema.type === "object") { checkObjectCompatibility(location, baseSchema, implSchema, ctx, errors); } // 4) If the schema is array-typed, compare items if (baseSchema.type === "array") { checkArrayCompatibility(location, baseSchema, implSchema, ctx, errors); } // 5) If the schema has an enum, verify that impl doesn't have extra values errors = checkEnumConflict(location, baseSchema, implSchema, ctx, errors); return errors; } // ############################################################ // Helper functions - Simple types // ############################################################ /** * Checks if two type values are compatible. * Handles both string types (OpenAPI 3.0) and array types (OpenAPI 3.1). * * Examples of compatible types: * - "object" and ["object"] are compatible * - ["string", "integer"] and ["integer", "string"] are compatible (same types, different order) * - ["integer"] and "integer" are compatible * * @param baseType - The base schema type * @param implType - The implementation schema type * @returns true if types are compatible, false otherwise */ function areTypesCompatible(baseType, implType) { if (!baseType || !implType) { return true; // If either is undefined, consider compatible } // Convert both to arrays for easier comparison const baseTypes = Array.isArray(baseType) ? baseType : [baseType]; const implTypes = Array.isArray(implType) ? implType : [implType]; // Check if they have the same length if (baseTypes.length !== implTypes.length) { return false; } // Sort both arrays and compare - types are compatible if they contain the same set const baseSorted = [...baseTypes].sort(); const implSorted = [...implTypes].sort(); return baseSorted.every((type, index) => type === implSorted[index]); } /** * Checks whether `implSchema` has a different type than `baseSchema`. * Handles both OpenAPI 3.0 string types and OpenAPI 3.1 array types. */ function checkTypeConflict(location, baseSchema, implSchema, ctx, errors) { if (baseSchema.type && implSchema.type) { if (!areTypesCompatible(baseSchema.type, implSchema.type)) { // For error reporting, show all types (as array or string) const baseTypeStr = Array.isArray(baseSchema.type) ? baseSchema.type.join(" | ") : baseSchema.type; const implTypeStr = Array.isArray(implSchema.type) ? implSchema.type.join(" | ") : implSchema.type; const error = typeConflictError(location, baseTypeStr, implTypeStr, ctx); errors.addError(error); } } return errors; } /** * Checks whether `implSchema` has any enum values that are not in `baseSchema`. */ function checkEnumConflict(location, baseSchema, implSchema, ctx, errors) { if (Array.isArray(baseSchema.enum) && Array.isArray(implSchema.enum)) { for (const implVal of implSchema.enum) { if (!baseSchema.enum.includes(implVal)) { const error = enumConflictError(location, implVal, ctx); errors.addError(error); } } } return errors; } // ############################################################ // Helper functions - Object types // ############################################################ /** * Checks whether object properties are compatible. * * 1. Get matching, missing, and extra properties * 2. Check that matching props are compatible * 3. Handle missing props - only flag as errors if they are required in the base schema * 4. Handle extra props - assume they aren't allowed by the base schema, unless the * base schema defines `additionalProperties` as true or provides a schema * 5. Check the compatibility of `additionalProperties` schemas between base and impl */ function checkObjectCompatibility(location, baseSchema, implSchema, ctx, errors) { // Step 1: Get matching, missing, and extra properties const baseProps = baseSchema.properties || {}; const implProps = implSchema.properties || {}; const propsByStatus = getPropsByStatus(baseProps, implProps); // Step 2: Check that matching props are compatible for (const propName of propsByStatus.matching) { const baseProp = baseProps[propName]; const implProp = implProps[propName]; const propLoc = `${location}.${propName}`; const propErrors = checkSchemaCompatibility(propLoc, baseProp, implProp, ctx); errors.addErrors(propErrors.getAllErrors()); } // Step 3: Handle missing props - only flag as errors if they are required const requiredProps = baseSchema.required || []; for (const propName of propsByStatus.missing) { // Only flag as missing if the property is actually required if (requiredProps.includes(propName)) { const propLoc = `${location}.${propName}`; const error = missingFieldError(propLoc, propName, { ...ctx, baseSchema }); errors.addError(error); } } // Step 4: Handle extra props //TODO: @widal001 (2025-06-06) - Make this more robust // WHEN additionalProperties or unevaluatedProperties is: // 1. undefined || true => allow any extra props // 2. false || not: {} => disallow any extra props // 3. schema => validate extra props against that schema const extraPropsAllowed = baseSchema.additionalProperties === true; const extraPropsSchema = baseSchema.additionalProperties; if (extraPropsAllowed) { // If additionalProperties are allowed, skip return errors; } else if (extraPropsSchema) { // If additionalProperties need to match a schema, // validate extra props against that schema (already flattened) for (const propName of propsByStatus.extra) { const implProp = implProps[propName]; const propLoc = `${location}.${propName}`; const propErrors = checkSchemaCompatibility(propLoc, extraPropsSchema, implProp, ctx); errors.addErrors(propErrors.getAllErrors()); } } else { // Otherwise, extra props are not allowed, and should be flagged as errors for (const propName of propsByStatus.extra) { const propLoc = `${location}.${propName}`; const error = extraFieldError(propLoc, propName, ctx); errors.addError(error); } } // Step 5: Check the compatibility of additionalProperties schemas const baseAdditionalProps = baseSchema.additionalProperties; const implAdditionalProps = implSchema.additionalProperties; if (baseAdditionalProps && implAdditionalProps) { // Both are schema objects - validate compatibility (already flattened) const additionalPropsErrors = checkSchemaCompatibility(`${location}[prop]`, baseAdditionalProps, implAdditionalProps, ctx); errors.addErrors(additionalPropsErrors.getAllErrors()); } return errors; } /** * Separates properties into matching, missing, and extra. * * - matching: properties that are present in both base and impl * - missing: properties that are present in base but not in impl * - extra: properties that are present in impl but not in base */ function getPropsByStatus(baseProps, implProps) { const matching = []; const missing = []; const extra = []; // Check all base properties for (const propName of Object.keys(baseProps)) { if (propName in implProps) { matching.push(propName); } else { missing.push(propName); } } // Check for extra properties in implementation for (const propName of Object.keys(implProps)) { if (!(propName in baseProps)) { extra.push(propName); } } return { matching, missing, extra }; } // ############################################################ // Helper functions - Array types // ############################################################ /** * Checks whether array items are compatible. * * This function validates that: * 1. The implementation schema has items defined * 2. The items in the implementation schema are compatible with the base schema's items */ function checkArrayCompatibility(location, baseSchema, implSchema, ctx, errors) { // If base schema has no items defined, any items are allowed if (!baseSchema.items) { return errors; } // If impl schema has no items defined, that's an error if (!implSchema.items) { const error = { type: ERROR_TYPE, level: "ERROR", subType: ctx.errorSubType, endpoint: ctx.endpoint, statusCode: ctx.statusCode, mimeType: ctx.mimeType, conflictType: "MISSING_FIELD", message: `Array schema must define items`, location, }; errors.addError(error); return errors; } // Check that the items are compatible const baseItems = baseSchema.items; const implItems = implSchema.items; const itemErrors = checkSchemaCompatibility(`${location}[0]`, baseItems, implItems, ctx); errors.addErrors(itemErrors.getAllErrors()); return errors; } // ############################################################ // Error creation functions // ############################################################ /** * Creates an error when types are mismatched. */ function typeConflictError(location, baseType, implType, ctx) { return { type: ERROR_TYPE, level: "ERROR", subType: ctx.errorSubType, endpoint: ctx.endpoint, statusCode: ctx.statusCode, mimeType: ctx.mimeType, conflictType: "TYPE_CONFLICT", message: `Type mismatch. Base is '${baseType}', impl is '${implType}'`, baseType, implType, location, }; } /** * Creates an error when implementation has extra enum values. */ function enumConflictError(location, extraValue, ctx) { return { type: ERROR_TYPE, level: "ERROR", subType: ctx.errorSubType, endpoint: ctx.endpoint, statusCode: ctx.statusCode, mimeType: ctx.mimeType, conflictType: "ENUM_CONFLICT", message: `Enum mismatch. Extra value '${extraValue}' in implementation not allowed by base spec`, location, }; } /** * Creates an error when implementation is missing a required field. */ function missingFieldError(location, fieldName, ctx) { const isRequired = ctx.baseSchema?.required?.includes(fieldName); return { type: ERROR_TYPE, level: "ERROR", subType: ctx.errorSubType, endpoint: ctx.endpoint, statusCode: ctx.statusCode, mimeType: ctx.mimeType, conflictType: "MISSING_FIELD", message: `Missing ${isRequired ? "required" : "optional"} property '${fieldName}'`, location, }; } /** * Creates an error when implementation has extra properties. */ function extraFieldError(location, fieldName, ctx) { return { type: ERROR_TYPE, level: "ERROR", subType: ctx.errorSubType, endpoint: ctx.endpoint, statusCode: ctx.statusCode, mimeType: ctx.mimeType, conflictType: "EXTRA_FIELD", message: `Implementation schema has extra property '${fieldName}' not defined in base schema (and 'additionalProperties' is not allowed)`, location, }; }