@output.ai/http 0.0.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/README.md +26 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +10 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/trace-error.d.ts +5 -0
- package/dist/hooks/trace-error.js +34 -0
- package/dist/hooks/trace-request.d.ts +6 -0
- package/dist/hooks/trace-request.js +42 -0
- package/dist/hooks/trace-response.d.ts +6 -0
- package/dist/hooks/trace-response.js +45 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.integration.test.d.ts +1 -0
- package/dist/index.integration.test.js +117 -0
- package/dist/index.js +40 -0
- package/dist/index.spec.d.ts +1 -0
- package/dist/index.spec.js +237 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/redact-headers.d.ts +6 -0
- package/dist/utils/redact-headers.js +27 -0
- package/dist/utils/redact-headers.spec.d.ts +1 -0
- package/dist/utils/redact-headers.spec.js +245 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @output.ai/http
|
|
2
|
+
|
|
3
|
+
TypeScript HTTP client that wraps [ky](https://github.com/sindresorhus/ky) with Flow SDK tracing hooks.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @output.ai/http
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { httpClient } from '@output.ai/http';
|
|
15
|
+
|
|
16
|
+
const client = httpClient({
|
|
17
|
+
prefixUrl: 'https://api.example.com'
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const response = await client.get('users/1');
|
|
21
|
+
const userData = await response.json();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Environment Variables
|
|
25
|
+
|
|
26
|
+
- `LOG_HTTP_VERBOSE=true` - Enables detailed request/response logging including headers and body
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for HTTP client behavior
|
|
3
|
+
*/
|
|
4
|
+
export const config = {
|
|
5
|
+
/**
|
|
6
|
+
* Whether to log verbose HTTP information (headers and bodies)
|
|
7
|
+
* Controlled by LOG_HTTP_VERBOSE environment variable
|
|
8
|
+
*/
|
|
9
|
+
logVerbose: ['1', 'true'].includes(process.env.TRACING_HTTP_VERBOSE)
|
|
10
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { HTTPError } from 'ky';
|
|
2
|
+
import { trace } from '@output.ai/trace';
|
|
3
|
+
import { redactHeaders } from '../utils/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Traces HTTP errors for observability using Flow SDK tracing
|
|
6
|
+
*/
|
|
7
|
+
export const traceError = async (error) => {
|
|
8
|
+
const request = error.request;
|
|
9
|
+
const response = error.response;
|
|
10
|
+
const requestHeaders = Object.fromEntries(request.headers.entries());
|
|
11
|
+
trace({
|
|
12
|
+
lib: 'flow-http',
|
|
13
|
+
event: 'http.error',
|
|
14
|
+
input: {
|
|
15
|
+
method: request.method,
|
|
16
|
+
url: request.url,
|
|
17
|
+
requestHeaders: redactHeaders(requestHeaders)
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
error: error instanceof HTTPError && response ? {
|
|
21
|
+
name: error.name,
|
|
22
|
+
message: error.message,
|
|
23
|
+
status: response.status,
|
|
24
|
+
statusText: response.statusText,
|
|
25
|
+
headers: redactHeaders(Object.fromEntries(response.headers.entries()))
|
|
26
|
+
} : {
|
|
27
|
+
name: error.name,
|
|
28
|
+
message: error.message,
|
|
29
|
+
type: 'network'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return error;
|
|
34
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { trace } from '@output.ai/trace';
|
|
2
|
+
import { redactHeaders } from '../utils/index.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Safely parses request body as JSON, falling back to string if parsing fails
|
|
6
|
+
*/
|
|
7
|
+
async function safeParseBody(request) {
|
|
8
|
+
if (!request.body) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const cloned = request.clone();
|
|
12
|
+
const body = await cloned.text();
|
|
13
|
+
if (!body) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(body);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return body;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Traces HTTP request for observability using Flow SDK tracing
|
|
25
|
+
* Respects LOG_HTTP_VERBOSE environment variable for detailed logging
|
|
26
|
+
*/
|
|
27
|
+
export const traceRequest = async (request) => {
|
|
28
|
+
const traceData = {
|
|
29
|
+
method: request.method,
|
|
30
|
+
url: request.url
|
|
31
|
+
};
|
|
32
|
+
if (config.logVerbose) {
|
|
33
|
+
const headers = Object.fromEntries(request.headers.entries());
|
|
34
|
+
traceData.headers = redactHeaders(headers);
|
|
35
|
+
traceData.body = await safeParseBody(request);
|
|
36
|
+
}
|
|
37
|
+
trace({
|
|
38
|
+
lib: 'flow-http',
|
|
39
|
+
event: 'http.request',
|
|
40
|
+
input: traceData
|
|
41
|
+
});
|
|
42
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { trace } from '@output.ai/trace';
|
|
2
|
+
import { redactHeaders } from '../utils/index.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Safely parses response body based on content type
|
|
6
|
+
*/
|
|
7
|
+
async function safeParseResponseBody(response) {
|
|
8
|
+
const cloned = response.clone();
|
|
9
|
+
const contentType = response.headers.get('content-type') || '';
|
|
10
|
+
const body = contentType.includes('application/json') ?
|
|
11
|
+
await cloned.json() :
|
|
12
|
+
await cloned.text();
|
|
13
|
+
if (body !== null && body !== undefined && body !== '') {
|
|
14
|
+
return body;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Traces HTTP response for observability using Flow SDK tracing
|
|
20
|
+
* Respects LOG_HTTP_VERBOSE environment variable for detailed logging
|
|
21
|
+
*/
|
|
22
|
+
export const traceResponse = async (request, options, response) => {
|
|
23
|
+
const inputData = {
|
|
24
|
+
method: request.method,
|
|
25
|
+
url: request.url
|
|
26
|
+
};
|
|
27
|
+
const outputData = {
|
|
28
|
+
status: response.status,
|
|
29
|
+
statusText: response.statusText
|
|
30
|
+
};
|
|
31
|
+
if (config.logVerbose) {
|
|
32
|
+
const requestHeaders = Object.fromEntries(request.headers.entries());
|
|
33
|
+
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
34
|
+
inputData.requestHeaders = redactHeaders(requestHeaders);
|
|
35
|
+
outputData.headers = redactHeaders(responseHeaders);
|
|
36
|
+
outputData.body = await safeParseResponseBody(response);
|
|
37
|
+
}
|
|
38
|
+
trace({
|
|
39
|
+
lib: 'flow-http',
|
|
40
|
+
event: 'http.response',
|
|
41
|
+
input: inputData,
|
|
42
|
+
output: outputData
|
|
43
|
+
});
|
|
44
|
+
return response;
|
|
45
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Options } from 'ky';
|
|
2
|
+
/**
|
|
3
|
+
* Creates an HTTP client with Flow SDK tracing
|
|
4
|
+
*
|
|
5
|
+
* @param options - ky options to extend the base client with
|
|
6
|
+
* @returns Extended ky instance with Flow SDK hooks
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { httpClient } from '@output.ai/http';
|
|
11
|
+
*
|
|
12
|
+
* const client = httpClient({
|
|
13
|
+
* prefixUrl: 'https://api.example.com',
|
|
14
|
+
* timeout: 30000,
|
|
15
|
+
* retry: { limit: 3 }
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const response = await client.get('users/1');
|
|
19
|
+
* const data = await response.json();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function httpClient(options?: Options): import("ky").KyInstance;
|
|
23
|
+
export { HTTPError, TimeoutError } from 'ky';
|
|
24
|
+
export type { Options as HttpClientOptions } from 'ky';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { httpClient } from './index.js';
|
|
3
|
+
describe('HTTP Client Authentication Integration', () => {
|
|
4
|
+
const httpBinClient = httpClient({
|
|
5
|
+
prefixUrl: 'https://httpbin.org',
|
|
6
|
+
timeout: 5000
|
|
7
|
+
});
|
|
8
|
+
const clientsClient = httpBinClient.extend({
|
|
9
|
+
headers: {
|
|
10
|
+
'X-API-Key': 'demo-api-key-12345'
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
const contractsClient = httpBinClient.extend({
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Basic ${Buffer.from('demo-user:demo-pass').toString('base64')}`
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
describe('Authentication Headers', () => {
|
|
19
|
+
it('should include API key for clients endpoints', async () => {
|
|
20
|
+
const response = await clientsClient.get('anything/clients');
|
|
21
|
+
const data = await response.json();
|
|
22
|
+
expect(data.headers['X-Api-Key']).toBe('demo-api-key-12345');
|
|
23
|
+
expect(data.url).toContain('/anything/clients');
|
|
24
|
+
expect(data.method).toBe('GET');
|
|
25
|
+
}, 10000);
|
|
26
|
+
it('should include Basic auth for contracts endpoints', async () => {
|
|
27
|
+
const response = await contractsClient.get('anything/contracts');
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
expect(data.headers['Authorization']).toMatch(/^Basic /);
|
|
30
|
+
expect(data.headers['Authorization']).toBe(`Basic ${Buffer.from('demo-user:demo-pass').toString('base64')}`);
|
|
31
|
+
expect(data.url).toContain('/anything/contracts');
|
|
32
|
+
}, 10000);
|
|
33
|
+
it('should remove auth headers when overridden with undefined', async () => {
|
|
34
|
+
const response = await clientsClient.get('anything/clients/export', {
|
|
35
|
+
headers: { 'X-API-Key': undefined }
|
|
36
|
+
});
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
expect(data.headers['X-Api-Key']).toBeUndefined();
|
|
39
|
+
expect(data.url).toContain('/anything/clients/export');
|
|
40
|
+
}, 10000);
|
|
41
|
+
it('should properly handle POST with JSON data and authentication', async () => {
|
|
42
|
+
const testData = { name: 'Test Client', email: 'test@example.com' };
|
|
43
|
+
const response = await clientsClient.post('anything/clients', { json: testData });
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
expect(data.headers['X-Api-Key']).toBe('demo-api-key-12345');
|
|
46
|
+
expect(data.json).toEqual(testData);
|
|
47
|
+
expect(data.method).toBe('POST');
|
|
48
|
+
}, 10000);
|
|
49
|
+
});
|
|
50
|
+
describe('URL Path Construction', () => {
|
|
51
|
+
it('should correctly build URLs with chained prefixUrl', async () => {
|
|
52
|
+
const response = await clientsClient.get('anything/clients/details');
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
expect(data.url).toContain('/anything/clients/details');
|
|
55
|
+
}, 10000);
|
|
56
|
+
it('should handle root path correctly', async () => {
|
|
57
|
+
const response = await contractsClient.get('anything/contracts');
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
expect(data.url).toMatch(/\/anything\/contracts\/?$/);
|
|
60
|
+
}, 10000);
|
|
61
|
+
it('should handle POST to specific endpoints', async () => {
|
|
62
|
+
const testContract = { clientId: '123', title: 'Test Contract', value: 5000 };
|
|
63
|
+
const response = await contractsClient.post('anything/contracts/create', { json: testContract });
|
|
64
|
+
const data = await response.json();
|
|
65
|
+
expect(data.url).toContain('/anything/contracts/create');
|
|
66
|
+
expect(data.method).toBe('POST');
|
|
67
|
+
expect(data.json).toEqual(testContract);
|
|
68
|
+
}, 10000);
|
|
69
|
+
});
|
|
70
|
+
describe('Authentication Override Patterns', () => {
|
|
71
|
+
it('should allow per-request header overrides', async () => {
|
|
72
|
+
const response = await clientsClient.get('anything/clients/public', {
|
|
73
|
+
headers: {
|
|
74
|
+
'X-API-Key': 'different-key-456'
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
expect(data.headers['X-Api-Key']).toBe('different-key-456');
|
|
79
|
+
}, 10000);
|
|
80
|
+
it('should support multiple authentication methods', async () => {
|
|
81
|
+
const response = await contractsClient.get('anything/contracts/special', {
|
|
82
|
+
headers: {
|
|
83
|
+
'X-Special-Token': 'special-value',
|
|
84
|
+
'X-Client-ID': 'client-123'
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const data = await response.json();
|
|
88
|
+
expect(data.headers['Authorization']).toMatch(/^Basic /);
|
|
89
|
+
expect(data.headers['X-Special-Token']).toBe('special-value');
|
|
90
|
+
expect(data.headers['X-Client-Id']).toBe('client-123');
|
|
91
|
+
}, 10000);
|
|
92
|
+
});
|
|
93
|
+
describe('Real API Client Pattern', () => {
|
|
94
|
+
it('should demonstrate the complete authentication pattern', async () => {
|
|
95
|
+
const clientsResponse = await clientsClient.get('anything/clients');
|
|
96
|
+
const clientsData = await clientsResponse.json();
|
|
97
|
+
expect(clientsData.headers['X-Api-Key']).toBe('demo-api-key-12345');
|
|
98
|
+
const createResponse = await clientsClient.post('anything/clients', {
|
|
99
|
+
json: { name: 'Test Client', email: 'test@example.com' }
|
|
100
|
+
});
|
|
101
|
+
const createData = await createResponse.json();
|
|
102
|
+
expect(createData.headers['X-Api-Key']).toBe('demo-api-key-12345');
|
|
103
|
+
const exportResponse = await clientsClient.get('anything/clients/export', { headers: { 'X-API-Key': undefined } });
|
|
104
|
+
const exportData = await exportResponse.json();
|
|
105
|
+
expect(exportData.headers['X-Api-Key']).toBeUndefined();
|
|
106
|
+
const contractsResponse = await contractsClient.get('anything/contracts');
|
|
107
|
+
const contractsResponseData = await contractsResponse.json();
|
|
108
|
+
expect(contractsResponseData.headers['Authorization']).toMatch(/^Basic /);
|
|
109
|
+
const contractCreateResponse = await contractsClient.post('anything/contracts', {
|
|
110
|
+
json: { clientId: '123', title: 'Service Agreement', value: 10000 }
|
|
111
|
+
});
|
|
112
|
+
const contractCreateData = await contractCreateResponse.json();
|
|
113
|
+
expect(contractCreateData.headers['Authorization']).toMatch(/^Basic /);
|
|
114
|
+
console.log('✅ All authentication patterns working correctly');
|
|
115
|
+
}, 15000);
|
|
116
|
+
});
|
|
117
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import ky from 'ky';
|
|
2
|
+
import { traceRequest, traceResponse, traceError } from './hooks/index.js';
|
|
3
|
+
// Create base HTTP client with standard Flow SDK tracing hooks
|
|
4
|
+
const baseHttpClient = ky.create({
|
|
5
|
+
hooks: {
|
|
6
|
+
beforeRequest: [
|
|
7
|
+
traceRequest
|
|
8
|
+
],
|
|
9
|
+
afterResponse: [
|
|
10
|
+
traceResponse
|
|
11
|
+
],
|
|
12
|
+
beforeError: [
|
|
13
|
+
traceError
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Creates an HTTP client with Flow SDK tracing
|
|
19
|
+
*
|
|
20
|
+
* @param options - ky options to extend the base client with
|
|
21
|
+
* @returns Extended ky instance with Flow SDK hooks
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* import { httpClient } from '@output.ai/http';
|
|
26
|
+
*
|
|
27
|
+
* const client = httpClient({
|
|
28
|
+
* prefixUrl: 'https://api.example.com',
|
|
29
|
+
* timeout: 30000,
|
|
30
|
+
* retry: { limit: 3 }
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* const response = await client.get('users/1');
|
|
34
|
+
* const data = await response.json();
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function httpClient(options = {}) {
|
|
38
|
+
return baseHttpClient.extend(options);
|
|
39
|
+
}
|
|
40
|
+
export { HTTPError, TimeoutError } from 'ky';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { httpClient, HTTPError, TimeoutError } from './index.js';
|
|
3
|
+
import { trace } from '@output.ai/trace';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
vi.mock('@output.ai/trace', () => ({
|
|
6
|
+
trace: vi.fn()
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./config.js', () => ({
|
|
9
|
+
config: {
|
|
10
|
+
logVerbose: false
|
|
11
|
+
}
|
|
12
|
+
}));
|
|
13
|
+
// Mock ky at the module level to intercept at the source
|
|
14
|
+
vi.mock('ky', () => {
|
|
15
|
+
const createMockResponse = () => new Response(JSON.stringify({ success: true }), {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: { 'content-type': 'application/json' }
|
|
18
|
+
});
|
|
19
|
+
class MockKy {
|
|
20
|
+
hooks = {};
|
|
21
|
+
options = {};
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.hooks = options.hooks || {};
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
async runHooks(hookType, ...args) {
|
|
27
|
+
const hooks = this.hooks[hookType] || [];
|
|
28
|
+
for (const hook of hooks) {
|
|
29
|
+
await hook(...args);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async get(url, options = {}) {
|
|
33
|
+
return this.makeRequest('GET', url, options);
|
|
34
|
+
}
|
|
35
|
+
async post(url, options = {}) {
|
|
36
|
+
return this.makeRequest('POST', url, options);
|
|
37
|
+
}
|
|
38
|
+
async put(url, options = {}) {
|
|
39
|
+
return this.makeRequest('PUT', url, options);
|
|
40
|
+
}
|
|
41
|
+
async patch(url, options = {}) {
|
|
42
|
+
return this.makeRequest('PATCH', url, options);
|
|
43
|
+
}
|
|
44
|
+
async delete(url, options = {}) {
|
|
45
|
+
return this.makeRequest('DELETE', url, options);
|
|
46
|
+
}
|
|
47
|
+
async makeRequest(method, url, options = {}) {
|
|
48
|
+
// Construct full URL like ky would
|
|
49
|
+
const fullUrl = this.options.prefixUrl ? `${this.options.prefixUrl}/${url}` : `https://example.com/${url}`;
|
|
50
|
+
const request = new Request(fullUrl, { method });
|
|
51
|
+
// Run beforeRequest hooks
|
|
52
|
+
await this.runHooks('beforeRequest', request);
|
|
53
|
+
const response = createMockResponse();
|
|
54
|
+
// Run afterResponse hooks
|
|
55
|
+
await this.runHooks('afterResponse', request, options, response);
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
extend(options = {}) {
|
|
59
|
+
const mergedOptions = { ...this.options, ...options };
|
|
60
|
+
const mergedHooks = { ...this.hooks };
|
|
61
|
+
if (options.hooks) {
|
|
62
|
+
Object.entries(options.hooks).forEach(([hookType, hookArray]) => {
|
|
63
|
+
mergedHooks[hookType] = [
|
|
64
|
+
...(this.hooks[hookType] || []),
|
|
65
|
+
...(Array.isArray(hookArray) ? hookArray : [])
|
|
66
|
+
];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
mergedOptions.hooks = mergedHooks;
|
|
70
|
+
return new MockKy(mergedOptions);
|
|
71
|
+
}
|
|
72
|
+
create(options = {}) {
|
|
73
|
+
return new MockKy(options);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
default: new MockKy(),
|
|
78
|
+
create: (options) => new MockKy(options),
|
|
79
|
+
HTTPError: class HTTPError extends Error {
|
|
80
|
+
constructor(message) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = 'HTTPError';
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
TimeoutError: class TimeoutError extends Error {
|
|
86
|
+
constructor(message) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = 'TimeoutError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const mockedTrace = trace;
|
|
94
|
+
const mockedConfig = vi.mocked(config);
|
|
95
|
+
describe('HTTP Client', () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
mockedTrace.mockClear();
|
|
98
|
+
});
|
|
99
|
+
describe('httpClient function', () => {
|
|
100
|
+
it('should create an HTTP client with default options', () => {
|
|
101
|
+
const client = httpClient();
|
|
102
|
+
expect(client).toBeDefined();
|
|
103
|
+
expect(typeof client.get).toBe('function');
|
|
104
|
+
expect(typeof client.post).toBe('function');
|
|
105
|
+
expect(typeof client.put).toBe('function');
|
|
106
|
+
expect(typeof client.patch).toBe('function');
|
|
107
|
+
expect(typeof client.delete).toBe('function');
|
|
108
|
+
});
|
|
109
|
+
it('should create an HTTP client with custom options', () => {
|
|
110
|
+
const client = httpClient({
|
|
111
|
+
prefixUrl: 'https://api.example.com',
|
|
112
|
+
timeout: 5000
|
|
113
|
+
});
|
|
114
|
+
expect(client).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
it('should allow method chaining with extend', () => {
|
|
117
|
+
const client = httpClient();
|
|
118
|
+
const extendedClient = client.extend({
|
|
119
|
+
headers: { 'X-Custom': 'test' }
|
|
120
|
+
});
|
|
121
|
+
expect(extendedClient).toBeDefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('HTTP Client Interface', () => {
|
|
125
|
+
it('should have all HTTP methods', () => {
|
|
126
|
+
const client = httpClient();
|
|
127
|
+
expect(typeof client.get).toBe('function');
|
|
128
|
+
expect(typeof client.post).toBe('function');
|
|
129
|
+
expect(typeof client.put).toBe('function');
|
|
130
|
+
expect(typeof client.patch).toBe('function');
|
|
131
|
+
expect(typeof client.delete).toBe('function');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('Error Exports', () => {
|
|
135
|
+
it('should export HTTPError', () => {
|
|
136
|
+
expect(HTTPError).toBeDefined();
|
|
137
|
+
expect(typeof HTTPError).toBe('function');
|
|
138
|
+
});
|
|
139
|
+
it('should export TimeoutError', () => {
|
|
140
|
+
expect(TimeoutError).toBeDefined();
|
|
141
|
+
expect(typeof TimeoutError).toBe('function');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('Tracing Configuration', () => {
|
|
145
|
+
it('should not trace headers or bodies by default', async () => {
|
|
146
|
+
mockedConfig.logVerbose = false;
|
|
147
|
+
const client = httpClient({
|
|
148
|
+
prefixUrl: 'https://api.example.com'
|
|
149
|
+
});
|
|
150
|
+
await client.get('users/1');
|
|
151
|
+
expect(mockedTrace).toHaveBeenCalled();
|
|
152
|
+
const traceCall = mockedTrace.mock.calls.find(call => call[0].event === 'http.request');
|
|
153
|
+
expect(traceCall).toBeDefined();
|
|
154
|
+
if (traceCall) {
|
|
155
|
+
expect(traceCall[0].input.headers).toBeUndefined();
|
|
156
|
+
expect(traceCall[0].input.body).toBeUndefined();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
it('should trace headers and bodies when verbose logging is enabled', async () => {
|
|
160
|
+
mockedConfig.logVerbose = true;
|
|
161
|
+
const client = httpClient({
|
|
162
|
+
prefixUrl: 'https://api.example.com'
|
|
163
|
+
});
|
|
164
|
+
await client.post('users', { json: { name: 'test', email: 'test@example.com' } });
|
|
165
|
+
expect(mockedTrace).toHaveBeenCalled();
|
|
166
|
+
const requestTraceCall = mockedTrace.mock.calls.find(call => call[0].event === 'http.request');
|
|
167
|
+
expect(requestTraceCall).toBeDefined();
|
|
168
|
+
if (requestTraceCall) {
|
|
169
|
+
expect(requestTraceCall[0].input.headers).toBeDefined();
|
|
170
|
+
expect(requestTraceCall[0].input.body).toBeDefined();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('Hook Preservation', () => {
|
|
175
|
+
it('should preserve original hooks when extending client with custom hooks', async () => {
|
|
176
|
+
const customBeforeRequestCalled = vi.fn();
|
|
177
|
+
const customAfterResponseCalled = vi.fn();
|
|
178
|
+
const customBeforeErrorCalled = vi.fn();
|
|
179
|
+
const client = httpClient({
|
|
180
|
+
prefixUrl: 'https://api.example.com'
|
|
181
|
+
});
|
|
182
|
+
const extendedClient = client.extend({
|
|
183
|
+
hooks: {
|
|
184
|
+
beforeRequest: [
|
|
185
|
+
async (request) => {
|
|
186
|
+
customBeforeRequestCalled();
|
|
187
|
+
return request;
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
afterResponse: [
|
|
191
|
+
async (request, options, response) => {
|
|
192
|
+
customAfterResponseCalled();
|
|
193
|
+
return response;
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
beforeError: [
|
|
197
|
+
async (error) => {
|
|
198
|
+
customBeforeErrorCalled();
|
|
199
|
+
return error;
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
await extendedClient.get('users/1');
|
|
205
|
+
expect(customBeforeRequestCalled).toHaveBeenCalled();
|
|
206
|
+
expect(customAfterResponseCalled).toHaveBeenCalled();
|
|
207
|
+
expect(mockedTrace).toHaveBeenCalled();
|
|
208
|
+
const requestTraceCall = mockedTrace.mock.calls.find(call => call[0].event === 'http.request');
|
|
209
|
+
const responseTraceCall = mockedTrace.mock.calls.find(call => call[0].event === 'http.response');
|
|
210
|
+
expect(requestTraceCall).toBeDefined();
|
|
211
|
+
expect(responseTraceCall).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('Mocking Verification', () => {
|
|
215
|
+
it('should use mocked responses and not make real HTTP requests', async () => {
|
|
216
|
+
const client = httpClient({
|
|
217
|
+
prefixUrl: 'https://api.example.com'
|
|
218
|
+
});
|
|
219
|
+
// Test GET request
|
|
220
|
+
const getResponse = await client.get('users/1');
|
|
221
|
+
const getData = await getResponse.json();
|
|
222
|
+
expect(getData).toEqual({ success: true });
|
|
223
|
+
// Test POST request
|
|
224
|
+
const postResponse = await client.post('users', { json: { name: 'test' } });
|
|
225
|
+
const postData = await postResponse.json();
|
|
226
|
+
expect(postData).toEqual({ success: true });
|
|
227
|
+
// Verify no actual network delay (should be very fast)
|
|
228
|
+
const startTime = Date.now();
|
|
229
|
+
await client.get('test');
|
|
230
|
+
const duration = Date.now() - startTime;
|
|
231
|
+
expect(duration).toBeLessThan(50); // Mocked calls should be nearly instantaneous
|
|
232
|
+
// Verify that responses come from mocks, not real HTTP
|
|
233
|
+
expect(getData.success).toBe(true);
|
|
234
|
+
expect(postData.success).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { redactHeaders } from './redact-headers.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { redactHeaders } from './redact-headers.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive header patterns for redaction (case-insensitive)
|
|
3
|
+
*/
|
|
4
|
+
const SENSITIVE_HEADER_PATTERNS = [
|
|
5
|
+
/authorization/i,
|
|
6
|
+
/token/i,
|
|
7
|
+
/api-?key/i,
|
|
8
|
+
/apikey/i,
|
|
9
|
+
/secret/i,
|
|
10
|
+
/password/i,
|
|
11
|
+
/key/i,
|
|
12
|
+
/cookie/i
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Redacts sensitive headers for safe logging
|
|
16
|
+
* @param headers - Headers object to redact
|
|
17
|
+
* @returns Object with sensitive headers redacted
|
|
18
|
+
*/
|
|
19
|
+
export function redactHeaders(headers) {
|
|
20
|
+
const result = {};
|
|
21
|
+
const entries = headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
22
|
+
for (const [key, value] of entries) {
|
|
23
|
+
const isSensitive = SENSITIVE_HEADER_PATTERNS.some(pattern => pattern.test(key));
|
|
24
|
+
result[key] = isSensitive ? '[REDACTED]' : value;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { redactHeaders } from './redact-headers.js';
|
|
3
|
+
describe('redactHeaders', () => {
|
|
4
|
+
describe('with Record<string, string> input', () => {
|
|
5
|
+
it('should redact sensitive headers (case insensitive)', () => {
|
|
6
|
+
const headers = {
|
|
7
|
+
Authorization: 'Bearer token123',
|
|
8
|
+
'X-API-Key': 'secret-key',
|
|
9
|
+
apikey: 'another-secret',
|
|
10
|
+
'X-Auth-Token': 'auth-token',
|
|
11
|
+
'Secret-Header': 'top-secret',
|
|
12
|
+
Password: 'password123',
|
|
13
|
+
'Private-Key': 'private-key-data',
|
|
14
|
+
Cookie: 'session=abc123',
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'User-Agent': 'test-agent'
|
|
17
|
+
};
|
|
18
|
+
const result = redactHeaders(headers);
|
|
19
|
+
expect(result).toEqual({
|
|
20
|
+
Authorization: '[REDACTED]',
|
|
21
|
+
'X-API-Key': '[REDACTED]',
|
|
22
|
+
apikey: '[REDACTED]',
|
|
23
|
+
'X-Auth-Token': '[REDACTED]',
|
|
24
|
+
'Secret-Header': '[REDACTED]',
|
|
25
|
+
Password: '[REDACTED]',
|
|
26
|
+
'Private-Key': '[REDACTED]',
|
|
27
|
+
Cookie: '[REDACTED]',
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'User-Agent': 'test-agent'
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('should handle mixed case header names', () => {
|
|
33
|
+
const headers = {
|
|
34
|
+
AUTHORIZATION: 'Bearer token',
|
|
35
|
+
'x-api-key': 'secret',
|
|
36
|
+
'Api-Key': 'another-secret',
|
|
37
|
+
'TOKEN-HEADER': 'token-value',
|
|
38
|
+
'content-type': 'application/json'
|
|
39
|
+
};
|
|
40
|
+
const result = redactHeaders(headers);
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
AUTHORIZATION: '[REDACTED]',
|
|
43
|
+
'x-api-key': '[REDACTED]',
|
|
44
|
+
'Api-Key': '[REDACTED]',
|
|
45
|
+
'TOKEN-HEADER': '[REDACTED]',
|
|
46
|
+
'content-type': 'application/json'
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it('should not redact non-sensitive headers', () => {
|
|
50
|
+
const headers = {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
Accept: 'application/json',
|
|
53
|
+
'User-Agent': 'test-agent',
|
|
54
|
+
'X-Custom-Header': 'custom-value',
|
|
55
|
+
'Cache-Control': 'no-cache'
|
|
56
|
+
};
|
|
57
|
+
const result = redactHeaders(headers);
|
|
58
|
+
expect(result).toEqual(headers);
|
|
59
|
+
});
|
|
60
|
+
it('should handle empty headers object', () => {
|
|
61
|
+
const headers = {};
|
|
62
|
+
const result = redactHeaders(headers);
|
|
63
|
+
expect(result).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
it('should handle headers with empty values', () => {
|
|
66
|
+
const headers = {
|
|
67
|
+
Authorization: '',
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'X-API-Key': ''
|
|
70
|
+
};
|
|
71
|
+
const result = redactHeaders(headers);
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
Authorization: '[REDACTED]',
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'X-API-Key': '[REDACTED]'
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('with Headers object input', () => {
|
|
80
|
+
it('should redact sensitive headers from Headers object', () => {
|
|
81
|
+
const headers = new Headers();
|
|
82
|
+
headers.set('Authorization', 'Bearer token123');
|
|
83
|
+
headers.set('X-API-Key', 'secret-key');
|
|
84
|
+
headers.set('Content-Type', 'application/json');
|
|
85
|
+
headers.set('User-Agent', 'test-agent');
|
|
86
|
+
const result = redactHeaders(headers);
|
|
87
|
+
expect(result).toEqual({
|
|
88
|
+
authorization: '[REDACTED]',
|
|
89
|
+
'x-api-key': '[REDACTED]',
|
|
90
|
+
'content-type': 'application/json',
|
|
91
|
+
'user-agent': 'test-agent'
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
it('should handle empty Headers object', () => {
|
|
95
|
+
const headers = new Headers();
|
|
96
|
+
const result = redactHeaders(headers);
|
|
97
|
+
expect(result).toEqual({});
|
|
98
|
+
});
|
|
99
|
+
it('should preserve header name casing from Headers object', () => {
|
|
100
|
+
const headers = new Headers();
|
|
101
|
+
headers.set('authorization', 'Bearer token');
|
|
102
|
+
headers.set('X-Custom-Header', 'value');
|
|
103
|
+
const result = redactHeaders(headers);
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
authorization: '[REDACTED]',
|
|
106
|
+
'x-custom-header': 'value'
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('sensitive header patterns', () => {
|
|
111
|
+
it('should redact headers containing "authorization"', () => {
|
|
112
|
+
const headers = {
|
|
113
|
+
Authorization: 'Bearer token',
|
|
114
|
+
'X-Authorization': 'token',
|
|
115
|
+
'Custom-Authorization-Header': 'value'
|
|
116
|
+
};
|
|
117
|
+
const result = redactHeaders(headers);
|
|
118
|
+
Object.keys(result).forEach(key => {
|
|
119
|
+
if (key.toLowerCase().includes('authorization')) {
|
|
120
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
it('should redact headers containing "token"', () => {
|
|
125
|
+
const headers = {
|
|
126
|
+
'X-Auth-Token': 'token123',
|
|
127
|
+
'Access-Token': 'access123',
|
|
128
|
+
'Refresh-Token': 'refresh123',
|
|
129
|
+
'Token-Header': 'token-value'
|
|
130
|
+
};
|
|
131
|
+
const result = redactHeaders(headers);
|
|
132
|
+
Object.keys(result).forEach(key => {
|
|
133
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
it('should redact headers containing "api-key" or "apikey"', () => {
|
|
137
|
+
const headers = {
|
|
138
|
+
'X-API-Key': 'key123',
|
|
139
|
+
'X-Api-Key': 'key456',
|
|
140
|
+
apikey: 'key789',
|
|
141
|
+
'Custom-ApiKey': 'custom-key'
|
|
142
|
+
};
|
|
143
|
+
const result = redactHeaders(headers);
|
|
144
|
+
Object.keys(result).forEach(key => {
|
|
145
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it('should redact headers containing "secret"', () => {
|
|
149
|
+
const headers = {
|
|
150
|
+
'X-Secret': 'secret123',
|
|
151
|
+
'Client-Secret': 'client-secret',
|
|
152
|
+
'Secret-Key': 'secret-key'
|
|
153
|
+
};
|
|
154
|
+
const result = redactHeaders(headers);
|
|
155
|
+
Object.keys(result).forEach(key => {
|
|
156
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
it('should redact headers containing "password"', () => {
|
|
160
|
+
const headers = {
|
|
161
|
+
Password: 'pass123',
|
|
162
|
+
'X-Password': 'secret-pass',
|
|
163
|
+
'User-Password': 'user-pass'
|
|
164
|
+
};
|
|
165
|
+
const result = redactHeaders(headers);
|
|
166
|
+
Object.keys(result).forEach(key => {
|
|
167
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
it('should redact headers containing "key"', () => {
|
|
171
|
+
const headers = {
|
|
172
|
+
'Private-Key': 'private123',
|
|
173
|
+
'Public-Key': 'public123',
|
|
174
|
+
'Encryption-Key': 'encrypt123',
|
|
175
|
+
'Symmetric-Key': 'sym123'
|
|
176
|
+
};
|
|
177
|
+
const result = redactHeaders(headers);
|
|
178
|
+
Object.keys(result).forEach(key => {
|
|
179
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it('should redact headers containing "cookie"', () => {
|
|
183
|
+
const headers = {
|
|
184
|
+
Cookie: 'session=abc123',
|
|
185
|
+
'Set-Cookie': 'token=xyz789',
|
|
186
|
+
'X-Cookie-Data': 'cookie-info'
|
|
187
|
+
};
|
|
188
|
+
const result = redactHeaders(headers);
|
|
189
|
+
Object.keys(result).forEach(key => {
|
|
190
|
+
expect(result[key]).toBe('[REDACTED]');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('edge cases', () => {
|
|
195
|
+
it('should handle headers with special characters in values', () => {
|
|
196
|
+
const headers = {
|
|
197
|
+
Authorization: 'Bearer !@#$%^&*()_+-=[]{}|;:,.<>?',
|
|
198
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
199
|
+
};
|
|
200
|
+
const result = redactHeaders(headers);
|
|
201
|
+
expect(result).toEqual({
|
|
202
|
+
Authorization: '[REDACTED]',
|
|
203
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it('should handle headers with unicode characters', () => {
|
|
207
|
+
const headers = {
|
|
208
|
+
'X-API-Key': '🔑secret-key🔐',
|
|
209
|
+
'X-Custom': 'héllo wörld'
|
|
210
|
+
};
|
|
211
|
+
const result = redactHeaders(headers);
|
|
212
|
+
expect(result).toEqual({
|
|
213
|
+
'X-API-Key': '[REDACTED]',
|
|
214
|
+
'X-Custom': 'héllo wörld'
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
it('should handle very long header values', () => {
|
|
218
|
+
const longValue = 'a'.repeat(10000);
|
|
219
|
+
const headers = {
|
|
220
|
+
Authorization: `Bearer ${longValue}`,
|
|
221
|
+
'X-Long-Header': longValue
|
|
222
|
+
};
|
|
223
|
+
const result = redactHeaders(headers);
|
|
224
|
+
expect(result).toEqual({
|
|
225
|
+
Authorization: '[REDACTED]',
|
|
226
|
+
'X-Long-Header': longValue
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
it('should match partial words in header names (current behavior)', () => {
|
|
230
|
+
const headers = {
|
|
231
|
+
Keyboard: 'qwerty', // contains "key" - will be redacted
|
|
232
|
+
Secretary: 'admin', // contains "secret" - will be redacted
|
|
233
|
+
Tokens: 'abc123', // contains "token" - will be redacted
|
|
234
|
+
'Content-Length': '123' // doesn't contain sensitive patterns - will not be redacted
|
|
235
|
+
};
|
|
236
|
+
const result = redactHeaders(headers);
|
|
237
|
+
expect(result).toEqual({
|
|
238
|
+
Keyboard: '[REDACTED]',
|
|
239
|
+
Secretary: '[REDACTED]',
|
|
240
|
+
Tokens: '[REDACTED]',
|
|
241
|
+
'Content-Length': '123'
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@output.ai/http",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Framework abstraction to make HTTP calls with tracing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rm -rf ./dist && tsc"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/growthxai/flow-sdk"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@output.ai/trace": "0.0.1",
|
|
20
|
+
"ky": "~1.9.1"
|
|
21
|
+
}
|
|
22
|
+
}
|