1import * as Arr from "../../Array.ts"
2import * as Equal from "../../Equal.ts"
3import { decodeUriFragment, formatUriFragmentToken, unescapeToken } from "../../JsonPointer.ts"
4import type * as JsonSchema from "../../JsonSchema.ts"
5import { rewriteRefs } from "../../JsonSchema.ts"
6import * as RegEx from "../../RegExp.ts"
7import type * as Schema from "../../Schema.ts"
8import * as InternalAST from "../../SchemaAST.ts"
9import type * as SchemaRepresentation from "../../SchemaRepresentation.ts"
10import { errorWithPath } from "../errors.ts"
11import * as InternalRecord from "../record.ts"
12import * as InternalAnnotations from "./annotations.ts"
13
14type Path = ReadonlyArray<string | number>
15
16function formatDefinitionReference(key: string): string {
17 return `#/$defs/${formatUriFragmentToken(key)}`
18}
19
20const jsonSchemaAnnotationExcludedKeys = new Set([
21 ...InternalAnnotations.annotationExcludedKeys,
22 InternalAnnotations.IDENTIFIER_FALLBACK_KEY,
23 ...InternalAnnotations.jsonSchemaAnnotationKeys
24])
25
26function 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
72type JsonSchemaNumberType = "number" | "integer"
73
74function 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
105function 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.
113const inlineableCheckKeywords =
114 "|type|format|pattern|multipleOf|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength|minItems|maxItems|uniqueItems|minProperties|maxProperties|propertyNames|"
115
116function hasOnlyKeywords(schema: JsonSchema.JsonSchema, allowed: string): boolean {
117 return Object.keys(schema).every((key) => allowed.includes(`|${key}|`))
118}
119
120function 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.
125const promotableAnnotationKeywords = "|title|description|default|examples|readOnly|writeOnly|"
126const inlineableAnnotatedCheckKeywords = inlineableCheckKeywords + promotableAnnotationKeywords
127
128function 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`.
167type Exactness = boolean | ReadonlyArray<Exactness>
168
169type CompiledJsonSchema = [schema: JsonSchema.JsonSchema, dependencies: Array<Exactness>]
170
171function 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
178function isApproximateCheckOutput(
179 output: SchemaRepresentation.ToJsonSchema.CheckOutput
180): output is readonly [schema: JsonSchema.JsonSchema, approximate: true] {
181 return Array.isArray(output)
182}
183
184function 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
648function 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
653function 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
673function 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 */
691export 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 */
709export 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}