| 1 | import { assert } from "@effect/vitest" |
| 2 | import { type Schema, type SchemaAST, SchemaRepresentation } from "effect" |
| 3 | import { TestSchema } from "effect/testing" |
| 4 | import { describe, it } from "vitest" |
| 5 | import { deepStrictEqual, throws } from "../../utils/assert.ts" |
| 6 | |
| 7 | const makeCode = SchemaRepresentation.makeCode |
| 8 | |
| 9 | type Expected = { |
| 10 | readonly codes: ReadonlyArray<SchemaRepresentation.Code> |
| 11 | readonly references?: Partial<SchemaRepresentation.CodeDocument["references"]> |
| 12 | } |
| 13 | |
| 14 | function 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 | |
| 28 | describe("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 | }) |