mirror of
https://github.com/langgenius/dify.git
synced 2026-01-07 23:04:12 +00:00
Compare commits
7 Commits
refactor/q
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41857cf9cd | ||
|
|
78ab09b521 | ||
|
|
249a491743 | ||
|
|
df67842fae | ||
|
|
a4495ab586 | ||
|
|
7c3f15c507 | ||
|
|
5db66ad033 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dify-client",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -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",
|
||||
|
||||
191
sdks/nodejs-client/pnpm-lock.yaml
generated
191
sdks/nodejs-client/pnpm-lock.yaml
generated
@@ -7,10 +7,6 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
@@ -541,12 +537,6 @@ packages:
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -566,10 +556,6 @@ packages:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -593,10 +579,6 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -627,33 +609,9 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -748,35 +706,11 @@ packages:
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -785,26 +719,10 @@ packages:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@@ -906,18 +824,6 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -1016,9 +922,6 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1675,16 +1578,6 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 9.0.1
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.13.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
@@ -1703,11 +1596,6 @@ snapshots:
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -1727,10 +1615,6 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@@ -1751,31 +1635,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
@@ -1911,59 +1772,17 @@ snapshots:
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@@ -2055,14 +1874,6 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -2147,8 +1958,6 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
@@ -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,46 @@ 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();
|
||||
vi.useRealTimers(); // Ensure real timers are used by default
|
||||
});
|
||||
|
||||
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 +60,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({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
headers: {},
|
||||
});
|
||||
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
})
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const client = new HttpClient({ apiKey: "test" });
|
||||
await client.requestRaw({
|
||||
@@ -53,21 +82,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 +121,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 +154,13 @@ describe("HttpClient", () => {
|
||||
});
|
||||
|
||||
it("respects form-data headers", async () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
headers: {},
|
||||
});
|
||||
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
})
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const client = new HttpClient({ apiKey: "test" });
|
||||
const form = {
|
||||
@@ -120,7 +174,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,30 +182,27 @@ 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: {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 401,
|
||||
data: { message: "unauthorized" },
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
client.requestRaw({ method: "GET", path: "/meta" })
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
|
||||
mockRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 429,
|
||||
data: { message: "rate" },
|
||||
headers: { "retry-after": "2" },
|
||||
},
|
||||
});
|
||||
data: { message: "rate" },
|
||||
})
|
||||
);
|
||||
const error = await client
|
||||
.requestRaw({ method: "GET", path: "/meta" })
|
||||
.catch((err) => err);
|
||||
@@ -160,109 +211,96 @@ 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: {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 422,
|
||||
data: { message: "invalid" },
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
|
||||
).rejects.toBeInstanceOf(ValidationError);
|
||||
|
||||
mockRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
config: { url: "/files/upload" },
|
||||
response: {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
data: { message: "bad upload" },
|
||||
headers: {},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
|
||||
).rejects.toBeInstanceOf(FileUploadError);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
mockRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: "ECONNABORTED",
|
||||
message: "timeout",
|
||||
});
|
||||
// Test AbortError (which is what timeout produces)
|
||||
global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
|
||||
await expect(
|
||||
client.requestRaw({ method: "GET", path: "/meta" })
|
||||
).rejects.toBeInstanceOf(TimeoutError);
|
||||
|
||||
mockRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
message: "network",
|
||||
});
|
||||
// Test network error
|
||||
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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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 });
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
data: { message: "server" },
|
||||
})
|
||||
);
|
||||
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
|
||||
|
||||
mockRequest.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { message: "server" }, headers: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.requestRaw({ method: "GET", path: "/meta" })
|
||||
).rejects.toBeInstanceOf(APIError);
|
||||
});
|
||||
|
||||
it("logs requests and responses when enableLogging is true", async () => {
|
||||
const mockRequest = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
headers: {},
|
||||
});
|
||||
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: { ok: true },
|
||||
})
|
||||
);
|
||||
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
const client = new HttpClient({ apiKey: "test", enableLogging: true });
|
||||
@@ -275,8 +313,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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
})
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
const client = new HttpClient({
|
||||
@@ -286,14 +331,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") {
|
||||
@@ -121,6 +119,28 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const shouldParseJson = (contentType: string | null, text: string): boolean => {
|
||||
if (contentType) {
|
||||
return contentType.toLowerCase().includes("application/json");
|
||||
}
|
||||
return text.length > 0;
|
||||
};
|
||||
|
||||
const parseResponseBody = (text: string, contentType: string | null): unknown => {
|
||||
if (!shouldParseJson(contentType, text)) {
|
||||
return text;
|
||||
}
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Fallback to raw text if JSON parsing fails
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const isReadableStream = (value: unknown): value is Readable => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
@@ -128,17 +148,34 @@ 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 createBodyFactory = (
|
||||
method: RequestMethod,
|
||||
data?: unknown
|
||||
): { getBody: () => BodyInit | undefined; canRetry: boolean } => {
|
||||
if (method === "GET" || data === undefined) {
|
||||
return { getBody: () => undefined, canRetry: true };
|
||||
}
|
||||
if (isReadableStream(data)) {
|
||||
return { getBody: () => data as unknown as BodyInit, canRetry: false };
|
||||
}
|
||||
if (isFormData(data)) {
|
||||
return { getBody: () => data as BodyInit, canRetry: true };
|
||||
}
|
||||
const jsonBody = JSON.stringify(data);
|
||||
return { getBody: () => jsonBody, canRetry: true };
|
||||
};
|
||||
|
||||
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 +196,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 +308,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,44 +349,93 @@ 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,
|
||||
};
|
||||
|
||||
if (method !== "GET" && data !== undefined) {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
const { getBody, canRetry: canRetryBody } = createBodyFactory(method, data);
|
||||
|
||||
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) {
|
||||
// Create a new AbortController for each attempt to handle timeouts properly
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeout * 1000);
|
||||
|
||||
try {
|
||||
const response = await this.axios.request(axiosConfig);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: getBody(),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get("content-type");
|
||||
// Read body as text first to avoid "Body has already been read" error
|
||||
const text = await response.text();
|
||||
const responseBody = parseResponseBody(text, contentType);
|
||||
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");
|
||||
// Type assertion needed: DOM ReadableStream vs Node.js stream types are incompatible
|
||||
// but Readable.fromWeb handles the conversion correctly at runtime
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
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");
|
||||
// Read body as text first to handle malformed JSON gracefully
|
||||
const text = await response.text();
|
||||
responseData = parseResponseBody(text, contentType);
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: responseData,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
} catch (error) {
|
||||
const mapped = mapAxiosError(error);
|
||||
if (!shouldRetry(mapped, attempt, maxRetries)) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
let mapped: DifyError;
|
||||
if (error instanceof DifyError) {
|
||||
mapped = error;
|
||||
} else {
|
||||
mapped = mapFetchError(error, url);
|
||||
}
|
||||
|
||||
if (!canRetryBody || !shouldRetry(mapped, attempt, maxRetries)) {
|
||||
throw mapped;
|
||||
}
|
||||
const retryAfterSeconds =
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
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 mockFetch = vi.fn();
|
||||
|
||||
const setupAxiosMock = () => {
|
||||
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
|
||||
// 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;
|
||||
};
|
||||
|
||||
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 +54,37 @@ describe("Send Requests", () => {
|
||||
const difyClient = new DifyClient("test");
|
||||
const method = "GET";
|
||||
const endpoint = routes.application.url();
|
||||
mockRequest.mockResolvedValue({
|
||||
status: 200,
|
||||
data: "response",
|
||||
headers: {},
|
||||
});
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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 +110,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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "ok",
|
||||
})
|
||||
);
|
||||
|
||||
await workflowClient.getLogs({
|
||||
createdAtAfter: "2024-01-01T00:00:00Z",
|
||||
@@ -150,78 +166,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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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(
|
||||
createMockResponse({
|
||||
status: 200,
|
||||
data: "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,
|
||||
|
||||
Reference in New Issue
Block a user