Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,13 @@
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.4",
"@scalar/api-reference": "^1.43.1",
"detect-port": "^2.1.0",
"fastify": "^5.6.2",
"fastify-fusion": "^1.4.3",
"hookified": "^1.15.0",
"html-escaper": "^3.0.3",
"pino": "^10.1.1",
"pino-pretty": "^13.1.3"
"html-escaper": "^3.0.3"
},
"files": [
"dist",
Expand Down
499 changes: 458 additions & 41 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

17 changes: 0 additions & 17 deletions src/fastify-config.ts

This file was deleted.

138 changes: 87 additions & 51 deletions src/mock-http.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import path from "node:path";
import fastifyCookie from "@fastify/cookie";
import fastifyHelmet from "@fastify/helmet";
import fastifyRateLimit, {
type RateLimitPluginOptions,
} from "@fastify/rate-limit";
import fastifyStatic from "@fastify/static";
import { fastifySwagger } from "@fastify/swagger";
import { detect } from "detect-port";
import Fastify, { type FastifyInstance } from "fastify";
import { type FuseOptions, fuse } from "fastify-fusion";
import { Hookified, type HookifiedOptions } from "hookified";
import { getFastifyConfig } from "./fastify-config.js";
import pkg from "../package.json" with { type: "json" };
import { anythingRoute } from "./routes/anything/index.js";
import {
basicAuthRoute,
Expand Down Expand Up @@ -60,9 +56,27 @@ import {
} from "./routes/response-inspection/index.js";
import { sitemapRoute } from "./routes/sitemap.js";
import { statusCodeRoute } from "./routes/status-codes/index.js";
import { fastifySwaggerConfig, registerSwaggerUi } from "./swagger.js";
import { swaggerDescription } from "./swagger.js";
import { TapManager } from "./tap-manager.js";

/**
* Rate limiting options for MockHttp.
* Compatible with @fastify/rate-limit options.
*/
export type RateLimitOptions = {
/** Maximum number of requests allowed in the time window */
max?: number;
/** Time window for rate limiting (e.g., "1 minute", 60000) */
timeWindow?: number | string;
/** List of IPs to exclude from rate limiting */
allowList?: string[];
/** Custom error response builder */
errorResponseBuilder?: (
request: unknown,
context: unknown,
) => Record<string, unknown>;
};

export type HttpBinOptions = {
httpMethods?: boolean;
redirects?: boolean;
Expand Down Expand Up @@ -107,7 +121,7 @@ export type MockHttpOptions = {
* Rate limiting options. Defaults to 1000 requests per minute (localhost excluded).
* Set to undefined to disable rate limiting, or provide custom options to configure.
*/
rateLimit?: boolean | RateLimitPluginOptions;
rateLimit?: boolean | RateLimitOptions;
/**
* Hookified options.
*/
Expand Down Expand Up @@ -139,7 +153,7 @@ export class MockHttp extends Hookified {
dynamicData: true,
};

private _rateLimit?: RateLimitPluginOptions = {
private _rateLimit?: RateLimitOptions = {
max: 1000,
timeWindow: "1 minute",
allowList: ["127.0.0.1", "::1"],
Expand Down Expand Up @@ -175,7 +189,7 @@ export class MockHttp extends Hookified {
if (options.rateLimit === false) {
this._rateLimit = undefined;
} else {
this._rateLimit = options.rateLimit as RateLimitPluginOptions;
this._rateLimit = options.rateLimit as RateLimitOptions;
}
}

Expand Down Expand Up @@ -301,7 +315,7 @@ export class MockHttp extends Hookified {
* Set to undefined to disable rate limiting, or provide custom options to configure.
* @default { max: 1000, timeWindow: "1 minute", allowList: ["127.0.0.1", "::1"] }
*/
public get rateLimit(): RateLimitPluginOptions | undefined {
public get rateLimit(): RateLimitOptions | undefined {
return this._rateLimit;
}

Expand All @@ -312,7 +326,7 @@ export class MockHttp extends Hookified {
* Note: Changing this property requires restarting the server (close() then start()) for changes to take effect.
* @default { max: 1000, timeWindow: "1 minute", allowList: ["127.0.0.1", "::1"] }
*/
public set rateLimit(rateLimit: RateLimitPluginOptions | undefined) {
public set rateLimit(rateLimit: RateLimitOptions | undefined) {
this._rateLimit = rateLimit;
}

Expand Down Expand Up @@ -354,7 +368,21 @@ export class MockHttp extends Hookified {
await this._server.close();
}

this._server = Fastify(getFastifyConfig(this._logging));
this._server = Fastify({
logger: this._logging
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: true,
ignore: "pid,hostname",
singleLine: true,
},
},
}
: false,
});

// Register injection hook to intercept requests
this._server.addHook("onRequest", async (request, reply) => {
Expand Down Expand Up @@ -383,45 +411,58 @@ export class MockHttp extends Hookified {
}
});

// Register Scalar API client
// Configure fastify-fusion options
const fuseOptions: FuseOptions = {
log: false, // Logging configured at Fastify construction time
helmet: this._helmet
? {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
frameSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
}
: false,
rateLimit: this._rateLimit ?? false,
static: [{ dir: "./public", path: "/" }],
openApi: this._apiDocs
? {
title: "Mock HTTP API",
description: swaggerDescription,
version: pkg.version,
openApiRoutePrefix: "/docs",
docsRoutePath: "/docs/ui", // Use different path than "/" for fusion's UI
}
: false,
cors: false,
cache: false,
};

// Apply fastify-fusion configuration
await fuse(this._server, fuseOptions);

// Register Scalar API client (separate static mount)
await this._server.register(fastifyStatic, {
root: path.resolve("./node_modules/@scalar/api-reference/dist"),
prefix: "/scalar",
});

// Register the Public for favicon
await this.server.register(fastifyStatic, {
root: path.resolve("./public"),
decorateReply: false,
});

// Register the site map route
await this._server.register(sitemapRoute);

// Register the Helmet plugin for security headers
if (this._helmet) {
await this._server.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
frameSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
});
}

// Register the rate limit plugin if configured
if (this._rateLimit) {
await this._server.register(fastifyRateLimit, this._rateLimit);
}

if (this._apiDocs) {
await this.registerApiDocs();
}
Expand Down Expand Up @@ -519,21 +560,16 @@ export class MockHttp extends Hookified {
}

/**
* This will register the API documentation routes including openapi and swagger ui.
* This will register the API documentation routes including the index/home page.
* Note: Swagger/OpenAPI is registered by fastify-fusion's openApi option.
* @param fastifyInstance - the server instance to register the routes on.
*/
public async registerApiDocs(
fastifyInstance?: FastifyInstance,
): Promise<void> {
const fastify = fastifyInstance ?? this._server;

// Set up Swagger for API documentation
await fastify.register(fastifySwagger, fastifySwaggerConfig);

// Register Swagger UI
await registerSwaggerUi(fastify);

// Register the index / home page route
// Register the index / home page route (Scalar UI)
await fastify.register(indexRoute);
}

Expand Down
45 changes: 2 additions & 43 deletions src/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,11 @@
import { fastifySwaggerUi } from "@fastify/swagger-ui";
import type { FastifyInstance } from "fastify";
import pkg from "../package.json" with { type: "json" };

const description = `
export const swaggerDescription = `
A simple HTTP server that can be used to mock HTTP responses for testing purposes. Inspired by [httpbin](https://httpbin.org/) and built using \`nodejs\` and \`fastify\` with the idea of running it via https://mockhttp.org, via docker \`jaredwray/mockhttp\`, or nodejs \`npm install jaredwray/mockhttp\`.

* [GitHub Repository](https://github.com/jaredwray/mockhttp)
* [Docker Image](https://hub.docker.com/r/jaredwray/mockhttp)
* [NPM Package](https://www.npmjs.com/package/jaredwray/mockhttp)

# About mockhttp.org
# About mockhttp.org

[mockhttp.org](https://mockhttp.org) is a free service that runs this codebase and allows you to use it for testing purposes. It is a simple way to mock HTTP responses for testing purposes. Ran via [Cloudflare](https://cloudflare.com) and [Google Cloud Run](https://cloud.google.com/run/) across 7 regions globally and can do millions of requests per second.
`;

export const fastifySwaggerConfig = {
openapi: {
info: {
title: "Mock HTTP API",
description,
version: pkg.version,
},
consumes: ["application/json"],
produces: ["application/json"],
},
};

export const registerSwaggerUi = async (fastify: FastifyInstance) => {
await fastify.register(fastifySwaggerUi, {
routePrefix: "/docs",
uiConfig: {
docExpansion: "none",
deepLinking: false,
},
uiHooks: {
/* v8 ignore next -- @preserve */
onRequest(_request, _reply, next) {
next();
},
/* v8 ignore next -- @preserve */
preHandler(_request, _reply, next) {
next();
},
},

staticCSP: true,
/* v8 ignore next -- @preserve */
transformSpecification: (swaggerObject, _request, _reply) => swaggerObject,
transformSpecificationClone: true,
});
};
1 change: 1 addition & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe("start", () => {
it("should start the server and log info", async () => {
process.env.PORT = "8080";
process.env.HOST = "localhost";
process.env.LOGGING = "false";
const mockHttp = await start();
expect(mockHttp.port).toBe(8080);
expect(mockHttp.host).toBe("localhost");
Expand Down
Loading
Loading