mirror of
https://github.com/langgenius/dify.git
synced 2026-05-11 14:58:23 +08:00
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:
parent
c58a093fd1
commit
5db66ad033
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user