diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index afbb58fee1..5c3540217d 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -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", diff --git a/sdks/nodejs-client/src/http/client.test.js b/sdks/nodejs-client/src/http/client.test.js index 05892547ed..ae1340b99a 100644 --- a/sdks/nodejs-client/src/http/client.test.js +++ b/sdks/nodejs-client/src/http/client.test.js @@ -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( diff --git a/sdks/nodejs-client/src/http/client.ts b/sdks/nodejs-client/src/http/client.ts index 44b63c9903..fdfe9ecc09 100644 --- a/sdks/nodejs-client/src/http/client.ts +++ b/sdks/nodejs-client/src/http/client.ts @@ -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): 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 { + 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; } diff --git a/sdks/nodejs-client/src/index.test.js b/sdks/nodejs-client/src/index.test.js index 289f4d9b1b..c13573aa23 100644 --- a/sdks/nodejs-client/src/index.test.js +++ b/sdks/nodejs-client/src/index.test.js @@ -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"); }); }); diff --git a/sdks/nodejs-client/tests/test-utils.js b/sdks/nodejs-client/tests/test-utils.js index 0d42514e9a..d61d982678 100644 --- a/sdks/nodejs-client/tests/test-utils.js +++ b/sdks/nodejs-client/tests/test-utils.js @@ -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,