UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

239 lines • 12 kB
import { randomUUID } from './crypto.js'; import { getPathValue } from '../common/object.js'; import { capitalize } from '../common/string.js'; import { Ajv } from 'ajv'; import $RefParser from '@apidevtools/json-schema-ref-parser'; import cloneDeep from 'lodash/cloneDeep.js'; /** * Normalises a JSON Schema by standardising it's internal implementation. * * We prefer to not use $ref elements in our schemas, so we inline them; it's easier then to process errors. * * @param schema - The JSON schema (as a string) to normalise. * @returns The normalised JSON schema. */ export async function normaliseJsonSchema(schema) { // we want to modify the schema, removing any $ref elements and inlining with their source const parsedSchema = JSON.parse(schema); await $RefParser.dereference(parsedSchema, { resolve: { external: false } }); return parsedSchema; } function createAjvValidator(handleInvalidAdditionalProperties, schema) { // allowUnionTypes: Allows types like `type: ["string", "number"]` if (handleInvalidAdditionalProperties === 'strip') { // we need to let additional properties through, so that we can strip them later schema.additionalProperties = true; } const ajv = new Ajv({ allowUnionTypes: true, allErrors: true, verbose: true }); ajv.addKeyword('x-taplo'); const validator = ajv.compile(schema); return validator; } const validatorsCache = new Map(); /** * Given a subject object and a JSON schema contract, validate the subject against the contract. * * Errors are returned in a zod-like format, and processed to better handle unions. * * @param subject - The object to validate. * @param schema - The JSON schema to validate against. * @param handleInvalidAdditionalProperties - Whether to strip or fail on invalid additional properties. * @param identifier - The identifier of the schema being validated, used to cache the validator. * @returns The result of the validation. If the state is 'error', the errors will be in a zod-like format. */ export function jsonSchemaValidate(subject, schema, handleInvalidAdditionalProperties, identifier) { const subjectToModify = cloneDeep(subject); const cacheKey = identifier ?? randomUUID(); const validator = validatorsCache.get(cacheKey) ?? createAjvValidator(handleInvalidAdditionalProperties, schema); validatorsCache.set(cacheKey, validator); validator(subjectToModify); // Errors from the contract are post-processed to be more zod-like and to deal with unions better let jsonSchemaErrors; if (validator.errors && validator.errors.length > 0) { jsonSchemaErrors = convertJsonSchemaErrors(validator.errors, subjectToModify, schema); return { state: 'error', data: undefined, errors: jsonSchemaErrors, rawErrors: validator.errors, }; } if (handleInvalidAdditionalProperties === 'strip') { const topLevelSchemaProperties = Object.keys(schema.properties ?? {}); // strip any properties that are not in the top level schema from subjectToModify Object.keys(subjectToModify).forEach((key) => { if (!topLevelSchemaProperties.includes(key)) { // this isn't actually dynamic, because key came from Object.keys // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete subjectToModify[key]; } }); } return { state: 'ok', data: subjectToModify, errors: undefined, rawErrors: undefined, }; } /** * Converts errors from Ajv into a zod-like format. * * @param rawErrors - JSON Schema errors taken directly from Ajv. * @param subject - The object being validated. * @param schema - The JSON schema to validated against. * @returns The errors in a zod-like format. */ function convertJsonSchemaErrors(rawErrors, subject, schema) { // This reduces the number of errors by simplifying errors coming from different branches of a union const errors = simplifyUnionErrors(rawErrors, subject, schema); // Now we can remap errors to be more zod-like return errors.map((error) => { const path = error.instancePath.split('/').slice(1); if (error.params.missingProperty) { const missingProperty = error.params.missingProperty; return { path: [...path, missingProperty], message: 'Required' }; } if (error.params.type) { const expectedType = Array.isArray(error.params.type) ? error.params.type.join(', ') : error.params.type; const actualType = getPathValue(subject, path.join('.')); return { path, message: `Expected ${expectedType}, received ${typeof actualType}` }; } if (error.keyword === 'anyOf' || error.keyword === 'oneOf') { return { path, message: 'Invalid input' }; } if (error.params.allowedValues) { const allowedValues = error.params.allowedValues; const actualValue = getPathValue(subject, path.join('.')); return { path, message: `Invalid enum value. Expected ${allowedValues .map((value) => JSON.stringify(value)) .join(' | ')}, received ${JSON.stringify(actualValue)}`.replace(/"/g, "'"), }; } if (error.params.comparison) { const comparison = error.params.comparison; const limit = error.params.limit; const actualValue = getPathValue(subject, path.join('.')); let comparisonText = comparison; switch (comparison) { case '<=': comparisonText = 'less than or equal to'; break; case '<': comparisonText = 'less than'; break; case '>=': comparisonText = 'greater than or equal to'; break; case '>': comparisonText = 'greater than'; break; } return { path, message: capitalize(`${typeof actualValue} must be ${comparisonText} ${limit}`), }; } if (error.params.additionalProperty) { const supportedProperties = Object.keys(error.parentSchema?.properties ?? {}); // if a property was already set, remove it from here const alreadySetProperties = Object.keys(error.data ?? {}); const remainingProperties = supportedProperties.filter((property) => !alreadySetProperties.includes(property)); if (remainingProperties.length > 0) { return { path: [...path, error.params.additionalProperty], message: `No additional properties allowed. You can set ${remainingProperties.sort().join(', ')}.`, }; } } return { path, message: error.message, }; }); } /** * If a JSON schema specifies a union (anyOf, oneOf), and the subject doesn't meet any of the 'candidates' for the * union, then the error list received ends up being quite long: you get an error for the union property itself, and * then additional errors for each of the candidate branches. * * This function simplifies the error collection. By default it strips anything other than the union error itself. * * In some cases, it can be possible to identify what the intended branch of the union was -- for instance, maybe there * is a discriminating field like `type` that is unique between the branches. We inspect each candidate branch and if * one branch is less wrong than the others -- e.g. It had a valid `type`, but problems elsewhere -- then we keep the * errors for that branch. * * This is complex but in practise gives much more actionable errors. * * @param rawErrors - JSON Schema errors taken directly from Ajv. * @param subject - The object being validated. * @param schema - The JSON schema to validated against. * @returns A simplified list of errors. */ function simplifyUnionErrors(rawErrors, subject, schema) { let errors = rawErrors; const resolvedUnionErrors = new Set(); while (true) { const unionError = errors.filter((error) => (error.keyword === 'oneOf' || error.keyword === 'anyOf') && !resolvedUnionErrors.has(error.instancePath))[0]; if (unionError === undefined) { break; } // split errors into those sharing an instance path and those not const unrelatedErrors = errors.filter((error) => !error.instancePath.startsWith(unionError.instancePath)); // we start by assuming only the union error itself is useful, and not the errors from the candidate schemas let simplifiedUnionRelatedErrors = [unionError]; // get the schema list from where the union issue occured const dottedSchemaPath = unionError.schemaPath.replace('#/', '').replace(/\//g, '.'); const unionSchemas = getPathValue(schema, dottedSchemaPath); // and the slice of the subject that caused the issue const subjectValue = getPathValue(subject, unionError.instancePath.split('/').slice(1).join('.')); if (unionSchemas !== undefined && subjectValue !== undefined) { // we know that none of the union schemas are correct, but for each of them we can measure how wrong they are const correctValuesAndErrors = unionSchemas .map((candidateSchemaFromUnion) => { const candidateSchemaValidator = createAjvValidator('fail', candidateSchemaFromUnion); candidateSchemaValidator(subjectValue); let score = 0; if (candidateSchemaFromUnion.type === 'object') { // provided the schema is an object, we can measure how many properties are good const candidatesObjectProperties = Object.keys(candidateSchemaFromUnion.properties); score = candidatesObjectProperties.reduce((acc, propertyName) => { const subSchema = candidateSchemaFromUnion.properties[propertyName]; const subjectValueSlice = getPathValue(subjectValue, propertyName); const subValidator = createAjvValidator('fail', subSchema); if (subValidator(subjectValueSlice)) { return acc + 1; } return acc; }, score); } return [score, candidateSchemaValidator.errors]; }) .sort(([scoreA], [scoreB]) => scoreA - scoreB); if (correctValuesAndErrors.length >= 2) { const [bestScore, bestErrors] = correctValuesAndErrors[correctValuesAndErrors.length - 1]; const [penultimateScore] = correctValuesAndErrors[correctValuesAndErrors.length - 2]; if (bestScore !== penultimateScore) { // If there's a winner, show the errors for the best schema as they'll likely be actionable. // We got these through a nested schema, so we need to adjust the instance path simplifiedUnionRelatedErrors = [ unionError, ...bestErrors.map((bestError) => ({ ...bestError, instancePath: unionError.instancePath + bestError.instancePath, })), ]; } } } errors = [...unrelatedErrors, ...simplifiedUnionRelatedErrors]; resolvedUnionErrors.add(unionError.instancePath); } return errors; } //# sourceMappingURL=json-schema.js.map