feat(openapi-envelope): update from_openapi handler to use httpEnvelope and CallError
Handler returns httpEnvelope(data, { statusCode, headers, contentType }) instead of raw response data.
HTTP errors throw CallError('EXECUTION_ERROR', ...) instead of plain Error.
Added tests for envelope wrapping and CallError behavior.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import * as Type from "@alkdev/typebox";
|
import * as Type from "@alkdev/typebox";
|
||||||
import { FromSchema } from "./from_schema.js";
|
import { FromSchema } from "./from_schema.js";
|
||||||
import { OperationType, type OperationSpec, type OperationHandler, type OperationContext } from "./types.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 {
|
export interface OpenAPIFS {
|
||||||
readFile(path: string): Promise<string>;
|
readFile(path: string): Promise<string>;
|
||||||
@@ -265,18 +267,24 @@ function createHTTPOperation(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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") || "";
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
|
let data: unknown;
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
return response.json();
|
data = await response.json();
|
||||||
} else if (contentType.includes("text/")) {
|
} else if (contentType.includes("text/")) {
|
||||||
return response.text();
|
data = await response.text();
|
||||||
} else {
|
} else {
|
||||||
return response.arrayBuffer();
|
data = await response.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return httpEnvelope(data, {
|
||||||
|
statusCode: response.status,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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 { FromOpenAPI } from "../src/from_openapi.js";
|
||||||
import { OperationType } from "../src/types.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";
|
import { Value } from "@alkdev/typebox/value";
|
||||||
|
|
||||||
const simpleSpec = {
|
const simpleSpec = {
|
||||||
@@ -191,4 +193,206 @@ describe("FromOpenAPI", () => {
|
|||||||
const ops = FromOpenAPI(specWithRef as any, config);
|
const ops = FromOpenAPI(specWithRef as any, config);
|
||||||
expect(ops.length).toBe(1);
|
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<string, string>; 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<string, string>; 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<string, string>; 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<string, string>", 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<string, string> };
|
||||||
|
expect(meta.headers["content-type"]).toBe("application/json");
|
||||||
|
expect(meta.headers["x-request-id"]).toBe("req-123");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user