1import { assert, describe, it } from "@effect/vitest"
2import type { Options as AjvOptions } from "ajv"
3import { Exit, JsonSchema, Schema, SchemaRepresentation } from "effect"
4
5// oxlint-disable-next-line @typescript-eslint/no-require-imports
6const Ajv2020 = require("ajv/dist/2020")
7
8const ajv = new Ajv2020.default(
9 {
10 allErrors: true,
11 strict: false,
12 validateSchema: true,
13 code: { esm: true }
14 } satisfies AjvOptions
15)
16
17function compile(document: JsonSchema.Document<"draft-2020-12">) {
18 return ajv.compile({
19 $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
20 ...document.schema,
21 $defs: document.definitions
22 })
23}
24
25function assertSameAcceptedValues(
26 left: (input: unknown) => unknown,
27 right: (input: unknown) => unknown,
28 inputs: ReadonlyArray<unknown>
29): void {
30 for (const input of inputs) {
31 assert.strictEqual(
32 left(input) === true,
33 right(input) === true,
34 `Validation differs for ${JSON.stringify(input)}`
35 )
36 }
37}
38
39function assertJsonSchemaEquivalent(
40 left: JsonSchema.Document<"draft-2020-12">,
41 right: JsonSchema.Document<"draft-2020-12">,
42 inputs: ReadonlyArray<unknown>
43): void {
44 assertSameAcceptedValues(compile(left), compile(right), inputs)
45}
46
47function assertJsonSchemaImportRoundTrip(
48 schema: JsonSchema.JsonSchema,
49 inputs: ReadonlyArray<unknown>
50): void {
51 const source = JsonSchema.fromSchemaDraft2020_12(schema)
52 const imported = SchemaRepresentation.fromJsonSchemaDocument(source, { patterns: "apply" })
53 const representation = SchemaRepresentation.toRepresentation(imported.ast)
54 const emitted = SchemaRepresentation.toJsonSchemaDocument(representation, { onExcessProperty: "error" })
55 assertJsonSchemaEquivalent(source, emitted, inputs)
56
57 const validate = compile(source)
58 const decode = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder<unknown>, {
59 onExcessProperty: "error"
60 })
61 assertSameAcceptedValues((input) => Exit.isSuccess(decode(input)), validate, inputs)
62}
63
64function assertRepresentationRoundTrip(
65 schema: Schema.ConstraintDecoder<unknown>,
66 inputs: ReadonlyArray<unknown>,
67 options: Schema.ToJsonSchemaOptions = { onExcessProperty: "error" }
68): void {
69 const emitted = Schema.toJsonSchemaDocument(schema, options)
70 const imported = SchemaRepresentation.fromJsonSchemaDocument(emitted, { patterns: "apply" })
71 const decodeSource = Schema.decodeUnknownExit(schema, { onExcessProperty: "error" })
72 const decodeImported = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder<unknown>, {
73 onExcessProperty: "error"
74 })
75 assertSameAcceptedValues(
76 (input) => Exit.isSuccess(decodeImported(input)),
77 (input) => Exit.isSuccess(decodeSource(input)),
78 inputs
79 )
80 assertJsonSchemaEquivalent(emitted, Schema.toJsonSchemaDocument(imported, options), inputs)
81}
82
83function assertLooseRepresentationRoundTrip(
84 schema: Schema.ConstraintDecoder<unknown>,
85 inputs: ReadonlyArray<unknown>,
86 options?: Schema.ToJsonSchemaOptions
87): Schema.Top {
88 const emitted = Schema.toJsonSchemaDocument(schema, options)
89 const imported = SchemaRepresentation.fromJsonSchemaDocument(emitted, { patterns: "apply" })
90 const decodeSource = Schema.decodeUnknownExit(schema, { onExcessProperty: "error" })
91 const decodeImported = Schema.decodeUnknownExit(imported as unknown as Schema.ConstraintDecoder<unknown>, {
92 onExcessProperty: "error"
93 })
94 for (const input of inputs) {
95 if (Exit.isSuccess(decodeSource(input))) {
96 assert.isTrue(Exit.isSuccess(decodeImported(input)), `Import rejects ${JSON.stringify(input)}`)
97 }
98 }
99 assertJsonSchemaEquivalent(emitted, Schema.toJsonSchemaDocument(imported, options), inputs)
100 return imported
101}
102
103describe("JSON Schema round-trip laws", () => {
104 describe("toJsonSchema(fromJsonSchema(A))", () => {
105 it("applies type-specific keywords only to matching instances", () => {
106 assertJsonSchemaImportRoundTrip(
107 { minLength: 2 },
108 ["", "a", "ab", 0, true, null, [], {}]
109 )
110 assertJsonSchemaImportRoundTrip(
111 { minimum: 1 },
112 [0, 1, "a", true, null, [], {}]
113 )
114 assertJsonSchemaImportRoundTrip(
115 { minItems: 1 },
116 [[], [1], "a", 0, true, null, {}]
117 )
118 assertJsonSchemaImportRoundTrip(
119 { required: ["a"] },
120 [{}, { a: 1 }, [], "a", 0, true, null]
121 )
122 })
123
124 it("preserves Unicode string lengths and patterns", () => {
125 const inputs = ["", "a", "é", "😀", "e\u0301", "😀a", "\uD800"]
126 assertJsonSchemaImportRoundTrip(
127 { type: "string", minLength: 2, maxLength: 2 },
128 inputs
129 )
130 assertJsonSchemaImportRoundTrip(
131 { type: "string", pattern: "^.$" },
132 inputs
133 )
134 })
135
136 it("conjoins enum with its sibling constraints", () => {
137 assertJsonSchemaImportRoundTrip(
138 { enum: ["a", "ab", 1], minLength: 2 },
139 ["a", "ab", 1, true]
140 )
141 })
142
143 it("conjoins const with its sibling constraints", () => {
144 assertJsonSchemaImportRoundTrip(
145 { const: "a", minLength: 2 },
146 ["a", "aa", 1, null]
147 )
148 })
149
150 it("intersects finite enums as sets", () => {
151 assertJsonSchemaImportRoundTrip(
152 { allOf: [{ enum: ["a", "b", 1] }, { enum: ["b", 1, true] }] },
153 ["a", "b", 1, true, null]
154 )
155 })
156
157 it("preserves oneOf branch multiplicity", () => {
158 assertJsonSchemaImportRoundTrip(
159 { oneOf: [{ const: "a" }, { const: "a" }] },
160 ["a", "b", 1]
161 )
162 })
163
164 it("preserves minItems after prefixItems", () => {
165 assertJsonSchemaImportRoundTrip(
166 {
167 type: "array",
168 prefixItems: [{ type: "string" }],
169 items: { type: "number" },
170 minItems: 2
171 },
172 [[], ["a"], ["a", 1], ["a", 1, 2], ["a", "b"]]
173 )
174 })
175
176 it("intersects one constraint with multiple alternatives linearly", () => {
177 assertJsonSchemaImportRoundTrip(
178 {
179 allOf: [
180 {
181 anyOf: [
182 { type: "string", minLength: 2, maxLength: 3 },
183 { type: "string", minLength: 5, maxLength: 6 }
184 ]
185 },
186 { type: "string", pattern: "^a" }
187 ]
188 },
189 ["a", "aa", "bb", "aaaa", "aaaaa", "bbbbb", 1]
190 )
191 })
192
193 it("preserves empty closed objects", () => {
194 assertJsonSchemaImportRoundTrip(
195 { type: "object", additionalProperties: false },
196 [{}, { a: 1 }, [], [1], null, 1]
197 )
198 })
199
200 it("applies additionalProperties to required-only names", () => {
201 assertJsonSchemaImportRoundTrip(
202 { type: "object", required: ["a"], additionalProperties: false },
203 [{}, { a: 1 }, { a: "a" }, { b: 1 }, []]
204 )
205 })
206
207 it("applies an additionalProperties schema to required-only names", () => {
208 assertJsonSchemaImportRoundTrip(
209 { type: "object", required: ["a"], additionalProperties: { type: "string" } },
210 [{}, { a: 1 }, { a: "a" }, { a: "a", b: "b" }, { a: "a", b: 1 }, []]
211 )
212 })
213
214 it("preserves closed object scopes", () => {
215 assertJsonSchemaImportRoundTrip(
216 {
217 type: "object",
218 additionalProperties: false,
219 allOf: [{ properties: { a: { type: "string" } } }]
220 },
221 [{}, { a: "a" }, { a: 1 }, { b: 1 }, { a: "a", b: 1 }, []]
222 )
223 })
224
225 it("preserves sibling additionalProperties schemas", () => {
226 assertJsonSchemaImportRoundTrip(
227 {
228 type: "object",
229 properties: { a: { type: "string" } },
230 allOf: [{ additionalProperties: { type: "boolean" } }]
231 },
232 [{}, { a: "a" }, { b: true }, { b: "b" }, { a: "a", b: true }, []]
233 )
234 })
235
236 it("preserves closed patternProperties", () => {
237 assertJsonSchemaImportRoundTrip(
238 {
239 type: "object",
240 patternProperties: { "^a": { type: "string" } },
241 additionalProperties: false
242 },
243 [{}, { a: "a" }, { a: 1 }, { ab: "a", b: 1 }, { b: 1 }, []]
244 )
245 })
246 })
247
248 describe("fromJsonSchema(toJsonSchema(X))", () => {
249 it("preserves structs", () => {
250 assertRepresentationRoundTrip(
251 Schema.Struct({ a: Schema.String }),
252 [{}, { a: "a" }, { a: 1 }, { a: "a", b: 1 }, []]
253 )
254 })
255
256 it("preserves string indexes", () => {
257 assertRepresentationRoundTrip(
258 Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])),
259 [{}, { a: "a" }, { a: 1 }, { a: true }, []]
260 )
261 })
262
263 it("imports the loose default export of approximate pattern records", () => {
264 const schema = Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite)
265 const inputs = [{}, { A: 1 }, { A: "a" }, { a: 1 }, { a: "a" }, []]
266 assertRepresentationRoundTrip(
267 schema,
268 inputs
269 )
270 const emitted = Schema.toJsonSchemaDocument(schema)
271 assert.deepStrictEqual(emitted.schema, { type: "object", additionalProperties: true })
272 const imported = assertLooseRepresentationRoundTrip(schema, inputs)
273 assert.isFalse(Schema.is(schema)({ A: "a" }))
274 assert.isTrue(Schema.is(imported)({ A: "a" }))
275 })
276
277 it("handles conjunctive key patterns permissively", () => {
278 const schema = Schema.Record(
279 Schema.String.check(Schema.isStartingWith("ab"), Schema.isEndingWith("z")),
280 Schema.Finite
281 )
282 const document = Schema.toJsonSchemaDocument(schema)
283 assert.deepStrictEqual(document.schema, {
284 type: "object",
285 additionalProperties: true
286 })
287 const validate = compile(document)
288
289 assert.strictEqual(validate({ abc: "not a number" }), true)
290 assert.deepStrictEqual(Schema.decodeUnknownSync(schema)({ abc: "not a number" }), {})
291 assert.strictEqual(validate({ abz: "not a number" }), true)
292 assert.isTrue(Exit.isFailure(Schema.decodeUnknownExit(schema)({ abz: "not a number" })))
293 assert.strictEqual(validate({ abz: 1 }), true)
294 assert.deepStrictEqual(Schema.decodeUnknownSync(schema)({ abz: 1 }), { abz: 1 })
295
296 const strictDocument = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
297 assert.deepStrictEqual(strictDocument.schema, {
298 type: "object",
299 propertyNames: {
300 type: "string",
301 pattern: "^ab",
302 allOf: [{ pattern: "z$" }]
303 },
304 additionalProperties: { type: "number" }
305 })
306 const validateStrict = compile(strictDocument)
307 assert.strictEqual(validateStrict({ abc: "not a number" }), false)
308 assert.isTrue(
309 Exit.isFailure(
310 Schema.decodeUnknownExit(schema, { onExcessProperty: "error" })({ abc: "not a number" })
311 )
312 )
313 assert.strictEqual(validateStrict({ abz: "not a number" }), false)
314 assert.strictEqual(validateStrict({ abz: 1 }), true)
315 })
316
317 it("uses permissive value alternatives with strict conjunctive indexes", () => {
318 const schema = Schema.StructWithRest(Schema.Struct({}), [
319 Schema.Record(
320 Schema.String.check(Schema.isStartingWith("a"), Schema.isEndingWith("z")),
321 Schema.Finite
322 ),
323 Schema.Record(
324 Schema.String.check(Schema.isStartingWith("b"), Schema.isEndingWith("z")),
325 Schema.Boolean
326 )
327 ])
328 const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
329 assert.deepStrictEqual(document.schema, {
330 type: "object",
331 propertyNames: {
332 anyOf: [
333 {
334 type: "string",
335 pattern: "^a",
336 allOf: [{ pattern: "z$" }]
337 },
338 {
339 type: "string",
340 pattern: "^b",
341 allOf: [{ pattern: "z$" }]
342 }
343 ]
344 },
345 additionalProperties: {
346 anyOf: [{ type: "number" }, { type: "boolean" }]
347 }
348 })
349 const validate = compile(document)
350
351 assert.strictEqual(validate({ az: 1 }), true)
352 assert.deepStrictEqual(
353 Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })({ az: 1 }) as unknown,
354 { az: 1 }
355 )
356 assert.strictEqual(validate({ bz: true }), true)
357 assert.deepStrictEqual(
358 Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })({ bz: true }) as unknown,
359 { bz: true }
360 )
361 assert.strictEqual(validate({ az: true }), true)
362 assert.isTrue(
363 Exit.isFailure(Schema.decodeUnknownExit(schema, { onExcessProperty: "error" })({ az: true }))
364 )
365 assert.strictEqual(validate({ cz: 1 }), false)
366 assert.isTrue(
367 Exit.isFailure(Schema.decodeUnknownExit(schema, { onExcessProperty: "error" })({ cz: 1 }))
368 )
369 })
370
371 it("imports the loose export of approximate pattern and string indexes", () => {
372 const schema = Schema.StructWithRest(Schema.Struct({}), [
373 Schema.Record(Schema.String.check(Schema.isUppercased()), Schema.Finite),
374 Schema.Record(Schema.String, Schema.Boolean)
375 ])
376 const inputs = [{}, { A: true }, { A: 1 }, { a: true }, { a: 1 }, []]
377 const imported = assertLooseRepresentationRoundTrip(schema, inputs, { onExcessProperty: "error" })
378 assert.isFalse(Schema.is(schema)({ A: true }))
379 assert.isTrue(Schema.is(imported)({ A: true }))
380 })
381
382 it("preserves multiple string indexes", () => {
383 assertRepresentationRoundTrip(
384 Schema.StructWithRest(Schema.Struct({}), [
385 Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.String])),
386 Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.Finite]))
387 ]),
388 [{}, { a: true }, { a: false }, { a: 1 }, { a: "a" }, []]
389 )
390 })
391
392 it("applies string indexes to explicit properties", () => {
393 assertRepresentationRoundTrip(
394 Schema.StructWithRest(Schema.Struct({ a: Schema.Union([Schema.String, Schema.Boolean]) }), [
395 Schema.Record(Schema.String, Schema.Boolean)
396 ]),
397 [{}, { a: true }, { a: false }, { a: "a" }, { a: 1 }, { a: true, b: false }, []]
398 )
399 })
400 })
401})