1/**
2 * Helpers for normalizing and converting JSON Schema and OpenAPI schema
3 * documents. Supported inputs include JSON Schema Draft-07, Draft 2020-12,
4 * OpenAPI 3.0, and OpenAPI 3.1; conversions normalize through
5 * `Document<"draft-2020-12">` before emitting another dialect, including
6 * JSON Schema Draft-04. The module also defines document types, meta-schema
7 * constants, and OpenAPI component-key helpers.
8 *
9 * @since 4.0.0
10 */
11import * as InternalRecord from "./internal/record.ts"
12import { formatUriFragment, parseUriFragment } from "./JsonPointer.ts"
13import * as Predicate from "./Predicate.ts"
14
15/**
16 * A plain object representing a single JSON Schema node.
17 *
18 * **When to use**
19 *
20 * Use to represent an arbitrary JSON Schema object regardless of dialect.
21 *
22 * **Details**
23 *
24 * This is an open record type (`[x: string]: unknown`) so it can hold any JSON
25 * Schema keyword. Most functions in this module accept or return this type.
26 *
27 * @category models
28 * @since 4.0.0
29 */
30export interface JsonSchema {
31 [x: string]: unknown
32}
33
34/**
35 * The set of JSON Schema dialects supported by this module.
36 *
37 * **When to use**
38 *
39 * Use as the dialect marker for `JsonSchema` documents when parsing,
40 * converting, or emitting schemas across the supported formats.
41 *
42 * **Details**
43 *
44 * Supported values are `"draft-04"` for JSON Schema Draft-04, `"draft-07"`
45 * for JSON Schema Draft-07, `"draft-2020-12"` for JSON Schema Draft 2020-12
46 * and the canonical internal form, `"openapi-3.1"` for OpenAPI 3.1, and
47 * `"openapi-3.0"` for OpenAPI 3.0.
48 *
49 * @see {@link Document} for a single root schema tagged with a dialect
50 * @see {@link MultiDocument} for multiple root schemas tagged with a dialect
51 *
52 * @category models
53 * @since 4.0.0
54 */
55export type Dialect = "draft-04" | "draft-07" | "draft-2020-12" | "openapi-3.1" | "openapi-3.0"
56
57/**
58 * The JSON Schema primitive type names.
59 *
60 * **When to use**
61 *
62 * Use to restrict a JSON Schema `type` keyword to the supported primitive names.
63 *
64 * @category models
65 * @since 4.0.0
66 */
67export type Type = "string" | "number" | "boolean" | "array" | "object" | "null" | "integer"
68
69/**
70 * A record of named JSON Schema definitions, keyed by definition name.
71 *
72 * **When to use**
73 *
74 * Use as the shared lookup table for named JSON Schema nodes that are
75 * referenced from JSON Schema documents.
76 *
77 * **Details**
78 *
79 * The map is dialect-neutral. Conversion APIs emit it as `$defs`,
80 * `definitions`, or `components.schemas` depending on the target format.
81 *
82 * @see {@link Document} for a single root schema with definitions
83 * @see {@link MultiDocument} for multiple root schemas sharing definitions
84 * @category models
85 * @since 4.0.0
86 */
87export interface Definitions extends Record<string, JsonSchema> {}
88
89/**
90 * A structured container for a single JSON Schema and its associated
91 * definitions.
92 *
93 * **When to use**
94 *
95 * Use when you need to carry a root schema together with its shared
96 * definitions, or when converting between dialects with the `from*` and `to*`
97 * functions.
98 *
99 * **Details**
100 *
101 * The `schema` field holds the root schema *without* the definitions
102 * collection. Root definitions are stored separately in `definitions` and
103 * referenced via `#/$defs/<name>` for Draft-2020-12, `#/definitions/<name>`
104 * for Draft-04 and Draft-07, and `#/components/schemas/<name>` for OpenAPI 3.1
105 * and OpenAPI 3.0.
106 *
107 * **Example** (Inspecting a parsed document)
108 *
109 * ```ts import.meta.vitest
110 * import { JsonSchema } from "effect"
111 *
112 * const raw: JsonSchema.JsonSchema = {
113 * type: "string",
114 * $defs: { Trimmed: { type: "string", minLength: 1 } }
115 * }
116 *
117 * const doc = JsonSchema.fromSchemaDraft2020_12(raw)
118 *
119 * doc.dialect // => "draft-2020-12"
120 * doc.schema // => { type: "string" }
121 * doc.definitions // => { Trimmed: { type: "string", minLength: 1 } }
122 * ```
123 *
124 * @see {@link MultiDocument}
125 * @see {@link fromSchemaDraft2020_12}
126 * @category models
127 * @since 4.0.0
128 */
129export interface Document<D extends Dialect> {
130 readonly dialect: D
131 readonly schema: JsonSchema
132 readonly definitions: Definitions
133}
134
135/**
136 * Like {@link Document}, but carries multiple root schemas that share a
137 * single definitions pool.
138 *
139 * **When to use**
140 *
141 * Use when generating several schemas, such as a request body
142 * and a response body, that reference the same set of definitions.
143 *
144 * **Details**
145 *
146 * The `schemas` tuple is non-empty and contains at least one element.
147 *
148 * @see {@link Document}
149 * @see {@link toMultiDocumentOpenApi3_1}
150 * @category models
151 * @since 4.0.0
152 */
153export interface MultiDocument<D extends Dialect> {
154 readonly dialect: D
155 readonly schemas: readonly [JsonSchema, ...Array<JsonSchema>]
156 readonly definitions: Definitions
157}
158
159/**
160 * Represents the `$schema` meta-schema URI for JSON Schema Draft-04.
161 *
162 * **When to use**
163 *
164 * Use when constructing a Draft-04 JSON Schema document and you need a stable
165 * value for the root `$schema` field.
166 *
167 * @see {@link META_SCHEMA_URI_DRAFT_07} for the Draft-07 `$schema` URI
168 * @category constants
169 * @since 4.0.0
170 */
171export const META_SCHEMA_URI_DRAFT_04 = "http://json-schema.org/draft-04/schema#"
172
173/**
174 * Represents the `$schema` meta-schema URI for JSON Schema Draft-07.
175 *
176 * **When to use**
177 *
178 * Use when constructing a Draft-07 JSON Schema document and you need a stable
179 * value for the root `$schema` field.
180 *
181 * **Details**
182 *
183 * The exported value is the literal string
184 * `http://json-schema.org/draft-07/schema#`.
185 *
186 * @see {@link META_SCHEMA_URI_DRAFT_04} for the Draft-04 `$schema` URI
187 * @see {@link META_SCHEMA_URI_DRAFT_2020_12} for the Draft 2020-12 `$schema` URI
188 *
189 * @category constants
190 * @since 4.0.0
191 */
192export const META_SCHEMA_URI_DRAFT_07 = "http://json-schema.org/draft-07/schema#"
193
194/**
195 * Represents the `$schema` meta-schema URI for JSON Schema Draft 2020-12.
196 *
197 * **When to use**
198 *
199 * Use when you need to populate the `$schema` field while emitting a JSON
200 * Schema document that should declare JSON Schema Draft 2020-12.
201 *
202 * **Details**
203 *
204 * The exported value is the literal string
205 * `https://json-schema.org/draft/2020-12/schema`.
206 *
207 * @see {@link META_SCHEMA_URI_DRAFT_07} for the Draft-07 `$schema` URI
208 *
209 * @category constants
210 * @since 4.0.0
211 */
212export const META_SCHEMA_URI_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"
213
214const META_SCHEMA_URI_OPEN_API_3_1 = "https://spec.openapis.org/oas/3.1/dialect/base"
215
216function isMetaSchemaUri(value: unknown, uri: string): boolean {
217 return value === uri || value === (uri.endsWith("#") ? uri.slice(0, -1) : `${uri}#`)
218}
219
220function rewriteOpenApiComponentsReference(reference: string): string {
221 const path = reference.startsWith("#") ? parseUriFragment(reference) : undefined
222 return path !== undefined && path[0] === "components" && path[1] === "schemas"
223 ? formatUriFragment(["$defs", ...path.slice(2)])
224 : reference
225}
226
227const OPEN_API_31_TARGET_COLLISIONS = ["example", "discriminator", "xml", "externalDocs"]
228
229/**
230 * Parses a raw Draft-07 JSON Schema into a `Document<"draft-2020-12">`.
231 *
232 * **When to use**
233 *
234 * Use when you have a raw JSON Schema object that follows Draft-07 conventions
235 * and need the canonical Draft-2020-12 document representation.
236 *
237 * **Details**
238 *
239 * This converts Draft-07 tuple syntax (`items` as array plus
240 * `additionalItems`) to Draft-2020-12 form (`prefixItems` plus `items`), splits
241 * `dependencies` into `dependentRequired` and `dependentSchemas`, converts
242 * plain-name `$id` fragments to `$anchor`, and extracts root-level
243 * `definitions` into the `definitions` field. Local JSON Pointer refs are
244 * relocated when one of these structural conversions moves its target.
245 *
246 * **Gotchas**
247 *
248 * Unknown and custom keywords are copied as opaque values. Their contents are
249 * not treated as nested schemas. Draft-07 keywords such as `if` / `then` /
250 * `else` and `contains` are preserved and their subschemas are converted.
251 * Siblings of a valid Draft-07 `$ref` are ignored according to Draft-07
252 * semantics. The conversion throws when a Draft-07 `$id` fragment cannot be
253 * represented as a Draft-2020-12 `$anchor`, or when an unknown Draft-07
254 * keyword would become an active Draft-2020-12 keyword after copying.
255 *
256 * **Example** (Parsing a Draft-07 schema)
257 *
258 * ```ts import.meta.vitest
259 * import { JsonSchema } from "effect"
260 *
261 * const raw: JsonSchema.JsonSchema = {
262 * type: "object",
263 * properties: {
264 * tags: {
265 * type: "array",
266 * items: { type: "string" }
267 * }
268 * }
269 * }
270 *
271 * const doc = JsonSchema.fromSchemaDraft07(raw)
272 * doc.dialect // => "draft-2020-12"
273 * doc.schema.properties // => { tags: { type: "array", items: { type: "string" } } }
274 * ```
275 *
276 * @see {@link fromSchemaDraft2020_12}
277 * @see {@link fromSchemaOpenApi3_0}
278 * @see {@link toDocumentDraft07}
279 * @category decoding
280 * @since 4.0.0
281 */
282export function fromSchemaDraft07(js: JsonSchema): Document<"draft-2020-12"> {
283 return fromSchemaDraft2020_12(convertDraft07(js))
284}
285
286/**
287 * Parses a raw Draft-2020-12 JSON Schema into a `Document<"draft-2020-12">`.
288 *
289 * **When to use**
290 *
291 * Use when you already have a raw JSON Schema object in Draft-2020-12 format.
292 *
293 * **Details**
294 *
295 * This separates `$defs` from the root schema into the `definitions` field.
296 * Unlike {@link fromSchemaDraft07}, this performs no keyword rewriting.
297 *
298 * **Example** (Parsing a Draft-2020-12 schema)
299 *
300 * ```ts import.meta.vitest
301 * import { JsonSchema } from "effect"
302 *
303 * const raw: JsonSchema.JsonSchema = {
304 * type: "number",
305 * minimum: 0,
306 * $defs: { PositiveInt: { type: "integer", minimum: 1 } }
307 * }
308 *
309 * const doc = JsonSchema.fromSchemaDraft2020_12(raw)
310 * doc.schema // => { type: "number", minimum: 0 }
311 * doc.definitions // => { PositiveInt: { type: "integer", minimum: 1 } }
312 * ```
313 *
314 * @see {@link fromSchemaDraft07}
315 * @see {@link fromSchemaOpenApi3_1}
316 * @category decoding
317 * @since 4.0.0
318 */
319export function fromSchemaDraft2020_12(js: JsonSchema): Document<"draft-2020-12"> {
320 const { $defs, ...schema } = js
321 return {
322 dialect: "draft-2020-12",
323 schema,
324 definitions: Predicate.isObject($defs) ? ($defs as Definitions) : {}
325 }
326}
327
328/**
329 * Parses a raw OpenAPI 3.1 JSON Schema into a `Document<"draft-2020-12">`.
330 *
331 * **When to use**
332 *
333 * Use when you need to consume raw JSON Schema objects from an OpenAPI 3.1
334 * specification.
335 *
336 * **Details**
337 *
338 * This rewrites `#/components/schemas/...` refs to `#/$defs/...`, normalizes the
339 * OpenAPI base dialect URI to Draft 2020-12, converts the deprecated singular
340 * `example` field to `examples`, then delegates to
341 * {@link fromSchemaDraft2020_12}.
342 *
343 * **Gotchas**
344 *
345 * When both `example` and `examples` are present, the singular example is
346 * prepended to the array. Custom `$schema` dialect URIs and unknown keywords
347 * are copied opaquely. Component references inside a schema resource identified
348 * by `$id` are left unchanged because they are relative to that resource.
349 *
350 * **Example** (Parsing an OpenAPI 3.1 schema)
351 *
352 * ```ts import.meta.vitest
353 * import { JsonSchema } from "effect"
354 *
355 * const raw: JsonSchema.JsonSchema = {
356 * type: "object",
357 * properties: {
358 * user: { $ref: "#/components/schemas/User" }
359 * }
360 * }
361 *
362 * const doc = JsonSchema.fromSchemaOpenApi3_1(raw)
363 * doc.schema.properties // => { user: { $ref: "#/$defs/User" } }
364 * ```
365 *
366 * @see {@link fromSchemaOpenApi3_0}
367 * @see {@link toMultiDocumentOpenApi3_1}
368 * @category decoding
369 * @since 4.0.0
370 */
371export function fromSchemaOpenApi3_1(js: JsonSchema): Document<"draft-2020-12"> {
372 const isRootResource = createsResource(js.$id)
373 const schema = transformSchema(js, (schema, inEmbeddedResource) => {
374 if (!isRootResource && !inEmbeddedResource) rewriteSchemaRef(schema, rewriteOpenApiComponentsReference)
375 if (isMetaSchemaUri(schema.$schema, META_SCHEMA_URI_OPEN_API_3_1)) {
376 InternalRecord.assignProperty(schema, "$schema", META_SCHEMA_URI_DRAFT_2020_12)
377 }
378 if (Object.hasOwn(schema, "example")) {
379 const examples = schema.examples
380 if (examples === undefined || Array.isArray(examples)) {
381 InternalRecord.assignProperty(schema, "examples", [schema.example, ...(examples ?? [])])
382 delete schema.example
383 }
384 }
385 }) as JsonSchema
386 return fromSchemaDraft2020_12(schema)
387}
388
389/**
390 * Parses a raw OpenAPI 3.0 JSON Schema into a `Document<"draft-2020-12">`.
391 *
392 * **When to use**
393 *
394 * Use when you need to consume raw JSON Schema objects from an OpenAPI 3.0
395 * specification.
396 *
397 * **Details**
398 *
399 * This directly converts OpenAPI 3.0 schema objects to Draft-2020-12. It
400 * handles `nullable`, singular `example`, boolean `exclusiveMinimum` and
401 * `exclusiveMaximum`, and OpenAPI component refs. Only values in OpenAPI
402 * schema positions are traversed as schemas.
403 *
404 * **Gotchas**
405 *
406 * OpenAPI 3.0 `nullable` is applied only when the same Schema Object has an
407 * explicit string `type`; other constraints such as `enum` are left
408 * unchanged. Unknown keywords, vendor extensions, and annotation values are
409 * copied opaquely unless their name would become active in Draft 2020-12 and
410 * change meaning, in which case conversion throws. Siblings of a valid
411 * OpenAPI 3.0 `$ref` are ignored.
412 *
413 * **Example** (Parsing an OpenAPI 3.0 nullable schema)
414 *
415 * ```ts import.meta.vitest
416 * import { JsonSchema } from "effect"
417 *
418 * const raw: JsonSchema.JsonSchema = {
419 * type: "string",
420 * nullable: true
421 * }
422 *
423 * const doc = JsonSchema.fromSchemaOpenApi3_0(raw)
424 * doc.schema.type // => ["string", "null"]
425 * ```
426 *
427 * @see {@link fromSchemaOpenApi3_1}
428 * @see {@link fromSchemaDraft07}
429 * @category decoding
430 * @since 4.0.0
431 */
432export function fromSchemaOpenApi3_0(schema: JsonSchema): Document<"draft-2020-12"> {
433 return fromSchemaDraft2020_12(convertOpenApi30(schema))
434}
435
436/**
437 * Converts a `Document<"draft-2020-12">` to a `Document<"draft-07">`.
438 *
439 * **When to use**
440 *
441 * Use when you need to output a canonical JSON Schema document in Draft-07
442 * format.
443 *
444 * **Details**
445 *
446 * This rewrites `#/$defs/...` refs to `#/definitions/...`, converts
447 * Draft-2020-12 tuple syntax (`prefixItems` plus `items`) to Draft-07 form
448 * (`items` as array plus `additionalItems`), merges `dependentRequired` and
449 * `dependentSchemas` into `dependencies`, and converts both the root schema
450 * and all definitions. Local JSON Pointer refs are relocated when structural
451 * keywords move.
452 *
453 * **Gotchas**
454 *
455 * Unknown and custom keywords are copied as opaque values. Known keywords
456 * that Draft-07 cannot represent cause the conversion to throw
457 * instead of being dropped. These include dynamic references,
458 * `unevaluatedProperties`, `unevaluatedItems`, and non-default `minContains`
459 * or `maxContains` constraints. Conversion also throws when an opaque
460 * Draft-2020-12 keyword would collide with an active Draft-07 keyword, or when
461 * `$id` and `$anchor` occur together because Draft-07 cannot preserve both identifiers.
462 *
463 * **Example** (Converting to Draft-07)
464 *
465 * ```ts import.meta.vitest
466 * import { JsonSchema } from "effect"
467 *
468 * const doc = JsonSchema.fromSchemaDraft2020_12({
469 * type: "array",
470 * prefixItems: [{ type: "string" }, { type: "number" }],
471 * items: { type: "boolean" }
472 * })
473 *
474 * const draft07 = JsonSchema.toDocumentDraft07(doc)
475 * draft07.dialect // => "draft-07"
476 * draft07.schema.items // => [{ type: "string" }, { type: "number" }]
477 * draft07.schema.additionalItems // => { type: "boolean" }
478 * ```
479 *
480 * @see {@link fromSchemaDraft07}
481 * @see {@link toDocumentDraft04} for converting to Draft-04
482 * @see {@link toMultiDocumentOpenApi3_1}
483 * @category encoding
484 * @since 4.0.0
485 */
486export function toDocumentDraft07(document: Document<"draft-2020-12">): Document<"draft-07"> {
487 return {
488 dialect: "draft-07",
489 ...convertDocument(document, draft07Adapter)
490 }
491}
492
493/**
494 * Converts a `Document<"draft-2020-12">` to a `Document<"draft-04">`.
495 *
496 * **When to use**
497 *
498 * Use when you need to output a canonical JSON Schema document in Draft-04
499 * format.
500 *
501 * **Details**
502 *
503 * This directly rewrites `#/$defs/...` refs to `#/definitions/...`, converts
504 * tuple syntax, merges canonical dependencies, lowers `const` to `enum`,
505 * converts numeric exclusive bounds to the Draft-04 boolean form, lowers
506 * conditionals and basic `contains` through boolean applicators, and converts
507 * both the root schema and all definitions.
508 *
509 * **Gotchas**
510 *
511 * Unknown and custom keywords are copied as opaque values. Newer annotation
512 * keywords are preserved as Draft-04 extensions. Known keywords
513 * without a Draft-04 equivalent, including `propertyNames`, non-default
514 * `contains` cardinality, dynamic references, and unevaluated constraints,
515 * cause the conversion to throw instead of being dropped. A conditional with
516 * both branches also throws when lowering it would duplicate a nested schema
517 * identifier. Conversion also throws when an opaque Draft-2020-12 keyword
518 * would collide with an active Draft-04 keyword, or when `$id` and `$anchor`
519 * occur together because Draft-04 cannot preserve both identifiers.
520 *
521 * **Example** (Converting exclusive bounds)
522 *
523 * ```ts import.meta.vitest
524 * import { JsonSchema } from "effect"
525 *
526 * const doc = JsonSchema.fromSchemaDraft2020_12({
527 * type: "number",
528 * exclusiveMinimum: 0
529 * })
530 *
531 * JsonSchema.toDocumentDraft04(doc).schema // => { type: "number", minimum: 0, exclusiveMinimum: true }
532 * ```
533 *
534 * @see {@link toDocumentDraft07} for converting to Draft-07
535 * @category encoding
536 * @since 4.0.0
537 */
538export function toDocumentDraft04(document: Document<"draft-2020-12">): Document<"draft-04"> {
539 return {
540 dialect: "draft-04",
541 ...convertDocument(document, draft04Adapter, {
542 booleanAdapter: (schema) => schema ? {} : { not: {} }
543 })
544 }
545}
546
547/**
548 * Converts a `MultiDocument<"draft-2020-12">` to a
549 * `MultiDocument<"openapi-3.1">`.
550 *
551 * **When to use**
552 *
553 * Use when you need to emit an OpenAPI 3.1 multi-document from canonical JSON
554 * Schema documents.
555 *
556 * **Details**
557 *
558 * This rewrites local `#/$defs/...` refs to `#/components/schemas/...` and
559 * sanitizes definition keys to match the OpenAPI component key pattern
560 * (`^[a-zA-Z0-9.\-_]+$`) by replacing invalid characters with `_`. Valid keys
561 * are preserved. When sanitized keys collide, the converter appends the first
562 * available `_1`, `_2`, and subsequent suffix, with allocation independent of
563 * definition insertion order. All local refs are updated to use the allocated
564 * keys, including refs to paths within a definition.
565 *
566 * **Gotchas**
567 *
568 * External refs and local refs outside `#/$defs` are left unchanged. Conversion
569 * throws when a custom keyword would become an active OpenAPI keyword and
570 * therefore change meaning. References inside schema resources identified by
571 * `$id` are left unchanged. Conversion throws when an identified root schema
572 * references the detached shared definitions pool because OpenAPI cannot
573 * preserve that fragment reference.
574 *
575 * **Example** (Converting to OpenAPI 3.1)
576 *
577 * ```ts import.meta.vitest
578 * import { JsonSchema } from "effect"
579 *
580 * const multi: JsonSchema.MultiDocument<"draft-2020-12"> = {
581 * dialect: "draft-2020-12",
582 * schemas: [{ $ref: "#/$defs/User" }],
583 * definitions: {
584 * User: { type: "object", properties: { name: { type: "string" } } }
585 * }
586 * }
587 *
588 * const openapi = JsonSchema.toMultiDocumentOpenApi3_1(multi)
589 * openapi.dialect // => "openapi-3.1"
590 * openapi.schemas[0] // => { $ref: "#/components/schemas/User" }
591 * ```
592 *
593 * @see {@link toDocumentDraft07}
594 * @see {@link MultiDocument}
595 * @category encoding
596 * @since 4.0.0
597 */
598export function toMultiDocumentOpenApi3_1(multiDocument: MultiDocument<"draft-2020-12">): MultiDocument<"openapi-3.1"> {
599 const definitionKeys = Object.keys(multiDocument.definitions)
600 const keyMap = new Map<string, string>()
601 const usedKeys = new Set(definitionKeys.filter((key) => VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(key)))
602 const invalidKeys = definitionKeys
603 .filter((key) => !VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(key))
604 .sort()
605 .map((key) => [key, sanitizeOpenApiComponentsSchemasKey(key)] as const)
606 for (const [key, base] of invalidKeys) {
607 if (usedKeys.has(base)) continue
608 usedKeys.add(base)
609 keyMap.set(key, base)
610 }
611 for (const [key, base] of invalidKeys) {
612 if (keyMap.has(key)) continue
613 let candidate: string
614 let suffix = 0
615 do candidate = `${base}_${++suffix}`
616 while (usedKeys.has(candidate))
617 usedKeys.add(candidate)
618 keyMap.set(key, candidate)
619 }
620
621 function rewrite(
622 schema: JsonSchema,
623 rejectSharedDefinitionRefs = false
624 ): JsonSchema {
625 const isRootResource = createsResource(schema.$id)
626 const localDefinitions = Predicate.isObject(schema.$defs) ? schema.$defs : undefined
627 return transformSchema(schema, (schema, inEmbeddedResource) => {
628 rejectKeywordCollisions(schema, OPEN_API_31_TARGET_COLLISIONS, "OpenAPI 3.1", "Draft 2020-12")
629 rewriteSchemaRef(schema, (reference, keyword) => {
630 const path = reference.startsWith("#") ? parseUriFragment(reference) : undefined
631 if (path === undefined || path[0] !== "$defs" || path.length < 2) return reference
632 const key = path[1]
633 if (isRootResource) {
634 if (
635 rejectSharedDefinitionRefs &&
636 !inEmbeddedResource &&
637 Object.hasOwn(multiDocument.definitions, key) &&
638 (localDefinitions === undefined || !Object.hasOwn(localDefinitions, key))
639 ) {
640 unsupported(keyword, "OpenAPI 3.1", "a schema resource cannot reference the shared definitions pool")
641 }
642 return reference
643 }
644 return inEmbeddedResource
645 ? reference
646 : formatUriFragment(["components", "schemas", keyMap.get(key) ?? key, ...path.slice(2)])
647 })
648 }) as JsonSchema
649 }
650
651 const schemas = multiDocument.schemas.map((schema) => rewrite(schema, true)) as unknown as MultiDocument<
652 "openapi-3.1"
653 >["schemas"]
654 const definitions: Definitions = {}
655 for (const key of definitionKeys) {
656 InternalRecord.assignProperty(
657 definitions,
658 keyMap.get(key) ?? key,
659 rewrite(multiDocument.definitions[key])
660 )
661 }
662
663 return {
664 dialect: "openapi-3.1",
665 schemas,
666 definitions
667 }
668}
669
670/** @internal */
671export const VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP = /^[a-zA-Z0-9.\-_]+$/
672
673/**
674 * Returns a sanitized key for an OpenAPI component schema.
675 * Should match the `^[a-zA-Z0-9.\-_]+$` regular expression.
676 *
677 * @internal
678 */
679export function sanitizeOpenApiComponentsSchemasKey(s: string): string {
680 return s.length === 0 ? "_" : s.replace(/[^a-zA-Z0-9._-]/gu, "_")
681}
682
683/** @internal */
684export function getReferenceKey($ref: string): string | undefined {
685 const path = $ref.startsWith("#") ? parseUriFragment($ref) : undefined
686 return path !== undefined && path.length === 2 && path[0] === "$defs"
687 ? path[1]
688 : undefined
689}
690
691function transformSchema(
692 node: unknown,
693 transform: (schema: Record<string, unknown>, inEmbeddedResource: boolean) => void
694): unknown {
695 return walk(node, false, true)
696
697 function walk(node: unknown, inheritedResource: boolean, isRoot = false): unknown {
698 if (!Predicate.isObject(node)) return node
699 const inEmbeddedResource = inheritedResource || (!isRoot && createsResource(node.$id))
700
701 const out: Record<string, unknown> = {}
702 for (const key of Object.keys(node)) {
703 const value = node[key]
704 let transformed = value
705 switch (key) {
706 case "$defs":
707 case "properties":
708 case "patternProperties":
709 case "dependentSchemas":
710 transformed = mapObject(value, (value) => walk(value, inEmbeddedResource)) ?? value
711 break
712 case "allOf":
713 case "anyOf":
714 case "oneOf":
715 case "prefixItems":
716 transformed = Array.isArray(value) ? value.map((value) => walk(value, inEmbeddedResource)) : value
717 break
718 case "not":
719 case "additionalProperties":
720 case "propertyNames":
721 case "unevaluatedProperties":
722 case "items":
723 case "contains":
724 case "unevaluatedItems":
725 case "if":
726 case "then":
727 case "else":
728 case "contentSchema":
729 transformed = walk(value, inEmbeddedResource)
730 }
731 InternalRecord.assignProperty(out, key, transformed)
732 }
733 transform(out, inEmbeddedResource)
734 return out
735 }
736}
737
738/** @internal */
739export function rewriteRefs(schema: JsonSchema, rewrite: ($ref: string) => string): JsonSchema {
740 return transformSchema(schema, (schema) => {
741 rewriteSchemaRef(schema, rewrite)
742 }) as JsonSchema
743}
744
745function rewriteSchemaRef(
746 schema: Record<string, unknown>,
747 rewrite: ($ref: string, keyword: "$ref" | "$dynamicRef") => string
748): void {
749 if (typeof schema.$ref === "string") {
750 InternalRecord.assignProperty(schema, "$ref", rewrite(schema.$ref, "$ref"))
751 }
752 if (typeof schema.$dynamicRef === "string") {
753 InternalRecord.assignProperty(schema, "$dynamicRef", rewrite(schema.$dynamicRef, "$dynamicRef"))
754 }
755}
756
757function mapObject(
758 value: unknown,
759 f: (node: unknown, key: string) => unknown
760): Record<string, unknown> | undefined {
761 if (!Predicate.isObject(value)) return undefined
762 const out: Record<string, unknown> = {}
763 for (const key of Object.keys(value)) {
764 InternalRecord.assignProperty(out, key, f(value[key], key))
765 }
766 return out
767}
768
769type Path = ReadonlyArray<string>
770type Convert = (root: unknown, sourcePath?: Path, targetPath?: Path) => unknown
771
772interface Context {
773 readonly isDocumentRoot: boolean
774 readonly schema: (value: unknown, sourceKey: string, targetKey?: string) => unknown
775 readonly schemaAt: (value: unknown, sourcePath: Path, targetPath: Path) => unknown
776 readonly schemaArray: (value: unknown, sourceKey: string, targetKey?: string) => unknown
777 readonly schemaMap: (value: unknown, sourceKey: string, targetKey?: string) => unknown
778 readonly reference: (out: JsonSchema, value: unknown) => void
779}
780
781type Adapter = (schema: JsonSchema, context: Context) => JsonSchema
782
783type PendingReference = readonly [out: JsonSchema, value: string, sourceResource: string]
784
785interface ResourceScope {
786 readonly parent?: ResourceScope
787 readonly sourceRoot: Path
788 readonly targetRoot: Path
789 readonly uri: string
790}
791
792interface ConverterOptions {
793 readonly booleanAdapter?: (schema: boolean) => JsonSchema | boolean
794 readonly trackIds?: boolean
795 readonly ignoreRefSiblings?: boolean
796}
797
798// Adapters decide which values are schemas. The kernel only handles recursion,
799// resource scopes, and reference relocation between structural source/target paths.
800function runConverter<A>(adapter: Adapter, options: ConverterOptions | undefined, use: (convert: Convert) => A): A {
801 const locations = new Map<string, Path>()
802 const references: Array<PendingReference> = []
803 let rootUri = ROOT_URI
804
805 function convert(root: unknown, sourcePath: Path = [], targetPath: Path = []): unknown {
806 if (sourcePath.length === 0 && options?.trackIds) {
807 const id = Predicate.isObject(root) ? getResourceId(root) : undefined
808 rootUri = resolveResourceUri(id, ROOT_URI) ?? ROOT_URI
809 }
810 return loop(root, sourcePath, targetPath, { sourceRoot: [], targetRoot: [], uri: rootUri })
811 }
812
813 function finish(): void {
814 for (const [out, value, sourceResource] of references) {
815 let reference = value
816 const resolved = resolveUrl(value, sourceResource)
817 if (resolved !== undefined) {
818 const sourcePointer = parseUriFragment(resolved.hash)
819 resolved.hash = ""
820 if (sourcePointer !== undefined) {
821 const targetPath = locations.get(locationKey(resolved.href, sourcePointer))
822 if (targetPath !== undefined) reference = relocateReference(value, targetPath)
823 }
824 }
825 InternalRecord.assignProperty(out, "$ref", reference)
826 }
827 }
828
829 const out = use(convert)
830 finish()
831 return out
832
833 function loop(
834 node: unknown,
835 sourcePath: Path,
836 targetPath: Path,
837 resourceScope: ResourceScope
838 ): unknown {
839 if (typeof node === "boolean") {
840 recordLocations(sourcePath, targetPath, resourceScope)
841 return options?.booleanAdapter?.(node) ?? node
842 }
843 if (!Predicate.isObject(node)) return node
844
845 let currentResourceScope = resourceScope
846 const id = getResourceId(node)
847 if (sourcePath.length > 0 && options?.trackIds && createsResource(id)) {
848 const uri = resolveResourceUri(id, resourceScope.uri)
849 if (uri !== undefined) {
850 currentResourceScope = {
851 parent: resourceScope,
852 sourceRoot: sourcePath,
853 targetRoot: targetPath,
854 uri
855 }
856 }
857 }
858 recordLocations(sourcePath, targetPath, currentResourceScope)
859 const currentResource = currentResourceScope.uri
860
861 const context: Context = {
862 isDocumentRoot: sourcePath.length === 0,
863 schema(value, sourceKey, targetKey = sourceKey) {
864 return loop(value, [...sourcePath, sourceKey], [...targetPath, targetKey], currentResourceScope)
865 },
866 schemaAt(value, sourceSuffix, targetSuffix) {
867 return loop(value, [...sourcePath, ...sourceSuffix], [...targetPath, ...targetSuffix], currentResourceScope)
868 },
869 schemaArray(value, sourceKey, targetKey = sourceKey) {
870 return Array.isArray(value)
871 ? value.map((item, index) =>
872 loop(
873 item,
874 [...sourcePath, sourceKey, String(index)],
875 [...targetPath, targetKey, String(index)],
876 currentResourceScope
877 )
878 )
879 : value
880 },
881 schemaMap(value, sourceKey, targetKey = sourceKey) {
882 if (!Predicate.isObject(value)) return value
883 return mapObject(value, (item, key) =>
884 loop(
885 item,
886 [...sourcePath, sourceKey, key],
887 [...targetPath, targetKey, key],
888 currentResourceScope
889 ))
890 },
891 reference(out, value) {
892 if (typeof value === "string") {
893 references.push([out, value, currentResource])
894 } else {
895 InternalRecord.assignProperty(out, "$ref", value)
896 }
897 }
898 }
899 return adapter(node, context)
900 }
901
902 function getResourceId(schema: JsonSchema): unknown {
903 return options?.ignoreRefSiblings === true && typeof schema.$ref === "string" ? undefined : schema.$id
904 }
905
906 function recordLocations(sourcePath: Path, targetPath: Path, scope: ResourceScope): void {
907 // A JSON Pointer can address an embedded schema from any containing resource.
908 if (scope.parent !== undefined) recordLocations(sourcePath, targetPath, scope.parent)
909 locations.set(
910 locationKey(scope.uri, sourcePath.slice(scope.sourceRoot.length)),
911 targetPath.slice(scope.targetRoot.length)
912 )
913 }
914}
915
916const ROOT_URI = "https://effect.invalid/.json-schema/"
917
918function resolveUrl(value: string, base: string): URL | undefined {
919 return URL.canParse(value, base) ? new URL(value, base) : undefined
920}
921
922function resolveResourceUri(value: unknown, base: string): string | undefined {
923 if (typeof value !== "string") return undefined
924 const url = resolveUrl(value, base)
925 if (url === undefined) return undefined
926 url.hash = ""
927 return url.href
928}
929
930function relocateReference(reference: string, targetPath: Path): string {
931 const index = reference.indexOf("#")
932 if (index === -1 && targetPath.length === 0) return reference
933 const uri = index === -1 ? reference : reference.slice(0, index)
934 return `${uri}${formatUriFragment(targetPath)}`
935}
936
937function locationKey(resource: string, pointer: Path): string {
938 return `${resource}\u0000${JSON.stringify(pointer)}`
939}
940
941function createsResource(id: unknown): boolean {
942 return typeof id === "string" && id.length > 0 && id[0] !== "#"
943}
944
945function convertSchema(root: JsonSchema, adapter: Adapter, options?: ConverterOptions): JsonSchema {
946 return runConverter(adapter, options, (convert) => convert(root) as JsonSchema)
947}
948
949function convertDocument(
950 document: Document<"draft-2020-12">,
951 adapter: Adapter,
952 options?: ConverterOptions
953): { readonly schema: JsonSchema; readonly definitions: Definitions } {
954 return runConverter(adapter, { ...options, trackIds: true }, (convert) => ({
955 schema: convert(document.schema) as JsonSchema,
956 definitions: mapObject(
957 document.definitions,
958 (definition, key) => convert(definition, ["$defs", key], ["definitions", key]) as JsonSchema
959 ) as Definitions
960 }))
961}
962
963const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties"])
964const SCHEMA_ARRAY_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"])
965const JSON_SCHEMA_SINGLE_KEYWORDS = new Set([
966 "not",
967 "additionalProperties",
968 "propertyNames",
969 "contains",
970 "if",
971 "then",
972 "else",
973 "contentSchema"
974])
975const OPEN_API_30_SCHEMA_MAP_KEYWORDS = new Set(["properties"])
976const OPEN_API_30_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "items", "additionalProperties"])
977const DRAFT_04_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "additionalProperties", "contentSchema"])
978
979function convertSubschemaKeyword(
980 out: JsonSchema,
981 key: string,
982 value: unknown,
983 context: Context,
984 singleKeywords: ReadonlySet<string>,
985 mapKeywords: ReadonlySet<string> = SCHEMA_MAP_KEYWORDS
986): boolean {
987 let converted: unknown
988 if (mapKeywords.has(key)) converted = context.schemaMap(value, key)
989 else if (SCHEMA_ARRAY_KEYWORDS.has(key)) converted = context.schemaArray(value, key)
990 else if (singleKeywords.has(key)) converted = context.schema(value, key)
991 else return false
992 InternalRecord.assignProperty(out, key, converted)
993 return true
994}
995
996const PRE_2020_TO_2020_COLLISIONS = [
997 "$anchor",
998 "$defs",
999 "$dynamicAnchor",
1000 "$dynamicRef",
1001 "$vocabulary",
1002 "contentSchema",
1003 "dependentRequired",
1004 "dependentSchemas",
1005 "maxContains",
1006 "minContains",
1007 "prefixItems",
1008 "unevaluatedItems",
1009 "unevaluatedProperties"
1010]
1011const DRAFT_07_TO_2020_COLLISIONS = [...PRE_2020_TO_2020_COLLISIONS, "deprecated"]
1012const OPEN_API_30_TO_2020_COLLISIONS = [
1013 ...PRE_2020_TO_2020_COLLISIONS,
1014 "$comment",
1015 "$id",
1016 "$schema",
1017 "const",
1018 "contains",
1019 "contentEncoding",
1020 "contentMediaType",
1021 "else",
1022 "examples",
1023 "if",
1024 "patternProperties",
1025 "propertyNames",
1026 "then"
1027]
1028const ANCHOR_REGEXP = /^[A-Za-z_][-A-Za-z0-9._]*$/
1029const LEGACY_ID_FRAGMENT_REGEXP = /^[A-Za-z][-A-Za-z0-9._:]*$/
1030
1031function convertDraft07(root: JsonSchema): JsonSchema {
1032 return convertSchema(root, (source, context) => {
1033 const out: JsonSchema = {}
1034
1035 if (typeof source.$ref === "string") {
1036 context.reference(out, source.$ref)
1037 if (Object.hasOwn(source, "definitions")) {
1038 InternalRecord.assignProperty(out, "$defs", context.schemaMap(source.definitions, "definitions", "$defs"))
1039 }
1040 return out
1041 }
1042 rejectKeywordCollisions(source, DRAFT_07_TO_2020_COLLISIONS, "Draft 2020-12", "Draft-07")
1043
1044 let items: unknown = undefined
1045 let additionalItems: unknown = undefined
1046
1047 for (const key of Object.keys(source)) {
1048 const value = source[key]
1049 if (convertSubschemaKeyword(out, key, value, context, JSON_SCHEMA_SINGLE_KEYWORDS)) continue
1050 switch (key) {
1051 case "$schema":
1052 InternalRecord.assignProperty(
1053 out,
1054 key,
1055 isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_07) ? META_SCHEMA_URI_DRAFT_2020_12 : value
1056 )
1057 break
1058 case "$id":
1059 convertDraft07Id(out, value)
1060 break
1061 case "definitions":
1062 InternalRecord.assignProperty(out, "$defs", context.schemaMap(value, key, "$defs"))
1063 break
1064 case "dependencies": {
1065 if (!Predicate.isObject(value)) {
1066 InternalRecord.assignProperty(out, key, value)
1067 break
1068 }
1069 const dependentRequired: JsonSchema = {}
1070 const dependentSchemas: JsonSchema = {}
1071 for (const dependency of Object.keys(value)) {
1072 const dependencyValue = value[dependency]
1073 InternalRecord.assignProperty(
1074 Array.isArray(dependencyValue) ? dependentRequired : dependentSchemas,
1075 dependency,
1076 Array.isArray(dependencyValue)
1077 ? dependencyValue
1078 : context.schemaAt(
1079 dependencyValue,
1080 ["dependencies", dependency],
1081 ["dependentSchemas", dependency]
1082 )
1083 )
1084 }
1085 if (Object.keys(dependentRequired).length > 0) {
1086 InternalRecord.assignProperty(out, "dependentRequired", dependentRequired)
1087 }
1088 if (Object.keys(dependentSchemas).length > 0) {
1089 InternalRecord.assignProperty(out, "dependentSchemas", dependentSchemas)
1090 }
1091 break
1092 }
1093 case "items":
1094 items = value
1095 break
1096 case "additionalItems":
1097 additionalItems = value
1098 break
1099 default:
1100 InternalRecord.assignProperty(out, key, value)
1101 }
1102 }
1103
1104 if (items !== undefined) {
1105 if (Array.isArray(items)) {
1106 InternalRecord.assignProperty(out, "prefixItems", context.schemaArray(items, "items", "prefixItems"))
1107 if (additionalItems !== undefined) {
1108 InternalRecord.assignProperty(out, "items", context.schema(additionalItems, "additionalItems", "items"))
1109 }
1110 } else {
1111 InternalRecord.assignProperty(out, "items", context.schema(items, "items"))
1112 }
1113 }
1114
1115 return out
1116 }, { trackIds: true, ignoreRefSiblings: true })
1117}
1118
1119function convertDraft07Id(out: JsonSchema, value: unknown): void {
1120 if (typeof value !== "string" || !value.includes("#")) {
1121 InternalRecord.assignProperty(out, "$id", value)
1122 return
1123 }
1124 const fragmentIndex = value.indexOf("#")
1125 const id = value.slice(0, fragmentIndex)
1126 const anchor = value.slice(fragmentIndex + 1)
1127 if (anchor.length === 0) {
1128 if (id.length > 0) InternalRecord.assignProperty(out, "$id", id)
1129 return
1130 }
1131 if (!ANCHOR_REGEXP.test(anchor)) {
1132 unsupported("$id", "Draft 2020-12", `fragment "#${anchor}" is not a valid $anchor`)
1133 }
1134 if (id.length > 0) InternalRecord.assignProperty(out, "$id", id)
1135 InternalRecord.assignProperty(out, "$anchor", anchor)
1136}
1137
1138function unsupported(keyword: string, dialect: string, details: string): never {
1139 throw new Error(`Cannot convert JSON Schema keyword "${keyword}" to ${dialect}: ${details}`)
1140}
1141
1142function rejectKeywordCollisions(
1143 source: JsonSchema,
1144 keywords: ReadonlyArray<string>,
1145 targetDialect: string,
1146 sourceDialect: string
1147): void {
1148 for (const keyword of keywords) {
1149 if (Object.hasOwn(source, keyword)) {
1150 unsupported(keyword, targetDialect, `it is not active in ${sourceDialect} but would become active in the target`)
1151 }
1152 }
1153}
1154
1155const DRAFT_07_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies"]
1156
1157function convertMetaSchemaKeyword(
1158 out: JsonSchema,
1159 value: unknown,
1160 context: Context,
1161 targetUri: string,
1162 targetDialect: string
1163): void {
1164 if (context.isDocumentRoot) {
1165 InternalRecord.assignProperty(
1166 out,
1167 "$schema",
1168 isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_2020_12) ? targetUri : value
1169 )
1170 } else if (!isMetaSchemaUri(value, META_SCHEMA_URI_DRAFT_2020_12)) {
1171 unsupported("$schema", targetDialect, "an embedded resource cannot declare a different dialect")
1172 }
1173}
1174
1175function draft07Adapter(source: JsonSchema, context: Context): JsonSchema {
1176 rejectKeywordCollisions(source, DRAFT_07_TARGET_COLLISIONS, "Draft-07", "Draft 2020-12")
1177 const out: JsonSchema = {}
1178 let reference: unknown = undefined
1179 let prefixItems: unknown = undefined
1180 let items: unknown = undefined
1181
1182 for (const key of Object.keys(source)) {
1183 const value = source[key]
1184 if (convertSubschemaKeyword(out, key, value, context, JSON_SCHEMA_SINGLE_KEYWORDS)) continue
1185 switch (key) {
1186 case "$ref":
1187 reference = value
1188 break
1189 case "$schema":
1190 convertMetaSchemaKeyword(out, value, context, META_SCHEMA_URI_DRAFT_07, "Draft-07")
1191 break
1192 case "$id":
1193 case "$anchor":
1194 break
1195 case "$defs":
1196 InternalRecord.assignProperty(out, "definitions", context.schemaMap(value, key, "definitions"))
1197 break
1198 case "prefixItems":
1199 prefixItems = value
1200 break
1201 case "items":
1202 items = value
1203 break
1204 case "dependentRequired":
1205 case "dependentSchemas":
1206 case "minContains":
1207 case "maxContains":
1208 break
1209 case "$dynamicRef":
1210 case "$dynamicAnchor":
1211 case "$vocabulary":
1212 case "unevaluatedProperties":
1213 case "unevaluatedItems":
1214 unsupported(key, "Draft-07", "the target dialect has no equivalent")
1215 case "required":
1216 if (Array.isArray(value) && value.length === 0) break
1217 InternalRecord.assignProperty(out, key, value)
1218 break
1219 default:
1220 InternalRecord.assignProperty(out, key, value)
1221 }
1222 }
1223
1224 convertTuple(out, prefixItems, items, context)
1225
1226 if (Object.hasOwn(source, "contains")) {
1227 const minContains = source.minContains
1228 const maxContains = source.maxContains
1229 if ((minContains !== undefined && minContains !== 1) || maxContains !== undefined) {
1230 unsupported("minContains/maxContains", "Draft-07", "contains cardinality cannot be represented")
1231 }
1232 if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", minContains)
1233 } else {
1234 if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", source.minContains)
1235 if (Object.hasOwn(source, "maxContains")) InternalRecord.assignProperty(out, "maxContains", source.maxContains)
1236 }
1237
1238 convertDependencies(source, out, context, "draft-07")
1239 convertLegacyId(source, out, "$id", "Draft-07")
1240
1241 convertReference(out, reference, context)
1242
1243 return out
1244}
1245
1246function convertTuple(out: JsonSchema, prefixItems: unknown, items: unknown, context: Context): void {
1247 if (prefixItems === undefined) {
1248 if (items !== undefined) InternalRecord.assignProperty(out, "items", context.schema(items, "items"))
1249 return
1250 }
1251 InternalRecord.assignProperty(out, "items", context.schemaArray(prefixItems, "prefixItems", "items"))
1252 if (items !== undefined) {
1253 InternalRecord.assignProperty(out, "additionalItems", context.schema(items, "items", "additionalItems"))
1254 }
1255}
1256
1257function convertReference(out: JsonSchema, reference: unknown, context: Context): void {
1258 if (reference === undefined) return
1259 if (typeof reference === "string" && Object.keys(out).length > 0) {
1260 const referenceSchema: JsonSchema = {}
1261 context.reference(referenceSchema, reference)
1262 appendAllOf(out, referenceSchema)
1263 } else {
1264 context.reference(out, reference)
1265 }
1266}
1267
1268function convertDependencies(
1269 source: JsonSchema,
1270 out: JsonSchema,
1271 context: Context,
1272 targetDialect: "draft-04" | "draft-07"
1273): void {
1274 const dependentRequired = Predicate.isObject(source.dependentRequired) ? source.dependentRequired : undefined
1275 const dependentSchemas = Predicate.isObject(source.dependentSchemas) ? source.dependentSchemas : undefined
1276 if (dependentRequired === undefined && dependentSchemas === undefined) return
1277
1278 const dependencies: JsonSchema = {}
1279 const keys = new Set([
1280 ...Object.keys(dependentRequired ?? {}),
1281 ...Object.keys(dependentSchemas ?? {})
1282 ])
1283 for (const key of keys) {
1284 const required = dependentRequired?.[key]
1285 const dependency = dependentSchemas?.[key]
1286 const omitRequired = targetDialect === "draft-04" && Array.isArray(required) && required.length === 0
1287 if (dependency === undefined) {
1288 if (!omitRequired) InternalRecord.assignProperty(dependencies, key, required)
1289 } else if (required === undefined || omitRequired) {
1290 InternalRecord.assignProperty(
1291 dependencies,
1292 key,
1293 context.schemaAt(dependency, ["dependentSchemas", key], ["dependencies", key])
1294 )
1295 } else {
1296 InternalRecord.assignProperty(dependencies, key, {
1297 allOf: [
1298 context.schemaAt(dependency, ["dependentSchemas", key], ["dependencies", key, "allOf", "0"]),
1299 { required }
1300 ]
1301 })
1302 }
1303 }
1304 if (Object.keys(dependencies).length > 0) InternalRecord.assignProperty(out, "dependencies", dependencies)
1305}
1306
1307function convertOpenApi30(root: JsonSchema): JsonSchema {
1308 return convertSchema(root, (source, context) => {
1309 const out: JsonSchema = {}
1310
1311 if (typeof source.$ref === "string") {
1312 context.reference(out, rewriteOpenApiComponentsReference(source.$ref))
1313 return out
1314 }
1315 rejectKeywordCollisions(source, OPEN_API_30_TO_2020_COLLISIONS, "Draft 2020-12", "OpenAPI 3.0")
1316
1317 for (const key of Object.keys(source)) {
1318 const value = source[key]
1319 if (
1320 convertSubschemaKeyword(
1321 out,
1322 key,
1323 value,
1324 context,
1325 OPEN_API_30_SCHEMA_SINGLE_KEYWORDS,
1326 OPEN_API_30_SCHEMA_MAP_KEYWORDS
1327 )
1328 ) {
1329 continue
1330 }
1331 switch (key) {
1332 case "example":
1333 InternalRecord.assignProperty(out, "examples", [value])
1334 break
1335 case "nullable":
1336 case "exclusiveMinimum":
1337 case "exclusiveMaximum":
1338 break
1339 default:
1340 InternalRecord.assignProperty(out, key, value)
1341 }
1342 }
1343
1344 convertOpenApiExclusiveBound(source, out, "minimum")
1345 convertOpenApiExclusiveBound(source, out, "maximum")
1346
1347 if (source.nullable === true && typeof source.type === "string") {
1348 InternalRecord.assignProperty(out, "type", [source.type, "null"])
1349 }
1350
1351 return out
1352 })
1353}
1354
1355function convertOpenApiExclusiveBound(
1356 source: JsonSchema,
1357 out: JsonSchema,
1358 boundKey: "minimum" | "maximum"
1359): void {
1360 const exclusiveKey = boundKey === "minimum" ? "exclusiveMinimum" : "exclusiveMaximum"
1361 const exclusive = source[exclusiveKey]
1362 if (typeof exclusive !== "boolean") {
1363 if (exclusive !== undefined) InternalRecord.assignProperty(out, exclusiveKey, exclusive)
1364 return
1365 }
1366 if (exclusive && typeof source[boundKey] === "number") {
1367 InternalRecord.assignProperty(out, exclusiveKey, source[boundKey])
1368 delete out[boundKey]
1369 }
1370}
1371
1372const DRAFT_04_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies", "id"]
1373
1374function draft04Adapter(source: JsonSchema, context: Context): JsonSchema {
1375 rejectKeywordCollisions(source, DRAFT_04_TARGET_COLLISIONS, "Draft-04", "Draft 2020-12")
1376 const out: JsonSchema = {}
1377 let reference: unknown = undefined
1378 let prefixItems: unknown = undefined
1379 let items: unknown = undefined
1380 let constSchema: JsonSchema | undefined
1381
1382 for (const key of Object.keys(source)) {
1383 const value = source[key]
1384 if (convertSubschemaKeyword(out, key, value, context, DRAFT_04_SCHEMA_SINGLE_KEYWORDS)) continue
1385 switch (key) {
1386 case "$ref":
1387 reference = value
1388 break
1389 case "$schema":
1390 convertMetaSchemaKeyword(out, value, context, META_SCHEMA_URI_DRAFT_04, "Draft-04")
1391 break
1392 case "$id":
1393 case "$anchor":
1394 break
1395 case "$defs":
1396 InternalRecord.assignProperty(out, "definitions", context.schemaMap(value, key, "definitions"))
1397 break
1398 case "prefixItems":
1399 prefixItems = value
1400 break
1401 case "items":
1402 items = value
1403 break
1404 case "$dynamicRef":
1405 case "$dynamicAnchor":
1406 case "$vocabulary":
1407 case "unevaluatedProperties":
1408 case "unevaluatedItems":
1409 case "propertyNames":
1410 unsupported(key, "Draft-04", "the target dialect has no equivalent")
1411 case "dependentRequired":
1412 case "dependentSchemas":
1413 case "contains":
1414 case "minContains":
1415 case "maxContains":
1416 case "if":
1417 case "then":
1418 case "else":
1419 case "minimum":
1420 case "maximum":
1421 case "exclusiveMinimum":
1422 case "exclusiveMaximum":
1423 break
1424 case "const":
1425 constSchema = { enum: [value] }
1426 break
1427 case "required":
1428 if (Array.isArray(value) && value.length === 0) break
1429 InternalRecord.assignProperty(out, key, value)
1430 break
1431 default:
1432 InternalRecord.assignProperty(out, key, value)
1433 }
1434 }
1435
1436 convertTuple(out, prefixItems, items, context)
1437
1438 convertDraft04ExclusiveBound(source, out, "minimum")
1439 convertDraft04ExclusiveBound(source, out, "maximum")
1440 convertDependencies(source, out, context, "draft-04")
1441 convertLegacyId(source, out, "id", "Draft-04")
1442 convertDraft04Conditionals(source, out, context)
1443 convertDraft04Contains(source, out, context)
1444
1445 if (constSchema !== undefined) {
1446 if (Object.hasOwn(source, "enum")) {
1447 appendAllOf(out, constSchema)
1448 } else {
1449 InternalRecord.assignProperty(out, "enum", constSchema.enum)
1450 }
1451 }
1452
1453 convertReference(out, reference, context)
1454
1455 return out
1456}
1457
1458function convertDraft04Conditionals(source: JsonSchema, out: JsonSchema, context: Context): void {
1459 const hasIf = Object.hasOwn(source, "if")
1460 const hasThen = Object.hasOwn(source, "then")
1461 const hasElse = Object.hasOwn(source, "else")
1462 if (!hasIf || (!hasThen && !hasElse)) {
1463 if (hasIf) InternalRecord.assignProperty(out, "if", context.schema(source.if, "if"))
1464 if (hasThen) InternalRecord.assignProperty(out, "then", context.schema(source.then, "then"))
1465 if (hasElse) InternalRecord.assignProperty(out, "else", context.schema(source.else, "else"))
1466 return
1467 }
1468
1469 const index = Array.isArray(out.allOf) ? out.allOf.length : 0
1470 const base = ["allOf", String(index), "anyOf"] as const
1471 const convertBranch = (key: "if" | "then" | "else", targetPath: Path): unknown =>
1472 context.schemaAt(source[key], [key], [...base, ...targetPath])
1473 let conditional: JsonSchema
1474 if (hasThen && hasElse) {
1475 if (hasSchemaIdentifier(source.if)) {
1476 unsupported("if", "Draft-04", "lowering both branches would duplicate a schema identifier")
1477 }
1478 // (if AND then) OR ((NOT if) AND else)
1479 conditional = {
1480 anyOf: [
1481 {
1482 allOf: [
1483 convertBranch("if", ["0", "allOf", "0"]),
1484 convertBranch("then", ["0", "allOf", "1"])
1485 ]
1486 },
1487 {
1488 allOf: [
1489 { not: convertBranch("if", ["1", "allOf", "0", "not"]) },
1490 convertBranch("else", ["1", "allOf", "1"])
1491 ]
1492 }
1493 ]
1494 }
1495 } else if (hasThen) {
1496 // (NOT if) OR then
1497 conditional = {
1498 anyOf: [
1499 { not: convertBranch("if", ["0", "not"]) },
1500 convertBranch("then", ["1"])
1501 ]
1502 }
1503 } else {
1504 // if OR else
1505 conditional = {
1506 anyOf: [
1507 convertBranch("if", ["0"]),
1508 convertBranch("else", ["1"])
1509 ]
1510 }
1511 }
1512 appendAllOf(out, conditional)
1513}
1514
1515function hasSchemaIdentifier(node: unknown): boolean {
1516 if (!Predicate.isObject(node)) return false
1517 if (Object.hasOwn(node, "$id") || Object.hasOwn(node, "$anchor")) return true
1518 for (const key of Object.keys(node)) {
1519 const value = node[key]
1520 switch (key) {
1521 case "$defs":
1522 case "properties":
1523 case "patternProperties":
1524 case "dependentSchemas":
1525 if (Predicate.isObject(value) && Object.values(value).some(hasSchemaIdentifier)) return true
1526 break
1527 case "allOf":
1528 case "anyOf":
1529 case "oneOf":
1530 case "prefixItems":
1531 if (Array.isArray(value) && value.some(hasSchemaIdentifier)) return true
1532 break
1533 case "not":
1534 case "additionalProperties":
1535 case "propertyNames":
1536 case "unevaluatedProperties":
1537 case "items":
1538 case "contains":
1539 case "unevaluatedItems":
1540 case "if":
1541 case "then":
1542 case "else":
1543 case "contentSchema":
1544 if (hasSchemaIdentifier(value)) return true
1545 }
1546 }
1547 return false
1548}
1549
1550function convertDraft04Contains(source: JsonSchema, out: JsonSchema, context: Context): void {
1551 if (!Object.hasOwn(source, "contains")) {
1552 if (Object.hasOwn(source, "minContains")) InternalRecord.assignProperty(out, "minContains", source.minContains)
1553 if (Object.hasOwn(source, "maxContains")) InternalRecord.assignProperty(out, "maxContains", source.maxContains)
1554 return
1555 }
1556
1557 const minContains = source.minContains
1558 const maxContains = source.maxContains
1559 if ((minContains !== undefined && minContains !== 1) || maxContains !== undefined) {
1560 unsupported("minContains/maxContains", "Draft-04", "contains cardinality cannot be represented")
1561 }
1562
1563 const index = Array.isArray(out.allOf) ? out.allOf.length : 0
1564 const contains = context.schemaAt(
1565 source.contains,
1566 ["contains"],
1567 ["allOf", String(index), "anyOf", "1", "not", "items", "not"]
1568 )
1569 appendAllOf(out, {
1570 anyOf: [
1571 { not: { type: "array" } },
1572 { not: { items: { not: contains } } }
1573 ]
1574 })
1575}
1576
1577function appendAllOf(out: JsonSchema, schema: JsonSchema): void {
1578 if (Array.isArray(out.allOf)) out.allOf.push(schema)
1579 else InternalRecord.assignProperty(out, "allOf", [schema])
1580}
1581
1582function convertLegacyId(
1583 source: JsonSchema,
1584 out: JsonSchema,
1585 targetKey: "$id" | "id",
1586 dialect: string
1587): void {
1588 const id = source.$id
1589 const anchor = source.$anchor
1590 if (anchor === undefined) {
1591 if (id !== undefined) InternalRecord.assignProperty(out, targetKey, id)
1592 return
1593 }
1594 if (typeof anchor !== "string" || !ANCHOR_REGEXP.test(anchor)) {
1595 unsupported("$anchor", dialect, "the anchor is not valid")
1596 }
1597 if (!LEGACY_ID_FRAGMENT_REGEXP.test(anchor)) {
1598 unsupported("$anchor", dialect, "the anchor cannot be represented as a plain-name fragment identifier")
1599 }
1600 if (id === undefined) {
1601 InternalRecord.assignProperty(out, targetKey, `#${anchor}`)
1602 } else {
1603 unsupported("$anchor", dialect, "it cannot be combined with the schema $id")
1604 }
1605}
1606
1607function convertDraft04ExclusiveBound(
1608 source: JsonSchema,
1609 out: JsonSchema,
1610 boundKey: "minimum" | "maximum"
1611): void {
1612 const exclusiveKey = boundKey === "minimum" ? "exclusiveMinimum" : "exclusiveMaximum"
1613 const bound = source[boundKey]
1614 const exclusive = source[exclusiveKey]
1615 if (typeof exclusive === "number") {
1616 const isBoundStricter = typeof bound === "number" &&
1617 (boundKey === "minimum" ? bound > exclusive : bound < exclusive)
1618 if (isBoundStricter) {
1619 InternalRecord.assignProperty(out, boundKey, bound)
1620 } else {
1621 InternalRecord.assignProperty(out, boundKey, exclusive)
1622 InternalRecord.assignProperty(out, exclusiveKey, true)
1623 }
1624 } else if (bound !== undefined) {
1625 InternalRecord.assignProperty(out, boundKey, bound)
1626 }
1627}