Skip to content

Commit 59c5a29

Browse files
committed
Add a first pass at the HTTP client API
1 parent 64b424d commit 59c5a29

File tree

5 files changed

+146
-14
lines changed

5 files changed

+146
-14
lines changed

package-lock.json

Lines changed: 25 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
"@types/klaw": "^3.0.2",
9999
"@types/lodash": "^4.14.117",
100100
"@types/mocha": "^5.2.5",
101-
"@types/node": "^16.3.2",
101+
"@types/node": "^16.18.38",
102102
"@types/node-forge": "^0.9.9",
103103
"@types/request-promise-native": "^1.0.15",
104104
"@types/rimraf": "^2.0.2",

src/api/api-model.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { reportError, addBreadcrumb } from '../error-tracking';
1010
import { HtkConfig } from "../config";
1111
import { ActivationError, Interceptor } from "../interceptors";
1212
import { getDnsServer } from '../dns-server';
13+
import * as Client from '../client/client';
1314

1415
const INTERCEPTOR_TIMEOUT = 1000;
1516

@@ -194,6 +195,13 @@ export class ApiModel {
194195
return { success: !interceptor.isActive(proxyPort) };
195196
}
196197

198+
async sendRequest(
199+
requestDefinition: Client.RequestDefinition,
200+
requestOptions: Client.RequestOptions
201+
): Promise<Client.ResponseDefinition> {
202+
return Client.sendRequest(requestDefinition, requestOptions);
203+
}
204+
197205
}
198206

199207
// Wait for a promise, falling back to defaultValue on error or timeout

src/api/rest-api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ParsedQs } from 'qs';
1212

1313
import { ErrorLike, StatusError } from '../util/error';
1414
import { ApiModel } from './api-model';
15+
import * as Client from '../client/client';
1516

1617
/**
1718
* This file exposes the API model via a REST-ish classic HTTP API.
@@ -73,6 +74,34 @@ export function exposeRestAPI(
7374
const result = await apiModel.activateInterceptor(interceptorId, proxyPort, interceptorOptions);
7475
res.send({ result });
7576
}));
77+
78+
server.post('/client/send', handleErrors(async (req, res) => {
79+
const bodyData = req.body;
80+
if (!bodyData) throw new StatusError(400, "No request definition or options provided");
81+
82+
const {
83+
request,
84+
options
85+
} = bodyData;
86+
87+
if (!request) throw new StatusError(400, "No request definition provided");
88+
if (!options) throw new StatusError(400, "No request options provided");
89+
90+
const result = await apiModel.sendRequest({
91+
...request,
92+
// Body buffers are serialized as base64 (for both requests & responses)
93+
rawBody: Buffer.from(request.rawBody ?? '', 'base64')
94+
}, {
95+
...options
96+
});
97+
98+
res.send({
99+
result: {
100+
...result,
101+
rawBody: result.rawBody?.toString('base64') ?? ''
102+
}
103+
});
104+
}));
76105
}
77106

78107
function getProxyPort(stringishInput: any) {

src/client/client.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as net from 'net';
2+
import * as http from 'http';
3+
import * as https from 'https';
4+
import { streamToBuffer } from '../util/stream';
5+
6+
export type RawHeaders = Array<[key: string, value: string]>;
7+
8+
export interface RequestDefinition {
9+
method: string;
10+
url: string;
11+
headers: RawHeaders;
12+
rawBody?: Uint8Array;
13+
}
14+
15+
export interface RequestOptions {
16+
}
17+
18+
export interface ResponseDefinition {
19+
statusCode: number;
20+
statusMessage?: string;
21+
headers: RawHeaders;
22+
rawBody?: Buffer;
23+
}
24+
25+
export async function sendRequest(
26+
requestDefn: RequestDefinition,
27+
options: RequestOptions
28+
): Promise<ResponseDefinition> {
29+
const url = new URL(requestDefn.url);
30+
31+
const request = (url.protocol === 'https:' ? https : http).request({
32+
protocol: url.protocol,
33+
host: url.host,
34+
port: url.port,
35+
path: url.pathname,
36+
37+
method: requestDefn.method,
38+
39+
// Node supports sending raw headers via [key, value, key, value] array, but we need an
40+
// 'any' as the types don't believe it:
41+
headers: flattenPairedRawHeaders(requestDefn.headers) as any
42+
});
43+
44+
if (requestDefn.rawBody?.byteLength) {
45+
request.end(requestDefn.rawBody);
46+
} else {
47+
request.end();
48+
}
49+
50+
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
51+
request.on('error', reject);
52+
request.on('response', resolve);
53+
});
54+
55+
const body = await streamToBuffer(response);
56+
57+
return {
58+
statusCode: response.statusCode!,
59+
statusMessage: response.statusMessage,
60+
headers: pairFlatRawHeaders(response.rawHeaders),
61+
rawBody: body
62+
}
63+
}
64+
65+
/**
66+
* Turn node's _very_ raw headers ([k, v, k, v, ...]) into our slightly more convenient
67+
* pairwise tuples [[k, v], [k, v], ...] RawHeaders structure.
68+
*/
69+
export function pairFlatRawHeaders(flatRawHeaders: string[]): RawHeaders {
70+
const result: RawHeaders = [];
71+
for (let i = 0; i < flatRawHeaders.length; i += 2 /* Move two at a time */) {
72+
result[i/2] = [flatRawHeaders[i], flatRawHeaders[i+1]];
73+
}
74+
return result;
75+
}
76+
77+
/**
78+
* Turn our raw headers [[k, v], [k, v], ...] tuples into Node's very flat
79+
* [k, v, k, v, ...] structure.
80+
*/
81+
export function flattenPairedRawHeaders(rawHeaders: RawHeaders): string[] {
82+
return rawHeaders.flat();
83+
}

0 commit comments

Comments
 (0)