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 3b119b4188d..c7ff5bff44f 100644 --- a/packages/effect/src/unstable/httpapi/OpenApi.ts +++ b/packages/effect/src/unstable/httpapi/OpenApi.ts @@ -227,6 +227,20 @@ 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)) { + InternalRecord.assignProperty(out, key, cloneOpenAPISpec((value as Record)[key])) + } + 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 @@ -276,7 +290,7 @@ function fromApiWith( ): OpenAPISpec { const cached = cache.get(api) if (cached !== undefined) { - return cached + return cloneOpenAPISpec(cached) } let spec: OpenAPISpec = { openapi: "3.1.0", @@ -685,7 +699,7 @@ function fromApiWith( spec = transformFn(spec) as OpenAPISpec }) - cache.set(api, spec) + cache.set(api, cloneOpenAPISpec(spec)) return spec } diff --git a/packages/effect/test/unstable/httpapi/OpenApi.test.ts b/packages/effect/test/unstable/httpapi/OpenApi.test.ts index b1d1b88030b..c0de5e47d22 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -70,6 +70,45 @@ 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) + + 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("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" 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" } },