1import { assert, describe, it } from "@effect/vitest"
2import { JsonSchema, Schema, SchemaAST, SchemaRepresentation } from "effect"
3import { throws } from "../../utils/assert.ts"
4
5function expectError(thunk: () => void, expected: string | Error): void {
6 if (typeof expected === "string") {
7 throws(thunk, expected)
8 } else {
9 throws(thunk, (error: unknown) => {
10 assert.strictEqual(error, expected)
11 return undefined
12 })
13 }
14}
15
16const StringRepresentation: SchemaRepresentation.Representation = {
17 _tag: "String",
18 checks: []
19}
20
21const NumberRepresentation: SchemaRepresentation.Representation = {
22 _tag: "Number",
23 checks: []
24}
25
26const EmptyUnionRepresentation: SchemaRepresentation.Representation = {
27 _tag: "Union",
28 types: [],
29 options: { mode: "anyOf" },
30 checks: []
31}
32
33describe("SchemaRepresentation.toJsonSchemaDocument", () => {
34 // Representation documents are public, persistable inputs. These tests construct them
35 // directly so the compiler is covered independently from Schema-to-Representation lowering.
36 function compile(representation: SchemaRepresentation.Representation) {
37 return SchemaRepresentation.toJsonSchemaDocument(
38 { representation, references: {} },
39 { onExcessProperty: "error" }
40 ).schema
41 }
42
43 describe("local reference round trips", () => {
44 for (
45 const [key, token] of [
46 ["Rate", "Rate"],
47 ["id~a/b", "id~0a~1b"],
48 ["Rate%", "Rate%25"],
49 ["id~a/b%25", "id~0a~1b%2525"]
50 ]
51 ) {
52 it(`preserves the definition named ${key}`, () => {
53 const input = JsonSchema.fromSchemaDraft2020_12({
54 $ref: `#/$defs/${token}`,
55 $defs: { [key]: { type: "string" } }
56 })
57 const imported = SchemaRepresentation.fromJsonSchemaDocument(input)
58 assert.strictEqual(Schema.is(imported)("value"), true)
59 assert.strictEqual(Schema.is(imported)(1), false)
60
61 const output = SchemaRepresentation.toJsonSchemaDocument(
62 SchemaRepresentation.toRepresentation(imported.ast)
63 )
64 assert.deepStrictEqual(Object.keys(output.definitions), [key])
65 const restored = SchemaRepresentation.fromJsonSchemaDocument(output)
66 assert.strictEqual(Schema.is(restored)("value"), true)
67 assert.strictEqual(Schema.is(restored)(1), false)
68 assert.strictEqual(output.schema.$ref, input.schema.$ref)
69 })
70 }
71 })
72
73 describe("representation nodes", () => {
74 const keywords = [
75 ["Any", { _tag: "Any", checks: [] }, {}],
76 ["Unknown", { _tag: "Unknown", checks: [] }, {}],
77 ["ObjectKeyword", { _tag: "ObjectKeyword", checks: [] }, {
78 anyOf: [{ type: "object" }, { type: "array" }]
79 }],
80 ["Void", { _tag: "Void", checks: [] }, { type: "null" }],
81 ["Undefined", { _tag: "Undefined", checks: [] }, { type: "null" }],
82 ["BigInt", { _tag: "BigInt", checks: [] }, {
83 type: "string",
84 allOf: [{ pattern: "^-?\\d+$" }]
85 }],
86 ["Symbol", { _tag: "Symbol", checks: [] }, {
87 type: "string",
88 allOf: [{ pattern: "^Symbol\\(([\\s\\S]*)\\)$" }]
89 }],
90 ["UniqueSymbol", { _tag: "UniqueSymbol", symbol: Symbol.for("value"), checks: [] }, {
91 type: "string",
92 enum: ["Symbol(value)"]
93 }],
94 ["Null", { _tag: "Null", checks: [] }, { type: "null" }],
95 ["Never", { _tag: "Never", checks: [] }, { not: {} }]
96 ] satisfies ReadonlyArray<
97 readonly [string, SchemaRepresentation.Representation, JsonSchema.JsonSchema]
98 >
99
100 for (const [tag, representation, expected] of keywords) {
101 it(tag, () => {
102 assert.deepStrictEqual(compile(representation), expected)
103 })
104 }
105
106 it("local UniqueSymbol", () => {
107 assert.deepStrictEqual(
108 compile({ _tag: "UniqueSymbol", symbol: Symbol("value"), checks: [] }),
109 { not: {} }
110 )
111 })
112
113 it("compiles Suspend", () => {
114 assert.deepStrictEqual(
115 compile({
116 _tag: "Suspend",
117 thunk: StringRepresentation,
118 checks: []
119 }),
120 { type: "string" }
121 )
122 })
123
124 it("compiles a bigint Literal", () => {
125 assert.deepStrictEqual(
126 compile({
127 _tag: "Literal",
128 literal: 1n,
129 checks: []
130 }),
131 {
132 type: "string",
133 enum: ["1"]
134 }
135 )
136 })
137
138 it("compiles a string Literal", () => {
139 assert.deepStrictEqual(
140 compile({
141 _tag: "Literal",
142 literal: "a",
143 checks: []
144 }),
145 {
146 type: "string",
147 enum: ["a"]
148 }
149 )
150 })
151
152 it("compiles an empty Enum", () => {
153 assert.deepStrictEqual(compile({ _tag: "Enum", enums: [], checks: [] }), { not: {} })
154 })
155
156 it("compiles an Enum", () => {
157 assert.deepStrictEqual(
158 compile({
159 _tag: "Enum",
160 enums: [
161 ["A", "a"],
162 ["One", 1]
163 ],
164 checks: []
165 }),
166 {
167 anyOf: [
168 { type: "string", enum: ["a"], title: "A" },
169 { type: "number", enum: [1], title: "One" }
170 ]
171 }
172 )
173 })
174
175 it("rejects non-finite numeric Enum values", () => {
176 expectError(
177 () =>
178 compile({
179 _tag: "Enum",
180 enums: [
181 ["StringNaN", "NaN"],
182 ["NumberNaN", Number.NaN]
183 ],
184 checks: []
185 }),
186 `Invalid numeric enum value NaN\n at ["representation"]["enums"][1][1]`
187 )
188 })
189
190 it("compiles a TemplateLiteral", () => {
191 assert.deepStrictEqual(
192 compile({
193 _tag: "TemplateLiteral",
194 parts: [
195 { _tag: "Literal", literal: "prefix-", checks: [] },
196 StringRepresentation
197 ],
198 checks: []
199 }),
200 { type: "string", pattern: "^prefix-[\\s\\S]*?$" }
201 )
202 })
203 })
204
205 describe("arrays and objects", () => {
206 it("compiles an empty Objects node as any non-null JSON value", () => {
207 assert.deepStrictEqual(
208 compile({
209 _tag: "Objects",
210 propertySignatures: [],
211 indexSignatures: [],
212 checks: []
213 }),
214 { not: { type: "null" } }
215 )
216 })
217
218 it("compiles optional tuple elements", () => {
219 assert.deepStrictEqual(
220 compile({
221 _tag: "Arrays",
222 elements: [
223 { type: StringRepresentation, isOptional: false },
224 { type: NumberRepresentation, isOptional: true }
225 ],
226 rest: [],
227 checks: []
228 }),
229 {
230 type: "array",
231 prefixItems: [{ type: "string" }, {
232 anyOf: [
233 { type: "number" },
234 { type: "string", enum: ["NaN", "Infinity", "-Infinity"] }
235 ]
236 }],
237 maxItems: 2,
238 minItems: 1
239 }
240 )
241 })
242
243 it("rejects multiple tuple rest elements", () => {
244 expectError(
245 () =>
246 compile({
247 _tag: "Arrays",
248 elements: [],
249 rest: [StringRepresentation, NumberRepresentation],
250 checks: []
251 }),
252 `Invalid schema representation document\n at ["representation"]["rest"]`
253 )
254 })
255
256 it("rejects a symbol object property", () => {
257 expectError(
258 () =>
259 compile({
260 _tag: "Objects",
261 propertySignatures: [{
262 name: Symbol.for("value"),
263 type: StringRepresentation,
264 isOptional: false,
265 isMutable: false
266 }],
267 indexSignatures: [],
268 checks: []
269 }),
270 `Invalid schema representation document\n at ["representation"]["propertySignatures"][0]["name"]`
271 )
272 })
273
274 it("compiles a Never index-signature value as false", () => {
275 assert.deepStrictEqual(
276 compile({
277 _tag: "Objects",
278 propertySignatures: [],
279 indexSignatures: [{ parameter: StringRepresentation, type: { _tag: "Never", checks: [] } }],
280 checks: []
281 }),
282 { type: "object", additionalProperties: false }
283 )
284 })
285
286 it("removes an unconstrained index-signature value", () => {
287 assert.deepStrictEqual(
288 compile({
289 _tag: "Objects",
290 propertySignatures: [],
291 indexSignatures: [{ parameter: StringRepresentation, type: { _tag: "Unknown", checks: [] } }],
292 checks: []
293 }),
294 { type: "object" }
295 )
296 })
297 })
298
299 describe("unions", () => {
300 it("compacts a union of same-type literals", () => {
301 assert.deepStrictEqual(
302 compile({
303 _tag: "Union",
304 types: [
305 { _tag: "Literal", literal: "a", checks: [] },
306 { _tag: "Literal", literal: "b", checks: [] }
307 ],
308 options: { mode: "anyOf" },
309 checks: []
310 }),
311 { type: "string", enum: ["a", "b"] }
312 )
313 })
314
315 it("does not compact a union of mixed-type literals", () => {
316 assert.deepStrictEqual(
317 compile({
318 _tag: "Union",
319 types: [
320 { _tag: "Literal", literal: "a", checks: [] },
321 { _tag: "Literal", literal: 1, checks: [] }
322 ],
323 options: { mode: "anyOf" },
324 checks: []
325 }),
326 {
327 anyOf: [{ type: "string", enum: ["a"] }, { type: "number", enum: [1] }]
328 }
329 )
330 })
331 })
332
333 describe("annotations and checks", () => {
334 it("emits every supported standard annotation", () => {
335 assert.deepStrictEqual(
336 compile({
337 _tag: "String",
338 annotations: {
339 title: "Title",
340 description: "Description",
341 default: "default",
342 examples: ["a", "b"],
343 readOnly: true,
344 writeOnly: false
345 },
346 checks: []
347 }),
348 {
349 type: "string",
350 title: "Title",
351 description: "Description",
352 default: "default",
353 examples: ["a", "b"],
354 readOnly: true,
355 writeOnly: false
356 }
357 )
358 })
359
360 it("uses a check fragment when the base JSON Schema is empty", () => {
361 assert.deepStrictEqual(
362 compile({
363 _tag: "Unknown",
364 checks: [{
365 _tag: "Filter",
366 aborted: false,
367 annotations: { toJsonSchema: () => ({ description: "checked" }) }
368 }]
369 }),
370 { description: "checked" }
371 )
372 })
373
374 it("appends a check to an existing allOf", () => {
375 assert.deepStrictEqual(
376 compile({
377 _tag: "BigInt",
378 checks: [{
379 _tag: "Filter",
380 aborted: false,
381 annotations: { toJsonSchema: () => ({ description: "checked" }) }
382 }]
383 }),
384 {
385 type: "string",
386 allOf: [{ pattern: "^-?\\d+$" }, { description: "checked" }]
387 }
388 )
389 })
390
391 it("lets a check refine number to integer", () => {
392 assert.deepStrictEqual(
393 compile({
394 _tag: "Number",
395 checks: [{
396 _tag: "Filter",
397 aborted: false,
398 annotations: { toJsonSchema: () => ({ type: "integer" }) }
399 }]
400 }),
401 { type: "integer" }
402 )
403 })
404
405 it("compiles a callback-free FilterGroup without ordinary annotations", () => {
406 assert.deepStrictEqual(
407 compile({
408 _tag: "String",
409 checks: [{
410 _tag: "FilterGroup",
411 checks: [{
412 _tag: "Filter",
413 aborted: false,
414 annotations: { toJsonSchema: () => ({ minLength: 1 }) }
415 }]
416 }]
417 }),
418 { type: "string", allOf: [{ minLength: 1 }] }
419 )
420 })
421 })
422
423 describe("collection and union edge cases", () => {
424 it("compiles tuple element annotations", () => {
425 assert.deepStrictEqual(
426 compile({
427 _tag: "Arrays",
428 elements: [{
429 type: StringRepresentation,
430 isOptional: false,
431 annotations: { description: "element" }
432 }],
433 rest: [],
434 checks: []
435 }),
436 {
437 type: "array",
438 prefixItems: [{ type: "string", allOf: [{ description: "element" }] }],
439 maxItems: 1,
440 minItems: 1
441 }
442 )
443 })
444
445 it("omits minItems when every tuple element is optional", () => {
446 assert.deepStrictEqual(
447 compile({
448 _tag: "Arrays",
449 elements: [{ type: StringRepresentation, isOptional: true }],
450 rest: [],
451 checks: []
452 }),
453 {
454 type: "array",
455 prefixItems: [{ type: "string" }],
456 maxItems: 1
457 }
458 )
459 })
460
461 it("compiles a constrained tuple rest", () => {
462 assert.deepStrictEqual(
463 compile({
464 _tag: "Arrays",
465 elements: [],
466 rest: [StringRepresentation],
467 checks: []
468 }),
469 { type: "array", items: { type: "string" } }
470 )
471 })
472
473 it("compiles property annotations", () => {
474 assert.deepStrictEqual(
475 compile({
476 _tag: "Objects",
477 propertySignatures: [{
478 name: "value",
479 type: StringRepresentation,
480 isOptional: true,
481 isMutable: false,
482 annotations: { description: "property" }
483 }],
484 indexSignatures: [],
485 checks: []
486 }),
487 {
488 type: "object",
489 properties: { value: { type: "string", allOf: [{ description: "property" }] } },
490 additionalProperties: false
491 }
492 )
493 })
494
495 it("compiles a single-member Union without compacting it", () => {
496 assert.deepStrictEqual(
497 compile({
498 _tag: "Union",
499 types: [StringRepresentation],
500 options: { mode: "anyOf" },
501 checks: []
502 }),
503 { anyOf: [{ type: "string" }] }
504 )
505 })
506
507 it("falls back from oneOf to anyOf when a branch is approximate", () => {
508 const approximate: SchemaRepresentation.Representation = {
509 _tag: "String",
510 checks: [{
511 _tag: "Filter",
512 aborted: false,
513 annotations: { toJsonSchema: () => [{ minLength: 1 }, true] }
514 }]
515 }
516 assert.deepStrictEqual(
517 compile({
518 _tag: "Union",
519 types: [approximate, { _tag: "Boolean", checks: [] }],
520 options: { mode: "oneOf" },
521 annotations: { description: "annotated union" },
522 checks: []
523 }),
524 {
525 anyOf: [{ type: "string", minLength: 1 }, { type: "boolean" }],
526 description: "annotated union"
527 }
528 )
529 })
530
531 it("propagates approximation through nested representations", () => {
532 const approximate: SchemaRepresentation.Representation = {
533 _tag: "String",
534 checks: [{ _tag: "Filter", aborted: false }]
535 }
536 assert.deepStrictEqual(
537 compile({
538 _tag: "Union",
539 types: [{ _tag: "Arrays", elements: [], rest: [approximate], checks: [] }, NumberRepresentation],
540 options: { mode: "oneOf" },
541 checks: []
542 }),
543 {
544 anyOf: [
545 { type: "array", items: { type: "string" } },
546 {
547 anyOf: [
548 { type: "number" },
549 { type: "string", enum: ["NaN", "Infinity", "-Infinity"] }
550 ]
551 }
552 ]
553 }
554 )
555 })
556
557 it("resolves an approximate oneOf copied by contextual annotations", () => {
558 const approximate: SchemaRepresentation.Representation = {
559 _tag: "String",
560 checks: [{
561 _tag: "Filter",
562 aborted: false,
563 annotations: { toJsonSchema: () => [{ minLength: 1 }, true] }
564 }]
565 }
566 assert.deepStrictEqual(
567 compile({
568 _tag: "Arrays",
569 elements: [{
570 type: {
571 _tag: "Union",
572 types: [approximate, { _tag: "Boolean", checks: [] }],
573 options: { mode: "oneOf" },
574 checks: []
575 },
576 isOptional: false,
577 annotations: { description: "element" }
578 }],
579 rest: [],
580 checks: []
581 }),
582 {
583 type: "array",
584 prefixItems: [{
585 anyOf: [{ type: "string", minLength: 1 }, { type: "boolean" }],
586 allOf: [{ description: "element" }]
587 }],
588 minItems: 1,
589 maxItems: 1
590 }
591 )
592 })
593
594 it("keeps oneOf when every branch is exact", () => {
595 assert.deepStrictEqual(
596 compile({
597 _tag: "Union",
598 types: [StringRepresentation, { _tag: "Boolean", checks: [] }],
599 options: { mode: "oneOf" },
600 checks: []
601 }),
602 { oneOf: [{ type: "string" }, { type: "boolean" }] }
603 )
604 })
605
606 it("rejects an unsupported index-signature parameter", () => {
607 expectError(
608 () =>
609 compile({
610 _tag: "Objects",
611 propertySignatures: [],
612 indexSignatures: [{ parameter: NumberRepresentation, type: StringRepresentation }],
613 checks: []
614 }),
615 `Invalid schema representation document\n at ["representation"]["indexSignatures"][0]["parameter"]`
616 )
617 })
618
619 it("compiles an empty union as Never", () => {
620 assert.deepStrictEqual(
621 SchemaRepresentation.toJsonSchemaDocument({
622 representation: EmptyUnionRepresentation,
623 references: {}
624 }).schema,
625 { not: {} }
626 )
627 })
628
629 it("removes items when an empty tuple has an open rest", () => {
630 assert.deepStrictEqual(
631 SchemaRepresentation.toJsonSchemaDocument({
632 representation: {
633 _tag: "Arrays",
634 elements: [],
635 rest: [{ _tag: "Unknown", checks: [] }],
636 checks: []
637 },
638 references: {}
639 }).schema,
640 { type: "array" }
641 )
642 })
643 })
644
645 describe("checks and callbacks", () => {
646 it("passes the type produced by isInt to following check callbacks", () => {
647 let receivedType: unknown
648 const dependent = Schema.makeFilter<number>(() => true, {
649 toJsonSchema: ({ type }) => {
650 receivedType = type
651 return { minimum: 0 }
652 }
653 })
654 const document = SchemaRepresentation.toRepresentation(
655 Schema.Number.check(Schema.isInt(), dependent).ast
656 )
657
658 assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, {
659 type: "integer",
660 minimum: 0
661 })
662 assert.strictEqual(receivedType, "integer")
663 })
664
665 it("omits isPattern flags that JSON Schema cannot represent", () => {
666 const pattern = SchemaRepresentation.toJsonSchemaDocument(
667 SchemaRepresentation.toRepresentation(Schema.String.check(Schema.isPattern(/^[a-z]+$/i)).ast)
668 )
669 assert.deepStrictEqual(pattern, {
670 dialect: "draft-2020-12",
671 schema: { type: "string" },
672 definitions: {}
673 })
674 })
675
676 it("treats an empty override as authoritative and ignores a leaf without a callback", () => {
677 let visits = 0
678 const document: SchemaRepresentation.Document = {
679 representation: {
680 _tag: "String",
681 checks: [{
682 _tag: "FilterGroup",
683 annotations: { toJsonSchema: () => ({}) },
684 checks: [{
685 _tag: "Filter",
686 aborted: false,
687 annotations: {
688 description: "ignored",
689 toJsonSchema: () => {
690 visits++
691 return { minLength: 1 }
692 }
693 }
694 }]
695 }, {
696 _tag: "Filter",
697 aborted: false,
698 annotations: { description: "no callback" }
699 }]
700 },
701 references: {}
702 }
703
704 assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, { type: "string" })
705 assert.strictEqual(visits, 0)
706 })
707
708 it("compiles representation.schemas before invoking a callback", () => {
709 const document: SchemaRepresentation.Document = {
710 representation: {
711 _tag: "Objects",
712 propertySignatures: [],
713 indexSignatures: [],
714 checks: [{
715 _tag: "Filter",
716 aborted: false,
717 representation: {
718 id: "acme/schema/propertyNames",
719 payload: null,
720 schemas: [StringRepresentation]
721 },
722 annotations: {
723 toJsonSchema: ({ schemas }: SchemaRepresentation.ToJsonSchema.CheckInput) => ({
724 propertyNames: schemas[0]
725 })
726 }
727 }]
728 },
729 references: {}
730 }
731
732 assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, {
733 not: { type: "null" },
734 propertyNames: { type: "string" }
735 })
736 })
737
738 it("propagates approximation from representation.schemas", () => {
739 const approximate: SchemaRepresentation.Representation = {
740 _tag: "String",
741 checks: [{
742 _tag: "Filter",
743 aborted: false,
744 annotations: { toJsonSchema: () => [{ minLength: 1 }, true] }
745 }]
746 }
747 const dependent: SchemaRepresentation.Representation = {
748 _tag: "String",
749 checks: [{
750 _tag: "Filter",
751 aborted: false,
752 representation: {
753 id: "acme/schema/dependent",
754 payload: null,
755 schemas: [approximate]
756 },
757 annotations: { toJsonSchema: () => ({}) }
758 }]
759 }
760
761 assert.deepStrictEqual(
762 compile({
763 _tag: "Union",
764 types: [dependent, { _tag: "Boolean", checks: [] }],
765 options: { mode: "oneOf" },
766 checks: []
767 }),
768 { anyOf: [{ type: "string" }, { type: "boolean" }] }
769 )
770 })
771 })
772
773 describe("declarations and pattern extraction", () => {
774 it("approximates declarations", () => {
775 const document: SchemaRepresentation.Document = {
776 representation: {
777 _tag: "Declaration",
778 typeParameters: [StringRepresentation],
779 checks: [],
780 representation: {
781 id: "acme/schema/Box",
782 payload: null
783 }
784 },
785 references: {}
786 }
787
788 assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, {})
789 })
790
791 it("compiles every member of a union index-signature parameter", () => {
792 const template = SchemaRepresentation.toRepresentation(
793 Schema.TemplateLiteral(["a", Schema.String]).ast
794 ).representation
795 const pattern = SchemaRepresentation.toRepresentation(
796 Schema.String.check(Schema.isPattern(/^b/u)).ast
797 ).representation
798 const document: SchemaRepresentation.Document = {
799 representation: {
800 _tag: "Objects",
801 propertySignatures: [],
802 indexSignatures: [{
803 parameter: {
804 _tag: "Union",
805 types: [template, pattern],
806 options: { mode: "anyOf" },
807 checks: []
808 },
809 type: StringRepresentation
810 }],
811 checks: []
812 },
813 references: {}
814 }
815
816 const schema = SchemaRepresentation.toJsonSchemaDocument(document).schema
817 assert.deepStrictEqual(Object.keys(schema.patternProperties ?? {}), ["^a[\\s\\S]*?$", "^b"])
818 })
819
820 it("does not extract a partial pattern from boolean applicator members", () => {
821 const document: SchemaRepresentation.Document = {
822 representation: {
823 _tag: "Objects",
824 propertySignatures: [],
825 indexSignatures: [{
826 parameter: {
827 _tag: "String",
828 checks: [{
829 _tag: "Filter",
830 aborted: false,
831 annotations: {
832 toJsonSchema: () => ({ allOf: [false, { pattern: "^a" }] })
833 }
834 }]
835 },
836 type: StringRepresentation
837 }],
838 checks: []
839 },
840 references: {}
841 }
842
843 assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaDocument(document).schema, {
844 type: "object",
845 additionalProperties: true
846 })
847 })
848
849 it("does not use an approximate pattern as an index selector", () => {
850 const document: SchemaRepresentation.Document = {
851 representation: {
852 _tag: "Objects",
853 propertySignatures: [],
854 indexSignatures: [{
855 parameter: {
856 _tag: "String",
857 checks: [{
858 _tag: "Filter",
859 aborted: false,
860 annotations: { toJsonSchema: () => [{ pattern: "^a" }, true] }
861 }]
862 },
863 type: NumberRepresentation
864 }],
865 checks: []
866 },
867 references: {}
868 }
869
870 assert.deepStrictEqual(compile(document.representation), {
871 type: "object",
872 propertyNames: { type: "string", pattern: "^a" },
873 additionalProperties: {
874 anyOf: [
875 { type: "number" },
876 { type: "string", enum: ["NaN", "Infinity", "-Infinity"] }
877 ]
878 }
879 })
880 assert.deepStrictEqual(
881 compile({
882 _tag: "Union",
883 types: [document.representation, { _tag: "Boolean", checks: [] }],
884 options: { mode: "oneOf" },
885 checks: []
886 }),
887 {
888 anyOf: [{
889 type: "object",
890 propertyNames: { type: "string", pattern: "^a" },
891 additionalProperties: {
892 anyOf: [
893 { type: "number" },
894 { type: "string", enum: ["NaN", "Infinity", "-Infinity"] }
895 ]
896 }
897 }, { type: "boolean" }]
898 }
899 )
900 })
901
902 it("keeps an exact key selector when its value is approximate", () => {
903 const key: SchemaRepresentation.Representation = {
904 _tag: "String",
905 checks: [{
906 _tag: "Filter",
907 aborted: false,
908 annotations: { toJsonSchema: () => ({ pattern: "^a" }) }
909 }]
910 }
911 const value: SchemaRepresentation.Representation = {
912 _tag: "String",
913 checks: [{
914 _tag: "Filter",
915 aborted: false,
916 annotations: { toJsonSchema: () => [{ minLength: 1 }, true] }
917 }]
918 }
919 assert.deepStrictEqual(
920 compile({
921 _tag: "Union",
922 types: [{
923 _tag: "Objects",
924 propertySignatures: [],
925 indexSignatures: [{ parameter: key, type: value }],
926 checks: []
927 }, { _tag: "Boolean", checks: [] }],
928 options: { mode: "oneOf" },
929 checks: []
930 }),
931 {
932 anyOf: [{
933 type: "object",
934 patternProperties: { "^a": { type: "string", minLength: 1 } },
935 additionalProperties: false
936 }, { type: "boolean" }]
937 }
938 )
939 })
940
941 it("compiles all supported template-literal parts and rejects other nodes", () => {
942 const representation: SchemaRepresentation.Representation = {
943 _tag: "TemplateLiteral",
944 parts: [
945 { _tag: "Literal", literal: "p", checks: [] },
946 NumberRepresentation,
947 {
948 _tag: "TemplateLiteral",
949 parts: [
950 { _tag: "Literal", literal: "x", checks: [] },
951 StringRepresentation
952 ],
953 checks: []
954 },
955 {
956 _tag: "Union",
957 types: [
958 { _tag: "Literal", literal: "a", checks: [] },
959 { _tag: "Literal", literal: "b", checks: [] }
960 ],
961 options: { mode: "anyOf" },
962 checks: []
963 }
964 ],
965 checks: []
966 }
967
968 assert.deepStrictEqual(
969 SchemaRepresentation.toJsonSchemaDocument({ representation, references: {} }).schema,
970 {
971 type: "string",
972 pattern: `^p${SchemaAST.FINITE_PATTERN}x${SchemaAST.STRING_PATTERN}(?:a|b)$`
973 }
974 )
975
976 expectError(
977 () =>
978 SchemaRepresentation.toJsonSchemaDocument({
979 representation: {
980 _tag: "TemplateLiteral",
981 parts: [{ _tag: "Boolean", checks: [] }],
982 checks: []
983 },
984 references: {}
985 }),
986 "Invalid schema representation document"
987 )
988 })
989 })
990
991 describe("normalization", () => {
992 describe("conservative check compaction", () => {
993 function check(
994 fragment: JsonSchema.JsonSchema,
995 annotations?: SchemaRepresentation.Filter["annotations"]
996 ): SchemaRepresentation.Filter {
997 return {
998 _tag: "Filter",
999 aborted: false,
1000 annotations: { ...annotations, toJsonSchema: () => fragment }
1001 }
1002 }
1003
1004 function compileChecks(...fragments: ReadonlyArray<JsonSchema.JsonSchema>) {
1005 return compile({
1006 _tag: "Any",
1007 checks: fragments.map((fragment) => check(fragment))
1008 })
1009 }
1010
1011 it("keeps colliding keywords in separate schema objects", () => {
1012 assert.deepStrictEqual(
1013 compile({
1014 _tag: "String",
1015 checks: [check({ minLength: 1 }), check({ minLength: 2 })]
1016 }),
1017 {
1018 type: "string",
1019 minLength: 1,
1020 allOf: [{ minLength: 2 }]
1021 }
1022 )
1023 })
1024
1025 it("inlines pure annotations on a check", () => {
1026 assert.deepStrictEqual(
1027 compile({
1028 _tag: "String",
1029 checks: [check({ minLength: 1 }, {
1030 title: "title",
1031 description: "description",
1032 default: "default",
1033 examples: ["example"],
1034 readOnly: true,
1035 writeOnly: false
1036 })]
1037 }),
1038 {
1039 type: "string",
1040 minLength: 1,
1041 title: "title",
1042 description: "description",
1043 default: "default",
1044 examples: ["example"],
1045 readOnly: true,
1046 writeOnly: false
1047 }
1048 )
1049 })
1050
1051 it("inlines callback metadata with pure check annotations", () => {
1052 assert.deepStrictEqual(
1053 compile({
1054 _tag: "String",
1055 checks: [check({ minLength: 1, title: "callback" }, { description: "annotation" })]
1056 }),
1057 {
1058 type: "string",
1059 minLength: 1,
1060 title: "callback",
1061 description: "annotation"
1062 }
1063 )
1064 })
1065
1066 it("keeps colliding annotations in a separate schema object", () => {
1067 assert.deepStrictEqual(
1068 compile({
1069 _tag: "String",
1070 annotations: { description: "string" },
1071 checks: [check({ minLength: 1 }, { description: "check" })]
1072 }),
1073 {
1074 type: "string",
1075 description: "string",
1076 allOf: [{ minLength: 1, description: "check" }]
1077 }
1078 )
1079 })
1080
1081 it("keeps format annotations in a separate schema object", () => {
1082 assert.deepStrictEqual(
1083 compile({
1084 _tag: "String",
1085 checks: [check({ minLength: 1 }, { format: "annotated-check" })]
1086 }),
1087 {
1088 type: "string",
1089 allOf: [{ minLength: 1, format: "annotated-check" }]
1090 }
1091 )
1092 })
1093
1094 it("inlines propertyNames without a collision", () => {
1095 assert.deepStrictEqual(
1096 compile({
1097 _tag: "Objects",
1098 propertySignatures: [],
1099 indexSignatures: [],
1100 checks: [check({ propertyNames: { minLength: 1 } })]
1101 }),
1102 {
1103 not: { type: "null" },
1104 propertyNames: { minLength: 1 }
1105 }
1106 )
1107 })
1108
1109 it("keeps colliding propertyNames in separate schema objects", () => {
1110 assert.deepStrictEqual(
1111 compile({
1112 _tag: "Objects",
1113 propertySignatures: [],
1114 indexSignatures: [],
1115 checks: [
1116 check({ propertyNames: { minLength: 1 } }),
1117 check({ propertyNames: { maxLength: 2 } })
1118 ]
1119 }),
1120 {
1121 not: { type: "null" },
1122 propertyNames: { minLength: 1 },
1123 allOf: [{ propertyNames: { maxLength: 2 } }]
1124 }
1125 )
1126 })
1127
1128 it("keeps a reference separate from contextual annotations", () => {
1129 const document = SchemaRepresentation.toJsonSchemaDocument({
1130 representation: {
1131 _tag: "Objects",
1132 propertySignatures: [{
1133 name: "value",
1134 type: { _tag: "Reference", $ref: "Value" },
1135 isOptional: false,
1136 isMutable: false,
1137 annotations: { description: "contextual" }
1138 }],
1139 indexSignatures: [],
1140 checks: []
1141 },
1142 references: { Value: StringRepresentation }
1143 })
1144
1145 assert.deepStrictEqual(document.schema, {
1146 type: "object",
1147 properties: {
1148 value: {
1149 allOf: [
1150 { $ref: "#/$defs/Value" },
1151 { description: "contextual" }
1152 ]
1153 }
1154 },
1155 required: ["value"],
1156 additionalProperties: true
1157 })
1158 })
1159
1160 const coupledKeywords = [
1161 [
1162 "contains and minContains",
1163 { contains: { const: "a" } },
1164 { minContains: 2 }
1165 ],
1166 [
1167 "properties and additionalProperties",
1168 { properties: { a: true } },
1169 { additionalProperties: false }
1170 ],
1171 [
1172 "prefixItems and items",
1173 { prefixItems: [true] },
1174 { items: false }
1175 ],
1176 [
1177 "if and else",
1178 { if: { required: ["a"] } },
1179 { else: { required: ["b"] } }
1180 ]
1181 ] satisfies ReadonlyArray<readonly [string, JsonSchema.JsonSchema, JsonSchema.JsonSchema]>
1182
1183 for (const [name, first, second] of coupledKeywords) {
1184 it(`keeps ${name} in separate schema objects`, () => {
1185 for (const [left, right] of [[first, second], [second, first]]) {
1186 assert.deepStrictEqual(compileChecks(left, right), {
1187 ...left,
1188 allOf: [right]
1189 })
1190 }
1191 })
1192 }
1193
1194 it("keeps a fragment containing a custom keyword intact", () => {
1195 assert.deepStrictEqual(
1196 compile({
1197 _tag: "String",
1198 checks: [check({ minLength: 1, "x-check": true })]
1199 }),
1200 {
1201 type: "string",
1202 allOf: [{ minLength: 1, "x-check": true }]
1203 }
1204 )
1205 })
1206
1207 it("keeps format scoped after absorbing a number type", () => {
1208 assert.deepStrictEqual(
1209 compile({
1210 _tag: "Number",
1211 checks: [check({ type: "number" }, { format: "finite" })]
1212 }),
1213 {
1214 type: "number",
1215 allOf: [{ format: "finite" }]
1216 }
1217 )
1218 })
1219
1220 it("inlines callback metadata after absorbing a number type", () => {
1221 assert.deepStrictEqual(
1222 compile({
1223 _tag: "Number",
1224 checks: [check({ type: "integer", description: "integer" })]
1225 }),
1226 {
1227 type: "integer",
1228 description: "integer"
1229 }
1230 )
1231 })
1232
1233 it("does not overwrite an annotation after absorbing a number type", () => {
1234 assert.deepStrictEqual(
1235 compile({
1236 _tag: "Number",
1237 annotations: { description: "number" },
1238 checks: [check({ type: "integer" }, { description: "integer" })]
1239 }),
1240 {
1241 type: "integer",
1242 description: "number",
1243 allOf: [{ description: "integer" }]
1244 }
1245 )
1246 })
1247 })
1248
1249 it("extracts nested number types without losing other allOf members", () => {
1250 const output = SchemaRepresentation.toJsonSchemaDocument({
1251 representation: {
1252 _tag: "Number",
1253 checks: [{
1254 _tag: "Filter",
1255 aborted: false,
1256 annotations: {
1257 toJsonSchema: () => ({
1258 allOf: [
1259 {
1260 description: "nested",
1261 allOf: [{ type: "number" }, { minimum: 1 }]
1262 },
1263 { type: "integer", maximum: 10 },
1264 { title: "kept" }
1265 ]
1266 })
1267 }
1268 }]
1269 },
1270 references: {}
1271 })
1272
1273 assert.deepStrictEqual(output.schema, {
1274 type: "integer",
1275 allOf: [
1276 { description: "nested", allOf: [{ minimum: 1 }] },
1277 { maximum: 10 },
1278 { title: "kept" }
1279 ]
1280 })
1281
1282 assert.deepStrictEqual(
1283 SchemaRepresentation.toJsonSchemaDocument({
1284 representation: {
1285 _tag: "Number",
1286 checks: [{
1287 _tag: "Filter",
1288 aborted: false,
1289 annotations: {
1290 toJsonSchema: () => ({ allOf: [{ type: "number" }, { type: "number" }] })
1291 }
1292 }]
1293 },
1294 references: {}
1295 }).schema,
1296 { type: "number" }
1297 )
1298 })
1299 })
1300
1301 describe("safety and errors", () => {
1302 it("never exposes compiler capabilities as extension annotations", () => {
1303 const document: SchemaRepresentation.Document = {
1304 representation: {
1305 _tag: "String",
1306 annotations: {
1307 description: "text",
1308 identifier: "id",
1309 representation: { id: "acme/schema/String", payload: null },
1310 toCode: () => ({ runtime: "ignored" }),
1311 toJsonSchema: () => ({ title: "ignored" }),
1312 "x-custom": { enabled: true },
1313 "x-invalid": () => "ignored"
1314 },
1315 checks: []
1316 },
1317 references: {}
1318 }
1319
1320 assert.deepStrictEqual(
1321 SchemaRepresentation.toJsonSchemaDocument(document, {
1322 includeAnnotationKey: () => true
1323 }).schema,
1324 {
1325 type: "string",
1326 description: "text",
1327 identifier: "id",
1328 "x-custom": { enabled: true }
1329 }
1330 )
1331 })
1332
1333 it("captures exceptions from JSON Schema callbacks", () => {
1334 const cause = new Error("json schema callback")
1335 const document: SchemaRepresentation.Document = {
1336 representation: {
1337 _tag: "String",
1338 checks: [{
1339 _tag: "Filter",
1340 aborted: false,
1341 annotations: {
1342 toJsonSchema: () => {
1343 throw cause
1344 }
1345 }
1346 }]
1347 },
1348 references: {}
1349 }
1350 expectError(
1351 () => SchemaRepresentation.toJsonSchemaDocument(document),
1352 cause
1353 )
1354 })
1355
1356 it("supports __proto__ as a reference", () => {
1357 const document = SchemaRepresentation.toJsonSchemaDocument(
1358 SchemaRepresentation.toRepresentation(
1359 Schema.String.annotate({ identifier: "__proto__" }).ast
1360 )
1361 )
1362
1363 assert.deepStrictEqual(document.schema, {
1364 $ref: "#/$defs/__proto__"
1365 })
1366 assert.deepStrictEqual(Object.keys(document.definitions), ["__proto__"])
1367 assert.deepStrictEqual(document.definitions["__proto__"], {
1368 type: "string"
1369 })
1370 })
1371
1372 it("reports missing references with their document path", () => {
1373 const document: SchemaRepresentation.Document = {
1374 representation: { _tag: "Reference", $ref: "Missing" },
1375 references: {}
1376 }
1377 expectError(
1378 () => SchemaRepresentation.toJsonSchemaDocument(document),
1379 `Invalid reference Missing\n at ["representation"]["$ref"]`
1380 )
1381 })
1382 })
1383})