| 1 | import * as Arr from "../../Array.ts" |
| 2 | import * as Equal from "../../Equal.ts" |
| 3 | import { decodeUriFragment, formatUriFragmentToken, unescapeToken } from "../../JsonPointer.ts" |
| 4 | import type * as JsonSchema from "../../JsonSchema.ts" |
| 5 | import { rewriteRefs } from "../../JsonSchema.ts" |
| 6 | import * as RegEx from "../../RegExp.ts" |
| 7 | import type * as Schema from "../../Schema.ts" |
| 8 | import * as InternalAST from "../../SchemaAST.ts" |
| 9 | import type * as SchemaRepresentation from "../../SchemaRepresentation.ts" |
| 10 | import { errorWithPath } from "../errors.ts" |
| 11 | import * as InternalRecord from "../record.ts" |
| 12 | import * as InternalAnnotations from "./annotations.ts" |
| 13 | |
| 14 | type Path = ReadonlyArray<string | number> |
| 15 | |
| 16 | function formatDefinitionReference(key: string): string { |
| 17 | return `#/$defs/${formatUriFragmentToken(key)}` |
| 18 | } |
| 19 | |
| 20 | const jsonSchemaAnnotationExcludedKeys = new Set([ |
| 21 | ...InternalAnnotations.annotationExcludedKeys, |
| 22 | InternalAnnotations.IDENTIFIER_FALLBACK_KEY, |
| 23 | ...InternalAnnotations.jsonSchemaAnnotationKeys |
| 24 | ]) |
| 25 | |
| 26 | function collectJsonSchemaAnnotations( |
| 27 | annotations: Schema.Annotations.Annotations | undefined, |
| 28 | options: Schema.ToJsonSchemaOptions | undefined |
| 29 | ): JsonSchema.JsonSchema | undefined { |
| 30 | if (annotations === undefined) return undefined |
| 31 | |
| 32 | const out: JsonSchema.JsonSchema = {} |
| 33 | const title = annotations.title |
| 34 | if (typeof title === "string") out.title = title |
| 35 | const description = annotations.description |
| 36 | const expected = annotations.expected |
| 37 | if (typeof description === "string") out.description = description |
| 38 | else if (options?.generateDescriptions === true && typeof expected === "string") out.description = expected |
| 39 | |
| 40 | const defaultValue = annotations.default |
| 41 | if (InternalAST.isJson(defaultValue)) out.default = defaultValue |
| 42 | const examples = annotations.examples |
| 43 | if (Array.isArray(examples) && InternalAST.isJson(examples)) out.examples = examples |
| 44 | const readOnly = annotations.readOnly |
| 45 | if (typeof readOnly === "boolean") out.readOnly = readOnly |
| 46 | const writeOnly = annotations.writeOnly |
| 47 | if (typeof writeOnly === "boolean") out.writeOnly = writeOnly |
| 48 | const format = annotations.format |
| 49 | if (typeof format === "string") out.format = format |
| 50 | const contentEncoding = annotations.contentEncoding |
| 51 | if (typeof contentEncoding === "string") out.contentEncoding = contentEncoding |
| 52 | const contentMediaType = annotations.contentMediaType |
| 53 | if (typeof contentMediaType === "string") out.contentMediaType = contentMediaType |
| 54 | const contentSchema = annotations.contentSchema |
| 55 | if (InternalAST.isJson(contentSchema)) out.contentSchema = contentSchema |
| 56 | |
| 57 | if (options?.includeAnnotationKey !== undefined) { |
| 58 | for (const [key, value] of Object.entries(annotations)) { |
| 59 | if ( |
| 60 | jsonSchemaAnnotationExcludedKeys.has(key) || |
| 61 | !options.includeAnnotationKey(key) |
| 62 | ) { |
| 63 | continue |
| 64 | } |
| 65 | if (InternalAST.isJson(value)) InternalRecord.assignProperty(out, key, value) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | return Object.keys(out).length === 0 ? undefined : out |
| 70 | } |
| 71 | |
| 72 | type JsonSchemaNumberType = "number" | "integer" |
| 73 | |
| 74 | function extractJsonSchemaNumberType(schema: JsonSchema.JsonSchema): { |
| 75 | readonly type: JsonSchemaNumberType | undefined |
| 76 | readonly schema: JsonSchema.JsonSchema |
| 77 | } { |
| 78 | let type: JsonSchemaNumberType | undefined = schema.type === "number" || schema.type === "integer" |
| 79 | ? schema.type |
| 80 | : undefined |
| 81 | let out = schema |
| 82 | if (type !== undefined) { |
| 83 | out = { ...schema } |
| 84 | delete out.type |
| 85 | } |
| 86 | if (Array.isArray(out.allOf)) { |
| 87 | const members: Array<JsonSchema.JsonSchema> = [] |
| 88 | let changed = false |
| 89 | for (const member of out.allOf) { |
| 90 | const extracted = extractJsonSchemaNumberType(member) |
| 91 | if (extracted.type !== undefined) { |
| 92 | changed = true |
| 93 | if (type === undefined || extracted.type === "integer") type = extracted.type |
| 94 | } |
| 95 | if (Object.keys(extracted.schema).length > 0) members.push(extracted.schema) |
| 96 | } |
| 97 | if (changed) { |
| 98 | const { allOf: _, ...rest } = out |
| 99 | out = members.length === 0 ? rest : { ...rest, allOf: members } |
| 100 | } |
| 101 | } |
| 102 | return { type, schema: out } |
| 103 | } |
| 104 | |
| 105 | function isJsonSchemaNumberEncoding(schema: JsonSchema.JsonSchema): boolean { |
| 106 | if (!Array.isArray(schema.anyOf) || schema.anyOf.length !== 2 || schema.anyOf[0]?.type !== "number") return false |
| 107 | const special = schema.anyOf[1] |
| 108 | return special?.type === "string" && Array.isArray(special.enum) && special.enum.length === 3 && |
| 109 | special.enum.includes("NaN") && special.enum.includes("Infinity") && special.enum.includes("-Infinity") |
| 110 | } |
| 111 | |
| 112 | // Keep this allowlist closed: applicators and dependent keywords can change meaning when moved across schema objects. |
| 113 | const inlineableCheckKeywords = |
| 114 | "|type|format|pattern|multipleOf|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength|minItems|maxItems|uniqueItems|minProperties|maxProperties|propertyNames|" |
| 115 | |
| 116 | function hasOnlyKeywords(schema: JsonSchema.JsonSchema, allowed: string): boolean { |
| 117 | return Object.keys(schema).every((key) => allowed.includes(`|${key}|`)) |
| 118 | } |
| 119 | |
| 120 | function hasNoCollisions(left: JsonSchema.JsonSchema, rightKeys: ReadonlyArray<string>): boolean { |
| 121 | return typeof left.$ref !== "string" && rightKeys.every((key) => !Object.hasOwn(left, key)) |
| 122 | } |
| 123 | |
| 124 | // `format` and `content*` can affect validation, so they are not treated as pure annotations. |
| 125 | const promotableAnnotationKeywords = "|title|description|default|examples|readOnly|writeOnly|" |
| 126 | const inlineableAnnotatedCheckKeywords = inlineableCheckKeywords + promotableAnnotationKeywords |
| 127 | |
| 128 | function appendJsonSchema( |
| 129 | left: JsonSchema.JsonSchema, |
| 130 | right: JsonSchema.JsonSchema, |
| 131 | inlineCheck?: true |
| 132 | ): JsonSchema.JsonSchema { |
| 133 | if (Object.keys(left).length === 0) return right |
| 134 | const rightKeys = Object.keys(right) |
| 135 | if (rightKeys.length === 0) return left |
| 136 | const leftType = left.type === "number" || left.type === "integer" ? left.type : undefined |
| 137 | const isNumberEncoding = isJsonSchemaNumberEncoding(left) |
| 138 | if (leftType !== undefined || isNumberEncoding) { |
| 139 | const extracted = extractJsonSchemaNumberType(right) |
| 140 | if (extracted.type !== undefined) { |
| 141 | const type = leftType === "integer" || extracted.type === "integer" ? "integer" : "number" |
| 142 | const base: JsonSchema.JsonSchema = { ...left, type } |
| 143 | if (isNumberEncoding) delete base.anyOf |
| 144 | const extractedKeys = Object.keys(extracted.schema) |
| 145 | if (extractedKeys.length === 0) return base |
| 146 | return hasOnlyKeywords(extracted.schema, promotableAnnotationKeywords) && |
| 147 | hasNoCollisions(base, extractedKeys) |
| 148 | ? { ...base, ...extracted.schema } |
| 149 | : appendJsonSchema(base, extracted.schema, inlineCheck) |
| 150 | } |
| 151 | } |
| 152 | if (inlineCheck && hasNoCollisions(left, rightKeys)) { |
| 153 | return { ...left, ...right } |
| 154 | } |
| 155 | const members = Array.isArray(right.allOf) && rightKeys.length === 1 ? right.allOf : [right] |
| 156 | if (Array.isArray(left.allOf)) { |
| 157 | return { ...left, allOf: [...left.allOf, ...members] } |
| 158 | } |
| 159 | if (typeof left.$ref === "string") { |
| 160 | return { allOf: [left, ...members] } |
| 161 | } |
| 162 | return { ...left, allOf: members } |
| 163 | } |
| 164 | |
| 165 | // Exactness forms a dependency graph because definitions can be recursive. A |
| 166 | // cycle is exact unless it can reach `false`. |
| 167 | type Exactness = boolean | ReadonlyArray<Exactness> |
| 168 | |
| 169 | type CompiledJsonSchema = [schema: JsonSchema.JsonSchema, dependencies: Array<Exactness>] |
| 170 | |
| 171 | function isExact(exactness: Exactness, seen = new Set<ReadonlyArray<Exactness>>()): boolean { |
| 172 | if (typeof exactness === "boolean") return exactness |
| 173 | if (seen.has(exactness)) return true |
| 174 | seen.add(exactness) |
| 175 | return exactness.every((dependency) => isExact(dependency, seen)) |
| 176 | } |
| 177 | |
| 178 | function isApproximateCheckOutput( |
| 179 | output: SchemaRepresentation.ToJsonSchema.CheckOutput |
| 180 | ): output is readonly [schema: JsonSchema.JsonSchema, approximate: true] { |
| 181 | return Array.isArray(output) |
| 182 | } |
| 183 | |
| 184 | function compileJsonSchema( |
| 185 | representations: readonly [ |
| 186 | SchemaRepresentation.Representation, |
| 187 | ...Array<SchemaRepresentation.Representation> |
| 188 | ], |
| 189 | rootPaths: ReadonlyArray<Path>, |
| 190 | references: SchemaRepresentation.References, |
| 191 | options: Schema.ToJsonSchemaOptions | undefined |
| 192 | ): JsonSchema.MultiDocument<"draft-2020-12"> { |
| 193 | const compiledDefinitions = new Map<string, CompiledJsonSchema>() |
| 194 | const definitionAliases = new Map<string, string>() |
| 195 | const compiledRepresentations = new WeakMap<SchemaRepresentation.Representation, CompiledJsonSchema>() |
| 196 | const pendingOneOf = new Map< |
| 197 | ReadonlyArray<JsonSchema.JsonSchema>, |
| 198 | readonly [branches: ReadonlyArray<Exactness>, schemas: Array<JsonSchema.JsonSchema>] |
| 199 | >() |
| 200 | const fallbackDefinitions = new Map<string, Array<string>>() |
| 201 | const referenceKeys = Object.keys(references) |
| 202 | for (const key of referenceKeys) { |
| 203 | compileDefinition(key, ["references", key]) |
| 204 | } |
| 205 | const compiledSchemas = Arr.map( |
| 206 | representations, |
| 207 | (representation, index) => recur(representation, rootPaths[index], []) |
| 208 | ) |
| 209 | for (const [branches, schemas] of pendingOneOf.values()) { |
| 210 | if (!isExact(branches)) { |
| 211 | for (const schema of schemas) { |
| 212 | if (Array.isArray(schema.oneOf)) { |
| 213 | schema.anyOf = schema.oneOf |
| 214 | delete schema.oneOf |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | for (const key of referenceKeys) { |
| 220 | const representation = references[key] |
| 221 | const fallback = getIdentifierFallback(representation) |
| 222 | if (fallback === undefined) continue |
| 223 | const schema = compiledDefinitions.get(key)![0] |
| 224 | const candidates = fallbackDefinitions.get(fallback) |
| 225 | const match = candidates?.find((candidate) => Equal.equals(compiledDefinitions.get(candidate)![0], schema)) |
| 226 | if (match === undefined) { |
| 227 | if (candidates === undefined) fallbackDefinitions.set(fallback, [key]) |
| 228 | else candidates.push(key) |
| 229 | } else { |
| 230 | definitionAliases.set(key, match) |
| 231 | } |
| 232 | } |
| 233 | const schemas = Arr.map(compiledSchemas, finalizeJsonSchema) |
| 234 | const definitions: Record<string, JsonSchema.JsonSchema> = {} |
| 235 | for (const key of referenceKeys) { |
| 236 | if (!definitionAliases.has(key)) { |
| 237 | InternalRecord.assignProperty(definitions, key, finalizeJsonSchema(compiledDefinitions.get(key)![0])) |
| 238 | } |
| 239 | } |
| 240 | return { dialect: "draft-2020-12", schemas, definitions } |
| 241 | |
| 242 | function registerPendingOneOf(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema { |
| 243 | if (Array.isArray(schema.oneOf)) pendingOneOf.get(schema.oneOf)?.[1].push(schema) |
| 244 | return schema |
| 245 | } |
| 246 | |
| 247 | function compileDefinition(key: string, path: Path): CompiledJsonSchema { |
| 248 | if (!Object.hasOwn(references, key)) { |
| 249 | throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) |
| 250 | } |
| 251 | const cached = compiledDefinitions.get(key) |
| 252 | if (cached !== undefined) return cached |
| 253 | |
| 254 | // Register the dependency cell before compiling so recursive references share it. |
| 255 | const result: CompiledJsonSchema = [{}, []] |
| 256 | compiledDefinitions.set(key, result) |
| 257 | result[0] = recur(references[key], ["references", key], result[1]) |
| 258 | return result |
| 259 | } |
| 260 | |
| 261 | function finalizeJsonSchema(schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema { |
| 262 | return rewriteRefs(schema, ($ref) => { |
| 263 | const pointer = decodeUriFragment($ref) |
| 264 | if (pointer === undefined) { |
| 265 | if (/^#(?:\/|%2f)/i.test($ref)) { |
| 266 | throw new Error(`Invalid JSON Pointer URI fragment ${JSON.stringify($ref)}`) |
| 267 | } |
| 268 | return $ref |
| 269 | } |
| 270 | if (!pointer.startsWith("/$defs/")) return $ref |
| 271 | const separator = pointer.indexOf("/", 7) |
| 272 | const canonical = definitionAliases.get( |
| 273 | unescapeToken(pointer.slice(7, separator < 0 ? undefined : separator)) |
| 274 | ) |
| 275 | if (canonical === undefined) return $ref |
| 276 | // URI-encoded slashes separate pointer tokens; only ~1 represents a slash within a token. |
| 277 | return $ref.replace( |
| 278 | /^#(?:\/|%2f).*?(?:\/|%2f).*?(?=\/|%2f|$)/i, |
| 279 | formatDefinitionReference(canonical) |
| 280 | ) |
| 281 | }) |
| 282 | } |
| 283 | |
| 284 | function getIdentifierFallback( |
| 285 | representation: SchemaRepresentation.Representation |
| 286 | ): string | undefined { |
| 287 | if (representation._tag === "Reference") return undefined |
| 288 | const annotations = representation.checks.length === 0 |
| 289 | ? representation.annotations |
| 290 | : representation.checks[representation.checks.length - 1].annotations |
| 291 | return typeof annotations?.identifier !== "string" && |
| 292 | typeof annotations?.[InternalAnnotations.IDENTIFIER_FALLBACK_KEY] === "string" |
| 293 | ? annotations[InternalAnnotations.IDENTIFIER_FALLBACK_KEY] |
| 294 | : undefined |
| 295 | } |
| 296 | |
| 297 | function compileCheck( |
| 298 | check: SchemaRepresentation.Check, |
| 299 | type: JsonSchema.Type | undefined, |
| 300 | path: Path, |
| 301 | dependencies: Array<Exactness> |
| 302 | ): readonly [schema: JsonSchema.JsonSchema, inline?: true | undefined] | undefined { |
| 303 | const annotations = check.annotations |
| 304 | const callback = annotations?.toJsonSchema |
| 305 | if (callback !== undefined) { |
| 306 | const schemas = check.representation?.schemas?.map((schema, index) => |
| 307 | recur(schema, [...path, "representation", "schemas", index], dependencies) |
| 308 | ) ?? [] |
| 309 | const result = (callback as SchemaRepresentation.ToJsonSchema.Check)({ type, schemas }) |
| 310 | const approximate = isApproximateCheckOutput(result) |
| 311 | if (approximate) { |
| 312 | dependencies.push(false) |
| 313 | } |
| 314 | const fragment = approximate ? result[0] : result |
| 315 | const ordinary = collectJsonSchemaAnnotations(annotations, options) |
| 316 | const schema = ordinary === undefined ? fragment : { ...fragment, ...ordinary } |
| 317 | const allowed = ordinary === undefined ? inlineableCheckKeywords : inlineableAnnotatedCheckKeywords |
| 318 | return [ |
| 319 | schema, |
| 320 | check._tag === "Filter" && |
| 321 | hasOnlyKeywords(schema, allowed) && |
| 322 | (ordinary === undefined || hasOnlyKeywords(ordinary, promotableAnnotationKeywords)) |
| 323 | ? true |
| 324 | : undefined |
| 325 | ] |
| 326 | } |
| 327 | if (check._tag === "Filter") { |
| 328 | dependencies.push(false) |
| 329 | return undefined |
| 330 | } |
| 331 | |
| 332 | const schemas = check.checks.flatMap((child, index) => { |
| 333 | const result = compileCheck(child, type, [...path, "checks", index], dependencies) |
| 334 | return result === undefined ? [] : [result[0]] |
| 335 | }) |
| 336 | if (schemas.length === 0) return undefined |
| 337 | const ordinary = collectJsonSchemaAnnotations(annotations, options) |
| 338 | const schema = { allOf: schemas } |
| 339 | return [ordinary === undefined ? schema : { ...schema, ...ordinary }] |
| 340 | } |
| 341 | |
| 342 | function recur( |
| 343 | representation: SchemaRepresentation.Representation, |
| 344 | path: Path, |
| 345 | dependencies: Array<Exactness> |
| 346 | ): JsonSchema.JsonSchema { |
| 347 | if (representation._tag === "Reference") { |
| 348 | dependencies.push(compileDefinition(representation.$ref, path)[1]) |
| 349 | return { $ref: formatDefinitionReference(representation.$ref) } |
| 350 | } |
| 351 | const cached = compiledRepresentations.get(representation) |
| 352 | if (cached !== undefined) { |
| 353 | dependencies.push(cached[1]) |
| 354 | return cached[0] |
| 355 | } |
| 356 | |
| 357 | const local: Array<Exactness> = [] |
| 358 | dependencies.push(local) |
| 359 | let output = on(representation, path, local) |
| 360 | const ordinary = collectJsonSchemaAnnotations(representation.annotations, options) |
| 361 | if (ordinary !== undefined) { |
| 362 | output = { ...output, ...ordinary } |
| 363 | } |
| 364 | for (let index = 0; index < representation.checks.length; index++) { |
| 365 | const type = typeof output.type === "string" && isJsonSchemaType(output.type) ? output.type : undefined |
| 366 | const check = compileCheck(representation.checks[index], type, [...path, "checks", index], local) |
| 367 | if (check !== undefined) output = appendJsonSchema(output, ...check) |
| 368 | } |
| 369 | compiledRepresentations.set(representation, [output, local]) |
| 370 | return registerPendingOneOf(output) |
| 371 | } |
| 372 | |
| 373 | function on( |
| 374 | representation: Exclude<SchemaRepresentation.Representation, SchemaRepresentation.Reference>, |
| 375 | path: Path, |
| 376 | dependencies: Array<Exactness> |
| 377 | ): JsonSchema.JsonSchema { |
| 378 | switch (representation._tag) { |
| 379 | case "Any": |
| 380 | case "Unknown": |
| 381 | return {} |
| 382 | case "ObjectKeyword": |
| 383 | return { anyOf: [{ type: "object" }, { type: "array" }] } |
| 384 | case "Void": |
| 385 | case "Undefined": |
| 386 | case "Null": |
| 387 | return { type: "null" } |
| 388 | case "BigInt": |
| 389 | return { type: "string", allOf: [{ pattern: "^-?\\d+$" }] } |
| 390 | case "Symbol": |
| 391 | return { type: "string", allOf: [{ pattern: "^Symbol\\(([\\s\\S]*)\\)$" }] } |
| 392 | case "UniqueSymbol": |
| 393 | return globalThis.Symbol.keyFor(representation.symbol) === undefined |
| 394 | ? { not: {} } |
| 395 | : { type: "string", enum: [globalThis.String(representation.symbol)] } |
| 396 | case "Declaration": |
| 397 | dependencies.push(false) |
| 398 | return {} |
| 399 | case "Suspend": |
| 400 | return recur(representation.thunk, [...path, "thunk"], dependencies) |
| 401 | case "Never": |
| 402 | return { not: {} } |
| 403 | case "String": |
| 404 | return { type: "string" } |
| 405 | case "Number": |
| 406 | return { |
| 407 | anyOf: [ |
| 408 | { type: "number" }, |
| 409 | { type: "string", enum: ["NaN", "Infinity", "-Infinity"] } |
| 410 | ] |
| 411 | } |
| 412 | case "Boolean": |
| 413 | return { type: "boolean" } |
| 414 | case "Literal": { |
| 415 | const literal = representation.literal |
| 416 | return typeof literal === "bigint" |
| 417 | ? { type: "string", enum: [globalThis.String(literal)] } |
| 418 | : { type: typeof literal, enum: [literal] } |
| 419 | } |
| 420 | case "Enum": { |
| 421 | const types = representation.enums.map(([title, literal], index) => { |
| 422 | if (typeof literal === "number" && !globalThis.Number.isFinite(literal)) { |
| 423 | throw errorWithPath( |
| 424 | `Invalid numeric enum value ${globalThis.String(literal)}`, |
| 425 | [...path, "enums", index, 1] |
| 426 | ) |
| 427 | } |
| 428 | return { type: typeof literal, enum: [literal], title } |
| 429 | }) |
| 430 | return types.length === 0 ? { not: {} } : { anyOf: types } |
| 431 | } |
| 432 | case "TemplateLiteral": |
| 433 | return { type: "string", pattern: `^${representation.parts.map(getPartPattern).join("")}$` } |
| 434 | case "Arrays": { |
| 435 | if (representation.rest.length > 1) { |
| 436 | throw errorWithPath("Invalid schema representation document", [...path, "rest"]) |
| 437 | } |
| 438 | const out: JsonSchema.JsonSchema = { type: "array" } |
| 439 | let minItems = representation.elements.length |
| 440 | const prefixItems = representation.elements.map((element, index) => { |
| 441 | if (element.isOptional) minItems-- |
| 442 | const schema = recur(element.type, [...path, "elements", index, "type"], dependencies) |
| 443 | const annotations = collectJsonSchemaAnnotations(element.annotations, options) |
| 444 | return annotations === undefined |
| 445 | ? schema |
| 446 | : registerPendingOneOf(appendJsonSchema(schema, annotations)) |
| 447 | }) |
| 448 | if (prefixItems.length > 0) { |
| 449 | out.prefixItems = prefixItems |
| 450 | out.maxItems = representation.elements.length |
| 451 | if (minItems > 0) out.minItems = minItems |
| 452 | } else { |
| 453 | out.items = false |
| 454 | } |
| 455 | if (representation.rest.length === 1) { |
| 456 | delete out.maxItems |
| 457 | const rest = recur(representation.rest[0], [...path, "rest", 0], dependencies) |
| 458 | if (Object.keys(rest).length > 0) out.items = rest |
| 459 | else delete out.items |
| 460 | } |
| 461 | return out |
| 462 | } |
| 463 | case "Objects": { |
| 464 | if (representation.propertySignatures.length === 0 && representation.indexSignatures.length === 0) { |
| 465 | return { not: { type: "null" } } |
| 466 | } |
| 467 | const out: JsonSchema.JsonSchema = { type: "object" } |
| 468 | const properties: Record<string, JsonSchema.JsonSchema> = {} |
| 469 | const required: Array<string> = [] |
| 470 | for (let index = 0; index < representation.propertySignatures.length; index++) { |
| 471 | const property = representation.propertySignatures[index] |
| 472 | if (typeof property.name !== "string") { |
| 473 | throw errorWithPath("Invalid schema representation document", [ |
| 474 | ...path, |
| 475 | "propertySignatures", |
| 476 | index, |
| 477 | "name" |
| 478 | ]) |
| 479 | } |
| 480 | const name = property.name |
| 481 | const schema = recur(property.type, [...path, "propertySignatures", index, "type"], dependencies) |
| 482 | const annotations = collectJsonSchemaAnnotations(property.annotations, options) |
| 483 | InternalRecord.assignProperty( |
| 484 | properties, |
| 485 | name, |
| 486 | annotations === undefined |
| 487 | ? schema |
| 488 | : registerPendingOneOf(appendJsonSchema(schema, annotations)) |
| 489 | ) |
| 490 | if (!property.isOptional) required.push(name) |
| 491 | } |
| 492 | if (representation.propertySignatures.length > 0) out.properties = properties |
| 493 | if (required.length > 0) out.required = required |
| 494 | const patternProperties: Record<string, JsonSchema.JsonSchema | false> = {} |
| 495 | const additionalPropertySchemas: Array<JsonSchema.JsonSchema | false> = [] |
| 496 | const indexValueSchemas: Array<JsonSchema.JsonSchema | false> = [] |
| 497 | let hasUnrepresentableIndexPattern = false |
| 498 | for (let index = 0; index < representation.indexSignatures.length; index++) { |
| 499 | const signature = representation.indexSignatures[index] |
| 500 | let type: JsonSchema.JsonSchema | false = recur( |
| 501 | signature.type, |
| 502 | [...path, "indexSignatures", index, "type"], |
| 503 | dependencies |
| 504 | ) |
| 505 | if (Equal.equals(type, { not: {} })) type = false |
| 506 | indexValueSchemas.push(type) |
| 507 | const patterns = getParameterPatterns( |
| 508 | signature.parameter, |
| 509 | [...path, "indexSignatures", index, "parameter"], |
| 510 | new Set() |
| 511 | ) |
| 512 | if (patterns === undefined) { |
| 513 | hasUnrepresentableIndexPattern = true |
| 514 | dependencies.push(false) |
| 515 | continue |
| 516 | } |
| 517 | if (patterns.length === 0) { |
| 518 | additionalPropertySchemas.push(type) |
| 519 | } else { |
| 520 | for (const pattern of patterns) { |
| 521 | const previous = patternProperties[pattern] |
| 522 | InternalRecord.assignProperty( |
| 523 | patternProperties, |
| 524 | pattern, |
| 525 | previous === undefined |
| 526 | ? type |
| 527 | : previous === false || type === false |
| 528 | ? false |
| 529 | : registerPendingOneOf(appendJsonSchema(previous, type)) |
| 530 | ) |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | const hasPatternProperties = Object.keys(patternProperties).length > 0 |
| 535 | if (hasPatternProperties) { |
| 536 | out.patternProperties = patternProperties |
| 537 | } |
| 538 | if (hasUnrepresentableIndexPattern && options?.onExcessProperty === "error") { |
| 539 | const propertyNames: Array<JsonSchema.JsonSchema> = [] |
| 540 | if (representation.propertySignatures.length > 0) { |
| 541 | propertyNames.push({ enum: Object.keys(properties) }) |
| 542 | } |
| 543 | for (let index = 0; index < representation.indexSignatures.length; index++) { |
| 544 | propertyNames.push( |
| 545 | recur( |
| 546 | representation.indexSignatures[index].parameter, |
| 547 | [...path, "indexSignatures", index, "parameter"], |
| 548 | dependencies |
| 549 | ) |
| 550 | ) |
| 551 | } |
| 552 | out.propertyNames = propertyNames.length === 1 ? propertyNames[0] : { anyOf: propertyNames } |
| 553 | out.additionalProperties = indexValueSchemas.length === 1 |
| 554 | ? indexValueSchemas[0] |
| 555 | : { anyOf: indexValueSchemas } |
| 556 | if (additionalPropertySchemas.length > 0) { |
| 557 | out.allOf = additionalPropertySchemas.map((type) => ({ type: "object", additionalProperties: type })) |
| 558 | } |
| 559 | } else if (additionalPropertySchemas.length === 0) { |
| 560 | out.additionalProperties = hasUnrepresentableIndexPattern || options?.onExcessProperty !== "error" |
| 561 | } else if ( |
| 562 | additionalPropertySchemas.length === 1 && |
| 563 | representation.propertySignatures.length === 0 && |
| 564 | !hasPatternProperties |
| 565 | ) { |
| 566 | out.additionalProperties = additionalPropertySchemas[0] |
| 567 | } else if (additionalPropertySchemas.length > 0) { |
| 568 | out.allOf = additionalPropertySchemas.map((type) => ({ type: "object", additionalProperties: type })) |
| 569 | } |
| 570 | if ( |
| 571 | typeof out.additionalProperties === "object" && |
| 572 | out.additionalProperties !== null && |
| 573 | Object.keys(out.additionalProperties).length === 0 |
| 574 | ) { |
| 575 | delete out.additionalProperties |
| 576 | } |
| 577 | return out |
| 578 | } |
| 579 | case "Union": { |
| 580 | const branches: Array<Exactness> = [] |
| 581 | const types = representation.types.map((type, index) => recur(type, [...path, "types", index], branches)) |
| 582 | dependencies.push(branches) |
| 583 | if (types.length === 0) return { not: {} } |
| 584 | const mode = representation.options?.mode ?? "anyOf" |
| 585 | if (mode === "anyOf" && types.length > 1) { |
| 586 | const compacted = compactEnums(types) |
| 587 | if (compacted !== undefined) return compacted |
| 588 | } |
| 589 | if (mode === "oneOf") pendingOneOf.set(types, [branches, []]) |
| 590 | return { [mode]: types } |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | function getParameterPatterns( |
| 596 | parameter: SchemaRepresentation.Representation, |
| 597 | path: Path, |
| 598 | seenReferences: ReadonlySet<string> |
| 599 | ): ReadonlyArray<string> | undefined { |
| 600 | switch (parameter._tag) { |
| 601 | case "Reference": { |
| 602 | if (!Object.hasOwn(references, parameter.$ref)) { |
| 603 | throw errorWithPath(`Invalid reference ${parameter.$ref}`, [...path, "$ref"]) |
| 604 | } |
| 605 | compileDefinition(parameter.$ref, path) |
| 606 | if (seenReferences.has(parameter.$ref)) return undefined |
| 607 | const next = new Set(seenReferences).add(parameter.$ref) |
| 608 | return getParameterPatterns(references[parameter.$ref], ["references", parameter.$ref], next) |
| 609 | } |
| 610 | case "String": { |
| 611 | if (parameter.checks.length === 0) return [] |
| 612 | if ( |
| 613 | parameter.checks.length !== 1 || |
| 614 | parameter.checks[0]._tag !== "Filter" || |
| 615 | parameter.checks[0].annotations?.toJsonSchema === undefined |
| 616 | ) { |
| 617 | return undefined |
| 618 | } |
| 619 | const dependencies: Array<Exactness> = [] |
| 620 | const schema = recur(parameter, path, dependencies) |
| 621 | if (!isExact(dependencies)) return undefined |
| 622 | return schema.type === "string" && typeof schema.pattern === "string" && |
| 623 | hasOnlyKeywords(schema, "|type|pattern|title|description|default|examples|readOnly|writeOnly|") |
| 624 | ? [schema.pattern] |
| 625 | : undefined |
| 626 | } |
| 627 | case "TemplateLiteral": |
| 628 | return parameter.checks.length === 0 ? [`^${parameter.parts.map(getPartPattern).join("")}$`] : undefined |
| 629 | case "Union": { |
| 630 | if (parameter.types.length === 0 || parameter.checks.length > 0 || parameter.options?.mode === "oneOf") { |
| 631 | return undefined |
| 632 | } |
| 633 | const patterns: Array<string> = [] |
| 634 | for (let index = 0; index < parameter.types.length; index++) { |
| 635 | const memberPatterns = getParameterPatterns(parameter.types[index], [...path, "types", index], seenReferences) |
| 636 | if (memberPatterns === undefined) return undefined |
| 637 | if (memberPatterns.length === 0) return [] |
| 638 | patterns.push(...memberPatterns) |
| 639 | } |
| 640 | return patterns |
| 641 | } |
| 642 | default: |
| 643 | throw errorWithPath("Invalid schema representation document", path) |
| 644 | } |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | function isJsonSchemaType(input: string): input is JsonSchema.Type { |
| 649 | return input === "string" || input === "number" || input === "boolean" || input === "array" || |
| 650 | input === "object" || input === "null" || input === "integer" |
| 651 | } |
| 652 | |
| 653 | function compactEnums( |
| 654 | schemas: ReadonlyArray<JsonSchema.JsonSchema> |
| 655 | ): JsonSchema.JsonSchema | undefined { |
| 656 | let sharedType: unknown = undefined |
| 657 | const values: Array<unknown> = [] |
| 658 | for (const schema of schemas) { |
| 659 | const keys = Object.keys(schema) |
| 660 | if (keys.length !== 2 || schema.type === undefined || !Array.isArray(schema.enum) || schema.enum.length === 0) { |
| 661 | return undefined |
| 662 | } |
| 663 | if (sharedType === undefined) sharedType = schema.type |
| 664 | else if (schema.type !== sharedType) return undefined |
| 665 | for (const value of schema.enum) { |
| 666 | if (values.includes(value)) return undefined |
| 667 | values.push(value) |
| 668 | } |
| 669 | } |
| 670 | return { type: sharedType, enum: values } |
| 671 | } |
| 672 | |
| 673 | function getPartPattern(part: SchemaRepresentation.Representation): string { |
| 674 | switch (part._tag) { |
| 675 | case "Literal": |
| 676 | return RegEx.escape(globalThis.String(part.literal)) |
| 677 | case "String": |
| 678 | return InternalAST.STRING_PATTERN |
| 679 | case "Number": |
| 680 | return InternalAST.FINITE_PATTERN |
| 681 | case "TemplateLiteral": |
| 682 | return part.parts.map(getPartPattern).join("") |
| 683 | case "Union": |
| 684 | return `(?:${part.types.map(getPartPattern).join("|")})` |
| 685 | default: |
| 686 | throw errorWithPath("Invalid schema representation document", []) |
| 687 | } |
| 688 | } |
| 689 | |
| 690 | /** @internal */ |
| 691 | export function toJsonSchemaDocument( |
| 692 | document: SchemaRepresentation.Document, |
| 693 | options?: Schema.ToJsonSchemaOptions |
| 694 | ): JsonSchema.Document<"draft-2020-12"> { |
| 695 | const output = compileJsonSchema( |
| 696 | [document.representation], |
| 697 | [["representation"]], |
| 698 | document.references, |
| 699 | options |
| 700 | ) |
| 701 | return { |
| 702 | dialect: output.dialect, |
| 703 | schema: output.schemas[0], |
| 704 | definitions: output.definitions |
| 705 | } |
| 706 | } |
| 707 | |
| 708 | /** @internal */ |
| 709 | export function toJsonSchemaMultiDocument( |
| 710 | document: SchemaRepresentation.MultiDocument, |
| 711 | options?: Schema.ToJsonSchemaOptions |
| 712 | ): JsonSchema.MultiDocument<"draft-2020-12"> { |
| 713 | return compileJsonSchema( |
| 714 | document.representations, |
| 715 | document.representations.map((_, index) => ["representations", index]), |
| 716 | document.references, |
| 717 | options |
| 718 | ) |
| 719 | } |