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 SonamuContext {
// Base fields
request: FastifyRequest;
reply: FastifyReply;
user?: ContextUser;
// Add custom fields
tenant?: {
id: number;
name: string;
subdomain: string;
features: string[];
};
session?: {
id: string;
createdAt: Date;
expiresAt: Date;
data: Record<string, any>;
};
locale: string;
timezone: string;
requestId: string;
}
}
Use TypeScriptβs Declaration Merging to extend types. 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: "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: "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: "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