Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/client/__tests__/http-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Headers } from "node-fetch";
import { Buffer } from "node:buffer";
import OpenAPIClientAxios from "openapi-client-axios";
import { OpenAPIV3 } from "openapi-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand All @@ -7,6 +8,7 @@ import { HttpClient } from "../http-client";
// Mock the OpenAPIClientAxios initialization
const mockApi = {
getPet: vi.fn(),
downloadFile: vi.fn(),
testOperation: vi.fn(),
complexOperation: vi.fn(),
};
Expand Down Expand Up @@ -95,6 +97,162 @@ describe("HttpClient", () => {
expect(response.headers.get("content-type")).toBe("application/json");
});

it("requests binary responses as arraybuffers", async () => {
const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: "get",
path: "/files/{fileId}",
operationId: "downloadFile",
parameters: [{ name: "fileId", in: "path", required: true, schema: { type: "string" } }],
responses: {
"200": {
description: "File contents",
content: {
"application/octet-stream": {
schema: { type: "string", format: "binary" },
},
},
},
},
};
const fileBytes = Buffer.from("file contents");
mockApi.downloadFile.mockResolvedValueOnce({
data: fileBytes,
status: 200,
headers: { "content-type": "application/pdf" },
});

const response = await client.executeOperation(operation, { fileId: "file-1" });

expect(mockApi.downloadFile).toHaveBeenCalledWith({ fileId: "file-1" }, undefined, {
headers: { "Content-Type": null },
responseType: "arraybuffer",
});
expect(response.data).toBe(fileBytes);
expect(response.headers.get("content-type")).toBe("application/pdf");
});

it("detects binary responses through local response and schema references", async () => {
const spec: OpenAPIV3.Document = {
openapi: "3.0.0",
info: { title: "Test API", version: "1.0.0" },
paths: {
"/files/{fileId}": {
get: {
operationId: "downloadFile",
responses: { "200": { $ref: "#/components/responses/FileResponse" } },
},
},
},
components: {
responses: {
FileResponse: {
description: "File contents",
content: { "application/pdf": { schema: { $ref: "#/components/schemas/BinaryFile" } } },
},
},
schemas: { BinaryFile: { type: "string", format: "binary" } },
},
};
const operation = spec.paths["/files/{fileId}"]?.get as OpenAPIV3.OperationObject & {
method: string;
path: string;
};
const referencedClient = new HttpClient({ baseUrl: "https://api.example.com" }, spec);
mockApi.downloadFile.mockResolvedValueOnce({ data: Buffer.from("pdf"), status: 200, headers: {} });

await referencedClient.executeOperation(operation, { fileId: "file-1" });

expect(mockApi.downloadFile).toHaveBeenCalledWith(
{ fileId: "file-1" },
undefined,
expect.objectContaining({ responseType: "arraybuffer" }),
);
});

it("decodes successful JSON variants from binary operations", async () => {
const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: "get",
path: "/files/{fileId}",
operationId: "downloadFile",
responses: {
"200": {
description: "File or metadata",
content: {
"application/octet-stream": { schema: { type: "string", format: "binary" } },
"application/json": { schema: { type: "object" } },
},
},
},
};
const metadata = { status: "processing" };
mockApi.downloadFile.mockResolvedValueOnce({
data: Buffer.from(JSON.stringify(metadata)),
status: 200,
headers: { "content-type": "application/json" },
});

const response = await client.executeOperation(operation, { fileId: "file-1" });

expect(response.data).toEqual(metadata);
});

it("decodes structured JSON errors from binary operations", async () => {
const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: "get",
path: "/files/{fileId}",
operationId: "downloadFile",
responses: {
"200": {
description: "File contents",
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
"404": { description: "File not found" },
},
};
const errorData = { code: "NOT_FOUND", message: "File not found" };
mockApi.downloadFile.mockRejectedValueOnce({
response: {
data: Buffer.from(JSON.stringify(errorData)),
status: 404,
statusText: "Not Found",
headers: { "content-type": "application/json; charset=utf-8" },
},
});

await expect(client.executeOperation(operation, { fileId: "missing" })).rejects.toMatchObject({
status: 404,
data: errorData,
});
});

it("decodes text errors from ArrayBuffer responses", async () => {
const operation: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: "get",
path: "/files/{fileId}",
operationId: "downloadFile",
responses: {
"200": {
description: "File contents",
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
},
};
const errorBytes = Uint8Array.from(Buffer.from("download unavailable"));
mockApi.downloadFile.mockRejectedValueOnce({
response: {
data: errorBytes.buffer,
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "text/plain; charset=utf-8" },
},
});

await expect(client.executeOperation(operation, { fileId: "file-1" })).rejects.toMatchObject({
status: 503,
data: "download unavailable",
});
});

