import { assert, describe, it } from "@effect/vitest" import { type Context, Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi" class HeaderAuthA extends HttpApiMiddleware.Service()("HeaderAuthA", { security: { auth: HttpApiSecurity.apiKey({ key: "x-api-key" }) } }) {} class HeaderAuthB extends HttpApiMiddleware.Service()("HeaderAuthB", { security: { auth: HttpApiSecurity.apiKey({ key: "x-api-key" }) } }) {} class HeaderAuthUpper extends HttpApiMiddleware.Service()("HeaderAuthUpper", { security: { auth: HttpApiSecurity.apiKey({ key: "X-API-Key" }) } }) {} class QueryAuth extends HttpApiMiddleware.Service()("QueryAuth", { security: { auth: HttpApiSecurity.apiKey({ key: "api_key", in: "query" }) } }) {} class TokenAuthUpper extends HttpApiMiddleware.Service()("TokenAuthUpper", { security: { auth: HttpApiSecurity.http({ scheme: "Token" }) } }) {} class TokenAuthLower extends HttpApiMiddleware.Service()("TokenAuthLower", { security: { auth: HttpApiSecurity.http({ scheme: "token" }) } }) {} class ProtoHeaderAuth extends HttpApiMiddleware.Service()("ProtoHeaderAuth", { security: { ["__proto__"]: HttpApiSecurity.apiKey({ key: "x-api-key" }) } }) {} class ProtoQueryAuth extends HttpApiMiddleware.Service()("ProtoQueryAuth", { security: { ["__proto__"]: HttpApiSecurity.apiKey({ key: "api_key", in: "query" }) } }) {} const makeSecurityApi = ( first: Context.Key, second: Context.Key ) => HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/first").middleware(first), HttpApiEndpoint.get("second", "/second").middleware(second) ) ) describe("OpenApi", () => { 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" const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.post("create", "/create", { payload: [ Schema.Struct({ a: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileA })), Schema.Struct({ b: Schema.String }).pipe(HttpApiSchema.asJson({ contentType: profileB })) ] }) ) ) const spec = OpenApi.fromApi(Api) const content = spec.paths["/create"]?.post?.requestBody?.content assert.isDefined(content) assert.property(content, profileA) assert.property(content, profileB) assert.deepStrictEqual(content[profileA]?.schema, { type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: false }) assert.deepStrictEqual(content[profileB]?.schema, { type: "object", properties: { b: { type: "string" } }, required: ["b"], additionalProperties: false }) }) it("emits buffered and stream successes with the same status", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("chat", "/chat", { success: [ Schema.Struct({ message: Schema.String }), HttpApiSchema.StreamSse({ events: Schema.Struct({ event: Schema.String, data: Schema.String }), error: Schema.Struct({ reason: Schema.String }) }) ] }) ) ) const spec = OpenApi.fromApi(Api) const content = spec.paths["/chat"]?.get?.responses[200]?.content assert.isNotNull(content) assert.property(content, "application/json") assert.property(content, "text/event-stream") const streamExtension = content?.["text/event-stream"]?.["x-effect-stream"] assert.isNotNull(streamExtension) if (streamExtension?.encoding !== "sse") { throw new Error("Expected SSE stream extension") } assert.strictEqual( streamExtension.failureEvent, "effect/httpapi/stream/failure" ) assert.property(streamExtension, "causeSchema") assert.property(streamExtension, "errorSchema") }) it("preserves the data schema identifier for SSE streams", () => { const Event = Schema.Struct({ kind: Schema.String, payload: Schema.String }).annotate({ identifier: "MyEvent" }) const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("stream", "/stream", { success: [HttpApiSchema.StreamSse({ data: Event })] }) ) ) const spec = OpenApi.fromApi(Api) const schemas = spec.components?.schemas // The decoded data schema keeps its identifier. assert.deepStrictEqual(schemas?.MyEvent, { type: "object", properties: { kind: { type: "string" }, payload: { type: "string" } }, required: ["kind", "payload"], additionalProperties: false }) }) it("rejects duplicate method and path pairs", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/shared"), HttpApiEndpoint.get("second", "/shared") ) ) assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/shared/) }) it("rejects equivalent templated method and path pairs", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/users/:id"), HttpApiEndpoint.get("second", "/users/:userId") ) ) assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operation for GET \/users\/\{userId\}/) }) it("rejects duplicate operation identifiers", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Identifier, "shared"), HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Identifier, "shared") ) ) assert.throws(() => OpenApi.fromApi(Api), /Duplicate OpenAPI operationId: shared/) }) it("allows operations without identifiers", () => { const removeOperationId = (operation: Record) => { const transformed = { ...operation } delete transformed.operationId return transformed } const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/first").annotate(OpenApi.Transform, removeOperationId), HttpApiEndpoint.get("second", "/second").annotate(OpenApi.Transform, removeOperationId) ) ) const spec = OpenApi.fromApi(Api) const first = spec.paths["/first"]?.get const second = spec.paths["/second"]?.get assert.isDefined(first) assert.isDefined(second) assert.isFalse(Object.hasOwn(first, "operationId")) assert.isFalse(Object.hasOwn(second, "operationId")) }) it("rejects incompatible security schemes with the same name", () => { const Api = makeSecurityApi(HeaderAuthA, QueryAuth) assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: auth/) }) it("deduplicates equivalent security schemes with the same name", () => { const Api = makeSecurityApi(HeaderAuthA, HeaderAuthB) const spec = OpenApi.fromApi(Api) assert.deepStrictEqual(spec.components.securitySchemes.auth, { type: "apiKey", name: "x-api-key", in: "header" }) }) it("deduplicates API key header names case-insensitively", () => { const Api = makeSecurityApi(HeaderAuthUpper, HeaderAuthA) const spec = OpenApi.fromApi(Api) assert.deepStrictEqual(spec.components.securitySchemes.auth, { type: "apiKey", name: "X-API-Key", in: "header" }) }) it("deduplicates HTTP security schemes case-insensitively", () => { const Api = makeSecurityApi(TokenAuthUpper, TokenAuthLower) const spec = OpenApi.fromApi(Api) assert.deepStrictEqual(spec.components.securitySchemes.auth, { type: "http", scheme: "Token" }) }) it("stores __proto__ security scheme names as own properties", () => { const Api = HttpApi.make("Api").add( HttpApiGroup.make("test").add( HttpApiEndpoint.get("first", "/first").middleware(ProtoHeaderAuth) ) ) const spec = OpenApi.fromApi(Api) const schemes = spec.components.securitySchemes assert.isTrue(Object.hasOwn(schemes, "__proto__")) assert.deepStrictEqual(Object.keys(schemes), ["__proto__"]) assert.strictEqual(Object.getPrototypeOf(schemes), Object.prototype) assert.deepStrictEqual(schemes["__proto__"], { type: "apiKey", name: "x-api-key", in: "header" }) }) it("rejects incompatible __proto__ security schemes", () => { const Api = makeSecurityApi(ProtoHeaderAuth, ProtoQueryAuth) assert.throws(() => OpenApi.fromApi(Api), /Conflicting OpenAPI security scheme: __proto__/) }) })