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..0e5830e3167 100644 --- a/packages/effect/test/unstable/httpapi/OpenApi.test.ts +++ b/packages/effect/test/unstable/httpapi/OpenApi.test.ts @@ -70,6 +70,33 @@ 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("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"