it("throws error when operation ID is missing", async () => {
const operationWithoutId: OpenAPIV3.OperationObject & { method: string; path: string } = {
method: "GET",
Expand Down
80 changes: 73 additions & 7 deletions src/client/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AxiosInstance } from "axios";
import FormData from "form-data";
import fs from "fs";
import { Headers } from "node-fetch";
import { Buffer } from "node:buffer";
import OpenAPIClientAxios from "openapi-client-axios";
import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
import { isFileUploadParameter } from "../openapi/file-upload";
Expand Down Expand Up @@ -32,8 +33,10 @@ export class HttpClientError extends Error {
export class HttpClient {
private api: Promise<AxiosInstance>;
private client: OpenAPIClientAxios;
private openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document;

constructor(config: HttpClientConfig, openApiSpec: OpenAPIV3.Document | OpenAPIV3_1.Document) {
this.openApiSpec = openApiSpec;
// @ts-expect-error OpenAPIClientAxios can be imported as default or named export, we handle both cases
this.client = new (OpenAPIClientAxios.default ?? OpenAPIClientAxios)({
definition: openApiSpec,
Expand Down Expand Up @@ -104,6 +107,72 @@ export class HttpClient {
return formData;
}

/**
* Whether an operation declares a binary success response. Axios otherwise
* decodes response bodies as text, which can corrupt downloaded file bytes.
*/
private hasBinaryResponse(operation: OpenAPIV3.OperationObject): boolean {
const successStatuses = ["200", "201", "202", "203", "204", "206"];

return successStatuses.some((status) => {
const response = operation.responses?.[status];
if (!response) return false;
const responseObject =
"$ref" in response ? this.resolveLocalRef<OpenAPIV3.ResponseObject>(response.$ref) : response;
if (!responseObject) return false;

return Object.entries(responseObject.content ?? {}).some(([mediaType, media]) => {
const schema = media.schema;
const schemaObject =
schema && "$ref" in schema ? this.resolveLocalRef<OpenAPIV3.SchemaObject>(schema.$ref) : schema;
const hasBinarySchema = schemaObject && !("$ref" in schemaObject) && schemaObject.format === "binary";

return mediaType === "application/octet-stream" || mediaType.startsWith("image/") || hasBinarySchema;
});
});
}

private resolveLocalRef<T>(ref: string): T | null {
if (!ref.startsWith("#/")) return null;

let current: unknown = this.openApiSpec;
for (const rawPart of ref.slice(2).split("/")) {
const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
if (!current || typeof current !== "object" || !(part in current)) return null;
current = (current as Record<string, unknown>)[part];
}
return current as T;
}

/**
* Axios returns every response as bytes when responseType is arraybuffer,
* including JSON and text errors from binary download operations.
*/
private decodeTextData(data: unknown, headers: Headers): unknown {
let bytes: Buffer | null = null;
if (Buffer.isBuffer(data)) {
bytes = data;
} else if (data instanceof ArrayBuffer) {
bytes = Buffer.from(data);
} else if (ArrayBuffer.isView(data)) {
bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
if (!bytes) return data;

const contentType = headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("json") && !contentType.startsWith("text/")) return data;

const text = bytes.toString("utf8");
if (contentType.includes("json")) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
return text;
}

/**
* Execute an OpenAPI operation
*/
Expand Down Expand Up @@ -165,6 +234,7 @@ export class HttpClient {
headers: {
...headers,
},
...(this.hasBinaryResponse(operation) ? { responseType: "arraybuffer" as const } : {}),
};

// first argument is url parameters, second is body parameters
Expand All @@ -179,7 +249,7 @@ export class HttpClient {
});

return {
data: response.data,
data: this.decodeTextData(response.data, responseHeaders) as T,
status: response.status,
headers: responseHeaders,
};
Expand All @@ -190,13 +260,9 @@ export class HttpClient {
Object.entries(error.response.headers).forEach(([key, value]) => {
if (value) headers.append(key, value.toString());
});
const data = this.decodeTextData(error.response.data, headers);

throw new HttpClientError(
error.response.statusText || "Request failed",
error.response.status,
error.response.data,
headers,
);
throw new HttpClientError(error.response.statusText || "Request failed", error.response.status, data, headers);
}
throw error;
}
Expand Down
Loading