Skip to content

Commit 008917f

Browse files
committed
Add caching & redirect httpbin endpoints
1 parent 448a023 commit 008917f

8 files changed

Lines changed: 455 additions & 3 deletions

File tree

src/endpoints/http-index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,6 @@ export * from './http/bearer.js';
5858
export * from './http/hidden-basic-auth.js';
5959
export * from './http/response-headers.js';
6060
export * from './http/base64.js';
61+
export * from './http/cache.js';
62+
export * from './http/etag.js';
63+
export * from './http/redirect.js';

src/endpoints/http/cache.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { StatusError } from '@httptoolkit/util';
2+
import { HttpEndpoint } from '../http-index.js';
3+
import { httpResponseInspection } from '../groups.js';
4+
import { buildHttpBinAnythingData } from '../../httpbin-compat.js';
5+
import { serializeJson } from '../../util.js';
6+
7+
const GET_FIELDS = ["url", "args", "headers", "origin"];
8+
9+
export const cache: HttpEndpoint = {
10+
matchPath: (path) => path === '/cache',
11+
handle: async (req, res) => {
12+
if (req.headers['if-modified-since'] || req.headers['if-none-match']) {
13+
res.writeHead(304).end();
14+
return;
15+
}
16+
17+
const data = await buildHttpBinAnythingData(req, { fieldFilter: GET_FIELDS });
18+
res.writeHead(200, { 'content-type': 'application/json' });
19+
res.end(serializeJson(data));
20+
},
21+
meta: {
22+
path: '/cache',
23+
description: 'Returns 304 Not Modified if an If-Modified-Since or If-None-Match header is present, otherwise returns the same response as /get.',
24+
examples: ['/cache'],
25+
group: httpResponseInspection
26+
}
27+
};
28+
29+
export const cacheWithAge: HttpEndpoint = {
30+
matchPath: (path) => {
31+
if (!path.startsWith('/cache/')) return false;
32+
const n = parseInt(path.slice('/cache/'.length), 10);
33+
if (isNaN(n)) throw new StatusError(400, `Invalid cache duration in ${path}`);
34+
return true;
35+
},
36+
handle: async (req, res) => {
37+
const n = parseInt(req.url!.split('?')[0].slice('/cache/'.length), 10);
38+
const data = await buildHttpBinAnythingData(req, { fieldFilter: GET_FIELDS });
39+
res.writeHead(200, {
40+
'content-type': 'application/json',
41+
'cache-control': `public, max-age=${n}`
42+
});
43+
res.end(serializeJson(data));
44+
},
45+
meta: {
46+
path: '/cache/{n}',
47+
description: 'Sets a Cache-Control header for n seconds, then returns the same response as /get.',
48+
examples: ['/cache/60'],
49+
group: httpResponseInspection
50+
}
51+
};

src/endpoints/http/etag.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { HttpEndpoint } from '../http-index.js';
2+
import { httpResponseInspection } from '../groups.js';
3+
import { buildHttpBinAnythingData } from '../../httpbin-compat.js';
4+
import { serializeJson } from '../../util.js';
5+
6+
const GET_FIELDS = ["url", "args", "headers", "origin"];
7+
8+
// Parse a comma-separated list of etag values, handling both quoted and unquoted forms
9+
const parseEtagList = (header: string): string[] =>
10+
header.split(',').map(s => s.trim().replace(/^"(.*)"$/, '$1'));
11+
12+
export const etag: HttpEndpoint = {
13+
matchPath: (path) => path.startsWith('/etag/') && path.length > '/etag/'.length,
14+
handle: async (req, res) => {
15+
const etagValue = req.url!.split('?')[0].slice('/etag/'.length);
16+
17+
const ifNoneMatch = req.headers['if-none-match'];
18+
if (ifNoneMatch) {
19+
const tags = parseEtagList(ifNoneMatch);
20+
if (tags.includes(etagValue) || tags.includes('*')) {
21+
res.writeHead(304, { 'etag': etagValue }).end();
22+
return;
23+
}
24+
}
25+
26+
const ifMatch = req.headers['if-match'];
27+
if (ifMatch) {
28+
const tags = parseEtagList(ifMatch);
29+
if (!tags.includes(etagValue) && !tags.includes('*')) {
30+
res.writeHead(412, { 'etag': etagValue }).end();
31+
return;
32+
}
33+
}
34+
35+
const data = await buildHttpBinAnythingData(req, { fieldFilter: GET_FIELDS });
36+
res.writeHead(200, {
37+
'content-type': 'application/json',
38+
'etag': etagValue
39+
});
40+
res.end(serializeJson(data));
41+
},
42+
meta: {
43+
path: '/etag/{etag}',
44+
description: 'Assumes the given etag for the resource and responds according to If-None-Match and If-Match request headers.',
45+
examples: ['/etag/my-etag'],
46+
group: httpResponseInspection
47+
}
48+
};

