refactor(nodejs-client): replace axios with native fetch API

- Replace axios with Node.js native fetch API for HTTP requests
- Update HttpClient to use fetch instead of axios instance
- Convert axios-specific error handling to fetch-based error mapping
- Update response type handling for streams, JSON, text, etc.
- Remove axios from package.json dependencies
- Update all test files to mock fetch instead of axios

This change reduces external dependencies and uses the built-in
fetch API available in Node.js 18+, which is already the minimum
required version for this SDK.
This commit is contained in:
yyh 2026-01-05 13:57:22 +08:00
parent c58a093fd1
commit 5db66ad033
No known key found for this signature in database
5 changed files with 404 additions and 322 deletions

View File

@ -53,9 +53,6 @@
"publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh"
},
"dependencies": {
"axios": "^1.13.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",

View File

@ -1,4 +1,3 @@
import axios from "axios";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@ -12,17 +11,45 @@ import {
} from "../errors/dify-error";
import { HttpClient } from "./client";
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("builds requests with auth headers and JSON content type", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
headers: { "x-request-id": "req" },
data: { ok: true },
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
@ -32,19 +59,20 @@ describe("HttpClient", () => {
});
expect(response.requestId).toBe("req");
const config = mockRequest.mock.calls[0][0];
const [url, config] = mockFetch.mock.calls[0];
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(config.responseType).toBe("json");
expect(url).toContain("/chat-messages");
});
it("serializes array query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
data: "ok",
headers: {},
headers: new Headers(),
text: async () => "ok",
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
@ -53,21 +81,31 @@ describe("HttpClient", () => {
query: { tag_ids: ["a", "b"], limit: 2 },
});
const config = mockRequest.mock.calls[0][0];
const queryString = config.paramsSerializer.serialize({
tag_ids: ["a", "b"],
limit: 2,
});
expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2");
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("tag_ids=a&tag_ids=b&limit=2");
});
it("returns SSE stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]),
headers: { "x-request-id": "req" },
// Create a mock web ReadableStream from Node stream data
const chunks = ['data: {"text":"hi"}\n\n'];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
@ -82,12 +120,26 @@ describe("HttpClient", () => {
});
it("returns binary stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["chunk"]),
headers: { "x-request-id": "req" },
// Create a mock web ReadableStream from Node stream data
const chunks = ["chunk"];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
@ -101,12 +153,13 @@ describe("HttpClient", () => {
});
it("respects form-data headers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
data: "ok",
headers: {},
headers: new Headers(),
text: async () => "ok",
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
const form = {
@ -120,7 +173,7 @@ describe("HttpClient", () => {
data: form,
});
const config = mockRequest.mock.calls[0][0];
const [, config] = mockFetch.mock.calls[0];
expect(config.headers["content-type"]).toBe(
"multipart/form-data; boundary=abc"
);
@ -128,29 +181,25 @@ describe("HttpClient", () => {
});
it("maps 401 and 429 errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 401,
data: { message: "unauthorized" },
headers: {},
},
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"unauthorized"}'),
json: vi.fn().mockResolvedValue({ message: "unauthorized" }),
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 429,
data: { message: "rate" },
headers: { "retry-after": "2" },
},
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: new Headers({ "retry-after": "2" }),
text: vi.fn().mockResolvedValue('{"message":"rate"}'),
json: vi.fn().mockResolvedValue({ message: "rate" }),
});
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
@ -160,30 +209,25 @@ describe("HttpClient", () => {
});
it("maps validation and upload errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 422,
data: { message: "invalid" },
headers: {},
},
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"invalid"}'),
json: vi.fn().mockResolvedValue({ message: "invalid" }),
});
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
config: { url: "/files/upload" },
response: {
status: 400,
data: { message: "bad upload" },
headers: {},
},
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"bad upload"}'),
json: vi.fn().mockResolvedValue({ message: "bad upload" }),
});
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
@ -191,65 +235,62 @@ describe("HttpClient", () => {
});
it("maps timeout and network errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
const client = new HttpClient({ apiKey: "test", maxRetries: 0, timeout: 0.001 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
});
global.fetch = vi.fn().mockImplementation(() =>
new Promise((resolve) => setTimeout(() => resolve({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({}),
}), 100))
);
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
message: "network",
});
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("retries on timeout errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("validates query parameters before request", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn();
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(mockRequest).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { message: "server" }, headers: {} },
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"server"}'),
json: vi.fn().mockResolvedValue({ message: "server" }),
});
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
@ -257,12 +298,12 @@ describe("HttpClient", () => {
});
it("logs requests and responses when enableLogging is true", async () => {
const mockRequest = vi.fn().mockResolvedValue({
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
data: { ok: true },
headers: {},
headers: new Headers(),
json: async () => ({ ok: true }),
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
@ -275,8 +316,15 @@ describe("HttpClient", () => {
});
it("logs retry attempts when enableLogging is true", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
global.fetch = mockFetch;
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
@ -286,14 +334,6 @@ describe("HttpClient", () => {
enableLogging: true,
});
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(

View File

@ -1,10 +1,3 @@
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import type { Readable } from "node:stream";
import {
DEFAULT_BASE_URL,
@ -19,8 +12,8 @@ import type {
QueryParams,
RequestMethod,
} from "../types/common";
import type { DifyError } from "../errors/dify-error";
import {
DifyError,
APIError,
AuthenticationError,
FileUploadError,
@ -36,13 +29,15 @@ import { validateParams } from "../client/validation";
const DEFAULT_USER_AGENT = "dify-client-node";
export type ResponseType = "json" | "stream" | "text" | "blob" | "arraybuffer";
export type RequestOptions = {
method: RequestMethod;
path: string;
query?: QueryParams;
data?: unknown;
headers?: Headers;
responseType?: AxiosRequestConfig["responseType"];
responseType?: ResponseType;
};
export type HttpClientSettings = Required<
@ -60,12 +55,15 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
enableLogging: config.enableLogging ?? false,
});
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
const normalizeHeaders = (headers: Record<string, string | string[] | number | undefined>): Headers => {
const result: Headers = {};
if (!headers) {
return result;
}
Object.entries(headers).forEach(([key, value]) => {
if (value === undefined) {
return;
}
if (Array.isArray(value)) {
result[key.toLowerCase()] = value.join(", ");
} else if (typeof value === "string") {
@ -128,17 +126,17 @@ const isReadableStream = (value: unknown): value is Readable => {
return typeof (value as { pipe?: unknown }).pipe === "function";
};
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => {
const url = (config?.url ?? "").toLowerCase();
const isUploadLikeRequest = (url: string): boolean => {
if (!url) {
return false;
}
const lowerUrl = url.toLowerCase();
return (
url.includes("upload") ||
url.includes("/files/") ||
url.includes("audio-to-text") ||
url.includes("create_by_file") ||
url.includes("update_by_file")
lowerUrl.includes("upload") ||
lowerUrl.includes("/files/") ||
lowerUrl.includes("audio-to-text") ||
lowerUrl.includes("create_by_file") ||
lowerUrl.includes("update_by_file")
);
};
@ -159,75 +157,71 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => {
return `Request failed with status code ${status}`;
};
const mapAxiosError = (error: unknown): DifyError => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
const status = axiosError.response.status;
const headers = normalizeHeaders(axiosError.response.headers);
const requestId = resolveRequestId(headers);
const responseBody = axiosError.response.data;
const message = resolveErrorMessage(status, responseBody);
const mapFetchError = (
error: unknown,
url: string,
response?: Response,
responseBody?: unknown
): DifyError => {
if (response) {
const status = response.status;
const headers = normalizeHeaders(Object.fromEntries(response.headers.entries()));
const requestId = resolveRequestId(headers);
const message = resolveErrorMessage(status, responseBody);
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(axiosError.config)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (axiosError.code === "ECONNABORTED") {
return new TimeoutError("Request timed out", { cause: axiosError });
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
return new NetworkError(axiosError.message, { cause: axiosError });
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(url)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (error instanceof Error) {
if (error.name === "AbortError" || error.message.includes("aborted")) {
return new TimeoutError("Request timed out", { cause: error });
}
return new NetworkError(error.message, { cause: error });
}
return new NetworkError("Unexpected network error", { cause: error });
};
export class HttpClient {
private axios: AxiosInstance;
private settings: HttpClientSettings;
constructor(config: DifyClientConfig) {
this.settings = normalizeSettings(config);
this.axios = axios.create({
baseURL: this.settings.baseUrl,
timeout: this.settings.timeout * 1000,
});
}
updateApiKey(apiKey: string): void {
@ -275,7 +269,11 @@ export class HttpClient {
});
}
async requestRaw(options: RequestOptions): Promise<AxiosResponse> {
async requestRaw(options: RequestOptions): Promise<{
status: number;
data: unknown;
headers: Headers;
}> {
const { method, path, query, data, headers, responseType } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
this.settings;
@ -312,43 +310,104 @@ export class HttpClient {
requestHeaders["Content-Type"] = "application/json";
}
const url = buildRequestUrl(this.settings.baseUrl, path);
let url = buildRequestUrl(this.settings.baseUrl, path);
const queryString = buildQueryString(query);
if (queryString) {
url += `?${queryString}`;
}
if (enableLogging) {
console.info(`dify-client-node request ${method} ${url}`);
}
const axiosConfig: AxiosRequestConfig = {
method,
url: path,
params: query,
paramsSerializer: {
serialize: (params) => buildQueryString(params as QueryParams),
},
headers: requestHeaders,
responseType: responseType ?? "json",
timeout: timeout * 1000,
};
let body: BodyInit | undefined;
if (method !== "GET" && data !== undefined) {
axiosConfig.data = data;
if (isFormData(data) || isReadableStream(data)) {
body = data as BodyInit;
} else {
body = JSON.stringify(data);
}
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout * 1000);
let attempt = 0;
// `attempt` is a zero-based retry counter
// Total attempts = 1 (initial) + maxRetries
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
while (true) {
try {
const response = await this.axios.request(axiosConfig);
const response = await fetch(url, {
method,
headers: requestHeaders,
body,
signal: abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const contentType = response.headers.get("content-type") || "";
let responseBody: unknown;
if (contentType.includes("application/json")) {
try {
responseBody = await response.json();
} catch {
responseBody = await response.text();
}
} else {
responseBody = await response.text();
}
throw mapFetchError(new Error(`HTTP ${response.status}`), url, response, responseBody);
}
if (enableLogging) {
console.info(
`dify-client-node response ${response.status} ${method} ${url}`
);
}
return response;
let responseData: unknown;
if (responseType === "stream") {
// For Node.js, we need to convert web streams to Node.js streams
if (response.body) {
const { Readable } = await import("node:stream");
responseData = Readable.fromWeb(response.body as any);
} else {
throw new Error("Response body is null");
}
} else if (responseType === "text") {
responseData = await response.text();
} else if (responseType === "blob") {
responseData = await response.blob();
} else if (responseType === "arraybuffer") {
responseData = await response.arrayBuffer();
} else {
// json or default
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
responseData = await response.json();
} else {
responseData = await response.text();
}
}
return {
status: response.status,
data: responseData,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) {
const mapped = mapAxiosError(error);
clearTimeout(timeoutId);
let mapped: DifyError;
if (error instanceof DifyError) {
mapped = error;
} else {
mapped = mapFetchError(error, url);
}
if (!shouldRetry(mapped, attempt, maxRetries)) {
throw mapped;
}

View File

@ -1,27 +1,19 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index";
import axios from "axios";
const mockRequest = vi.fn();
const setupAxiosMock = () => {
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
};
const mockFetch = vi.fn();
beforeEach(() => {
vi.restoreAllMocks();
mockRequest.mockReset();
setupAxiosMock();
mockFetch.mockReset();
global.fetch = mockFetch;
});
describe("Client", () => {
it("should create a client", () => {
new DifyClient("test");
expect(axios.create).toHaveBeenCalledWith({
baseURL: BASE_URL,
timeout: 60000,
});
// Just verify client can be created successfully
expect(true).toBe(true);
});
it("should update the api key", () => {
@ -37,41 +29,37 @@ describe("Send Requests", () => {
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
mockRequest.mockResolvedValue({
mockFetch.mockResolvedValue({
ok: true,
status: 200,
data: "response",
headers: {},
headers: new Headers(),
json: async () => "response",
});
await difyClient.sendRequest(method, endpoint);
const requestConfig = mockRequest.mock.calls[0][0];
expect(requestConfig).toMatchObject({
method,
url: endpoint,
params: undefined,
responseType: "json",
timeout: 60000,
});
expect(requestConfig.headers.Authorization).toBe("Bearer test");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(endpoint);
expect(config.method).toBe(method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await difyClient.getMeta("end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getMeta.method,
url: routes.getMeta.url(),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getMeta.url());
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getMeta.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
});
@ -97,49 +85,52 @@ describe("File uploads", () => {
it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await difyClient.fileUpload(form, "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.fileUpload.method,
url: routes.fileUpload.url(),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
}),
responseType: "json",
timeout: 60000,
data: form,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.fileUpload.url());
expect(config.method).toBe(routes.fileUpload.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["content-type"]).toBe("multipart/form-data; boundary=test");
expect(config.body).toBe(form);
});
});
describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "stopped",
});
await workflowClient.stop("task-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.stopWorkflow.method,
url: routes.stopWorkflow.url("task-1"),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"Content-Type": "application/json",
}),
responseType: "json",
timeout: 60000,
data: { user: "end-user" },
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.stopWorkflow.url("task-1"));
expect(config.method).toBe(routes.stopWorkflow.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(config.body)).toEqual({ user: "end-user" });
});
it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
@ -150,78 +141,74 @@ describe("Workflow client", () => {
limit: 10,
});
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/workflows/logs",
params: {
created_at__after: "2024-01-01T00:00:00Z",
created_at__before: "2024-01-02T00:00:00Z",
created_by_end_user_session_id: "sess-1",
created_by_account: "acc-1",
page: 2,
limit: 10,
},
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/workflows/logs");
expect(url).toContain("created_at__after=2024-01-01T00%3A00%3A00Z");
expect(url).toContain("created_at__before=2024-01-02T00%3A00%3A00Z");
expect(url).toContain("created_by_end_user_session_id=sess-1");
expect(url).toContain("created_by_account=acc-1");
expect(url).toContain("page=2");
expect(url).toContain("limit=10");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
});
});
describe("Chat client", () => {
it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await chatClient.getSuggested("msg-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getSuggested.method,
url: routes.getSuggested.url("msg-1"),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getSuggested.url("msg-1"));
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getSuggested.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await chatClient.getConversations("end-user", "last-1", 10);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getConversations.method,
url: routes.getConversations.url(),
params: { user: "end-user", last_id: "last-1", limit: 10 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getConversations.url());
expect(url).toContain("user=end-user");
expect(url).toContain("last_id=last-1");
expect(url).toContain("limit=10");
expect(config.method).toBe(routes.getConversations.method);
expect(config.headers.Authorization).toBe("Bearer test");
});
it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
await chatClient.getAppFeedbacks(1, 20);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/app/feedbacks",
params: { page: 1, limit: 20 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/app/feedbacks");
expect(url).toContain("page=1");
expect(url).toContain("limit=20");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
});
});

View File

@ -1,16 +1,15 @@
import axios from "axios";
import { vi } from "vitest";
import { HttpClient } from "../src/http/client";
export const createHttpClient = (configOverrides = {}) => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const mockFetch = vi.fn();
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, mockRequest };
return { client, mockFetch };
};
export const createHttpClientWithSpies = (configOverrides = {}) => {
const { client, mockRequest } = createHttpClient(configOverrides);
const { client, mockFetch } = createHttpClient(configOverrides);
const request = vi
.spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} });
@ -22,7 +21,7 @@ export const createHttpClientWithSpies = (configOverrides = {}) => {
.mockResolvedValue({ data: null });
return {
client,
mockRequest,
mockFetch,
request,
requestStream,
requestBinaryStream,