diff --git a/src/from_openapi.ts b/src/from_openapi.ts index cac3cf0..207d2a6 100644 --- a/src/from_openapi.ts +++ b/src/from_openapi.ts @@ -1,6 +1,8 @@ import * as Type from "@alkdev/typebox"; import { FromSchema } from "./from_schema.js"; import { OperationType, type OperationSpec, type OperationHandler, type OperationContext } from "./types.js"; +import { CallError } from "./error.js"; +import { httpEnvelope } from "./response-envelope.js"; export interface OpenAPIFS { readFile(path: string): Promise; @@ -265,18 +267,24 @@ function createHTTPOperation( }); if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + throw new CallError("EXECUTION_ERROR", `HTTP ${response.status}: ${response.statusText}`); } const contentType = response.headers.get("Content-Type") || ""; - + let data: unknown; if (contentType.includes("application/json")) { - return response.json(); + data = await response.json(); } else if (contentType.includes("text/")) { - return response.text(); + data = await response.text(); } else { - return response.arrayBuffer(); + data = await response.arrayBuffer(); } + + return httpEnvelope(data, { + statusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + contentType, + }); }; return { diff --git a/test/from_openapi.test.ts b/test/from_openapi.test.ts index d24fe65..f320e2a 100644 --- a/test/from_openapi.test.ts +++ b/test/from_openapi.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { FromOpenAPI } from "../src/from_openapi.js"; import { OperationType } from "../src/types.js"; +import { CallError } from "../src/error.js"; +import { isResponseEnvelope } from "../src/response-envelope.js"; import { Value } from "@alkdev/typebox/value"; const simpleSpec = { @@ -191,4 +193,206 @@ describe("FromOpenAPI", () => { const ops = FromOpenAPI(specWithRef as any, config); expect(ops.length).toBe(1); }); +}); + +describe("FromOpenAPI handler envelope behavior", () => { + const config = { + namespace: "api", + baseUrl: "https://api.example.com", + }; + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns httpEnvelope with JSON response data", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "application/json", "X-Custom": "value" }), + json: async () => ({ users: ["alice", "bob"] }), + text: async () => "", + arrayBuffer: async () => new ArrayBuffer(0), + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const listUsers = ops.find((o) => o.name === "listUsers")!; + const result = await listUsers.handler({}, {} as any); + + expect(isResponseEnvelope(result)).toBe(true); + if (isResponseEnvelope(result)) { + expect(result.meta.source).toBe("http"); + expect(result.data).toEqual({ users: ["alice", "bob"] }); + const meta = result.meta as { statusCode: number; headers: Record; contentType: string }; + expect(meta.statusCode).toBe(200); + expect(meta.contentType).toBe("application/json"); + expect(meta.headers).toHaveProperty("x-custom", "value"); + } + }); + + it("returns httpEnvelope with text response data", async () => { + const textSpec = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: { + "/readme": { + get: { + operationId: "getReadme", + responses: { + "200": { + content: { "text/plain": { schema: { type: "string" } } }, + }, + }, + }, + }, + }, + }; + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "text/plain" }), + json: async () => ({}), + text: async () => "Hello, world!", + arrayBuffer: async () => new ArrayBuffer(0), + }); + + const ops = FromOpenAPI(textSpec as any, config); + const getReadme = ops.find((o) => o.name === "getReadme")!; + const result = await getReadme.handler({}, {} as any); + + expect(isResponseEnvelope(result)).toBe(true); + if (isResponseEnvelope(result)) { + expect(result.meta.source).toBe("http"); + expect(result.data).toBe("Hello, world!"); + const meta = result.meta as { statusCode: number; headers: Record; contentType: string }; + expect(meta.statusCode).toBe(200); + expect(meta.contentType).toBe("text/plain"); + } + }); + + it("returns httpEnvelope with arrayBuffer for binary responses", async () => { + const binaryData = new Uint8Array([1, 2, 3, 4]).buffer; + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "Content-Type": "application/octet-stream" }), + json: async () => ({}), + text: async () => "", + arrayBuffer: async () => binaryData, + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const listUsers = ops.find((o) => o.name === "listUsers")!; + const result = await listUsers.handler({}, {} as any); + + expect(isResponseEnvelope(result)).toBe(true); + if (isResponseEnvelope(result)) { + expect(result.meta.source).toBe("http"); + expect(result.data).toBe(binaryData); + const meta = result.meta as { statusCode: number; headers: Record; contentType: string }; + expect(meta.contentType).toBe("application/octet-stream"); + } + }); + + it("throws CallError on HTTP error status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + headers: new Headers(), + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const getUser = ops.find((o) => o.name === "getUser")!; + + try { + await getUser.handler({ id: "123" }, {} as any); + expect.fail("Expected CallError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe("EXECUTION_ERROR"); + expect((error as CallError).message).toContain("HTTP 404"); + } + }); + + it("throws CallError on 500 status", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: new Headers(), + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const listUsers = ops.find((o) => o.name === "listUsers")!; + + try { + await listUsers.handler({}, {} as any); + expect.fail("Expected CallError to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(CallError); + expect((error as CallError).code).toBe("EXECUTION_ERROR"); + expect((error as CallError).message).toContain("HTTP 500"); + } + }); + + it("includes statusCode from response in envelope", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + statusText: "Created", + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ id: "abc" }), + text: async () => "", + arrayBuffer: async () => new ArrayBuffer(0), + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const createUser = ops.find((o) => o.name === "createUser")!; + const result = await createUser.handler({ body: { name: "Alice" } }, {} as any); + + expect(isResponseEnvelope(result)).toBe(true); + if (isResponseEnvelope(result)) { + const meta = result.meta as { statusCode: number }; + expect(meta.statusCode).toBe(201); + } + }); + + it("converts response headers to Record", async () => { + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + headers.set("X-Request-Id", "req-123"); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers, + json: async () => ({ result: "ok" }), + text: async () => "", + arrayBuffer: async () => new ArrayBuffer(0), + }); + + const ops = FromOpenAPI(simpleSpec as any, config); + const listUsers = ops.find((o) => o.name === "listUsers")!; + const result = await listUsers.handler({}, {} as any); + + expect(isResponseEnvelope(result)).toBe(true); + if (isResponseEnvelope(result)) { + const meta = result.meta as { headers: Record }; + expect(meta.headers["content-type"]).toBe("application/json"); + expect(meta.headers["x-request-id"]).toBe("req-123"); + } + }); }); \ No newline at end of file