From af24c4a6b17cf06b3671d5454a92d7bdbcbecd6d Mon Sep 17 00:00:00 2001 From: Liam Phung Date: Fri, 16 Oct 2026 19:41:35 +0700 Subject: [PATCH] feat(routes): mount tenants and feature-flags routers in createApiRouter (#1216) --- docs/tenants-api.md | 1 + src/routes/index.ts | 14 ++++++++ src/routes/tenants.openapi.test.ts | 53 ++++++++++++++++++++++++++---- src/routes/tenants.ts | 10 +++--- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/docs/tenants-api.md b/docs/tenants-api.md index f840a469..ebd1b64d 100644 --- a/docs/tenants-api.md +++ b/docs/tenants-api.md @@ -122,6 +122,7 @@ curl -i http://localhost:3000/api/tenants \ - Middleware: `src/middleware/etag.ts` — `etagMiddleware` + `generateETag` + `etagMatches` - Route wiring: `src/routes/tenants.ts` (`GET /` handler) +- App router registration: `src/routes/index.ts` (`/api/tenants` and `/api/feature-flags`) ## Validation Errors diff --git a/src/routes/index.ts b/src/routes/index.ts index a81e1670..a724767b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -50,6 +50,8 @@ import { InMemoryUsageStore } from "../services/usageStore.js"; import { requireAuth, type AuthenticatedLocals } from "../middleware/requireAuth.js"; import { apiKeyRepository } from "../repositories/apiKeyRepository.js"; import { adminAuth } from "../middleware/adminAuth.js"; +import { createTenantsRouter, type TenantRepository } from "./tenants.js"; +import { createFeatureFlagsRouter } from "./feature-flags.js"; const openApiPath = path.join(process.cwd(), "docs/openapi.json"); const openApiSpec = JSON.parse(readFileSync(openApiPath, "utf8")); @@ -70,6 +72,10 @@ export interface ApiRouterDeps healthCheckConfig?: HealthCheckConfig; /** Credits repository for the /api/credits hot-path lookup. */ creditsRepository?: CreditsRepository; + /** Optional tenant repository for /api/tenants. */ + tenantRepository?: TenantRepository; + /** Optional feature flags map for /api/feature-flags. */ + featureFlags?: Record; } export function createApiRouter(deps: ApiRouterDeps = {}): Router { @@ -77,6 +83,14 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { router.use("/health", healthRouter); router.use("/plans", createPlansRouter()); + router.use("/tenants", createTenantsRouter({ tenantRepository: deps.tenantRepository })); + router.use( + "/feature-flags", + createFeatureFlagsRouter({ + rateLimit: deps.restRateLimit, + flags: deps.featureFlags, + }), + ); // Hot-path credits lookup (idx_credits_lookup_hot) — see migrations/credits_index.sql router.use( "/credits", diff --git a/src/routes/tenants.openapi.test.ts b/src/routes/tenants.openapi.test.ts index da039f9b..ef58d166 100644 --- a/src/routes/tenants.openapi.test.ts +++ b/src/routes/tenants.openapi.test.ts @@ -1,11 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; import express from 'express'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; -import { errorHandler } from '../middleware/errorHandler.js'; -import { requestIdMiddleware } from '../middleware/requestId.js'; +import { createApp } from '../app.js'; import { logger } from '../logger.js'; -import { createTenantsRouter, type TenantRecord, type TenantRepository } from './tenants.js'; +import { type TenantRecord, type TenantRepository } from './tenants.js'; import type { CreateTenantInput, UpdateTenantInput } from '../validators/tenants.js'; // --------------------------------------------------------------------------- @@ -77,11 +77,20 @@ class MockTenantRepository implements TenantRepository { } function buildApp(repository = new MockTenantRepository()) { + const realApp = createApp(); + realApp.locals.tenantRepository = repository; + const app = express(); - app.use(express.json()); - app.use(requestIdMiddleware); - app.use('/api/tenants', createTenantsRouter({ tenantRepository: repository })); - app.use(errorHandler); + app.use((req, _res, next) => { + const userId = req.header('x-user-id'); + if (userId && !req.header('authorization')) { + const secret = process.env.JWT_SECRET ?? 'dev_jwt_secret_minimum_32_characters_long'; + const token = jwt.sign({ userId, sub: userId }, secret, { algorithm: 'HS256' }); + req.headers.authorization = `Bearer ${token}`; + } + next(); + }); + app.use(realApp); return { app, repository }; } @@ -468,3 +477,33 @@ describe('GET /api/tenants — list integration', () => { expect(res.body.error.code).toBe('UNAUTHORIZED'); }); }); + +describe('GET /api/feature-flags — registered in createApp()', () => { + it('returns 200 success envelope with feature flags and ETag support', async () => { + const { app } = buildApp(); + + const first = await request(app) + .get('/api/feature-flags') + .set('x-request-id', 'req-flags-ok'); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + success: true, + requestId: 'req-flags-ok', + data: { + flags: expect.objectContaining({ + 'sso-login': true, + 'dark-mode': true, + }), + }, + }); + expect(first.headers.etag).toMatch(/^"[0-9a-f]{64}"$/); + + const second = await request(app) + .get('/api/feature-flags') + .set('If-None-Match', first.headers.etag as string); + + expect(second.status).toBe(304); + }); +}); + diff --git a/src/routes/tenants.ts b/src/routes/tenants.ts index d113e2f9..dc2bab7b 100644 --- a/src/routes/tenants.ts +++ b/src/routes/tenants.ts @@ -100,7 +100,9 @@ function correlationId(req: Request): string { export function createTenantsRouter(deps: TenantsRouterDeps = {}): Router { const router = Router(); - const tenantRepository = deps.tenantRepository ?? new InMemoryTenantRepository(); + const defaultTenantRepository = deps.tenantRepository ?? new InMemoryTenantRepository(); + const getRepo = (req: Request): TenantRepository => + (req.app?.locals?.tenantRepository as TenantRepository | undefined) ?? defaultTenantRepository; router.post( '/', @@ -110,7 +112,7 @@ export function createTenantsRouter(deps: TenantsRouterDeps = {}): Router { try { const actorId = res.locals.authenticatedUser!.id; const body = createTenantSchema.parse(req.body); - const tenant = await tenantRepository.create(body, actorId); + const tenant = await getRepo(req).create(body, actorId); logger.info('[tenants] tenant created', { requestId: requestId(req), @@ -136,7 +138,7 @@ export function createTenantsRouter(deps: TenantsRouterDeps = {}): Router { const actorId = res.locals.authenticatedUser!.id; const { tenantId } = tenantParamsSchema.parse(req.params); const body = updateTenantSchema.parse(req.body); - const tenant = await tenantRepository.update(tenantId, body, actorId); + const tenant = await getRepo(req).update(tenantId, body, actorId); logger.info('[tenants] tenant updated', { requestId: requestId(req), @@ -159,7 +161,7 @@ export function createTenantsRouter(deps: TenantsRouterDeps = {}): Router { async (req: Request, res: Response, next) => { try { const actorId = res.locals.authenticatedUser!.id; - const tenants = await tenantRepository.list(actorId); + const tenants = await getRepo(req).list(actorId); logger.info('[tenants] tenants listed', { requestId: requestId(req),