@loupeat/fmiddleware
v1.1.0
Published
Framework-agnostic HTTP middleware for Express and AWS Lambda
Maintainers
Readme
@loupeat/fmiddleware
A framework-agnostic HTTP middleware for building APIs that run on both Express.js and AWS Lambda.
Features
- Framework-agnostic: Write your API once, deploy to Express.js or AWS Lambda
- Powerful routing: Path parameters, wildcards, and pattern matching
- Pre-processors: Enrich requests with authentication, context, and validation
- Post-processors: Handle errors, logging, and response transformation
- Built-in validation: JSON Schema validation with custom keywords (uuid, email, json)
- Typed errors: Semantic error classes that map to HTTP status codes
- TypeScript-first: Full type safety for requests and responses
- OpenAPI generation: Generate OpenAPI 3.0 specs automatically from handlers
Installation
npm install @loupeat/fmiddlewareFor AWS Lambda support, also install the types:
npm install --save-dev @types/aws-lambdaQuick Start
Express.js
import express from "express";
import { FExpressMiddleware, FRequest } from "@loupeat/fmiddleware";
const app = express();
const api = new FExpressMiddleware();
interface Note {
id: string;
title: string;
}
// Register a simple GET endpoint
api.get("/api/notes", async (request: FRequest<any, any>) => {
const notes: Note[] = [{ id: "1", title: "Hello World" }];
return api.responses.OK<any, Note[]>(request, notes);
});
// Use FMiddleware as Express middleware
app.use(express.json());
app.all("*", async (req, res) => {
const response = await api.process(req);
for (const [key, value] of Object.entries(response.headers || {})) {
res.setHeader(key, value as string);
}
res.status(response.statusCode).json(response.body);
});
app.listen(3000);AWS Lambda
import { APIGatewayProxyHandler } from "aws-lambda";
import { FAWSLambdaMiddleware, FRequest } from "@loupeat/fmiddleware";
interface Note {
id: string;
title: string;
}
const api = new FAWSLambdaMiddleware();
api.get("/api/notes", async (request: FRequest<any, any>) => {
const notes: Note[] = [{ id: "1", title: "Hello World" }];
return api.responses.OK<any, Note[]>(request, notes);
});
export const handler: APIGatewayProxyHandler = async (event) => {
return api.process(event);
};Routing
Registering Handlers
FMiddleware provides methods for all common HTTP verbs:
api.get(path, handler);
api.post(path, handler, schema?);
api.put(path, handler, schema?);
api.delete(path, handler);Path Parameters
Capture dynamic segments using {paramName} syntax:
import { FRequest } from "@loupeat/fmiddleware";
api.get("/api/notes/{noteId}", async (request: FRequest<any, any>) => {
const noteId = api.pathParameter(request, "noteId");
const note = await notesService.get(noteId);
if (!note) {
return api.responses.NotFound(request, `Note ${noteId} not found`);
}
return api.responses.OK<any, Note>(request, note);
});Greedy Path Parameters
Capture multiple path segments using {paramName+}:
interface FilePathResponse {
filepath: string;
}
api.get("/api/files/{filepath+}", async (request: FRequest<any, any>) => {
// For /api/files/documents/2024/report.pdf
// filepath = "documents/2024/report.pdf"
const filepath = api.pathParameter(request, "filepath");
return api.responses.OK<any, FilePathResponse>(request, { filepath });
});Security Warning: Path parameters can contain traversal sequences like
../. If you use path parameters for file system operations, always validate that the resolved path stays within your intended directory:import * as path from "path"; const baseDir = "/var/uploads"; const userPath = api.pathParameter(request, "filepath"); const resolved = path.resolve(baseDir, userPath); if (!resolved.startsWith(baseDir)) { throw new ForbiddenError("Invalid file path"); }
Query Parameters
api.get("/api/notes/search", async (request: FRequest<any, any>) => {
// Required parameter - throws ValidationError if missing
const query = api.queryStringParameter(request, "q");
// Optional parameter - returns undefined if missing
const tag = api.queryStringParameterOptional(request, "tag");
const results = await notesService.search(query, tag);
return api.responses.OK<any, Note[]>(request, results);
});Path Patterns
FMiddleware supports wildcards for pre/post-processors:
| Pattern | Matches |
|---------|---------|
| /api/notes | Exact match |
| /api/notes/* | Single segment wildcard |
| /api/notes/** | Multi-segment wildcard |
| /api/notes/{id} | Path parameter |
| /api/notes/{path+} | Greedy path parameter |
Request Validation
Validate request bodies using JSON Schema (Draft-07):
import { FRequest } from "@loupeat/fmiddleware";
interface CreateNoteRequest {
title: string;
content: string;
tags?: string[];
}
const CreateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: {
type: "array",
items: { type: "string" }
}
},
required: ["title", "content"]
};
api.post("/api/notes", async (request: FRequest<any, CreateNoteRequest>) => {
// request.body is validated against the schema and typed
const { title, content, tags } = request.body;
const note = await notesService.create({ title, content, tags });
return api.responses.OK<CreateNoteRequest, Note>(request, note);
}, CreateNoteSchema);Custom Validation Keywords
The built-in validator supports custom keywords:
// UUID validation
const schema = {
type: "object",
properties: {
id: { type: "string", uuid: true }
}
};
// Email validation
const schema = {
type: "object",
properties: {
email: { type: "string", email: true }
}
};
// JSON string validation
const schema = {
type: "object",
properties: {
metadata: { type: "string", json: true }
}
};Pre-Processors
Pre-processors run before the handler and can enrich the request context:
import {
FMiddleware,
FRequest,
FHandler,
RequestPreProcessor,
AuthenticationError
} from "@loupeat/fmiddleware";
interface User {
id: string;
email: string;
}
const AuthPreProcessor: RequestPreProcessor = {
name: "AuthPreProcessor",
pathPatterns: ["/api/notes/**"],
requestSource: "*", // "express", "aws-lambda", or "*" for both
process: async (
api: FMiddleware<any, any>,
request: FRequest<any, any>,
handler: FHandler<any, any>
) => {
const authHeader = request.headers["authorization"];
if (!authHeader) {
throw new AuthenticationError("Missing authorization header");
}
const token = authHeader.replace(/^Bearer /, "");
const user = await authService.verifyToken(token);
// Add user to request context
request.context["user"] = user;
}
};
// Register the pre-processor
api.addRequestPreProcessor(AuthPreProcessor);
// Access context in handlers
api.get("/api/notes", async (request: FRequest<any, any>) => {
const user = api.context<User>(request, "user");
const notes = await notesService.listByUser(user.id);
return api.responses.OK<any, Note[]>(request, notes);
});Pre-Processor Options
const MyPreProcessor: RequestPreProcessor = {
name: "MyPreProcessor",
pathPatterns: ["/api/**"], // Which paths to match
httpMethods: [FHttpMethod.POST, FHttpMethod.PUT], // Optional: specific methods only
requestSource: "*", // "express", "aws-lambda", or "*"
process: async (api, request, handler) => {
// Your logic here
}
};Post-Processors
Post-processors run after the handler and can transform responses or handle errors:
import { FMiddleware, FResponse, ResponsePostProcessor } from "@loupeat/fmiddleware";
const LoggingPostProcessor: ResponsePostProcessor = {
name: "LoggingPostProcessor",
pathPatterns: ["/**"],
requestSource: "*",
process: async (api: FMiddleware<any, any>, response: FResponse<any, any, any>) => {
console.log(`${response.request.httpMethod} ${response.request.path} - ${response.statusCode}`);
if (response.error) {
console.error("Request failed:", response.error);
}
}
};
api.addResponsePostProcessor(LoggingPostProcessor);Error Handling
FMiddleware provides semantic error classes that automatically map to HTTP status codes:
import {
FRequest,
ValidationError, // 400 Bad Request
AuthenticationError, // 401 Unauthorized
ForbiddenError, // 403 Forbidden
NotFoundError, // 404 Not Found
ConflictError // 409 Conflict
} from "@loupeat/fmiddleware";
interface User {
id: string;
}
api.get("/api/notes/{noteId}", async (request: FRequest<any, any>) => {
const noteId = api.pathParameter(request, "noteId");
const user = api.context<User>(request, "user");
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(`Note ${noteId} not found`);
}
if (note.userId !== user.id) {
throw new ForbiddenError("You don't have access to this note");
}
return api.responses.OK<any, Note>(request, note);
});Response Helpers
interface Note {
id: string;
title: string;
}
// 200 OK with typed body
api.responses.OK<any, Note>(request, { id: "1", title: "Hello" });
// 200 OK with custom headers
api.responses.OK<any, Note>(request, note, { "Cache-Control": "max-age=60" });
// 204 No Content
api.responses.NoContent(request);
// 400 Bad Request
api.responses.BadRequest(request, "Invalid input");
// 404 Not Found
api.responses.NotFound(request, "Resource not found");
// Custom status code with typed body
api.responses._<any, Note>(request, 201, { id: "1", title: "Created" });Complete Example: Notes API
Here's a complete example of a Notes API with authentication:
import {
FExpressMiddleware,
FMiddleware,
FRequest,
FHandler,
RequestPreProcessor,
AuthenticationError,
NotFoundError,
ForbiddenError,
validator
} from "@loupeat/fmiddleware";
// Types
interface User {
id: string;
email: string;
}
interface Note {
id: string;
userId: string;
title: string;
content: string;
tags: string[];
}
interface CreateNoteRequest {
title: string;
content: string;
tags?: string[];
}
interface UpdateNoteRequest {
title?: string;
content?: string;
tags?: string[];
}
// Schemas
const CreateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: { type: "array", items: { type: "string" } }
},
required: ["title", "content"]
};
const UpdateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: { type: "array", items: { type: "string" } }
}
};
// Initialize middleware
const api = new FExpressMiddleware();
// Authentication pre-processor
const AuthPreProcessor: RequestPreProcessor = {
name: "AuthPreProcessor",
pathPatterns: ["/api/notes/**", "/api/notes"],
requestSource: "*",
process: async (
_api: FMiddleware<any, any>,
request: FRequest<any, any>,
_handler: FHandler<any, any>
) => {
const authHeader = request.headers["authorization"];
if (!authHeader) {
throw new AuthenticationError("Missing authorization header");
}
const token = authHeader.replace(/^Bearer /, "");
const user = await verifyToken(token); // Your auth logic
request.context["user"] = user;
}
};
api.addRequestPreProcessor(AuthPreProcessor);
// Routes
export function registerNotesApi(api: FExpressMiddleware) {
// List all notes for user
api.get("/api/notes", async (request: FRequest<any, any>) => {
const user = api.context<User>(request, "user");
const notes = await notesService.listByUser(user.id);
return api.responses.OK<any, Note[]>(request, notes);
});
// Create a new note
api.post("/api/notes", async (request: FRequest<any, CreateNoteRequest>) => {
const user = api.context<User>(request, "user");
const { title, content, tags } = request.body;
const note = await notesService.create({
userId: user.id,
title,
content,
tags: tags || []
});
return api.responses.OK<CreateNoteRequest, Note>(request, note);
}, CreateNoteSchema);
// Get a specific note
api.get("/api/notes/{noteId}", async (request: FRequest<any, any>) => {
const user = api.context<User>(request, "user");
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(`Note ${noteId} not found`);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
return api.responses.OK<any, Note>(request, note);
});
// Update a note
api.put("/api/notes/{noteId}", async (request: FRequest<any, UpdateNoteRequest>) => {
const user = api.context<User>(request, "user");
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(`Note ${noteId} not found`);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
const updated = await notesService.update(noteId, request.body);
return api.responses.OK<UpdateNoteRequest, Note>(request, updated);
}, UpdateNoteSchema);
// Delete a note
api.delete("/api/notes/{noteId}", async (request: FRequest<any, any>) => {
const user = api.context<User>(request, "user");
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(`Note ${noteId} not found`);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
await notesService.delete(noteId);
return api.responses.NoContent(request);
});
// Search notes
api.get("/api/notes/search", async (request: FRequest<any, any>) => {
const user = api.context<User>(request, "user");
const query = api.queryStringParameterOptional(request, "q") || "";
const tag = api.queryStringParameterOptional(request, "tag");
const notes = await notesService.search(user.id, { query, tag });
return api.responses.OK<any, Note[]>(request, notes);
});
}AWS Lambda Deployment
FMiddleware works great with serverless frameworks. Here's how to deploy to AWS Lambda.
With Serverless Framework
We recommend using Serverless Framework or AWS CDK for Lambda deployments.
serverless.yml:
service: notes-api
plugins:
- serverless-esbuild # For TypeScript bundling
custom:
esbuild:
bundle: true
minify: false
sourcemap: true
target: node20
provider:
name: aws
runtime: nodejs20.x
region: eu-west-1
environment:
LOG_LEVEL: info
functions:
api:
handler: src/handler.main
events:
- http:
method: any
path: "api/{proxy+}"
cors: true
timeout: 15src/handler.ts:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { FAWSLambdaMiddleware } from "@loupeat/fmiddleware";
import { registerNotesApi } from "./notes-api";
let api: FAWSLambdaMiddleware;
export const main = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
// Initialize once per cold start
if (!api) {
api = new FAWSLambdaMiddleware();
registerNotesApi(api);
}
return api.process(event);
};Splitting by Authentication Context
A common pattern is to split Lambda functions by authentication context rather than by resource. This approach:
- Optimizes cold starts: Public handlers don't load auth processors
- Improves security: Authentication code is isolated to protected functions
- Enables different configurations: More memory/timeout for authenticated requests
serverless.yml:
functions:
# Public endpoints - no authentication
public:
handler: src/lambda.publicHandler
events:
- http:
method: any
path: "api/public/{proxy+}"
cors: true
timeout: 15
memorySize: 256
# Private endpoints - requires JWT
private:
handler: src/lambda.privateHandler
events:
- http:
method: any
path: "api/private/{proxy+}"
cors: true
authorizer:
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
timeout: 30
memorySize: 512src/lambda.ts:
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { FAWSLambdaMiddleware } from "@loupeat/fmiddleware";
import { registerApi } from "./api";
let publicApi: FAWSLambdaMiddleware;
let privateApi: FAWSLambdaMiddleware;
// Public handler - no auth required
export const publicHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
if (!publicApi) {
publicApi = new FAWSLambdaMiddleware();
publicApi.setPathPrefix("/api/public");
registerApi(publicApi);
}
return publicApi.process(event);
};
// Private handler - JWT validated by API Gateway
export const privateHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
if (!privateApi) {
privateApi = new FAWSLambdaMiddleware();
privateApi.setPathPrefix("/api/private");
registerApi(privateApi);
}
return privateApi.process(event);
};The setPathPrefix() method ensures each Lambda only registers handlers matching its prefix, reducing initialization time and memory usage.
Express.js Integration
src/app.ts:
import express, { Request, Response } from "express";
import { FExpressMiddleware, FResponse } from "@loupeat/fmiddleware";
import { registerNotesApi } from "./notes-api";
const app = express();
const api = new FExpressMiddleware();
// Register your routes
registerNotesApi(api);
// Parse JSON bodies
app.use(express.json());
// Route all requests through FMiddleware
app.all("*", async (req: Request, res: Response) => {
const response: FResponse<any, any, any> = await api.process(req);
// Set headers
for (const [key, value] of Object.entries(response.headers || {})) {
res.setHeader(key, value as string);
}
// Send response
res.status(response.statusCode).json(response.body);
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});Security Note:
request.sourceIpin Express comes fromrequest.ip, which respects theX-Forwarded-Forheader iftrust proxyis enabled. If your Express app runs behind a reverse proxy (nginx, load balancer), configuretrust proxycorrectly. If running without a proxy, ensuretrust proxyis disabled to prevent IP spoofing via theX-Forwarded-Forheader. See Express trust proxy documentation.
Configuration
Logging
Set the log level via environment variable:
LOG_LEVEL=debug # debug, info, warn, errorDefault Headers
Both FExpressMiddleware and FAWSLambdaMiddleware set a CORS header by default:
{
"Access-Control-Allow-Origin": "*"
}You can add custom headers to responses:
api.responses.OK<any, Note>(request, note, api.headers({
"Cache-Control": "max-age=60",
"X-Custom-Header": "value"
}));OpenAPI Generation
FMiddleware includes built-in support for generating OpenAPI 3.0 specifications from your registered handlers.
Adding OpenAPI Metadata to Handlers
You can enrich handlers with OpenAPI metadata for better documentation:
import { FExpressMiddleware, OpenAPIMetadata } from "@loupeat/fmiddleware";
const api = new FExpressMiddleware();
// GET with OpenAPI metadata
api.get("/api/notes", async (request) => {
const notes = await notesService.list();
return api.responses.OK(request, notes);
}, {
summary: "List all notes",
description: "Retrieves all notes for the authenticated user",
tags: ["Notes"],
queryParams: [
{ name: "tag", description: "Filter by tag", required: false },
{ name: "limit", description: "Max results", schema: { type: "integer" } }
],
responseSchema: {
type: "array",
items: { $ref: "#/components/schemas/Note" }
}
});
// POST with schema and OpenAPI metadata
api.post("/api/notes", async (request) => {
const note = await notesService.create(request.body);
return api.responses.OK(request, note);
}, CreateNoteSchema, {
summary: "Create a note",
tags: ["Notes"],
requestBodyDescription: "Note to create"
});OpenAPI Metadata Options
interface OpenAPIMetadata {
summary?: string; // Brief description
description?: string; // Detailed description
tags?: string[]; // Categorization tags
operationId?: string; // Unique operation ID
deprecated?: boolean; // Mark as deprecated
queryParams?: QueryParamDef[]; // Query parameter definitions
pathParams?: Record<string, PathParamDef>; // Path parameter descriptions
responseSchema?: any; // JSON Schema for response
responses?: Record<string, ResponseDef>; // Custom response definitions
requestBodyDescription?: string; // Description for request body
}Generating OpenAPI Specifications
Use the OpenApiGenerator class to generate specs from your middleware:
import {
FExpressMiddleware,
OpenApiGenerator,
GeneratorConfig
} from "@loupeat/fmiddleware";
import * as fs from "fs";
// Initialize and register handlers
const api = new FExpressMiddleware();
registerAllRoutes(api);
// Configure the generator
const config: GeneratorConfig = {
info: {
title: "My API",
version: "1.0.0",
description: "API description"
},
servers: [
{ url: "https://api.example.com", description: "Production" }
],
tags: [
{ name: "Notes", description: "Note management" }
],
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
},
// Custom security inference based on path
securityInference: (path) => {
if (path.includes("/public/")) return [];
return [{ bearerAuth: [] }];
},
// Custom tag inference based on path
tagInference: (path) => {
const match = path.match(/\/api\/(\w+)/);
return match ? [match[1]] : ["General"];
}
};
// Generate the spec
const generator = new OpenApiGenerator(api, config);
const spec = generator.generate();
// Write to file
fs.writeFileSync("openapi.json", JSON.stringify(spec, null, 2));Generator Config Options
interface GeneratorConfig {
info: {
title: string;
version: string;
description?: string;
};
servers?: Array<{ url: string; description?: string }>;
tags?: Array<{ name: string; description?: string }>;
securitySchemes?: Record<string, any>;
securityInference?: (path: string) => any[]; // Custom security logic
tagInference?: (path: string) => string[]; // Custom tag logic
}Automatic Inference
The generator automatically infers:
- Path parameters from
{param}patterns in routes - Operation IDs from HTTP method + path
- Summaries from HTTP method + resource name
- Request body schemas from handler schema parameter
- Standard error responses (400, 401, 403, 404, 500)
API Reference
Core Types
// Request type with generics for original request and body type
FRequest<OriginalRequestType, RequestBodyType>
// Response type with generics
FResponse<OriginalRequestType, RequestBodyType, ResponseBodyType>
// Handler function signature
(request: FRequest<any, RequestBodyType>) => Promise<FResponse<any, RequestBodyType, ResponseBodyType>>FMiddleware
| Method | Description |
|--------|-------------|
| get(path, handler, openapi?) | Register GET handler with optional OpenAPI metadata |
| post(path, handler, schema?, openapi?) | Register POST handler with validation and OpenAPI metadata |
| put(path, handler, schema?, openapi?) | Register PUT handler with validation and OpenAPI metadata |
| delete(path, handler, schema?, openapi?) | Register DELETE handler with optional OpenAPI metadata |
| addRequestPreProcessor(processor) | Add a pre-processor |
| addResponsePostProcessor(processor) | Add a post-processor |
| pathParameter(request, name) | Get path parameter value |
| queryStringParameter(request, name) | Get required query parameter |
| queryStringParameterOptional(request, name) | Get optional query parameter |
| context<T>(request, key) | Get typed value from request context |
| setPathPrefix(prefix) | Only register handlers matching prefix |
| getHandlers() | Get all registered handlers (for OpenAPI generation) |
| responses.OK<Req, Res>(request, body) | Return 200 with typed response |
| responses.NoContent(request) | Return 204 |
| responses.NotFound(request, message) | Return 404 |
| responses.BadRequest(request, message) | Return 400 |
| responses._(request, status, body) | Return custom status code |
Error Classes
| Class | HTTP Status |
|-------|-------------|
| ValidationError | 400 |
| AuthenticationError | 401 |
| ForbiddenError | 403 |
| NotFoundError | 404 |
| ConflictError | 409 |
OpenAPI Types
| Type | Description |
|------|-------------|
| OpenAPIMetadata | Metadata to attach to handlers for documentation |
| QueryParamDef | Query parameter definition |
| PathParamDef | Path parameter description |
| ResponseDef | Response definition |
| OpenAPISpec | Full OpenAPI 3.0 specification |
| GeneratorConfig | Configuration for OpenApiGenerator |
| OpenApiGenerator | Class to generate OpenAPI specs from middleware |
License
MIT