src/endpoints/http/redirect.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { StatusError } from '@httptoolkit/util';
2+
import { HttpEndpoint, HttpRequest } from '../http-index.js';
3+
import { httpRedirects } from '../groups.js';
4+
import { TLSSocket } from 'tls';
5+
6+
const MAX_REDIRECTS = 100;
7+
8+
const getBaseUrl = (req: HttpRequest): string => {
9+
const isHTTPS = req.socket instanceof TLSSocket;
10+
const host = req.headers[':authority'] || req.headers.host;
11+
return `${isHTTPS ? 'https' : 'http'}://${host}`;
12+
};
13+
14+
const parseN = (path: string, prefix: string): number => {
15+
const n = parseInt(path.slice(prefix.length), 10);
16+
if (isNaN(n) || n < 1) throw new StatusError(400, `Invalid redirect count in ${path}`);
17+
if (n > MAX_REDIRECTS) throw new StatusError(400, `Redirect count exceeds maximum of ${MAX_REDIRECTS}`);
18+
return n;
19+
};
20+
21+
export const redirectTo: HttpEndpoint = {
22+
matchPath: (path) => path === '/redirect-to',
23+
handle: (req, res, { query }) => {
24+
const url = query.get('url');
25+
if (!url) {
26+
res.writeHead(400).end('Missing "url" query parameter');
27+
return;
28+
}
29+
30+
const statusCode = parseInt(query.get('status_code') || '302', 10);
31+
res.writeHead(statusCode, { 'location': url }).end();
32+
},
33+
meta: {
34+
path: '/redirect-to',
35+
description: 'Redirects to the URL specified in the "url" query parameter, with an optional "status_code" (default 302).',
36+
examples: ['/redirect-to?url=/get', '/redirect-to?url=/get&status_code=301'],
37+
group: httpRedirects
38+
}
39+
};
40+
41+
export const redirectN: HttpEndpoint = {
42+
matchPath: (path) => {
43+
if (!path.match(/^\/redirect\/\d+$/)) return false;
44+
parseN(path, '/redirect/');
45+
return true;
46+
},
47+
handle: (_req, res, { path }) => {
48+
const n = parseN(path, '/redirect/');
49+
const location = n <= 1 ? '/get' : `/relative-redirect/${n - 1}`;
50+
res.writeHead(302, { 'location': location }).end();
51+
},
52+
meta: {
53+
path: '/redirect/{n}',
54+
description: 'Redirects n times using relative URLs (via /relative-redirect), then returns /get.',
55+
examples: ['/redirect/3'],
56+
group: httpRedirects
57+
}
58+
};
59+
60+
export const relativeRedirectN: HttpEndpoint = {
61+
matchPath: (path) => {
62+
if (!path.match(/^\/relative-redirect\/\d+$/)) return false;
63+
parseN(path, '/relative-redirect/');
64+
return true;
65+
},
66+
handle: (req, res, { path }) => {
67+
const n = parseN(path, '/relative-redirect/');
68+
const location = n <= 1 ? '/get' : `/relative-redirect/${n - 1}`;
69+
res.writeHead(302, { 'location': location }).end();
70+
},
71+
meta: {
72+
path: '/relative-redirect/{n}',
73+
description: 'Redirects n times using relative URLs, then returns /get.',
74+
examples: ['/relative-redirect/3'],
75+
group: httpRedirects
76+
}
77+
};
78+
79+
export const absoluteRedirectN: HttpEndpoint = {
80+
matchPath: (path) => {
81+
if (!path.match(/^\/absolute-redirect\/\d+$/)) return false;
82+
parseN(path, '/absolute-redirect/');
83+
return true;
84+
},
85+
handle: (req, res, { path }) => {
86+
const n = parseN(path, '/absolute-redirect/');
87+
const base = getBaseUrl(req);
88+
const location = n <= 1 ? `${base}/get` : `${base}/absolute-redirect/${n - 1}`;
89+
res.writeHead(302, { 'location': location }).end();
90+
},
91+
meta: {
92+
path: '/absolute-redirect/{n}',
93+
description: 'Redirects n times using absolute URLs, then returns /get.',
94+
examples: ['/absolute-redirect/3'],
95+
group: httpRedirects
96+
}
97+
};

src/httpbin-compat.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ const getFiles = (body: Buffer, req: HttpRequest) => {
8080
}));
8181
}
8282

