OpenAPI & API Documentation
Generate interactive API documentation with OpenAPI 3.x and Swagger UI from your Subatom routes.
OpenAPI Generation Overview
Subatom provides automatic OpenAPI schema generation from route definitions and validation schemas:
- Automatic Schema Inference - subatom-infer schemas are converted to OpenAPI schemas
- Interactive Swagger UI - Browse and test your API live
- Type-Safe - Documentation stays in sync with code via TypeScript types
- Extensible - Add custom metadata, examples, and descriptions
Setup
Installation
npm install subatom
Basic Configuration
// src/index.ts
import { Subatom, SubAtomDocs } from "subatom";
import infer from "subatom-infer";
const app = new Subatom();
// Register OpenAPI documentation
SubAtomDocs(app, {
title: "My API",
version: "1.0.0",
description: "API documentation",
});
// Access at:
// GET /docs (Swagger UI)
// GET /openapi.json (OpenAPI spec)
Route Documentation
Basic Route with Schema
app.get("/users/:id", {
schema: {
params: infer.object({
id: infer.string().describe("User ID"),
}),
},
controller: (ctx) => {
ctx.res.json({ id: ctx.params.id, name: "John" });
},
});
Generates OpenAPI:
get:
summary: Get User
parameters:
- name: id
in: path
required: true
schema:
type: string
description: User ID
responses:
"200":
description: Success
Complete Documentation Example
const userSchema = infer.object({
id: infer.string(),
email: infer.string().email().describe("User email address"),
name: infer.string().describe("Full name"),
role: infer.string(["user", "admin"]).describe("User role"),
createdAt: infer.string().datetime().describe("Account creation date"),
});
app.post("/users", {
schema: {
body: infer.object({
email: infer.string().email().describe("Email address"),
name: infer.string().min(1).describe("Full name"),
password: infer.string().min(8).describe("Password (min 8 chars)"),
}),
},
controller: async (ctx) => {
// Implementation
},
});
Query Parameters
app.get("/users", {
schema: {
query: infer.object({
skip: infer.number()
.int()
.nonnegative()
.default(0)
.describe("Number of records to skip"),
take: infer.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Number of records to return"),
email: infer.string().email().optional().describe("Filter by email"),
role: infer.enum(["user", "admin"]).optional().describe("Filter by role"),
}),
},
controller: async (ctx) => {
// Get users with filtering and pagination
},
});
Generates OpenAPI:
get:
parameters:
- name: skip
in: query
schema:
type: integer
minimum: 0
default: 0
description: Number of records to skip
- name: take
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Number of records to return
- name: email
in: query
required: false
schema:
type: string
format: email
description: Filter by email
- name: role
in: query
required: false
schema:
type: string
enum: [user, admin]
description: Filter by role
Response Documentation
Custom Response Type
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
app.get("/users/:id", {
schema: {
params: infer.object({ id: infer.string() }),
},
controller: async (ctx): Promise<User> => {
return {
id: ctx.params.id,
email: "user@example.com",
name: "John Doe",
createdAt: new Date(),
};
},
});
Multiple Response Codes
app.get("/users/:id", {
schema: {
params: infer.object({ id: infer.string() }),
},
controller: async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) {
// 404 response
ctx.res.status(404).json({ error: "User not found" });
return;
}
// 200 response
ctx.res.json(user);
},
});
Document with custom metadata:
app.get("/users/:id", {
name: "getUserById",
summary: "Get User by ID",
description: "Retrieve a specific user by their ID",
tags: ["Users"],
examples: [
{
id: "user-123",
email: "john@example.com",
name: "John Doe",
},
],
schema: {
params: infer.object({ id: infer.string() }),
},
controller: async (ctx) => {
ctx.res.json(await findUser(ctx.params.id));
},
});
Error Responses
Document error cases:
app.post("/users", {
tags: ["Users"],
summary: "Create User",
schema: {
body: infer.object({
email: infer.string().email(),
name: infer.string(),
}),
},
controller: async (ctx) => {
const exists = await checkUserExists(ctx.body.email);
if (exists) {
// 409 Conflict
ctx.res.status(409).json({
error: "User already exists",
code: "USER_EXISTS",
});
return;
}
// 201 Created
const user = await createUser(ctx.body);
ctx.res.status(201).json(user);
},
});
Authentication Documentation
Bearer Token
app.get("/me", {
summary: "Get Current User",
description: "Requires valid JWT token",
tags: ["Auth"],
security: {
bearerAuth: [],
},
middleware: [requireAuth],
controller: (ctx) => {
ctx.res.json(ctx.user);
},
});
API Key
app.get("/admin/stats", {
summary: "Get Statistics",
security: {
apiKey: [],
},
middleware: [requireApiKey],
controller: (ctx) => {
ctx.res.json({ users: 1000, posts: 5000 });
},
});
Configure security schemes:
SubAtomDocs(app, {
title: "My API",
version: "1.0.0",
security: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "JWT token in Authorization header",
},
apiKey: {
type: "apiKey",
in: "header",
name: "X-API-Key",
description: "API key for service-to-service auth",
},
},
});
Tags & Organization
const router = new Router();
// Users routes
router.get("/", {
tags: ["Users"],
summary: "List Users",
controller: async (ctx) => {
ctx.res.json(await getUsers());
},
});
router.post("/", {
tags: ["Users"],
summary: "Create User",
schema: {
body: infer.object({ email: infer.string().email(), name: infer.string() }),
},
controller: async (ctx) => {
ctx.res.status(201).json(await createUser(ctx.body));
},
});
// Posts routes
router.get("/posts", {
tags: ["Posts"],
summary: "List Posts",
controller: async (ctx) => {
ctx.res.json(await getPosts());
},
});
router.post("/posts", {
tags: ["Posts"],
summary: "Create Post",
schema: {
body: infer.object({ title: infer.string(), content: infer.string() }),
},
controller: async (ctx) => {
ctx.res.status(201).json(await createPost(ctx.body));
},
});
app.use("/api", router);
Examples & Defaults
const createUserSchema = infer.object({
email: infer.string().email(),
name: infer.string(),
role: infer.string(["user", "admin"]).default("user"),
});
app.post("/users", {
schema: { body: createUserSchema },
examples: [
{
name: "Create Regular User",
value: {
email: "john@example.com",
name: "John Doe",
role: "user",
},
},
{
name: "Create Admin User",
value: {
email: "admin@example.com",
name: "Admin User",
role: "admin",
},
},
],
controller: async (ctx) => {
ctx.res.status(201).json(await createUser(ctx.body));
},
});
Swagger Customization
Branding
SubAtomDocs(app, {
title: "My Company API",
version: "1.0.0",
description: "Production-grade API",
contact: {
name: "API Support",
url: "https://example.com/support",
email: "support@example.com",
},
license: {
name: "MIT",
url: "https://opensource.org/licenses/MIT",
},
});
Swagger UI Customization
SubAtomDocs(app, {
title: "My API",
swaggerOptions: {
swaggerUrl: "/docs/swagger.json",
apisSorter: "alpha",
operationsSorter: "method",
displayOperationId: true,
filter: true,
showRequestHeaders: true,
defaultModelsExpandDepth: 1,
},
});
Endpoints
After registering docs:
- GET /docs - Swagger UI
- GET /openapi.json - OpenAPI 3.1.0 specification
- GET /docs/swagger.json - OpenAPI 3.1.0 specification (if enabled)
Best Practices
1. Describe All Fields
// Good
const schema = infer.object({
email: infer.string().email().describe("Email address"),
name: infer.string().describe("Full name"),
});
// Bad
const schema = infer.object({
email: infer.string().email(),
name: infer.string(),
});
2. Use Consistent Tags
const TAGS = {
USERS: "Users",
POSTS: "Posts",
AUTH: "Authentication",
} as const;
app.get("/users", { tags: [TAGS.USERS], /* ... */ });
app.get("/posts", { tags: [TAGS.POSTS], /* ... */ });
app.post("/login", { tags: [TAGS.AUTH], /* ... */ });
3. Document Error Codes
app.post("/users", {
schema: { /* ... */ },
controller: async (ctx) => {
if (await userExists(ctx.body.email)) {
// Document this error in OpenAPI
ctx.res.status(409).json({
error: "Email already registered",
code: "EMAIL_EXISTS",
});
return;
}
},
});
4. Use Versioning
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
SubAtomDocs(app, {
title: "My API",
version: "2.0.0",
});
5. Hide Internal Routes
// Public routes
app.get("/users", {
tags: ["Users"], // Will appear in docs
controller: (ctx) => {
ctx.res.json(users);
},
});
// Internal routes
app.get("/health", {
// No tags - won't appear in docs
controller: (ctx) => {
ctx.res.json({ ok: true });
},
});
Common Pitfalls
❌ Missing Descriptions
// Wrong: No context
app.get("/api/data", {
schema: { query: infer.object({ id: infer.string() }) },
controller: (ctx) => { /* ... */ },
});
// Correct: Clear documentation
app.get("/api/users/:id", {
summary: "Get User by ID",
description: "Retrieve a specific user profile",
tags: ["Users"],
schema: {
params: infer.object({ id: infer.string().describe("User ID") }),
},
controller: (ctx) => { /* ... */ },
});
❌ Inconsistent Response Types
// Wrong: Different responses
app.get("/users/:id", {
controller: (ctx) => {
if (found) ctx.res.json({ user: {} });
else ctx.res.json({ error: "not found" });
},
});
// Correct: Consistent structure
app.get("/users/:id", {
controller: (ctx) => {
if (!found) {
ctx.res.status(404).json({ error: "not found" });
return;
}
ctx.res.json({ user: {} });
},
});
Next Steps
- Learn about Validation for schema design
- Explore Routing for endpoint organization
- Read Best Practices
- Check Security for authentication docs