From 2fd3fee022e9024bd232a9c96c6ea24eb4164d29 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Thu, 23 Jul 2026 12:39:14 +0800 Subject: [PATCH 1/5] fix(openapi): return fresh cached specs --- .changeset/fix-openapi-from-api-cache-copy.md | 5 ++++ .../effect/src/unstable/httpapi/OpenApi.ts | 23 +++++++++++++++++-- .../test/unstable/httpapi/OpenApi.test.ts | 20 ++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-openapi-from-api-cache-copy.md diff --git a/.changeset/fix-openapi-from-api-cache-copy.md b/.changeset/fix-openapi-from-api-cache-copy.md new file mode 100644 index 00000000000..68a5c04f448 --- /dev/null +++ b/.changeset/fix-openapi-from-api-cache-copy.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Return fresh OpenAPI specs from cached `OpenApi.fromApi` calls. diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index 7c34bb73559..cbb797c807b 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -224,6 +224,25 @@ const compileSchemas: CompileSchemas = (asts) => ) ) +const cloneOpenAPISpec = (value: A): A => { + if (Array.isArray(value)) { + return value.map(cloneOpenAPISpec) as A + } + if (value !== null && typeof value === "object") { + const out: Record = {} + for (const key of Object.keys(value)) { + Object.defineProperty(out, key, { + value: cloneOpenAPISpec((value as Record)[key]), + enumerable: true, + configurable: true, + writable: true + }) + } + return out as A + } + return value +} + /** * This function checks if a given tag exists within the provided context. If * the tag is present, it retrieves the associated value and applies the given @@ -273,7 +292,7 @@ function fromApiWith( ): OpenAPISpec { const cached = cache.get(api) if (cached !== undefined) { - return cached + return cloneOpenAPISpec(cached) } let spec: OpenAPISpec = { openapi: "3.1.0", @@ -665,7 +684,7 @@ function fromApiWith( cache.set(api, spec) - return spec + return cloneOpenAPISpec(spec) } type ResponseBodies = Map< diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 72e2f2e97fc..12d687cefef 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -70,6 +70,26 @@ const makeSecurityApi = ( ) describe("OpenApi", () => { + it("returns fresh spec instances when using the cache", () => { + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("test").add( + HttpApiEndpoint.get("get", "/resource") + ) + ) + + const first = OpenApi.fromApi(Api) + first.info.title = "mutated" + first.paths["/resource"]!.get!.summary = "mutated" + + const second = OpenApi.fromApi(Api) + + assert.notStrictEqual(first, second) + assert.notStrictEqual(first.info, second.info) + assert.notStrictEqual(first.paths["/resource"]!.get, second.paths["/resource"]!.get) + assert.strictEqual(second.info.title, "Api") + assert.isUndefined(second.paths["/resource"]!.get!.summary) + }) + it("preserves every declared payload content type for normalized equivalents", () => { const profileA = "Application/Vnd.Effect+JSON; Profile=A" const profileB = "application/vnd.effect+json; profile=b" From 7a0d740393e5be24802bd1a6a2641326b91a91db Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 27 Jul 2026 11:46:51 +0800 Subject: [PATCH 2/5] test(openapi): cover cached spec mutation --- packages/effect/test/unstable/httpapi/OpenApi.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 12d687cefef..0e5830e3167 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -88,6 +88,13 @@ describe("OpenApi", () => { assert.notStrictEqual(first.paths["/resource"]!.get, second.paths["/resource"]!.get) assert.strictEqual(second.info.title, "Api") assert.isUndefined(second.paths["/resource"]!.get!.summary) + + second.info.title = "mutated again" + second.paths["/resource"]!.get!.summary = "mutated again" + const third = OpenApi.fromApi(Api) + + assert.strictEqual(third.info.title, "Api") + assert.isUndefined(third.paths["/resource"]!.get!.summary) }) it("preserves every declared payload content type for normalized equivalents", () => { From 5ff5ddf5d9c83832a9cde0d5c8795e30ab1ec830 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Tue, 11 Aug 2026 16:05:48 +0800 Subject: [PATCH 3/5] test(openapi): expect cached spec copies --- .../test/unstable/httpapi/OpenApiRepresentation.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts index fb9b62c6b54..b7bfe385672 100644 --- a/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApiRepresentation.test.ts @@ -110,7 +110,7 @@ describe("OpenApi representation v2 consumer", () => { ) }) - it("shares definitions and caches by API identity", () => { + it("shares definitions and returns cached copies by API identity", () => { const Shared = Schema.Struct({ value: Schema.FiniteFromString }).annotate({ identifier: "Shared" }) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( @@ -122,8 +122,10 @@ describe("OpenApi representation v2 consumer", () => { ) const first = OpenApi.fromApi(Api) + const second = OpenApi.fromApi(Api) - assert.strictEqual(OpenApi.fromApi(Api), first) + assert.notStrictEqual(second, first) + assert.deepStrictEqual(second, first) assert.deepStrictEqual(first.components.schemas.Shared, { type: "object", properties: { value: { type: "string" } }, From 20ac92f28bde3b30e7284cc63cd8a0c1285479bd Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 11 Aug 2026 14:51:42 +0200 Subject: [PATCH 4/5] refactor(openapi): reuse safe record assignment when cloning Use the existing internal record helper while recursively copying OpenAPI objects. This preserves __proto__ as an own enumerable property without duplicating descriptor logic for every key. --- packages/effect/src/unstable/httpapi/OpenApi.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index 4c884222218..4d83e3a6ca5 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -234,12 +234,7 @@ const cloneOpenAPISpec = (value: A): A => { if (value !== null && typeof value === "object") { const out: Record = {} for (const key of Object.keys(value)) { - Object.defineProperty(out, key, { - value: cloneOpenAPISpec((value as Record)[key]), - enumerable: true, - configurable: true, - writable: true - }) + InternalRecord.assignProperty(out, key, cloneOpenAPISpec((value as Record)[key])) } return out as A } From 8f76b220ec9c554c6b6a075713112a7c437fae16 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 11 Aug 2026 14:52:11 +0200 Subject: [PATCH 5/5] fix(openapi): isolate cached specs from annotation inputs Store a deep-cloned snapshot in the cache after generating a specification, then return the newly generated object directly. This prevents objects retained by Override or Transform annotations from mutating future cached results while preserving fresh mutable results for callers. Add regression coverage for mutation of an Override input. --- packages/effect/src/unstable/httpapi/OpenApi.ts | 4 ++-- .../effect/test/unstable/httpapi/OpenApi.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/unstable/httpapi/OpenApi.ts b/packages/effect/src/unstable/httpapi/OpenApi.ts index 4d83e3a6ca5..c7ff5bff44f 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -699,9 +699,9 @@ function fromApiWith( spec = transformFn(spec) as OpenAPISpec }) - cache.set(api, spec) + cache.set(api, cloneOpenAPISpec(spec)) - return cloneOpenAPISpec(spec) + return spec } type ResponseBodies = Map< diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index 7eb20ce2d27..c0de5e47d22 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -97,6 +97,18 @@ describe("OpenApi", () => { assert.isUndefined(third.paths["/resource"]!.get!.summary) }) + it("isolates the cached spec from external override mutations", () => { + const info = { title: "Api", version: "1.0.0" } + const Api = HttpApi.make("Api").annotate(OpenApi.Override, { info }) + + OpenApi.fromApi(Api) + info.title = "mutated" + + const cached = OpenApi.fromApi(Api) + + assert.strictEqual(cached.info.title, "Api") + }) + it("preserves every declared payload content type for normalized equivalents", () => { const profileA = "Application/Vnd.Effect+JSON; Profile=A" const profileB = "application/vnd.effect+json; profile=b"