| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { JsonSchema, Schema, SchemaRepresentation, SchemaTransformation } from "effect" |
| 3 | import { throws } from "../../utils/assert.ts" |
| 4 | |
| 5 | // oxlint-disable-next-line @typescript-eslint/no-require-imports |
| 6 | const AjvDraft07 = require("ajv") |
| 7 | |
| 8 | function expectError(thunk: () => void, expected: string | Error): void { |
| 9 | if (typeof expected === "string") { |
| 10 | throws(thunk, expected) |
| 11 | } else { |
| 12 | throws(thunk, (error: unknown) => { |
| 13 | assert.strictEqual(error, expected) |
| 14 | return undefined |
| 15 | }) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | const StringRepresentation: SchemaRepresentation.Representation = { |
| 20 | _tag: "String", |
| 21 | checks: [] |
| 22 | } |
| 23 | |
| 24 | const stringIdentityTransformation = SchemaTransformation.transform({ |
| 25 | decode: (value: string) => value, |
| 26 | encode: (value: string) => value |
| 27 | }) |
| 28 | |
| 29 | describe("SchemaRepresentation.toJsonSchemaMultiDocument", () => { |
| 30 | describe("definition canonicalization", () => { |
| 31 | it("deduplicates equivalent fallback definitions", () => { |
| 32 | const Content = Schema.Struct({ text: Schema.String }).annotate({ identifier: "Tool.Content" }) |
| 33 | const first = Schema.toCodecJson(Schema.fromJsonString(Content)) |
| 34 | const second = Schema.toCodecJson(Schema.fromJsonString(Content)) |
| 35 | const document = SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 36 | |
| 37 | assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaMultiDocument(document), { |
| 38 | dialect: "draft-2020-12", |
| 39 | schemas: [ |
| 40 | { $ref: "#/$defs/Tool.ContentEncoded" }, |
| 41 | { $ref: "#/$defs/Tool.ContentEncoded" } |
| 42 | ], |
| 43 | definitions: { |
| 44 | "Tool.ContentEncoded": { |
| 45 | type: "string", |
| 46 | contentMediaType: "application/json" |
| 47 | } |
| 48 | } |
| 49 | }) |
| 50 | }) |
| 51 | |
| 52 | it("does not deduplicate different fallback definitions", () => { |
| 53 | const Content = Schema.String.annotate({ identifier: "Fallback" }) |
| 54 | const string = Schema.toCodecJson( |
| 55 | Schema.String.pipe(Schema.decodeTo(Content, stringIdentityTransformation)) |
| 56 | ) |
| 57 | const boolean = Schema.toCodecJson( |
| 58 | Schema.Boolean.pipe( |
| 59 | Schema.decodeTo( |
| 60 | Content, |
| 61 | SchemaTransformation.transform({ |
| 62 | decode: (value) => String(value), |
| 63 | encode: (value) => value === "true" |
| 64 | }) |
| 65 | ) |
| 66 | ) |
| 67 | ) |
| 68 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 69 | SchemaRepresentation.toRepresentations([string.ast, boolean.ast]) |
| 70 | ) |
| 71 | |
| 72 | assert.deepStrictEqual(output.schemas, [ |
| 73 | { $ref: "#/$defs/FallbackEncoded" }, |
| 74 | { $ref: "#/$defs/FallbackEncoded_1" } |
| 75 | ]) |
| 76 | assert.deepStrictEqual(output.definitions, { |
| 77 | FallbackEncoded: { type: "string" }, |
| 78 | FallbackEncoded_1: { type: "boolean" } |
| 79 | }) |
| 80 | }) |
| 81 | |
| 82 | it("does not deduplicate explicit identifiers", () => { |
| 83 | const ExplicitA = Schema.String.annotate({ identifier: "ExplicitA" }) |
| 84 | const ExplicitB = Schema.String.annotate({ identifier: "ExplicitB" }) |
| 85 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 86 | SchemaRepresentation.toRepresentations([ExplicitA.ast, ExplicitB.ast]) |
| 87 | ) |
| 88 | |
| 89 | assert.deepStrictEqual(output.schemas, [ |
| 90 | { $ref: "#/$defs/ExplicitA" }, |
| 91 | { $ref: "#/$defs/ExplicitB" } |
| 92 | ]) |
| 93 | assert.deepStrictEqual(output.definitions, { |
| 94 | ExplicitA: { type: "string" }, |
| 95 | ExplicitB: { type: "string" } |
| 96 | }) |
| 97 | }) |
| 98 | |
| 99 | it("deduplicates fallback definitions after linking their dependencies", () => { |
| 100 | const Child = Schema.String.annotate({ identifier: "Child" }) |
| 101 | const Parent = Schema.Struct({ child: Child }).annotate({ identifier: "Parent" }) |
| 102 | const make = () => { |
| 103 | const encoded = Schema.Struct({ |
| 104 | child: Schema.fromJsonString(Child) |
| 105 | }) |
| 106 | return Schema.toCodecJson( |
| 107 | encoded.pipe( |
| 108 | Schema.decodeTo( |
| 109 | Parent, |
| 110 | SchemaTransformation.transform({ |
| 111 | decode: ({ child }) => ({ child }), |
| 112 | encode: ({ child }) => ({ child }) |
| 113 | }) |
| 114 | ) |
| 115 | ) |
| 116 | ) |
| 117 | } |
| 118 | const first = make() |
| 119 | const second = make() |
| 120 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 121 | SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 122 | ) |
| 123 | |
| 124 | assert.deepStrictEqual(output.schemas, [ |
| 125 | { $ref: "#/$defs/ParentEncoded" }, |
| 126 | { $ref: "#/$defs/ParentEncoded" } |
| 127 | ]) |
| 128 | assert.deepStrictEqual(output.definitions, { |
| 129 | ChildEncoded: { |
| 130 | type: "string", |
| 131 | contentMediaType: "application/json" |
| 132 | }, |
| 133 | ParentEncoded: { |
| 134 | type: "object", |
| 135 | properties: { |
| 136 | child: { $ref: "#/$defs/ChildEncoded" } |
| 137 | }, |
| 138 | required: ["child"], |
| 139 | additionalProperties: true |
| 140 | } |
| 141 | }) |
| 142 | }) |
| 143 | |
| 144 | it("escapes canonical fallback references", () => { |
| 145 | const Content = Schema.String.annotate({ identifier: "Child/~" }) |
| 146 | const first = Schema.toCodecJson(Schema.fromJsonString(Content)) |
| 147 | const second = Schema.toCodecJson(Schema.fromJsonString(Content)) |
| 148 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 149 | SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 150 | ) |
| 151 | |
| 152 | assert.deepStrictEqual(output.schemas, [ |
| 153 | { $ref: "#/$defs/Child~1~0Encoded" }, |
| 154 | { $ref: "#/$defs/Child~1~0Encoded" } |
| 155 | ]) |
| 156 | assert.deepStrictEqual(output.definitions, { |
| 157 | "Child/~Encoded": { |
| 158 | type: "string", |
| 159 | contentMediaType: "application/json" |
| 160 | } |
| 161 | }) |
| 162 | }) |
| 163 | |
| 164 | for ( |
| 165 | const [identifier, token] of [ |
| 166 | ["Rate", "Rate"], |
| 167 | ["id~a/b", "id~0a~1b"], |
| 168 | ["Rate%", "Rate%25"], |
| 169 | ["Rate%2F", "Rate%252F"], |
| 170 | ["Rate%25", "Rate%2525"] |
| 171 | ] |
| 172 | ) { |
| 173 | it(`rewrites callback references to the canonical ${identifier} definition`, () => { |
| 174 | const Content = Schema.String.annotate({ identifier }) |
| 175 | const make = () => |
| 176 | Schema.toCodecJson( |
| 177 | Schema.String.check(Schema.isMinLength(1)).pipe( |
| 178 | Schema.decodeTo(Content, stringIdentityTransformation) |
| 179 | ) |
| 180 | ) |
| 181 | const first = make() |
| 182 | const second = make() |
| 183 | const callback = Schema.Unknown.check(Schema.makeFilter(() => true, { |
| 184 | toJsonSchema: () => ({ $ref: `#/$defs/${token}Encoded_1` }) |
| 185 | })) |
| 186 | const document = SchemaRepresentation.toRepresentations([first.ast, second.ast, callback.ast]) |
| 187 | assert.deepStrictEqual(Object.keys(document.references), [`${identifier}Encoded`, `${identifier}Encoded_1`]) |
| 188 | const output = SchemaRepresentation.toJsonSchemaMultiDocument(document) |
| 189 | |
| 190 | assert.deepStrictEqual(Object.keys(output.definitions), [`${identifier}Encoded`]) |
| 191 | assert.deepStrictEqual(output.schemas[2], { $ref: `#/$defs/${token}Encoded` }) |
| 192 | for (const restored of SchemaRepresentation.fromJsonSchemaMultiDocument(output)) { |
| 193 | assert.strictEqual(Schema.is(restored)("value"), true) |
| 194 | assert.strictEqual(Schema.is(restored)(1), false) |
| 195 | } |
| 196 | }) |
| 197 | } |
| 198 | |
| 199 | it("rewrites callback references with encoded leading separators", () => { |
| 200 | const Content = Schema.String.annotate({ identifier: "Rate%" }) |
| 201 | const make = () => |
| 202 | Schema.toCodecJson( |
| 203 | Schema.String.check(Schema.isMinLength(1)).pipe( |
| 204 | Schema.decodeTo(Content, stringIdentityTransformation) |
| 205 | ) |
| 206 | ) |
| 207 | const callback = Schema.Unknown.check(Schema.makeFilter(() => true, { |
| 208 | toJsonSchema: () => ({ $ref: "#%2F$defs%2FRate%25Encoded_1" }) |
| 209 | })) |
| 210 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 211 | SchemaRepresentation.toRepresentations([make().ast, make().ast, callback.ast]) |
| 212 | ) |
| 213 | |
| 214 | assert.deepStrictEqual(Object.keys(output.definitions), ["Rate%Encoded"]) |
| 215 | assert.deepStrictEqual(output.schemas[2], { $ref: "#/$defs/Rate%25Encoded" }) |
| 216 | assert.doesNotThrow(() => SchemaRepresentation.fromJsonSchemaMultiDocument(output)) |
| 217 | }) |
| 218 | |
| 219 | for ( |
| 220 | const $ref of [ |
| 221 | "#/$defs/Rate%Encoded_1", |
| 222 | "#%2F$defs%2FRate%Encoded_1", |
| 223 | "#/$defs/Rate#Encoded_1" |
| 224 | ] |
| 225 | ) { |
| 226 | it(`rejects malformed callback reference ${JSON.stringify($ref)}`, () => { |
| 227 | const callback = Schema.Unknown.check(Schema.makeFilter(() => true, { |
| 228 | toJsonSchema: () => ({ $ref }) |
| 229 | })) |
| 230 | expectError( |
| 231 | () => |
| 232 | SchemaRepresentation.toJsonSchemaMultiDocument( |
| 233 | SchemaRepresentation.toRepresentations([callback.ast]) |
| 234 | ), |
| 235 | `Invalid JSON Pointer URI fragment ${JSON.stringify($ref)}` |
| 236 | ) |
| 237 | }) |
| 238 | } |
| 239 | |
| 240 | for (const separator of ["/", "%2F", "%2f", "~1", "%7E1"]) { |
| 241 | it(`distinguishes pointer separators from escaped slashes in ${separator} callback references`, () => { |
| 242 | const Content = Schema.String.annotate({ identifier: "Box/properties/value" }) |
| 243 | const make = () => |
| 244 | Schema.toCodecJson( |
| 245 | Schema.String.check(Schema.isMinLength(1)).pipe( |
| 246 | Schema.decodeTo(Content, stringIdentityTransformation) |
| 247 | ) |
| 248 | ) |
| 249 | const Box = Schema.Struct({ valueEncoded_1: Schema.Boolean }).annotate({ identifier: "Box" }) |
| 250 | const $ref = `#/$defs/Box${separator}properties${separator}valueEncoded_1` |
| 251 | const callback = Schema.Unknown.check(Schema.makeFilter(() => true, { |
| 252 | toJsonSchema: () => ({ $ref }) |
| 253 | })) |
| 254 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 255 | SchemaRepresentation.toRepresentations([make().ast, make().ast, Box.ast, callback.ast]) |
| 256 | ) |
| 257 | // Normalize URI fragments before validation: Ajv alone treats %2F as token contents. |
| 258 | const document = JsonSchema.toDocumentDraft07({ |
| 259 | dialect: output.dialect, |
| 260 | schema: output.schemas[3], |
| 261 | definitions: output.definitions |
| 262 | }) |
| 263 | const validate = new AjvDraft07.default({ strict: false }).compile({ |
| 264 | ...document.schema, |
| 265 | definitions: document.definitions |
| 266 | }) |
| 267 | const isName = separator === "~1" || separator === "%7E1" |
| 268 | assert.strictEqual(validate(true), !isName) |
| 269 | assert.strictEqual(validate("value"), isName) |
| 270 | assert.strictEqual(output.schemas[3].$ref, isName ? "#/$defs/Box~1properties~1valueEncoded" : $ref) |
| 271 | }) |
| 272 | } |
| 273 | |
| 274 | it("preserves an encoded pointer suffix when rewriting a percent-named alias", () => { |
| 275 | const representation = SchemaRepresentation.toRepresentation( |
| 276 | Schema.Struct({ "value%2F": Schema.Boolean }).ast |
| 277 | ).representation |
| 278 | if (representation._tag !== "Objects") throw new Error("Expected an object representation") |
| 279 | const definition: SchemaRepresentation.Representation = { |
| 280 | ...representation, |
| 281 | annotations: { "~identifier": "Box%" } |
| 282 | } |
| 283 | const $ref = "#/$defs/Box%25_1%2Fproperties%2Fvalue%252F" |
| 284 | const reference = Object.freeze({ $ref, default: Object.freeze({ $ref }) }) |
| 285 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 286 | representations: [{ |
| 287 | _tag: "Unknown", |
| 288 | checks: [{ |
| 289 | _tag: "Filter", |
| 290 | aborted: false, |
| 291 | annotations: { toJsonSchema: () => reference } |
| 292 | }] |
| 293 | }], |
| 294 | references: { "Box%": definition, "Box%_1": definition } |
| 295 | }) |
| 296 | |
| 297 | assert.strictEqual(output.schemas[0].$ref, "#/$defs/Box%25%2Fproperties%2Fvalue%252F") |
| 298 | assert.deepStrictEqual(output.schemas[0].default, { $ref }) |
| 299 | assert.strictEqual(reference.$ref, $ref) |
| 300 | const document = JsonSchema.toDocumentDraft07({ |
| 301 | dialect: output.dialect, |
| 302 | schema: output.schemas[0], |
| 303 | definitions: output.definitions |
| 304 | }) |
| 305 | const validate = new AjvDraft07.default({ strict: false }).compile({ |
| 306 | ...document.schema, |
| 307 | definitions: document.definitions |
| 308 | }) |
| 309 | assert.strictEqual(validate(true), true) |
| 310 | assert.strictEqual(validate("value"), false) |
| 311 | }) |
| 312 | |
| 313 | // JSON Schema callbacks are user-provided and may have effects, so invocation count is observable. |
| 314 | it("invokes each fallback callback once before deduplicating equivalent output", () => { |
| 315 | let visits = 0 |
| 316 | const Content = Schema.String.annotate({ identifier: "Callback" }) |
| 317 | const make = (reverse: boolean) => { |
| 318 | const encoded = Schema.String.check( |
| 319 | Schema.makeFilter<string>(() => true, { |
| 320 | toJsonSchema: () => { |
| 321 | visits++ |
| 322 | return reverse ? { maxLength: 10, minLength: 1 } : { minLength: 1, maxLength: 10 } |
| 323 | } |
| 324 | }) |
| 325 | ) |
| 326 | return Schema.toCodecJson( |
| 327 | encoded.pipe(Schema.decodeTo(Content, stringIdentityTransformation)) |
| 328 | ) |
| 329 | } |
| 330 | const first = make(false) |
| 331 | const second = make(true) |
| 332 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 333 | SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 334 | ) |
| 335 | |
| 336 | assert.strictEqual(visits, 2) |
| 337 | assert.deepStrictEqual(output.schemas, [ |
| 338 | { $ref: "#/$defs/CallbackEncoded" }, |
| 339 | { $ref: "#/$defs/CallbackEncoded" } |
| 340 | ]) |
| 341 | assert.deepStrictEqual(output.definitions, { |
| 342 | CallbackEncoded: { |
| 343 | type: "string", |
| 344 | minLength: 1, |
| 345 | maxLength: 10 |
| 346 | } |
| 347 | }) |
| 348 | }) |
| 349 | |
| 350 | it("reuses a compiled definition when extracting an index signature pattern", () => { |
| 351 | let visits = 0 |
| 352 | const Key = Schema.String.check( |
| 353 | Schema.makeFilter<string>(() => true, { |
| 354 | identifier: "Key", |
| 355 | toJsonSchema: () => { |
| 356 | visits++ |
| 357 | return { pattern: "^key$" } |
| 358 | } |
| 359 | }) |
| 360 | ) |
| 361 | const Root = Schema.Record(Key, Schema.String) |
| 362 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 363 | SchemaRepresentation.toRepresentations([Root.ast]) |
| 364 | ) |
| 365 | |
| 366 | assert.strictEqual(visits, 1) |
| 367 | assert.deepStrictEqual(output.schemas, [{ |
| 368 | type: "object", |
| 369 | patternProperties: { |
| 370 | "^key$": { type: "string" } |
| 371 | }, |
| 372 | additionalProperties: true |
| 373 | }]) |
| 374 | }) |
| 375 | |
| 376 | it("deduplicates fallback definitions that share a recursive dependency", () => { |
| 377 | interface OptionalNode { |
| 378 | readonly next?: OptionalNode | undefined |
| 379 | } |
| 380 | const Encoded: Schema.Codec<OptionalNode> = Schema.suspend(() => |
| 381 | Schema.Struct({ |
| 382 | next: Schema.optional(Encoded) |
| 383 | }) |
| 384 | ) |
| 385 | const Content = Encoded.annotate({ identifier: "Node" }) |
| 386 | const make = () => |
| 387 | Schema.toCodecJson( |
| 388 | Encoded.pipe( |
| 389 | Schema.decodeTo( |
| 390 | Content, |
| 391 | SchemaTransformation.transform({ |
| 392 | decode: (node) => node, |
| 393 | encode: (node) => node |
| 394 | }) |
| 395 | ) |
| 396 | ) |
| 397 | ) |
| 398 | const first = make() |
| 399 | const second = make() |
| 400 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 401 | SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 402 | ) |
| 403 | |
| 404 | assert.deepStrictEqual(output.schemas, [ |
| 405 | { $ref: "#/$defs/NodeEncoded" }, |
| 406 | { $ref: "#/$defs/NodeEncoded" } |
| 407 | ]) |
| 408 | assert.deepStrictEqual(output.definitions, { |
| 409 | Objects_: { |
| 410 | type: "object", |
| 411 | properties: { |
| 412 | next: { |
| 413 | anyOf: [ |
| 414 | { $ref: "#/$defs/Objects_" }, |
| 415 | { type: "null" } |
| 416 | ] |
| 417 | } |
| 418 | }, |
| 419 | additionalProperties: true |
| 420 | }, |
| 421 | NodeEncoded: { $ref: "#/$defs/Objects_" } |
| 422 | }) |
| 423 | }) |
| 424 | |
| 425 | it("does not deduplicate fallback definitions with distinct recursive dependencies", () => { |
| 426 | interface RequiredNode { |
| 427 | readonly next: RequiredNode |
| 428 | } |
| 429 | const Content: Schema.Codec<RequiredNode> = Schema.Struct({ |
| 430 | next: Schema.suspend(() => Content) |
| 431 | }).annotate({ identifier: "Node" }) |
| 432 | const make = () => { |
| 433 | const Encoded: Schema.Codec<RequiredNode> = Schema.Struct({ |
| 434 | next: Schema.suspend(() => Encoded) |
| 435 | }) |
| 436 | return Schema.toCodecJson( |
| 437 | Encoded.pipe( |
| 438 | Schema.decodeTo( |
| 439 | Content, |
| 440 | SchemaTransformation.transform({ |
| 441 | decode: (node) => node, |
| 442 | encode: (node) => node |
| 443 | }) |
| 444 | ) |
| 445 | ) |
| 446 | ) |
| 447 | } |
| 448 | const first = make() |
| 449 | const second = make() |
| 450 | const output = SchemaRepresentation.toJsonSchemaMultiDocument( |
| 451 | SchemaRepresentation.toRepresentations([first.ast, second.ast]) |
| 452 | ) |
| 453 | |
| 454 | assert.deepStrictEqual(output.schemas, [ |
| 455 | { $ref: "#/$defs/NodeEncoded" }, |
| 456 | { $ref: "#/$defs/NodeEncoded_1" } |
| 457 | ]) |
| 458 | assert.deepStrictEqual(output.definitions, { |
| 459 | Suspend_: { |
| 460 | type: "object", |
| 461 | properties: { |
| 462 | next: { $ref: "#/$defs/Suspend_" } |
| 463 | }, |
| 464 | required: ["next"], |
| 465 | additionalProperties: true |
| 466 | }, |
| 467 | NodeEncoded: { |
| 468 | type: "object", |
| 469 | properties: { |
| 470 | next: { $ref: "#/$defs/Suspend_" } |
| 471 | }, |
| 472 | required: ["next"], |
| 473 | additionalProperties: true |
| 474 | }, |
| 475 | Suspend_1: { |
| 476 | type: "object", |
| 477 | properties: { |
| 478 | next: { $ref: "#/$defs/Suspend_1" } |
| 479 | }, |
| 480 | required: ["next"], |
| 481 | additionalProperties: true |
| 482 | }, |
| 483 | NodeEncoded_1: { |
| 484 | type: "object", |
| 485 | properties: { |
| 486 | next: { $ref: "#/$defs/Suspend_1" } |
| 487 | }, |
| 488 | required: ["next"], |
| 489 | additionalProperties: true |
| 490 | } |
| 491 | }) |
| 492 | }) |
| 493 | }) |
| 494 | |
| 495 | describe("multi-root compilation", () => { |
| 496 | it("preserves root order and shared named definitions", () => { |
| 497 | const A = Schema.String.annotate({ identifier: "A", description: "a" }) |
| 498 | const B = Schema.String.annotate({ identifier: "B", description: "b" }) |
| 499 | const C = Schema.Tuple([A, B]) |
| 500 | const multiDocument = SchemaRepresentation.toRepresentations([A.ast, B.ast, C.ast]) |
| 501 | const jsonMultiDocument = SchemaRepresentation.toJsonSchemaMultiDocument(multiDocument) |
| 502 | assert.deepStrictEqual(jsonMultiDocument, { |
| 503 | dialect: "draft-2020-12", |
| 504 | schemas: [ |
| 505 | { "$ref": "#/$defs/A" }, |
| 506 | { "$ref": "#/$defs/B" }, |
| 507 | { |
| 508 | "type": "array", |
| 509 | "prefixItems": [ |
| 510 | { "$ref": "#/$defs/A" }, |
| 511 | { "$ref": "#/$defs/B" } |
| 512 | ], |
| 513 | "minItems": 2, |
| 514 | "maxItems": 2 |
| 515 | } |
| 516 | ], |
| 517 | definitions: { |
| 518 | A: { |
| 519 | "type": "string", |
| 520 | "description": "a" |
| 521 | }, |
| 522 | B: { |
| 523 | "type": "string", |
| 524 | "description": "b" |
| 525 | } |
| 526 | } |
| 527 | }) |
| 528 | }) |
| 529 | it("emits standard annotations and oneOf unions", () => { |
| 530 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 531 | representations: [ |
| 532 | { |
| 533 | _tag: "String", |
| 534 | annotations: { |
| 535 | format: "email", |
| 536 | contentEncoding: "base64", |
| 537 | contentMediaType: "application/json", |
| 538 | contentSchema: { type: "string" } |
| 539 | }, |
| 540 | checks: [] |
| 541 | }, |
| 542 | { |
| 543 | _tag: "Union", |
| 544 | types: [StringRepresentation, { _tag: "Boolean", checks: [] }], |
| 545 | options: { mode: "oneOf" }, |
| 546 | checks: [] |
| 547 | } |
| 548 | ], |
| 549 | references: {} |
| 550 | }) |
| 551 | |
| 552 | assert.deepStrictEqual(output.schemas, [ |
| 553 | { |
| 554 | type: "string", |
| 555 | format: "email", |
| 556 | contentEncoding: "base64", |
| 557 | contentMediaType: "application/json", |
| 558 | contentSchema: { type: "string" } |
| 559 | }, |
| 560 | { oneOf: [{ type: "string" }, { type: "boolean" }] } |
| 561 | ]) |
| 562 | }) |
| 563 | |
| 564 | it("resolves oneOf exactness through recursive references", () => { |
| 565 | const approximate: SchemaRepresentation.Representation = { |
| 566 | _tag: "String", |
| 567 | checks: [{ |
| 568 | _tag: "Filter", |
| 569 | aborted: false, |
| 570 | annotations: { toJsonSchema: () => [{ minLength: 1 }, true] } |
| 571 | }] |
| 572 | } |
| 573 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 574 | representations: [ |
| 575 | { _tag: "Reference", $ref: "Exact" }, |
| 576 | { _tag: "Reference", $ref: "Approximate" } |
| 577 | ], |
| 578 | references: { |
| 579 | Exact: { |
| 580 | _tag: "Union", |
| 581 | types: [{ _tag: "Reference", $ref: "Exact" }, { _tag: "Boolean", checks: [] }], |
| 582 | options: { mode: "oneOf" }, |
| 583 | checks: [] |
| 584 | }, |
| 585 | Approximate: { |
| 586 | _tag: "Union", |
| 587 | types: [{ _tag: "Reference", $ref: "Approximate" }, approximate], |
| 588 | options: { mode: "oneOf" }, |
| 589 | checks: [] |
| 590 | } |
| 591 | } |
| 592 | }) |
| 593 | |
| 594 | assert.deepStrictEqual(output, { |
| 595 | dialect: "draft-2020-12", |
| 596 | schemas: [ |
| 597 | { $ref: "#/$defs/Exact" }, |
| 598 | { $ref: "#/$defs/Approximate" } |
| 599 | ], |
| 600 | definitions: { |
| 601 | Exact: { |
| 602 | oneOf: [{ $ref: "#/$defs/Exact" }, { type: "boolean" }] |
| 603 | }, |
| 604 | Approximate: { |
| 605 | anyOf: [{ $ref: "#/$defs/Approximate" }, { type: "string", minLength: 1 }] |
| 606 | } |
| 607 | } |
| 608 | }) |
| 609 | }) |
| 610 | |
| 611 | it("uses group overrides without visiting children and otherwise falls back to allOf", () => { |
| 612 | let visits = 0 |
| 613 | const child: SchemaRepresentation.Filter = { |
| 614 | _tag: "Filter", |
| 615 | aborted: false, |
| 616 | annotations: { |
| 617 | toJsonSchema: () => { |
| 618 | visits++ |
| 619 | return { minLength: 1 } |
| 620 | } |
| 621 | } |
| 622 | } |
| 623 | const override: SchemaRepresentation.FilterGroup = { |
| 624 | _tag: "FilterGroup", |
| 625 | checks: [child], |
| 626 | annotations: { |
| 627 | description: "override", |
| 628 | toJsonSchema: () => ({ format: "custom" }) |
| 629 | } |
| 630 | } |
| 631 | const fallback: SchemaRepresentation.FilterGroup = { |
| 632 | _tag: "FilterGroup", |
| 633 | checks: [child, { _tag: "Filter", aborted: false }], |
| 634 | annotations: { description: "fallback" } |
| 635 | } |
| 636 | const document: SchemaRepresentation.MultiDocument = { |
| 637 | representations: [ |
| 638 | { _tag: "String", checks: [override] }, |
| 639 | { _tag: "String", checks: [fallback] } |
| 640 | ], |
| 641 | references: {} |
| 642 | } |
| 643 | |
| 644 | const output = SchemaRepresentation.toJsonSchemaMultiDocument(document) |
| 645 | assert.strictEqual(visits, 1) |
| 646 | assert.deepStrictEqual(output.schemas, [ |
| 647 | { |
| 648 | type: "string", |
| 649 | allOf: [{ format: "custom", description: "override" }] |
| 650 | }, |
| 651 | { |
| 652 | type: "string", |
| 653 | allOf: [{ allOf: [{ minLength: 1 }], description: "fallback" }] |
| 654 | } |
| 655 | ]) |
| 656 | }) |
| 657 | |
| 658 | it("preserves unknown references returned by callbacks", () => { |
| 659 | const reference = { $ref: "#/$defs/Unknown" } |
| 660 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 661 | representations: [{ |
| 662 | _tag: "String", |
| 663 | checks: [{ |
| 664 | _tag: "Filter", |
| 665 | aborted: false, |
| 666 | annotations: { toJsonSchema: () => reference } |
| 667 | }] |
| 668 | }], |
| 669 | references: {} |
| 670 | }) |
| 671 | |
| 672 | assert.deepStrictEqual(output.schemas, [{ |
| 673 | type: "string", |
| 674 | allOf: [reference] |
| 675 | }]) |
| 676 | }) |
| 677 | |
| 678 | it("rewrites callback references without mutating callback output", () => { |
| 679 | const defaultValue = Object.freeze({ $ref: "#/$defs//properties/value" }) |
| 680 | const reference = Object.freeze({ $ref: "#/$defs//properties/value", default: defaultValue }) |
| 681 | const definition = (): SchemaRepresentation.Representation => ({ |
| 682 | _tag: "String", |
| 683 | annotations: { "~identifier": "Value" }, |
| 684 | checks: [] |
| 685 | }) |
| 686 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 687 | representations: [{ |
| 688 | _tag: "String", |
| 689 | annotations: { default: -0 }, |
| 690 | checks: [{ |
| 691 | _tag: "Filter", |
| 692 | aborted: false, |
| 693 | annotations: { toJsonSchema: () => reference } |
| 694 | }] |
| 695 | }], |
| 696 | references: { A: definition(), "": definition() } |
| 697 | }) |
| 698 | |
| 699 | assert.deepStrictEqual(reference, { $ref: "#/$defs//properties/value", default: defaultValue }) |
| 700 | assert.strictEqual(Object.is(output.schemas[0].default, -0), true) |
| 701 | assert.deepStrictEqual(output, { |
| 702 | dialect: "draft-2020-12", |
| 703 | schemas: [{ |
| 704 | type: "string", |
| 705 | default: -0, |
| 706 | allOf: [{ $ref: "#/$defs/A/properties/value", default: { $ref: "#/$defs//properties/value" } }] |
| 707 | }], |
| 708 | definitions: { A: { type: "string" } } |
| 709 | }) |
| 710 | }) |
| 711 | |
| 712 | it("preserves unknown references in annotation values", () => { |
| 713 | const reference = { $ref: "#/$defs/Unknown" } |
| 714 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 715 | representations: [{ |
| 716 | _tag: "String", |
| 717 | annotations: { default: reference }, |
| 718 | checks: [] |
| 719 | }], |
| 720 | references: {} |
| 721 | }) |
| 722 | |
| 723 | assert.deepStrictEqual(output.schemas, [{ |
| 724 | type: "string", |
| 725 | default: reference |
| 726 | }]) |
| 727 | }) |
| 728 | |
| 729 | it("resolves referenced index-signature parameters and stops cycles", () => { |
| 730 | const record = ( |
| 731 | parameter: SchemaRepresentation.Representation |
| 732 | ): SchemaRepresentation.Representation => ({ |
| 733 | _tag: "Objects", |
| 734 | propertySignatures: [], |
| 735 | indexSignatures: [{ parameter, type: StringRepresentation }], |
| 736 | checks: [] |
| 737 | }) |
| 738 | const pattern = SchemaRepresentation.toRepresentation( |
| 739 | Schema.String.check(Schema.isPattern(/^a/u)).ast |
| 740 | ).representation |
| 741 | const output = SchemaRepresentation.toJsonSchemaMultiDocument({ |
| 742 | representations: [ |
| 743 | record({ _tag: "Reference", $ref: "Pattern" }), |
| 744 | record({ _tag: "Reference", $ref: "Cycle" }) |
| 745 | ], |
| 746 | references: { |
| 747 | Pattern: pattern, |
| 748 | Cycle: { _tag: "Reference", $ref: "Cycle" } |
| 749 | } |
| 750 | }) |
| 751 | |
| 752 | assert.deepStrictEqual(output.schemas, [ |
| 753 | { |
| 754 | type: "object", |
| 755 | patternProperties: { "^a": { type: "string" } }, |
| 756 | additionalProperties: true |
| 757 | }, |
| 758 | { |
| 759 | type: "object", |
| 760 | additionalProperties: true |
| 761 | } |
| 762 | ]) |
| 763 | |
| 764 | expectError( |
| 765 | () => |
| 766 | SchemaRepresentation.toJsonSchemaDocument({ |
| 767 | representation: record({ _tag: "Reference", $ref: "Missing" }), |
| 768 | references: {} |
| 769 | }), |
| 770 | `Invalid reference Missing\n at ["representation"]["indexSignatures"][0]["parameter"]["$ref"]` |
| 771 | ) |
| 772 | }) |
| 773 | }) |
| 774 | }) |