Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/httpapi-status-key-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

unstable/httpapi HttpApiSchema: carry `httpApiStatus` in the key context so a named schema reused with `status()` keeps one representation identity — one OpenAPI component referenced by every status — instead of throwing `Duplicate identifier`
5 changes: 5 additions & 0 deletions .changeset/representation-annotated-container-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

SchemaRepresentation: compare container slots structurally when two ASTs carry the same annotations object, so a derivation of a context-only copy (e.g. the JSON codec of a schema containing `Unknown` reused with `HttpApiSchema.status`) no longer publishes duplicate components for one declared identifier
32 changes: 31 additions & 1 deletion packages/effect/src/internal/schema/toRepresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function fromASTs(
const anonymousReferences = new Map<SchemaAST.AST, string>()
const referenceOwners = new Map<string, SchemaAST.AST>()
const valueIds = new Map<unknown, number>()
const compositeIds = new Map<string, number>()
const canonicalByKey = new Map<string, SchemaAST.AST>()
let nextValueId = 0
const buildingReferences = new Set<string>()
Expand Down Expand Up @@ -95,10 +96,39 @@ function fromASTs(
return id
}

// Identity of a container slot on an annotated node: arrays and property/index
// signature wrappers are pure structure with no identity of their own, so they
// compare by their contents. AST nodes (and any other value) keep reference identity.
function getSlotId(value: unknown): number {
let composite: string | undefined
if (globalThis.Array.isArray(value)) {
composite = `[${value.map(getSlotId).join(",")}]`
} else if (value instanceof SchemaAST.PropertySignature) {
composite = `ps:${getValueId(value.name)}:${getValueId(value.type)}`
} else if (value instanceof SchemaAST.IndexSignature) {
composite = `is:${getValueId(value.parameter)}:${getValueId(value.type)}`
} else {
return getValueId(value)
}
const existing = compositeIds.get(composite)
if (existing !== undefined) return existing
const id = nextValueId++
compositeIds.set(composite, id)
return id
}

function getIdentityKey(ast: SchemaAST.AST): string {
let identity = ast._tag
// Two ASTs holding the same annotations object are claims to one declared schema:
// rebuilds of it (e.g. a codec derivation of a context-only copy) allocate fresh
// container wrappers, and those must not read as a second schema. Anonymous nodes
// and nodes with different annotation objects keep pure reference identity, so
// referentially distinct declarations stay distinct.
const structural = ast.annotations !== undefined
for (const [key, value] of Object.entries(ast)) {
if (key !== "_tag" && key !== "context") identity += `:${getValueId(value)}`
if (key !== "_tag" && key !== "context") {
identity += `:${structural ? getSlotId(value) : getValueId(value)}`
}
}
return identity
}
Expand Down
20 changes: 17 additions & 3 deletions packages/effect/src/unstable/httpapi/HttpApiSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,16 @@ export type StatusLiteral = keyof typeof statusCodeByLiteral
*
* **Details**
*
* This is equivalent to calling `.annotate({ httpApiStatus: code })` on the
* This is equivalent to calling `.annotateKey({ httpApiStatus: code })` on the
* schema. You can pass either a numeric status code (for example, `201`) or a
* common literal name (for example, `"Created"`).
*
* The status is carried in the AST's key context rather than its annotations:
* the status describes how one endpoint uses the schema, not what the schema
* is, and context is excluded from representation identity. A named schema
* reused with different statuses therefore stays one schema — and one OpenAPI
* component — instead of forking into duplicate identifiers.
*
* @category schemas
* @since 4.0.0
*/
Expand All @@ -165,7 +171,7 @@ export function status(code: StatusLiteral): {
}
export function status(code: number | StatusLiteral) {
const statusCode = typeof code === "string" ? statusCodeByLiteral[code] : code
return <S extends Schema.Top>(self: S): S["Rebuild"] => self.annotate({ httpApiStatus: statusCode })
return <S extends Schema.Top>(self: S): S["Rebuild"] => self.annotateKey({ httpApiStatus: statusCode })
}

/**
Expand Down Expand Up @@ -667,7 +673,15 @@ export const isNoContent = (ast: SchemaAST.AST): boolean => {

const resolveHttpApiEncoding = SchemaAST.resolveAt<Encoding>("~httpApiEncoding")

const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
const resolveHttpApiStatusAnnotation = SchemaAST.resolveAt<number>("httpApiStatus")

// `status` stores the code in the key context (see `status` for why); the
// annotation fallback keeps declaration-site statuses like the `HttpApiError`
// classes working.
function resolveHttpApiStatus(ast: SchemaAST.AST): number | undefined {
const fromContext = ast.context?.annotations?.httpApiStatus
return typeof fromContext === "number" ? fromContext : resolveHttpApiStatusAnnotation(ast)
}

const defaultJsonEncoding: Encoding = {
_tag: "Json",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,26 @@ describe("SchemaRepresentation.toRepresentations", () => {
)
})

it("shares a named reference across context-only copies whose derivation rebuilds a container", () => {
const Widget = Schema.Struct({
id: Schema.String,
metadata: Schema.Unknown
}).annotate({ identifier: "Widget" })
const forked = Widget.annotateKey({ description: "context-only fork" })

const base = Schema.toCodecJson(Widget)
const fork = Schema.toCodecJson(forked)
assert.notStrictEqual(base.ast, fork.ast)

const document = SchemaRepresentation.toRepresentations([base.ast, fork.ast])

assert.deepStrictEqual(Object.keys(document.references), ["Widget"])
assert.deepStrictEqual(document.representations, [
{ _tag: "Reference", $ref: "Widget" },
{ _tag: "Reference", $ref: "Widget" }
])
})

it("suffixes referentially distinct ASTs with equal representations", () => {
const first = Schema.String.annotate({ identifier: "Value" })
const second = Schema.String.annotate({ identifier: "Value" })
Expand Down
60 changes: 60 additions & 0 deletions packages/effect/test/unstable/httpapi/OpenApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,64 @@ describe("OpenApi", () => {

assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/)
})

it("emits one component for a named schema reused with HttpApiSchema.status", () => {
const Widget = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Widget" })

const Api = HttpApi.make("Api").add(
HttpApiGroup.make("widgets").add(
HttpApiEndpoint.get("get", "/widgets/:id", {
params: { id: Schema.String },
success: Widget
}),
HttpApiEndpoint.post("create", "/widgets", {
success: Widget.pipe(HttpApiSchema.status(201))
})
)
)

const spec = OpenApi.fromApi(Api)
const schemaNames = Object.keys(spec.components.schemas).filter((name) => name.startsWith("Widget"))

assert.deepStrictEqual(schemaNames, ["Widget"])
assert.deepStrictEqual(
spec.paths["/widgets/{id}"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema,
{ $ref: "#/components/schemas/Widget" }
)
assert.deepStrictEqual(
spec.paths["/widgets"]?.post?.responses?.["201"]?.content?.["application/json"]?.schema,
{ $ref: "#/components/schemas/Widget" }
)
})
it("emits one component for a named schema containing Unknown reused with HttpApiSchema.status", () => {
const Widget = Schema.Struct({
id: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Unknown)
}).annotate({ identifier: "Widget" })

const Api = HttpApi.make("Api").add(
HttpApiGroup.make("widgets").add(
HttpApiEndpoint.get("get", "/widgets/:id", {
params: { id: Schema.String },
success: Widget
}),
HttpApiEndpoint.post("create", "/widgets", {
success: Widget.pipe(HttpApiSchema.status(201))
})
)
)

const spec = OpenApi.fromApi(Api)
const schemaNames = Object.keys(spec.components.schemas).filter((name) => name.startsWith("Widget"))

assert.deepStrictEqual(schemaNames, ["Widget"])
assert.deepStrictEqual(
spec.paths["/widgets/{id}"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema,
{ $ref: "#/components/schemas/Widget" }
)
assert.deepStrictEqual(
spec.paths["/widgets"]?.post?.responses?.["201"]?.content?.["application/json"]?.schema,
{ $ref: "#/components/schemas/Widget" }
)
})
})
Loading