Skip to content

Commit d6771df

Browse files
authored
Merge pull request #115 from jaredwray/claude/implement-dynamic-endpoints-OHAGh
feat: implement httpbin Dynamic Data endpoints
2 parents d695e85 + 110f01f commit d6771df

20 files changed

Lines changed: 1713 additions & 0 deletions

src/mock-http.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ import {
2222
getCookiesRoute,
2323
postCookieRoute,
2424
} from "./routes/cookies/index.js";
25+
import {
26+
base64Route,
27+
bytesRoute,
28+
delayRoute,
29+
dripRoute,
30+
linksRoute,
31+
rangeRoute,
32+
streamBytesRoute,
33+
streamRoute,
34+
uuidRoute,
35+
} from "./routes/dynamic-data/index.js";
2536
import {
2637
deleteRoute,
2738
getRoute,
@@ -63,6 +74,7 @@ export type HttpBinOptions = {
6374
anything?: boolean;
6475
auth?: boolean;
6576
images?: boolean;
77+
dynamicData?: boolean;
6678
};
6779

6880
export type MockHttpOptions = {
@@ -124,6 +136,7 @@ export class MockHttp extends Hookified {
124136
anything: true,
125137
auth: true,
126138
images: true,
139+
dynamicData: true,
127140
};
128141

129142
private _rateLimit?: RateLimitPluginOptions = {
@@ -424,6 +437,7 @@ export class MockHttp extends Hookified {
424437
anything,
425438
auth,
426439
images,
440+
dynamicData,
427441
} = this._httpBin;
428442

429443
if (httpMethods) {
@@ -466,6 +480,10 @@ export class MockHttp extends Hookified {
466480
await this.registerImageRoutes();
467481
}
468482

483+
if (dynamicData) {
484+
await this.registerDynamicDataRoutes();
485+
}
486+
469487
if (this._autoDetectPort) {
470488
const originalPort = this._port;
471489
this._port = await this.detectPort();
@@ -640,6 +658,25 @@ export class MockHttp extends Hookified {
640658
const fastify = fastifyInstance ?? this._server;
641659
await fastify.register(imageRoutes);
642660
}
661+
662+
/**
663+
* Register the dynamic data routes.
664+
* @param fastifyInstance - the server instance to register the routes on.
665+
*/
666+
public async registerDynamicDataRoutes(
667+
fastifyInstance?: FastifyInstance,
668+
): Promise<void> {
669+
const fastify = fastifyInstance ?? this._server;
670+
await fastify.register(uuidRoute);
671+
await fastify.register(bytesRoute);
672+
await fastify.register(streamBytesRoute);
673+
await fastify.register(delayRoute);
674+
await fastify.register(base64Route);
675+
await fastify.register(streamRoute);
676+
await fastify.register(rangeRoute);
677+
await fastify.register(dripRoute);
678+
await fastify.register(linksRoute);
679+
}
643680
}
644681

645682
export const mockhttp = MockHttp;

src/routes/dynamic-data/base64.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type {
2+
FastifyInstance,
3+
FastifyReply,
4+
FastifyRequest,
5+
FastifySchema,
6+
} from "fastify";
7+
8+
type Base64Request = FastifyRequest<{
9+
Params: { value: string };
10+
}>;
11+
12+
const base64Schema: FastifySchema = {
13+
description: "Decodes base64url-encoded string",
14+
tags: ["Dynamic Data"],
15+
params: {
16+
type: "object",
17+
properties: {
18+
value: {
19+
type: "string",
20+
description: "Base64 encoded value to decode",
21+
},
22+
},
23+
required: ["value"],
24+
},
25+
response: {
26+
200: {
27+
type: "string",
28+
description: "Decoded value",
29+
},
30+
400: {
31+
type: "object",
32+
properties: {
33+
error: { type: "string" },
34+
},
35+
},
36+
},
37+
};
38+
39+
// Validate base64 string - returns true if valid
40+
function isValidBase64(str: string): boolean {
41+
// Base64 should only contain A-Z, a-z, 0-9, +, /, = (or - and _ for base64url)
42+
const base64Regex = /^[A-Za-z0-9+/\-_]*={0,2}$/;
43+
if (!base64Regex.test(str)) {
44+
return false;
45+
}
46+
47+
// Check for valid length: base64 length mod 4 cannot be 1
48+
// (0, 2, 3 are valid; 1 is invalid as it can't represent valid encoded data)
49+
const strippedLength = str.replace(/=+$/, "").length;
50+
return strippedLength % 4 !== 1;
51+
}
52+
53+
export const base64Route = (fastify: FastifyInstance) => {
54+
fastify.get(
55+
"/base64/:value",
56+
{ schema: base64Schema },
57+
async (request: Base64Request, reply: FastifyReply) => {
58+
const { value } = request.params;
59+
60+
// Validate base64 input
61+
if (!isValidBase64(value)) {
62+
return reply.code(400).send({ error: "Incorrect Base64 data" });
63+
}
64+
65+
// Support both standard base64 and base64url encoding
66+
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
67+
const decoded = Buffer.from(normalizedValue, "base64").toString("utf-8");
68+
69+
return reply
70+
.header("Content-Type", "text/html; charset=utf-8")
71+
.send(decoded);
72+
},
73+
);
74+
};

src/routes/dynamic-data/bytes.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { randomBytes } from "node:crypto";
2+
import type {
3+
FastifyInstance,
4+
FastifyReply,
5+
FastifyRequest,
6+
FastifySchema,
7+
} from "fastify";
8+
9+
type BytesRequest = FastifyRequest<{
10+
Params: { n: string };
11+
Querystring: { seed?: string };
12+
}>;
13+
14+
const bytesSchema: FastifySchema = {
15+
description: "Returns n random bytes generated with given seed",
16+
tags: ["Dynamic Data"],
17+
params: {
18+
type: "object",
19+
properties: {
20+
n: { type: "string", description: "Number of bytes to generate" },
21+
},
22+
required: ["n"],
23+
},
24+
querystring: {
25+
type: "object",
26+
properties: {
27+
seed: {
28+
type: "string",
29+
description: "Seed for random number generation",
30+
},
31+
},
32+
},
33+
response: {
34+
200: {
35+
type: "string",
36+
description: "Random binary data",
37+
},
38+
400: {
39+
type: "object",
40+
properties: {
41+
error: { type: "string" },
42+
},
43+
},
44+
},
45+
};
46+
47+
// Simple seeded random number generator (LCG)
48+
function seededRandom(seed: number): () => number {
49+
let state = seed;
50+
return () => {
51+
state = (state * 1103515245 + 12345) & 0x7fffffff;
52+
return state / 0x7fffffff;
53+
};
54+
}
55+
56+
function generateSeededBytes(n: number, seed: number): Buffer {
57+
const random = seededRandom(seed);
58+
const bytes = Buffer.alloc(n);
59+
for (let i = 0; i < n; i++) {
60+
bytes[i] = Math.floor(random() * 256);
61+
}
62+
return bytes;
63+
}
64+
65+
const MAX_BYTES = 100 * 1024; // 100KB limit
66+
67+
export const bytesRoute = (fastify: FastifyInstance) => {
68+
fastify.get(
69+
"/bytes/:n",
70+
{ schema: bytesSchema },
71+
async (request: BytesRequest, reply: FastifyReply) => {
72+
const n = Number.parseInt(request.params.n, 10);
73+
74+
if (Number.isNaN(n) || n < 0) {
75+
return reply
76+
.code(400)
77+
.send({ error: "n must be a non-negative integer" });
78+
}
79+
80+
const limitedN = Math.min(n, MAX_BYTES);
81+
const seedParam = request.query.seed;
82+
83+
let bytes: Buffer;
84+
if (seedParam !== undefined) {
85+
const seed = Number.parseInt(seedParam, 10);
86+
if (Number.isNaN(seed)) {
87+
return reply.code(400).send({ error: "seed must be an integer" });
88+
}
89+
bytes = generateSeededBytes(limitedN, seed);
90+
} else {
91+
bytes = randomBytes(limitedN);
92+
}
93+
94+
return reply
95+
.header("Content-Type", "application/octet-stream")
96+
.header("Content-Length", bytes.length)
97+
.send(bytes);
98+
},
99+
);
100+
};

src/routes/dynamic-data/delay.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type {
2+
FastifyInstance,
3+
FastifyReply,
4+
FastifyRequest,
5+
FastifySchema,
6+
} from "fastify";
7+
8+
type DelayRequest = FastifyRequest<{
9+
Params: { delay: string };
10+
Querystring: Record<string, string>;
11+
}>;
12+
13+
const delaySchema: FastifySchema = {
14+
description: "Returns a delayed response (max of 10 seconds)",
15+
tags: ["Dynamic Data"],
16+
params: {
17+
type: "object",
18+
properties: {
19+
delay: {
20+
type: "string",
21+
description: "Delay in seconds (max 10)",
22+
},
23+
},
24+
required: ["delay"],
25+
},
26+
querystring: {
27+
type: "object",
28+
additionalProperties: true,
29+
},
30+
response: {
31+
200: {
32+
type: "object",
33+
properties: {
34+
args: { type: "object" },
35+
data: { type: "string" },
36+
files: { type: "object" },
37+
form: { type: "object" },
38+
headers: { type: "object" },
39+
origin: { type: "string" },
40+
url: { type: "string" },
41+
},
42+
},
43+
400: {
44+
type: "object",
45+
properties: {
46+
error: { type: "string" },
47+
},
48+
},
49+
},
50+
};
51+
52+
const MAX_DELAY = 10; // Maximum delay in seconds
53+
54+
function sleep(ms: number): Promise<void> {
55+
return new Promise((resolve) => setTimeout(resolve, ms));
56+
}
57+
58+
export const delayRoute = (fastify: FastifyInstance) => {
59+
const handler = async (request: DelayRequest, reply: FastifyReply) => {
60+
const delay = Number.parseFloat(request.params.delay);
61+
62+
if (Number.isNaN(delay) || delay < 0) {
63+
return reply
64+
.code(400)
65+
.send({ error: "delay must be a non-negative number" });
66+
}
67+
68+
// Cap delay at MAX_DELAY seconds
69+
const actualDelay = Math.min(delay, MAX_DELAY);
70+
await sleep(actualDelay * 1000);
71+
72+
const { protocol } = request;
73+
/* v8 ignore next -- @preserve */
74+
const host = request.headers.host || "localhost";
75+
76+
return {
77+
/* v8 ignore next -- @preserve */
78+
args: request.query || {},
79+
data: "",
80+
files: {},
81+
form: {},
82+
headers: request.headers,
83+
origin: request.ip,
84+
url: `${protocol}://${host}${request.url}`,
85+
};
86+
};
87+
88+
fastify.get("/delay/:delay", { schema: delaySchema }, handler);
89+
fastify.post("/delay/:delay", { schema: delaySchema }, handler);
90+
fastify.put("/delay/:delay", { schema: delaySchema }, handler);
91+
fastify.patch("/delay/:delay", { schema: delaySchema }, handler);
92+
fastify.delete("/delay/:delay", { schema: delaySchema }, handler);
93+
};

0 commit comments

Comments
 (0)