TypeScript-first API framework built on Fastify. Decorators for routing, JWT authentication, Zod-powered validation, auto-generated OpenAPI docs, and structured logging -- all in a lightweight package.
| Feature | api-forge | Express | NestJS | Fastify (raw) |
|---|---|---|---|---|
| TypeScript-first | Yes | No | Yes | Partial |
| Decorator routing | Yes | No | Yes | No |
| Built-in JWT auth | Yes | No | Yes | No |
| Zod validation | Yes | No | No (class-validator) | No |
| Auto OpenAPI docs | Yes | No | Yes (Swagger module) | Plugin |
| Structured logging | Yes (pino) | No | Yes | Yes (pino) |
| Bundle complexity | Light | Light | Heavy | Light |
| Learning curve | Low | Low | High | Low |
npm install api-forge fastify zod jsonwebtoken pinoimport { createApp, Controller, Get } from "api-forge";
@Controller("/")
class HelloController {
@Get("/")
async hello() {
return { message: "Hello from api-forge!" };
}
}
const app = createApp({ port: 3000, logger: true });
app.controllers([HelloController]);
await app.listen();import { z } from "zod";
import {
createApp,
Controller,
Get,
Post,
Put,
Delete,
Auth,
Roles,
Body,
Query,
Params,
authPlugin,
openapiPlugin,
paginate,
NotFoundError,
type Request,
} from "api-forge";
// Define schemas with Zod
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(["user", "editor", "admin"]).default("user"),
});
const ListQuery = z.object({
page: z.coerce.number().int().positive().default(1),
perPage: z.coerce.number().int().min(1).max(100).default(20),
});
@Controller("/api/users")
class UserController {
@Get("/", { summary: "List users", tags: ["users"] })
@Query(ListQuery)
async list(req: Request) {
const { page, perPage } = req.query as z.infer<typeof ListQuery>;
// ... fetch from database
return paginate([], 0, page, perPage);
}
@Post("/", { summary: "Create user", tags: ["users"] })
@Auth()
@Body(CreateUserSchema)
async create(req: Request) {
const data = req.body as z.infer<typeof CreateUserSchema>;
// ... insert into database
return { id: 1, ...data };
}
@Delete("/:id", { summary: "Delete user", tags: ["users"] })
@Roles("admin")
async delete(req: Request) {
// Only admins can delete
return { deleted: true };
}
}
const app = createApp({ port: 3000, logger: true });
app.register(authPlugin({ secret: process.env.JWT_SECRET! }));
app.register(openapiPlugin({
title: "My API",
version: "1.0.0",
description: "User management API",
}));
app.controllers([UserController]);
await app.listen();
// Server at http://localhost:3000
// Docs at http://localhost:3000/docsCreate an api-forge application instance.
const app = createApp({
port: 3000, // default: 3000
host: "0.0.0.0", // default: "0.0.0.0"
logger: true, // default: true (pino)
prefix: "/api", // optional global prefix
trustProxy: false, // default: false
});Returns a ForgeApp with methods:
| Method | Description |
|---|---|
app.register(plugin, opts?) |
Register a Fastify or api-forge plugin |
app.controllers([...]) |
Register decorated controller classes |
app.listen() |
Start the server, returns address string |
app.close() |
Graceful shutdown |
app.inject(opts) |
Fastify inject for testing |
app.instance |
Raw Fastify instance (escape hatch) |
@Controller("/prefix") // Class-level path prefix
@Get("/path") // GET route
@Post("/path") // POST route
@Put("/path") // PUT route
@Delete("/path") // DELETE route
@Patch("/path") // PATCH routeAll route decorators accept an optional options object:
@Get("/users", {
summary: "List all users", // OpenAPI summary
tags: ["users"], // OpenAPI tags
deprecated: false, // mark as deprecated
})Powered by Zod. Schemas are validated before the handler runs. On failure, a 422 response with structured error details is returned.
@Body(zodSchema) // Validate request body
@Query(zodSchema) // Validate query parameters
@Params(zodSchema) // Validate route parametersStandalone validation:
import { validate } from "api-forge";
const data = validate(MySchema, rawInput); // throws ValidationError on failure// Register the auth plugin
app.register(authPlugin({
secret: "your-jwt-secret",
expiresIn: "15m", // access token TTL
refreshExpiresIn: "7d", // refresh token TTL
issuer: "my-app", // optional JWT issuer
}));
// Use decorators on routes
@Auth() // require valid JWT
@Roles("admin", "editor") // require specific roles (implies @Auth)Token management:
import { getJwt } from "api-forge";
const jwt = getJwt();
const tokens = jwt.generateTokenPair({ sub: "user-123", roles: ["admin"] });
// { accessToken: "...", refreshToken: "..." }
const decoded = jwt.verifyAccessToken(tokens.accessToken);
// { sub: "user-123", roles: ["admin"], iat: ..., exp: ... }
const rotated = jwt.rotateTokens(tokens.refreshToken, (sub) => ({
sub,
roles: ["admin"],
}));app.register(openapiPlugin({
title: "My API",
version: "1.0.0",
description: "API description",
specPath: "/openapi.json", // default
uiPath: "/docs", // default
}));Visit /docs for the Swagger UI. The JSON spec is at /openapi.json.
import { compose, timing, cors, requestLogger } from "api-forge";
import type { MiddlewareFn } from "api-forge";
// Built-in middleware
app.register(async (fastify) => {
// Or use the compose utility for custom chains
const chain = compose([timing(), requestLogger()]);
});
// Custom middleware
const myMiddleware: MiddlewareFn = async (ctx, next) => {
console.log("before handler");
await next();
console.log("after handler");
};
// Apply at controller level
@UseMiddleware(myMiddleware)
@Controller("/guarded")
class GuardedController { ... }api-forge provides structured error classes that automatically map to HTTP status codes:
import {
AppError, // base class (500)
BadRequestError, // 400
UnauthorizedError, // 401
ForbiddenError, // 403
NotFoundError, // 404
ConflictError, // 409
ValidationError, // 422
TooManyRequestsError, // 429
InternalError, // 500
} from "api-forge";
// Throw anywhere in a handler
throw new NotFoundError("User");
// Response: { "error": { "code": "NOT_FOUND", "message": "User not found" } }Built on pino with automatic pretty-printing in development.
import { createLogger, createChildLogger } from "api-forge";
const logger = createLogger({ level: "debug" });
const child = createChildLogger(logger, "UserService");
child.info({ userId: 123 }, "User created");Every request automatically gets an X-Request-Id header (generated or forwarded).
Environment-based configuration with Zod validation:
import { loadConfig, getConfig } from "api-forge";
// Validates: NODE_ENV, PORT, HOST, LOG_LEVEL, JWT_SECRET, etc.
const config = loadConfig();
console.log(config.PORT); // 3000api-forge ships with test utilities:
import { createTestApp, Controller, Get } from "api-forge";
const app = createTestApp(); // logger off, ephemeral port
app.controllers([MyController]);
const res = await app.inject({ method: "GET", url: "/my-route" });
expect(res.statusCode).toBe(200);Run the project tests:
npm testsrc/
index.ts Public API exports
server.ts App factory (createApp)
router.ts Decorator-based routing
middleware.ts Middleware composition
auth/
jwt.ts JWT sign/verify/refresh
guards.ts @Auth, @Roles decorators & hooks
types.ts Auth type definitions
validation/
schema.ts @Body, @Query, @Params decorators
errors.ts Zod error formatting
openapi/
generator.ts OpenAPI spec generation
ui.ts Swagger UI plugin
logging/
logger.ts Pino logger factory
request-id.ts Request ID middleware
errors/
handler.ts Global error handler
types.ts Error class hierarchy
utils/
config.ts Env config with Zod
types.ts Shared TypeScript types
- Node.js >= 18
- TypeScript >= 5.0 (with
experimentalDecoratorsenabled)
MIT