1import { assert, describe, it } from "@effect/vitest"
2import { Schema, SchemaAST, SchemaRepresentation } from "effect"
3import { throws } from "../../utils/assert.ts"
4
5function makeStringProperty<const Name>(name: Name) {
6 return {
7 name,
8 type: { _tag: "String", checks: [] },
9 isOptional: false,
10 isMutable: false
11 } as const
12}
13
14function assertToJson(
15 representation: SchemaRepresentation.Representation,
16 jsonRepresentation: Schema.Json
17): void {
18 assert.deepStrictEqual(
19 SchemaRepresentation.toJson({ representation, references: {} }),
20 { representation: jsonRepresentation, references: {} }
21 )
22}
23
24function assertRoundtrip(
25 representation: SchemaRepresentation.Representation,
26 jsonRepresentation: Schema.Json
27): void {
28 const document: SchemaRepresentation.Document = { representation, references: {} }
29 const json: Schema.Json = { representation: jsonRepresentation, references: {} }
30 assertToJson(representation, jsonRepresentation)
31 assert.deepStrictEqual(SchemaRepresentation.fromJson(json), document)
32}
33
34describe("SchemaRepresentation.toJson", () => {
35 it("rejects invalid documents", () => {
36 throws(
37 () =>
38 SchemaRepresentation.toJson({
39 representation: { _tag: "Reference", $ref: "" },
40 references: {}
41 }),
42 `Expected a value with a length of at least 1\n at ["representation"]["$ref"]`
43 )
44 })
45
46 it("rejects checks on Suspend representations", () => {
47 throws(
48 () =>
49 SchemaRepresentation.toJson({
50 representation: {
51 _tag: "Suspend",
52 checks: [null],
53 thunk: { _tag: "String", checks: [] }
54 } as never,
55 references: {}
56 }),
57 `Expected no excess property\n at ["representation"]["checks"][0]`
58 )
59 })
60
61 it("requires representation when persisting a Filter", () => {
62 throws(
63 () =>
64 SchemaRepresentation.toJson({
65 representation: { _tag: "String", checks: [{ _tag: "Filter", aborted: false }] },
66 references: {}
67 }),
68 `Missing key\n at ["representation"]["checks"][0]["representation"]`
69 )
70 })
71
72 it("removes live callbacks and preserves JSON annotations from a custom filter", () => {
73 const filter = Schema.makeFilter<string>(() => true, {
74 description: "custom",
75 callback: () => "live",
76 representation: {
77 id: "acme/schema/custom",
78 payload: { minimum: 1 },
79 schemas: [Schema.Number.ast]
80 },
81 toCode: () => ({ runtime: "Custom" }),
82 toJsonSchema: () => ({ minLength: 1 }),
83 arbitraryConstraint: { minLength: 1 }
84 }).abort()
85
86 assert.deepStrictEqual(
87 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.String.check(filter).ast)),
88 {
89 representation: {
90 _tag: "String",
91 checks: [{
92 _tag: "Filter",
93 representation: {
94 id: "acme/schema/custom",
95 payload: { minimum: 1 },
96 schemas: [{ _tag: "Number", checks: [] }]
97 },
98 annotations: {
99 description: "custom",
100 arbitraryConstraint: { minLength: 1 }
101 },
102 aborted: true
103 }]
104 },
105 references: {}
106 }
107 )
108 })
109
110 it("removes live callbacks from a custom declaration", () => {
111 const schema = Schema.declare<string>((input): input is string => typeof input === "string", {
112 description: "custom",
113 representation: { id: "acme/schema/custom", payload: null },
114 toCode: () => ({ runtime: "Custom", Type: "string" }),
115 toJsonSchema: () => ({ type: "string" })
116 })
117
118 assert.deepStrictEqual(
119 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)),
120 {
121 representation: {
122 _tag: "Declaration",
123 representation: { id: "acme/schema/custom", payload: null },
124 annotations: {
125 description: "custom"
126 },
127 typeParameters: [],
128 checks: []
129 },
130 references: {}
131 }
132 )
133 })
134
135 it("preserves JSON annotations", () => {
136 const document = SchemaRepresentation.toRepresentation(
137 Schema.String.annotate({ values: ["1", "Symbol(a)", "NaN"] }).ast
138 )
139
140 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
141 representation: {
142 _tag: "String",
143 annotations: { values: ["1", "Symbol(a)", "NaN"] },
144 checks: []
145 },
146 references: {}
147 })
148 })
149
150 it("omits annotations containing only non-JSON values", () => {
151 assertToJson(
152 {
153 _tag: "String",
154 annotations: { callback: () => "live" },
155 checks: []
156 },
157 { _tag: "String", checks: [] }
158 )
159 })
160
161 it("omits undefined annotations", () => {
162 assertToJson(
163 {
164 _tag: "String",
165 annotations: undefined,
166 checks: []
167 },
168 { _tag: "String", checks: [] }
169 )
170 })
171
172 it("prunes nested representation annotations", () => {
173 assertToJson(
174 {
175 _tag: "Union",
176 types: [{
177 _tag: "String",
178 annotations: {
179 title: "nested",
180 callback: () => "live"
181 },
182 checks: []
183 }],
184 options: { mode: "anyOf" },
185 checks: []
186 },
187 {
188 _tag: "Union",
189 types: [{
190 _tag: "String",
191 annotations: { title: "nested" },
192 checks: []
193 }],
194 options: { mode: "anyOf" },
195 checks: []
196 }
197 )
198 })
199
200 it("prunes annotations in check schema dependencies", () => {
201 assertToJson(
202 {
203 _tag: "String",
204 checks: [{
205 _tag: "Filter",
206 representation: {
207 id: "acme/schema/custom",
208 payload: null,
209 schemas: [{
210 _tag: "Number",
211 annotations: {
212 title: "dependency",
213 callback: () => "live"
214 },
215 checks: []
216 }]
217 },
218 aborted: false
219 }]
220 },
221 {
222 _tag: "String",
223 checks: [{
224 _tag: "Filter",
225 representation: {
226 id: "acme/schema/custom",
227 payload: null,
228 schemas: [{
229 _tag: "Number",
230 annotations: { title: "dependency" },
231 checks: []
232 }]
233 },
234 aborted: false
235 }]
236 }
237 )
238 })
239
240 it("preserves __proto__ annotations as data properties", () => {
241 const annotations: Record<string, unknown> = {}
242 Object.defineProperty(annotations, "__proto__", {
243 value: "safe",
244 enumerable: true
245 })
246
247 assertToJson(
248 {
249 _tag: "String",
250 annotations,
251 checks: []
252 },
253 {
254 _tag: "String",
255 annotations: JSON.parse(`{"__proto__":"safe"}`),
256 checks: []
257 }
258 )
259 })
260
261 it("preserves shared JSON annotation values", () => {
262 const shared = { value: "shared" }
263 const document = SchemaRepresentation.toRepresentation(
264 Schema.String.annotate({ value: { left: shared, right: shared } }).ast
265 )
266
267 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
268 representation: {
269 _tag: "String",
270 annotations: {
271 value: {
272 left: { value: "shared" },
273 right: { value: "shared" }
274 }
275 },
276 checks: []
277 },
278 references: {}
279 })
280 })
281
282 it("omits a cyclic annotation atomically", () => {
283 const cyclic: { self?: unknown } = {}
284 cyclic.self = cyclic
285 const document = SchemaRepresentation.toRepresentation(
286 Schema.String.annotate({ cyclic, title: "kept" }).ast
287 )
288
289 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
290 representation: {
291 _tag: "String",
292 annotations: { title: "kept" },
293 checks: []
294 },
295 references: {}
296 })
297 })
298
299 it("omits a sparse-array annotation atomically", () => {
300 const sparse = new Array<unknown>(1)
301 const document = SchemaRepresentation.toRepresentation(
302 Schema.String.annotate({ sparse, title: "kept" }).ast
303 )
304
305 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
306 representation: {
307 _tag: "String",
308 annotations: { title: "kept" },
309 checks: []
310 },
311 references: {}
312 })
313 })
314
315 it("omits an annotation containing bigint atomically", () => {
316 const document = SchemaRepresentation.toRepresentation(
317 Schema.String.annotate({ invalid: { value: 1n }, title: "kept" }).ast
318 )
319
320 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
321 representation: {
322 _tag: "String",
323 annotations: { title: "kept" },
324 checks: []
325 },
326 references: {}
327 })
328 })
329
330 it("omits an annotation containing undefined atomically", () => {
331 const document = SchemaRepresentation.toRepresentation(
332 Schema.String.annotate({ invalid: { value: undefined }, title: "kept" }).ast
333 )
334
335 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
336 representation: {
337 _tag: "String",
338 annotations: { title: "kept" },
339 checks: []
340 },
341 references: {}
342 })
343 })
344
345 it("encodes annotation accessors", () => {
346 const accessor = {}
347 Object.defineProperty(accessor, "value", {
348 enumerable: true,
349 get() {
350 return "value"
351 }
352 })
353 const document = SchemaRepresentation.toRepresentation(Schema.String.annotate({ accessor }).ast)
354
355 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
356 representation: {
357 _tag: "String",
358 annotations: { accessor: { value: "value" } },
359 checks: []
360 },
361 references: {}
362 })
363 })
364
365 it("preserves filter groups without an identity", () => {
366 const first = Schema.makeFilter<string>(() => true, {
367 representation: { id: "acme/schema/first", payload: null }
368 })
369 const second = Schema.makeFilter<string>(() => true, {
370 representation: { id: "acme/schema/second", payload: null }
371 }).abort()
372 const group = Schema.makeFilterGroup([first, second], { description: "both" })
373
374 assert.deepStrictEqual(
375 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.String.check(group).ast)),
376 {
377 representation: {
378 _tag: "String",
379 checks: [{
380 _tag: "FilterGroup",
381 annotations: { description: "both" },
382 checks: [
383 {
384 _tag: "Filter",
385 representation: { id: "acme/schema/first", payload: null },
386 aborted: false
387 },
388 {
389 _tag: "Filter",
390 representation: { id: "acme/schema/second", payload: null },
391 aborted: true
392 }
393 ]
394 }]
395 },
396 references: {}
397 }
398 )
399 })
400
401 it("preserves tuple element annotations independently", () => {
402 const schema = Schema.Tuple([
403 Schema.String.annotateKey({ description: "element", callback: () => "live" })
404 ])
405
406 assert.deepStrictEqual(
407 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)),
408 {
409 representation: {
410 _tag: "Arrays",
411 elements: [{
412 type: { _tag: "String", checks: [] },
413 isOptional: false,
414 annotations: { description: "element" }
415 }],
416 rest: [],
417 checks: []
418 },
419 references: {}
420 }
421 )
422 })
423
424 it("preserves property annotations independently", () => {
425 const schema = Schema.Struct({
426 value: Schema.String.annotateKey({ description: "property", callback: () => "live" })
427 })
428
429 assert.deepStrictEqual(
430 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)),
431 {
432 representation: {
433 _tag: "Objects",
434 propertySignatures: [{
435 name: { type: "string", value: "value" },
436 type: { _tag: "String", checks: [] },
437 isOptional: false,
438 isMutable: false,
439 annotations: { description: "property" }
440 }],
441 indexSignatures: [],
442 checks: []
443 },
444 references: {}
445 }
446 )
447 })
448
449 it("encodes bigint structural values", () => {
450 assert.deepStrictEqual(
451 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.Literal(1n).ast)),
452 {
453 representation: {
454 _tag: "Literal",
455 literal: { type: "bigint", value: "1" },
456 checks: []
457 },
458 references: {}
459 }
460 )
461 })
462
463 it("preserves ambiguous Enum values", () => {
464 assertRoundtrip(
465 {
466 _tag: "Enum",
467 enums: [
468 ["StringNaN", "NaN"],
469 ["NumberNaN", Number.NaN],
470 ["StringInfinity", "Infinity"],
471 ["NumberInfinity", Number.POSITIVE_INFINITY]
472 ],
473 checks: []
474 },
475 {
476 _tag: "Enum",
477 enums: [
478 ["StringNaN", { type: "string", value: "NaN" }],
479 ["NumberNaN", { type: "number", value: "NaN" }],
480 ["StringInfinity", { type: "string", value: "Infinity" }],
481 ["NumberInfinity", { type: "number", value: "Infinity" }]
482 ],
483 checks: []
484 }
485 )
486 })
487
488 it("preserves ambiguous property names", () => {
489 const globalSymbol = Symbol.for("acme/schema/key")
490 assertRoundtrip(
491 {
492 _tag: "Objects",
493 propertySignatures: [
494 makeStringProperty("1"),
495 makeStringProperty(1),
496 makeStringProperty("Symbol(acme/schema/key)"),
497 makeStringProperty(globalSymbol)
498 ],
499 indexSignatures: [],
500 checks: []
501 },
502 {
503 _tag: "Objects",
504 propertySignatures: [
505 makeStringProperty({ type: "string", value: "1" }),
506 makeStringProperty({ type: "number", value: 1 }),
507 makeStringProperty({ type: "string", value: "Symbol(acme/schema/key)" }),
508 makeStringProperty({ type: "symbol", value: "Symbol(acme/schema/key)" })
509 ],
510 indexSignatures: [],
511 checks: []
512 }
513 )
514 })
515
516 it("preserves string literals resembling non-finite numbers", () => {
517 for (const literal of ["NaN", "Infinity", "-Infinity"]) {
518 assert.deepStrictEqual(
519 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(Schema.Literal(literal).ast)),
520 {
521 representation: { _tag: "Literal", literal: { type: "string", value: literal }, checks: [] },
522 references: {}
523 }
524 )
525 }
526 })
527
528 it("encodes global symbols", () => {
529 assert.deepStrictEqual(
530 SchemaRepresentation.toJson(
531 SchemaRepresentation.toRepresentation(Schema.UniqueSymbol(Symbol.for("acme/schema/key")).ast)
532 ),
533 {
534 representation: {
535 _tag: "UniqueSymbol",
536 symbol: "Symbol(acme/schema/key)",
537 checks: []
538 },
539 references: {}
540 }
541 )
542 })
543
544 it("rejects local symbols", () => {
545 throws(
546 () =>
547 SchemaRepresentation.toJson(
548 SchemaRepresentation.toRepresentation(Schema.UniqueSymbol(Symbol("local")).ast)
549 ),
550 `cannot serialize to string, Symbol is not registered\n at ["representation"]["symbol"]`
551 )
552 })
553
554 it("rejects local symbols used as property names", () => {
555 throws(
556 () =>
557 SchemaRepresentation.toJson({
558 representation: {
559 _tag: "Objects",
560 propertySignatures: [
561 makeStringProperty(Symbol("local"))
562 ],
563 indexSignatures: [],
564 checks: []
565 },
566 references: {}
567 }),
568 `cannot serialize to string, Symbol is not registered\n at ["representation"]["propertySignatures"][0]["name"]["value"]`
569 )
570 })
571
572 it("encodes recursive references", () => {
573 let schema: Schema.Codec<unknown>
574 schema = Schema.suspend((): Schema.Codec<unknown> => schema)
575
576 assert.deepStrictEqual(
577 SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast)),
578 {
579 representation: { _tag: "Reference", $ref: "Suspend_" },
580 references: {
581 Suspend_: {
582 _tag: "Suspend",
583 thunk: { _tag: "Reference", $ref: "Suspend_" },
584 checks: []
585 }
586 }
587 }
588 )
589 })
590
591 it("encodes fromJsonString annotations", () => {
592 const schema = SchemaAST.toEncoded(Schema.fromJsonString(Schema.Struct({ value: Schema.Number })).ast)
593 const document = SchemaRepresentation.toRepresentation(schema)
594
595 assert.deepStrictEqual(SchemaRepresentation.toJson(document), {
596 representation: {
597 _tag: "String",
598 annotations: {
599 contentMediaType: "application/json",
600 expected: "a string that will be decoded as JSON"
601 },
602 checks: []
603 },
604 references: {}
605 })
606 })
607})