1import { assert, describe, it } from "@effect/vitest"
2import { deepStrictEqual } from "@effect/vitest/utils"
3import * as JsonSchema from "effect/JsonSchema"
4import * as Schema from "effect/Schema"
5
6// oxlint-disable-next-line @typescript-eslint/no-require-imports
7const AjvDraft07 = require("ajv")
8// oxlint-disable-next-line @typescript-eslint/no-require-imports
9const AjvDraft2020 = require("ajv/dist/2020")
10// oxlint-disable-next-line @typescript-eslint/no-require-imports
11const AjvDraft04 = require("ajv-draft-04")
12
13const ajvDraft07 = new AjvDraft07.default({ allErrors: true, strict: false })
14const ajvDraft2020 = new AjvDraft2020.default({ allErrors: true, strict: false })
15const ajvDraft04 = new AjvDraft04.default({ allErrors: true, strict: false })
16
17function makeSchema(document: JsonSchema.Document<"draft-04" | "draft-07">): JsonSchema.JsonSchema {
18 return {
19 $schema: document.dialect === "draft-04"
20 ? JsonSchema.META_SCHEMA_URI_DRAFT_04
21 : JsonSchema.META_SCHEMA_URI_DRAFT_07,
22 ...document.schema,
23 ...(Object.keys(document.definitions).length > 0 ? { definitions: document.definitions } : {})
24 }
25}
26
27function makeCanonicalSchema(document: JsonSchema.Document<"draft-2020-12">): JsonSchema.JsonSchema {
28 return {
29 $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
30 ...document.schema,
31 ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {})
32 }
33}
34
35function assertDoesNotMutate<A>(input: A, f: (input: A) => unknown): void {
36 const before = structuredClone(input)
37 f(input)
38 deepStrictEqual(input, before)
39}
40
41describe("JsonSchema", () => {
42 describe("meta-schema URIs", () => {
43 it("exports the URI for every supported dialect", () => {
44 deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_04, "http://json-schema.org/draft-04/schema#")
45 deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_07, "http://json-schema.org/draft-07/schema#")
46 deepStrictEqual(JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, "https://json-schema.org/draft/2020-12/schema")
47 })
48 })
49
50 describe("sanitizeOpenApiComponentsSchemasKey", () => {
51 const sanitizeOpenApiComponentsKey = JsonSchema.sanitizeOpenApiComponentsSchemasKey
52
53 it("returns '_' for empty input", () => {
54 deepStrictEqual(sanitizeOpenApiComponentsKey(""), "_")
55 })
56
57 it("returns input when already valid", () => {
58 deepStrictEqual(sanitizeOpenApiComponentsKey("Simple"), "Simple")
59 deepStrictEqual(sanitizeOpenApiComponentsKey("with-dash"), "with-dash")
60 deepStrictEqual(sanitizeOpenApiComponentsKey("with_underscore"), "with_underscore")
61 deepStrictEqual(sanitizeOpenApiComponentsKey("with.dot"), "with.dot")
62 deepStrictEqual(sanitizeOpenApiComponentsKey("A1.B2-_"), "A1.B2-_")
63 })
64
65 it("replaces invalid characters with '_'", () => {
66 const cases: ReadonlyArray<readonly [string, string]> = [
67 ["a b", "a_b"],
68 ["a/b", "a_b"],
69 ["a:b", "a_b"],
70 ["a@b", "a_b"],
71 ["a#b", "a_b"],
72 ["a?b", "a_b"],
73 ["a+b", "a_b"],
74 ["a*b", "a_b"],
75 ["a,b", "a_b"],
76 ["a;b", "a_b"],
77 ["a|b", "a_b"],
78 ["a=b", "a_b"]
79 ]
80 for (const [input, expected] of cases) {
81 deepStrictEqual(sanitizeOpenApiComponentsKey(input), expected)
82 }
83 })
84
85 it("preserves length when only replacements are needed", () => {
86 deepStrictEqual(sanitizeOpenApiComponentsKey("a b").length, "a b".length)
87 deepStrictEqual(sanitizeOpenApiComponentsKey("..").length, "..".length)
88 deepStrictEqual(sanitizeOpenApiComponentsKey("a--b").length, "a--b".length)
89 })
90
91 it("replaces non-ascii characters with '_'", () => {
92 deepStrictEqual(sanitizeOpenApiComponentsKey("café"), "caf_")
93 deepStrictEqual(sanitizeOpenApiComponentsKey("你好"), "__")
94 deepStrictEqual(sanitizeOpenApiComponentsKey("🤖"), "_")
95 deepStrictEqual(sanitizeOpenApiComponentsKey("a🤖b"), "a_b")
96 })
97
98 it("is idempotent", () => {
99 const inputs = [
100 "",
101 "Simple",
102 "a b",
103 "a/b",
104 "a..b",
105 "café",
106 "🤖",
107 "A1.B2-_"
108 ] as const
109 for (const input of inputs) {
110 const once = sanitizeOpenApiComponentsKey(input)
111 const twice = sanitizeOpenApiComponentsKey(once)
112 deepStrictEqual(twice, once)
113 }
114 })
115 })
116
117 describe("fromSchemaDraft07", () => {
118 it("rewrites the Draft-07 meta-schema URI without an empty fragment", () => {
119 deepStrictEqual(
120 JsonSchema.fromSchemaDraft07({ $schema: "http://json-schema.org/draft-07/schema" }).schema,
121 { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 }
122 )
123 })
124
125 it("preserves all Draft-07 semantics and opaque custom keywords", () => {
126 const custom = { $ref: "#/definitions/Literal" }
127 const input: JsonSchema.JsonSchema = {
128 if: { properties: { kind: { const: "full" } } },
129 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
130 then: { required: ["value"] },
131 else: { not: { required: ["value"] } },
132 contains: { type: "number" },
133 dependencies: {
134 enabled: ["value"],
135 kind: { required: ["label"] }
136 },
137 "x-custom": custom
138 }
139
140 deepStrictEqual(JsonSchema.fromSchemaDraft07(input), {
141 dialect: "draft-2020-12",
142 schema: {
143 if: { properties: { kind: { const: "full" } } },
144 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
145 then: { required: ["value"] },
146 else: { not: { required: ["value"] } },
147 contains: { type: "number" },
148 dependentRequired: { enabled: ["value"] },
149 dependentSchemas: { kind: { required: ["label"] } },
150 "x-custom": custom
151 },
152 definitions: {}
153 })
154 })
155
156 it("preserves Draft-07 validation semantics in the canonical dialect", () => {
157 const source: JsonSchema.JsonSchema = {
158 type: "object",
159 properties: {
160 kind: { enum: ["full", "compact"] },
161 value: { type: "string" },
162 extra: true,
163 values: { type: "array", contains: { type: "number" } }
164 },
165 required: ["kind", "values"],
166 dependencies: { value: ["extra"] },
167 if: { properties: { kind: { const: "full" } } },
168 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
169 then: { required: ["value"] },
170 else: { not: { required: ["value"] } }
171 }
172 const canonical = makeCanonicalSchema(JsonSchema.fromSchemaDraft07(source))
173 deepStrictEqual(ajvDraft07.validateSchema(source), true)
174 deepStrictEqual(ajvDraft2020.validateSchema(canonical), true)
175
176 const validateSource = ajvDraft07.compile(source)
177 const validateCanonical = ajvDraft2020.compile(canonical)
178 for (
179 const value of [
180 { kind: "full", value: "ok", extra: true, values: [1] },
181 { kind: "full", values: [1] },
182 { kind: "full", value: "missing dependency", values: [1] },
183 { kind: "compact", values: [1] },
184 { kind: "compact", value: "forbidden", extra: true, values: [1] },
185 { kind: "full", value: "ok", extra: true, values: ["no"] }
186 ]
187 ) {
188 deepStrictEqual(validateCanonical(value), validateSource(value))
189 }
190 })
191
192 it("converts Draft-07 plain-name identifiers to canonical anchors", () => {
193 const document = JsonSchema.fromSchemaDraft07({
194 $id: "https://example.com/node#entry",
195 type: "string"
196 })
197
198 deepStrictEqual(document.schema, {
199 $id: "https://example.com/node",
200 $anchor: "entry",
201 type: "string"
202 })
203 })
204
205 it("converts canonical anchors without schema identifiers", () => {
206 const document: JsonSchema.Document<"draft-2020-12"> = {
207 dialect: "draft-2020-12",
208 schema: { $anchor: "entry" },
209 definitions: {}
210 }
211
212 deepStrictEqual(JsonSchema.toDocumentDraft07(document).schema, {
213 $id: "#entry"
214 })
215 deepStrictEqual(JsonSchema.toDocumentDraft04(document).schema, {
216 id: "#entry"
217 })
218 })
219
220 it("rejects canonical anchors combined with schema identifiers", () => {
221 for (const $id of ["https://example.com/node", "https://example.com/node#"]) {
222 const document: JsonSchema.Document<"draft-2020-12"> = {
223 dialect: "draft-2020-12",
224 schema: { $id, $anchor: "entry" },
225 definitions: {}
226 }
227
228 assert.throws(
229 () => JsonSchema.toDocumentDraft07(document),
230 /Cannot convert JSON Schema keyword "\$anchor" to Draft-07/
231 )
232 assert.throws(
233 () => JsonSchema.toDocumentDraft04(document),
234 /Cannot convert JSON Schema keyword "\$anchor" to Draft-04/
235 )
236 }
237 })
238
239 it("rejects canonical anchors that cannot become legacy plain-name identifiers", () => {
240 const document: JsonSchema.Document<"draft-2020-12"> = {
241 dialect: "draft-2020-12",
242 schema: { $anchor: "_node" },
243 definitions: {}
244 }
245
246 assert.throws(
247 () => JsonSchema.toDocumentDraft07(document),
248 /Cannot convert JSON Schema keyword "\$anchor" to Draft-07/
249 )
250 assert.throws(
251 () => JsonSchema.toDocumentDraft04(document),
252 /Cannot convert JSON Schema keyword "\$anchor" to Draft-04/
253 )
254 })
255
256 it("rejects Draft-07 identifiers with fragments that cannot become anchors", () => {
257 assert.throws(
258 () => JsonSchema.fromSchemaDraft07({ $id: "https://example.com/node#not/a/plain-name" }),
259 /Cannot convert JSON Schema keyword "\$id" to Draft 2020-12/
260 )
261 })
262
263 it("rejects Draft-07 custom keywords that would become active in the canonical dialect", () => {
264 for (
265 const schema of [
266 { dependentRequired: { value: ["other"] } },
267 { $vocabulary: { "https://example.com/vocabulary": true } },
268 { contentSchema: {} },
269 { deprecated: true }
270 ]
271 ) {
272 assert.throws(
273 () => JsonSchema.fromSchemaDraft07(schema),
274 /Cannot convert JSON Schema keyword .* to Draft 2020-12/
275 )
276 }
277 })
278
279 it("preserves not", () => {
280 const input: JsonSchema.JsonSchema = { not: { type: "string" } }
281 const result = JsonSchema.fromSchemaDraft07(input)
282 deepStrictEqual(result, {
283 dialect: "draft-2020-12",
284 schema: { not: { type: "string" } },
285 definitions: {}
286 })
287 })
288
289 it("normalizes a schema without definitions to the canonical document shape", () => {
290 const input: JsonSchema.JsonSchema = {
291 type: "string"
292 }
293 const result = JsonSchema.fromSchemaDraft07(input)
294 deepStrictEqual(result, {
295 dialect: "draft-2020-12",
296 schema: {
297 type: "string"
298 },
299 definitions: {}
300 })
301 })
302
303 it("extracts root definitions and rewrites Draft-07 refs to $defs refs", () => {
304 const input: JsonSchema.JsonSchema = {
305 type: "object",
306 properties: {
307 a: { $ref: "#/definitions/A" },
308 b: { $ref: "#/definitions/B" }
309 },
310 definitions: {
311 A: {
312 type: "string",
313 $ref: "#/definitions/B"
314 },
315 B: {
316 type: "number"
317 }
318 }
319 }
320 const result = JsonSchema.fromSchemaDraft07(input)
321 deepStrictEqual(result, {
322 dialect: "draft-2020-12",
323 schema: {
324 type: "object",
325 properties: {
326 a: { $ref: "#/$defs/A" },
327 b: { $ref: "#/$defs/B" }
328 }
329 },
330 definitions: {
331 A: {
332 $ref: "#/$defs/B"
333 },
334 B: {
335 type: "number"
336 }
337 }
338 })
339
340 const validateCanonical = ajvDraft2020.compile(makeCanonicalSchema(result))
341 const values = [
342 { a: 1, b: 2 },
343 { a: "not a number", b: 2 },
344 { a: 1, b: "not a number" }
345 ]
346 deepStrictEqual(values.map(validateCanonical), [true, false, false])
347 })
348
349 it("ignores $id next to Draft-07 refs when tracking resources", () => {
350 const result = JsonSchema.fromSchemaDraft07({
351 definitions: {
352 Target: { type: "string" },
353 Reference: {
354 $id: "nested.json",
355 $ref: "#/definitions/Target"
356 }
357 }
358 })
359
360 deepStrictEqual(result.definitions.Reference, { $ref: "#/$defs/Target" })
361 })
362
363 it("converts Draft-07 tuple items to prefixItems", () => {
364 const input: JsonSchema.JsonSchema = {
365 type: "array",
366 items: [
367 { type: "string" },
368 { type: "number" }
369 ],
370 additionalItems: { type: "boolean" }
371 }
372 const result = JsonSchema.fromSchemaDraft07(input)
373 deepStrictEqual(result, {
374 dialect: "draft-2020-12",
375 schema: {
376 type: "array",
377 prefixItems: [
378 { type: "string" },
379 { type: "number" }
380 ],
381 items: { type: "boolean" }
382 },
383 definitions: {}
384 })
385 })
386
387 it("relocates refs into converted tuple items and schema dependencies", () => {
388 const result = JsonSchema.fromSchemaDraft07({
389 properties: {
390 container: {
391 items: [{ type: "string" }],
392 dependencies: { value: { type: "number" } }
393 },
394 tupleItem: { $ref: "#/properties/container/items/0" },
395 dependency: { $ref: "#/properties/container/dependencies/value" }
396 }
397 })
398
399 deepStrictEqual(result.schema, {
400 properties: {
401 container: {
402 prefixItems: [{ type: "string" }],
403 dependentSchemas: { value: { type: "number" } }
404 },
405 tupleItem: { $ref: "#/properties/container/prefixItems/0" },
406 dependency: { $ref: "#/properties/container/dependentSchemas/value" }
407 }
408 })
409 })
410
411 it("preserves a single items schema as items", () => {
412 const input: JsonSchema.JsonSchema = {
413 type: "array",
414 items: { type: "string" }
415 }
416 const result = JsonSchema.fromSchemaDraft07(input)
417 deepStrictEqual(result, {
418 dialect: "draft-2020-12",
419 schema: {
420 type: "array",
421 items: { type: "string" }
422 },
423 definitions: {}
424 })
425 })
426
427 it("preserves annotations", () => {
428 const input: JsonSchema.JsonSchema = {
429 type: "string",
430 title: "My String",
431 description: "A string value",
432 default: "default",
433 examples: ["example1", "example2"],
434 format: "email",
435 readOnly: true,
436 writeOnly: true
437 }
438 const result = JsonSchema.fromSchemaDraft07(input)
439 deepStrictEqual(result, {
440 dialect: "draft-2020-12",
441 schema: {
442 type: "string",
443 title: "My String",
444 description: "A string value",
445 default: "default",
446 examples: ["example1", "example2"],
447 format: "email",
448 readOnly: true,
449 writeOnly: true
450 },
451 definitions: {}
452 })
453 })
454
455 it("preserves string constraints", () => {
456 const input: JsonSchema.JsonSchema = {
457 type: "string",
458 pattern: "^[a-z]+$",
459 minLength: 1,
460 maxLength: 100
461 }
462 const result = JsonSchema.fromSchemaDraft07(input)
463 deepStrictEqual(result, {
464 dialect: "draft-2020-12",
465 schema: {
466 type: "string",
467 pattern: "^[a-z]+$",
468 minLength: 1,
469 maxLength: 100
470 },
471 definitions: {}
472 })
473 })
474
475 it("preserves number constraints", () => {
476 const input: JsonSchema.JsonSchema = {
477 type: "number",
478 minimum: 0,
479 maximum: 100,
480 exclusiveMinimum: 0,
481 exclusiveMaximum: 100,
482 multipleOf: 2
483 }
484 const result = JsonSchema.fromSchemaDraft07(input)
485 deepStrictEqual(result, {
486 dialect: "draft-2020-12",
487 schema: {
488 type: "number",
489 minimum: 0,
490 maximum: 100,
491 exclusiveMinimum: 0,
492 exclusiveMaximum: 100,
493 multipleOf: 2
494 },
495 definitions: {}
496 })
497 })
498
499 it("preserves array constraints", () => {
500 const input: JsonSchema.JsonSchema = {
501 type: "array",
502 items: { type: "string" },
503 minItems: 1,
504 maxItems: 10,
505 uniqueItems: true
506 }
507 const result = JsonSchema.fromSchemaDraft07(input)
508 deepStrictEqual(result, {
509 dialect: "draft-2020-12",
510 schema: {
511 type: "array",
512 items: { type: "string" },
513 minItems: 1,
514 maxItems: 10,
515 uniqueItems: true
516 },
517 definitions: {}
518 })
519 })
520
521 it("preserves object constraints", () => {
522 const input: JsonSchema.JsonSchema = {
523 type: "object",
524 properties: {
525 name: { type: "string" },
526 age: { type: "number" }
527 },
528 required: ["name"],
529 patternProperties: {
530 "^S_": { type: "string" }
531 },
532 additionalProperties: { type: "boolean" },
533 propertyNames: { pattern: "^[A-Z]" },
534 minProperties: 1,
535 maxProperties: 10
536 }
537 const result = JsonSchema.fromSchemaDraft07(input)
538 deepStrictEqual(result, {
539 dialect: "draft-2020-12",
540 schema: {
541 type: "object",
542 properties: {
543 name: { type: "string" },
544 age: { type: "number" }
545 },
546 required: ["name"],
547 patternProperties: {
548 "^S_": { type: "string" }
549 },
550 additionalProperties: { type: "boolean" },
551 propertyNames: { pattern: "^[A-Z]" },
552 minProperties: 1,
553 maxProperties: 10
554 },
555 definitions: {}
556 })
557 })
558
559 it("preserves enum, const, allOf, anyOf, and oneOf", () => {
560 const input: JsonSchema.JsonSchema = {
561 enum: ["a", "b", "c"],
562 const: "constant",
563 allOf: [
564 { type: "array", items: { type: "string" } },
565 { minItems: 1 }
566 ],
567 anyOf: [
568 { type: "array", items: [{ type: "string" }] },
569 { type: "number" }
570 ],
571 oneOf: [
572 { type: "array", items: [{ type: "string" }], additionalItems: { type: "number" } },
573 { type: "boolean" }
574 ]
575 }
576 const result = JsonSchema.fromSchemaDraft07(input)
577 deepStrictEqual(result, {
578 dialect: "draft-2020-12",
579 schema: {
580 enum: ["a", "b", "c"],
581 const: "constant",
582 allOf: [
583 { type: "array", items: { type: "string" } },
584 { minItems: 1 }
585 ],
586 anyOf: [
587 { type: "array", prefixItems: [{ type: "string" }] },
588 { type: "number" }
589 ],
590 oneOf: [
591 { type: "array", prefixItems: [{ type: "string" }], items: { type: "number" } },
592 { type: "boolean" }
593 ]
594 },
595 definitions: {}
596 })
597 })
598
599 it("moves nested definitions to $defs and relocates local JSON Pointer refs", () => {
600 const input: JsonSchema.JsonSchema = {
601 type: "object",
602 properties: {
603 nested: {
604 definitions: {
605 NestedType: {
606 type: "number"
607 }
608 },
609 $ref: "#/properties/nested/definitions/NestedType"
610 }
611 }
612 }
613 const result = JsonSchema.fromSchemaDraft07(input)
614 deepStrictEqual(result, {
615 dialect: "draft-2020-12",
616 schema: {
617 type: "object",
618 properties: {
619 nested: {
620 $defs: {
621 NestedType: {
622 type: "number"
623 }
624 },
625 $ref: "#/properties/nested/$defs/NestedType"
626 }
627 }
628 },
629 definitions: {}
630 })
631 })
632
633 it("relocates fragment refs relative to nested schema resources", () => {
634 const result = JsonSchema.fromSchemaDraft07({
635 properties: {
636 nested: {
637 $id: "nested.json",
638 definitions: { Value: { type: "string" } },
639 allOf: [{ $ref: "#/definitions/Value" }]
640 }
641 }
642 })
643
644 deepStrictEqual(result.schema, {
645 properties: {
646 nested: {
647 $id: "nested.json",
648 $defs: { Value: { type: "string" } },
649 allOf: [{ $ref: "#/$defs/Value" }]
650 }
651 }
652 })
653 })
654
655 it("relocates URI refs to nested schema resources", () => {
656 const result = JsonSchema.fromSchemaDraft07({
657 $id: "https://example.com/root.json",
658 properties: {
659 value: { $ref: "nested.json#/definitions/Value" }
660 },
661 definitions: {
662 Nested: {
663 $id: "schemas/../nested.json",
664 definitions: { Value: { type: "string" } }
665 }
666 }
667 })
668
669 deepStrictEqual(result, {
670 dialect: "draft-2020-12",
671 schema: {
672 $id: "https://example.com/root.json",
673 properties: {
674 value: { $ref: "nested.json#/$defs/Value" }
675 }
676 },
677 definitions: {
678 Nested: {
679 $id: "schemas/../nested.json",
680 $defs: { Value: { type: "string" } }
681 }
682 }
683 })
684 })
685
686 it("relocates JSON Pointer URI fragments", () => {
687 const result = JsonSchema.fromSchemaDraft07({
688 properties: {
689 value: { $ref: "#/definitions/a%20b" },
690 equivalent: { $ref: "#/definitions/%56alue" },
691 encodedSeparators: { $ref: "#%2Fdefinitions%2FValue" },
692 percent: { $ref: "#/definitions/a%25b" },
693 escaped: { $ref: "#/definitions/a~1b" },
694 utf8: { $ref: "#/definitions/caf%C3%A9" },
695 invalid: { $ref: "#/%" },
696 invalidUtf8: { $ref: "#/%FF" }
697 },
698 definitions: {
699 "a b": { type: "string" },
700 Value: { type: "number" },
701 "a%b": { type: "boolean" },
702 "a/b": { type: "object" },
703 "café": { type: "array" }
704 }
705 })
706
707 deepStrictEqual(result, {
708 dialect: "draft-2020-12",
709 schema: {
710 properties: {
711 value: { $ref: "#/$defs/a%20b" },
712 equivalent: { $ref: "#/$defs/Value" },
713 encodedSeparators: { $ref: "#/$defs/Value" },
714 percent: { $ref: "#/$defs/a%25b" },
715 escaped: { $ref: "#/$defs/a~1b" },
716 utf8: { $ref: "#/$defs/caf%C3%A9" },
717 invalid: { $ref: "#/%" },
718 invalidUtf8: { $ref: "#/%FF" }
719 }
720 },
721 definitions: {
722 "a b": { type: "string" },
723 Value: { type: "number" },
724 "a%b": { type: "boolean" },
725 "a/b": { type: "object" },
726 "café": { type: "array" }
727 }
728 })
729 })
730
731 it("preserves custom properties in Draft-07 input", () => {
732 const input: JsonSchema.JsonSchema = {
733 type: "string",
734 "x-custom": "value"
735 }
736 const result = JsonSchema.fromSchemaDraft07(input)
737 deepStrictEqual(result, {
738 dialect: "draft-2020-12",
739 schema: {
740 type: "string",
741 "x-custom": "value"
742 },
743 definitions: {}
744 })
745 })
746
747 it("preserves malformed values for recognized keywords", () => {
748 const input: JsonSchema.JsonSchema = {
749 $ref: 1,
750 definitions: {
751 Invalid: [1, { not: false }] as unknown as JsonSchema.JsonSchema
752 },
753 properties: {
754 nested: {
755 definitions: "invalid",
756 not: [false, { type: "string" }]
757 }
758 },
759 patternProperties: "invalid",
760 allOf: "invalid"
761 }
762
763 deepStrictEqual(JsonSchema.fromSchemaDraft07(input), {
764 dialect: "draft-2020-12",
765 schema: {
766 $ref: 1,
767 properties: {
768 nested: {
769 $defs: "invalid",
770 not: [false, { type: "string" }]
771 }
772 },
773 patternProperties: "invalid",
774 allOf: "invalid"
775 },
776 definitions: {
777 Invalid: [1, { not: false }] as unknown as JsonSchema.JsonSchema
778 }
779 })
780 })
781
782 it("ignores additionalItems when items is not a tuple", () => {
783 deepStrictEqual(
784 JsonSchema.fromSchemaDraft07({
785 type: "array",
786 items: { type: "string" },
787 additionalItems: false
788 }).schema,
789 {
790 type: "array",
791 items: { type: "string" }
792 }
793 )
794 })
795 })
796
797 describe("fromSchemaDraft2020_12", () => {
798 it("normalizes a schema without $defs to the canonical document shape", () => {
799 const input: JsonSchema.JsonSchema = {
800 type: "string"
801 }
802 const result = JsonSchema.fromSchemaDraft2020_12(input)
803 deepStrictEqual(result, {
804 dialect: "draft-2020-12",
805 schema: {
806 type: "string"
807 },
808 definitions: {}
809 })
810 })
811
812 it("extracts root $defs without rewriting Draft-2020-12 refs", () => {
813 const input: JsonSchema.JsonSchema = {
814 type: "object",
815 properties: {
816 a: { $ref: "#/$defs/A" },
817 b: { $ref: "#/$defs/B" }
818 },
819 $defs: {
820 A: {
821 type: "string",
822 $ref: "#/$defs/B"
823 },
824 B: {
825 type: "number"
826 }
827 }
828 }
829 const result = JsonSchema.fromSchemaDraft2020_12(input)
830 deepStrictEqual(result, {
831 dialect: "draft-2020-12",
832 schema: {
833 type: "object",
834 properties: {
835 a: { $ref: "#/$defs/A" },
836 b: { $ref: "#/$defs/B" }
837 }
838 },
839 definitions: {
840 A: {
841 type: "string",
842 $ref: "#/$defs/B"
843 },
844 B: {
845 type: "number"
846 }
847 }
848 })
849 })
850
851 it("preserves nested definitions and local JSON Pointer refs", () => {
852 const input: JsonSchema.JsonSchema = {
853 type: "object",
854 properties: {
855 nested: {
856 definitions: {
857 NestedType: {
858 type: "number"
859 }
860 },
861 $ref: "#/properties/nested/definitions/NestedType"
862 }
863 }
864 }
865 const result = JsonSchema.fromSchemaDraft2020_12(input)
866 deepStrictEqual(result, {
867 dialect: "draft-2020-12",
868 schema: {
869 type: "object",
870 properties: {
871 nested: {
872 definitions: {
873 NestedType: {
874 type: "number"
875 }
876 },
877 $ref: "#/properties/nested/definitions/NestedType"
878 }
879 }
880 },
881 definitions: {}
882 })
883 })
884
885 it("keeps non-standard properties for Draft-2020-12 input", () => {
886 const input: JsonSchema.JsonSchema = {
887 type: "string",
888 "x-custom": "value"
889 }
890 const result = JsonSchema.fromSchemaDraft2020_12(input)
891 deepStrictEqual(result, {
892 dialect: "draft-2020-12",
893 schema: {
894 type: "string",
895 "x-custom": "value"
896 },
897 definitions: {}
898 })
899 })
900 })
901
902 describe("fromSchemaOpenApi3_1", () => {
903 it("normalizes the OpenAPI 3.1 base dialect URI", () => {
904 deepStrictEqual(
905 JsonSchema.fromSchemaOpenApi3_1({ $schema: "https://spec.openapis.org/oas/3.1/dialect/base" }).schema,
906 { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 }
907 )
908 })
909
910 it("preserves non-string refs and malformed schema maps", () => {
911 const input: JsonSchema.JsonSchema = {
912 $ref: null,
913 enum: [null],
914 properties: [{ $ref: "#/components/schemas/Literal" }]
915 }
916
917 deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), {
918 dialect: "draft-2020-12",
919 schema: input,
920 definitions: {}
921 })
922 })
923
924 it("rewrites OpenAPI component schema refs to $defs refs", () => {
925 const input: JsonSchema.JsonSchema = {
926 type: "object",
927 properties: {
928 a: { $ref: "#/components/schemas/A" },
929 b: { $ref: "#/components/schemas/B" },
930 dynamic: { $dynamicRef: "#/components/schemas/Dynamic" }
931 }
932 }
933 const result = JsonSchema.fromSchemaOpenApi3_1(input)
934 deepStrictEqual(result, {
935 dialect: "draft-2020-12",
936 schema: {
937 type: "object",
938 properties: {
939 a: { $ref: "#/$defs/A" },
940 b: { $ref: "#/$defs/B" },
941 dynamic: { $dynamicRef: "#/$defs/Dynamic" }
942 }
943 },
944 definitions: {}
945 })
946 })
947
948 it("rewrites percent-encoded OpenAPI component refs", () => {
949 deepStrictEqual(
950 JsonSchema.fromSchemaOpenApi3_1({ $ref: "#%2Fcomponents%2Fschemas%2FA%20B" }),
951 {
952 dialect: "draft-2020-12",
953 schema: { $ref: "#/$defs/A%20B" },
954 definitions: {}
955 }
956 )
957 })
958
959 it("normalizes singular examples throughout OpenAPI 3.1 schemas", () => {
960 deepStrictEqual(
961 JsonSchema.fromSchemaOpenApi3_1({
962 example: "root",
963 properties: {
964 value: { example: "nested" }
965 }
966 }),
967 {
968 dialect: "draft-2020-12",
969 schema: {
970 examples: ["root"],
971 properties: {
972 value: { examples: ["nested"] }
973 }
974 },
975 definitions: {}
976 }
977 )
978 })
979
980 it("merges singular and array examples in OpenAPI 3.1 schemas", () => {
981 deepStrictEqual(
982 JsonSchema.fromSchemaOpenApi3_1({ example: "singular", examples: ["first", "second"] }).schema,
983 { examples: ["singular", "first", "second"] }
984 )
985 })
986
987 it("rewrites refs only in schema positions", () => {
988 const literal = { $ref: "#/components/schemas/Literal" }
989 const input: JsonSchema.JsonSchema = {
990 properties: { value: { $ref: "#/components/schemas/Value" } },
991 const: literal,
992 enum: [literal],
993 default: literal,
994 examples: [literal]
995 }
996
997 deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), {
998 dialect: "draft-2020-12",
999 schema: {
1000 properties: { value: { $ref: "#/$defs/Value" } },
1001 const: literal,
1002 enum: [literal],
1003 default: literal,
1004 examples: [literal]
1005 },
1006 definitions: {}
1007 })
1008 })
1009
1010 it("rewrites refs throughout Draft 2020-12 subschemas", () => {
1011 const input: JsonSchema.JsonSchema = {
1012 $defs: { Alias: { $ref: "#/components/schemas/Value" } },
1013 properties: { value: { $ref: "#/components/schemas/Value" } },
1014 patternProperties: { pattern: { $ref: "#/components/schemas/Value" } },
1015 dependentSchemas: { dependency: { $ref: "#/components/schemas/Value" } },
1016 allOf: [{ $ref: "#/components/schemas/Value" }],
1017 anyOf: [{ $ref: "#/components/schemas/Value" }],
1018 oneOf: [{ $ref: "#/components/schemas/Value" }],
1019 prefixItems: [{ $ref: "#/components/schemas/Value" }],
1020 additionalProperties: { $ref: "#/components/schemas/Value" },
1021 unevaluatedProperties: { $ref: "#/components/schemas/Value" },
1022 propertyNames: { $ref: "#/components/schemas/Value" },
1023 items: { $ref: "#/components/schemas/Value" },
1024 contains: { $ref: "#/components/schemas/Value" },
1025 unevaluatedItems: { $ref: "#/components/schemas/Value" },
1026 not: { $ref: "#/components/schemas/Value" },
1027 if: { $ref: "#/components/schemas/Value" },
1028 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
1029 then: { $ref: "#/components/schemas/Value" },
1030 else: { $ref: "#/components/schemas/Value" },
1031 contentSchema: { $ref: "#/components/schemas/Value" }
1032 }
1033
1034 deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), {
1035 dialect: "draft-2020-12",
1036 schema: {
1037 properties: { value: { $ref: "#/$defs/Value" } },
1038 patternProperties: { pattern: { $ref: "#/$defs/Value" } },
1039 dependentSchemas: { dependency: { $ref: "#/$defs/Value" } },
1040 allOf: [{ $ref: "#/$defs/Value" }],
1041 anyOf: [{ $ref: "#/$defs/Value" }],
1042 oneOf: [{ $ref: "#/$defs/Value" }],
1043 prefixItems: [{ $ref: "#/$defs/Value" }],
1044 additionalProperties: { $ref: "#/$defs/Value" },
1045 unevaluatedProperties: { $ref: "#/$defs/Value" },
1046 propertyNames: { $ref: "#/$defs/Value" },
1047 items: { $ref: "#/$defs/Value" },
1048 contains: { $ref: "#/$defs/Value" },
1049 unevaluatedItems: { $ref: "#/$defs/Value" },
1050 not: { $ref: "#/$defs/Value" },
1051 if: { $ref: "#/$defs/Value" },
1052 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
1053 then: { $ref: "#/$defs/Value" },
1054 else: { $ref: "#/$defs/Value" },
1055 contentSchema: { $ref: "#/$defs/Value" }
1056 },
1057 definitions: { Alias: { $ref: "#/$defs/Value" } }
1058 })
1059 })
1060
1061 it("does not rewrite refs inside root schema resources", () => {
1062 const input: JsonSchema.JsonSchema = {
1063 $id: "inline.json",
1064 allOf: [
1065 { $ref: "#/components/schemas/Value" },
1066 { $dynamicRef: "#/components/schemas/Value" }
1067 ]
1068 }
1069
1070 deepStrictEqual(JsonSchema.fromSchemaOpenApi3_1(input), {
1071 dialect: "draft-2020-12",
1072 schema: input,
1073 definitions: {}
1074 })
1075 })
1076
1077 it("extracts root $defs after rewriting OpenAPI component refs", () => {
1078 const input: JsonSchema.JsonSchema = {
1079 type: "object",
1080 properties: {
1081 a: {
1082 $ref: "#/components/schemas/A"
1083 }
1084 },
1085 $defs: {
1086 MyType: {
1087 type: "string"
1088 }
1089 }
1090 }
1091 const result = JsonSchema.fromSchemaOpenApi3_1(input)
1092 deepStrictEqual(result, {
1093 dialect: "draft-2020-12",
1094 schema: {
1095 type: "object",
1096 properties: {
1097 a: {
1098 $ref: "#/$defs/A"
1099 }
1100 }
1101 },
1102 definitions: {
1103 MyType: {
1104 type: "string"
1105 }
1106 }
1107 })
1108 })
1109
1110 it("preserves nested definitions and local JSON Pointer refs", () => {
1111 const input: JsonSchema.JsonSchema = {
1112 type: "object",
1113 properties: {
1114 nested: {
1115 definitions: {
1116 NestedType: {
1117 type: "number"
1118 }
1119 },
1120 $ref: "#/properties/nested/definitions/NestedType"
1121 }
1122 }
1123 }
1124 const result = JsonSchema.fromSchemaOpenApi3_1(input)
1125 deepStrictEqual(result, {
1126 dialect: "draft-2020-12",
1127 schema: {
1128 type: "object",
1129 properties: {
1130 nested: {
1131 definitions: {
1132 NestedType: {
1133 type: "number"
1134 }
1135 },
1136 $ref: "#/properties/nested/definitions/NestedType"
1137 }
1138 }
1139 },
1140 definitions: {}
1141 })
1142 })
1143
1144 it("keeps non-standard properties for OpenAPI 3.1 input", () => {
1145 const input: JsonSchema.JsonSchema = {
1146 type: "string",
1147 "x-custom": "value"
1148 }
1149 const result = JsonSchema.fromSchemaOpenApi3_1(input)
1150 deepStrictEqual(result, {
1151 dialect: "draft-2020-12",
1152 schema: {
1153 type: "string",
1154 "x-custom": "value"
1155 },
1156 definitions: {}
1157 })
1158 })
1159 })
1160
1161 describe("fromSchemaOpenApi3_0", () => {
1162 function assertFromSchemaOpenApi3_0(input: JsonSchema.JsonSchema, expected: {
1163 readonly schema: JsonSchema.JsonSchema
1164 readonly definitions?: JsonSchema.Definitions
1165 }) {
1166 const result = JsonSchema.fromSchemaOpenApi3_0(input)
1167 deepStrictEqual(result, {
1168 dialect: "draft-2020-12",
1169 schema: expected.schema,
1170 definitions: expected.definitions ?? {}
1171 })
1172 }
1173
1174 it("rewrites OpenAPI 3.0 component schema refs to $defs refs", () => {
1175 const input: JsonSchema.JsonSchema = {
1176 type: "object",
1177 properties: {
1178 a: { $ref: "#/components/schemas/A" },
1179 b: { $ref: "#/components/schemas/B" }
1180 }
1181 }
1182 const result = JsonSchema.fromSchemaOpenApi3_0(input)
1183 deepStrictEqual(result, {
1184 dialect: "draft-2020-12",
1185 schema: {
1186 type: "object",
1187 properties: {
1188 a: { $ref: "#/$defs/A" },
1189 b: { $ref: "#/$defs/B" }
1190 }
1191 },
1192 definitions: {}
1193 })
1194 })
1195
1196 it("rewrites percent-encoded OpenAPI 3.0 component refs", () => {
1197 deepStrictEqual(
1198 JsonSchema.fromSchemaOpenApi3_0({ $ref: "#%2Fcomponents%2Fschemas%2FA%20B" }),
1199 {
1200 dialect: "draft-2020-12",
1201 schema: { $ref: "#/$defs/A%20B" },
1202 definitions: {}
1203 }
1204 )
1205 })
1206
1207 it("preserves definitions as an opaque OpenAPI 3.0 extension", () => {
1208 const definitions = { Value: { type: "string" } }
1209 deepStrictEqual(JsonSchema.fromSchemaOpenApi3_0({ definitions }), {
1210 dialect: "draft-2020-12",
1211 schema: { definitions },
1212 definitions: {}
1213 })
1214 })
1215
1216 it("normalizes singular OpenAPI 3.0 example to a draft examples array", () => {
1217 const input: JsonSchema.JsonSchema = {
1218 type: "string",
1219 example: "a"
1220 }
1221 const result = JsonSchema.fromSchemaOpenApi3_0(input)
1222 deepStrictEqual(result, {
1223 dialect: "draft-2020-12",
1224 schema: {
1225 type: "string",
1226 examples: ["a"]
1227 },
1228 definitions: {}
1229 })
1230 })
1231
1232 it("preserves the OpenAPI 3.0 deprecated annotation", () => {
1233 assertFromSchemaOpenApi3_0(
1234 { type: "string", deprecated: true },
1235 { schema: { type: "string", deprecated: true } }
1236 )
1237 })
1238
1239 it("rejects examples because it is not active in OpenAPI 3.0", () => {
1240 assert.throws(
1241 () => JsonSchema.fromSchemaOpenApi3_0({ examples: ["value"] }),
1242 /Cannot convert JSON Schema keyword "examples" to Draft 2020-12/
1243 )
1244 })
1245
1246 it("rejects Draft 2020-12 keywords that are not supported by OpenAPI 3.0", () => {
1247 assert.throws(
1248 () => JsonSchema.fromSchemaOpenApi3_0({ unevaluatedProperties: false }),
1249 /Cannot convert JSON Schema keyword "unevaluatedProperties" to Draft 2020-12/
1250 )
1251 })
1252
1253 it("does not traverse opaque OpenAPI values", () => {
1254 const literal = { nullable: true, $ref: "#/components/schemas/Literal" }
1255 assertFromSchemaOpenApi3_0(
1256 {
1257 type: "object",
1258 default: literal,
1259 example: literal,
1260 discriminator: { mapping: { value: "#/components/schemas/Value" } },
1261 "x-custom": literal
1262 },
1263 {
1264 schema: {
1265 type: "object",
1266 default: literal,
1267 examples: [literal],
1268 discriminator: { mapping: { value: "#/components/schemas/Value" } },
1269 "x-custom": literal
1270 }
1271 }
1272 )
1273 })
1274
1275 type OpenApi3_0Case = {
1276 readonly name: string
1277 readonly input: JsonSchema.JsonSchema
1278 readonly expected: JsonSchema.JsonSchema
1279 }
1280
1281 function testOpenApi3_0Cases(cases: ReadonlyArray<OpenApi3_0Case>): void {
1282 for (const { expected, input, name } of cases) {
1283 it(name, () => assertFromSchemaOpenApi3_0(input, { schema: expected }))
1284 }
1285 }
1286
1287 describe("nullable", () => {
1288 testOpenApi3_0Cases([
1289 {
1290 name: "ignores nullable without an explicit type",
1291 input: { nullable: true },
1292 expected: {}
1293 },
1294 {
1295 name: "adds null to a string type",
1296 input: { type: "string", nullable: true },
1297 expected: { type: ["string", "null"] }
1298 },
1299 {
1300 name: "does not widen enum values",
1301 input: { type: "string", enum: ["a", "b"], nullable: true },
1302 expected: { type: ["string", "null"], enum: ["a", "b"] }
1303 },
1304 {
1305 name: "does not duplicate null in enum values",
1306 input: { type: "string", enum: ["a", "b", null], nullable: true },
1307 expected: { type: ["string", "null"], enum: ["a", "b", null] }
1308 },
1309 {
1310 name: "preserves enum when null is its only value",
1311 input: { type: "string", enum: [null], nullable: true },
1312 expected: { type: ["string", "null"], enum: [null] }
1313 },
1314 {
1315 name: "ignores nullable when another constraint is present without type",
1316 input: { nullable: true, minimum: 0 },
1317 expected: { minimum: 0 }
1318 },
1319 {
1320 name: "drops nullable false",
1321 input: { type: "string", nullable: false },
1322 expected: { type: "string" }
1323 },
1324 {
1325 name: "normalizes nullable inside allOf independently from the parent",
1326 input: { type: "string", allOf: [{ nullable: true }] },
1327 expected: {
1328 type: "string",
1329 allOf: [{}]
1330 }
1331 },
1332 {
1333 name: "normalizes nullable on both a parent and its allOf member",
1334 input: { type: "string", nullable: true, allOf: [{ nullable: true }] },
1335 expected: {
1336 type: ["string", "null"],
1337 allOf: [{}]
1338 }
1339 }
1340 ])
1341 })
1342
1343 describe("exclusivity", () => {
1344 testOpenApi3_0Cases([
1345 {
1346 name: "turns exclusiveMinimum true into the minimum value",
1347 input: { type: "number", minimum: 10, exclusiveMinimum: true },
1348 expected: { type: "number", exclusiveMinimum: 10 }
1349 },
1350 {
1351 name: "turns exclusiveMaximum true into the maximum value",
1352 input: { type: "number", maximum: 100, exclusiveMaximum: true },
1353 expected: { type: "number", exclusiveMaximum: 100 }
1354 },
1355 {
1356 name: "drops exclusiveMinimum false",
1357 input: { type: "number", minimum: 10, exclusiveMinimum: false },
1358 expected: { type: "number", minimum: 10 }
1359 },
1360 {
1361 name: "drops exclusiveMaximum false",
1362 input: { type: "number", maximum: 100, exclusiveMaximum: false },
1363 expected: { type: "number", maximum: 100 }
1364 },
1365 {
1366 name: "drops exclusiveMinimum true when minimum is absent",
1367 input: { type: "number", exclusiveMinimum: true },
1368 expected: { type: "number" }
1369 },
1370 {
1371 name: "drops exclusiveMaximum true when maximum is absent",
1372 input: { type: "number", exclusiveMaximum: true },
1373 expected: { type: "number" }
1374 }
1375 ])
1376 })
1377 })
1378
1379 describe("toDocumentDraft07", () => {
1380 it("rewrites the canonical meta-schema URI with an empty fragment", () => {
1381 deepStrictEqual(
1382 JsonSchema.toDocumentDraft07({
1383 dialect: "draft-2020-12",
1384 schema: { $schema: `${JsonSchema.META_SCHEMA_URI_DRAFT_2020_12}#` },
1385 definitions: {}
1386 }).schema,
1387 { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07 }
1388 )
1389 })
1390
1391 it("omits the canonical meta-schema URI from embedded resources", () => {
1392 const input: JsonSchema.Document<"draft-2020-12"> = {
1393 dialect: "draft-2020-12",
1394 schema: {},
1395 definitions: {
1396 Embedded: {
1397 $id: "embedded.json",
1398 $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
1399 type: "string"
1400 }
1401 }
1402 }
1403
1404 deepStrictEqual(JsonSchema.toDocumentDraft07(input).definitions.Embedded, {
1405 $id: "embedded.json",
1406 type: "string"
1407 })
1408 })
1409
1410 it("rejects custom dialects in embedded resources", () => {
1411 assert.throws(
1412 () =>
1413 JsonSchema.toDocumentDraft07({
1414 dialect: "draft-2020-12",
1415 schema: {},
1416 definitions: {
1417 Embedded: {
1418 $id: "embedded.json",
1419 $schema: "https://example.com/dialect",
1420 type: "string"
1421 }
1422 }
1423 }),
1424 /Cannot convert JSON Schema keyword "\$schema" to Draft-07/
1425 )
1426 })
1427
1428 it("lowers canonical keywords and preserves opaque custom keywords", () => {
1429 const custom = { $ref: "#/$defs/Literal" }
1430 const result = JsonSchema.toDocumentDraft07({
1431 dialect: "draft-2020-12",
1432 schema: {
1433 if: { properties: { kind: { const: "full" } } },
1434 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
1435 then: { required: ["value"] },
1436 else: { not: { required: ["value"] } },
1437 contains: { type: "number" },
1438 dependentRequired: {
1439 enabled: ["value"],
1440 kind: ["label"]
1441 },
1442 dependentSchemas: {
1443 kind: { minProperties: 1 },
1444 mode: { required: ["value"] }
1445 },
1446 "x-custom": custom
1447 },
1448 definitions: {}
1449 })
1450
1451 deepStrictEqual(result.schema, {
1452 if: { properties: { kind: { const: "full" } } },
1453 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
1454 then: { required: ["value"] },
1455 else: { not: { required: ["value"] } },
1456 contains: { type: "number" },
1457 dependencies: {
1458 enabled: ["value"],
1459 kind: { allOf: [{ minProperties: 1 }, { required: ["label"] }] },
1460 mode: { required: ["value"] }
1461 },
1462 "x-custom": custom
1463 })
1464 })
1465
1466 it("moves nested $defs to definitions and relocates local JSON Pointer refs", () => {
1467 const result = JsonSchema.toDocumentDraft07({
1468 dialect: "draft-2020-12",
1469 schema: {
1470 properties: {
1471 nested: {
1472 $defs: { Value: { type: "string" } },
1473 $ref: "#/properties/nested/$defs/Value"
1474 }
1475 }
1476 },
1477 definitions: {}
1478 })
1479
1480 deepStrictEqual(result.schema, {
1481 properties: {
1482 nested: {
1483 definitions: { Value: { type: "string" } },
1484 allOf: [{ $ref: "#/properties/nested/definitions/Value" }]
1485 }
1486 }
1487 })
1488 })
1489
1490 it("relocates fragment refs when emitting nested schema resources", () => {
1491 const input: JsonSchema.Document<"draft-2020-12"> = {
1492 dialect: "draft-2020-12",
1493 schema: {
1494 allOf: [
1495 { $ref: "nested.json#/$defs/Value" },
1496 { $ref: "external.json#/$defs/Value" }
1497 ]
1498 },
1499 definitions: {
1500 Nested: {
1501 $id: "nested.json",
1502 $defs: { Value: { type: "string" } },
1503 allOf: [{ $ref: "#/$defs/Value" }]
1504 }
1505 }
1506 }
1507
1508 deepStrictEqual(JsonSchema.toDocumentDraft07(input), {
1509 dialect: "draft-07",
1510 schema: {
1511 allOf: [
1512 { $ref: "nested.json#/definitions/Value" },
1513 { $ref: "external.json#/$defs/Value" }
1514 ]
1515 },
1516 definitions: {
1517 Nested: {
1518 $id: "nested.json",
1519 definitions: { Value: { type: "string" } },
1520 allOf: [{ $ref: "#/definitions/Value" }]
1521 }
1522 }
1523 })
1524 deepStrictEqual(JsonSchema.toDocumentDraft04(input), {
1525 dialect: "draft-04",
1526 schema: {
1527 allOf: [
1528 { $ref: "nested.json#/definitions/Value" },
1529 { $ref: "external.json#/$defs/Value" }
1530 ]
1531 },
1532 definitions: {
1533 Nested: {
1534 id: "nested.json",
1535 definitions: { Value: { type: "string" } },
1536 allOf: [{ $ref: "#/definitions/Value" }]
1537 }
1538 }
1539 })
1540 })
1541
1542 it("converts contentSchema bodies and references", () => {
1543 const input: JsonSchema.Document<"draft-2020-12"> = {
1544 dialect: "draft-2020-12",
1545 schema: {
1546 contentSchema: { $defs: { Value: { const: "value" } } },
1547 allOf: [{ $ref: "#/contentSchema/$defs/Value" }]
1548 },
1549 definitions: {}
1550 }
1551
1552 deepStrictEqual(JsonSchema.toDocumentDraft07(input).schema, {
1553 contentSchema: { definitions: { Value: { const: "value" } } },
1554 allOf: [{ $ref: "#/contentSchema/definitions/Value" }]
1555 })
1556 deepStrictEqual(JsonSchema.toDocumentDraft04(input).schema, {
1557 contentSchema: { definitions: { Value: { enum: ["value"] } } },
1558 allOf: [{ $ref: "#/contentSchema/definitions/Value" }]
1559 })
1560 })
1561
1562 it("rejects canonical constraints that Draft-07 cannot represent", () => {
1563 for (
1564 const schema of [
1565 { contains: { type: "string" }, minContains: 2 },
1566 { contains: { type: "string" }, maxContains: 3 },
1567 { unevaluatedProperties: false },
1568 { unevaluatedItems: false },
1569 { $dynamicRef: "#node" },
1570 { $vocabulary: { "https://example.com/vocabulary": true } },
1571 { dependencies: { value: ["other"] } }
1572 ]
1573 ) {
1574 assert.throws(
1575 () => JsonSchema.toDocumentDraft07({ dialect: "draft-2020-12", schema, definitions: {} }),
1576 /Cannot convert JSON Schema keyword .* to Draft-07/
1577 )
1578 }
1579 })
1580
1581 it("preserves Schema.Never", () => {
1582 const result = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(Schema.Never))
1583 deepStrictEqual(result, {
1584 dialect: "draft-07",
1585 schema: { not: {} },
1586 definitions: {}
1587 })
1588 })
1589
1590 it("omits an empty required array", () => {
1591 const document = JsonSchema.toDocumentDraft07({
1592 dialect: "draft-2020-12",
1593 schema: { type: "object", required: [] },
1594 definitions: {}
1595 })
1596
1597 deepStrictEqual(document.schema, { type: "object" })
1598 })
1599
1600 it("preserves every supported annotation and validation keyword", () => {
1601 const schema: JsonSchema.JsonSchema = {
1602 type: "object",
1603 required: ["value"],
1604 enum: ["a", "b"],
1605 const: "a",
1606 title: "title",
1607 description: "description",
1608 default: "a",
1609 examples: ["a"],
1610 format: "custom",
1611 readOnly: true,
1612 writeOnly: true,
1613 pattern: "^a$",
1614 minimum: 0,
1615 maximum: 10,
1616 exclusiveMinimum: 0,
1617 exclusiveMaximum: 10,
1618 minLength: 1,
1619 maxLength: 10,
1620 minItems: 1,
1621 maxItems: 10,
1622 minProperties: 1,
1623 maxProperties: 10,
1624 multipleOf: 2,
1625 uniqueItems: true,
1626 properties: { value: { type: "string" } },
1627 patternProperties: { "^x-": { type: "string" } },
1628 not: { type: "null" },
1629 additionalProperties: false,
1630 propertyNames: { minLength: 1 },
1631 allOf: [{ type: "object" }],
1632 anyOf: [{ type: "string" }],
1633 oneOf: [{ type: "number" }],
1634 items: { type: "string" }
1635 }
1636
1637 deepStrictEqual(
1638 JsonSchema.toDocumentDraft07({
1639 dialect: "draft-2020-12",
1640 schema,
1641 definitions: {}
1642 }).schema,
1643 schema
1644 )
1645 })
1646
1647 it("preserves validation semantics", () => {
1648 const document = JsonSchema.toDocumentDraft07({
1649 dialect: "draft-2020-12",
1650 schema: {
1651 type: "object",
1652 properties: {
1653 mode: { const: "on" },
1654 value: { type: "number", exclusiveMinimum: 0 },
1655 tuple: { type: "array", prefixItems: [{ type: "string" }], items: false }
1656 },
1657 required: ["mode", "value", "tuple"],
1658 additionalProperties: false
1659 },
1660 definitions: {}
1661 })
1662 const schema = makeSchema(document)
1663 deepStrictEqual(ajvDraft07.validateSchema(schema), true)
1664
1665 const validate = ajvDraft07.compile(schema)
1666 deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a"] }), true)
1667 deepStrictEqual(validate({ mode: "off", value: 1, tuple: ["a"] }), false)
1668 deepStrictEqual(validate({ mode: "on", value: 0, tuple: ["a"] }), false)
1669 deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a", "b"] }), false)
1670 })
1671
1672 it("rewrites $defs refs to Draft-07 definitions refs", () => {
1673 const input: JsonSchema.Document<"draft-2020-12"> = {
1674 dialect: "draft-2020-12",
1675 schema: {
1676 type: "object",
1677 properties: {
1678 a: { $ref: "#/$defs/A" },
1679 b: { $ref: "#/$defs/B" }
1680 }
1681 },
1682 definitions: {
1683 A: {
1684 type: "string",
1685 $ref: "#/$defs/B"
1686 },
1687 B: {
1688 type: "number"
1689 }
1690 }
1691 }
1692 const result = JsonSchema.toDocumentDraft07(input)
1693 deepStrictEqual(result, {
1694 dialect: "draft-07",
1695 schema: {
1696 type: "object",
1697 properties: {
1698 a: { $ref: "#/definitions/A" },
1699 b: { $ref: "#/definitions/B" }
1700 }
1701 },
1702 definitions: {
1703 A: {
1704 type: "string",
1705 allOf: [{ $ref: "#/definitions/B" }]
1706 },
1707 B: {
1708 type: "number"
1709 }
1710 }
1711 })
1712 })
1713
1714 it("rewrites refs only in schema positions", () => {
1715 const literal = { $ref: "#/$defs/Literal" }
1716 const result = JsonSchema.toDocumentDraft07({
1717 dialect: "draft-2020-12",
1718 schema: {
1719 properties: { value: { $ref: "#/$defs/Value" } },
1720 const: literal,
1721 enum: [literal],
1722 default: literal,
1723 examples: [literal]
1724 },
1725 definitions: { Value: { type: "string" } }
1726 })
1727
1728 deepStrictEqual(result, {
1729 dialect: "draft-07",
1730 schema: {
1731 properties: { value: { $ref: "#/definitions/Value" } },
1732 const: literal,
1733 enum: [literal],
1734 default: literal,
1735 examples: [literal]
1736 },
1737 definitions: { Value: { type: "string" } }
1738 })
1739 })
1740
1741 it("preserves constraints next to refs and existing allOf", () => {
1742 const document = JsonSchema.toDocumentDraft07({
1743 dialect: "draft-2020-12",
1744 schema: {
1745 $ref: "#/$defs/S",
1746 minLength: 3,
1747 allOf: [{ maxLength: 5 }]
1748 },
1749 definitions: { S: { type: "string" } }
1750 })
1751
1752 deepStrictEqual(document.schema, {
1753 minLength: 3,
1754 allOf: [
1755 { maxLength: 5 },
1756 { $ref: "#/definitions/S" }
1757 ]
1758 })
1759
1760 const schema = makeSchema(document)
1761 deepStrictEqual(ajvDraft07.validateSchema(schema), true)
1762 const validate = ajvDraft07.compile(schema)
1763 deepStrictEqual(validate("abc"), true)
1764 deepStrictEqual(validate("a"), false)
1765 deepStrictEqual(validate("abcdef"), false)
1766 })
1767
1768 it("keeps existing allOf locations stable when wrapping ref siblings", () => {
1769 const document = JsonSchema.toDocumentDraft07({
1770 dialect: "draft-2020-12",
1771 schema: {
1772 $ref: "#/$defs/Base",
1773 allOf: [{ type: "string" }],
1774 properties: { copy: { $ref: "#/allOf/0" } }
1775 },
1776 definitions: { Base: { type: "object" } }
1777 })
1778
1779 deepStrictEqual(document.schema, {
1780 allOf: [
1781 { type: "string" },
1782 { $ref: "#/definitions/Base" }
1783 ],
1784 properties: { copy: { $ref: "#/allOf/0" } }
1785 })
1786 })
1787
1788 it("converts prefixItems to a Draft-07 items tuple", () => {
1789 const input: JsonSchema.Document<"draft-2020-12"> = {
1790 dialect: "draft-2020-12",
1791 schema: {
1792 type: "array",
1793 prefixItems: [
1794 { type: "string" },
1795 { type: "number" }
1796 ],
1797 items: { type: "boolean" }
1798 },
1799 definitions: {}
1800 }
1801 const result = JsonSchema.toDocumentDraft07(input)
1802 deepStrictEqual(result, {
1803 dialect: "draft-07",
1804 schema: {
1805 type: "array",
1806 items: [
1807 { type: "string" },
1808 { type: "number" }
1809 ],
1810 additionalItems: { type: "boolean" }
1811 },
1812 definitions: {}
1813 })
1814 })
1815
1816 it("preserves a single items schema as items", () => {
1817 const input: JsonSchema.Document<"draft-2020-12"> = {
1818 dialect: "draft-2020-12",
1819 schema: {
1820 type: "array",
1821 items: { type: "string" }
1822 },
1823 definitions: {}
1824 }
1825 const result = JsonSchema.toDocumentDraft07(input)
1826 deepStrictEqual(result, {
1827 dialect: "draft-07",
1828 schema: {
1829 type: "array",
1830 items: { type: "string" }
1831 },
1832 definitions: {}
1833 })
1834 })
1835
1836 it("preserves custom properties in Draft-07 output", () => {
1837 const input: JsonSchema.Document<"draft-2020-12"> = {
1838 dialect: "draft-2020-12",
1839 schema: {
1840 type: "string",
1841 "x-custom": "value"
1842 },
1843 definitions: {}
1844 }
1845 const result = JsonSchema.toDocumentDraft07(input)
1846 deepStrictEqual(result, {
1847 dialect: "draft-07",
1848 schema: {
1849 type: "string",
1850 "x-custom": "value"
1851 },
1852 definitions: {}
1853 })
1854 })
1855
1856 it("preserves malformed values for recognized keywords", () => {
1857 const input: JsonSchema.Document<"draft-2020-12"> = {
1858 dialect: "draft-2020-12",
1859 schema: {
1860 $ref: 1,
1861 properties: "invalid",
1862 not: [false, { type: "string" }],
1863 allOf: "invalid",
1864 prefixItems: "invalid"
1865 },
1866 definitions: {}
1867 }
1868
1869 deepStrictEqual(JsonSchema.toDocumentDraft07(input), {
1870 dialect: "draft-07",
1871 schema: {
1872 $ref: 1,
1873 properties: "invalid",
1874 not: [false, { type: "string" }],
1875 allOf: "invalid",
1876 items: "invalid"
1877 },
1878 definitions: {}
1879 })
1880 })
1881 })
1882
1883 describe("toDocumentDraft04", () => {
1884 it("rewrites the canonical meta-schema URI with an empty fragment", () => {
1885 deepStrictEqual(
1886 JsonSchema.toDocumentDraft04({
1887 dialect: "draft-2020-12",
1888 schema: { $schema: `${JsonSchema.META_SCHEMA_URI_DRAFT_2020_12}#` },
1889 definitions: {}
1890 }).schema,
1891 { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_04 }
1892 )
1893 })
1894
1895 it("omits the canonical meta-schema URI from embedded resources", () => {
1896 const input: JsonSchema.Document<"draft-2020-12"> = {
1897 dialect: "draft-2020-12",
1898 schema: {},
1899 definitions: {
1900 Embedded: {
1901 $id: "embedded.json",
1902 $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
1903 type: "string"
1904 }
1905 }
1906 }
1907
1908 deepStrictEqual(JsonSchema.toDocumentDraft04(input).definitions.Embedded, {
1909 id: "embedded.json",
1910 type: "string"
1911 })
1912 })
1913
1914 it("rejects custom dialects in embedded resources", () => {
1915 assert.throws(
1916 () =>
1917 JsonSchema.toDocumentDraft04({
1918 dialect: "draft-2020-12",
1919 schema: {},
1920 definitions: {
1921 Embedded: {
1922 $id: "embedded.json",
1923 $schema: "https://example.com/dialect",
1924 type: "string"
1925 }
1926 }
1927 }),
1928 /Cannot convert JSON Schema keyword "\$schema" to Draft-04/
1929 )
1930 })
1931
1932 it("lowers canonical dependencies directly and preserves custom keywords", () => {
1933 const result = JsonSchema.toDocumentDraft04({
1934 dialect: "draft-2020-12",
1935 schema: {
1936 dependentRequired: { enabled: ["value"] },
1937 dependentSchemas: { mode: { required: ["value"] } },
1938 "x-custom": { value: true }
1939 },
1940 definitions: {}
1941 })
1942
1943 deepStrictEqual(result.schema, {
1944 dependencies: {
1945 enabled: ["value"],
1946 mode: { required: ["value"] }
1947 },
1948 "x-custom": { value: true }
1949 })
1950 })
1951
1952 it("rewrites $defs refs to Draft-04 definitions refs", () => {
1953 const input: JsonSchema.Document<"draft-2020-12"> = {
1954 dialect: "draft-2020-12",
1955 schema: {
1956 type: "object",
1957 properties: {
1958 value: { $ref: "#/$defs/Value" }
1959 }
1960 },
1961 definitions: {
1962 Value: { type: "string" }
1963 }
1964 }
1965 const result = JsonSchema.toDocumentDraft04(input)
1966 deepStrictEqual(result, {
1967 dialect: "draft-04",
1968 schema: {
1969 type: "object",
1970 properties: {
1971 value: { $ref: "#/definitions/Value" }
1972 }
1973 },
1974 definitions: {
1975 Value: { type: "string" }
1976 }
1977 })
1978 })
1979
1980 it("preserves every supported Draft-04 keyword and newer annotations as extensions", () => {
1981 const input: JsonSchema.JsonSchema = {
1982 type: "object",
1983 required: ["value"],
1984 enum: ["a", "b"],
1985 title: "title",
1986 description: "description",
1987 default: "a",
1988 format: "custom",
1989 pattern: "^a$",
1990 minimum: 0,
1991 maximum: 10,
1992 minLength: 1,
1993 maxLength: 10,
1994 minItems: 1,
1995 maxItems: 10,
1996 minProperties: 1,
1997 maxProperties: 10,
1998 multipleOf: 2,
1999 uniqueItems: true,
2000 properties: { value: { type: "string" } },
2001 patternProperties: { "^x-": { type: "string" } },
2002 not: { type: "null" },
2003 additionalProperties: false,
2004 allOf: [{ type: "object" }],
2005 anyOf: [{ type: "string" }],
2006 oneOf: [{ type: "number" }],
2007 items: { type: "string" },
2008 examples: ["a"],
2009 readOnly: true,
2010 writeOnly: true
2011 }
2012 const expected: JsonSchema.JsonSchema = {
2013 type: "object",
2014 required: ["value"],
2015 enum: ["a", "b"],
2016 title: "title",
2017 description: "description",
2018 default: "a",
2019 format: "custom",
2020 pattern: "^a$",
2021 minimum: 0,
2022 maximum: 10,
2023 minLength: 1,
2024 maxLength: 10,
2025 minItems: 1,
2026 maxItems: 10,
2027 minProperties: 1,
2028 maxProperties: 10,
2029 multipleOf: 2,
2030 uniqueItems: true,
2031 properties: { value: { type: "string" } },
2032 patternProperties: { "^x-": { type: "string" } },
2033 not: { type: "null" },
2034 additionalProperties: { not: {} },
2035 allOf: [{ type: "object" }],
2036 anyOf: [{ type: "string" }],
2037 oneOf: [{ type: "number" }],
2038 items: { type: "string" },
2039 examples: ["a"],
2040 readOnly: true,
2041 writeOnly: true
2042 }
2043
2044 deepStrictEqual(
2045 JsonSchema.toDocumentDraft04({
2046 dialect: "draft-2020-12",
2047 schema: input,
2048 definitions: {}
2049 }).schema,
2050 expected
2051 )
2052 })
2053
2054 it("rejects validation keywords that Draft-04 cannot represent", () => {
2055 for (
2056 const schema of [
2057 { propertyNames: { minLength: 1 } },
2058 { contains: { type: "string" }, minContains: 0 },
2059 { contains: { type: "string" }, minContains: 2 },
2060 {
2061 if: { $id: "condition" },
2062 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
2063 then: {},
2064 else: {}
2065 },
2066 { unevaluatedProperties: false },
2067 { $dynamicRef: "#node" },
2068 { $vocabulary: { "https://example.com/vocabulary": true } },
2069 { id: "legacy-id" }
2070 ]
2071 ) {
2072 assert.throws(
2073 () => JsonSchema.toDocumentDraft04({ dialect: "draft-2020-12", schema, definitions: {} }),
2074 /Cannot convert JSON Schema keyword .* to Draft-04/
2075 )
2076 }
2077 })
2078
2079 it("converts inactive conditional schema bodies", () => {
2080 for (const key of ["if", "then", "else"] as const) {
2081 const result = JsonSchema.toDocumentDraft04({
2082 dialect: "draft-2020-12",
2083 schema: {
2084 [key]: { $defs: { Value: { const: "value" } } },
2085 allOf: [{ $ref: `#/${key}/$defs/Value` }]
2086 },
2087 definitions: {}
2088 })
2089
2090 deepStrictEqual(result.schema, {
2091 [key]: { definitions: { Value: { enum: ["value"] } } },
2092 allOf: [{ $ref: `#/${key}/definitions/Value` }]
2093 })
2094 }
2095 })
2096
2097 it("lowers conditionals and contains while preserving validation semantics", () => {
2098 const document = JsonSchema.toDocumentDraft04({
2099 dialect: "draft-2020-12",
2100 schema: {
2101 type: "object",
2102 properties: {
2103 mode: { enum: ["full", "compact"] },
2104 value: { type: "string" },
2105 values: { contains: { type: "number" } }
2106 },
2107 required: ["mode", "values"],
2108 if: { properties: { mode: { const: "full" } } },
2109 // oxlint-disable-next-line unicorn/no-thenable -- JSON Schema keyword
2110 then: { required: ["value"] },
2111 else: { not: { required: ["value"] } }
2112 },
2113 definitions: {}
2114 })
2115 const schema = makeSchema(document)
2116 deepStrictEqual(ajvDraft04.validateSchema(schema), true)
2117
2118 const validate = ajvDraft04.compile(schema)
2119 deepStrictEqual(validate({ mode: "full", value: "ok", values: [1] }), true)
2120 deepStrictEqual(validate({ mode: "full", values: [1] }), false)
2121 deepStrictEqual(validate({ mode: "compact", values: [1] }), true)
2122 deepStrictEqual(validate({ mode: "compact", value: "unexpected", values: [1] }), false)
2123 deepStrictEqual(validate({ mode: "full", value: "ok", values: ["no"] }), false)
2124 deepStrictEqual(validate({ mode: "full", value: "ok", values: 1 }), true)
2125 })
2126
2127 it("omits an empty required array", () => {
2128 const document = JsonSchema.toDocumentDraft04({
2129 dialect: "draft-2020-12",
2130 schema: { type: "object", required: [] },
2131 definitions: {}
2132 })
2133
2134 deepStrictEqual(document.schema, { type: "object" })
2135 deepStrictEqual(ajvDraft04.validateSchema(makeSchema(document)), true)
2136 })
2137
2138 it("omits empty dependentRequired arrays", () => {
2139 const empty = JsonSchema.toDocumentDraft04({
2140 dialect: "draft-2020-12",
2141 schema: { dependentRequired: { value: [] } },
2142 definitions: {}
2143 })
2144 deepStrictEqual(empty.schema, {})
2145 deepStrictEqual(ajvDraft04.validateSchema(makeSchema(empty)), true)
2146
2147 const combined = JsonSchema.toDocumentDraft04({
2148 dialect: "draft-2020-12",
2149 schema: {
2150 dependentRequired: { value: [] },
2151 dependentSchemas: { value: { type: "string" } }
2152 },
2153 definitions: {}
2154 })
2155 deepStrictEqual(combined.schema, { dependencies: { value: { type: "string" } } })
2156 deepStrictEqual(ajvDraft04.validateSchema(makeSchema(combined)), true)
2157 })
2158
2159 it("converts const to enum", () => {
2160 const input: JsonSchema.Document<"draft-2020-12"> = {
2161 dialect: "draft-2020-12",
2162 schema: {
2163 enum: ["a", "b"],
2164 const: "b",
2165 allOf: [{ type: "string" }]
2166 },
2167 definitions: {}
2168 }
2169 const result = JsonSchema.toDocumentDraft04(input)
2170 deepStrictEqual(result.schema, {
2171 enum: ["a", "b"],
2172 allOf: [{ type: "string" }, { enum: ["b"] }]
2173 })
2174
2175 deepStrictEqual(
2176 JsonSchema.toDocumentDraft04({
2177 dialect: "draft-2020-12",
2178 schema: { const: "a" },
2179 definitions: {}
2180 }).schema,
2181 { enum: ["a"] }
2182 )
2183
2184 deepStrictEqual(
2185 JsonSchema.toDocumentDraft04({
2186 dialect: "draft-2020-12",
2187 schema: { enum: ["a", "b"], const: "b" },
2188 definitions: {}
2189 }).schema,
2190 { enum: ["a", "b"], allOf: [{ enum: ["b"] }] }
2191 )
2192 })
2193
2194 it("preserves refs in literal values", () => {
2195 const literal = { $ref: "#/$defs/Literal" }
2196
2197 deepStrictEqual(
2198 JsonSchema.toDocumentDraft04({
2199 dialect: "draft-2020-12",
2200 schema: { const: literal },
2201 definitions: {}
2202 }).schema,
2203 { enum: [literal] }
2204 )
2205 deepStrictEqual(
2206 JsonSchema.toDocumentDraft04({
2207 dialect: "draft-2020-12",
2208 schema: { enum: [literal], default: literal },
2209 definitions: {}
2210 }).schema,
2211 { enum: [literal], default: literal }
2212 )
2213 })
2214
2215 it("converts numeric exclusive bounds to Draft-04 boolean exclusivity", () => {
2216 const cases: ReadonlyArray<readonly [JsonSchema.JsonSchema, JsonSchema.JsonSchema]> = [
2217 [{ minimum: 1 }, { minimum: 1 }],
2218 [{ exclusiveMinimum: 1 }, { minimum: 1, exclusiveMinimum: true }],
2219 [{ minimum: 2, exclusiveMinimum: 1 }, { minimum: 2 }],
2220 [{ minimum: 1, exclusiveMinimum: 1 }, { minimum: 1, exclusiveMinimum: true }],
2221 [{ minimum: 1, exclusiveMinimum: 2 }, { minimum: 2, exclusiveMinimum: true }],
2222 [{ maximum: 2 }, { maximum: 2 }],
2223 [{ exclusiveMaximum: 2 }, { maximum: 2, exclusiveMaximum: true }],
2224 [{ maximum: 1, exclusiveMaximum: 2 }, { maximum: 1 }],
2225 [{ maximum: 2, exclusiveMaximum: 2 }, { maximum: 2, exclusiveMaximum: true }],
2226 [{ maximum: 2, exclusiveMaximum: 1 }, { maximum: 1, exclusiveMaximum: true }]
2227 ]
2228 for (const [schema, expected] of cases) {
2229 deepStrictEqual(
2230 JsonSchema.toDocumentDraft04({
2231 dialect: "draft-2020-12",
2232 schema,
2233 definitions: {}
2234 }).schema,
2235 expected
2236 )
2237 }
2238 })
2239
2240 it("converts boolean schemas only in schema positions", () => {
2241 const input: JsonSchema.Document<"draft-2020-12"> = {
2242 dialect: "draft-2020-12",
2243 schema: {
2244 type: "object",
2245 properties: {
2246 allowed: true,
2247 denied: false,
2248 nested: { not: false }
2249 },
2250 additionalProperties: false,
2251 allOf: [true],
2252 anyOf: [false]
2253 },
2254 definitions: {}
2255 }
2256 const result = JsonSchema.toDocumentDraft04(input)
2257 deepStrictEqual(result.schema, {
2258 type: "object",
2259 properties: {
2260 allowed: {},
2261 denied: { not: {} },
2262 nested: { not: { not: {} } }
2263 },
2264 additionalProperties: { not: {} },
2265 allOf: [{}],
2266 anyOf: [{ not: {} }]
2267 })
2268 })
2269
2270 it("converts tuple members and their trailing boolean schema", () => {
2271 const input: JsonSchema.Document<"draft-2020-12"> = {
2272 dialect: "draft-2020-12",
2273 schema: {
2274 type: "array",
2275 prefixItems: [true, false],
2276 items: false
2277 },
2278 definitions: {}
2279 }
2280 const result = JsonSchema.toDocumentDraft04(input)
2281 deepStrictEqual(result.schema, {
2282 type: "array",
2283 items: [{}, { not: {} }],
2284 additionalItems: { not: {} }
2285 })
2286
2287 deepStrictEqual(
2288 JsonSchema.toDocumentDraft04({
2289 dialect: "draft-2020-12",
2290 schema: { type: "array", items: false },
2291 definitions: {}
2292 }).schema,
2293 { type: "array", items: { not: {} } }
2294 )
2295 })
2296
2297 it("converts schemas in additionalProperties and additionalItems", () => {
2298 const result = JsonSchema.toDocumentDraft04({
2299 dialect: "draft-2020-12",
2300 schema: {
2301 type: "object",
2302 additionalProperties: { const: "value" },
2303 properties: {
2304 tuple: {
2305 type: "array",
2306 prefixItems: [{ type: "string" }],
2307 items: { const: "rest" }
2308 }
2309 }
2310 },
2311 definitions: {}
2312 })
2313
2314 deepStrictEqual(result.schema, {
2315 type: "object",
2316 additionalProperties: { enum: ["value"] },
2317 properties: {
2318 tuple: {
2319 type: "array",
2320 items: [{ type: "string" }],
2321 additionalItems: { enum: ["rest"] }
2322 }
2323 }
2324 })
2325 })
2326
2327 it("preserves malformed values for recognized keywords", () => {
2328 const result = JsonSchema.toDocumentDraft04({
2329 dialect: "draft-2020-12",
2330 schema: {
2331 properties: "invalid",
2332 not: [1],
2333 allOf: "invalid"
2334 },
2335 definitions: {}
2336 })
2337
2338 deepStrictEqual(result.schema, {
2339 properties: "invalid",
2340 not: [1],
2341 allOf: "invalid"
2342 })
2343 })
2344
2345 it("preserves allOf, not, and null", () => {
2346 const input: JsonSchema.Document<"draft-2020-12"> = {
2347 dialect: "draft-2020-12",
2348 schema: {
2349 anyOf: [
2350 { type: "null" },
2351 { allOf: [{ not: { type: "string" } }] }
2352 ]
2353 },
2354 definitions: {}
2355 }
2356 const result = JsonSchema.toDocumentDraft04(input)
2357 deepStrictEqual(result.schema, input.schema)
2358 })
2359
2360 it("preserves newer annotations as Draft-04 extensions", () => {
2361 const input: JsonSchema.Document<"draft-2020-12"> = {
2362 dialect: "draft-2020-12",
2363 schema: {
2364 type: "object",
2365 examples: [{ value: 1 }]
2366 },
2367 definitions: {}
2368 }
2369 const result = JsonSchema.toDocumentDraft04(input)
2370 deepStrictEqual(result.schema, { type: "object", examples: [{ value: 1 }] })
2371 })
2372
2373 it("preserves constraints next to refs", () => {
2374 const document = JsonSchema.toDocumentDraft04({
2375 dialect: "draft-2020-12",
2376 schema: { $ref: "#/$defs/S", minLength: 3 },
2377 definitions: { S: { type: "string" } }
2378 })
2379
2380 deepStrictEqual(document.schema, {
2381 allOf: [{ $ref: "#/definitions/S" }],
2382 minLength: 3
2383 })
2384
2385 const schema = makeSchema(document)
2386 deepStrictEqual(ajvDraft04.validateSchema(schema), true)
2387 const validate = ajvDraft04.compile(schema)
2388 deepStrictEqual(validate("abc"), true)
2389 deepStrictEqual(validate("a"), false)
2390 })
2391
2392 it("preserves validation semantics for converted constraints", () => {
2393 const document = JsonSchema.toDocumentDraft04({
2394 dialect: "draft-2020-12",
2395 schema: {
2396 type: "object",
2397 properties: {
2398 mode: { const: "on" },
2399 value: { type: "number", exclusiveMinimum: 0, exclusiveMaximum: 2 },
2400 tuple: { type: "array", prefixItems: [{ type: "string" }], items: false }
2401 },
2402 required: ["mode", "value", "tuple"],
2403 additionalProperties: false
2404 },
2405 definitions: {}
2406 })
2407 const schema = makeSchema(document)
2408 deepStrictEqual(ajvDraft04.validateSchema(schema), true)
2409
2410 const validate = ajvDraft04.compile(schema)
2411 deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a"] }), true)
2412 deepStrictEqual(validate({ mode: "off", value: 1, tuple: ["a"] }), false)
2413 deepStrictEqual(validate({ mode: "on", value: 0, tuple: ["a"] }), false)
2414 deepStrictEqual(validate({ mode: "on", value: 1, tuple: ["a", "b"] }), false)
2415 })
2416
2417 it("converts documents generated from Effect schemas", () => {
2418 const shared = Schema.Struct({ value: Schema.String })
2419 const schema = Schema.Struct({
2420 mode: Schema.Literal("enabled"),
2421 threshold: Schema.Finite.check(Schema.isGreaterThan(0)),
2422 tuple: Schema.Tuple([Schema.String, Schema.Boolean]),
2423 left: shared,
2424 right: shared
2425 })
2426 const document = JsonSchema.toDocumentDraft04(Schema.toJsonSchemaDocument(schema))
2427 const draft04 = makeSchema(document)
2428 deepStrictEqual(ajvDraft04.validateSchema(draft04), true)
2429
2430 const validate = ajvDraft04.compile(draft04)
2431 const valid = {
2432 mode: "enabled",
2433 threshold: 1,
2434 tuple: ["a", true],
2435 left: { value: "left" },
2436 right: { value: "right" }
2437 }
2438 deepStrictEqual(validate(valid), true)
2439 deepStrictEqual(validate({ ...valid, threshold: 0 }), false)
2440 deepStrictEqual(validate({ ...valid, tuple: ["a", true, false] }), false)
2441 })
2442 })
2443
2444 describe("toMultiDocumentOpenApi3_1", () => {
2445 it("rejects opaque keywords that OpenAPI 3.1 would activate", () => {
2446 for (
2447 const input of [
2448 { schemas: [{ example: "value" }], definitions: {} },
2449 { schemas: [{ properties: { value: { discriminator: {} } } }], definitions: {} },
2450 { schemas: [{}], definitions: { Value: { xml: {} } } },
2451 { schemas: [{ externalDocs: {} }], definitions: {} }
2452 ] as const
2453 ) {
2454 assert.throws(
2455 () => JsonSchema.toMultiDocumentOpenApi3_1({ dialect: "draft-2020-12", ...input }),
2456 /Cannot convert JSON Schema keyword .* to OpenAPI 3.1/
2457 )
2458 }
2459 })
2460
2461 it("does not inspect opaque values for OpenAPI 3.1 keyword collisions", () => {
2462 const literal = { example: "value", discriminator: {}, xml: {}, externalDocs: {} }
2463 deepStrictEqual(
2464 JsonSchema.toMultiDocumentOpenApi3_1({
2465 dialect: "draft-2020-12",
2466 schemas: [{ const: literal }],
2467 definitions: {}
2468 }).schemas,
2469 [{ const: literal }]
2470 )
2471 })
2472
2473 it("rewrites `$defs` references to `components/schemas`", () => {
2474 const input: JsonSchema.MultiDocument<"draft-2020-12"> = {
2475 dialect: "draft-2020-12",
2476 schemas: [
2477 {
2478 type: "object",
2479 properties: {
2480 a: { $ref: "#/$defs/A" },
2481 b: { $ref: "#/$defs/B" }
2482 }
2483 }
2484 ],
2485 definitions: {
2486 A: {
2487 type: "string",
2488 $ref: "#/$defs/B"
2489 },
2490 B: {
2491 type: "number"
2492 }
2493 }
2494 }
2495 const result = JsonSchema.toMultiDocumentOpenApi3_1(input)
2496 deepStrictEqual(result, {
2497 dialect: "openapi-3.1",
2498 schemas: [
2499 {
2500 type: "object",
2501 properties: {
2502 a: { $ref: "#/components/schemas/A" },
2503 b: { $ref: "#/components/schemas/B" }
2504 }
2505 }
2506 ],
2507 definitions: {
2508 A: {
2509 type: "string",
2510 $ref: "#/components/schemas/B"
2511 },
2512 B: {
2513 type: "number"
2514 }
2515 }
2516 })
2517 })
2518
2519 it("rewrites refs only in schema positions", () => {
2520 const literal = { $ref: "#/$defs/Literal" }
2521 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2522 dialect: "draft-2020-12",
2523 schemas: [{
2524 properties: { value: { $ref: "#/$defs/Value" } },
2525 const: literal,
2526 enum: [literal],
2527 default: literal,
2528 examples: [literal]
2529 }],
2530 definitions: { Value: { type: "string" } }
2531 })
2532
2533 deepStrictEqual(result, {
2534 dialect: "openapi-3.1",
2535 schemas: [{
2536 properties: { value: { $ref: "#/components/schemas/Value" } },
2537 const: literal,
2538 enum: [literal],
2539 default: literal,
2540 examples: [literal]
2541 }],
2542 definitions: { Value: { type: "string" } }
2543 })
2544 })
2545
2546 it("sanitizes component schema keys and rewritten refs together", () => {
2547 const input: JsonSchema.MultiDocument<"draft-2020-12"> = {
2548 dialect: "draft-2020-12",
2549 schemas: [
2550 {
2551 type: "object",
2552 properties: {
2553 "A.B": { "$ref": "#/$defs/A$B" }
2554 }
2555 }
2556 ],
2557 definitions: {
2558 "A$B": { "$ref": "#/$defs/B$C" },
2559 "B$C": { type: "string" }
2560 }
2561 }
2562 const result = JsonSchema.toMultiDocumentOpenApi3_1(input)
2563 deepStrictEqual(result, {
2564 dialect: "openapi-3.1",
2565 schemas: [
2566 {
2567 type: "object",
2568 properties: {
2569 "A.B": { "$ref": "#/components/schemas/A_B" }
2570 }
2571 }
2572 ],
2573 definitions: {
2574 "A_B": { "$ref": "#/components/schemas/B_C" },
2575 "B_C": { type: "string" }
2576 }
2577 })
2578 })
2579
2580 it("unescapes a definition key before sanitizing its ref", () => {
2581 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2582 dialect: "draft-2020-12",
2583 schemas: [{ $ref: "#/$defs/A~1B" }],
2584 definitions: {
2585 "A/B": { type: "string" }
2586 }
2587 })
2588
2589 deepStrictEqual(result, {
2590 dialect: "openapi-3.1",
2591 schemas: [{ $ref: "#/components/schemas/A_B" }],
2592 definitions: {
2593 A_B: { type: "string" }
2594 }
2595 })
2596 })
2597
2598 it("decodes a definition key before sanitizing its ref", () => {
2599 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2600 dialect: "draft-2020-12",
2601 schemas: [{
2602 allOf: [
2603 { $ref: "#/$defs/A%20B" },
2604 { $dynamicRef: "#/$defs/A%20B" }
2605 ]
2606 }],
2607 definitions: {
2608 "A B": { type: "string" }
2609 }
2610 })
2611
2612 deepStrictEqual(result, {
2613 dialect: "openapi-3.1",
2614 schemas: [{
2615 allOf: [
2616 { $ref: "#/components/schemas/A_B" },
2617 { $dynamicRef: "#/components/schemas/A_B" }
2618 ]
2619 }],
2620 definitions: {
2621 A_B: { type: "string" }
2622 }
2623 })
2624 })
2625
2626 it("does not rewrite local refs inside embedded schema resources", () => {
2627 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2628 dialect: "draft-2020-12",
2629 schemas: [{
2630 allOf: [
2631 { $dynamicRef: "#/$defs/Embedded" },
2632 {
2633 $id: "inline.json",
2634 $defs: { Inner: { type: "string" } },
2635 $dynamicRef: "#/$defs/Inner"
2636 }
2637 ]
2638 }],
2639 definitions: {
2640 Embedded: {
2641 $id: "embedded.json",
2642 $defs: { Inner: { type: "number" } },
2643 allOf: [
2644 { $ref: "#/$defs/Inner" },
2645 { $dynamicRef: "#/$defs/Inner" }
2646 ]
2647 }
2648 }
2649 })
2650
2651 deepStrictEqual(result, {
2652 dialect: "openapi-3.1",
2653 schemas: [{
2654 allOf: [
2655 { $dynamicRef: "#/components/schemas/Embedded" },
2656 {
2657 $id: "inline.json",
2658 $defs: { Inner: { type: "string" } },
2659 $dynamicRef: "#/$defs/Inner"
2660 }
2661 ]
2662 }],
2663 definitions: {
2664 Embedded: {
2665 $id: "embedded.json",
2666 $defs: { Inner: { type: "number" } },
2667 allOf: [
2668 { $ref: "#/$defs/Inner" },
2669 { $dynamicRef: "#/$defs/Inner" }
2670 ]
2671 }
2672 }
2673 })
2674 })
2675
2676 it("does not rewrite local refs inside root schema resources", () => {
2677 const schema = {
2678 $id: "inline.json",
2679 $defs: { Inner: { type: "string" } },
2680 allOf: [
2681 { $ref: "#/$defs/Inner" },
2682 { $dynamicRef: "#/$defs/Inner" }
2683 ]
2684 }
2685
2686 deepStrictEqual(
2687 JsonSchema.toMultiDocumentOpenApi3_1({
2688 dialect: "draft-2020-12",
2689 schemas: [schema],
2690 definitions: {}
2691 }),
2692 {
2693 dialect: "openapi-3.1",
2694 schemas: [schema],
2695 definitions: {}
2696 }
2697 )
2698 })
2699
2700 it("rejects shared definition refs from root schema resources", () => {
2701 for (const keyword of ["$ref", "$dynamicRef"] as const) {
2702 assert.throws(
2703 () =>
2704 JsonSchema.toMultiDocumentOpenApi3_1({
2705 dialect: "draft-2020-12",
2706 schemas: [{ $id: "inline.json", [keyword]: "#/$defs/Value" }],
2707 definitions: { Value: { type: "string" } }
2708 }),
2709 new RegExp(`Cannot convert JSON Schema keyword "\\${keyword}" to OpenAPI 3\\.1`)
2710 )
2711 }
2712 })
2713
2714 it("suffixes a sanitized key that collides with a valid key", () => {
2715 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2716 dialect: "draft-2020-12",
2717 schemas: [
2718 {
2719 allOf: [
2720 { $ref: "#/$defs/A_B" },
2721 { $ref: "#/$defs/A~1B" }
2722 ]
2723 }
2724 ],
2725 definitions: {
2726 A_B: { type: "string" },
2727 "A/B": { type: "number" }
2728 }
2729 })
2730
2731 deepStrictEqual(result, {
2732 dialect: "openapi-3.1",
2733 schemas: [
2734 {
2735 allOf: [
2736 { $ref: "#/components/schemas/A_B" },
2737 { $ref: "#/components/schemas/A_B_1" }
2738 ]
2739 }
2740 ],
2741 definitions: {
2742 A_B: { type: "string" },
2743 A_B_1: { type: "number" }
2744 }
2745 })
2746 })
2747
2748 it("allocates suffixes deterministically and skips occupied keys", () => {
2749 const convert = (definitions: JsonSchema.Definitions) =>
2750 JsonSchema.toMultiDocumentOpenApi3_1({
2751 dialect: "draft-2020-12",
2752 schemas: [
2753 {
2754 allOf: [
2755 { $ref: "#/$defs/A_B" },
2756 { $ref: "#/$defs/A_B_1" },
2757 { $ref: "#/$defs/A~1B" },
2758 { $ref: "#/$defs/A?B" }
2759 ]
2760 }
2761 ],
2762 definitions
2763 })
2764 const expected: JsonSchema.MultiDocument<"openapi-3.1"> = {
2765 dialect: "openapi-3.1",
2766 schemas: [
2767 {
2768 allOf: [
2769 { $ref: "#/components/schemas/A_B" },
2770 { $ref: "#/components/schemas/A_B_1" },
2771 { $ref: "#/components/schemas/A_B_2" },
2772 { $ref: "#/components/schemas/A_B_3" }
2773 ]
2774 }
2775 ],
2776 definitions: {
2777 A_B: { type: "string" },
2778 A_B_1: { type: "boolean" },
2779 A_B_2: { type: "number" },
2780 A_B_3: { type: "null" }
2781 }
2782 }
2783
2784 deepStrictEqual(
2785 convert({
2786 "A?B": { type: "null" },
2787 A_B_1: { type: "boolean" },
2788 "A/B": { type: "number" },
2789 A_B: { type: "string" }
2790 }),
2791 expected
2792 )
2793 deepStrictEqual(
2794 convert({
2795 A_B: { type: "string" },
2796 "A/B": { type: "number" },
2797 A_B_1: { type: "boolean" },
2798 "A?B": { type: "null" }
2799 }),
2800 expected
2801 )
2802 })
2803
2804 it("reserves sanitized bases before allocating suffixes", () => {
2805 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2806 dialect: "draft-2020-12",
2807 schemas: [
2808 {
2809 allOf: [
2810 { $ref: "#/$defs/A~1B" },
2811 { $ref: "#/$defs/A?B" },
2812 { $ref: "#/$defs/A?B?1" }
2813 ]
2814 }
2815 ],
2816 definitions: {
2817 "A/B": { type: "number" },
2818 "A?B": { type: "string" },
2819 "A?B?1": { type: "boolean" }
2820 }
2821 })
2822
2823 deepStrictEqual(result, {
2824 dialect: "openapi-3.1",
2825 schemas: [
2826 {
2827 allOf: [
2828 { $ref: "#/components/schemas/A_B" },
2829 { $ref: "#/components/schemas/A_B_2" },
2830 { $ref: "#/components/schemas/A_B_1" }
2831 ]
2832 }
2833 ],
2834 definitions: {
2835 A_B: { type: "number" },
2836 A_B_2: { type: "string" },
2837 A_B_1: { type: "boolean" }
2838 }
2839 })
2840 })
2841
2842 it("rewrites nested definition refs without changing other refs", () => {
2843 const result = JsonSchema.toMultiDocumentOpenApi3_1({
2844 dialect: "draft-2020-12",
2845 schemas: [
2846 {
2847 allOf: [
2848 { $ref: "#/$defs/A~1B/properties/value" },
2849 { $ref: "https://example.com/schema#/$defs/A~1B" },
2850 { $ref: "#/other/A~1B" }
2851 ]
2852 }
2853 ],
2854 definitions: {
2855 "A/B": {
2856 type: "object",
2857 properties: { value: { type: "string" } }
2858 }
2859 }
2860 })
2861
2862 deepStrictEqual(result, {
2863 dialect: "openapi-3.1",
2864 schemas: [
2865 {
2866 allOf: [
2867 { $ref: "#/components/schemas/A_B/properties/value" },
2868 { $ref: "https://example.com/schema#/$defs/A~1B" },
2869 { $ref: "#/other/A~1B" }
2870 ]
2871 }
2872 ],
2873 definitions: {
2874 A_B: {
2875 type: "object",
2876 properties: { value: { type: "string" } }
2877 }
2878 }
2879 })
2880 })
2881 })
2882
2883 describe("input immutability", () => {
2884 const canonicalSchema: JsonSchema.JsonSchema = {
2885 type: "object",
2886 properties: {
2887 value: {
2888 type: "array",
2889 prefixItems: [{ type: "string" }],
2890 items: false
2891 }
2892 }
2893 }
2894
2895 for (
2896 const [name, convert, input] of [
2897 [
2898 "fromSchemaDraft07",
2899 JsonSchema.fromSchemaDraft07,
2900 {
2901 type: "array",
2902 items: [{ type: "string" }],
2903 additionalItems: false,
2904 definitions: { Value: { type: "string" } }
2905 }
2906 ],
2907 ["fromSchemaDraft2020_12", JsonSchema.fromSchemaDraft2020_12, canonicalSchema],
2908 ["fromSchemaOpenApi3_1", JsonSchema.fromSchemaOpenApi3_1, canonicalSchema],
2909 [
2910 "fromSchemaOpenApi3_0",
2911 JsonSchema.fromSchemaOpenApi3_0,
2912 { type: "array", items: { type: "string" }, nullable: true }
2913 ]
2914 ] as const
2915 ) {
2916 it(`${name} does not mutate its input`, () => {
2917 assertDoesNotMutate(structuredClone(input), convert)
2918 })
2919 }
2920
2921 for (
2922 const [name, convert] of [
2923 ["toDocumentDraft07", JsonSchema.toDocumentDraft07],
2924 ["toDocumentDraft04", JsonSchema.toDocumentDraft04]
2925 ] as const
2926 ) {
2927 it(`${name} does not mutate its input`, () => {
2928 assertDoesNotMutate(
2929 {
2930 dialect: "draft-2020-12",
2931 schema: structuredClone(canonicalSchema),
2932 definitions: { Value: { type: "string" } }
2933 },
2934 convert
2935 )
2936 })
2937 }
2938
2939 it("toMultiDocumentOpenApi3_1 does not mutate its input", () => {
2940 assertDoesNotMutate(
2941 {
2942 dialect: "draft-2020-12",
2943 schemas: [structuredClone(canonicalSchema)] as const,
2944 definitions: { Value: { type: "string" } }
2945 },
2946 JsonSchema.toMultiDocumentOpenApi3_1
2947 )
2948 })
2949 })
2950
2951 describe("roundtrip conversions", () => {
2952 it("preserves a Draft-07 schema and definitions through canonical form", () => {
2953 const original: JsonSchema.JsonSchema = {
2954 type: "object",
2955 readOnly: true,
2956 writeOnly: true,
2957 not: { required: ["forbidden"] },
2958 properties: {
2959 name: { type: "string" },
2960 items: {
2961 type: "array",
2962 items: [
2963 { type: "string" },
2964 { type: "number" }
2965 ],
2966 additionalItems: { type: "boolean" }
2967 },
2968 ref: {
2969 $ref: "#/definitions/MyType"
2970 }
2971 },
2972 definitions: {
2973 MyType: {
2974 type: "string"
2975 }
2976 }
2977 }
2978
2979 const to2020_12 = JsonSchema.fromSchemaDraft07(original)
2980 const backTo07 = JsonSchema.toDocumentDraft07(to2020_12)
2981
2982 deepStrictEqual(backTo07.schema, {
2983 type: "object",
2984 readOnly: true,
2985 writeOnly: true,
2986 not: { required: ["forbidden"] },
2987 properties: {
2988 name: { type: "string" },
2989 items: {
2990 type: "array",
2991 items: [
2992 { type: "string" },
2993 { type: "number" }
2994 ],
2995 additionalItems: { type: "boolean" }
2996 },
2997 ref: {
2998 $ref: "#/definitions/MyType"
2999 }
3000 }
3001 })
3002 deepStrictEqual(backTo07.definitions, {
3003 MyType: {
3004 type: "string"
3005 }
3006 })
3007 })
3008 })
3009})