Context Extension Overview
Type Extension
TypeScript type extension Autocomplete support
Middleware
Request preprocessing Custom data injection
Validation Logic
Auth/permission checks Business rule application
Common Data
Session, tenant, etc. Request-scoped data
Type Extension
Custom User Type
// types/context.ts
// Extend base Context
declare module "sonamu" {
interface ContextUser {
id: number;
email: string;
username: string;
role: "admin" | "manager" | "normal";
// Add custom fields
organizationId: number;
departmentId: number;
permissions: string[];
lastLoginAt: Date;
preferredLanguage: "ko" | "en" | "ja";
}
}
Custom Context Fields
// types/context.ts
declare module "sonamu" {
interface ContextExtend {
// Add custom fields
tenant?: {
id: number;
name: string;
subdomain: string;
features: string[];
};
timezone: string;
requestId: string;
}
}
Use TypeScript’s Declaration Merging to extend the
ContextExtend interface.
Base fields such as request, reply, user, session, and locale are already included in the Context type,
so you don’t need to redeclare them here. This enables IDE autocomplete and type checking.Injecting Data with Middleware
Auth Middleware
// middleware/auth.middleware.ts
import { FastifyRequest, FastifyReply } from "fastify";
import jwt from "jsonwebtoken";
import { Sonamu } from "sonamu";
export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) {
// Extract token from Authorization header
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return; // No token (optional auth)
}
const token = authHeader.substring(7);
try {
// Verify JWT token
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: number;
role: string;
organizationId: number;
departmentId: number;
permissions: string[];
};
// Get user info from DB
const rdb = Sonamu.getPuri("r");
const user = await rdb.table("users").where("id", payload.userId).first();
if (!user) {
reply.status(401);
throw new Error("Invalid token");
}
// Set user in Context
const context = Sonamu.getContext();
context.user = {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
organizationId: payload.organizationId,
departmentId: payload.departmentId,
permissions: payload.permissions,
lastLoginAt: user.last_login_at,
preferredLanguage: user.preferred_language,
};
// Update last activity time
await rdb.table("users").where("id", user.id).update({ last_activity_at: new Date() });
} catch (error) {
reply.status(401);
throw new Error("Invalid or expired token");
}
}
Tenant Middleware
// middleware/tenant.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";
export async function tenantMiddleware(request: FastifyRequest) {
const context = Sonamu.getContext();
// Extract tenant info from subdomain or header
const subdomain = getSubdomain(request.hostname);
const tenantId = request.headers["x-tenant-id"] as string;
if (!subdomain && !tenantId) {
return; // No tenant info
}
const rdb = Sonamu.getPuri("r");
// Get tenant
const tenant = await rdb
.table("tenants")
.where((qb) => {
if (subdomain) {
qb.where("subdomain", subdomain);
}
if (tenantId) {
qb.orWhere("id", parseInt(tenantId));
}
})
.first();
if (!tenant) {
throw new Error("Tenant not found");
}
// Check tenant active status
if (!tenant.is_active) {
throw new Error("Tenant is inactive");
}
// Set tenant in Context
context.tenant = {
id: tenant.id,
name: tenant.name,
subdomain: tenant.subdomain,
features: tenant.features || [],
};
}
function getSubdomain(hostname: string): string | null {
const parts = hostname.split(".");
if (parts.length >= 3) {
return parts[0];
}
return null;
}
Locale Middleware
// middleware/locale.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";
const SUPPORTED_LOCALES = ["ko", "en", "ja"];
const DEFAULT_LOCALE = "en";
export function localeMiddleware(request: FastifyRequest) {
const context = Sonamu.getContext();
// 1. URL query parameter
const queryLocale = request.query?.locale as string;
if (queryLocale && SUPPORTED_LOCALES.includes(queryLocale)) {
context.locale = queryLocale;
return;
}
// 2. User preference (logged in state)
if (context.user?.preferredLanguage) {
context.locale = context.user.preferredLanguage;
return;
}
// 3. Accept-Language header
const acceptLanguage = request.headers["accept-language"];
if (acceptLanguage) {
const lang = acceptLanguage.split(",")[0].split("-")[0];
if (SUPPORTED_LOCALES.includes(lang)) {
context.locale = lang;
return;
}
}
// 4. Default
context.locale = DEFAULT_LOCALE;
}
Request ID Middleware
// middleware/request-id.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";
import crypto from "crypto";
export function requestIdMiddleware(request: FastifyRequest) {
const context = Sonamu.getContext();
// Client-provided Request ID or generate new
const requestId = (request.headers["x-request-id"] as string) || crypto.randomUUID();
context.requestId = requestId;
// Add to response header as well
context.reply.header("X-Request-Id", requestId);
}
Registering Middleware
// server.ts
import fastify from "fastify";
import { authMiddleware } from "./middleware/auth.middleware";
import { tenantMiddleware } from "./middleware/tenant.middleware";
import { localeMiddleware } from "./middleware/locale.middleware";
import { requestIdMiddleware } from "./middleware/request-id.middleware";
const app = fastify();
// Register middleware (order matters!)
app.addHook("preHandler", requestIdMiddleware);
app.addHook("preHandler", tenantMiddleware);
app.addHook("preHandler", authMiddleware);
app.addHook("preHandler", localeMiddleware);
// Register API routes
// ...
Practical Usage Examples
Multi-tenant API
class ProductModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async list(): Promise<Product[]> {
const context = Sonamu.getContext();
// Check tenant
if (!context.tenant) {
throw new Error("Tenant required");
}
const rdb = this.getPuri("r");
// Query only tenant-specific data
const products = await rdb.table("products").where("tenant_id", context.tenant.id).select("*");
return products;
}
@api({ httpMethod: "POST" })
async create(params: ProductSaveParams): Promise<{ productId: number }> {
const context = Sonamu.getContext();
if (!context.tenant) {
throw new Error("Tenant required");
}
const wdb = this.getPuri("w");
// Auto-add tenant ID
const [product] = await wdb
.table("products")
.insert({
...params,
tenant_id: context.tenant.id,
})
.returning("id");
return { productId: product.id };
}
}
Permission-based Access Control
class BaseModelClass {
protected requirePermission(permission: string): void {
const context = Sonamu.getContext();
if (!context.user) {
context.reply.status(401);
throw new Error("Authentication required");
}
if (!context.user.permissions.includes(permission)) {
context.reply.status(403);
throw new Error(`Permission denied: ${permission} required`);
}
}
}
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async create(params: UserSaveParams): Promise<{ userId: number }> {
// Check permission
this.requirePermission("user:create");
const wdb = this.getPuri("w");
const [user] = await wdb.table("users").insert(params).returning("id");
return { userId: user.id };
}
@api({ httpMethod: "DELETE" })
async remove(userId: number): Promise<void> {
this.requirePermission("user:delete");
const wdb = this.getPuri("w");
await wdb.table("users").where("id", userId).delete();
}
}
Internationalized Messages
// services/i18n.service.ts
import { Sonamu } from "sonamu";
const messages: Record<string, Record<string, string>> = {
ko: {
"user.not_found": "사용자를 찾을 수 없습니다",
"user.created": "사용자가 생성되었습니다",
"permission.denied": "권한이 없습니다",
},
en: {
"user.not_found": "User not found",
"user.created": "User created",
"permission.denied": "Permission denied",
},
ja: {
"user.not_found": "ユーザーが見つかりません",
"user.created": "ユーザーが作成されました",
"permission.denied": "権限がありません",
},
};
export function t(key: string): string {
const context = Sonamu.getContext();
const locale = context.locale || "en";
return messages[locale]?.[key] || key;
}
// Usage
class UserModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async get(id: number): Promise<User> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", id).first();
if (!user) {
// Internationalized error message
throw new Error(t("user.not_found"));
}
return user;
}
}
Automated Audit Logging
class BaseModelClass {
protected async audit(
action: string,
resourceType: string,
resourceId: number,
details?: any,
): Promise<void> {
const context = Sonamu.getContext();
const wdb = this.getPuri("w");
await wdb.table("audit_logs").insert({
user_id: context.user?.id,
tenant_id: context.tenant?.id,
action,
resource_type: resourceType,
resource_id: resourceId,
details: JSON.stringify(details || {}),
ip_address: context.request.ip,
user_agent: context.request.headers["user-agent"],
request_id: context.requestId,
timestamp: new Date(),
});
}
}
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async create(params: UserSaveParams): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
const [user] = await wdb.table("users").insert(params).returning("id");
// Auto audit log
await this.audit("CREATE", "user", user.id, { email: params.email });
return { userId: user.id };
}
@api({ httpMethod: "DELETE" })
async remove(userId: number): Promise<void> {
const rdb = this.getPuri("r");
const user = await rdb.table("users").where("id", userId).first();
await this.audit("DELETE", "user", userId, { username: user.username });
const wdb = this.getPuri("w");
await wdb.table("users").where("id", userId).delete();
}
}
Organization/Department Filtering
class DocumentModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async list(): Promise<Document[]> {
const context = Sonamu.getContext();
if (!context.user) {
throw new Error("Authentication required");
}
const rdb = this.getPuri("r");
let query = rdb.table("documents");
// Non-admins see only their organization/department
if (context.user.role !== "admin") {
query = query.where((qb) => {
qb.where("organization_id", context.user!.organizationId)
.orWhere("department_id", context.user!.departmentId)
.orWhere("user_id", context.user!.id);
});
}
return query.select("*");
}
}
Context-based Caching
class CacheService {
private cache = new Map<string, any>();
get(key: string): any {
const context = Sonamu.getContext();
const cacheKey = `${context.tenant?.id || "global"}:${key}`;
return this.cache.get(cacheKey);
}
set(key: string, value: any, ttl: number = 3600): void {
const context = Sonamu.getContext();
const cacheKey = `${context.tenant?.id || "global"}:${key}`;
this.cache.set(cacheKey, value);
// Delete after TTL
setTimeout(() => {
this.cache.delete(cacheKey);
}, ttl * 1000);
}
}
// Usage
class ProductModel extends BaseModelClass {
private cacheService = new CacheService();
@api({ httpMethod: "GET" })
async list(): Promise<Product[]> {
// Tenant-specific cache
const cached = this.cacheService.get("products:list");
if (cached) {
return cached;
}
const context = Sonamu.getContext();
const rdb = this.getPuri("r");
const products = await rdb.table("products").where("tenant_id", context.tenant?.id).select("*");
this.cacheService.set("products:list", products);
return products;
}
}
Cautions
Cautions when using custom Context: 1. Define type extensions early in the project 2.
Middleware execution order matters 3. Treat Context data as immutable 4. Consider encrypting
sensitive information 5. Prevent memory leaks (avoid storing large data)
Next Steps
SonamuContext
Understanding Context structure
Getting Context
Using Sonamu.getContext()
@api Decorator
API basic usage
Middleware
Request preprocessing