Skip to content

Commit c6a2dc5

Browse files
committed
Add a REST API interface
GraphQL will remain supported for now, for backward compat, but should eventually be phased out. It's been a bit of a pain in lots of ways: can't easily recognize requests from URL, very inflexible for forward/backward compat, very hard to support streaming or other non-trivial flow use cases, painful npm dep trees, etc.
1 parent 9d3fcff commit c6a2dc5

File tree

6 files changed

+256
-14
lines changed

6 files changed

+256
-14
lines changed

src/api/api-model.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class ApiModel {
5151
// convenient to do it up front.
5252
certificateFingerprint: generateSPKIFingerprint(this.config.https.certContent),
5353

54-
networkInterfaces: os.networkInterfaces(),
54+
networkInterfaces: this.getNetworkInterfaces(),
5555
systemProxy: await withFallback(() => getSystemProxy(), 2000, undefined),
5656

5757
dnsServers: proxyPort
@@ -70,6 +70,10 @@ export class ApiModel {
7070
}, 2000, []);
7171
}
7272

73+
getNetworkInterfaces() {
74+
return os.networkInterfaces();
75+
}
76+
7377
getInterceptors(proxyPort?: number) {
7478
return Promise.all(
7579
Object.keys(this.interceptors).map((key) => {
@@ -88,7 +92,7 @@ export class ApiModel {
8892
id: interceptor.id,
8993
version: interceptor.version,
9094
metadata: options.metadataType
91-
? this.getInterceptorMetadata(id, options.metadataType)
95+
? await this.getInterceptorMetadata(id, options.metadataType)
9296
: undefined,
9397
isActivable: await withFallback(
9498
async () => interceptor.isActivable(),

src/api/api-server.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { shutdown } from '../shutdown';
1111

1212
import { ApiModel } from './api-model';
1313
import { exposeGraphQLAPI } from './graphql-api';
14+
import { exposeRestAPI } from './rest-api';
1415

1516
/**
1617
* This file contains the core server API, used by the UI to query
@@ -27,6 +28,11 @@ import { exposeGraphQLAPI } from './graphql-api';
2728
* - Optionally (always set in the HTK app) requires an auth
2829
* token with every request, provided by $HTK_SERVER_TOKEN or
2930
* --token at startup.
31+
*
32+
* The API is available in two formats: a simple REST-ish API,
33+
* and a GraphQL that exists for backward compatibility. All
34+
* future development will happen on the REST API, and the
35+
* GraphQL API will eventually be removed.
3036
*/
3137

3238
export class HttpToolkitServerApi extends events.EventEmitter {
@@ -63,10 +69,14 @@ export class HttpToolkitServerApi extends events.EventEmitter {
6369
}));
6470

6571
this.server.use((req, res, next) => {
66-
if (req.method !== 'POST') {
67-
// We allow only POST, because that's all we expect for GraphQL queries,
72+
if (req.path === '/' && req.method !== 'POST') {
73+
// We allow only POST to GQL, because that's all we expect for GraphQL queries,
6874
// and this helps derisk some (admittedly unlikely) XSRF possibilities.
6975
res.status(405).send('Only POST requests are supported');
76+
77+
// XSRF is less of a risk elsewhere, as REST GET endpoints don't do dangerous
78+
// things. Also we're enforcing Origin headers everywhere so it should be
79+
// impossible regardless, but better safe than sorry!
7080
} else {
7181
next();
7282
}
@@ -99,6 +109,9 @@ export class HttpToolkitServerApi extends events.EventEmitter {
99109
}
100110
)
101111

112+
this.server.use(express.json());
113+
114+
exposeRestAPI(this.server, apiModel);
102115
exposeGraphQLAPI(this.server, apiModel);
103116
}
104117

src/api/graphql-api.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ const buildResolvers = (apiModel: ApiModel) => {
8080
'certificateContent',
8181
'certificateFingerprint'
8282
]),
83-
networkInterfaces: async (__: unknown, ___: unknown, context: any) =>
84-
(await getConfig(context)).networkInterfaces,
83+
networkInterfaces: apiModel.getNetworkInterfaces(),
8584
systemProxy: async (__: unknown, ___: unknown, context: any) =>
8685
(await getConfig(context)).systemProxy,
8786
dnsServers: async (__: void, { proxyPort }: { proxyPort: number }): Promise<string[]> =>

src/api/rest-api.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type {
2+
Application as ExpressApp,
3+
NextFunction
4+
} from 'express';
5+
import type {
6+
Request,
7+
Response,
8+
RequestHandler,
9+
RouteParameters
10+
} from 'express-serve-static-core';
11+
import type { ParsedQs } from 'qs';
12+
13+
import { ErrorLike, StatusError } from '../util/error';
14+
import { ApiModel } from './api-model';
15+
16+
/**
17+
* This file exposes the API model via a REST-ish classic HTTP API.
18+
* All endpoints take & receive JSON only. Status codes are used
19+
* directly to indicate any errors, with any details returned
20+
* as JSON in an `error` field.
21+
*/
22+
23+
export function exposeRestAPI(
24+
server: ExpressApp,
25+
apiModel: ApiModel
26+
) {
27+
server.get('/version', handleErrors((_req, res) => {
28+
res.send({ version: apiModel.getVersion() });
29+
}));
30+
31+
server.post('/update', handleErrors((_req, res) => {
32+
apiModel.updateServer();
33+
res.send({ success: true });
34+
}));
35+
36+
server.post('/shutdown', handleErrors((_req, res) => {
37+
apiModel.shutdownServer();
38+
res.send({ success: true });
39+
}));
40+
41+
server.get('/config', handleErrors(async (req, res) => {
42+
const proxyPort = getProxyPort(req.query.proxyPort);
43+
res.send({ config: await apiModel.getConfig(proxyPort) });
44+
}));
45+
46+
server.get('/config/network-interfaces', handleErrors((_req, res) => {
47+
res.send({ networkInterfaces: apiModel.getNetworkInterfaces() });
48+
}));
49+
50+
// Get top-line data on the current interceptor state
51+
server.get('/interceptors', handleErrors(async (req, res) => {
52+
const proxyPort = getProxyPort(req.query.proxyPort);
53+
res.send({ interceptors: await apiModel.getInterceptors(proxyPort) });
54+
}));
55+
56+
// Get full detailed data on a specific interceptor state, i.e. detailed metadata.
57+
server.get('/interceptors/:id', handleErrors(async (req, res) => {
58+
const interceptorId = req.params.id;
59+
const proxyPort = getProxyPort(req.query.proxyPort);
60+
61+
res.send({
62+
interceptors: await apiModel.getInterceptor(interceptorId, {
63+
proxyPort: proxyPort,
64+
metadataType: 'detailed'
65+
})
66+
});
67+
}));
68+
69+
server.post('/interceptors/:id/activate/:proxyPort', handleErrors(async (req, res) => {
70+
const interceptorId = req.params.id;
71+
const proxyPort = parseInt(req.params.proxyPort, 10);
72+
if (isNaN(proxyPort)) throw new StatusError(400, `Could not parse required proxy port: ${req.params.proxyPort}`);
73+
74+
const interceptorOptions = req.body || undefined;
75+
76+
const result = await apiModel.activateInterceptor(interceptorId, proxyPort, interceptorOptions);
77+
res.send({ result });
78+
}));
79+
}
80+
81+
function getProxyPort(stringishInput: any) {
82+
// Proxy port is optional everywhere, to make it possible to query data
83+
// in parallel (without waiting for Mockttp) for potentially faster setup.
84+
85+
if (!stringishInput) return undefined;
86+
87+
const proxyPort = parseInt(stringishInput as string, 10);
88+
if (isNaN(proxyPort)) return undefined;
89+
90+
return proxyPort;
91+
}
92+
93+
// A wrapper to automatically apply async error handling & responses to an Express handler. Fairly simple logic,
94+
// very awkward (but not actually very interesting) types.
95+
function handleErrors<
96+
Route extends string,
97+
P = RouteParameters<Route>,
98+
ResBody = any,
99+
ReqBody = any,
100+
ReqQuery = ParsedQs,
101+
Locals extends Record<string, any> = Record<string, any>,
102+
>(
103+
handler: RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals>
104+
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
105+
return (async (req: Request<P, ResBody, ReqBody, ReqQuery, Locals>, res: Response<any, Locals>, next: NextFunction) => {
106+
try {
107+
return await handler(req, res, next);
108+
} catch (e) {
109+
const error = e as ErrorLike;
110+
111+
console.log(`Error handling request to ${req.path}: ${error.message ?? error}`);
112+
reportError(error);
113+
114+
// Use default error handler if response started (kills the connection)
115+
if (res.headersSent) return next(error)
116+
else {
117+
const status = (error.status && error.status >= 400 && error.status < 600)
118+
? error.status
119+
: 500;
120+
121+
res.status(status).send({
122+
error: {
123+
code: error.code,
124+
message: error.message,
125+
stack: error.stack
126+
}
127+
})
128+
}
129+
}
130+
}) as any;
131+
}

src/util/error.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
export type ErrorLike = Partial<Error> & {
22
// Various properties we might want to look for on errors:
3-
code?: string,
4-
cmd?: string,
5-
signal?: string
3+
code?: string;
4+
cmd?: string;
5+
signal?: string;
6+
status?: number;
67
};
78

89
// Useful to easily cast and then examine errors that are otherwise 'unknown':
@@ -13,4 +14,25 @@ export function isErrorLike(error: any): error is ErrorLike {
1314
error.code ||
1415
error.stack
1516
)
17+
}
18+
19+
abstract class CustomErrorBase extends Error {
20+
constructor(message?: string) {
21+
super(message); // 'Error' breaks prototype chain here
22+
23+
// This restores the details of the real error subclass:
24+
this.name = new.target.name;
25+
Object.setPrototypeOf(this, new.target.prototype);
26+
}
27+
}
28+
export class StatusError extends CustomErrorBase {
29+
constructor(
30+
/**
31+
* Should be a valid HTTP status code
32+
*/
33+
public readonly status: number,
34+
message: string
35+
) {
36+
super(message);
37+
}
1638
}

test/integration-test.spec.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as tmp from 'tmp';
66
import decompress from 'decompress';
77
import { expect } from 'chai';
88
import getGraphQL from 'graphql.js';
9+
import fetch from 'node-fetch';
910

1011
import { getRemote } from 'mockttp';
1112

@@ -126,7 +127,7 @@ describe('Integration test', function () {
126127
await mockttp.stop();
127128
});
128129

129-
it('exposes the version over HTTP', async () => {
130+
it('exposes the version via GraphQL', async () => {
130131
const graphql = buildGraphql('http://localhost:45457/');
131132

132133
const response = await graphql(`
@@ -138,14 +139,43 @@ describe('Integration test', function () {
138139
expect(response.version).to.equal(require('../package.json').version);
139140
});
140141

141-
it('exposes interceptors over HTTP', async function () {
142+
it('exposes the version via REST', async () => {
143+
const response = await fetch('http://localhost:45457/version', {
144+
headers: { 'origin': 'https://app.httptoolkit.tech' }
145+
});
146+
147+
expect(response.ok).to.equal(true);
148+
const responseData = await response.json();
149+
150+
expect(responseData).to.deep.equal({
151+
version: require('../package.json').version
152+
});
153+
});
154+
155+
it('exposes the system configuration via REST', async () => {
156+
const response = await fetch('http://localhost:45457/config?proxyPort=8000', {
157+
headers: { 'origin': 'https://app.httptoolkit.tech' }
158+
});
159+
160+
expect(response.ok).to.equal(true);
161+
const responseData = await response.json();
162+
163+
const { config } = responseData;
164+
expect(config.certificatePath).not.to.equal(undefined);
165+
expect(config.certificateContent).not.to.equal(undefined);
166+
expect(config.certificateFingerprint).not.to.equal(undefined);
167+
expect(Object.keys(config.networkInterfaces).length).to.be.greaterThan(0);
168+
expect(config.ruleParameterKeys).to.deep.equal([]);
169+
expect(config.dnsServers.length).to.equal(1);
170+
});
171+
172+
it('exposes interceptors over GraphQL & REST', async function () {
142173
// Browser detection on a fresh machine (i.e. in CI) with many browsers
143174
// installed can take a couple of seconds. Give it one retry.
144175
this.retries(1);
145176

146177
const graphql = buildGraphql('http://localhost:45457/');
147-
148-
const response = await graphql(`
178+
const gqlResponse = await graphql(`
149179
query getInterceptors($proxyPort: Int!) {
150180
interceptors {
151181
id
@@ -156,6 +186,17 @@ describe('Integration test', function () {
156186
}
157187
`)({ proxyPort: 8000 });
158188

189+
const restResponse = await (await fetch('http://localhost:45457/interceptors?proxyPort=8000', {
190+
headers: { 'origin': 'https://app.httptoolkit.tech' }
191+
})).json();
192+
193+
// We drop metadata - much harder to assert against.
194+
restResponse.interceptors.forEach((interceptor: any) => {
195+
delete interceptor.metadata;
196+
});
197+
198+
expect(gqlResponse).to.deep.equal(restResponse);
199+
159200
const activable = (id: string, version = '1.0.0') => ({
160201
id,
161202
version,
@@ -170,7 +211,7 @@ describe('Integration test', function () {
170211
"isActive": false
171212
});
172213

173-
expect(response.interceptors).to.deep.equal([
214+
expect(restResponse.interceptors).to.deep.equal([
174215
activable('fresh-chrome'),
175216
activable('existing-chrome'),
176217
inactivable('fresh-chrome-beta'),
@@ -193,4 +234,36 @@ describe('Integration test', function () {
193234
activable('docker-attach')
194235
]);
195236
});
237+
238+
it("allows activating interceptors via REST API", async () => {
239+
const response = await fetch('http://localhost:45457/interceptors/existing-terminal/activate/8000', {
240+
method: 'POST',
241+
headers: { 'origin': 'https://app.httptoolkit.tech' }
242+
});
243+
244+
expect(response.ok).to.equal(true);
245+
const responseData = await response.json();
246+
expect(responseData).to.deep.equal({
247+
"result": {
248+
"metadata": {
249+
"commands": {
250+
"Bash": {
251+
"command": "eval \"$(curl -sS localhost:8001/setup)\"",
252+
"description": "Bash-compatible"
253+
},
254+
"Fish": {
255+
"command": "curl -sS localhost:8001/fish-setup | source",
256+
"description": "Fish"
257+
},
258+
"Powershell": {
259+
"command": "Invoke-Expression (Invoke-WebRequest http://localhost:8001/ps-setup).Content",
260+
"description": "Powershell"
261+
}
262+
},
263+
"port": 8001
264+
},
265+
"success": true
266+
}
267+
});
268+
});
196269
});

0 commit comments

Comments
 (0)