1import * as Iterable from "../../Iterable.ts"
2import * as JsonSchema from "../../JsonSchema.ts"
3import { remainder } from "../../Number.ts"
4import type * as Schema from "../../Schema.ts"
5import * as InternalAST from "../../SchemaAST.ts"
6import type * as SchemaRepresentation from "../../SchemaRepresentation.ts"
7import { errorWithPath } from "../errors.ts"
8import * as InternalRecord from "../record.ts"
9import { fromRepresentation, fromRepresentations } from "./fromRepresentation.ts"
10
11type Path = ReadonlyArray<string | number>
12type Representation = SchemaRepresentation.Representation
13type Check = SchemaRepresentation.Check
14
15interface Checked {
16 readonly checks: ReadonlyArray<Check>
17 readonly annotations?: Schema.Annotations.Annotations | undefined
18}
19
20type ImportedJsonSchemaRepresentation = Extract<Representation, {
21 readonly _tag:
22 | "Reference"
23 | "Suspend"
24 | "Never"
25 | "Unknown"
26 | "Null"
27 | "String"
28 | "Number"
29 | "Boolean"
30 | "Literal"
31 | "Arrays"
32 | "Objects"
33 | "Union"
34}>
35
36interface ImportedObjectPattern {
37 readonly source: string
38 readonly parameter: SchemaRepresentation.String
39 readonly type: ImportedJsonSchemaRepresentation
40}
41
42interface ImportedObjectProperty {
43 readonly type: ImportedJsonSchemaRepresentation | undefined
44 readonly isOptional: boolean
45}
46
47interface ImportedObjectScope {
48 readonly properties: ReadonlyMap<string, ImportedObjectProperty>
49 readonly hasProperties: boolean
50 readonly patterns: ReadonlyArray<ImportedObjectPattern>
51 readonly additionalProperties: ImportedJsonSchemaRepresentation
52}
53
54const never: ImportedJsonSchemaRepresentation = { _tag: "Never", checks: [] }
55const unknown: ImportedJsonSchemaRepresentation = { _tag: "Unknown", checks: [] }
56const string: ImportedJsonSchemaRepresentation = { _tag: "String", checks: [] }
57const unsupportedStructuredValue = "Only primitive values are supported in \"const\" and \"enum\"."
58const jsonSchemaPatternFlags = "u"
59
60function isObject(input: unknown): input is Record<string, unknown> {
61 return typeof input === "object" && input !== null && !Array.isArray(input)
62}
63
64function makeJsonLiteral(input: unknown, path: Path): ImportedJsonSchemaRepresentation | undefined {
65 if (input === null) return { _tag: "Null", checks: [] }
66 if (typeof input === "string" || typeof input === "number" || typeof input === "boolean") {
67 return { _tag: "Literal", literal: input, checks: [] }
68 }
69 if (typeof input === "object") throw errorWithPath(unsupportedStructuredValue, path)
70}
71
72const jsonSchemaValueTypes = ["null", "string", "number", "boolean", "object", "array"] as const
73
74const jsonSchemaTypeSpecificKeys = [
75 "minLength",
76 "maxLength",
77 "pattern",
78 "minimum",
79 "maximum",
80 "exclusiveMinimum",
81 "exclusiveMaximum",
82 "multipleOf",
83 "properties",
84 "required",
85 "additionalProperties",
86 "patternProperties",
87 "propertyNames",
88 "minProperties",
89 "maxProperties",
90 "items",
91 "prefixItems",
92 "minItems",
93 "maxItems",
94 "uniqueItems"
95]
96function isImportedJsonSchemaType(input: unknown): input is JsonSchema.Type {
97 return input === "integer" || typeof input === "string" && jsonSchemaValueTypes.some((type) => type === input)
98}
99
100function hasTypeSpecificKeywords(schema: JsonSchema.JsonSchema): boolean {
101 return jsonSchemaTypeSpecificKeys.some((key) => schema[key] !== undefined)
102}
103
104function jsonSchemaFilter(
105 id: string,
106 payload: Schema.Json,
107 schemas?: ReadonlyArray<Representation>
108): Check {
109 return {
110 _tag: "Filter",
111 aborted: false,
112 representation: {
113 id,
114 payload,
115 ...(schemas === undefined ? undefined : { schemas })
116 }
117 }
118}
119
120function addNumberCheck(
121 checks: Array<Check>,
122 value: unknown,
123 id: string,
124 key: string
125): void {
126 if (typeof value === "number") {
127 checks.push(jsonSchemaFilter(id, { [key]: value }))
128 }
129}
130
131function jsonSchemaAnnotations(
132 schema: JsonSchema.JsonSchema
133): Schema.Annotations.Annotations | undefined {
134 const annotations: Record<string, Schema.Json> = {}
135 if (typeof schema.title === "string") annotations.title = schema.title
136 if (typeof schema.description === "string") annotations.description = schema.description
137 if (Object.hasOwn(schema, "default")) annotations.default = schema.default as Schema.Json
138 if (Array.isArray(schema.examples)) annotations.examples = schema.examples as ReadonlyArray<Schema.Json>
139 if (typeof schema.readOnly === "boolean") annotations.readOnly = schema.readOnly
140 if (typeof schema.writeOnly === "boolean") annotations.writeOnly = schema.writeOnly
141 if (typeof schema.format === "string") annotations.format = schema.format
142 if (typeof schema.contentEncoding === "string") annotations.contentEncoding = schema.contentEncoding
143 if (typeof schema.contentMediaType === "string") annotations.contentMediaType = schema.contentMediaType
144 if (InternalAST.isJson(schema.contentSchema)) annotations.contentSchema = schema.contentSchema
145 return Object.keys(annotations).length === 0 ? undefined : annotations
146}
147
148function jsonDeclaration(
149 annotations: Schema.Annotations.Annotations | undefined
150): Representation {
151 return {
152 _tag: "Declaration",
153 representation: {
154 id: "effect/schema/Json",
155 payload: null
156 },
157 annotations: {
158 ...annotations,
159 expected: "JSON value"
160 },
161 checks: [],
162 typeParameters: []
163 }
164}
165
166function translateJsonSchemaMultiDocument(
167 document: JsonSchema.MultiDocument<"draft-2020-12">,
168 options?: SchemaRepresentation.FromJsonSchemaOptions,
169 singleRoot = false
170): SchemaRepresentation.MultiDocument {
171 const definitionCache = new Map<string, ImportedJsonSchemaRepresentation | null>()
172 const reachableDefinitions = new Map<string, Path>()
173 const objectScopesByProperties = new WeakMap<
174 SchemaRepresentation.Objects["propertySignatures"],
175 readonly [ReadonlyArray<ImportedObjectScope>, Path]
176 >()
177 const annotatedReferences: Array<{
178 readonly reference: SchemaRepresentation.Reference
179 readonly path: Path
180 }> = []
181 const resolvingChoices = new Set<string>()
182 const finalReferences = new Set<string>()
183 let inNestedResource = false
184
185 function finalize(representation: Representation): Representation {
186 switch (representation._tag) {
187 case "Unknown":
188 return jsonDeclaration(representation.annotations)
189 case "Reference":
190 finalReferences.add(representation.$ref)
191 return representation
192 case "Suspend":
193 return { ...representation, thunk: finalize(representation.thunk) }
194 case "Arrays":
195 return {
196 ...representation,
197 elements: representation.elements.map((element) => ({
198 ...element,
199 type: finalize(element.type)
200 })),
201 rest: representation.rest.map(finalize)
202 }
203 case "Objects":
204 // Intersections may reduce open patterns to fixed properties, so reject only
205 // if an open pattern remains in the final representation.
206 const [scopes, path] = objectScopesByProperties.get(representation.propertySignatures)!
207 if (
208 representation.indexSignatures[0]?.parameter === string &&
209 scopes.some((scope) => scope.patterns.length > 0)
210 ) {
211 throw errorWithPath(
212 "Cannot import open \"patternProperties\": unmatched keys cannot be typed correctly.",
213 path
214 )
215 }
216 return {
217 ...representation,
218 propertySignatures: representation.propertySignatures.map((property) => ({
219 ...property,
220 type: finalize(property.type)
221 })),
222 indexSignatures: representation.indexSignatures.map((indexSignature) => ({
223 parameter: finalize(indexSignature.parameter),
224 type: finalize(indexSignature.type)
225 })),
226 checks: representation.checks.map(finalizeCheck)
227 }
228 case "Union":
229 return { ...representation, types: representation.types.map(finalize) }
230 default:
231 return representation
232 }
233 }
234
235 function finalizeCheck(check: Check): Check {
236 const representation = check.representation
237 const schemas = representation?.schemas
238 if (representation === undefined || schemas === undefined) {
239 return check
240 }
241 return {
242 ...check,
243 representation: {
244 ...representation,
245 schemas: schemas.map(finalize)
246 }
247 }
248 }
249
250 function annotate(
251 representation: ImportedJsonSchemaRepresentation,
252 annotations: Schema.Annotations.Annotations | undefined
253 ): ImportedJsonSchemaRepresentation {
254 if (annotations === undefined) return representation
255 if (representation._tag === "Reference") {
256 return { _tag: "Suspend", annotations, checks: [], thunk: representation }
257 }
258 return {
259 ...representation,
260 annotations: { ...representation.annotations, ...annotations }
261 }
262 }
263
264 function translateDefinition(
265 key: string,
266 path: Path,
267 recursiveReferenceError?: string
268 ): ImportedJsonSchemaRepresentation {
269 const cached = definitionCache.get(key)
270 if (cached !== undefined) {
271 if (cached === null) {
272 throw errorWithPath(
273 recursiveReferenceError ??
274 `Definition ${JSON.stringify(key)} is a circular alias.`,
275 [...path, "$ref"]
276 )
277 }
278 return cached
279 }
280 definitionCache.set(key, null)
281 const representation = translateSchema(document.definitions[key], ["definitions", key])
282 definitionCache.set(key, representation)
283 return representation
284 }
285
286 function resolveReference(
287 reference: SchemaRepresentation.Reference,
288 path: Path,
289 recursiveReferenceError?: string,
290 seen: Set<string> = new Set()
291 ): ImportedJsonSchemaRepresentation {
292 if (seen.has(reference.$ref)) {
293 throw errorWithPath(
294 `Definition ${JSON.stringify(reference.$ref)} is a circular alias.`,
295 [...path, "$ref"]
296 )
297 }
298 seen.add(reference.$ref)
299 const representation = translateDefinition(reference.$ref, path, recursiveReferenceError)
300 if (representation._tag === "Reference") {
301 return resolveReference(representation, path, recursiveReferenceError, seen)
302 }
303 if (representation._tag === "Suspend" && representation.thunk._tag === "Reference") {
304 return annotate(
305 resolveReference(representation.thunk, path, recursiveReferenceError, seen),
306 representation.annotations
307 )
308 }
309 return representation
310 }
311
312 function mergeAnnotations(
313 left: Schema.Annotations.Annotations | undefined,
314 right: Schema.Annotations.Annotations | undefined
315 ): Schema.Annotations.Annotations | undefined {
316 if (left === undefined) return right
317 if (right === undefined) return left
318 return { ...left, ...right }
319 }
320
321 function annotateIntersection(
322 representation: ImportedJsonSchemaRepresentation,
323 left: ImportedJsonSchemaRepresentation,
324 right: ImportedJsonSchemaRepresentation
325 ): ImportedJsonSchemaRepresentation {
326 return annotate(
327 representation,
328 mergeAnnotations(
329 left._tag === "Reference" ? undefined : left.annotations,
330 right._tag === "Reference" ? undefined : right.annotations
331 )
332 )
333 }
334
335 function intersectChecks(
336 left: Checked,
337 right: Checked,
338 deduplicate: ReadonlyArray<string> = []
339 ): readonly [ReadonlyArray<Check>, Schema.Annotations.Annotations | undefined] {
340 let checks = right.checks
341 for (const id of deduplicate) {
342 if (left.checks.some((check) => check._tag === "Filter" && check.representation?.id === id)) {
343 checks = checks.filter((check) => check._tag !== "Filter" || check.representation?.id !== id)
344 }
345 }
346 if (checks.length === 0) {
347 const annotations = mergeAnnotations(left.annotations, right.annotations)
348 return [left.checks, annotations]
349 }
350 if (right.annotations !== undefined) {
351 checks = checks.length === 1 && checks[0].annotations === undefined
352 ? [{ ...checks[0], annotations: right.annotations }]
353 : [{
354 _tag: "FilterGroup",
355 checks: checks as [Check, ...Array<Check>],
356 annotations: right.annotations
357 }]
358 }
359 return [[...left.checks, ...checks], left.annotations]
360 }
361
362 function satisfiesPrimitiveCheck(check: Check, value: string | number): boolean | undefined {
363 if (check._tag === "FilterGroup") {
364 return check.checks.every((check) => satisfiesPrimitiveCheck(check, value))
365 }
366 const representation = check.representation!
367 const payload = representation.payload as Record<string, any>
368 switch (representation.id) {
369 case "effect/schema/isMinLength":
370 return (value as string).length >= payload.minLength
371 case "effect/schema/isMinCodePoints":
372 return Iterable.size(value as string) >= payload.minCodePoints
373 case "effect/schema/isMaxCodePoints":
374 return Iterable.size(value as string) <= payload.maxCodePoints
375 case "effect/schema/isPattern":
376 return new RegExp(payload.source as string, payload.flags as string).test(value as string)
377 case "effect/schema/isFinite":
378 return globalThis.Number.isFinite(value as number)
379 case "effect/schema/isInt":
380 return globalThis.Number.isSafeInteger(value as number)
381 case "effect/schema/isMultipleOf":
382 return remainder(value as number, payload.divisor) === 0
383 case "effect/schema/isGreaterThan":
384 return (value as number) > payload.exclusiveMinimum
385 case "effect/schema/isGreaterThanOrEqualTo":
386 return (value as number) >= payload.minimum
387 case "effect/schema/isLessThan":
388 return (value as number) < payload.exclusiveMaximum
389 case "effect/schema/isLessThanOrEqualTo":
390 return (value as number) <= payload.maximum
391 }
392 }
393
394 function satisfiesLiteral(
395 representation:
396 | SchemaRepresentation.String
397 | SchemaRepresentation.Number,
398 literal: SchemaRepresentation.Literal
399 ): boolean {
400 const value = literal.literal
401 if (representation._tag === "String" ? typeof value !== "string" : typeof value !== "number") {
402 return false
403 }
404 return representation.checks.every((check) => satisfiesPrimitiveCheck(check, value as string | number))
405 }
406
407 function intersectPrimitiveWithLiteral(
408 primitive:
409 | SchemaRepresentation.String
410 | SchemaRepresentation.Number
411 | SchemaRepresentation.Boolean,
412 literal: SchemaRepresentation.Literal
413 ): ImportedJsonSchemaRepresentation {
414 const satisfies = primitive._tag === "Boolean"
415 ? typeof literal.literal === "boolean"
416 : satisfiesLiteral(primitive, literal)
417 return satisfies ? annotateIntersection(literal, primitive, literal) : never
418 }
419
420 function intersectArrays(
421 left: SchemaRepresentation.Arrays,
422 right: SchemaRepresentation.Arrays,
423 path: Path
424 ): Pick<SchemaRepresentation.Arrays, "elements" | "rest"> | undefined {
425 const elements: Array<SchemaRepresentation.Element> = []
426 const length = Math.max(left.elements.length, right.elements.length)
427 for (let index = 0; index < length; index++) {
428 const leftElement = left.elements[index]
429 const rightElement = right.elements[index]
430 const isOptional = leftElement?.isOptional !== false && rightElement?.isOptional !== false
431 const leftType = leftElement?.type ?? left.rest[0]
432 const rightType = rightElement?.type ?? right.rest[0]
433 if (leftType === undefined || rightType === undefined) {
434 return isOptional ? { elements, rest: [] } : undefined
435 }
436 const type = intersect(
437 leftType as ImportedJsonSchemaRepresentation,
438 rightType as ImportedJsonSchemaRepresentation,
439 [...path, "elements", index, "type"]
440 )
441 if (type._tag === "Never") {
442 return isOptional ? { elements, rest: [] } : undefined
443 }
444 elements.push({
445 isOptional,
446 type
447 })
448 }
449
450 const leftRest = left.rest[0]
451 const rightRest = right.rest[0]
452 if (leftRest === undefined || rightRest === undefined) {
453 return { elements, rest: [] }
454 }
455 const rest = intersect(
456 leftRest as ImportedJsonSchemaRepresentation,
457 rightRest as ImportedJsonSchemaRepresentation,
458 [...path, "rest", 0]
459 )
460 return { elements, rest: rest._tag === "Never" ? [] : [rest] }
461 }
462
463 function intersectAll(
464 types: ReadonlyArray<ImportedJsonSchemaRepresentation>,
465 path: Path
466 ): ImportedJsonSchemaRepresentation {
467 let out = types[0] ?? unknown
468 for (let index = 1; index < types.length; index++) {
469 out = intersect(out, types[index], [...path, index])
470 }
471 return out
472 }
473
474 function lowerObject(
475 scopes: ReadonlyArray<ImportedObjectScope>,
476 checks: ReadonlyArray<Check>,
477 annotations: Schema.Annotations.Annotations | undefined,
478 path: Path
479 ): ImportedJsonSchemaRepresentation {
480 const names = new Set<string>()
481 let hasFiniteKeyDomain = false
482 let requiresFiniteKeyDomain = false
483 for (const scope of scopes) {
484 for (const name of scope.properties.keys()) names.add(name)
485 hasFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" && scope.patterns.length === 0
486 requiresFiniteKeyDomain ||= scope.additionalProperties._tag === "Never" ||
487 scope.additionalProperties._tag !== "Unknown" && (scope.hasProperties || scope.patterns.length > 0)
488 }
489 const closedPattern = scopes.length === 1 && names.size === 0 &&
490 scopes[0].additionalProperties._tag === "Never" && scopes[0].patterns.length === 1
491 ? scopes[0].patterns[0]
492 : undefined
493 if (!hasFiniteKeyDomain && requiresFiniteKeyDomain && closedPattern === undefined) {
494 throw errorWithPath(
495 scopes.some((scope) => scope.additionalProperties._tag === "Never")
496 ? "Cannot import this closed patterned object: only one pattern without properties is supported."
497 : "Cannot combine typed \"additionalProperties\" with other property schemas: Effect index signatures also check excluded keys.",
498 path
499 )
500 }
501
502 const properties: Array<SchemaRepresentation.PropertySignature> = []
503 for (const name of names) {
504 let isOptional = true
505 const types: Array<ImportedJsonSchemaRepresentation> = []
506 for (const scope of scopes) {
507 const property = scope.properties.get(name)
508 if (property !== undefined) {
509 if (!property.isOptional) isOptional = false
510 if (property.type !== undefined) types.push(property.type)
511 }
512 let matches = false
513 for (const pattern of scope.patterns) {
514 if (globalThis.RegExp(pattern.source, jsonSchemaPatternFlags).test(name)) {
515 types.push(pattern.type)
516 matches = true
517 }
518 }
519 if (property?.type === undefined && !matches) types.push(scope.additionalProperties)
520 }
521 const type = intersectAll(types, [...path, "properties", name])
522 if (!isOptional && type._tag === "Never") return never
523 properties.push({ name, type, isOptional, isMutable: false })
524 }
525
526 const indexSignatures: ReadonlyArray<SchemaRepresentation.IndexSignature> = closedPattern !== undefined
527 ? [{ parameter: closedPattern.parameter, type: closedPattern.type }]
528 : !hasFiniteKeyDomain
529 ? [{
530 parameter: string,
531 type: intersectAll(
532 scopes.map((scope) => scope.additionalProperties),
533 [...path, "additionalProperties"]
534 )
535 }]
536 : properties.length === 0
537 ? [{ parameter: string, type: never }]
538 : []
539 const representation: SchemaRepresentation.Objects = {
540 _tag: "Objects",
541 propertySignatures: properties,
542 indexSignatures,
543 checks,
544 annotations
545 }
546 objectScopesByProperties.set(properties, [scopes, path])
547 return representation
548 }
549
550 const jsonRootTags = ["Null", "String", "Number", "Boolean", "Objects", "Arrays"] as const
551 const allMasks = (1 << jsonRootTags.length) - 1
552
553 function rootMask(representation: ImportedJsonSchemaRepresentation): number {
554 switch (representation._tag) {
555 case "Never":
556 return 0
557 case "Unknown":
558 case "Reference":
559 return allMasks
560 case "Literal":
561 switch (typeof representation.literal) {
562 case "string":
563 return 2
564 case "number":
565 return 4
566 case "boolean":
567 return 8
568 default:
569 return 0
570 }
571 case "Suspend":
572 return rootMask(representation.thunk as ImportedJsonSchemaRepresentation)
573 case "Union": {
574 let out = 0
575 for (const type of representation.types) {
576 out |= rootMask(type as ImportedJsonSchemaRepresentation)
577 }
578 return out
579 }
580 default:
581 return 1 << jsonRootTags.indexOf(representation._tag)
582 }
583 }
584
585 function hasChoices(representation: ImportedJsonSchemaRepresentation): boolean {
586 switch (representation._tag) {
587 case "Reference": {
588 const key = representation.$ref
589 const cached = definitionCache.get(key)
590 if (cached === null || resolvingChoices.has(key)) return true
591 resolvingChoices.add(key)
592 const out = hasChoices(cached ?? translateDefinition(key, reachableDefinitions.get(key)!))
593 resolvingChoices.delete(key)
594 return out
595 }
596 case "Suspend":
597 return hasChoices(representation.thunk as ImportedJsonSchemaRepresentation)
598 case "Union":
599 return representation.types.length > 1 ||
600 representation.types.some((type) => hasChoices(type as ImportedJsonSchemaRepresentation))
601 case "Arrays":
602 return representation.elements.some((element) =>
603 hasChoices(element.type as ImportedJsonSchemaRepresentation)
604 ) || representation.rest.some((type) => hasChoices(type as ImportedJsonSchemaRepresentation))
605 case "Objects":
606 return representation.propertySignatures.some((property) =>
607 hasChoices(property.type as ImportedJsonSchemaRepresentation)
608 ) ||
609 representation.indexSignatures.some((indexSignature) =>
610 hasChoices(indexSignature.parameter as ImportedJsonSchemaRepresentation) ||
611 hasChoices(indexSignature.type as ImportedJsonSchemaRepresentation)
612 ) ||
613 objectScopesByProperties.get(representation.propertySignatures)![0].some((scope) =>
614 scope.patterns.some((pattern) => hasChoices(pattern.type))
615 ) ||
616 representation.checks.some(checkHasChoices)
617 default:
618 return false
619 }
620 }
621
622 function checkHasChoices(check: Check): boolean {
623 return check._tag === "FilterGroup"
624 ? check.checks.some(checkHasChoices)
625 : check.representation?.schemas?.some((schema) => hasChoices(schema as ImportedJsonSchemaRepresentation)) === true
626 }
627
628 function makeUnion(
629 source: SchemaRepresentation.Union,
630 types: ReadonlyArray<ImportedJsonSchemaRepresentation>
631 ): ImportedJsonSchemaRepresentation {
632 if (types.length === 0) return never
633 if (types.length === 1 && source.checks.length === 0) return annotate(types[0], source.annotations)
634 return { ...source, types }
635 }
636
637 function literalValue(
638 representation: ImportedJsonSchemaRepresentation
639 ): string | number | boolean | null | undefined {
640 if (representation._tag === "Null") return null
641 if (representation._tag !== "Literal") return undefined
642 const literal = representation.literal
643 return typeof literal === "string" || typeof literal === "number" || typeof literal === "boolean"
644 ? literal
645 : undefined
646 }
647
648 function intersectLiteralUnions(
649 left: SchemaRepresentation.Union,
650 right: SchemaRepresentation.Union,
651 path: Path
652 ): ImportedJsonSchemaRepresentation | undefined {
653 if ((left.options?.mode ?? "anyOf") !== "anyOf" || (right.options?.mode ?? "anyOf") !== "anyOf") {
654 return undefined
655 }
656 const rightByValue = new Map<string | number | boolean | null, ImportedJsonSchemaRepresentation>()
657 for (const type of right.types) {
658 const representation = type as ImportedJsonSchemaRepresentation
659 const value = literalValue(representation)
660 if (value === undefined) return undefined
661 rightByValue.set(value, representation)
662 }
663 const types: Array<ImportedJsonSchemaRepresentation> = []
664 for (const type of left.types) {
665 const representation = type as ImportedJsonSchemaRepresentation
666 const value = literalValue(representation)
667 if (value === undefined) return undefined
668 const match = rightByValue.get(value)
669 if (match !== undefined) types.push(intersect(representation, annotate(match, right.annotations), path))
670 }
671 return makeUnion(left, types)
672 }
673
674 function intersectUnionWithType(
675 union: SchemaRepresentation.Union,
676 type: ImportedJsonSchemaRepresentation,
677 path: Path,
678 unionOnLeft: boolean
679 ): ImportedJsonSchemaRepresentation {
680 const mask = rootMask(type)
681 const members = union.types
682 .map((member, index) => ({
683 index,
684 type: member as ImportedJsonSchemaRepresentation
685 }))
686 .filter((member) => (rootMask(member.type) & mask) !== 0)
687 if (members.length > 1 && hasChoices(type)) {
688 return unsupportedIntersection(path)
689 }
690 if (members.length === 1 && union.checks.length === 0) {
691 const member = annotate(members[0].type, union.annotations)
692 return unionOnLeft
693 ? intersect(member, type, [...path, "types", members[0].index])
694 : intersect(type, member, [...path, "types", members[0].index])
695 }
696 return makeUnion(
697 union,
698 members
699 .map((member) =>
700 unionOnLeft
701 ? intersect(member.type, type, [...path, "types", member.index])
702 : intersect(type, member.type, [...path, "types", member.index])
703 )
704 .filter((type) => type._tag !== "Never")
705 )
706 }
707
708 function isTypePartition(union: SchemaRepresentation.Union): boolean {
709 let mask = 0
710 for (const type of union.types) {
711 const memberMask = rootMask(type as ImportedJsonSchemaRepresentation)
712 if ((mask & memberMask) !== 0) return false
713 mask |= memberMask
714 }
715 return true
716 }
717
718 function unsupportedIntersection(path: Path): never {
719 throw errorWithPath(
720 "Cannot intersect these \"anyOf\" or \"oneOf\" alternatives without expanding their branches.",
721 path
722 )
723 }
724
725 function intersectPartition(
726 partition: SchemaRepresentation.Union,
727 other: SchemaRepresentation.Union,
728 path: Path,
729 partitionOnLeft: boolean
730 ): ImportedJsonSchemaRepresentation {
731 const partitionTypes = partition.types as ReadonlyArray<ImportedJsonSchemaRepresentation>
732 const otherTypes = other.types as ReadonlyArray<ImportedJsonSchemaRepresentation>
733 for (const type of otherTypes) {
734 const mask = rootMask(type)
735 if (partitionTypes.filter((member) => (rootMask(member) & mask) !== 0).length > 1 && hasChoices(type)) {
736 return unsupportedIntersection(path)
737 }
738 }
739 const types: Array<ImportedJsonSchemaRepresentation> = []
740 for (let index = 0; index < partitionTypes.length; index++) {
741 const member = partitionTypes[index]
742 const type = intersectUnionWithType(other, member, [...path, "types", index], !partitionOnLeft)
743 if (type._tag !== "Never") types.push(type)
744 }
745 return makeUnion(partition, types)
746 }
747
748 function intersectUnions(
749 left: SchemaRepresentation.Union,
750 right: SchemaRepresentation.Union,
751 path: Path
752 ): ImportedJsonSchemaRepresentation {
753 const literals = intersectLiteralUnions(left, right, path)
754 if (literals !== undefined) return literals
755 if (isTypePartition(left)) return intersectPartition(left, right, path, true)
756 if (isTypePartition(right)) return intersectPartition(right, left, path, false)
757 return unsupportedIntersection(path)
758 }
759
760 function intersect(
761 left: ImportedJsonSchemaRepresentation,
762 right: ImportedJsonSchemaRepresentation,
763 path: Path
764 ): ImportedJsonSchemaRepresentation {
765 if (left._tag === "Never") return left
766 if (right._tag === "Never") return right
767 if (left._tag === "Unknown") return annotateIntersection(right, left, right)
768 if (right._tag === "Unknown") return annotateIntersection(left, left, right)
769 if (left._tag === "Reference") return intersect(resolveReference(left, path), right, path)
770 if (right._tag === "Reference") return intersect(left, resolveReference(right, path), path)
771 if (left._tag === "Suspend") {
772 return annotate(
773 intersect(left.thunk as ImportedJsonSchemaRepresentation, right, path),
774 left.annotations
775 )
776 }
777 if (right._tag === "Suspend") {
778 return annotate(
779 intersect(left, right.thunk as ImportedJsonSchemaRepresentation, path),
780 right.annotations
781 )
782 }
783 if (left._tag === "Union" && right._tag === "Union") return intersectUnions(left, right, path)
784 if (left._tag === "Union") return intersectUnionWithType(left, right, path, true)
785 if (right._tag === "Union") return intersectUnionWithType(right, left, path, false)
786
787 switch (left._tag) {
788 case "Null":
789 return right._tag === "Null"
790 ? annotateIntersection({ _tag: "Null", checks: [...left.checks, ...right.checks] }, left, right)
791 : never
792 case "String":
793 if (right._tag === "Literal") {
794 return intersectPrimitiveWithLiteral(left, right)
795 }
796 if (right._tag !== "String") return never
797 const [stringChecks, stringAnnotations] = intersectChecks(left, right)
798 return annotate({ _tag: "String", checks: stringChecks }, stringAnnotations)
799 case "Number":
800 if (right._tag === "Literal") {
801 return intersectPrimitiveWithLiteral(left, right)
802 }
803 if (right._tag !== "Number") return never
804 const [numberChecks, numberAnnotations] = intersectChecks(left, right, [
805 "effect/schema/isFinite",
806 "effect/schema/isInt"
807 ])
808 return annotate({ _tag: "Number", checks: numberChecks }, numberAnnotations)
809 case "Boolean":
810 if (right._tag === "Literal") {
811 return intersectPrimitiveWithLiteral(left, right)
812 }
813 return right._tag === "Boolean"
814 ? annotateIntersection(
815 {
816 _tag: "Boolean",
817 checks: [...left.checks, ...right.checks]
818 },
819 left,
820 right
821 )
822 : never
823 case "Literal":
824 if (right._tag === "Literal") {
825 return left.literal === right.literal
826 ? annotateIntersection(
827 {
828 ...left,
829 checks: [...left.checks, ...right.checks]
830 },
831 left,
832 right
833 )
834 : never
835 }
836 if (
837 (right._tag === "String" || right._tag === "Number") && satisfiesLiteral(right, left) ||
838 right._tag === "Boolean" && typeof left.literal === "boolean"
839 ) {
840 return annotateIntersection(left, left, right)
841 }
842 return never
843 case "Arrays": {
844 if (right._tag !== "Arrays") return never
845 const arrays = intersectArrays(left, right, path)
846 if (arrays === undefined) return never
847 const [checks, annotations] = intersectChecks(left, right, ["effect/schema/isUnique"])
848 return annotate({ _tag: "Arrays", ...arrays, checks }, annotations)
849 }
850 case "Objects": {
851 if (right._tag !== "Objects") return never
852 const [checks, annotations] = intersectChecks(left, right)
853 const scopes = [
854 ...objectScopesByProperties.get(left.propertySignatures)![0],
855 ...objectScopesByProperties.get(right.propertySignatures)![0]
856 ]
857 return lowerObject(
858 scopes,
859 checks,
860 annotations,
861 path
862 )
863 }
864 }
865 }
866
867 function translateSchema(
868 input: unknown,
869 path: Path,
870 isRoot = false
871 ): ImportedJsonSchemaRepresentation {
872 if (input === false) {
873 return never
874 }
875 if (!isObject(input)) return unknown
876 const schema = options?.onEnter === undefined
877 ? input as JsonSchema.JsonSchema
878 : options.onEnter(input as JsonSchema.JsonSchema)
879 if (schema === undefined) {
880 return unknown
881 }
882 if (
883 isObject(schema.not) &&
884 Object.keys(schema.not).length === 0
885 ) return annotate(never, jsonSchemaAnnotations(schema))
886 const wasInNestedResource = inNestedResource
887 inNestedResource ||= !isRoot && typeof schema.$id === "string"
888 for (const keyword of Object.keys(schema)) {
889 if (keyword === "if" && !Object.hasOwn(schema, "then") && !Object.hasOwn(schema, "else")) continue
890 switch (keyword) {
891 case "if":
892 case "$dynamicRef":
893 case "contains":
894 case "dependentRequired":
895 case "dependentSchemas":
896 case "not":
897 case "unevaluatedItems":
898 case "unevaluatedProperties":
899 throw errorWithPath(
900 `Cannot import JSON Schema keyword "${keyword}": Effect has no equivalent constraint.`,
901 [...path, keyword]
902 )
903 }
904 }
905
906 let representation = translateType(schema, path)
907 if (Object.hasOwn(schema, "const")) {
908 const constPath = [...path, "const"]
909 const literal = makeJsonLiteral(schema.const, constPath)
910 if (literal !== undefined) representation = intersect(representation, literal, constPath)
911 }
912 if (Array.isArray(schema.enum)) {
913 const types = schema.enum.map((value, index) => makeJsonLiteral(value, [...path, "enum", index]) ?? unknown)
914 representation = intersect(
915 representation,
916 types.length === 1
917 ? types[0]
918 : { _tag: "Union", types, checks: [] },
919 [...path, "enum"]
920 )
921 }
922 if (typeof schema.$ref === "string") {
923 if (inNestedResource) {
924 throw errorWithPath(
925 "Cannot resolve $ref under a nested \"$id\". Resolve or flatten it first.",
926 [...path, "$ref"]
927 )
928 }
929 const $ref = JsonSchema.getReferenceKey(schema.$ref)
930 if ($ref === undefined) {
931 throw errorWithPath(
932 `Unsupported $ref ${JSON.stringify(schema.$ref)}. Use "#/$defs/Name".`,
933 [...path, "$ref"]
934 )
935 }
936 if (!Object.hasOwn(document.definitions, $ref)) {
937 throw errorWithPath(
938 `Missing definition ${JSON.stringify($ref)} for $ref ${JSON.stringify(schema.$ref)}.`,
939 [...path, "$ref"]
940 )
941 }
942 if (!reachableDefinitions.has($ref)) reachableDefinitions.set($ref, path)
943 const reference: SchemaRepresentation.Reference = { _tag: "Reference", $ref }
944 representation = representation._tag === "Unknown"
945 ? reference
946 : representation._tag === "Never"
947 ? never
948 : intersect(
949 resolveReference(
950 reference,
951 path,
952 `Recursive $ref ${JSON.stringify(reference.$ref)} cannot have sibling constraints.`
953 ),
954 representation,
955 path
956 )
957 }
958 const annotations = jsonSchemaAnnotations(schema)
959 if (annotations !== undefined && representation._tag === "Reference") {
960 annotatedReferences.push({ reference: representation, path })
961 }
962 representation = annotate(representation, annotations)
963
964 if (Array.isArray(schema.allOf)) {
965 for (let index = 0; index < schema.allOf.length; index++) {
966 representation = intersect(
967 representation,
968 translateSchema(schema.allOf[index], [...path, "allOf", index]),
969 [...path, "allOf", index]
970 )
971 }
972 }
973
974 for (const mode of ["anyOf", "oneOf"] as const) {
975 const members = schema[mode]
976 if (Array.isArray(members)) {
977 const union: ImportedJsonSchemaRepresentation = {
978 _tag: "Union",
979 types: members.map((member, index) => translateSchema(member, [...path, mode, index])),
980 ...(mode === "oneOf" ? { options: { mode } } : {}),
981 checks: []
982 }
983 representation = intersect(union, representation, [...path, mode])
984 }
985 }
986 inNestedResource = wasInNestedResource
987 return representation
988 }
989
990 function translateType(
991 schema: JsonSchema.JsonSchema,
992 path: Path
993 ): ImportedJsonSchemaRepresentation {
994 const types = Array.isArray(schema.type) && schema.type.every(isImportedJsonSchemaType)
995 ? schema.type
996 : !isImportedJsonSchemaType(schema.type) && hasTypeSpecificKeywords(schema)
997 ? jsonSchemaValueTypes
998 : undefined
999 if (types !== undefined) {
1000 return {
1001 _tag: "Union",
1002 types: types.map((type) => translateType({ ...schema, type }, path)),
1003 checks: []
1004 }
1005 }
1006
1007 const type = isImportedJsonSchemaType(schema.type) ? schema.type : undefined
1008 switch (type) {
1009 case "null":
1010 return { _tag: "Null", checks: [] }
1011 case "string":
1012 return {
1013 _tag: "String",
1014 checks: collectStringChecks(schema, path)
1015 }
1016 case "number":
1017 case "integer":
1018 return {
1019 _tag: "Number",
1020 checks: [
1021 jsonSchemaFilter(type === "number" ? "effect/schema/isFinite" : "effect/schema/isInt", null),
1022 ...collectNumberChecks(schema)
1023 ]
1024 }
1025 case "boolean":
1026 return { _tag: "Boolean", checks: [] }
1027 case "array": {
1028 const prefixItems = Array.isArray(schema.prefixItems) ? schema.prefixItems : undefined
1029 const minItems = typeof schema.minItems === "number" ? schema.minItems : 0
1030 const elements = prefixItems?.map((element, index) => ({
1031 isOptional: index + 1 > minItems,
1032 type: translateSchema(element, [...path, "prefixItems", index])
1033 })) ?? []
1034 const isTupleClosed = schema.items === false ||
1035 (schema.items === undefined &&
1036 prefixItems !== undefined &&
1037 schema.maxItems === prefixItems.length)
1038 const isMaxItemsRedundant = isTupleClosed &&
1039 typeof schema.maxItems === "number" &&
1040 schema.maxItems >= elements.length
1041 return {
1042 _tag: "Arrays",
1043 elements,
1044 rest: isTupleClosed
1045 ? []
1046 : [
1047 schema.items === undefined ? unknown : translateSchema(schema.items, [...path, "items"])
1048 ],
1049 checks: collectArrayChecks(schema, isMaxItemsRedundant)
1050 }
1051 }
1052 case "object": {
1053 const scope = collectObjectScope(schema, path)
1054 return lowerObject([scope], collectObjectChecks(schema, path), undefined, path)
1055 }
1056 default:
1057 return unknown
1058 }
1059 }
1060
1061 function importPatternCheck(pattern: string, path: Path): Check | undefined {
1062 switch (options?.patterns ?? "error") {
1063 case "error":
1064 throw errorWithPath(
1065 "Patterns may block validation and are disabled. Use patterns: \"apply\" for trusted schemas or \"ignore\" to discard them.",
1066 path
1067 )
1068 case "ignore":
1069 return undefined
1070 case "apply": {
1071 let regex: RegExp
1072 try {
1073 regex = globalThis.RegExp(pattern, jsonSchemaPatternFlags)
1074 } catch {
1075 throw errorWithPath("Cannot import pattern using ECMAScript Unicode mode.", path)
1076 }
1077 return jsonSchemaFilter("effect/schema/isPattern", { source: regex.source, flags: regex.flags })
1078 }
1079 }
1080 }
1081
1082 function collectStringChecks(schema: JsonSchema.JsonSchema, path: Path): Array<Check> {
1083 const checks: Array<Check> = []
1084 if (schema.minLength === 1) {
1085 addNumberCheck(checks, schema.minLength, "effect/schema/isMinLength", "minLength")
1086 } else {
1087 addNumberCheck(checks, schema.minLength, "effect/schema/isMinCodePoints", "minCodePoints")
1088 }
1089 addNumberCheck(checks, schema.maxLength, "effect/schema/isMaxCodePoints", "maxCodePoints")
1090 if (typeof schema.pattern === "string") {
1091 const check = importPatternCheck(schema.pattern, [...path, "pattern"])
1092 if (check !== undefined) checks.push(check)
1093 }
1094 return checks
1095 }
1096
1097 function collectNumberChecks(schema: JsonSchema.JsonSchema): Array<Check> {
1098 const checks: Array<Check> = []
1099 addNumberCheck(checks, schema.minimum, "effect/schema/isGreaterThanOrEqualTo", "minimum")
1100 addNumberCheck(checks, schema.maximum, "effect/schema/isLessThanOrEqualTo", "maximum")
1101 addNumberCheck(checks, schema.exclusiveMinimum, "effect/schema/isGreaterThan", "exclusiveMinimum")
1102 addNumberCheck(checks, schema.exclusiveMaximum, "effect/schema/isLessThan", "exclusiveMaximum")
1103 addNumberCheck(checks, schema.multipleOf, "effect/schema/isMultipleOf", "divisor")
1104 return checks
1105 }
1106
1107 function collectArrayChecks(schema: JsonSchema.JsonSchema, isMaxItemsRedundant: boolean): Array<Check> {
1108 const checks: Array<Check> = []
1109 if (
1110 !Array.isArray(schema.prefixItems) ||
1111 typeof schema.minItems === "number" && schema.minItems > schema.prefixItems.length
1112 ) {
1113 addNumberCheck(checks, schema.minItems, "effect/schema/isMinLength", "minLength")
1114 }
1115 if (!isMaxItemsRedundant) {
1116 addNumberCheck(checks, schema.maxItems, "effect/schema/isMaxLength", "maxLength")
1117 }
1118 if (schema.uniqueItems === true) {
1119 checks.push(jsonSchemaFilter("effect/schema/isUnique", null))
1120 }
1121 return checks
1122 }
1123
1124 function collectObjectScope(
1125 schema: JsonSchema.JsonSchema,
1126 path: Path
1127 ): ImportedObjectScope {
1128 const sourceProperties = isObject(schema.properties) ? schema.properties : {}
1129 const required = new Set(
1130 Array.isArray(schema.required)
1131 ? schema.required.filter((key): key is string => typeof key === "string")
1132 : []
1133 )
1134 const propertyNames = Object.keys(sourceProperties)
1135 const keys = new Set([...propertyNames, ...required])
1136 const properties = new Map(Array.from(keys, (name) => [name, {
1137 type: Object.hasOwn(sourceProperties, name)
1138 ? translateSchema(sourceProperties[name], [...path, "properties", name])
1139 : undefined,
1140 isOptional: !required.has(name)
1141 }]))
1142 const hasProperties = propertyNames.length > 0
1143 const patterns: Array<ImportedObjectPattern> = []
1144 if (
1145 isObject(schema.patternProperties)
1146 ) {
1147 for (const [pattern, value] of Object.entries(schema.patternProperties)) {
1148 const check = importPatternCheck(pattern, [...path, "patternProperties", pattern])
1149 if (check === undefined) return { properties, hasProperties, patterns: [], additionalProperties: unknown }
1150 patterns.push({
1151 source: pattern,
1152 parameter: {
1153 _tag: "String",
1154 checks: [check]
1155 },
1156 type: translateSchema(value, [...path, "patternProperties", pattern])
1157 })
1158 }
1159 }
1160 const additionalProperties = schema.additionalProperties === false
1161 ? never
1162 : isObject(schema.additionalProperties)
1163 ? translateSchema(schema.additionalProperties, [...path, "additionalProperties"])
1164 : unknown
1165 return { properties, hasProperties, patterns, additionalProperties }
1166 }
1167
1168 function collectObjectChecks(
1169 schema: JsonSchema.JsonSchema,
1170 path: Path
1171 ): Array<Check> {
1172 const checks: Array<Check> = []
1173 addNumberCheck(checks, schema.minProperties, "effect/schema/isMinProperties", "minProperties")
1174 addNumberCheck(checks, schema.maxProperties, "effect/schema/isMaxProperties", "maxProperties")
1175 if (schema.propertyNames !== undefined) {
1176 const propertyNamesPath = [...path, "propertyNames"]
1177 checks.push(jsonSchemaFilter(
1178 "effect/schema/isPropertyNames",
1179 null,
1180 [intersect(
1181 string,
1182 translateSchema(schema.propertyNames, propertyNamesPath),
1183 propertyNamesPath
1184 )]
1185 ))
1186 }
1187 return checks
1188 }
1189
1190 const references: Record<string, Representation> = {}
1191 const representations = document.schemas.map((schema, index) =>
1192 finalize(translateSchema(schema, singleRoot ? ["schema"] : ["schemas", index], true))
1193 ) as [Representation, ...Array<Representation>]
1194 // Definitions expanded away by intersections do not need a standalone output schema.
1195 for (const key of finalReferences) {
1196 InternalRecord.assignProperty(references, key, finalize(translateDefinition(key, reachableDefinitions.get(key)!)))
1197 }
1198 for (const { reference, path } of annotatedReferences) {
1199 resolveReference(reference, path)
1200 }
1201 return { representations, references }
1202}
1203
1204/** @internal */
1205export function fromJsonSchemaDocument(
1206 document: JsonSchema.Document<"draft-2020-12">,
1207 options: SchemaRepresentation.FromJsonSchemaOptions | undefined,
1208 revivers: ReadonlyArray<SchemaRepresentation.AnyReviver>
1209): Schema.Top {
1210 const translated = translateJsonSchemaMultiDocument(
1211 {
1212 dialect: document.dialect,
1213 schemas: [document.schema],
1214 definitions: document.definitions
1215 },
1216 options,
1217 true
1218 )
1219 return fromRepresentation({
1220 representation: translated.representations[0],
1221 references: translated.references
1222 }, revivers)
1223}
1224
1225/** @internal */
1226export function fromJsonSchemaMultiDocument(
1227 document: JsonSchema.MultiDocument<"draft-2020-12">,
1228 options: SchemaRepresentation.FromJsonSchemaOptions | undefined,
1229 revivers: ReadonlyArray<SchemaRepresentation.AnyReviver>
1230): readonly [Schema.Top, ...Array<Schema.Top>] {
1231 return fromRepresentations(translateJsonSchemaMultiDocument(document, options), revivers)
1232}