From 764e15fa3716590fca8a55358d6db5605d928284 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 29 Jul 2026 23:50:46 +0200 Subject: [PATCH] fix(schema): deduplicate JSON Schema fallback definitions --- .../deduplicate-json-schema-fallbacks.md | 10 + packages/effect/SCHEMA.md | 22 +- packages/effect/src/SchemaRepresentation.ts | 48 +-- .../internal/schema/fromJsonSchemaDocument.ts | 26 +- .../src/internal/schema/fromRepresentation.ts | 26 +- .../internal/schema/toJsonSchemaDocument.ts | 66 +++- .../src/internal/schema/toRepresentation.ts | 55 +-- .../fromJsonSchemaMultiDocument.test.ts | 100 +++--- .../fromRepresentations.test.ts | 88 ++--- .../fromSchemaMultiDocument.test.ts | 149 -------- .../toJsonSchemaMultiDocument.test.ts | 318 +++++++++++++++++- .../httpapi/OpenApiRepresentation.test.ts | 41 ++- .../typetest/schema/FromJsonSchema.tst.ts | 10 +- .../schema/SchemaRepresentation.tst.ts | 3 - .../schema/SchemaRepresentationReviver.tst.ts | 2 +- .../src/JsonSchemaGenerator.ts | 5 +- .../test/JsonSchemaGenerator.test.ts | 10 +- .../JsonSchemaGeneratorRepresentation.test.ts | 6 +- 18 files changed, 582 insertions(+), 403 deletions(-) create mode 100644 .changeset/deduplicate-json-schema-fallbacks.md delete mode 100644 packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts diff --git a/.changeset/deduplicate-json-schema-fallbacks.md b/.changeset/deduplicate-json-schema-fallbacks.md new file mode 100644 index 00000000000..1c5aa334987 --- /dev/null +++ b/.changeset/deduplicate-json-schema-fallbacks.md @@ -0,0 +1,10 @@ +--- +"effect": patch +"@effect/openapi-generator": patch +--- + +Deduplicate equivalent fallback definitions when compiling JSON Schema, and reconstruct only definitions reachable from multi-document roots. + +Remove `SchemaMultiDocument` and `fromSchemaMultiDocument`; multi-document import and revival now return the ordered root schemas directly. + +Stop the OpenAPI generator from emitting component schemas that are not reachable from a generated root. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 1e437f1d2dd..86e2cb882b7 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -6185,7 +6185,6 @@ flowchart TD LD -->|toJsonSchemaDocument|JD["JsonSchema.Document (draft-2020-12)"] JD -->|fromJsonSchemaDocument|S LD -->|toMultiDocument|LMD["live MultiDocument"] - SMD[SchemaMultiDocument] -->|fromSchemaMultiDocument|LMD LMD -->|toCodeDocument|CodeDocument LMD -->|toJsonSchemaMultiDocument|JMD[JsonSchema.MultiDocument] LMD -->|toJsonMultiDocument|JSON @@ -6213,20 +6212,6 @@ A `MultiDocument` stores multiple root representations that share the same `refe This is useful if you want to serialize a set of schemas together, or if you want to generate code for multiple schemas while emitting shared definitions only once. -### `SchemaMultiDocument` - -A `SchemaMultiDocument` contains live schemas plus a named definition map: - -```ts -interface SchemaMultiDocument { - readonly schemas: readonly [Schema.Top, ...Array] - readonly definitions: Readonly> -} -``` - -`fromJsonSchemaMultiDocument` returns this form. `fromSchemaMultiDocument` projects it to a `MultiDocument` while -preserving explicit definitions, including definitions that are not reachable from a root. - ## Projection and persistence boundaries ### Representations use the encoded side @@ -6374,7 +6359,8 @@ Effect exports individual revivers next to the built-in declarations and checks `Schema.OptionReviver`, `Schema.DateReviver`, and `Schema.isMinLengthReviver`. Supply every reviver required by the document; a missing or duplicate `id`, or a payload that does not satisfy its reviver's `payloadSchema`, is an error. -`fromRepresentations` rebuilds every root and named definition in a `MultiDocument` and returns a `SchemaMultiDocument`. +`fromRepresentations` rebuilds the ordered roots of a `MultiDocument` in a shared reference environment. Only references +reachable from those roots are revived. ### Custom revivers @@ -6430,8 +6416,8 @@ schema with revivers first. `SchemaRepresentation.fromJsonSchemaDocument` imports a JSON Schema Draft 2020-12 document as a runtime `Schema.Top`. It does not return a representation document. -`fromJsonSchemaMultiDocument` returns a `SchemaMultiDocument` containing all root schemas and definitions. Use -`fromSchemaMultiDocument` when that result must be passed to a representation compiler. +`fromJsonSchemaMultiDocument` returns the ordered root schemas. It translates only definitions reachable from those +roots. To pass the result to a representation compiler, call `toRepresentations` with the returned schemas' ASTs. Import is best-effort: JSON Schema constructs are translated to Effect schemas where possible, but the result is not a lossless reconstruction of an original Effect schema. The optional `onEnter` callback can normalize each JSON Schema node diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 7796ec4c50b..c0ca949e7d5 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -487,17 +487,6 @@ export interface MultiDocument { readonly references: References } -/** - * Live schemas reconstructed from a multi-document. - * - * @category models - * @since 4.0.0 - */ -export interface SchemaMultiDocument { - readonly schemas: readonly [Schema.Top, ...Array] - readonly definitions: Readonly> -} - /** * Reviver for a declaration. * @@ -713,27 +702,6 @@ export function toRepresentations( return InternalToRepresentation.toRepresentations(asts) } -/** - * Converts live schemas and their named definitions to a shared representation document. - * - * **When to use** - * - * Use when schemas with shared or unreachable definitions must be passed to representation compilers such as `toCodeDocument`. - * - * **Gotchas** - * - * Every schema is projected to its encoded side. Definitions are preserved even when no root reaches them. - * - * @see {@link toRepresentations} for converting AST roots without an explicit definition map - * @see {@link toCodeDocument} for generating code from the result - * - * @category constructors - * @since 4.0.0 - */ -export function fromSchemaMultiDocument(document: SchemaMultiDocument): MultiDocument { - return InternalToRepresentation.fromSchemaMultiDocument(document) -} - /** * Wraps a single representation document as a multi-document with one root. * @@ -1134,15 +1102,15 @@ export function fromRepresentation( } /** - * Reconstructs multiple runtime schemas and their shared definitions from a representation multi-document. + * Reconstructs multiple runtime schemas from a representation multi-document. * * **When to use** * - * Use when every root and named definition must be rebuilt in one shared reference environment. + * Use when multiple roots must be rebuilt in one shared reference environment. * * **Gotchas** * - * Every definition is revived, including definitions not reachable from a root. Revivers are resolved locally by `id`; none are installed implicitly. + * Only references reachable from a root are revived. Revivers are resolved locally by `id`; none are installed implicitly. * * @see {@link fromJsonMultiDocument} for decoding a persisted multi-document * @see {@link fromRepresentation} for a single root @@ -1153,7 +1121,7 @@ export function fromRepresentation( export function fromRepresentations( document: MultiDocument, options: { readonly revivers: ReadonlyArray } -): SchemaMultiDocument { +): readonly [Schema.Top, ...Array] { return InternalFromRepresentation.fromRepresentations(document, options.revivers) } @@ -1186,14 +1154,14 @@ export function fromJsonSchemaDocument( * * **When to use** * - * Use when multiple imported roots must preserve shared definitions, aliases, and recursion. + * Use when multiple imported roots share reachable definitions, aliases, or recursion. * * **Gotchas** * - * Every definition is translated, including definitions that no root references. Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * Only definitions reachable from a root are translated. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaDocument} for a single root - * @see {@link fromSchemaMultiDocument} for converting the result to a representation document + * @see {@link toRepresentations} for converting the returned schema ASTs to a representation document * * @category constructors * @since 4.0.0 @@ -1201,6 +1169,6 @@ export function fromJsonSchemaDocument( export function fromJsonSchemaMultiDocument( document: JsonSchema.MultiDocument<"draft-2020-12">, options?: FromJsonSchemaOptions -): SchemaMultiDocument { +): readonly [Schema.Top, ...Array] { return InternalFromJsonSchemaDocument.fromJsonSchemaMultiDocument(document, options) } diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 8301d808e72..586df451368 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -214,8 +214,8 @@ function translateJsonSchemaMultiDocument( options?: SchemaRepresentation.FromJsonSchemaOptions, singleRoot = false ): SchemaRepresentation.MultiDocument { - const definitionCache = new Map() - const definitionsInProgress = new Set() + const definitionCache = new Map() + const reachableDefinitions = new Map() const annotatedReferences: Array<{ readonly reference: SchemaRepresentation.Reference readonly path: Path @@ -227,16 +227,17 @@ function translateJsonSchemaMultiDocument( recursiveReferenceError?: string ): ImportedJsonSchemaRepresentation { const cached = definitionCache.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) { + if (cached === null) { + throw errorWithPath(recursiveReferenceError ?? `Invalid reference ${key}`, [...path, "$ref"]) + } + return cached + } if (!Object.hasOwn(document.definitions, key)) { throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) } - if (definitionsInProgress.has(key)) { - throw errorWithPath(recursiveReferenceError ?? `Invalid reference ${key}`, [...path, "$ref"]) - } - definitionsInProgress.add(key) + definitionCache.set(key, null) const representation = recur(document.definitions[key], ["definitions", key]) - definitionsInProgress.delete(key) definitionCache.set(key, representation) return representation } @@ -696,6 +697,7 @@ function translateJsonSchemaMultiDocument( if (typeof schema.$ref === "string") { const $ref = jsonSchemaReferenceKey(schema.$ref) if ($ref !== undefined) { + if (!reachableDefinitions.has($ref)) reachableDefinitions.set($ref, path) return { _tag: "Reference", $ref } } } @@ -890,12 +892,12 @@ function translateJsonSchemaMultiDocument( } const references: Record = {} - for (const key of Object.keys(document.definitions)) { - InternalRecord.assignProperty(references, key, unknownJsonSchemas(translateDefinition(key, ["definitions", key]))) - } const representations = document.schemas.map((schema, index) => unknownJsonSchemas(recur(schema, singleRoot ? ["schema"] : ["schemas", index])) ) as [Representation, ...Array] + for (const [key, path] of reachableDefinitions) { + InternalRecord.assignProperty(references, key, unknownJsonSchemas(translateDefinition(key, path))) + } for (const { reference, path } of annotatedReferences) { resolveReference(reference, path) } @@ -952,6 +954,6 @@ export function fromJsonSchemaDocument( export function fromJsonSchemaMultiDocument( document: JsonSchema.MultiDocument<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions -): SchemaRepresentation.SchemaMultiDocument { +): readonly [Schema.Top, ...Array] { return fromRepresentations(translateJsonSchemaMultiDocument(document, options), jsonSchemaRevivers) } diff --git a/packages/effect/src/internal/schema/fromRepresentation.ts b/packages/effect/src/internal/schema/fromRepresentation.ts index db939d6307e..30b647beb06 100644 --- a/packages/effect/src/internal/schema/fromRepresentation.ts +++ b/packages/effect/src/internal/schema/fromRepresentation.ts @@ -12,7 +12,7 @@ type Path = ReadonlyArray export function fromRepresentations( document: SchemaRepresentation.MultiDocument, revivers: ReadonlyArray -): SchemaRepresentation.SchemaMultiDocument { +): readonly [Schema.Top, ...Array] { return revivePersisted(document.representations, document.references, makeReviverMap(revivers), false) } @@ -58,18 +58,17 @@ function revivePersisted( references: SchemaRepresentation.References, reviverMap: ReadonlyMap, singleRoot: boolean -): SchemaRepresentation.SchemaMultiDocument { +): readonly [Schema.Top, ...Array] { const slots = new Map() - const referenceKeys = Object.keys(references) - - for (const key of referenceKeys) { - slots.set(key, new ReferenceSlot(key)) - } function resolveReference(key: string, path: Path): Schema.Top { - const slot = slots.get(key) + let slot = slots.get(key) if (slot === undefined) { - throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) + if (!Object.hasOwn(references, key)) { + throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) + } + slot = new ReferenceSlot(key) + slots.set(key, slot) } if (slot.body !== undefined) { return slot.body @@ -320,15 +319,10 @@ function revivePersisted( } } - const definitions: Record = {} - for (const key of referenceKeys) { - InternalRecord.assignProperty(definitions, key, resolveReference(key, ["references", key])) - } - const schemas = representations.map((representation, index) => recur(representation, singleRoot ? ["representation"] : ["representations", index]) ) as [Schema.Top, ...Array] - return { schemas, definitions } + return schemas } /** @internal */ @@ -341,5 +335,5 @@ export function fromRepresentation( document.references, makeReviverMap(revivers), true - ).schemas[0] + )[0] } diff --git a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts index 964dd9886e1..509439970c5 100644 --- a/packages/effect/src/internal/schema/toJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/toJsonSchemaDocument.ts @@ -1,4 +1,5 @@ import * as Arr from "../../Array.ts" +import * as Equal from "../../Equal.ts" import { escapeToken } from "../../JsonPointer.ts" import type * as JsonSchema from "../../JsonSchema.ts" import * as RegEx from "../../RegExp.ts" @@ -142,12 +143,63 @@ function compileJsonSchema( options: Schema.ToJsonSchemaOptions | undefined ): JsonSchema.MultiDocument<"draft-2020-12"> { const definitions: Record = {} - for (const key of Object.keys(references)) { - InternalRecord.assignProperty(definitions, key, recur(references[key], ["references", key])) + // null = compiling, string = canonical key, object = compiled schema + const definitionStates = new Map() + const compiledRepresentations = new WeakMap() + const fallbackDefinitions = new Map>() + const referenceKeys = Object.keys(references) + for (const key of referenceKeys) { + compileDefinition(key, ["references", key]) + } + for (const key of referenceKeys) { + const compiled = definitionStates.get(key)! + if (typeof compiled !== "string") { + InternalRecord.assignProperty(definitions, key, compiled) + } } const schemas = Arr.map(representations, (representation, index) => recur(representation, rootPaths[index])) return { dialect: "draft-2020-12", schemas, definitions } + function compileDefinition(key: string, path: Path): string { + const compiled = definitionStates.get(key) + if (compiled !== undefined) return typeof compiled === "string" ? compiled : key + if (!Object.hasOwn(references, key)) { + throw errorWithPath(`Invalid reference ${key}`, [...path, "$ref"]) + } + + definitionStates.set(key, null) + const representation = references[key] + const schema = recur(representation, ["references", key]) + + const fallback = getIdentifierFallback(representation) + if (fallback !== undefined) { + const candidates = fallbackDefinitions.get(fallback) + const match = candidates?.find((candidate) => Equal.equals(definitionStates.get(candidate), schema)) + if (match === undefined) { + if (candidates === undefined) fallbackDefinitions.set(fallback, [key]) + else candidates.push(key) + } else { + definitionStates.set(key, match) + return match + } + } + definitionStates.set(key, schema) + return key + } + + function getIdentifierFallback( + representation: SchemaRepresentation.Representation + ): string | undefined { + if (representation._tag === "Reference") return undefined + const annotations = representation.checks.length === 0 + ? representation.annotations + : representation.checks[representation.checks.length - 1].annotations + return typeof annotations?.identifier !== "string" && + typeof annotations?.[InternalAnnotations.IDENTIFIER_FALLBACK_KEY] === "string" + ? annotations[InternalAnnotations.IDENTIFIER_FALLBACK_KEY] + : undefined + } + function annotationSchemas( representation: CheckRepresentationAnnotation | undefined, path: Path @@ -183,11 +235,11 @@ function compileJsonSchema( path: Path ): JsonSchema.JsonSchema { if (representation._tag === "Reference") { - if (!Object.hasOwn(references, representation.$ref)) { - throw errorWithPath(`Invalid reference ${representation.$ref}`, [...path, "$ref"]) - } - return { $ref: `#/$defs/${escapeToken(representation.$ref)}` } + const canonical = compileDefinition(representation.$ref, path) + return { $ref: `#/$defs/${escapeToken(canonical)}` } } + const cached = compiledRepresentations.get(representation) + if (cached !== undefined) return cached let output = on(representation, path) const ordinary = collectJsonSchemaAnnotations(representation.annotations, options) @@ -201,6 +253,7 @@ function compileJsonSchema( output = appendJsonSchema(output, check) } } + compiledRepresentations.set(representation, output) return output } @@ -370,6 +423,7 @@ function compileJsonSchema( if (!Object.hasOwn(references, parameter.$ref)) { throw errorWithPath(`Invalid reference ${parameter.$ref}`, [...path, "$ref"]) } + compileDefinition(parameter.$ref, path) if (seenReferences.has(parameter.$ref)) return [] const next = new Set(seenReferences).add(parameter.$ref) return getParameterPatterns(references[parameter.$ref], ["references", parameter.$ref], next) diff --git a/packages/effect/src/internal/schema/toRepresentation.ts b/packages/effect/src/internal/schema/toRepresentation.ts index ace138d91a3..9a2ca72d3a8 100644 --- a/packages/effect/src/internal/schema/toRepresentation.ts +++ b/packages/effect/src/internal/schema/toRepresentation.ts @@ -17,7 +17,7 @@ export function toRepresentation( export function toRepresentations( asts: readonly [SchemaAST.AST, ...Array] ): SchemaRepresentation.MultiDocument { - return lowerASTs(asts, []) + return lowerASTs(asts) } type CheckRepresentationAnnotation = SchemaRepresentation.CheckRepresentationAnnotation< @@ -28,27 +28,6 @@ function annotationsField(annotations: A | undefined): { readonly annotations return annotations === undefined ? undefined : { annotations } } -/** @internal */ -export function fromSchemaMultiDocument( - document: SchemaRepresentation.SchemaMultiDocument -): SchemaRepresentation.MultiDocument { - const definitions = Object.entries(document.definitions).map(([key, schema]) => { - const original = schema.ast - const encoded = SchemaAST.getLastEncoding(original) - const body = SchemaAST.isSuspend(encoded) ? encoded.thunk() : encoded - return { key, original, encoded, body } - }) - const asts = Arr.map(document.schemas, (schema) => schema.ast) - return lowerASTs(asts, definitions) -} - -interface ExternalDefinition { - readonly key: string - readonly original: SchemaAST.AST - readonly encoded: SchemaAST.AST - readonly body: SchemaAST.AST -} - // Preserve repeated structural nodes as references without adding noise for leaf nodes. function isShareable(ast: SchemaAST.AST): boolean { return SchemaAST.isArrays(ast) || @@ -80,33 +59,20 @@ function hasSameReferenceOwner(self: SchemaAST.AST, that: SchemaAST.AST): boolea } function lowerASTs( - asts: readonly [SchemaAST.AST, ...Array], - externalDefinitions: ReadonlyArray + asts: readonly [SchemaAST.AST, ...Array] ): SchemaRepresentation.MultiDocument { const references: Record = {} const referenceMap = new Map() const fallbackReferences: Array = [] const referenceOwners = new Map() - const externalReferences = new Set(externalDefinitions.map((definition) => definition.key)) const visiting = new Set() const visited = new Set() const shared = new Set() - for (const definition of externalDefinitions) { - referenceOwners.set(definition.key, definition.body) - referenceMap.set(definition.original, definition.key) - referenceMap.set(definition.encoded, definition.key) - } - for (const ast of asts) visit(ast) - for (const definition of externalDefinitions) visit(definition.body) const representations = Arr.map(asts, (ast) => recur(ast)) - for (const definition of externalDefinitions) { - InternalRecord.assignProperty(references, definition.key, recur(definition.body, definition.key)) - } - return { representations, references } function generateReference(prefix: string, owner: SchemaAST.AST): string { @@ -165,31 +131,28 @@ function lowerASTs( }) } - function recur( - ast: SchemaAST.AST, - ownedReference?: string - ): SchemaRepresentation.Representation { + function recur(ast: SchemaAST.AST): SchemaRepresentation.Representation { const found = referenceMap.get(ast) - if (found !== undefined && found !== ownedReference) { + if (found !== undefined) { return { _tag: "Reference", $ref: found } } const projected = SchemaAST.getLastEncoding(ast) if (projected !== ast) { - return recur(projected, ownedReference) + return recur(projected) } - const referenceIdentifier = ownedReference === undefined ? resolveReferenceIdentifier(ast) : undefined + const referenceIdentifier = resolveReferenceIdentifier(ast) if (referenceIdentifier !== undefined) { const reference = getReference(referenceIdentifier, ast) referenceMap.set(ast, reference) - if (!Object.hasOwn(references, reference) && !externalReferences.has(reference)) { + if (!Object.hasOwn(references, reference)) { InternalRecord.assignProperty(references, reference, on(ast)) } return { _tag: "Reference", $ref: reference } } - if (ownedReference === undefined && shared.has(ast)) { + if (shared.has(ast)) { const reference = generateReference(`${ast._tag}_`, ast) referenceMap.set(ast, reference) InternalRecord.assignProperty(references, reference, on(ast)) @@ -207,7 +170,7 @@ function lowerASTs( visiting.delete(ast) const reference = referenceMap.get(ast) - if (reference !== undefined && reference !== ownedReference) { + if (reference !== undefined) { InternalRecord.assignProperty(references, reference, representation) return { _tag: "Reference", $ref: reference } } diff --git a/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts index 8ed62cd3c34..f39837cf437 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaMultiDocument.test.ts @@ -1,8 +1,17 @@ import { assert } from "@effect/vitest" -import { SchemaRepresentation } from "effect" +import { type SchemaAST, SchemaRepresentation } from "effect" import { describe, it } from "vitest" import { deepStrictEqual, throws } from "../../utils/assert.ts" +function importAndLower( + document: Parameters[0] +): SchemaRepresentation.MultiDocument { + const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument(document) + return SchemaRepresentation.toRepresentations( + schemas.map((schema) => schema.ast) as [SchemaAST.AST, ...Array] + ) +} + describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { it("preserves an onEnter exception by identity", () => { const cause = new Error("boom") @@ -26,28 +35,26 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { }) it("preserves contentSchema as an annotation without traversing it", () => { - const document = SchemaRepresentation.fromSchemaMultiDocument( - SchemaRepresentation.fromJsonSchemaMultiDocument({ - dialect: "draft-2020-12", - schemas: [{ - type: "string", - contentMediaType: "application/json", - contentSchema: { $ref: "#/$defs/Payload" } - }], - definitions: { - Payload: { - type: "object", - properties: { value: { type: "number" } }, - required: ["value"], - additionalProperties: false - } + const document = importAndLower({ + dialect: "draft-2020-12", + schemas: [{ + type: "string", + contentMediaType: "application/json", + contentSchema: { $ref: "#/$defs/Payload" } + }], + definitions: { + Payload: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + additionalProperties: false } - }) - ) + } + }) const content = document.representations[0] assert.strictEqual(content._tag, "String") - assert.deepStrictEqual(Object.keys(document.references), ["Payload"]) + assert.deepStrictEqual(document.references, {}) if (content._tag === "String") { assert.deepStrictEqual(content.annotations, { contentMediaType: "application/json", @@ -56,8 +63,25 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { } }) + it("does not import unreachable definitions", () => { + const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({ + dialect: "draft-2020-12", + schemas: [{ type: "string" }], + definitions: { + Unused: { type: "number", description: "unused" } + } + }, { + onEnter: (schema) => { + if (schema.description === "unused") throw new Error("unreachable") + return schema + } + }) + + assert.strictEqual(schemas[0].ast._tag, "String") + }) + it("preserves root order and shares definitions", () => { - const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = importAndLower({ dialect: "draft-2020-12", schemas: [ { $ref: "#/$defs/A" }, @@ -68,7 +92,7 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { definitions: { A: { type: "string", minLength: 1 } } - })) + }) deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [ @@ -115,7 +139,7 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { }) it("resolves alias chains when combining a reference", () => { - const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = importAndLower({ dialect: "draft-2020-12", schemas: [{ $ref: "#/$defs/A", description: "root" }], definitions: { @@ -123,7 +147,7 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { B: { $ref: "#/$defs/C" }, C: { type: "number" } } - })) + }) deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [{ @@ -145,46 +169,20 @@ describe("SchemaRepresentation.fromJsonSchemaMultiDocument", () => { }, aborted: false }] - }, - B: { - _tag: "Number", - checks: [{ - _tag: "Filter", - representation: { id: "effect/schema/isFinite", payload: null }, - annotations: { - identifier: "B", - expected: "a finite number", - arbitrary: { constraint: { noInfinity: true, noNaN: true } } - }, - aborted: false - }] - }, - C: { - _tag: "Number", - checks: [{ - _tag: "Filter", - representation: { id: "effect/schema/isFinite", payload: null }, - annotations: { - identifier: "C", - expected: "a finite number", - arbitrary: { constraint: { noInfinity: true, noNaN: true } } - }, - aborted: false - }] } } }) }) it("tracks recursive definitions independently", () => { - const document = SchemaRepresentation.fromSchemaMultiDocument(SchemaRepresentation.fromJsonSchemaMultiDocument({ + const document = importAndLower({ dialect: "draft-2020-12", schemas: [{ $ref: "#/$defs/A" }, { $ref: "#/$defs/B" }], definitions: { A: { $ref: "#/$defs/A" }, B: { $ref: "#/$defs/B" } } - })) + }) deepStrictEqual(SchemaRepresentation.toJsonMultiDocument(document), { representations: [ diff --git a/packages/effect/test/schema/representation/fromRepresentations.test.ts b/packages/effect/test/schema/representation/fromRepresentations.test.ts index 3c80f8d1899..832d378a7ef 100644 --- a/packages/effect/test/schema/representation/fromRepresentations.test.ts +++ b/packages/effect/test/schema/representation/fromRepresentations.test.ts @@ -7,7 +7,7 @@ function decode(schema: Schema.Top, input: unknown): unknown { describe("SchemaRepresentation.fromRepresentations", () => { it("preserves root order", () => { - const document = SchemaRepresentation.fromRepresentations({ + const schemas = SchemaRepresentation.fromRepresentations({ representations: [ { _tag: "String", checks: [] }, { _tag: "Boolean", checks: [] }, @@ -16,28 +16,29 @@ describe("SchemaRepresentation.fromRepresentations", () => { references: {} }, { revivers: [] }) - assert.strictEqual(decode(document.schemas[0], "value"), "value") - assert.strictEqual(decode(document.schemas[1], true), true) - assert.strictEqual(decode(document.schemas[2], 1), 1) + assert.strictEqual(decode(schemas[0], "value"), "value") + assert.strictEqual(decode(schemas[1], true), true) + assert.strictEqual(decode(schemas[2], 1), 1) }) - it("revives unreachable definitions", () => { - const document = SchemaRepresentation.fromRepresentations({ + it("does not revive unreachable references", () => { + const schemas = SchemaRepresentation.fromRepresentations({ representations: [{ _tag: "String", checks: [] }], - references: { Unused: { _tag: "Number", checks: [] } } + references: { + Unused: { + _tag: "Declaration", + typeParameters: [], + representation: { id: "missing", payload: null }, + checks: [] + } + } }, { revivers: [] }) - assert.deepStrictEqual(Object.keys(document.definitions), ["Unused"]) - assert.strictEqual(document.definitions.Unused.ast._tag, "Number") - assert.strictEqual(decode(document.definitions.Unused, 1), 1) - assert.deepStrictEqual(SchemaRepresentation.toRepresentation(document.definitions.Unused.ast).representation, { - _tag: "Reference", - $ref: "Unused" - }) + assert.strictEqual(decode(schemas[0], "value"), "value") }) it("normalizes aliases while preserving the outer reference", () => { - const document = SchemaRepresentation.fromRepresentations({ + const schemas = SchemaRepresentation.fromRepresentations({ representations: [{ _tag: "Reference", $ref: "Alias" }], references: { Alias: { _tag: "Reference", $ref: "Value" }, @@ -45,19 +46,16 @@ describe("SchemaRepresentation.fromRepresentations", () => { } }, { revivers: [] }) - assert.notStrictEqual(document.definitions.Value, document.definitions.Alias) - assert.strictEqual(document.definitions.Value.ast._tag, "String") - assert.strictEqual(document.definitions.Alias.ast._tag, "String") - assert.strictEqual(document.schemas[0], document.definitions.Alias) - assert.strictEqual(decode(document.schemas[0], "value"), "value") - assert.deepStrictEqual(SchemaRepresentation.toRepresentation(document.schemas[0].ast).representation, { + assert.strictEqual(schemas[0].ast._tag, "String") + assert.strictEqual(decode(schemas[0], "value"), "value") + assert.deepStrictEqual(SchemaRepresentation.toRepresentation(schemas[0].ast).representation, { _tag: "Reference", $ref: "Alias" }) }) it("shares a resolved reference between roots", () => { - const document = SchemaRepresentation.fromRepresentations({ + const schemas = SchemaRepresentation.fromRepresentations({ representations: [ { _tag: "Reference", $ref: "Shared" }, { _tag: "Reference", $ref: "Shared" } @@ -67,13 +65,26 @@ describe("SchemaRepresentation.fromRepresentations", () => { } }, { revivers: [] }) - assert.strictEqual(document.schemas[0], document.schemas[1]) - assert.strictEqual(document.schemas[0], document.definitions.Shared) - assert.strictEqual(document.schemas[0].ast._tag, "Number") + assert.strictEqual(schemas[0], schemas[1]) + assert.strictEqual(schemas[0].ast._tag, "Number") + }) + + it("resolves a reachable __proto__ reference", () => { + const references: Record = {} + Object.defineProperty(references, "__proto__", { + value: { _tag: "String", checks: [] }, + enumerable: true + }) + const schemas = SchemaRepresentation.fromRepresentations({ + representations: [{ _tag: "Reference", $ref: "__proto__" }], + references + }, { revivers: [] }) + + assert.strictEqual(decode(schemas[0], "value"), "value") }) it("revives recursive definitions", () => { - const document = SchemaRepresentation.fromRepresentations({ + const schemas = SchemaRepresentation.fromRepresentations({ representations: [{ _tag: "Reference", $ref: "Recursive" }], references: { Recursive: { @@ -98,13 +109,12 @@ describe("SchemaRepresentation.fromRepresentations", () => { } }, { revivers: [] }) - assert.strictEqual(document.schemas[0], document.definitions.Recursive) - assert.strictEqual(document.definitions.Recursive.ast._tag, "Objects") - if (document.definitions.Recursive.ast._tag === "Objects") { - assert.strictEqual(document.definitions.Recursive.ast.propertySignatures[1].type._tag, "Suspend") + assert.strictEqual(schemas[0].ast._tag, "Objects") + if (schemas[0].ast._tag === "Objects") { + assert.strictEqual(schemas[0].ast.propertySignatures[1].type._tag, "Suspend") } assert.deepStrictEqual( - decode(document.schemas[0], { + decode(schemas[0], { value: 1, next: { value: 2 } }), @@ -116,7 +126,7 @@ describe("SchemaRepresentation.fromRepresentations", () => { }) it("revives mutually recursive definitions with concrete roots", () => { - const document = SchemaRepresentation.fromRepresentations({ + const schemas = SchemaRepresentation.fromRepresentations({ representations: [ { _tag: "Reference", $ref: "A" }, { _tag: "Reference", $ref: "B" } @@ -147,14 +157,14 @@ describe("SchemaRepresentation.fromRepresentations", () => { } }, { revivers: [] }) - assert.strictEqual(document.definitions.A.ast._tag, "Objects") - assert.strictEqual(document.definitions.B.ast._tag, "Objects") - if (document.definitions.A.ast._tag === "Objects" && document.definitions.B.ast._tag === "Objects") { - const b = document.definitions.A.ast.propertySignatures[0].type - const a = document.definitions.B.ast.propertySignatures[0].type + assert.strictEqual(schemas[0].ast._tag, "Objects") + assert.strictEqual(schemas[1].ast._tag, "Objects") + if (schemas[0].ast._tag === "Objects" && schemas[1].ast._tag === "Objects") { + const b = schemas[0].ast.propertySignatures[0].type + const a = schemas[1].ast.propertySignatures[0].type assert.isTrue(b._tag === "Suspend" || a._tag === "Suspend") } - assert.deepStrictEqual(decode(document.schemas[0], { b: { a: {} } }), { b: { a: {} } }) - assert.deepStrictEqual(decode(document.schemas[1], { a: { b: {} } }), { a: { b: {} } }) + assert.deepStrictEqual(decode(schemas[0], { b: { a: {} } }), { b: { a: {} } }) + assert.deepStrictEqual(decode(schemas[1], { a: { b: {} } }), { a: { b: {} } }) }) }) diff --git a/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts deleted file mode 100644 index a77e48abcc4..00000000000 --- a/packages/effect/test/schema/representation/fromSchemaMultiDocument.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { assert, describe, it } from "@effect/vitest" -import { Schema, SchemaRepresentation } from "effect" - -describe("SchemaRepresentation.fromSchemaMultiDocument", () => { - it("preserves root order and lowers direct definitions", () => { - assert.deepStrictEqual( - SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Schema.String, Schema.Boolean], - definitions: { Value: Schema.Number } - }), - { - representations: [ - { _tag: "String", checks: [] }, - { _tag: "Boolean", checks: [] } - ], - references: { Value: { _tag: "Number", checks: [] } } - } - ) - }) - - it("keeps distinct keys for definitions sharing the same schema", () => { - assert.deepStrictEqual( - SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Schema.String], - definitions: { A: Schema.Number, B: Schema.Number } - }), - { - representations: [{ _tag: "String", checks: [] }], - references: { - A: { _tag: "Reference", $ref: "B" }, - B: { _tag: "Number", checks: [] } - } - } - ) - }) - - it("preserves mutually recursive definitions", () => { - const schemas = SchemaRepresentation.fromJsonSchemaMultiDocument({ - dialect: "draft-2020-12", - schemas: [{ $ref: "#/$defs/A" }], - definitions: { - A: { - type: "object", - properties: { b: { $ref: "#/$defs/B" } }, - additionalProperties: false - }, - B: { - type: "object", - properties: { a: { $ref: "#/$defs/A" } }, - additionalProperties: false - } - } - }) - - assert.deepStrictEqual(SchemaRepresentation.fromSchemaMultiDocument(schemas), { - representations: [{ _tag: "Reference", $ref: "A" }], - references: { - A: { - _tag: "Objects", - propertySignatures: [{ - name: "b", - type: { _tag: "Reference", $ref: "B" }, - isOptional: true, - isMutable: false - }], - indexSignatures: [], - checks: [], - annotations: { identifier: "A" } - }, - B: { - _tag: "Objects", - propertySignatures: [{ - name: "a", - type: { - _tag: "Suspend", - checks: [], - thunk: { _tag: "Reference", $ref: "A" } - }, - isOptional: true, - isMutable: false - }], - indexSignatures: [], - checks: [], - annotations: { identifier: "B" } - } - } - }) - }) - - it("allows an identified suspended external definition", () => { - const Value = Schema.suspend(() => Schema.String).annotate({ identifier: "Value" }) - const document = SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Value], - definitions: { Value } - }) - - assert.deepStrictEqual(document.representations[0], { _tag: "Reference", $ref: "Value" }) - }) - - it("restores the identifier of an external definition", () => { - const Value = Schema.Number - const representation = SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Value], - definitions: { Value } - }) - const document = SchemaRepresentation.fromRepresentations(representation, { revivers: [] }) - - assert.strictEqual(document.schemas[0], document.definitions.Value) - assert.strictEqual(document.definitions.Value.ast._tag, "Number") - assert.deepStrictEqual(SchemaRepresentation.toRepresentation(document.schemas[0].ast).representation, { - _tag: "Reference", - $ref: "Value" - }) - assert.deepStrictEqual(Object.keys(SchemaRepresentation.fromSchemaMultiDocument(document).references), ["Value"]) - }) - - it("rejects an identifier that collides with an external definition", () => { - const Root = Schema.Number.annotate({ identifier: "Value" }) - - assert.throws( - () => - SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Root], - definitions: { Value: Schema.String } - }), - /Duplicate identifier: "Value"/ - ) - }) - - it("supports __proto__ as an external definition key", () => { - const Value = Schema.Number - const definitions: Record = {} - Object.defineProperty(definitions, "__proto__", { - value: Value, - enumerable: true - }) - - const document = SchemaRepresentation.fromSchemaMultiDocument({ - schemas: [Value], - definitions - }) - - assert.deepStrictEqual(document.representations[0], { _tag: "Reference", $ref: "__proto__" }) - assert.deepStrictEqual(Object.keys(document.references), ["__proto__"]) - assert.strictEqual(Object.getPrototypeOf(document.references), Object.prototype) - assert.isTrue(Object.hasOwn(document.references, "__proto__")) - assert.strictEqual(document.references["__proto__"]._tag, "Number") - }) -}) diff --git a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts index 7353d78eeb8..666cf6adda2 100644 --- a/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts +++ b/packages/effect/test/schema/representation/toJsonSchemaMultiDocument.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Schema, SchemaRepresentation } from "effect" +import { Schema, SchemaRepresentation, SchemaTransformation } from "effect" import { throws } from "../../utils/assert.ts" function expectError(thunk: () => void, expected: string | Error): void { @@ -18,7 +18,323 @@ const StringRepresentation: SchemaRepresentation.Representation = { checks: [] } +const stringIdentityTransformation = SchemaTransformation.transform({ + decode: (value: string) => value, + encode: (value: string) => value +}) + describe("SchemaRepresentation.toJsonSchemaMultiDocument", () => { + it("deduplicates equivalent fallback definitions", () => { + const Content = Schema.Struct({ text: Schema.String }).annotate({ identifier: "Tool.Content" }) + const first = Schema.toCodecJson(Schema.fromJsonString(Content)) + const second = Schema.toCodecJson(Schema.fromJsonString(Content)) + const document = SchemaRepresentation.toRepresentations([first.ast, second.ast]) + + assert.deepStrictEqual(SchemaRepresentation.toJsonSchemaMultiDocument(document), { + dialect: "draft-2020-12", + schemas: [ + { $ref: "#/$defs/Tool.ContentJsonEncoding" }, + { $ref: "#/$defs/Tool.ContentJsonEncoding" } + ], + definitions: { + "Tool.ContentJsonEncoding": { + type: "string", + contentMediaType: "application/json" + } + } + }) + }) + + it("does not deduplicate different fallback definitions", () => { + const Content = Schema.String.annotate({ identifier: "Fallback" }) + const string = Schema.toCodecJson( + Schema.String.pipe(Schema.decodeTo(Content, stringIdentityTransformation)) + ) + const boolean = Schema.toCodecJson( + Schema.Boolean.pipe( + Schema.decodeTo( + Content, + SchemaTransformation.transform({ + decode: (value) => String(value), + encode: (value) => value === "true" + }) + ) + ) + ) + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([string.ast, boolean.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/FallbackJsonEncoding" }, + { $ref: "#/$defs/FallbackJsonEncoding1" } + ]) + assert.deepStrictEqual(output.definitions, { + FallbackJsonEncoding: { type: "string" }, + FallbackJsonEncoding1: { type: "boolean" } + }) + }) + + it("does not deduplicate explicit identifiers", () => { + const ExplicitA = Schema.String.annotate({ identifier: "ExplicitA" }) + const ExplicitB = Schema.String.annotate({ identifier: "ExplicitB" }) + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([ExplicitA.ast, ExplicitB.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/ExplicitA" }, + { $ref: "#/$defs/ExplicitB" } + ]) + assert.deepStrictEqual(output.definitions, { + ExplicitA: { type: "string" }, + ExplicitB: { type: "string" } + }) + }) + + it("deduplicates fallback definitions after linking their dependencies", () => { + const Child = Schema.String.annotate({ identifier: "Child" }) + const Parent = Schema.Struct({ child: Child }).annotate({ identifier: "Parent" }) + const make = () => { + const encoded = Schema.Struct({ + child: Schema.fromJsonString(Child) + }) + return Schema.toCodecJson( + encoded.pipe( + Schema.decodeTo( + Parent, + SchemaTransformation.transform({ + decode: ({ child }) => ({ child }), + encode: ({ child }) => ({ child }) + }) + ) + ) + ) + } + const first = make() + const second = make() + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([first.ast, second.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/ParentJsonEncoding" }, + { $ref: "#/$defs/ParentJsonEncoding" } + ]) + assert.deepStrictEqual(output.definitions, { + ChildJsonEncoding: { + type: "string", + contentMediaType: "application/json" + }, + ParentJsonEncoding: { + type: "object", + properties: { + child: { $ref: "#/$defs/ChildJsonEncoding" } + }, + required: ["child"], + additionalProperties: false + } + }) + }) + + it("escapes canonical fallback references", () => { + const Content = Schema.String.annotate({ identifier: "Child/~" }) + const first = Schema.toCodecJson(Schema.fromJsonString(Content)) + const second = Schema.toCodecJson(Schema.fromJsonString(Content)) + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([first.ast, second.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/Child~1~0JsonEncoding" }, + { $ref: "#/$defs/Child~1~0JsonEncoding" } + ]) + assert.deepStrictEqual(output.definitions, { + "Child/~JsonEncoding": { + type: "string", + contentMediaType: "application/json" + } + }) + }) + + it("compiles fallback definitions once before deduplicating them", () => { + let visits = 0 + const Content = Schema.String.annotate({ identifier: "Callback" }) + const make = (reverse: boolean) => { + const encoded = Schema.String.check( + Schema.makeFilter(() => true, { + toJsonSchema: () => { + visits++ + return reverse ? { maxLength: 10, minLength: 1 } : { minLength: 1, maxLength: 10 } + } + }) + ) + return Schema.toCodecJson( + encoded.pipe(Schema.decodeTo(Content, stringIdentityTransformation)) + ) + } + const first = make(false) + const second = make(true) + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([first.ast, second.ast]) + ) + + assert.strictEqual(visits, 2) + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/CallbackJsonEncoding" }, + { $ref: "#/$defs/CallbackJsonEncoding" } + ]) + assert.deepStrictEqual(output.definitions, { + CallbackJsonEncoding: { + type: "string", + allOf: [{ minLength: 1, maxLength: 10 }] + } + }) + }) + + it("reuses a compiled definition when extracting an index signature pattern", () => { + let visits = 0 + const Key = Schema.String.check( + Schema.makeFilter(() => true, { + identifier: "Key", + toJsonSchema: () => { + visits++ + return { pattern: "^key$" } + } + }) + ) + const Root = Schema.Record(Key, Schema.String) + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([Root.ast]) + ) + + assert.strictEqual(visits, 1) + assert.deepStrictEqual(output.schemas, [{ + type: "object", + patternProperties: { + "^key$": { type: "string" } + } + }]) + }) + + it("deduplicates fallback definitions that share a recursive dependency", () => { + interface OptionalNode { + readonly next?: OptionalNode | undefined + } + const Encoded: Schema.Codec = Schema.suspend(() => + Schema.Struct({ + next: Schema.optional(Encoded) + }) + ) + const Content = Encoded.annotate({ identifier: "Node" }) + const make = () => + Schema.toCodecJson( + Encoded.pipe( + Schema.decodeTo( + Content, + SchemaTransformation.transform({ + decode: (node) => node, + encode: (node) => node + }) + ) + ) + ) + const first = make() + const second = make() + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([first.ast, second.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/NodeJsonEncoding" }, + { $ref: "#/$defs/NodeJsonEncoding" } + ]) + assert.deepStrictEqual(output.definitions, { + Objects_: { + type: "object", + properties: { + next: { + anyOf: [ + { $ref: "#/$defs/Objects_" }, + { type: "null" } + ] + } + }, + additionalProperties: false + }, + NodeJsonEncoding: { $ref: "#/$defs/Objects_" } + }) + }) + + it("does not deduplicate fallback definitions with distinct recursive dependencies", () => { + interface RequiredNode { + readonly next: RequiredNode + } + const Content: Schema.Codec = Schema.Struct({ + next: Schema.suspend(() => Content) + }).annotate({ identifier: "Node" }) + const make = () => { + const Encoded: Schema.Codec = Schema.Struct({ + next: Schema.suspend(() => Encoded) + }) + return Schema.toCodecJson( + Encoded.pipe( + Schema.decodeTo( + Content, + SchemaTransformation.transform({ + decode: (node) => node, + encode: (node) => node + }) + ) + ) + ) + } + const first = make() + const second = make() + const output = SchemaRepresentation.toJsonSchemaMultiDocument( + SchemaRepresentation.toRepresentations([first.ast, second.ast]) + ) + + assert.deepStrictEqual(output.schemas, [ + { $ref: "#/$defs/NodeJsonEncoding" }, + { $ref: "#/$defs/NodeJsonEncoding1" } + ]) + assert.deepStrictEqual(output.definitions, { + Suspend_: { + type: "object", + properties: { + next: { $ref: "#/$defs/Suspend_" } + }, + required: ["next"], + additionalProperties: false + }, + NodeJsonEncoding: { + type: "object", + properties: { + next: { $ref: "#/$defs/Suspend_" } + }, + required: ["next"], + additionalProperties: false + }, + Suspend_1: { + type: "object", + properties: { + next: { $ref: "#/$defs/Suspend_1" } + }, + required: ["next"], + additionalProperties: false + }, + NodeJsonEncoding1: { + type: "object", + properties: { + next: { $ref: "#/$defs/Suspend_1" } + }, + required: ["next"], + additionalProperties: false + } + }) + }) + it("should handle multiple schemas", () => { const A = Schema.String.annotate({ identifier: "A", description: "a" }) const B = Schema.String.annotate({ identifier: "B", description: "b" }) diff --git a/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts index acc3468198e..e7688d070b5 100644 --- a/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" describe("OpenApi representation v2 consumer", () => { it("uses canonical JSON codecs for additional declaration schemas", () => { @@ -26,6 +26,45 @@ describe("OpenApi representation v2 consumer", () => { ) }) + it("deduplicates JSON encoding definitions across regular and SSE responses", () => { + const Content = Schema.Struct({ text: Schema.String }).annotate({ identifier: "Tool.Content" }) + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.get("content", "/content", { + success: Schema.fromJsonString(Content) + }), + HttpApiEndpoint.get("stream", "/stream", { + success: HttpApiSchema.StreamSse({ data: Content }) + }) + ) + ) + const spec = OpenApi.fromApi(Api) + + assert.deepStrictEqual(spec.components.schemas, { + "Tool.ContentJsonEncoding": { + type: "string", + contentMediaType: "application/json" + } + }) + assert.deepStrictEqual( + spec.paths["/content"]?.get?.responses[200]?.content?.["application/json"]?.schema, + { $ref: "#/components/schemas/Tool.ContentJsonEncoding" } + ) + assert.deepStrictEqual( + spec.paths["/stream"]?.get?.responses[200]?.content?.["text/event-stream"]?.schema, + { + type: "object", + properties: { + id: { anyOf: [{ type: "string" }, { type: "null" }] }, + event: { type: "string" }, + data: { $ref: "#/components/schemas/Tool.ContentJsonEncoding" } + }, + required: ["id", "event", "data"], + additionalProperties: false + } + ) + }) + it("projects request and response schemas to the encoded side", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( diff --git a/packages/effect/typetest/schema/FromJsonSchema.tst.ts b/packages/effect/typetest/schema/FromJsonSchema.tst.ts index 182a08a2d98..9e081e0fc6f 100644 --- a/packages/effect/typetest/schema/FromJsonSchema.tst.ts +++ b/packages/effect/typetest/schema/FromJsonSchema.tst.ts @@ -10,10 +10,7 @@ describe("JSON Schema importer", () => { const fromMultiDocument: ( document: JsonSchema.MultiDocument<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions - ) => SchemaRepresentation.SchemaMultiDocument = SchemaRepresentation.fromJsonSchemaMultiDocument - const fromSchemaMultiDocument: ( - document: SchemaRepresentation.SchemaMultiDocument - ) => SchemaRepresentation.MultiDocument = SchemaRepresentation.fromSchemaMultiDocument + ) => readonly [Schema.Top, ...Array] = SchemaRepresentation.fromJsonSchemaMultiDocument expect(fromDocument).type.toBe< ( document: JsonSchema.Document<"draft-2020-12">, @@ -24,10 +21,7 @@ describe("JSON Schema importer", () => { ( document: JsonSchema.MultiDocument<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions - ) => SchemaRepresentation.SchemaMultiDocument - >() - expect(fromSchemaMultiDocument).type.toBe< - (document: SchemaRepresentation.SchemaMultiDocument) => SchemaRepresentation.MultiDocument + ) => readonly [Schema.Top, ...Array] >() }) diff --git a/packages/effect/typetest/schema/SchemaRepresentation.tst.ts b/packages/effect/typetest/schema/SchemaRepresentation.tst.ts index 0ed61856844..6040e7abc7e 100644 --- a/packages/effect/typetest/schema/SchemaRepresentation.tst.ts +++ b/packages/effect/typetest/schema/SchemaRepresentation.tst.ts @@ -11,9 +11,6 @@ describe("SchemaRepresentation persisted wire", () => { asts: readonly [SchemaAST.AST, ...Array] ) => SchemaRepresentation.MultiDocument >() - expect(SchemaRepresentation.fromSchemaMultiDocument).type.toBe< - (document: SchemaRepresentation.SchemaMultiDocument) => SchemaRepresentation.MultiDocument - >() }) it("keeps projection explicit for single and multi documents", () => { diff --git a/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts b/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts index e3ac80c1b65..39273f44e95 100644 --- a/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts +++ b/packages/effect/typetest/schema/SchemaRepresentationReviver.tst.ts @@ -62,7 +62,7 @@ describe("SchemaRepresentation revivers", () => { ( document: SchemaRepresentation.MultiDocument, options: { readonly revivers: ReadonlyArray } - ) => SchemaRepresentation.SchemaMultiDocument + ) => readonly [Schema.Top, ...Array] >() const document = SchemaRepresentation.fromJson({ representation: { _tag: "String", checks: [] }, references: {} }) diff --git a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts index e52537cfdc6..56efabc186d 100644 --- a/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts +++ b/packages/tools/openapi-generator/src/JsonSchemaGenerator.ts @@ -220,10 +220,9 @@ function makeWithRepresentation() { return options?.onEnter === undefined ? out : options.onEnter(out) } } + const rootSchemas = SchemaRepresentation.fromJsonSchemaMultiDocument(document, importerOptions) const codeDocument = SchemaRepresentation.toCodeDocument( - SchemaRepresentation.fromSchemaMultiDocument( - SchemaRepresentation.fromJsonSchemaMultiDocument(document, importerOptions) - ) + SchemaRepresentation.toRepresentations(Arr.map(rootSchemas, (schema) => schema.ast)) ) return { diff --git a/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts b/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts index 1075be53dcb..1b4a6aab266 100644 --- a/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts +++ b/packages/tools/openapi-generator/test/JsonSchemaGenerator.test.ts @@ -144,15 +144,15 @@ export const A = B const result = generator.generate("openapi-3.1", definitions, false) const recursiveDeclaration = - "export const ResourcesNetworkCardSRIOV = Schema.suspend((): Schema.Codec => __recursive_ResourcesNetworkCardSRIOV)" + "export const ResourcesNetworkCard = Schema.suspend((): Schema.Codec => __recursive_ResourcesNetworkCard)" expect(result).toContain(recursiveDeclaration) - expect(result).toContain("const __recursive_ResourcesNetworkCardSRIOV =") + expect(result).toContain("const __recursive_ResourcesNetworkCard =") expect(result.indexOf(recursiveDeclaration)).toBeLessThan( - result.indexOf("export const ResourcesNetworkCard =") + result.indexOf("export const ResourcesNetworkCardSRIOV =") ) - expect(result.indexOf("export const ResourcesNetworkCard =")).toBeLessThan( - result.indexOf("const __recursive_ResourcesNetworkCardSRIOV =") + expect(result.indexOf("export const ResourcesNetworkCardSRIOV =")).toBeLessThan( + result.indexOf("const __recursive_ResourcesNetworkCard =") ) }) diff --git a/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts b/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts index 56731619655..02a9233bdae 100644 --- a/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts +++ b/packages/tools/openapi-generator/test/JsonSchemaGeneratorRepresentation.test.ts @@ -1,8 +1,8 @@ import * as JsonSchemaGenerator from "@effect/openapi-generator/JsonSchemaGenerator" import { assert, describe, it } from "@effect/vitest" -describe("JsonSchemaGenerator representation v2", () => { - it("keeps definitions out of roots and emits unreachable definitions", () => { +describe("JsonSchemaGenerator representation", () => { + it("emits only reachable definitions", () => { const generator = JsonSchemaGenerator.make() generator.addSchema("Root", { $ref: "#/components/schemas/Shared" }) @@ -16,8 +16,6 @@ describe("JsonSchemaGenerator representation v2", () => { `// non-recursive definitions export type Shared = string export const Shared = Schema.String.annotate({ "identifier": "Shared" }) -export type Unused = boolean -export const Unused = Schema.Boolean.annotate({ "identifier": "Unused" }) // schemas export type Root = Shared export const Root = Shared