@telorun/http-client 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Telo HTTP Client Standard Specification (v1.0 Draft)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `Http.Request` (and optionally `Http.Client`) definitions represent outgoing HTTP calls made by the Telo kernel to external services.
|
|
6
|
+
Because different programming languages implement HTTP clients differently (e.g., `fetch` in Node.js, `reqwest` in Rust), all Telo HTTP Client modules MUST adhere to this exact behavior to ensure cross-language compatibility.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. The Request Contract (Input Serialization)
|
|
11
|
+
|
|
12
|
+
When the Telo kernel executes an `Http.Request`, the underlying module must construct the outgoing request according to strict rules.
|
|
13
|
+
|
|
14
|
+
- **Headers Normalization:** All header keys provided in the manifest MUST be normalized to lowercase before sending.
|
|
15
|
+
- **Query Parameters:** If `query` is provided as an object, the module MUST safely URL-encode the keys and values and append them to the `url`.
|
|
16
|
+
- **Payload Serialization (Body):**
|
|
17
|
+
- If the `headers` include `content-type: application/json` (which should be the default if `body` is an object), the module MUST serialize the `body` to a JSON string.
|
|
18
|
+
- If the `content-type` is `application/x-www-form-urlencoded`, the module MUST serialize the object into a URL-encoded string.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 2. The Response Contract (Output Deserialization)
|
|
23
|
+
|
|
24
|
+
The output of an `Http.Request` becomes available to the Telo engine (e.g., for mapping via CEL expressions). The underlying engine MUST return a standardized **Telo Response Object**.
|
|
25
|
+
|
|
26
|
+
### Standardized Return Object
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"status": 200,
|
|
31
|
+
"headers": {
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
"x-ratelimit-remaining": "99"
|
|
34
|
+
},
|
|
35
|
+
"body": {
|
|
36
|
+
"userId": 1,
|
|
37
|
+
"title": "Hello World"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **Header Normalization:** The module MUST normalize all incoming response headers to lowercase keys.
|
|
43
|
+
- **Body Deserialization Rules:**
|
|
44
|
+
- **JSON:** If the response header `content-type` includes `application/json`, the module MUST attempt to parse the response body as a JSON object. _Edge Case:_ If the response is empty (0 bytes) but claims to be JSON, the module MUST return `null` for the body, not throw a parsing error.
|
|
45
|
+
- **Text/Other:** For any other content type, or if JSON parsing fails gracefully, the `body` MUST be returned as a raw String.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 3. Error Handling Contract (Network vs. HTTP)
|
|
50
|
+
|
|
51
|
+
It is crucial to differentiate between an HTTP error (the external server responded) and a Network error (the kernel couldn't reach the server).
|
|
52
|
+
|
|
53
|
+
### 3.1. HTTP Status Errors (4xx and 5xx)
|
|
54
|
+
|
|
55
|
+
- **Standard:** By default, HTTP status codes like `400`, `404`, or `500` **MUST NOT** throw a kernel execution error.
|
|
56
|
+
- They are considered successful _network_ executions. The module MUST return the standard Telo Response Object with the respective `status` code. It is up to the Telo manifest author to handle these via CEL mappings (e.g., `${{ result.status == 200 ? result.body : throw('API Failed') }}`).
|
|
57
|
+
|
|
58
|
+
### 3.2. Network & Engine Errors
|
|
59
|
+
|
|
60
|
+
If the request fails at the network layer (e.g., DNS resolution failure, connection refused, SSL error), the module MUST throw a standardized **Telo Network Error** that stops execution.
|
|
61
|
+
|
|
62
|
+
**Standardized Error Format:**
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"error": "NetworkError",
|
|
67
|
+
"code": "CONNECTION_REFUSED",
|
|
68
|
+
"message": "Failed to connect to api.external.com",
|
|
69
|
+
"details": {
|
|
70
|
+
"url": "https://api.external.com/data"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
_Valid `code` enumerations MUST include:_ `TIMEOUT`, `CONNECTION_REFUSED`, `DNS_RESOLUTION_FAILED`, `SSL_ERROR`. Modules must map their native engine errors to these generic codes.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 4. Execution Policies (Timeouts & Redirects)
|
|
80
|
+
|
|
81
|
+
To prevent hanging processes, Telo enforces strict default limits on outgoing requests.
|
|
82
|
+
|
|
83
|
+
- **Timeouts:** The module MUST enforce a default request timeout of **10,000 milliseconds (10 seconds)** unless overridden in the manifest. If the timeout is reached, it MUST throw a `NetworkError` with the code `TIMEOUT`.
|
|
84
|
+
- **Redirects:** The module MUST automatically follow `301` and `302` redirects, up to a maximum of **5 redirects**, to prevent infinite redirect loops.
|
|
@@ -151,9 +151,18 @@ class HttpRequestResource {
|
|
|
151
151
|
if (!client) {
|
|
152
152
|
throw new Error(`Http.Client "${clientName}" not found`);
|
|
153
153
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
const clientConfig = typeof client.snapshot === "function"
|
|
155
|
+
? client.snapshot()
|
|
156
|
+
: client;
|
|
157
|
+
const resolvedBaseUrl = ctx.expandValue(clientConfig.baseUrl ?? "", input ?? {});
|
|
158
|
+
clientBaseUrl = typeof resolvedBaseUrl === "string" ? resolvedBaseUrl : "";
|
|
159
|
+
const resolvedHeaders = ctx.expandValue(clientConfig.headers ?? {}, input ?? {});
|
|
160
|
+
clientHeaders = normalizeHeaders((resolvedHeaders ?? {}));
|
|
161
|
+
const resolvedTimeout = ctx.expandValue(clientConfig.timeout ?? DEFAULT_TIMEOUT, input ?? {});
|
|
162
|
+
clientTimeout =
|
|
163
|
+
typeof resolvedTimeout === "number" && Number.isFinite(resolvedTimeout)
|
|
164
|
+
? resolvedTimeout
|
|
165
|
+
: DEFAULT_TIMEOUT;
|
|
157
166
|
}
|
|
158
167
|
// Expand template fields from manifest.inputs using runtime input as context
|
|
159
168
|
// Manifest-level fields (url, method, etc.) serve as defaults when inputs is absent
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Telo HTTP Client module - HTTP client resource kinds for Telo manifests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"http",
|
|
8
|
+
"client",
|
|
9
|
+
"request"
|
|
10
|
+
],
|
|
11
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
12
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
16
|
+
"directory": "modules/http-client/nodejs"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
21
|
+
},
|
|
4
22
|
"type": "module",
|
|
5
23
|
"exports": {
|
|
6
24
|
"./http-client": {
|
|
@@ -13,10 +31,11 @@
|
|
|
13
31
|
}
|
|
14
32
|
},
|
|
15
33
|
"files": [
|
|
16
|
-
"dist
|
|
34
|
+
"dist",
|
|
35
|
+
"src/**"
|
|
17
36
|
],
|
|
18
37
|
"dependencies": {
|
|
19
|
-
"@telorun/sdk": "0.2.
|
|
38
|
+
"@telorun/sdk": "0.2.8"
|
|
20
39
|
},
|
|
21
40
|
"devDependencies": {
|
|
22
41
|
"@types/node": "^20.0.0",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
interface HttpClientManifest {
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
followRedirects?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class HttpClientResource implements ResourceInstance {
|
|
11
|
+
readonly metadata: { name: string; module: string; [key: string]: any };
|
|
12
|
+
|
|
13
|
+
constructor(private readonly manifest: any) {
|
|
14
|
+
this.metadata = manifest.metadata ?? {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
snapshot() {
|
|
18
|
+
return {
|
|
19
|
+
baseUrl: this.manifest.baseUrl ?? "",
|
|
20
|
+
headers: this.manifest.headers ?? {},
|
|
21
|
+
timeout: this.manifest.timeout ?? 10000,
|
|
22
|
+
followRedirects: this.manifest.followRedirects ?? true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function register(): void {}
|
|
28
|
+
|
|
29
|
+
export async function create(
|
|
30
|
+
resource: HttpClientManifest,
|
|
31
|
+
_ctx: ResourceContext,
|
|
32
|
+
): Promise<HttpClientResource> {
|
|
33
|
+
return new HttpClientResource(resource);
|
|
34
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { PassThrough, Readable } from "stream";
|
|
3
|
+
|
|
4
|
+
const MAX_REDIRECTS = 5;
|
|
5
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
6
|
+
|
|
7
|
+
interface TeloResponse {
|
|
8
|
+
status: number;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface NetworkErrorPayload {
|
|
14
|
+
error: "NetworkError";
|
|
15
|
+
code: "TIMEOUT" | "CONNECTION_REFUSED" | "DNS_RESOLUTION_FAILED" | "SSL_ERROR";
|
|
16
|
+
message: string;
|
|
17
|
+
details: { url: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createNetworkError(
|
|
21
|
+
code: NetworkErrorPayload["code"],
|
|
22
|
+
message: string,
|
|
23
|
+
url: string,
|
|
24
|
+
): Error {
|
|
25
|
+
const payload: NetworkErrorPayload = {
|
|
26
|
+
error: "NetworkError",
|
|
27
|
+
code,
|
|
28
|
+
message,
|
|
29
|
+
details: { url },
|
|
30
|
+
};
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
(err as any).networkError = payload;
|
|
33
|
+
(err as any).code = code;
|
|
34
|
+
Object.assign(err, payload);
|
|
35
|
+
return err;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mapNetworkError(err: unknown, url: string): never {
|
|
39
|
+
const e = err as Error;
|
|
40
|
+
if (e.name === "AbortError") {
|
|
41
|
+
throw createNetworkError("TIMEOUT", `Request timed out`, url);
|
|
42
|
+
}
|
|
43
|
+
const msg = e.message?.toLowerCase() ?? "";
|
|
44
|
+
if (msg.includes("econnrefused") || msg.includes("connection refused")) {
|
|
45
|
+
throw createNetworkError("CONNECTION_REFUSED", e.message, url);
|
|
46
|
+
}
|
|
47
|
+
if (msg.includes("enotfound") || msg.includes("getaddrinfo") || msg.includes("dns")) {
|
|
48
|
+
throw createNetworkError("DNS_RESOLUTION_FAILED", e.message, url);
|
|
49
|
+
}
|
|
50
|
+
if (msg.includes("ssl") || msg.includes("cert") || msg.includes("tls")) {
|
|
51
|
+
throw createNetworkError("SSL_ERROR", e.message, url);
|
|
52
|
+
}
|
|
53
|
+
throw createNetworkError("CONNECTION_REFUSED", e.message, url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
57
|
+
const result: Record<string, string> = {};
|
|
58
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
59
|
+
result[key.toLowerCase()] = value;
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function executeRequest(
|
|
65
|
+
url: string,
|
|
66
|
+
method: string,
|
|
67
|
+
headers: Record<string, string>,
|
|
68
|
+
body: string | undefined,
|
|
69
|
+
timeout: number,
|
|
70
|
+
stream = false,
|
|
71
|
+
): Promise<TeloResponse> {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
74
|
+
|
|
75
|
+
let currentUrl = url;
|
|
76
|
+
let redirectsLeft = MAX_REDIRECTS;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
const response = await fetch(currentUrl, {
|
|
81
|
+
method,
|
|
82
|
+
headers,
|
|
83
|
+
body,
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Handle redirects manually (limit to MAX_REDIRECTS)
|
|
89
|
+
if ((response.status === 301 || response.status === 302) && redirectsLeft > 0) {
|
|
90
|
+
const location = response.headers.get("location");
|
|
91
|
+
if (location) {
|
|
92
|
+
currentUrl = location.startsWith("http")
|
|
93
|
+
? location
|
|
94
|
+
: new URL(location, currentUrl).toString();
|
|
95
|
+
redirectsLeft--;
|
|
96
|
+
// For redirects, switch to GET and drop body per HTTP spec
|
|
97
|
+
method = "GET";
|
|
98
|
+
body = undefined;
|
|
99
|
+
delete headers["content-length"];
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Normalize response headers
|
|
105
|
+
const responseHeaders: Record<string, string> = {};
|
|
106
|
+
response.headers.forEach((value, key) => {
|
|
107
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Stream mode: pump body into a PassThrough eagerly so data flows immediately
|
|
111
|
+
if (stream) {
|
|
112
|
+
const webStream = response.body;
|
|
113
|
+
const body = new PassThrough();
|
|
114
|
+
(async () => {
|
|
115
|
+
if (!webStream) {
|
|
116
|
+
body.end();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const reader = webStream.getReader();
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
body.push(Buffer.from(value));
|
|
125
|
+
}
|
|
126
|
+
body.end();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
body.destroy(err as Error);
|
|
129
|
+
} finally {
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
return { status: response.status, headers: responseHeaders, body };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Deserialize body
|
|
137
|
+
const contentType = responseHeaders["content-type"] ?? "";
|
|
138
|
+
let responseBody: unknown;
|
|
139
|
+
|
|
140
|
+
if (contentType.includes("application/json")) {
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
responseBody = text.length === 0 ? null : JSON.parse(text);
|
|
143
|
+
} else {
|
|
144
|
+
responseBody = await response.text();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { status: response.status, headers: responseHeaders, body: responseBody };
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
mapNetworkError(err, url);
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeoutId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function executeWithRetry(
|
|
157
|
+
url: string,
|
|
158
|
+
method: string,
|
|
159
|
+
headers: Record<string, string>,
|
|
160
|
+
body: string | undefined,
|
|
161
|
+
timeout: number,
|
|
162
|
+
retriesLeft: number,
|
|
163
|
+
stream = false,
|
|
164
|
+
): Promise<TeloResponse> {
|
|
165
|
+
try {
|
|
166
|
+
return await executeRequest(url, method, headers, body, timeout, stream);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (retriesLeft > 0 && (err as any).error === "NetworkError") {
|
|
169
|
+
return executeWithRetry(url, method, headers, body, timeout, retriesLeft - 1, stream);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface HttpRequestInputs {
|
|
176
|
+
url: string;
|
|
177
|
+
method?: string;
|
|
178
|
+
query?: Record<string, string>;
|
|
179
|
+
headers?: Record<string, string>;
|
|
180
|
+
body?: string | Record<string, unknown>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface HttpRequestManifest extends HttpRequestInputs {
|
|
184
|
+
client?: string;
|
|
185
|
+
timeout?: number;
|
|
186
|
+
throwOnHttpError?: boolean;
|
|
187
|
+
retries?: number;
|
|
188
|
+
mode?: "buffer" | "stream";
|
|
189
|
+
inputs?: HttpRequestInputs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class HttpRequestResource implements ResourceInstance {
|
|
193
|
+
constructor(
|
|
194
|
+
private readonly manifest: HttpRequestManifest,
|
|
195
|
+
private readonly ctx: ResourceContext,
|
|
196
|
+
) {}
|
|
197
|
+
|
|
198
|
+
async invoke(input: any): Promise<TeloResponse | Readable> {
|
|
199
|
+
const ctx = this.ctx;
|
|
200
|
+
const m = this.manifest;
|
|
201
|
+
|
|
202
|
+
// Resolve client config
|
|
203
|
+
let clientBaseUrl = "";
|
|
204
|
+
let clientHeaders: Record<string, string> = {};
|
|
205
|
+
let clientTimeout = DEFAULT_TIMEOUT;
|
|
206
|
+
|
|
207
|
+
if (m.client) {
|
|
208
|
+
const clientName = ctx.expandValue(m.client, input ?? {}) as string;
|
|
209
|
+
const client: any = ctx.getResourcesByName("Client", clientName);
|
|
210
|
+
if (!client) {
|
|
211
|
+
throw new Error(`Http.Client "${clientName}" not found`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const clientConfig =
|
|
215
|
+
typeof client.snapshot === "function"
|
|
216
|
+
? (client.snapshot() as Record<string, unknown>)
|
|
217
|
+
: client;
|
|
218
|
+
|
|
219
|
+
const resolvedBaseUrl = ctx.expandValue(clientConfig.baseUrl ?? "", input ?? {});
|
|
220
|
+
clientBaseUrl = typeof resolvedBaseUrl === "string" ? resolvedBaseUrl : "";
|
|
221
|
+
|
|
222
|
+
const resolvedHeaders = ctx.expandValue(clientConfig.headers ?? {}, input ?? {});
|
|
223
|
+
clientHeaders = normalizeHeaders((resolvedHeaders ?? {}) as Record<string, string>);
|
|
224
|
+
|
|
225
|
+
const resolvedTimeout = ctx.expandValue(clientConfig.timeout ?? DEFAULT_TIMEOUT, input ?? {});
|
|
226
|
+
clientTimeout =
|
|
227
|
+
typeof resolvedTimeout === "number" && Number.isFinite(resolvedTimeout)
|
|
228
|
+
? resolvedTimeout
|
|
229
|
+
: DEFAULT_TIMEOUT;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Expand template fields from manifest.inputs using runtime input as context
|
|
233
|
+
// Manifest-level fields (url, method, etc.) serve as defaults when inputs is absent
|
|
234
|
+
const manifestInputs: HttpRequestInputs = {
|
|
235
|
+
url: m.url,
|
|
236
|
+
method: m.method,
|
|
237
|
+
query: m.query,
|
|
238
|
+
headers: m.headers,
|
|
239
|
+
body: m.body,
|
|
240
|
+
...m.inputs,
|
|
241
|
+
};
|
|
242
|
+
const resolved = ctx.expandValue(manifestInputs, input ?? {}) as HttpRequestInputs;
|
|
243
|
+
const rawUrl = resolved.url as string;
|
|
244
|
+
const method = ((resolved.method ?? "GET") || "GET").toUpperCase();
|
|
245
|
+
const requestHeaders = normalizeHeaders((resolved.headers ?? {}) as Record<string, string>);
|
|
246
|
+
const query = (resolved.query ?? {}) as Record<string, string>;
|
|
247
|
+
const body = resolved.body;
|
|
248
|
+
const effectiveTimeout = m.timeout ?? clientTimeout;
|
|
249
|
+
const retries = m.retries ?? 0;
|
|
250
|
+
const throwOnHttpError = m.throwOnHttpError ?? false;
|
|
251
|
+
|
|
252
|
+
// Build URL
|
|
253
|
+
let fullUrl = rawUrl.startsWith("http") ? rawUrl : `${clientBaseUrl}${rawUrl}`;
|
|
254
|
+
|
|
255
|
+
// Append query params
|
|
256
|
+
const queryEntries = Object.entries(query);
|
|
257
|
+
if (queryEntries.length > 0) {
|
|
258
|
+
const params = new URLSearchParams(queryEntries);
|
|
259
|
+
fullUrl = `${fullUrl}${fullUrl.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Merge headers: client defaults < request-specific
|
|
263
|
+
const mergedHeaders: Record<string, string> = { ...clientHeaders, ...requestHeaders };
|
|
264
|
+
|
|
265
|
+
// Serialize body
|
|
266
|
+
let serializedBody: string | undefined;
|
|
267
|
+
if (body !== undefined) {
|
|
268
|
+
if (typeof body === "object" && body !== null) {
|
|
269
|
+
const contentType = mergedHeaders["content-type"] ?? "application/json";
|
|
270
|
+
if (!mergedHeaders["content-type"]) {
|
|
271
|
+
mergedHeaders["content-type"] = "application/json";
|
|
272
|
+
}
|
|
273
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
274
|
+
serializedBody = new URLSearchParams(body as Record<string, string>).toString();
|
|
275
|
+
} else {
|
|
276
|
+
// Default to JSON
|
|
277
|
+
mergedHeaders["content-type"] = mergedHeaders["content-type"] ?? "application/json";
|
|
278
|
+
serializedBody = JSON.stringify(body);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
serializedBody = String(body);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const response = await executeWithRetry(
|
|
286
|
+
fullUrl,
|
|
287
|
+
method,
|
|
288
|
+
mergedHeaders,
|
|
289
|
+
serializedBody,
|
|
290
|
+
effectiveTimeout,
|
|
291
|
+
retries,
|
|
292
|
+
m.mode === "stream",
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (throwOnHttpError && response.status >= 400) {
|
|
296
|
+
throw new Error(`HTTP ${response.status} error from ${fullUrl}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (m.mode === "stream") {
|
|
300
|
+
return response.body as Readable;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return response;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function register(): void {}
|
|
308
|
+
|
|
309
|
+
export async function create(
|
|
310
|
+
resource: HttpRequestManifest,
|
|
311
|
+
ctx: ResourceContext,
|
|
312
|
+
): Promise<HttpRequestResource> {
|
|
313
|
+
return new HttpRequestResource(resource, ctx);
|
|
314
|
+
}
|