1import { assert } from "@effect/vitest"
2import { type Schema, type SchemaAST, SchemaRepresentation } from "effect"
3import { TestSchema } from "effect/testing"
4import { describe, it } from "vitest"
5import { deepStrictEqual, throws } from "../../utils/assert.ts"
6
7const makeCode = SchemaRepresentation.makeCode
8
9type Expected = {
10 readonly codes: ReadonlyArray<SchemaRepresentation.Code>
11 readonly references?: Partial<SchemaRepresentation.CodeDocument["references"]>
12}
13
14function assertCode(schemas: readonly [Schema.Top, ...Array<Schema.Top>], expected: Expected) {
15 const document = SchemaRepresentation.toRepresentations(
16 schemas.map((schema) => schema.ast) as [SchemaAST.AST, ...Array<SchemaAST.AST>]
17 )
18 deepStrictEqual(SchemaRepresentation.toCodeDocument(document), {
19 codes: expected.codes,
20 references: {
21 nonRecursives: expected.references?.nonRecursives ?? [],
22 recursives: expected.references?.recursives ?? {}
23 },
24 artifacts: []
25 })
26}
27
28describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => {
29 it("rejects open pattern scopes in roots and shared definitions", () => {
30 const open = { type: "object", patternProperties: { "^a": { type: "number" } } } as const
31 throws(
32 () =>
33 SchemaRepresentation.fromJsonSchemaMultiDocument({
34 dialect: "draft-2020-12",
35 schemas: [{ type: "string" }, { type: "array", items: open }],
36 definitions: {}
37 }, { patterns: "apply" }),
38 `Cannot import open "patternProperties": unmatched keys cannot be typed correctly.\n at ["schemas"][1]["items"]`
39 )
40 throws(
41 () =>
42 SchemaRepresentation.fromJsonSchemaMultiDocument({
43 dialect: "draft-2020-12",
44 schemas: [{ $ref: "#/$defs/Values" }, { type: "array", items: { $ref: "#/$defs/Values" } }],
45 definitions: { Values: open }
46 }, { patterns: "apply" }),
47 `Cannot import open "patternProperties": unmatched keys cannot be typed correctly.\n at ["definitions"]["Values"]`
48 )
49 })
50
51 it("imports closed patterned Records through shared definitions", async () => {
52 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
53 dialect: "draft-2020-12",
54 schemas: [{ $ref: "#/$defs/Values" }, { type: "array", items: { $ref: "#/$defs/Values" } }],
55 definitions: {
56 Values: {
57 type: "object",
58 patternProperties: { "^a": { type: "number" } },
59 additionalProperties: false
60 }
61 }
62 }, { patterns: "apply" })
63 const asserts = new TestSchema.Asserts(schemas[0] as unknown as Schema.ConstraintDecoder<unknown>)
64 const decoding = asserts.decoding()
65 await decoding.succeed({ a: 1, z: true }, { a: 1 })
66 await decoding.fail({ a: "x" }, `Expected number\n at ["a"]`)
67 await asserts.decoding({ parseOptions: { onExcessProperty: "error" } }).fail(
68 { z: true },
69 `Expected no excess property\n at ["z"]`
70 )
71 const arrayDecoding = new TestSchema.Asserts(schemas[1] as unknown as Schema.ConstraintDecoder<unknown>).decoding()
72 await arrayDecoding.succeed([{ a: 1 }])
73 await arrayDecoding.fail([{ a: "x" }], `Expected number\n at [0]["a"]`)
74 })
75
76 it("imports Never through shared definitions", () => {
77 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
78 dialect: "draft-2020-12",
79 schemas: [
80 { type: "object", properties: { value: { $ref: "#/$defs/Impossible" } } },
81 { $ref: "#/$defs/Impossible" }
82 ],
83 definitions: { Impossible: { not: {} } }
84 })
85 assertCode(schemas, {
86 codes: [
87 makeCode(
88 `Schema.StructWithRest(Schema.Struct({ "value": Schema.optionalKey(Impossible) }), [Schema.Record(Schema.String, Schema.Json.annotate({ "expected": "JSON value" }))])`,
89 `{ readonly "value"?: Impossible } & { readonly [x: string]: Schema.Json }`
90 ),
91 makeCode(`Impossible`, `Impossible`)
92 ],
93 references: {
94 nonRecursives: [{
95 $ref: "Impossible",
96 code: makeCode(`Schema.Never.annotate({ "identifier": "Impossible" })`, `never`)
97 }]
98 }
99 })
100 })
101
102 it("keeps resource scope separate for each root", () => {
103 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
104 dialect: "draft-2020-12",
105 schemas: [
106 { type: "object", properties: { child: { $id: "child", type: "number" } } },
107 { $id: "https://example.com/root", $ref: "#/$defs/X" }
108 ],
109 definitions: { X: { type: "string" } }
110 })
111 assertCode(schemas, {
112 codes: [
113 makeCode(
114 `Schema.StructWithRest(Schema.Struct({ "child": Schema.optionalKey(Schema.Number.check(Schema.isFinite().annotate({ "expected": "a finite number" }))) }), [Schema.Record(Schema.String, Schema.Json.annotate({ "expected": "JSON value" }))])`,
115 `{ readonly "child"?: number } & { readonly [x: string]: Schema.Json }`
116 ),
117 makeCode(`X`, `X`)
118 ],
119 references: {
120 nonRecursives: [{ $ref: "X", code: makeCode(`Schema.String.annotate({ "identifier": "X" })`, `string`) }]
121 }
122 })
123 })
124
125 it("rejects a reference in a nested resource with its multi-root path", () => {
126 throws(
127 () =>
128 SchemaRepresentation.fromJsonSchemaMultiDocument({
129 dialect: "draft-2020-12",
130 schemas: [
131 { type: "string" },
132 { type: "array", items: { $id: "child", $ref: "#/$defs/X" } }
133 ],
134 definitions: { X: { type: "string" } }
135 }),
136 `Cannot resolve $ref under a nested "$id". Resolve or flatten it first.\n at ["schemas"][1]["items"]["$ref"]`
137 )
138 })
139
140 it("propagates the pattern policy through reachable definitions", () => {
141 const document: Parameters<typeof SchemaRepresentation.fromJsonSchemaMultiDocument>[0] = {
142 dialect: "draft-2020-12" as const,
143 schemas: [{ $ref: "#/$defs/A" }],
144 definitions: {
145 A: { type: "string", pattern: "^a+$" }
146 }
147 }
148
149 throws(
150 () => SchemaRepresentation.fromJsonSchemaMultiDocument(document),
151 `Patterns may block validation and are disabled. Use patterns: "apply" for trusted schemas or "ignore" to discard them.\n at ["definitions"]["A"]["pattern"]`
152 )
153
154 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument(document, { patterns: "apply" })
155 assertCode(schemas, {
156 codes: [makeCode(`A`, `A`)],
157 references: {
158 nonRecursives: [{
159 $ref: "A",
160 code: makeCode(
161 `Schema.String.check(Schema.isPattern(new RegExp("^a+$", "u")).annotate({ "expected": "a string matching the RegExp ^a+$", "identifier": "A" }))`,
162 `string`
163 )
164 }]
165 }
166 })
167 })
168
169 it("preserves an onEnter exception by identity", () => {
170 const cause = new Error("boom")
171
172 throws(
173 () =>
174 SchemaRepresentation.fromJsonSchemaMultiDocument({
175 dialect: "draft-2020-12",
176 schemas: [{ type: "string" }],
177 definitions: {}
178 }, {
179 onEnter: () => {
180 throw cause
181 }
182 }),
183 (error: unknown) => {
184 assert.strictEqual(error, cause)
185 return undefined
186 }
187 )
188 })
189
190 it("preserves contentSchema as an annotation without traversing it", () => {
191 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
192 dialect: "draft-2020-12",
193 schemas: [{
194 type: "string",
195 contentMediaType: "application/json",
196 contentSchema: { $ref: "#/$defs/Payload" }
197 }],
198 definitions: {
199 Payload: {
200 type: "object",
201 properties: { value: { type: "number" } },
202 required: ["value"],
203 additionalProperties: false
204 }
205 }
206 })
207
208 assertCode(schemas, {
209 codes: [makeCode(
210 `Schema.String.annotate({ "contentMediaType": "application/json", "contentSchema": { "$ref": "#/$defs/Payload" } })`,
211 `string`
212 )]
213 })
214 })
215
216 it("does not import unreachable definitions", () => {
217 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
218 dialect: "draft-2020-12",
219 schemas: [{ type: "string" }],
220 definitions: {
221 Unused: { type: "number", description: "unused" }
222 }
223 }, {
224 onEnter: (schema) => {
225 if (schema.description === "unused") throw new Error("unreachable")
226 return schema
227 }
228 })
229
230 assertCode(schemas, { codes: [makeCode(`Schema.String`, `string`)] })
231 })
232
233 it("preserves root order and shares definitions", () => {
234 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
235 dialect: "draft-2020-12",
236 schemas: [
237 { $ref: "#/$defs/A" },
238 { $ref: "#/$defs/A", description: "second" },
239 { type: "array", items: { $ref: "#/$defs/A" } },
240 { $ref: "#/$defs/A", description: "fourth" }
241 ],
242 definitions: {
243 A: { type: "string", minLength: 1 }
244 }
245 })
246
247 assertCode(schemas, {
248 codes: [
249 makeCode(`A`, `A`),
250 makeCode(`Schema.suspend((): Schema.Codec<A> => A).annotate({ "description": "second" })`, `A`),
251 makeCode(`Schema.Array(A)`, `ReadonlyArray<A>`),
252 makeCode(`Schema.suspend((): Schema.Codec<A> => A).annotate({ "description": "fourth" })`, `A`)
253 ],
254 references: {
255 nonRecursives: [{
256 $ref: "A",
257 code: makeCode(
258 `Schema.String.check(Schema.isMinLength(1).annotate({ "expected": "a value with a length of at least 1", "identifier": "A" }))`,
259 `string`
260 )
261 }]
262 }
263 })
264 })
265
266 it("resolves alias chains when combining a reference", () => {
267 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
268 dialect: "draft-2020-12",
269 schemas: [{ $ref: "#/$defs/A", description: "root" }],
270 definitions: {
271 A: { $ref: "#/$defs/B" },
272 B: { $ref: "#/$defs/C" },
273 C: { type: "number" }
274 }
275 })
276
277 assertCode(schemas, {
278 codes: [makeCode(`Schema.suspend((): Schema.Codec<A> => A).annotate({ "description": "root" })`, `A`)],
279 references: {
280 nonRecursives: [{
281 $ref: "A",
282 code: makeCode(
283 `Schema.Number.check(Schema.isFinite().annotate({ "expected": "a finite number", "identifier": "A" }))`,
284 `number`
285 )
286 }]
287 }
288 })
289 })
290
291 it("tracks recursive definitions independently", () => {
292 const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({
293 dialect: "draft-2020-12",
294 schemas: [{ $ref: "#/$defs/A" }, { $ref: "#/$defs/B" }],
295 definitions: {
296 A: { $ref: "#/$defs/A" },
297 B: { $ref: "#/$defs/B" }
298 }
299 })
300
301 assertCode(schemas, {
302 codes: [makeCode(`A`, `A`), makeCode(`B`, `B`)],
303 references: {
304 recursives: {
305 A: makeCode(`Schema.suspend((): Schema.Codec<A> => A).annotate({ "identifier": "A" })`, `A`),
306 B: makeCode(`Schema.suspend((): Schema.Codec<B> => B).annotate({ "identifier": "B" })`, `B`)
307 }
308 }
309 })
310 })
311
312 it("throws when a reference that must be resolved is missing", () => {
313 throws(
314 () =>
315 SchemaRepresentation.fromJsonSchemaMultiDocument({
316 dialect: "draft-2020-12",
317 schemas: [{ $ref: "#/$defs/Missing", description: "resolve" }],
318 definitions: {}
319 }),
320 `Missing definition "Missing" for $ref "#/$defs/Missing".\n at ["schemas"][0]["$ref"]`
321 )
322 })
323
324 it("throws when resolving a circular alias chain", () => {
325 throws(
326 () =>
327 SchemaRepresentation.fromJsonSchemaMultiDocument({
328 dialect: "draft-2020-12",
329 schemas: [{ $ref: "#/$defs/A", description: "resolve" }],
330 definitions: {
331 A: { $ref: "#/$defs/B" },
332 B: { $ref: "#/$defs/A" }
333 }
334 }),
335 `Definition "A" is a circular alias.\n at ["schemas"][0]["$ref"]`
336 )
337 })
338})