/** * @prettier */ export const upperFirst = (value) => { if (typeof value === "string") { return `${value.charAt(0).toUpperCase()}${value.slice(1)}` } return value } /** * Lookup can be `basic` or `extended`. By default the lookup is `extended`. */ export const makeGetTitle = (fnAccessor) => { const getTitle = (schema, { lookup = "extended" } = {}) => { const fn = fnAccessor() if (schema?.title != null) return fn.upperFirst(String(schema.title)) if (lookup === "extended") { if (schema?.$anchor != null) return fn.upperFirst(String(schema.$anchor)) if (schema?.$id != null) return String(schema.$id) } return "" } return getTitle } export const makeGetType = (fnAccessor) => { const getType = (schema, processedSchemas = new WeakSet()) => { const fn = fnAccessor() if (schema == null) { return "any" } if (fn.isBooleanJSONSchema(schema)) { return schema ? "any" : "never" } if (typeof schema !== "object") { return "any" } if (processedSchemas.has(schema)) { return "any" // detect a cycle } processedSchemas.add(schema) const { type, prefixItems, items } = schema const getArrayType = () => { if (Array.isArray(prefixItems)) { const prefixItemsTypes = prefixItems.map((itemSchema) => getType(itemSchema, processedSchemas) ) const itemsType = items ? getType(items, processedSchemas) : "any" return `array<[${prefixItemsTypes.join(", ")}], ${itemsType}>` } else if (items) { const itemsType = getType(items, processedSchemas) return `array<${itemsType}>` } else { return "array" } } const inferType = () => { if ( Object.hasOwn(schema, "prefixItems") || Object.hasOwn(schema, "items") || Object.hasOwn(schema, "contains") ) { return getArrayType() } else if ( Object.hasOwn(schema, "properties") || Object.hasOwn(schema, "additionalProperties") || Object.hasOwn(schema, "patternProperties") ) { return "object" } else if (["int32", "int64"].includes(schema.format)) { // OpenAPI 3.1.0 integer custom formats return "integer" } else if (["float", "double"].includes(schema.format)) { // OpenAPI 3.1.0 number custom formats return "number" } else if ( Object.hasOwn(schema, "minimum") || Object.hasOwn(schema, "maximum") || Object.hasOwn(schema, "exclusiveMinimum") || Object.hasOwn(schema, "exclusiveMaximum") || Object.hasOwn(schema, "multipleOf") ) { return "number | integer" } else if ( Object.hasOwn(schema, "pattern") || Object.hasOwn(schema, "format") || Object.hasOwn(schema, "minLength") || Object.hasOwn(schema, "maxLength") ) { return "string" } else if (typeof schema.const !== "undefined") { if (schema.const === null) { return "null" } else if (typeof schema.const === "boolean") { return "boolean" } else if (typeof schema.const === "number") { return Number.isInteger(schema.const) ? "integer" : "number" } else if (typeof schema.const === "string") { return "string" } else if (Array.isArray(schema.const)) { return "array" } else if (typeof schema.const === "object") { return "object" } } return null } if (schema.not && getType(schema.not) === "any") { return "never" } const typeString = Array.isArray(type) ? type.map((t) => (t === "array" ? getArrayType() : t)).join(" | ") : type === "array" ? getArrayType() : [ "null", "boolean", "object", "array", "number", "integer", "string", ].includes(type) ? type : inferType() const handleCombiningKeywords = (keyword, separator) => { if (Array.isArray(schema[keyword])) { const combinedTypes = schema[keyword].map((subSchema) => getType(subSchema, processedSchemas) ) return `(${combinedTypes.join(separator)})` } return null } const oneOfString = handleCombiningKeywords("oneOf", " | ") const anyOfString = handleCombiningKeywords("anyOf", " | ") const allOfString = handleCombiningKeywords("allOf", " & ") const combinedStrings = [typeString, oneOfString, anyOfString, allOfString] .filter(Boolean) .join(" | ") processedSchemas.delete(schema) return combinedStrings || "any" } return getType } export const isBooleanJSONSchema = (schema) => typeof schema === "boolean" export const hasKeyword = (schema, keyword) => schema !== null && typeof schema === "object" && Object.hasOwn(schema, keyword) export const makeIsExpandable = (fnAccessor) => { const isExpandable = (schema) => { const fn = fnAccessor() return ( schema?.$schema || schema?.$vocabulary || schema?.$id || schema?.$anchor || schema?.$dynamicAnchor || schema?.$ref || schema?.$dynamicRef || schema?.$defs || schema?.$comment || schema?.allOf || schema?.anyOf || schema?.oneOf || fn.hasKeyword(schema, "not") || fn.hasKeyword(schema, "if") || fn.hasKeyword(schema, "then") || fn.hasKeyword(schema, "else") || schema?.dependentSchemas || schema?.prefixItems || fn.hasKeyword(schema, "items") || fn.hasKeyword(schema, "contains") || schema?.properties || schema?.patternProperties || fn.hasKeyword(schema, "additionalProperties") || fn.hasKeyword(schema, "propertyNames") || fn.hasKeyword(schema, "unevaluatedItems") || fn.hasKeyword(schema, "unevaluatedProperties") || schema?.description || schema?.enum || fn.hasKeyword(schema, "const") || fn.hasKeyword(schema, "contentSchema") || fn.hasKeyword(schema, "default") || schema?.examples || fn.getExtensionKeywords(schema).length > 0 ) } return isExpandable } export const stringify = (value) => { if ( value === null || ["number", "bigint", "boolean"].includes(typeof value) ) { return String(value) } if (Array.isArray(value)) { return `[${value.map(stringify).join(", ")}]` } return JSON.stringify(value) } const stringifyConstraintMultipleOf = (schema) => { if (typeof schema?.multipleOf !== "number") return null if (schema.multipleOf <= 0) return null if (schema.multipleOf === 1) return null const { multipleOf } = schema if (Number.isInteger(multipleOf)) { return `multiple of ${multipleOf}` } const decimalPlaces = multipleOf.toString().split(".")[1].length const factor = 10 ** decimalPlaces const numerator = multipleOf * factor const denominator = factor return `multiple of ${numerator}/${denominator}` } const stringifyConstraintNumberRange = (schema) => { const minimum = schema?.minimum const maximum = schema?.maximum const exclusiveMinimum = schema?.exclusiveMinimum const exclusiveMaximum = schema?.exclusiveMaximum const hasMinimum = typeof minimum === "number" const hasMaximum = typeof maximum === "number" const hasExclusiveMinimum = typeof exclusiveMinimum === "number" const hasExclusiveMaximum = typeof exclusiveMaximum === "number" const isMinExclusive = hasExclusiveMinimum && (!hasMinimum || minimum < exclusiveMinimum) // prettier-ignore const isMaxExclusive = hasExclusiveMaximum && (!hasMaximum || maximum > exclusiveMaximum) // prettier-ignore if ( (hasMinimum || hasExclusiveMinimum) && (hasMaximum || hasExclusiveMaximum) ) { const minSymbol = isMinExclusive ? "(" : "[" const maxSymbol = isMaxExclusive ? ")" : "]" const minValue = isMinExclusive ? exclusiveMinimum : minimum const maxValue = isMaxExclusive ? exclusiveMaximum : maximum return `${minSymbol}${minValue}, ${maxValue}${maxSymbol}` } if (hasMinimum || hasExclusiveMinimum) { const minSymbol = isMinExclusive ? ">" : "≥" const minValue = isMinExclusive ? exclusiveMinimum : minimum return `${minSymbol} ${minValue}` } if (hasMaximum || hasExclusiveMaximum) { const maxSymbol = isMaxExclusive ? "<" : "≤" const maxValue = isMaxExclusive ? exclusiveMaximum : maximum return `${maxSymbol} ${maxValue}` } return null } const stringifyConstraintRange = (label, min, max) => { const hasMin = typeof min === "number" const hasMax = typeof max === "number" if (hasMin && hasMax) { if (min === max) { return `${min} ${label}` } else { return `[${min}, ${max}] ${label}` } } if (hasMin) { return `≥ ${min} ${label}` } if (hasMax) { return `≤ ${max} ${label}` } return null } export const stringifyConstraints = (schema) => { const constraints = [] // validation Keywords for Numeric Instances (number and integer) const multipleOf = stringifyConstraintMultipleOf(schema) if (multipleOf !== null) { constraints.push({ scope: "number", value: multipleOf }) } const numberRange = stringifyConstraintNumberRange(schema) if (numberRange !== null) { constraints.push({ scope: "number", value: numberRange }) } // vocabularies for Semantic Content With "format" if (schema?.format) { constraints.push({ scope: "string", value: schema.format }) } // validation Keywords for Strings const stringRange = stringifyConstraintRange( "characters", schema?.minLength, schema?.maxLength ) if (stringRange !== null) { constraints.push({ scope: "string", value: stringRange }) } if (schema?.pattern) { constraints.push({ scope: "string", value: `matches ${schema?.pattern}` }) } // vocabulary for the Contents of String-Encoded Data if (schema?.contentMediaType) { constraints.push({ scope: "string", value: `media type: ${schema.contentMediaType}`, }) } if (schema?.contentEncoding) { constraints.push({ scope: "string", value: `encoding: ${schema.contentEncoding}`, }) } // validation Keywords for Arrays const arrayRange = stringifyConstraintRange( schema?.uniqueItems ? "unique items" : "items", schema?.minItems, schema?.maxItems ) if (arrayRange !== null) { constraints.push({ scope: "array", value: arrayRange }) } if (schema?.uniqueItems && !arrayRange) { constraints.push({ scope: "array", value: "unique" }) } const containsRange = stringifyConstraintRange( "contained items", schema?.minContains, schema?.maxContains ) if (containsRange !== null) { constraints.push({ scope: "array", value: containsRange }) } // validation Keywords for Objects const objectRange = stringifyConstraintRange( "properties", schema?.minProperties, schema?.maxProperties ) if (objectRange !== null) { constraints.push({ scope: "object", value: objectRange }) } return constraints } export const getDependentRequired = (propertyName, schema) => { if (!schema?.dependentRequired) return [] return Array.from( Object.entries(schema.dependentRequired).reduce((acc, [prop, list]) => { if (!Array.isArray(list)) return acc if (!list.includes(propertyName)) return acc acc.add(prop) return acc }, new Set()) ) } export const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === null || Object.getPrototypeOf(value) === Object.prototype) export const isEmptyObject = (value) => isPlainObject(value) && Object.keys(value).length === 0 export const isEmptyArray = (value) => Array.isArray(value) && value.length === 0 export const difference = (value, comparisonValue) => { const comparisonSet = new Set(comparisonValue) return value.filter((item) => !comparisonSet.has(item)) } export const getSchemaKeywords = () => { return [ // core vocabulary "$schema", "$vocabulary", "$id", "$anchor", "$dynamicAnchor", "$dynamicRef", "$ref", "$defs", "$comment", // applicator vocabulary "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "dependentSchemas", "prefixItems", "items", "contains", "properties", "patternProperties", "additionalProperties", "propertyNames", // unevaluated Locations vocabulary "unevaluatedItems", "unevaluatedProperties", // validation vocabulary // validation Keywords for Any Instance Type "type", "enum", "const", // validation Keywords for Numeric Instances (number and integer) "multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", // validation Keywords for Strings "maxLength", "minLength", "pattern", // validation Keywords for Arrays "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", // validation Keywords for Objects "maxProperties", "minProperties", "required", "dependentRequired", // basic Meta-Data Annotations vocabulary "title", "description", "default", "deprecated", "readOnly", "writeOnly", "examples", // semantic Content With "format" vocabulary "format", // contents of String-Encoded Data vocabulary "contentEncoding", "contentMediaType", "contentSchema", ] } export const makeGetExtensionKeywords = (fnAccessor) => { const getExtensionKeywords = (schema) => { const fn = fnAccessor() const keywords = fn.getSchemaKeywords() return isPlainObject(schema) ? difference(Object.keys(schema), keywords) : [] } return getExtensionKeywords }