@outputai/http 0.9.1-next.6fe398d.0 → 0.9.1
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/dist/fetch/index.js +5 -1
- package/dist/fetch/index.spec.js +55 -14
- package/package.json +2 -2
package/dist/fetch/index.js
CHANGED
|
@@ -2,6 +2,8 @@ import * as undici from 'undici';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { logRequest, logResponse, logError, logFailure } from './logger.js';
|
|
4
4
|
import { addRequestIdToResponse } from './utils.js';
|
|
5
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
6
|
+
const customDispatcher = new undici.EnvHttpProxyAgent({ allowH2: false });
|
|
5
7
|
/*
|
|
6
8
|
* Unifies undici and nodes realms
|
|
7
9
|
* https://github.com/nodejs/undici#keep-fetch-and-formdata-together
|
|
@@ -34,8 +36,10 @@ export const fetch = async (input, init) => {
|
|
|
34
36
|
const url = request.url;
|
|
35
37
|
const startedAt = performance.now();
|
|
36
38
|
await logRequest({ requestId, request });
|
|
39
|
+
// this allows for users not only to override the dispatcher but also to define it as undefined and remove it altogether.
|
|
40
|
+
const dispatcher = Object.hasOwn(init ?? {}, 'dispatcher') ? init?.dispatcher : customDispatcher;
|
|
37
41
|
try {
|
|
38
|
-
const response = await undici.fetch(request);
|
|
42
|
+
const response = await undici.fetch(request, dispatcher ? { dispatcher } : undefined);
|
|
39
43
|
const durationMs = performance.now() - startedAt;
|
|
40
44
|
// This enriches the response of the request id, so it is identifiable later.
|
|
41
45
|
addRequestIdToResponse(response, requestId);
|
package/dist/fetch/index.spec.js
CHANGED
|
@@ -30,6 +30,7 @@ describe('fetch/index', () => {
|
|
|
30
30
|
mockAgent: undefined,
|
|
31
31
|
previousDispatcher: undefined
|
|
32
32
|
};
|
|
33
|
+
const fetchWithMockDispatcher = (input, init) => fetch(input, { ...init, dispatcher: undiciCtx.mockAgent });
|
|
33
34
|
beforeEach(() => {
|
|
34
35
|
undiciCtx.mockAgent = new MockAgent();
|
|
35
36
|
undiciCtx.mockAgent.disableNetConnect();
|
|
@@ -54,7 +55,7 @@ describe('fetch/index', () => {
|
|
|
54
55
|
describe('fetch with MockAgent', () => {
|
|
55
56
|
it('returns 200, traces request start and response end', async () => {
|
|
56
57
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/ok', method: 'GET' }).reply(200, 'hello', { headers: { 'content-type': 'text/plain' } });
|
|
57
|
-
const response = await
|
|
58
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/ok`);
|
|
58
59
|
expect(response.status).toBe(200);
|
|
59
60
|
expect(await response.text()).toBe('hello');
|
|
60
61
|
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
@@ -76,7 +77,7 @@ describe('fetch/index', () => {
|
|
|
76
77
|
});
|
|
77
78
|
it('uses logError for status > 399 without logging response end', async () => {
|
|
78
79
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/missing', method: 'GET' }).reply(404, 'nope', { headers: { 'content-type': 'text/plain' } });
|
|
79
|
-
const response = await
|
|
80
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/missing`);
|
|
80
81
|
expect(response.status).toBe(404);
|
|
81
82
|
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
82
83
|
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
@@ -88,7 +89,7 @@ describe('fetch/index', () => {
|
|
|
88
89
|
});
|
|
89
90
|
it('treats status 399 as success (logs response end, not HTTP error)', async () => {
|
|
90
91
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/edge', method: 'GET' }).reply(399, '', { headers: { 'content-type': 'text/plain' } });
|
|
91
|
-
const response = await
|
|
92
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/edge`);
|
|
92
93
|
expect(response.status).toBe(399);
|
|
93
94
|
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
94
95
|
expect(loggerMock.logError).not.toHaveBeenCalled();
|
|
@@ -102,7 +103,7 @@ describe('fetch/index', () => {
|
|
|
102
103
|
'x-custom': 'plain-value'
|
|
103
104
|
}
|
|
104
105
|
}).reply(200, 'ok');
|
|
105
|
-
const response = await
|
|
106
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/with-plain-headers`, {
|
|
106
107
|
headers: { 'X-Custom': 'plain-value' }
|
|
107
108
|
});
|
|
108
109
|
expect(response.status).toBe(200);
|
|
@@ -121,7 +122,7 @@ describe('fetch/index', () => {
|
|
|
121
122
|
}).reply(200, 'ok');
|
|
122
123
|
const userHeaders = new Headers();
|
|
123
124
|
userHeaders.set('X-From-Headers', 'yes');
|
|
124
|
-
const response = await
|
|
125
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/with-headers-instance`, {
|
|
125
126
|
headers: userHeaders
|
|
126
127
|
});
|
|
127
128
|
expect(response.status).toBe(200);
|
|
@@ -131,7 +132,7 @@ describe('fetch/index', () => {
|
|
|
131
132
|
});
|
|
132
133
|
it('calls logFailure when the mock responds with replyWithError', async () => {
|
|
133
134
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/boom', method: 'GET' }).replyWithError(new Error('simulated network failure'));
|
|
134
|
-
await expect(
|
|
135
|
+
await expect(fetchWithMockDispatcher(`${MOCK_ORIGIN}/boom`)).rejects.toThrow('fetch failed');
|
|
135
136
|
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
136
137
|
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
137
138
|
expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
|
|
@@ -143,7 +144,7 @@ describe('fetch/index', () => {
|
|
|
143
144
|
expect(failure.cause.message).toBe('simulated network failure');
|
|
144
145
|
});
|
|
145
146
|
it('fails when no mock matches (disabled net)', async () => {
|
|
146
|
-
await expect(
|
|
147
|
+
await expect(fetchWithMockDispatcher(`${MOCK_ORIGIN}/unmocked`)).rejects.toThrow();
|
|
147
148
|
expect(loggerMock.logRequest).toHaveBeenCalledTimes(1);
|
|
148
149
|
expect(loggerMock.logResponse).not.toHaveBeenCalled();
|
|
149
150
|
expect(loggerMock.logFailure).toHaveBeenCalledTimes(1);
|
|
@@ -151,7 +152,7 @@ describe('fetch/index', () => {
|
|
|
151
152
|
});
|
|
152
153
|
it('passes method and body through to undici for POST JSON', async () => {
|
|
153
154
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/create', method: 'POST' }).reply(201, JSON.stringify({ id: 1 }), { headers: { 'content-type': 'application/json' } });
|
|
154
|
-
const response = await
|
|
155
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/create`, {
|
|
155
156
|
method: 'POST',
|
|
156
157
|
headers: { 'content-type': 'application/json' },
|
|
157
158
|
body: JSON.stringify({ name: 'a' })
|
|
@@ -162,7 +163,7 @@ describe('fetch/index', () => {
|
|
|
162
163
|
});
|
|
163
164
|
it('works when the second argument is omitted', async () => {
|
|
164
165
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/bare', method: 'GET' }).reply(204);
|
|
165
|
-
const response = await
|
|
166
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/bare`);
|
|
166
167
|
expect(response.status).toBe(204);
|
|
167
168
|
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
|
|
168
169
|
});
|
|
@@ -171,7 +172,7 @@ describe('fetch/index', () => {
|
|
|
171
172
|
it('accepts a URL object as the first argument', async () => {
|
|
172
173
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/from-url', method: 'GET' }).reply(200, 'url-ok', { headers: { 'content-type': 'text/plain' } });
|
|
173
174
|
const href = new URL('/from-url', `${MOCK_ORIGIN}/`);
|
|
174
|
-
const response = await
|
|
175
|
+
const response = await fetchWithMockDispatcher(href);
|
|
175
176
|
expect(response.status).toBe(200);
|
|
176
177
|
expect(await response.text()).toBe('url-ok');
|
|
177
178
|
expect(loggerMock.logRequest.mock.calls[0][0].request.url).toBe(href.href);
|
|
@@ -179,7 +180,7 @@ describe('fetch/index', () => {
|
|
|
179
180
|
it('accepts a Request as the first argument (no init)', async () => {
|
|
180
181
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-only', method: 'GET' }).reply(200, 'r1');
|
|
181
182
|
const input = new Request(`${MOCK_ORIGIN}/req-only`, { method: 'GET' });
|
|
182
|
-
const response = await
|
|
183
|
+
const response = await fetchWithMockDispatcher(input);
|
|
183
184
|
expect(response.status).toBe(200);
|
|
184
185
|
expect(await response.text()).toBe('r1');
|
|
185
186
|
const { request } = loggerMock.logRequest.mock.calls[0][0];
|
|
@@ -189,7 +190,7 @@ describe('fetch/index', () => {
|
|
|
189
190
|
it('accepts Request plus init that overrides method and body', async () => {
|
|
190
191
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/req-plus-init', method: 'POST' }).reply(201, JSON.stringify({ saved: true }), { headers: { 'content-type': 'application/json' } });
|
|
191
192
|
const input = new Request(`${MOCK_ORIGIN}/req-plus-init`, { method: 'GET' });
|
|
192
|
-
const response = await
|
|
193
|
+
const response = await fetchWithMockDispatcher(input, {
|
|
193
194
|
method: 'POST',
|
|
194
195
|
headers: { 'content-type': 'application/json' },
|
|
195
196
|
body: JSON.stringify({ name: 'override' })
|
|
@@ -200,7 +201,7 @@ describe('fetch/index', () => {
|
|
|
200
201
|
});
|
|
201
202
|
it('accepts string URL with explicit undefined init', async () => {
|
|
202
203
|
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({ path: '/explicit-undefined', method: 'GET' }).reply(200, 'ok');
|
|
203
|
-
const response = await
|
|
204
|
+
const response = await fetchWithMockDispatcher(`${MOCK_ORIGIN}/explicit-undefined`, undefined);
|
|
204
205
|
expect(response.status).toBe(200);
|
|
205
206
|
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('GET');
|
|
206
207
|
});
|
|
@@ -211,7 +212,7 @@ describe('fetch/index', () => {
|
|
|
211
212
|
headers: { 'content-type': 'application/json', 'x-request-trace-id': FIXED_REQUEST_ID }
|
|
212
213
|
}).reply(200, '{}');
|
|
213
214
|
const href = new URL('/url-post', `${MOCK_ORIGIN}/`);
|
|
214
|
-
const response = await
|
|
215
|
+
const response = await fetchWithMockDispatcher(href, {
|
|
215
216
|
method: 'POST',
|
|
216
217
|
headers: { 'Content-Type': 'application/json' },
|
|
217
218
|
body: '{}'
|
|
@@ -219,5 +220,45 @@ describe('fetch/index', () => {
|
|
|
219
220
|
expect(response.status).toBe(200);
|
|
220
221
|
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
|
|
221
222
|
});
|
|
223
|
+
it('uses caller-provided dispatcher without dropping init request options', async () => {
|
|
224
|
+
const dispatcher = new MockAgent();
|
|
225
|
+
dispatcher.disableNetConnect();
|
|
226
|
+
dispatcher.get(MOCK_ORIGIN).intercept({
|
|
227
|
+
path: '/custom-dispatcher',
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'content-type': 'application/json',
|
|
231
|
+
'x-request-trace-id': FIXED_REQUEST_ID,
|
|
232
|
+
'x-custom': 'custom-value'
|
|
233
|
+
}
|
|
234
|
+
}).reply(200, 'ok');
|
|
235
|
+
const response = await fetch(`${MOCK_ORIGIN}/custom-dispatcher`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
'X-Custom': 'custom-value'
|
|
240
|
+
},
|
|
241
|
+
body: '{}',
|
|
242
|
+
dispatcher
|
|
243
|
+
});
|
|
244
|
+
expect(response.status).toBe(200);
|
|
245
|
+
expect(await response.text()).toBe('ok');
|
|
246
|
+
expect(loggerMock.logRequest.mock.calls[0][0].request.method).toBe('POST');
|
|
247
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
248
|
+
await dispatcher.close();
|
|
249
|
+
});
|
|
250
|
+
it('allows dispatcher undefined to disable the default dispatcher', async () => {
|
|
251
|
+
undiciCtx.mockAgent.get(MOCK_ORIGIN).intercept({
|
|
252
|
+
path: '/undefined-dispatcher',
|
|
253
|
+
method: 'GET',
|
|
254
|
+
headers: { 'x-request-trace-id': FIXED_REQUEST_ID }
|
|
255
|
+
}).reply(200, 'ok');
|
|
256
|
+
const response = await fetch(`${MOCK_ORIGIN}/undefined-dispatcher`, {
|
|
257
|
+
dispatcher: undefined
|
|
258
|
+
});
|
|
259
|
+
expect(response.status).toBe(200);
|
|
260
|
+
expect(await response.text()).toBe('ok');
|
|
261
|
+
expect(loggerMock.logResponse).toHaveBeenCalledTimes(1);
|
|
262
|
+
});
|
|
222
263
|
});
|
|
223
264
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/http",
|
|
3
|
-
"version": "0.9.1
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Framework abstraction to make HTTP calls with tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"ky": "1.14.3",
|
|
13
13
|
"undici": "8.5.0",
|
|
14
|
-
"@outputai/core": "0.9.1
|
|
14
|
+
"@outputai/core": "0.9.1"
|
|
15
15
|
},
|
|
16
16
|
"license": "Apache-2.0",
|
|
17
17
|
"publishConfig": {
|