Skip to main content
You can extend SonamuContext to add custom data needed for your project.

Context Extension Overview

Type Extension

TypeScript type extensionAutocomplete support

Middleware

Request preprocessingCustom data injection

Validation Logic

Auth/permission checksBusiness 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