| 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 | */ |
| 11 | import * as InternalRecord from "./internal/record.ts" |
| 12 | import { formatUriFragment, parseUriFragment } from "./JsonPointer.ts" |
| 13 | import * 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 | */ |
| 30 | export 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 | */ |
| 55 | export 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 | */ |
| 67 | export 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 | */ |
| 87 | export 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 | */ |
| 129 | export 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 | */ |
| 153 | export 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 | */ |
| 171 | export 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 | */ |
| 192 | export 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 | */ |
| 212 | export const META_SCHEMA_URI_DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema" |
| 213 | |
| 214 | const META_SCHEMA_URI_OPEN_API_3_1 = "https://spec.openapis.org/oas/3.1/dialect/base" |
| 215 | |
| 216 | function isMetaSchemaUri(value: unknown, uri: string): boolean { |
| 217 | return value === uri || value === (uri.endsWith("#") ? uri.slice(0, -1) : `${uri}#`) |
| 218 | } |
| 219 | |
| 220 | function 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 | |
| 227 | const 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 | */ |
| 282 | export 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 | */ |
| 319 | export 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 | */ |
| 371 | export 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 | */ |
| 432 | export 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 | */ |
| 486 | export 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 | */ |
| 538 | export 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 | */ |
| 598 | export 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 */ |
| 671 | export 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 | */ |
| 679 | export function sanitizeOpenApiComponentsSchemasKey(s: string): string { |
| 680 | return s.length === 0 ? "_" : s.replace(/[^a-zA-Z0-9._-]/gu, "_") |
| 681 | } |
| 682 | |
| 683 | /** @internal */ |
| 684 | export 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 | |
| 691 | function 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 */ |
| 739 | export function rewriteRefs(schema: JsonSchema, rewrite: ($ref: string) => string): JsonSchema { |
| 740 | return transformSchema(schema, (schema) => { |
| 741 | rewriteSchemaRef(schema, rewrite) |
| 742 | }) as JsonSchema |
| 743 | } |
| 744 | |
| 745 | function 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 | |
| 757 | function 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 | |
| 769 | type Path = ReadonlyArray<string> |
| 770 | type Convert = (root: unknown, sourcePath?: Path, targetPath?: Path) => unknown |
| 771 | |
| 772 | interface 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 | |
| 781 | type Adapter = (schema: JsonSchema, context: Context) => JsonSchema |
| 782 | |
| 783 | type PendingReference = readonly [out: JsonSchema, value: string, sourceResource: string] |
| 784 | |
| 785 | interface ResourceScope { |
| 786 | readonly parent?: ResourceScope |
| 787 | readonly sourceRoot: Path |
| 788 | readonly targetRoot: Path |
| 789 | readonly uri: string |
| 790 | } |
| 791 | |
| 792 | interface 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. |
| 800 | function 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 | |
| 916 | const ROOT_URI = "https://effect.invalid/.json-schema/" |
| 917 | |
| 918 | function resolveUrl(value: string, base: string): URL | undefined { |
| 919 | return URL.canParse(value, base) ? new URL(value, base) : undefined |
| 920 | } |
| 921 | |
| 922 | function 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 | |
| 930 | function 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 | |
| 937 | function locationKey(resource: string, pointer: Path): string { |
| 938 | return `${resource}\u0000${JSON.stringify(pointer)}` |
| 939 | } |
| 940 | |
| 941 | function createsResource(id: unknown): boolean { |
| 942 | return typeof id === "string" && id.length > 0 && id[0] !== "#" |
| 943 | } |
| 944 | |
| 945 | function convertSchema(root: JsonSchema, adapter: Adapter, options?: ConverterOptions): JsonSchema { |
| 946 | return runConverter(adapter, options, (convert) => convert(root) as JsonSchema) |
| 947 | } |
| 948 | |
| 949 | function 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 | |
| 963 | const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties"]) |
| 964 | const SCHEMA_ARRAY_KEYWORDS = new Set(["allOf", "anyOf", "oneOf"]) |
| 965 | const JSON_SCHEMA_SINGLE_KEYWORDS = new Set([ |
| 966 | "not", |
| 967 | "additionalProperties", |
| 968 | "propertyNames", |
| 969 | "contains", |
| 970 | "if", |
| 971 | "then", |
| 972 | "else", |
| 973 | "contentSchema" |
| 974 | ]) |
| 975 | const OPEN_API_30_SCHEMA_MAP_KEYWORDS = new Set(["properties"]) |
| 976 | const OPEN_API_30_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "items", "additionalProperties"]) |
| 977 | const DRAFT_04_SCHEMA_SINGLE_KEYWORDS = new Set(["not", "additionalProperties", "contentSchema"]) |
| 978 | |
| 979 | function 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 | |
| 996 | const 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 | ] |
| 1011 | const DRAFT_07_TO_2020_COLLISIONS = [...PRE_2020_TO_2020_COLLISIONS, "deprecated"] |
| 1012 | const 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 | ] |
| 1028 | const ANCHOR_REGEXP = /^[A-Za-z_][-A-Za-z0-9._]*$/ |
| 1029 | const LEGACY_ID_FRAGMENT_REGEXP = /^[A-Za-z][-A-Za-z0-9._:]*$/ |
| 1030 | |
| 1031 | function 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 | |
| 1119 | function 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 | |
| 1138 | function unsupported(keyword: string, dialect: string, details: string): never { |
| 1139 | throw new Error(`Cannot convert JSON Schema keyword "${keyword}" to ${dialect}: ${details}`) |
| 1140 | } |
| 1141 | |
| 1142 | function 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 | |
| 1155 | const DRAFT_07_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies"] |
| 1156 | |
| 1157 | function 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 | |
| 1175 | function 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 | |
| 1246 | function 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 | |
| 1257 | function 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 | |
| 1268 | function 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 | |
| 1307 | function 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 | |
| 1355 | function 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 | |
| 1372 | const DRAFT_04_TARGET_COLLISIONS = ["additionalItems", "definitions", "dependencies", "id"] |
| 1373 | |
| 1374 | function 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 | |
| 1458 | function 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 | |
| 1515 | function 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 | |
| 1550 | function 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 | |
| 1577 | function 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 | |
| 1582 | function 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 | |
| 1607 | function 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 | } |