83-
// This endpoint returns the request details in a convenient JSON format for analysis. The format
83+
// This returns the request details in a convenient JSON format for analysis. The format
8484
// aims to exactly match the output of httpbin.org (https://github.com/postmanlabs/httpbin/) for
8585
// interoperability since this is widely used (and it's generally a reasonable format for this).
86-
export const buildHttpBinAnythingEndpoint = (options: {
86+
export const buildHttpBinAnythingData = async (req: HttpRequest, options: {
8787
fieldFilter?: string[]
88-
}) => async (req: HttpRequest, res: HttpResponse) => {
88+
} = {}) => {
8989
const input = await streamConsumers.buffer(req); // Wait for all request data
9090

9191
const isHTTPS = req.socket instanceof TLSSocket;
@@ -130,6 +130,13 @@ export const buildHttpBinAnythingEndpoint = (options: {
130130
result = _.pick(result, options.fieldFilter)
131131
}
132132

133+
return result;
134+
}
135+
136+
export const buildHttpBinAnythingEndpoint = (options: {
137+
fieldFilter?: string[]
138+
}) => async (req: HttpRequest, res: HttpResponse) => {
139+
const result = await buildHttpBinAnythingData(req, options);
133140
res.writeHead(200, { 'Content-Type': 'application/json' });
134141
res.end(serializeJson(result));
135142
}

test/cache.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("Cache endpoints", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
describe("/cache", () => {
23+
it("returns 200 with JSON when no conditional headers are present", async () => {
24+
const response = await fetch(`http://localhost:${serverPort}/cache`);
25+
expect(response.status).to.equal(200);
26+
const body = await response.json();
27+
expect(body).to.have.property('headers');
28+
expect(body).to.have.property('origin');
29+
expect(body).to.have.property('url');
30+
});
31+
32+
it("returns 304 when If-Modified-Since is present", async () => {
33+
const response = await fetch(`http://localhost:${serverPort}/cache`, {
34+
headers: { 'If-Modified-Since': 'Thu, 01 Jan 2020 00:00:00 GMT' }
35+
});
36+
expect(response.status).to.equal(304);
37+
});
38+
39+
it("returns 304 when If-None-Match is present", async () => {
40+
const response = await fetch(`http://localhost:${serverPort}/cache`, {
41+
headers: { 'If-None-Match': '"some-etag"' }
42+
});
43+
expect(response.status).to.equal(304);
44+
});
45+
});
46+
47+
describe("/cache/{n}", () => {
48+
it("returns Cache-Control header with max-age", async () => {
49+
const response = await fetch(`http://localhost:${serverPort}/cache/60`);
50+
expect(response.status).to.equal(200);
51+
expect(response.headers.get('cache-control')).to.equal('public, max-age=60');
52+
const body = await response.json();
53+
expect(body).to.have.property('url');
54+
});
55+
});
56+
});

test/etag.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
4+
5+
import { createServer } from '../src/server.js';
6+
7+
describe("ETag endpoint", () => {
8+
9+
let server: DestroyableServer;
10+
let serverPort: number;
11+
12+
beforeEach(async () => {
13+
server = makeDestroyable(await createServer());
14+
await new Promise<void>((resolve) => server.listen(resolve));
15+
serverPort = (server.address() as net.AddressInfo).port;
16+
});
17+
18+
afterEach(async () => {
19+
await server.destroy();
20+
});
21+
22+
it("returns 200 with ETag header when no conditional headers are present", async () => {
23+
const response = await fetch(`http://localhost:${serverPort}/etag/my-etag`);
24+
expect(response.status).to.equal(200);
25+
expect(response.headers.get('etag')).to.equal('my-etag');
26+
const body = await response.json();
27+
expect(body).to.have.property('url');
28+
});
29+
30+
it("returns 304 when If-None-Match matches", async () => {
31+
const response = await fetch(`http://localhost:${serverPort}/etag/my-etag`, {
32+
headers: { 'If-None-Match': 'my-etag' }
33+
});
34+
expect(response.status).to.equal(304);
35+
expect(response.headers.get('etag')).to.equal('my-etag');
36+
});
37+
38+
it("returns 200 when If-None-Match does not match", async () => {
39+
const response = await fetch(`http://localhost:${serverPort}/etag/my-etag`, {
40+
headers: { 'If-None-Match': '"other-etag"' }
41+
});
42+
expect(response.status).to.equal(200);
43+
expect(response.headers.get('etag')).to.equal('my-etag');
44+
});
45+
46+
it("returns 200 when If-Match matches", async () => {
47+
const response = await fetch(`http://localhost:${serverPort}/etag/my-etag`, {
48+
headers: { 'If-Match': 'my-etag' }
49+
});
50+
expect(response.status).to.equal(200);
51+
});
52+
53+
it("returns 412 when If-Match does not match", async () => {
54+
const response = await fetch(`http://localhost:${serverPort}/etag/my-etag`, {
55+
headers: { 'If-Match': '"other-etag"' }
56+
});
57+
expect(response.status).to.equal(412);
58+
});
59+
60+
it("handles wildcard If-None-Match", async () => {
61+
const response = await fetch(`http://localhost:${serverPort}/etag/anything`, {
62+
headers: { 'If-None-Match': '*' }
63+
});
64+
expect(response.status).to.equal(304);
65+
});
66+
});

0 commit comments

Comments
 (0)