From c4e4d924ad8cc0ba2cc35f790a4bd9ed07612c1b Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Sun, 2 Aug 2026 08:33:05 +0200 Subject: [PATCH] Add support for converting JSON Schema documents to Draft-04, closes #6846 --- .changeset/add-json-schema-draft-04.md | 5 + packages/effect/package.json | 1 + packages/effect/src/JsonSchema.ts | 589 +++++---- packages/effect/test/JsonSchema.test.ts | 1547 ++++++++++++++++++----- pnpm-lock.yaml | 15 + 5 files changed, 1630 insertions(+), 527 deletions(-) create mode 100644 .changeset/add-json-schema-draft-04.md diff --git a/.changeset/add-json-schema-draft-04.md b/.changeset/add-json-schema-draft-04.md new file mode 100644 index 00000000000..440b16628c3 --- /dev/null +++ b/.changeset/add-json-schema-draft-04.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add support for converting JSON Schema documents to Draft-04, preserve literal `$ref` values, `$ref` sibling constraints, `not`, `readOnly`, and `writeOnly` in Draft-07 conversions, correct the Draft-07 meta-schema URI, and prevent OpenAPI component-key collisions during conversion. diff --git a/packages/effect/package.json b/packages/effect/package.json index a1610d253e3..b065903e9ec 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -105,6 +105,7 @@ "@types/ini": "^4.1.1", "@types/node": "^26.1.2", "ajv": "^8.20.0", + "ajv-draft-04": "^1.0.0", "ast-types": "^0.14.2", "immer": "^11.1.11", "tinybench": "^6.0.2", diff --git a/packages/effect/src/JsonSchema.ts b/packages/effect/src/JsonSchema.ts index 2b62d87ed5f..4ef1a68a093 100644 --- a/packages/effect/src/JsonSchema.ts +++ b/packages/effect/src/JsonSchema.ts @@ -2,9 +2,9 @@ * Helpers for normalizing and converting JSON Schema and OpenAPI schema * documents. Supported inputs include JSON Schema Draft-07, Draft 2020-12, * OpenAPI 3.0, and OpenAPI 3.1; conversions normalize through - * `Document<"draft-2020-12">` before emitting another dialect. The module also - * defines document types, meta-schema constants, OpenAPI component-key helpers, - * and `$ref` resolution utilities. + * `Document<"draft-2020-12">` before emitting another dialect, including + * JSON Schema Draft-04. The module also defines document types, meta-schema + * constants, OpenAPI component-key helpers, and `$ref` resolution utilities. * * @since 4.0.0 */ @@ -43,9 +43,10 @@ export interface JsonSchema { * * **Details** * - * Supported values are `"draft-07"` for JSON Schema Draft-07, - * `"draft-2020-12"` for JSON Schema Draft 2020-12 and the canonical internal - * form, `"openapi-3.1"` for OpenAPI 3.1, and `"openapi-3.0"` for OpenAPI 3.0. + * Supported values are `"draft-04"` for JSON Schema Draft-04, `"draft-07"` + * for JSON Schema Draft-07, `"draft-2020-12"` for JSON Schema Draft 2020-12 + * and the canonical internal form, `"openapi-3.1"` for OpenAPI 3.1, and + * `"openapi-3.0"` for OpenAPI 3.0. * * @see {@link Document} for a single root schema tagged with a dialect * @see {@link MultiDocument} for multiple root schemas tagged with a dialect @@ -53,7 +54,7 @@ export interface JsonSchema { * @category models * @since 4.0.0 */ -export type Dialect = "draft-07" | "draft-2020-12" | "openapi-3.1" | "openapi-3.0" +export type Dialect = "draft-04" | "draft-07" | "draft-2020-12" | "openapi-3.1" | "openapi-3.0" /** * The JSON Schema primitive type names. @@ -104,8 +105,8 @@ export interface Definitions extends Record {} * The `schema` field holds the root schema *without* the definitions * collection. Root definitions are stored separately in `definitions` and * referenced via `#/$defs/` for Draft-2020-12, `#/definitions/` - * for Draft-07, and `#/components/schemas/` for OpenAPI 3.1 and - * OpenAPI 3.0. + * for Draft-04 and Draft-07, and `#/components/schemas/` for OpenAPI 3.1 + * and OpenAPI 3.0. * * **Example** (Inspecting a parsed document) * @@ -159,6 +160,20 @@ export interface MultiDocument { readonly definitions: Definitions } +/** + * Represents the `$schema` meta-schema URI for JSON Schema Draft-04. + * + * **When to use** + * + * Use when constructing a Draft-04 JSON Schema document and you need a stable + * value for the root `$schema` field. + * + * @see {@link META_SCHEMA_URI_DRAFT_07} for the Draft-07 `$schema` URI + * @category constants + * @since 4.0.0 + */ +export const META_SCHEMA_URI_DRAFT_04 = "http://json-schema.org/draft-04/schema#" + /** * Represents the `$schema` meta-schema URI for JSON Schema Draft-07. * @@ -170,14 +185,15 @@ export interface MultiDocument { * **Details** * * The exported value is the literal string - * `http://json-schema.org/draft-07/schema`. + * `http://json-schema.org/draft-07/schema#`. * + * @see {@link META_SCHEMA_URI_DRAFT_04} for the Draft-04 `$schema` URI * @see {@link META_SCHEMA_URI_DRAFT_2020_12} for the Draft 2020-12 `$schema` URI * * @category constants * @since 4.0.0 */ -export const META_SCHEMA_URI_DRAFT_07 = "http://json-schema.org/draft-07/schema" +export const META_SCHEMA_URI_DRAFT_07 = "http://json-schema.org/draft-07/schema#" /** * Represents the `$schema` meta-schema URI for JSON Schema Draft 2020-12. @@ -203,6 +219,57 @@ const RE_DEFINITIONS = /^#\/definitions(?=\/|$)/ const RE_DEFS = /^#\/\$defs(?=\/|$)/ const RE_COMPONENTS_SCHEMAS = /^#\/components\/schemas(?=\/|$)/ +const DRAFT_04_COPY_KEYWORDS = new Set([ + "$ref", + "type", + "required", + "enum", + "title", + "description", + "default", + "format", + "pattern", + "minLength", + "maxLength", + "minItems", + "maxItems", + "minProperties", + "maxProperties", + "multipleOf", + "uniqueItems" +]) + +const DRAFT_07_COPY_KEYWORDS = new Set([ + ...DRAFT_04_COPY_KEYWORDS, + "const", + "examples", + "readOnly", + "writeOnly", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum" +]) + +const DRAFT_04_SINGLE_SUBSCHEMA_KEYWORDS = new Set(["not"]) +const DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS = new Set(["not", "additionalProperties", "propertyNames"]) + +const MAP_SUBSCHEMA_KEYWORDS = new Set(["properties", "patternProperties"]) +const ARRAY_SUBSCHEMA_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) +const DRAFT_2020_12_MAP_SUBSCHEMA_KEYWORDS = new Set(["$defs", ...MAP_SUBSCHEMA_KEYWORDS, "dependentSchemas"]) +const DRAFT_2020_12_ARRAY_SUBSCHEMA_KEYWORDS = new Set([...ARRAY_SUBSCHEMA_KEYWORDS, "prefixItems"]) +const DRAFT_2020_12_SINGLE_SUBSCHEMA_KEYWORDS = new Set([ + ...DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS, + "unevaluatedProperties", + "items", + "contains", + "unevaluatedItems", + "if", + "then", + "else", + "contentSchema" +]) + /** * Parses a raw Draft-07 JSON Schema into a `Document<"draft-2020-12">`. * @@ -259,7 +326,7 @@ export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> { } function walk(node: unknown, isRoot: boolean): unknown { - if (Array.isArray(node)) return node.map((v) => walk(v, false)) + if (Array.isArray(node)) return node.map(walkNested) if (!Predicate.isObject(node)) return node const out: Record = {} @@ -270,13 +337,19 @@ export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> { for (const k of Object.keys(node)) { const v = node[k] - switch (k) { - case "$ref": - out.$ref = typeof v === "string" ? v.replace(RE_DEFINITIONS, "#/$defs") : v - break + if (k === "$ref") { + out.$ref = typeof v === "string" ? v.replace(RE_DEFINITIONS, "#/$defs") : v + continue + } + if (DRAFT_07_COPY_KEYWORDS.has(k)) { + out[k] = v + continue + } + if (rewriteSubschemaKeyword(out, k, v, walkNested, DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS)) continue + switch (k) { case "definitions": { - const mapped = walk_object(v, walk) + const mapped = mapObject(v, walkNested) if (isRoot) { definitions = mapped as Definitions | undefined } else { @@ -292,51 +365,6 @@ export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> { additionalItems = v break - case "properties": - case "patternProperties": { - const mapped = walk_object(v, walk) - out[k] = mapped ?? v - break - } - - case "additionalProperties": - case "propertyNames": - out[k] = walk(v, false) - break - - case "allOf": - case "anyOf": - case "oneOf": - out[k] = Array.isArray(v) ? v.map((x) => walk(x, false)) : v - break - - case "type": - case "required": - case "enum": - case "const": - case "title": - case "description": - case "default": - case "examples": - case "format": - case "readOnly": - case "writeOnly": - case "pattern": - case "minimum": - case "maximum": - case "exclusiveMinimum": - case "exclusiveMaximum": - case "minLength": - case "maxLength": - case "minItems": - case "maxItems": - case "minProperties": - case "maxProperties": - case "multipleOf": - case "uniqueItems": - out[k] = v - break - default: break } @@ -345,15 +373,19 @@ export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> { // Draft-07 tuples -> 2020-12 tuples if (prefixItems !== undefined) { if (Array.isArray(prefixItems)) { - out.prefixItems = prefixItems.map((x) => walk(x, false)) - if (additionalItems !== undefined) out.items = walk(additionalItems, false) + out.prefixItems = prefixItems.map(walkNested) + if (additionalItems !== undefined) out.items = walkNested(additionalItems) } else { - out.items = walk(prefixItems, false) + out.items = walkNested(prefixItems) } } return out } + + function walkNested(node: unknown): unknown { + return walk(node, false) + } } /** @@ -433,7 +465,10 @@ export function fromSchemaDraft2020_12(js: JsonSchema): Document<"draft-2020-12" * @since 4.0.0 */ export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> { - const schema = rewrite_refs(js, (ref) => ref.replace(RE_COMPONENTS_SCHEMAS, "#/$defs")) as JsonSchema + const schema = transformSchema( + js, + (schema) => rewriteSchemaRef(schema, (ref) => ref.replace(RE_COMPONENTS_SCHEMAS, "#/$defs")) + ) as JsonSchema return fromSchemaDraft2020_12(schema) } @@ -472,7 +507,7 @@ export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> * @since 4.0.0 */ export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-12"> { - const normalized = normalize_OpenApi3_0_to_Draft07(schema) + const normalized = normalizeOpenApi3_0ToDraft07(schema) return fromSchemaDraft07(normalized as JsonSchema) } @@ -513,6 +548,7 @@ export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-1 * ``` * * @see {@link fromSchemaDraft07} + * @see {@link toDocumentDraft04} for converting to Draft-04 * @see {@link toMultiDocumentOpenApi3_1} * @category encoding * @since 4.0.0 @@ -525,75 +561,165 @@ export function toDocumentDraft07(document: Document<"draft-2020-12">): Document } } -function toSchemaDraft07(schema: JsonSchema): JsonSchema { - return rewrite(schema) - - function rewrite(node: unknown): JsonSchema { - return walk(rewrite_refs(node, (ref) => ref.replace(RE_DEFS, "#/definitions")), true) as JsonSchema +/** + * Converts a `Document<"draft-2020-12">` to a `Document<"draft-04">`. + * + * **When to use** + * + * Use when you need to output a canonical JSON Schema document in Draft-04 + * format. + * + * **Details** + * + * This rewrites `#/$defs/...` refs to `#/definitions/...`, converts tuple + * syntax, lowers `const` to `enum`, converts numeric exclusive bounds to the + * Draft-04 boolean form, and converts both the root schema and all definitions. + * + * **Gotchas** + * + * Unsupported Draft-2020-12 and Draft-07 keywords are dropped. For example, + * `propertyNames` has no general Draft-04 equivalent and is omitted. + * + * **Example** (Converting exclusive bounds) + * + * ```ts import.meta.vitest + * import { JsonSchema } from "effect" + * + * const doc = JsonSchema.fromSchemaDraft2020_12({ + * type: "number", + * exclusiveMinimum: 0 + * }) + * + * JsonSchema.toDocumentDraft04(doc).schema // => { type: "number", minimum: 0, exclusiveMinimum: true } + * ``` + * + * @see {@link toDocumentDraft07} for converting to Draft-07 + * @category encoding + * @since 4.0.0 + */ +export function toDocumentDraft04(document: Document<"draft-2020-12">): Document<"draft-04"> { + const draft07 = toDocumentDraft07(document) + return { + dialect: "draft-04", + schema: toSchemaDraft04(draft07.schema), + definitions: Rec.map(draft07.definitions, toSchemaDraft04) } +} + +function toSchemaDraft04(schema: JsonSchema): JsonSchema { + return walk(schema) as JsonSchema - function walk(node: unknown, _isRoot: boolean): unknown { - if (Array.isArray(node)) return node.map((v) => walk(v, false)) + function walk(node: unknown): unknown { + if (node === true) return {} + if (node === false) return { not: {} } + if (Array.isArray(node)) return node.map(walk) if (!Predicate.isObject(node)) return node const src = node as Record const out: Record = {} - let prefixItems: unknown = undefined - let items: unknown = undefined + let hasConst = false + let constValue: unknown = undefined for (const k of Object.keys(src)) { const v = src[k] + if (DRAFT_04_COPY_KEYWORDS.has(k)) { + out[k] = v + continue + } + if (rewriteSubschemaKeyword(out, k, v, walk, DRAFT_04_SINGLE_SUBSCHEMA_KEYWORDS)) continue + switch (k) { - // We already rewrote $ref via rewrite_refs, so just copy it through. - case "$ref": - case "type": - case "required": - case "enum": case "const": - case "title": - case "description": - case "default": - case "examples": - case "format": - case "pattern": + hasConst = true + constValue = v + break + case "minimum": case "maximum": case "exclusiveMinimum": case "exclusiveMaximum": - case "minLength": - case "maxLength": - case "minItems": - case "maxItems": - case "minProperties": - case "maxProperties": - case "multipleOf": - case "uniqueItems": - out[k] = v break - // Schema maps - case "properties": - case "patternProperties": { - const mapped = walk_object(v, walk) - out[k] = mapped ?? v + case "additionalProperties": + case "additionalItems": + out[k] = typeof v === "boolean" ? v : walk(v) break - } - // Single subschemas - case "additionalProperties": - case "propertyNames": - out[k] = walk(v, false) + case "items": + out.items = Array.isArray(v) ? v.map(walk) : walk(v) break - // Schema arrays - case "allOf": - case "anyOf": - case "oneOf": - out[k] = Array.isArray(v) ? v.map((x) => walk(x, false)) : v + default: break + } + } + + convertExclusiveBound(src, out, "minimum", "exclusiveMinimum", (bound, exclusive) => bound > exclusive) + convertExclusiveBound(src, out, "maximum", "exclusiveMaximum", (bound, exclusive) => bound < exclusive) + + if (hasConst) { + const constSchema = { enum: [constValue] } + if (Object.hasOwn(src, "enum")) { + out.allOf = Array.isArray(out.allOf) ? [...out.allOf, constSchema] : [constSchema] + } else { + out.enum = constSchema.enum + } + } + + return out + } +} + +function convertExclusiveBound( + src: Record, + out: Record, + boundKey: "minimum" | "maximum", + exclusiveKey: "exclusiveMinimum" | "exclusiveMaximum", + isBoundStricter: (bound: number, exclusive: number) => boolean +): void { + const bound = src[boundKey] + const exclusive = src[exclusiveKey] + + if (typeof exclusive === "number") { + if (typeof bound === "number" && isBoundStricter(bound, exclusive)) { + out[boundKey] = bound + } else { + out[boundKey] = exclusive + out[exclusiveKey] = true + } + } else if (bound !== undefined) { + out[boundKey] = bound + } +} + +function toSchemaDraft07(schema: JsonSchema): JsonSchema { + return transformSchema(schema, (src) => { + rewriteSchemaRef(src, (ref) => ref.replace(RE_DEFS, "#/definitions")) + const out: Record = {} + + let prefixItems: unknown = undefined + let items: unknown = undefined + + for (const k of Object.keys(src)) { + const v = src[k] + if (k === "required" && Array.isArray(v) && v.length === 0) continue + if (DRAFT_07_COPY_KEYWORDS.has(k)) { + out[k] = v + continue + } + if ( + MAP_SUBSCHEMA_KEYWORDS.has(k) || + ARRAY_SUBSCHEMA_KEYWORDS.has(k) || + DRAFT_07_SINGLE_SUBSCHEMA_KEYWORDS.has(k) + ) { + out[k] = v + continue + } + + switch (k) { // Tuple handling (2020-12 form) case "prefixItems": prefixItems = v @@ -611,19 +737,25 @@ function toSchemaDraft07(schema: JsonSchema): JsonSchema { // 2020-12 tuples -> Draft-07 tuples if (prefixItems !== undefined) { if (Array.isArray(prefixItems)) { - out.items = prefixItems.map((x) => walk(x, false)) - if (items !== undefined) out.additionalItems = walk(items, false) + out.items = prefixItems + if (items !== undefined) out.additionalItems = items } else { // Non-standard, but keep a reasonable behavior - out.items = walk(prefixItems, false) + out.items = prefixItems } } else if (items !== undefined) { // Regular items schema stays as items - out.items = walk(items, false) + out.items = items + } + + const $ref = out.$ref + if (typeof $ref === "string" && Object.keys(out).length > 1) { + delete out.$ref + out.allOf = [{ $ref }, ...(Array.isArray(out.allOf) ? out.allOf : [])] } return out - } + }) as JsonSchema } /** @@ -637,11 +769,17 @@ function toSchemaDraft07(schema: JsonSchema): JsonSchema { * * **Details** * - * This rewrites `#/$defs/...` refs to `#/components/schemas/...`, sanitizes - * definition keys to match the OpenAPI component key pattern - * (`^[a-zA-Z0-9.\-_]+$`) by replacing invalid characters with `_`, updates all - * `$ref` pointers to use the sanitized keys, and converts all schemas and - * definitions in the multi-document. + * This rewrites local `#/$defs/...` refs to `#/components/schemas/...` and + * sanitizes definition keys to match the OpenAPI component key pattern + * (`^[a-zA-Z0-9.\-_]+$`) by replacing invalid characters with `_`. Valid keys + * are preserved. When sanitized keys collide, the converter appends the first + * available `_1`, `_2`, and subsequent suffix, with allocation independent of + * definition insertion order. All local refs are updated to use the allocated + * keys, including refs to paths within a definition. + * + * **Gotchas** + * + * External refs and local refs outside `#/$defs` are left unchanged. * * **Example** (Converting to OpenAPI 3.1) * @@ -667,26 +805,43 @@ function toSchemaDraft07(schema: JsonSchema): JsonSchema { * @since 4.0.0 */ export function toMultiDocumentOpenApi3_1(multiDocument: MultiDocument<"draft-2020-12">): MultiDocument<"openapi-3.1"> { + const definitionKeys = Object.keys(multiDocument.definitions) const keyMap = new Map() - for (const key of Object.keys(multiDocument.definitions)) { - const sanitized = sanitizeOpenApiComponentsSchemasKey(key) - if (sanitized !== key) { - keyMap.set(key, sanitized) - } + const usedKeys = new Set(definitionKeys.filter((key) => VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(key))) + const invalidKeys = definitionKeys + .filter((key) => !VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(key)) + .sort() + .map((key) => [key, sanitizeOpenApiComponentsSchemasKey(key)] as const) + for (const [key, base] of invalidKeys) { + if (usedKeys.has(base)) continue + usedKeys.add(base) + keyMap.set(key, base) + } + for (const [key, base] of invalidKeys) { + if (keyMap.has(key)) continue + let candidate: string + let suffix = 0 + do candidate = `${base}_${++suffix}` + while (usedKeys.has(candidate)) + usedKeys.add(candidate) + keyMap.set(key, candidate) } function rewrite(schema: JsonSchema): JsonSchema { - return rewrite_refs(schema, ($ref) => { - const tokens = $ref.split("/") - if (tokens.length > 0) { - const identifier = unescapeToken(tokens[tokens.length - 1]) - const sanitized = keyMap.get(identifier) - if (sanitized !== undefined) { - $ref = tokens.slice(0, -1).join("/") + "/" + sanitized - } - } - return $ref.replace(RE_DEFS, "#/components/schemas") - }) as JsonSchema + return transformSchema( + schema, + (schema) => + rewriteSchemaRef(schema, ($ref) => { + if (!$ref.startsWith("#/$defs/")) return $ref + + const path = $ref.slice("#/$defs/".length) + const separatorIndex = path.indexOf("/") + const token = separatorIndex === -1 ? path : path.slice(0, separatorIndex) + const rest = separatorIndex === -1 ? "" : path.slice(separatorIndex) + const key = keyMap.get(unescapeToken(token)) ?? token + return `#/components/schemas/${key}${rest}` + }) + ) as JsonSchema } return { @@ -709,64 +864,74 @@ export const VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP = /^[a-zA-Z0-9.\-_]+$/ * @internal */ export function sanitizeOpenApiComponentsSchemasKey(s: string): string { - if (s.length === 0) return "_" - if (VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(s)) return s - - const out: Array = [] - - for (const ch of s) { - const code = ch.codePointAt(0) - if ( - code !== undefined && - ((code >= 48 && code <= 57) || // 0-9 - (code >= 65 && code <= 90) || // A-Z - (code >= 97 && code <= 122) || // a-z - code === 46 || // . - code === 45 || // - - code === 95) // _ - ) { - out.push(ch) - } else { - out.push("_") - } - } - - return out.join("") + return s.length === 0 ? "_" : s.replace(/[^a-zA-Z0-9._-]/gu, "_") } -function rewrite_refs(node: unknown, f: ($ref: string) => string): unknown { - if (Array.isArray(node)) return node.map((v) => rewrite_refs(v, f)) - if (!Predicate.isObject(node)) return node +function transformSchema( + node: unknown, + transform: (schema: Record) => Record +): unknown { + return walk(node) - const out: Record = {} + function walk(node: unknown): unknown { + if (!Predicate.isObject(node) || Array.isArray(node)) return node - for (const k of Object.keys(node)) { - const v = node[k] - - if (k === "$ref") { - InternalRecord.assignProperty(out, k, typeof v === "string" ? f(v) : v) - } else if (Array.isArray(v) || Predicate.isObject(v)) { - InternalRecord.assignProperty(out, k, rewrite_refs(v, f)) - } else { - InternalRecord.assignProperty(out, k, v) + const out: Record = {} + for (const key of Object.keys(node)) { + const value = node[key] + let transformed = value + if (DRAFT_2020_12_MAP_SUBSCHEMA_KEYWORDS.has(key)) { + transformed = Array.isArray(value) ? value : mapObject(value, walk) ?? value + } else if (DRAFT_2020_12_ARRAY_SUBSCHEMA_KEYWORDS.has(key)) { + transformed = Array.isArray(value) ? value.map(walk) : value + } else if (DRAFT_2020_12_SINGLE_SUBSCHEMA_KEYWORDS.has(key)) { + transformed = walk(value) + } + InternalRecord.assignProperty(out, key, transformed) } + return transform(out) } +} - return out +function rewriteSchemaRef( + schema: Record, + rewrite: ($ref: string) => string +): Record { + if (typeof schema.$ref === "string") { + InternalRecord.assignProperty(schema, "$ref", rewrite(schema.$ref)) + } + return schema } -function walk_object( - value: unknown, - walk: (node: unknown, isRoot: boolean) => unknown -): Record | undefined { +function mapObject(value: unknown, f: (node: unknown) => unknown): Record | undefined { if (!Predicate.isObject(value)) return undefined const out: Record = {} - for (const k of Object.keys(value)) InternalRecord.assignProperty(out, k, walk(value[k], false)) + for (const k of Object.keys(value)) InternalRecord.assignProperty(out, k, f(value[k])) return out } -function normalize_OpenApi3_0_to_Draft07(node: unknown): unknown { - if (Array.isArray(node)) return node.map(normalize_OpenApi3_0_to_Draft07) +function rewriteSubschemaKeyword( + out: Record, + key: string, + value: unknown, + rewrite: (node: unknown) => unknown, + singleKeywords: ReadonlySet +): boolean { + if (MAP_SUBSCHEMA_KEYWORDS.has(key)) { + out[key] = mapObject(value, rewrite) ?? value + return true + } + if (ARRAY_SUBSCHEMA_KEYWORDS.has(key)) { + out[key] = Array.isArray(value) ? value.map(rewrite) : value + return true + } + if (!singleKeywords.has(key)) return false + out[key] = rewrite(value) + return true +} + +function normalizeOpenApi3_0ToDraft07(node: unknown): unknown { + if (Array.isArray(node)) return node.map(normalizeOpenApi3_0ToDraft07) if (!Predicate.isObject(node)) return node const src = node as Record @@ -781,61 +946,61 @@ function normalize_OpenApi3_0_to_Draft07(node: unknown): unknown { out.examples = [v] } } else if (Array.isArray(v) || Predicate.isObject(v)) { - InternalRecord.assignProperty(out, k, normalize_OpenApi3_0_to_Draft07(v)) + InternalRecord.assignProperty(out, k, normalizeOpenApi3_0ToDraft07(v)) } else { InternalRecord.assignProperty(out, k, v) } } // Draft-04-style numeric exclusivity booleans - out = adjust_exclusivity(out) + out = adjustExclusivity(out) // OpenAPI 3.0 nullable if (out.nullable === true) { - out = apply_nullable(out) + out = applyNullable(out) } delete out.nullable return out } -function adjust_exclusivity(node: Record): Record { - let out = node +function adjustExclusivity(node: Record): Record { + return adjustExclusiveBound( + adjustExclusiveBound(node, "minimum", "exclusiveMinimum"), + "maximum", + "exclusiveMaximum" + ) +} - if (typeof out.exclusiveMinimum === "boolean") { - if (out.exclusiveMinimum === true && typeof out.minimum === "number") { - out = { ...out, exclusiveMinimum: out.minimum } - delete out.minimum - } else { - out = { ...out } - delete out.exclusiveMinimum - } +function adjustExclusiveBound( + node: Record, + boundKey: "minimum" | "maximum", + exclusiveKey: "exclusiveMinimum" | "exclusiveMaximum" +): Record { + const exclusive = node[exclusiveKey] + if (typeof exclusive !== "boolean") return node + + const out = { ...node } + if (exclusive && typeof node[boundKey] === "number") { + out[exclusiveKey] = node[boundKey] + delete out[boundKey] + } else { + delete out[exclusiveKey] } - - if (typeof out.exclusiveMaximum === "boolean") { - if (out.exclusiveMaximum === true && typeof out.maximum === "number") { - out = { ...out, exclusiveMaximum: out.maximum } - delete out.maximum - } else { - out = { ...out } - delete out.exclusiveMaximum - } - } - return out } -function apply_nullable(node: Record): Record { +function applyNullable(node: Record): Record { // enum widening if (Array.isArray(node.enum)) { - return widen_type({ + return widenType({ ...node, enum: node.enum.includes(null) ? node.enum : [...node.enum, null] }) } // type widening - if (node.type !== undefined) return widen_type(node) + if (node.type !== undefined) return widenType(node) // const === null if (node.const === null) return node @@ -844,7 +1009,7 @@ function apply_nullable(node: Record): Record return { anyOf: [node, { type: "null" }] } } -function widen_type(node: Record): Record { +function widenType(node: Record): Record { const t = node.type if (typeof t === "string") return t === "null" ? node : { ...node, type: [t, "null"] } if (Array.isArray(t)) return t.includes("null") ? node : { ...node, type: [...t, "null"] } @@ -889,10 +1054,8 @@ function widen_type(node: Record): Record { */ export function resolve$ref($ref: string, definitions: Definitions): JsonSchema | undefined { const tokens = $ref.split("/") - if (tokens.length > 0) { - const identifier = unescapeToken(tokens[tokens.length - 1]) - if (Object.hasOwn(definitions, identifier)) return definitions[identifier] - } + const identifier = unescapeToken(tokens[tokens.length - 1]) + if (Object.hasOwn(definitions, identifier)) return definitions[identifier] } /** diff --git a/packages/effect/test/JsonSchema.test.ts b/packages/effect/test/JsonSchema.test.ts index 9c152988f08..1473b063150 100644 --- a/packages/effect/test/JsonSchema.test.ts +++ b/packages/effect/test/JsonSchema.test.ts @@ -1,9 +1,56 @@ -import { describe, it } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { deepStrictEqual } from "@effect/vitest/utils" import * as JsonSchema from "effect/JsonSchema" +import * as Schema from "effect/Schema" + +// oxlint-disable-next-line @typescript-eslint/no-require-imports +const AjvDraft07 = require("ajv") +// oxlint-disable-next-line @typescript-eslint/no-require-imports +const AjvDraft04 = require("ajv-draft-04") + +const ajvDraft07 = new AjvDraft07.default({ allErrors: true, strict: false }) +const ajvDraft04 = new AjvDraft04.default({ allErrors: true, strict: false }) + +function makeSchema(document: JsonSchema.Document<"draft-04" | "draft-07">): JsonSchema.JsonSchema { + return { + $schema: document.dialect === "draft-04" + ? JsonSchema.META_SCHEMA_URI_DRAFT_04 + : JsonSchema.META_SCHEMA_URI_DRAFT_07, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { definitions: document.definitions } : {}) + } +} + +function assertDoesNotMutate(input: A, f: (input: A) => unknown): void { + const before = structuredClone(input) + f(input) + deepStrictEqual(input, before) +} describe("JsonSchema", () => { + describe("meta-schema URIs", () => { + it("exports the URI for every supported dialect", () => { + deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_04, "http://json-schema.org/draft-04/schema#") + deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_07, "http://json-schema.org/draft-07/schema#") + deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, "https://json-schema.org/draft/2020-12/schema") + }) + }) + describe("resolve$ref", () => { + it("resolves a definition", () => { + const definition: JsonSchema.JsonSchema = { type: "string" } + deepStrictEqual(JsonSchema.resolve$ref("#/$defs/A", { A: definition }), definition) + }) + + it("unescapes the referenced JSON Pointer token", () => { + const definition: JsonSchema.JsonSchema = { type: "string" } + deepStrictEqual(JsonSchema.resolve$ref("#/$defs/A~1B~0C", { "A/B~C": definition }), definition) + }) + + it("returns undefined for a missing definition", () => { + deepStrictEqual(JsonSchema.resolve$ref("#/$defs/Missing", {}), undefined) + }) + it("ignores inherited definitions", () => { deepStrictEqual(JsonSchema.resolve$ref("#/$defs/constructor", {}), undefined) }) @@ -17,7 +64,53 @@ describe("JsonSchema", () => { }) }) - describe("sanitizeOpenApiComponentsKey", () => { + describe("resolveTopLevel$ref", () => { + it("resolves a top-level ref without mutating the document definitions", () => { + const definition: JsonSchema.JsonSchema = { type: "string" } + const document: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/A" }, + definitions: { A: definition } + } + const result = JsonSchema.resolveTopLevel$ref(document) + + assert.notStrictEqual(result, document) + assert.strictEqual(result.definitions, document.definitions) + deepStrictEqual(result, { + dialect: "draft-2020-12", + schema: definition, + definitions: document.definitions + }) + }) + + it("returns the same document when the top-level ref cannot be resolved", () => { + const document: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/Missing" }, + definitions: {} + } + + assert.strictEqual(JsonSchema.resolveTopLevel$ref(document), document) + }) + + it("returns the same document when there is no string top-level ref", () => { + const withoutRef: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { type: "string" }, + definitions: {} + } + const withNonStringRef: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { $ref: 1 }, + definitions: {} + } + + assert.strictEqual(JsonSchema.resolveTopLevel$ref(withoutRef), withoutRef) + assert.strictEqual(JsonSchema.resolveTopLevel$ref(withNonStringRef), withNonStringRef) + }) + }) + + describe("sanitizeOpenApiComponentsSchemasKey", () => { const sanitizeOpenApiComponentsKey = JsonSchema.sanitizeOpenApiComponentsSchemasKey it("returns '_' for empty input", () => { @@ -85,6 +178,16 @@ describe("JsonSchema", () => { }) describe("fromSchemaDraft07", () => { + it("preserves not", () => { + const input: JsonSchema.JsonSchema = { not: { type: "string" } } + const result = JsonSchema.fromSchemaDraft07(input) + deepStrictEqual(result, { + dialect: "draft-2020-12", + schema: { not: { type: "string" } }, + definitions: {} + }) + }) + it("normalizes a schema without definitions to the canonical document shape", () => { const input: JsonSchema.JsonSchema = { type: "string" @@ -178,7 +281,7 @@ describe("JsonSchema", () => { }) }) - it("should preserve annotations", () => { + it("preserves annotations", () => { const input: JsonSchema.JsonSchema = { type: "string", title: "My String", @@ -206,7 +309,7 @@ describe("JsonSchema", () => { }) }) - it("should handle string constraints", () => { + it("preserves string constraints", () => { const input: JsonSchema.JsonSchema = { type: "string", pattern: "^[a-z]+$", @@ -226,7 +329,7 @@ describe("JsonSchema", () => { }) }) - it("should handle number constraints", () => { + it("preserves number constraints", () => { const input: JsonSchema.JsonSchema = { type: "number", minimum: 0, @@ -250,7 +353,7 @@ describe("JsonSchema", () => { }) }) - it("should handle array constraints", () => { + it("preserves array constraints", () => { const input: JsonSchema.JsonSchema = { type: "array", items: { type: "string" }, @@ -272,7 +375,7 @@ describe("JsonSchema", () => { }) }) - it("should handle object constraints", () => { + it("preserves object constraints", () => { const input: JsonSchema.JsonSchema = { type: "object", properties: { @@ -310,7 +413,7 @@ describe("JsonSchema", () => { }) }) - it("should handle enum, const, allOf, anyOf, oneOf", () => { + it("preserves enum, const, allOf, anyOf, and oneOf", () => { const input: JsonSchema.JsonSchema = { enum: ["a", "b", "c"], const: "constant", @@ -398,6 +501,55 @@ describe("JsonSchema", () => { definitions: {} }) }) + + it("preserves malformed values for recognized keywords", () => { + const input: JsonSchema.JsonSchema = { + $ref: 1, + definitions: { + Invalid: [1, { not: false }] as unknown as JsonSchema.JsonSchema + }, + properties: { + nested: { + definitions: "invalid", + not: [false, { type: "string" }] + } + }, + patternProperties: "invalid", + allOf: "invalid" + } + + deepStrictEqual(JsonSchema.fromSchemaDraft07(input), { + dialect: "draft-2020-12", + schema: { + $ref: 1, + properties: { + nested: { + definitions: "invalid", + not: [false, { type: "string" }] + } + }, + patternProperties: "invalid", + allOf: "invalid" + }, + definitions: { + Invalid: [1, { not: false }] as unknown as JsonSchema.JsonSchema + } + }) + }) + + it("ignores additionalItems when items is not a tuple", () => { + deepStrictEqual( + JsonSchema.fromSchemaDraft07({ + type: "array", + items: { type: "string" }, + additionalItems: false + }).schema, + { + type: "array", + items: { type: "string" } + } + ) + }) }) describe("fromSchemaDraft2020_12", () => { @@ -506,6 +658,20 @@ describe("JsonSchema", () => { }) describe("fromSchemaOpenApi3_1", () => { + it("preserves non-string refs and malformed schema maps", () => { + const input: JsonSchema.JsonSchema = { + $ref: null, + enum: [null], + properties: [{ $ref: "#/components/schemas/Literal" }] + } + + deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), { + dialect: "draft-2020-12", + schema: input, + definitions: {} + }) + }) + it("rewrites OpenAPI component schema refs to $defs refs", () => { const input: JsonSchema.JsonSchema = { type: "object", @@ -528,6 +694,80 @@ describe("JsonSchema", () => { }) }) + it("rewrites refs only in schema positions", () => { + const literal = { $ref: "#/components/schemas/Literal" } + const input: JsonSchema.JsonSchema = { + properties: { value: { $ref: "#/components/schemas/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + } + + deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), { + dialect: "draft-2020-12", + schema: { + properties: { value: { $ref: "#/$defs/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + }, + definitions: {} + }) + }) + + it("rewrites refs throughout Draft 2020-12 subschemas", () => { + const input: JsonSchema.JsonSchema = { + $defs: { Alias: { $ref: "#/components/schemas/Value" } }, + properties: { value: { $ref: "#/components/schemas/Value" } }, + patternProperties: { pattern: { $ref: "#/components/schemas/Value" } }, + dependentSchemas: { dependency: { $ref: "#/components/schemas/Value" } }, + allOf: [{ $ref: "#/components/schemas/Value" }], + anyOf: [{ $ref: "#/components/schemas/Value" }], + oneOf: [{ $ref: "#/components/schemas/Value" }], + prefixItems: [{ $ref: "#/components/schemas/Value" }], + additionalProperties: { $ref: "#/components/schemas/Value" }, + unevaluatedProperties: { $ref: "#/components/schemas/Value" }, + propertyNames: { $ref: "#/components/schemas/Value" }, + items: { $ref: "#/components/schemas/Value" }, + contains: { $ref: "#/components/schemas/Value" }, + unevaluatedItems: { $ref: "#/components/schemas/Value" }, + not: { $ref: "#/components/schemas/Value" }, + if: { $ref: "#/components/schemas/Value" }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { $ref: "#/components/schemas/Value" }, + else: { $ref: "#/components/schemas/Value" }, + contentSchema: { $ref: "#/components/schemas/Value" } + } + + deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), { + dialect: "draft-2020-12", + schema: { + properties: { value: { $ref: "#/$defs/Value" } }, + patternProperties: { pattern: { $ref: "#/$defs/Value" } }, + dependentSchemas: { dependency: { $ref: "#/$defs/Value" } }, + allOf: [{ $ref: "#/$defs/Value" }], + anyOf: [{ $ref: "#/$defs/Value" }], + oneOf: [{ $ref: "#/$defs/Value" }], + prefixItems: [{ $ref: "#/$defs/Value" }], + additionalProperties: { $ref: "#/$defs/Value" }, + unevaluatedProperties: { $ref: "#/$defs/Value" }, + propertyNames: { $ref: "#/$defs/Value" }, + items: { $ref: "#/$defs/Value" }, + contains: { $ref: "#/$defs/Value" }, + unevaluatedItems: { $ref: "#/$defs/Value" }, + not: { $ref: "#/$defs/Value" }, + if: { $ref: "#/$defs/Value" }, + // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword + then: { $ref: "#/$defs/Value" }, + else: { $ref: "#/$defs/Value" }, + contentSchema: { $ref: "#/$defs/Value" } + }, + definitions: { Alias: { $ref: "#/$defs/Value" } } + }) + }) + it("extracts root $defs after rewriting OpenAPI component refs", () => { const input: JsonSchema.JsonSchema = { type: "object", @@ -696,330 +936,253 @@ describe("JsonSchema", () => { }) }) - describe("nullable", () => { - it("expands nullable schema without other keywords to anyOf", () => { - assertFromSchemaOpenApi3_0( - { nullable: true }, - { - schema: { - anyOf: [ - {}, - { type: "null" } - ] - } - } - ) - }) - - it("adds null to a string type", () => { - const input: JsonSchema.JsonSchema = { + it("prefers examples over a singular example", () => { + assertFromSchemaOpenApi3_0( + { type: "string", - nullable: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", + example: "ignored", + examples: ["kept"] + }, + { schema: { - type: ["string", "null"] - }, - definitions: {} - }) - }) - - it("adds null to a type array", () => { - const input: JsonSchema.JsonSchema = { - type: ["string", "number"], - nullable: true + type: "string", + examples: ["kept"] + } } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: ["string", "number", "null"] - }, - definitions: {} - }) - }) + ) + }) - it("keeps a non-null const while adding null to the type", () => { - const input: JsonSchema.JsonSchema = { - type: "string", - const: "a", - nullable: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: ["string", "null"], - const: "a" - }, - definitions: {} - }) - }) + type OpenApi3_0Case = { + readonly name: string + readonly input: JsonSchema.JsonSchema + readonly expected: JsonSchema.JsonSchema + } - it("wraps a non-null const in anyOf when type is absent", () => { - const input: JsonSchema.JsonSchema = { - const: "a", - nullable: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - anyOf: [ - { const: "a" }, - { type: "null" } - ] - }, - definitions: {} - }) - }) + function testOpenApi3_0Cases(cases: ReadonlyArray): void { + for (const { expected, input, name } of cases) { + it(name, () => assertFromSchemaOpenApi3_0(input, { schema: expected })) + } + } - it("keeps a null const without adding anyOf", () => { - const input: JsonSchema.JsonSchema = { - const: null, - nullable: true + describe("nullable", () => { + testOpenApi3_0Cases([ + { + name: "expands a schema without other keywords to anyOf", + input: { nullable: true }, + expected: { anyOf: [{}, { type: "null" }] } + }, + { + name: "adds null to a string type", + input: { type: "string", nullable: true }, + expected: { type: ["string", "null"] } + }, + { + name: "adds null to a type array", + input: { type: ["string", "number"], nullable: true }, + expected: { type: ["string", "number", "null"] } + }, + { + name: "does not widen the null type", + input: { type: "null", nullable: true }, + expected: { type: "null" } + }, + { + name: "does not duplicate null in a type array", + input: { type: ["string", "null"], nullable: true }, + expected: { type: ["string", "null"] } + }, + { + name: "leaves a malformed type unchanged", + input: { type: 1, nullable: true }, + expected: { type: 1 } + }, + { + name: "keeps a non-null const while adding null to the type", + input: { type: "string", const: "a", nullable: true }, + expected: { type: ["string", "null"], const: "a" } + }, + { + name: "wraps a non-null const in anyOf when type is absent", + input: { const: "a", nullable: true }, + expected: { anyOf: [{ const: "a" }, { type: "null" }] } + }, + { + name: "keeps a null const without adding anyOf", + input: { const: null, nullable: true }, + expected: { const: null } + }, + { + name: "adds null to enum values and type", + input: { type: "string", enum: ["a", "b"], nullable: true }, + expected: { type: ["string", "null"], enum: ["a", "b", null] } + }, + { + name: "does not duplicate null in enum values", + input: { type: "string", enum: ["a", "b", null], nullable: true }, + expected: { type: ["string", "null"], enum: ["a", "b", null] } + }, + { + name: "preserves enum when null is its only value", + input: { type: "string", enum: [null], nullable: true }, + expected: { type: ["string", "null"], enum: [null] } + }, + { + name: "uses anyOf for schemas without type", + input: { nullable: true, minimum: 0 }, + expected: { anyOf: [{ minimum: 0 }, { type: "null" }] } + }, + { + name: "drops nullable false", + input: { type: "string", nullable: false }, + expected: { type: "string" } + }, + { + name: "normalizes nullable inside allOf independently from the parent", + input: { type: "string", allOf: [{ nullable: true }] }, + expected: { + type: "string", + allOf: [{ anyOf: [{}, { type: "null" }] }] + } + }, + { + name: "normalizes nullable on both a parent and its allOf member", + input: { type: "string", nullable: true, allOf: [{ nullable: true }] }, + expected: { + type: ["string", "null"], + allOf: [{ anyOf: [{}, { type: "null" }] }] + } } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - const: null - }, - definitions: {} - }) - }) + ]) + }) - it("adds null to enum values and type", () => { - const input: JsonSchema.JsonSchema = { - type: "string", - enum: ["a", "b"], - nullable: true + describe("exclusivity", () => { + testOpenApi3_0Cases([ + { + name: "turns exclusiveMinimum true into the minimum value", + input: { type: "number", minimum: 10, exclusiveMinimum: true }, + expected: { type: "number", exclusiveMinimum: 10 } + }, + { + name: "turns exclusiveMaximum true into the maximum value", + input: { type: "number", maximum: 100, exclusiveMaximum: true }, + expected: { type: "number", exclusiveMaximum: 100 } + }, + { + name: "drops exclusiveMinimum false", + input: { type: "number", minimum: 10, exclusiveMinimum: false }, + expected: { type: "number", minimum: 10 } + }, + { + name: "drops exclusiveMaximum false", + input: { type: "number", maximum: 100, exclusiveMaximum: false }, + expected: { type: "number", maximum: 100 } + }, + { + name: "drops exclusiveMinimum true when minimum is absent", + input: { type: "number", exclusiveMinimum: true }, + expected: { type: "number" } + }, + { + name: "drops exclusiveMaximum true when maximum is absent", + input: { type: "number", exclusiveMaximum: true }, + expected: { type: "number" } } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: ["string", "null"], - enum: ["a", "b", null] - }, - definitions: {} - }) + ]) + }) + }) + + describe("toDocumentDraft07", () => { + it("preserves Schema.Never", () => { + const result = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(Schema.Never)) + deepStrictEqual(result, { + dialect: "draft-07", + schema: { not: {} }, + definitions: {} }) + }) - it("does not duplicate null in enum values", () => { - const input: JsonSchema.JsonSchema = { - type: "string", - enum: ["a", "b", null], - nullable: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: ["string", "null"], - enum: ["a", "b", null] - }, - definitions: {} - }) + it("omits an empty required array", () => { + const document = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { type: "object", required: [] }, + definitions: {} }) - it("preserves enum when null is the only enum value", () => { - const input: JsonSchema.JsonSchema = { - type: "string", - enum: [null], - nullable: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { + deepStrictEqual(document.schema, { type: "object" }) + }) + + it("preserves every supported annotation and validation keyword", () => { + const schema: JsonSchema.JsonSchema = { + type: "object", + required: ["value"], + enum: ["a", "b"], + const: "a", + title: "title", + description: "description", + default: "a", + examples: ["a"], + format: "custom", + readOnly: true, + writeOnly: true, + pattern: "^a$", + minimum: 0, + maximum: 10, + exclusiveMinimum: 0, + exclusiveMaximum: 10, + minLength: 1, + maxLength: 10, + minItems: 1, + maxItems: 10, + minProperties: 1, + maxProperties: 10, + multipleOf: 2, + uniqueItems: true, + properties: { value: { type: "string" } }, + patternProperties: { "^x-": { type: "string" } }, + not: { type: "null" }, + additionalProperties: false, + propertyNames: { minLength: 1 }, + allOf: [{ type: "object" }], + anyOf: [{ type: "string" }], + oneOf: [{ type: "number" }], + items: { type: "string" } + } + + deepStrictEqual( + JsonSchema.toDocumentDraft07({ dialect: "draft-2020-12", - schema: { - type: ["string", "null"], - enum: [null] - }, + schema, definitions: {} - }) - }) + }).schema, + schema + ) + }) - it("uses anyOf for nullable schemas without type", () => { - const input: JsonSchema.JsonSchema = { - nullable: true, - minimum: 0 - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - anyOf: [ - { - minimum: 0 - }, - { - type: "null" - } - ] + it("preserves validation semantics", () => { + const document = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + mode: { const: "on" }, + value: { type: "number", exclusiveMinimum: 0 }, + tuple: { type: "array", prefixItems: [{ type: "string" }], items: false } }, - definitions: {} - }) + required: ["mode", "value", "tuple"], + additionalProperties: false + }, + definitions: {} }) + const schema = makeSchema(document) + deepStrictEqual(ajvDraft07.validateSchema(schema), true) - it("drops nullable: false", () => { - const input: JsonSchema.JsonSchema = { - type: "string", - nullable: false - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "string" - }, - definitions: {} - }) - }) - - it("normalizes nullable inside allOf independently from the parent", () => { - assertFromSchemaOpenApi3_0( - { - type: "string", - allOf: [{ nullable: true }] - }, - { - schema: { - type: "string", - allOf: [{ - anyOf: [ - {}, - { type: "null" } - ] - }] - } - } - ) - assertFromSchemaOpenApi3_0( - { - type: "string", - nullable: true, - allOf: [{ nullable: true }] - }, - { - schema: { - type: ["string", "null"], - allOf: [{ - anyOf: [ - {}, - { type: "null" } - ] - }] - } - } - ) - }) - }) - - describe("exclusivity", () => { - it("turns exclusiveMinimum: true into exclusiveMinimum: minimum", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - minimum: 10, - exclusiveMinimum: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number", - exclusiveMinimum: 10 - }, - definitions: {} - }) - }) - - it("turns exclusiveMaximum: true into exclusiveMaximum: maximum", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - maximum: 100, - exclusiveMaximum: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number", - exclusiveMaximum: 100 - }, - definitions: {} - }) - }) - - it("drops exclusiveMinimum: false", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - minimum: 10, - exclusiveMinimum: false - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number", - minimum: 10 - }, - definitions: {} - }) - }) - - it("drops exclusiveMaximum: false", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - maximum: 100, - exclusiveMaximum: false - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number", - maximum: 100 - }, - definitions: {} - }) - }) - - it("drops exclusiveMinimum: true when minimum is absent", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - exclusiveMinimum: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number" - }, - definitions: {} - }) - }) - - it("drops exclusiveMaximum: true when maximum is absent", () => { - const input: JsonSchema.JsonSchema = { - type: "number", - exclusiveMaximum: true - } - const result = JsonSchema.fromSchemaOpenApi3_0(input) - deepStrictEqual(result, { - dialect: "draft-2020-12", - schema: { - type: "number" - }, - definitions: {} - }) - }) + const validate = ajvDraft07.compile(schema) + deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a"] }), true) + deepStrictEqual(validate({ mode: "off", value: 1, tuple: ["a"] }), false) + deepStrictEqual(validate({ mode: "on", value: 0, tuple: ["a"] }), false) + deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a", "b"] }), false) }) - }) - describe("toDocumentDraft07", () => { it("rewrites $defs refs to Draft-07 definitions refs", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1053,7 +1216,7 @@ describe("JsonSchema", () => { definitions: { A: { type: "string", - $ref: "#/definitions/B" + allOf: [{ $ref: "#/definitions/B" }] }, B: { type: "number" @@ -1062,6 +1225,60 @@ describe("JsonSchema", () => { }) }) + it("rewrites refs only in schema positions", () => { + const literal = { $ref: "#/$defs/Literal" } + const result = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + properties: { value: { $ref: "#/$defs/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + }, + definitions: { Value: { type: "string" } } + }) + + deepStrictEqual(result, { + dialect: "draft-07", + schema: { + properties: { value: { $ref: "#/definitions/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + }, + definitions: { Value: { type: "string" } } + }) + }) + + it("preserves constraints next to refs and existing allOf", () => { + const document = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: { + $ref: "#/$defs/S", + minLength: 3, + allOf: [{ maxLength: 5 }] + }, + definitions: { S: { type: "string" } } + }) + + deepStrictEqual(document.schema, { + minLength: 3, + allOf: [ + { $ref: "#/definitions/S" }, + { maxLength: 5 } + ] + }) + + const schema = makeSchema(document) + deepStrictEqual(ajvDraft07.validateSchema(schema), true) + const validate = ajvDraft07.compile(schema) + deepStrictEqual(validate("abc"), true) + deepStrictEqual(validate("a"), false) + deepStrictEqual(validate("abcdef"), false) + }) + it("converts prefixItems to a Draft-07 items tuple", () => { const input: JsonSchema.Document<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1128,10 +1345,434 @@ describe("JsonSchema", () => { definitions: {} }) }) + + it("preserves malformed values for recognized keywords", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + $ref: 1, + properties: "invalid", + not: [false, { type: "string" }], + allOf: "invalid", + prefixItems: "invalid" + }, + definitions: {} + } + + deepStrictEqual(JsonSchema.toDocumentDraft07(input), { + dialect: "draft-07", + schema: { + $ref: 1, + properties: "invalid", + not: [false, { type: "string" }], + allOf: "invalid", + items: "invalid" + }, + definitions: {} + }) + }) + }) + + describe("toDocumentDraft04", () => { + it("rewrites $defs refs to Draft-04 definitions refs", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + value: { $ref: "#/$defs/Value" } + } + }, + definitions: { + Value: { type: "string" } + } + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result, { + dialect: "draft-04", + schema: { + type: "object", + properties: { + value: { $ref: "#/definitions/Value" } + } + }, + definitions: { + Value: { type: "string" } + } + }) + }) + + it("preserves every supported Draft-04 keyword and drops newer annotations", () => { + const input: JsonSchema.JsonSchema = { + type: "object", + required: ["value"], + enum: ["a", "b"], + title: "title", + description: "description", + default: "a", + format: "custom", + pattern: "^a$", + minimum: 0, + maximum: 10, + minLength: 1, + maxLength: 10, + minItems: 1, + maxItems: 10, + minProperties: 1, + maxProperties: 10, + multipleOf: 2, + uniqueItems: true, + properties: { value: { type: "string" } }, + patternProperties: { "^x-": { type: "string" } }, + not: { type: "null" }, + additionalProperties: false, + allOf: [{ type: "object" }], + anyOf: [{ type: "string" }], + oneOf: [{ type: "number" }], + items: { type: "string" }, + examples: ["a"], + readOnly: true, + writeOnly: true, + propertyNames: { minLength: 1 } + } + const expected: JsonSchema.JsonSchema = { + type: "object", + required: ["value"], + enum: ["a", "b"], + title: "title", + description: "description", + default: "a", + format: "custom", + pattern: "^a$", + minimum: 0, + maximum: 10, + minLength: 1, + maxLength: 10, + minItems: 1, + maxItems: 10, + minProperties: 1, + maxProperties: 10, + multipleOf: 2, + uniqueItems: true, + properties: { value: { type: "string" } }, + patternProperties: { "^x-": { type: "string" } }, + not: { type: "null" }, + additionalProperties: false, + allOf: [{ type: "object" }], + anyOf: [{ type: "string" }], + oneOf: [{ type: "number" }], + items: { type: "string" } + } + + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: input, + definitions: {} + }).schema, + expected + ) + }) + + it("omits an empty required array", () => { + const document = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { type: "object", required: [] }, + definitions: {} + }) + + deepStrictEqual(document.schema, { type: "object" }) + deepStrictEqual(ajvDraft04.validateSchema(makeSchema(document)), true) + }) + + it("converts const to enum", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + enum: ["a", "b"], + const: "b", + allOf: [{ type: "string" }] + }, + definitions: {} + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result.schema, { + enum: ["a", "b"], + allOf: [{ type: "string" }, { enum: ["b"] }] + }) + + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { const: "a" }, + definitions: {} + }).schema, + { enum: ["a"] } + ) + + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { enum: ["a", "b"], const: "b" }, + definitions: {} + }).schema, + { enum: ["a", "b"], allOf: [{ enum: ["b"] }] } + ) + }) + + it("preserves refs in literal values", () => { + const literal = { $ref: "#/$defs/Literal" } + + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { const: literal }, + definitions: {} + }).schema, + { enum: [literal] } + ) + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { enum: [literal], default: literal }, + definitions: {} + }).schema, + { enum: [literal], default: literal } + ) + }) + + it("converts numeric exclusive bounds to Draft-04 boolean exclusivity", () => { + const cases: ReadonlyArray = [ + [{ minimum: 1 }, { minimum: 1 }], + [{ exclusiveMinimum: 1 }, { minimum: 1, exclusiveMinimum: true }], + [{ minimum: 2, exclusiveMinimum: 1 }, { minimum: 2 }], + [{ minimum: 1, exclusiveMinimum: 1 }, { minimum: 1, exclusiveMinimum: true }], + [{ minimum: 1, exclusiveMinimum: 2 }, { minimum: 2, exclusiveMinimum: true }], + [{ maximum: 2 }, { maximum: 2 }], + [{ exclusiveMaximum: 2 }, { maximum: 2, exclusiveMaximum: true }], + [{ maximum: 1, exclusiveMaximum: 2 }, { maximum: 1 }], + [{ maximum: 2, exclusiveMaximum: 2 }, { maximum: 2, exclusiveMaximum: true }], + [{ maximum: 2, exclusiveMaximum: 1 }, { maximum: 1, exclusiveMaximum: true }] + ] + for (const [schema, expected] of cases) { + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema, + definitions: {} + }).schema, + expected + ) + } + }) + + it("converts boolean schemas only in schema positions", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + allowed: true, + denied: false, + nested: { not: false } + }, + additionalProperties: false, + allOf: [true], + anyOf: [false] + }, + definitions: {} + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result.schema, { + type: "object", + properties: { + allowed: {}, + denied: { not: {} }, + nested: { not: { not: {} } } + }, + additionalProperties: false, + allOf: [{}], + anyOf: [{ not: {} }] + }) + }) + + it("converts tuple members while preserving additionalItems booleans", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + type: "array", + prefixItems: [true, false], + items: false + }, + definitions: {} + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result.schema, { + type: "array", + items: [{}, { not: {} }], + additionalItems: false + }) + + deepStrictEqual( + JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { type: "array", items: false }, + definitions: {} + }).schema, + { type: "array", items: { not: {} } } + ) + }) + + it("converts schemas in additionalProperties and additionalItems", () => { + const result = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + type: "object", + additionalProperties: { const: "value" }, + properties: { + tuple: { + type: "array", + prefixItems: [{ type: "string" }], + items: { const: "rest" } + } + } + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + type: "object", + additionalProperties: { enum: ["value"] }, + properties: { + tuple: { + type: "array", + items: [{ type: "string" }], + additionalItems: { enum: ["rest"] } + } + } + }) + }) + + it("preserves malformed values for recognized keywords", () => { + const result = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + properties: "invalid", + not: [1], + allOf: "invalid" + }, + definitions: {} + }) + + deepStrictEqual(result.schema, { + properties: "invalid", + not: [1], + allOf: "invalid" + }) + }) + + it("preserves allOf, not, and null", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + anyOf: [ + { type: "null" }, + { allOf: [{ not: { type: "string" } }] } + ] + }, + definitions: {} + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result.schema, input.schema) + }) + + it("drops keywords unavailable in Draft-04", () => { + const input: JsonSchema.Document<"draft-2020-12"> = { + dialect: "draft-2020-12", + schema: { + type: "object", + propertyNames: { pattern: "^[a-z]+$" }, + examples: [{ value: 1 }] + }, + definitions: {} + } + const result = JsonSchema.toDocumentDraft04(input) + deepStrictEqual(result.schema, { type: "object" }) + }) + + it("preserves constraints next to refs", () => { + const document = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { $ref: "#/$defs/S", minLength: 3 }, + definitions: { S: { type: "string" } } + }) + + deepStrictEqual(document.schema, { + allOf: [{ $ref: "#/definitions/S" }], + minLength: 3 + }) + + const schema = makeSchema(document) + deepStrictEqual(ajvDraft04.validateSchema(schema), true) + const validate = ajvDraft04.compile(schema) + deepStrictEqual(validate("abc"), true) + deepStrictEqual(validate("a"), false) + }) + + it("preserves validation semantics for converted constraints", () => { + const document = JsonSchema.toDocumentDraft04({ + dialect: "draft-2020-12", + schema: { + type: "object", + properties: { + mode: { const: "on" }, + value: { type: "number", exclusiveMinimum: 0, exclusiveMaximum: 2 }, + tuple: { type: "array", prefixItems: [{ type: "string" }], items: false } + }, + required: ["mode", "value", "tuple"], + additionalProperties: false + }, + definitions: {} + }) + const schema = makeSchema(document) + deepStrictEqual(ajvDraft04.validateSchema(schema), true) + + const validate = ajvDraft04.compile(schema) + deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a"] }), true) + deepStrictEqual(validate({ mode: "off", value: 1, tuple: ["a"] }), false) + deepStrictEqual(validate({ mode: "on", value: 0, tuple: ["a"] }), false) + deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a", "b"] }), false) + }) + + it("converts documents generated from Effect schemas", () => { + const shared = Schema.Struct({ value: Schema.String }) + const schema = Schema.Struct({ + mode: Schema.Literal("enabled"), + threshold: Schema.Finite.check(Schema.isGreaterThan(0)), + tuple: Schema.Tuple([Schema.String, Schema.Boolean]), + left: shared, + right: shared + }) + const document = JsonSchema.toDocumentDraft04(Schema.toJsonSchemaDocument(schema)) + const draft04 = makeSchema(document) + deepStrictEqual(ajvDraft04.validateSchema(draft04), true) + + const validate = ajvDraft04.compile(draft04) + const valid = { + mode: "enabled", + threshold: 1, + tuple: ["a", true], + left: { value: "left" }, + right: { value: "right" } + } + deepStrictEqual(validate(valid), true) + deepStrictEqual(validate({ ...valid, threshold: 0 }), false) + deepStrictEqual(validate({ ...valid, tuple: ["a", true, false] }), false) + }) }) - describe("toDocumentOpenApi3_1", () => { - it("should rewrite `$defs` references to `components/schemas`", () => { + describe("toMultiDocumentOpenApi3_1", () => { + it("rewrites `$defs` references to `components/schemas`", () => { const input: JsonSchema.MultiDocument<"draft-2020-12"> = { dialect: "draft-2020-12", schemas: [ @@ -1177,6 +1818,33 @@ describe("JsonSchema", () => { }) }) + it("rewrites refs only in schema positions", () => { + const literal = { $ref: "#/$defs/Literal" } + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ + properties: { value: { $ref: "#/$defs/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + }], + definitions: { Value: { type: "string" } } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [{ + properties: { value: { $ref: "#/components/schemas/Value" } }, + const: literal, + enum: [literal], + default: literal, + examples: [literal] + }], + definitions: { Value: { type: "string" } } + }) + }) + it("sanitizes component schema keys and rewritten refs together", () => { const input: JsonSchema.MultiDocument<"draft-2020-12"> = { dialect: "draft-2020-12", @@ -1210,12 +1878,260 @@ describe("JsonSchema", () => { } }) }) + + it("unescapes a definition key before sanitizing its ref", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [{ $ref: "#/$defs/A~1B" }], + definitions: { + "A/B": { type: "string" } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [{ $ref: "#/components/schemas/A_B" }], + definitions: { + A_B: { type: "string" } + } + }) + }) + + it("suffixes a sanitized key that collides with a valid key", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [ + { + allOf: [ + { $ref: "#/$defs/A_B" }, + { $ref: "#/$defs/A~1B" } + ] + } + ], + definitions: { + A_B: { type: "string" }, + "A/B": { type: "number" } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [ + { + allOf: [ + { $ref: "#/components/schemas/A_B" }, + { $ref: "#/components/schemas/A_B_1" } + ] + } + ], + definitions: { + A_B: { type: "string" }, + A_B_1: { type: "number" } + } + }) + }) + + it("allocates suffixes deterministically and skips occupied keys", () => { + const convert = (definitions: JsonSchema.Definitions) => + JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [ + { + allOf: [ + { $ref: "#/$defs/A_B" }, + { $ref: "#/$defs/A_B_1" }, + { $ref: "#/$defs/A~1B" }, + { $ref: "#/$defs/A?B" } + ] + } + ], + definitions + }) + const expected: JsonSchema.MultiDocument<"openapi-3.1"> = { + dialect: "openapi-3.1", + schemas: [ + { + allOf: [ + { $ref: "#/components/schemas/A_B" }, + { $ref: "#/components/schemas/A_B_1" }, + { $ref: "#/components/schemas/A_B_2" }, + { $ref: "#/components/schemas/A_B_3" } + ] + } + ], + definitions: { + A_B: { type: "string" }, + A_B_1: { type: "boolean" }, + A_B_2: { type: "number" }, + A_B_3: { type: "null" } + } + } + + deepStrictEqual( + convert({ + "A?B": { type: "null" }, + A_B_1: { type: "boolean" }, + "A/B": { type: "number" }, + A_B: { type: "string" } + }), + expected + ) + deepStrictEqual( + convert({ + A_B: { type: "string" }, + "A/B": { type: "number" }, + A_B_1: { type: "boolean" }, + "A?B": { type: "null" } + }), + expected + ) + }) + + it("reserves sanitized bases before allocating suffixes", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [ + { + allOf: [ + { $ref: "#/$defs/A~1B" }, + { $ref: "#/$defs/A?B" }, + { $ref: "#/$defs/A?B?1" } + ] + } + ], + definitions: { + "A/B": { type: "number" }, + "A?B": { type: "string" }, + "A?B?1": { type: "boolean" } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [ + { + allOf: [ + { $ref: "#/components/schemas/A_B" }, + { $ref: "#/components/schemas/A_B_2" }, + { $ref: "#/components/schemas/A_B_1" } + ] + } + ], + definitions: { + A_B: { type: "number" }, + A_B_2: { type: "string" }, + A_B_1: { type: "boolean" } + } + }) + }) + + it("rewrites nested definition refs without changing other refs", () => { + const result = JsonSchema.toMultiDocumentOpenApi3_1({ + dialect: "draft-2020-12", + schemas: [ + { + allOf: [ + { $ref: "#/$defs/A~1B/properties/value" }, + { $ref: "https://example.com/schema#/$defs/A~1B" }, + { $ref: "#/other/A~1B" } + ] + } + ], + definitions: { + "A/B": { + type: "object", + properties: { value: { type: "string" } } + } + } + }) + + deepStrictEqual(result, { + dialect: "openapi-3.1", + schemas: [ + { + allOf: [ + { $ref: "#/components/schemas/A_B/properties/value" }, + { $ref: "https://example.com/schema#/$defs/A~1B" }, + { $ref: "#/other/A~1B" } + ] + } + ], + definitions: { + A_B: { + type: "object", + properties: { value: { type: "string" } } + } + } + }) + }) + }) + + describe("input immutability", () => { + const schema: JsonSchema.JsonSchema = { + type: "object", + properties: { + value: { + type: "array", + prefixItems: [{ type: "string" }], + items: false, + nullable: true + } + }, + $defs: { + Value: { type: "string" } + } + } + + for ( + const [name, convert] of [ + ["fromSchemaDraft07", JsonSchema.fromSchemaDraft07], + ["fromSchemaDraft2020_12", JsonSchema.fromSchemaDraft2020_12], + ["fromSchemaOpenApi3_1", JsonSchema.fromSchemaOpenApi3_1], + ["fromSchemaOpenApi3_0", JsonSchema.fromSchemaOpenApi3_0] + ] as const + ) { + it(`${name} does not mutate its input`, () => { + assertDoesNotMutate(structuredClone(schema), convert) + }) + } + + for ( + const [name, convert] of [ + ["toDocumentDraft07", JsonSchema.toDocumentDraft07], + ["toDocumentDraft04", JsonSchema.toDocumentDraft04] + ] as const + ) { + it(`${name} does not mutate its input`, () => { + assertDoesNotMutate( + { + dialect: "draft-2020-12", + schema: structuredClone(schema), + definitions: { Value: { type: "string" } } + }, + convert + ) + }) + } + + it("toMultiDocumentOpenApi3_1 does not mutate its input", () => { + assertDoesNotMutate( + { + dialect: "draft-2020-12", + schemas: [structuredClone(schema)] as const, + definitions: { Value: { type: "string" } } + }, + JsonSchema.toMultiDocumentOpenApi3_1 + ) + }) }) describe("roundtrip conversions", () => { it("preserves a Draft-07 schema and definitions through canonical form", () => { const original: JsonSchema.JsonSchema = { type: "object", + readOnly: true, + writeOnly: true, + not: { required: ["forbidden"] }, properties: { name: { type: "string" }, items: { @@ -1242,6 +2158,9 @@ describe("JsonSchema", () => { deepStrictEqual(backTo07.schema, { type: "object", + readOnly: true, + writeOnly: true, + not: { required: ["forbidden"] }, properties: { name: { type: "string" }, items: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e60dd937a..87ea7cb26a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -375,6 +375,9 @@ importers: ajv: specifier: ^8.20.0 version: 8.20.0 + ajv-draft-04: + specifier: ^1.0.0 + version: 1.0.0(ajv@8.20.0) ast-types: specifier: ^0.14.2 version: 0.14.2 @@ -3527,6 +3530,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -9390,6 +9401,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3