server-http
Como escrever controllers, rotas e middlewares em server/src/presentation no Smaug — métodos async (req,res,next), req.userId! preenchido pelo authenticate (Bearer), cadeia instanceof mapeando erro de
Files1
View on GitHubSKILL.md.claude/skills/server-http/SKILL.md
GitHubCamada HTTP: controller, rota e middleware
Quando usar esta skill
Ao criar/alterar server/src/presentation/controllers/*.controller.ts,
presentation/routes/*.routes.ts ou presentation/middlewares/*.middleware.ts.
Anatomia canônica — controller
presentation/controllers/one-time-revenue.controller.ts:
import { Request, Response, NextFunction } from "express";
import { OneTimeRevenueService } from "@src/application/services/one-time-revenue.service";
import { PastCompetenceError } from "@src/domain/use-cases/one-time-revenue/create-one-time-revenue.use-case";
import {
RevenueNotFoundError,
PastCompetenceEditError,
} from "@src/domain/use-cases/one-time-revenue/update-one-time-revenue.use-case";
export class OneTimeRevenueController {
constructor(private readonly service: OneTimeRevenueService) {}
public async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await this.service.create(req.userId!, req.body);
res.status(201).json(result);
} catch (error) {
if (error instanceof PastCompetenceError) {
res.status(409).json({ error: error.code, message: error.message });
return;
}
next(error);
}
}
public async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await this.service.delete(req.userId!, req.params.id as string);
res.status(204).send();
} catch (error) {
if (error instanceof RevenueNotFoundError) {
res.status(404).json({ error: error.code, message: error.message });
return;
}
if (error instanceof PastCompetenceEditError) {
res.status(409).json({ error: error.code, message: error.message });
return;
}
next(error);
}
}
}
Regras do controller
Classe com métodos
asyncnormais — não propriedades arrow. É por isso que a rota precisa de.bind(controller).Assinatura sempre
(req: Request, res: Response, next: NextFunction): Promise<void>. Nuncareturn res....Uma dependência no construtor, chamada
service(oAuthControllerrecebe também aRefreshCookieConfig— ver abaixo; oUserControllerainda chama a sua deuserService).req.userId!com non-null assertion — o campo (ereq.sessionId) é declarado por module augmentation emauthenticate.middleware.tse garantido peloauthenticateda montagem. É a única exceção aceita ao banimento de!.req.sessionIdsó é lido peloAuthController.logout, sem!: lá o middleware é oauthenticateIfPresent, que pode não preenchê-lo.req.params.id as string(o Express 5 tipaparamsde forma frouxa).Mapeamento erro→status é manual, por método, com cadeia de
if (error instanceof X), cada um terminando emreturn;, enext(error)como último recurso (vira 500).Corpo de erro é sempre
{ error: error.code, message: error.message }— o campoerrorcarrega o código, não o texto humano.Tabela de status:
Operação Status create 201 + res.json(result)get / list / update / terminate 200 + res.json(result)delete 204 + res.send()(sem corpo)erro "não encontrado" 404 violação de regra de negócio 409 body/query inválido 400 (vem do validateRequest)Bearer ausente/inválido 401 UNAUTHORIZED(vem doauthenticate)credencial/refresh inválido 401 (mapeado no AuthController)Originnão confiável403 ORIGIN_NOT_ALLOWED(requireTrustedOrigin)limite de tentativas 429 TOO_MANY_REQUESTS+Retry-After(rate limit)O controller não toca em repositório nem em entidade: só service + classes de erro.
health.controller.tsé a única exceção: função exportadahealthController(_req, res), devolvendo 200 ou 503 comstatus: "degraded". Parâmetro não usado leva prefixo_.
Cookie de refresh — só no AuthController
O refresh token anda em cookie, e cookie é detalhe HTTP: o AuthService devolve
{ response, refreshToken, refreshTokenExpiresAt } sem saber de cookie, e o controller grava/apaga.
Os atributos que variam por ambiente (name, secure, path) chegam pelo construtor
(RefreshCookieConfig, montada no container a partir de authConfig.cookie); nada aqui lê env.
Trecho de presentation/controllers/auth.controller.ts:
export class AuthController {
constructor(
private readonly service: AuthService,
private readonly cookie: RefreshCookieConfig,
) {}
public async refresh(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await this.service.refresh(this.readRefreshCookie(req));
this.setRefreshCookie(res, result);
res.status(200).json(result.response);
} catch (error) {
if (error instanceof InvalidRefreshTokenError) {
this.clearRefreshCookie(res);
res.status(401).json({ error: error.code, message: error.message });
return;
}
// Conflito dentro da janela: outra aba já renovou; o cookie novo dela vale, não se apaga nada.
if (error instanceof RefreshTokenConflictError) {
res.status(409).json({ error: error.code, message: error.message });
return;
}
next(error);
}
}
private setRefreshCookie(res: Response, result: AuthSessionResult): void {
res.cookie(this.cookie.name, result.refreshToken, {
...this.cookieAttributes(),
maxAge: Math.max(0, result.refreshTokenExpiresAt.getTime() - Date.now()),
});
}
/** Mesmos atributos da emissão: o navegador só apaga o cookie se `Path` coincidir. */
private clearRefreshCookie(res: Response): void {
res.clearCookie(this.cookie.name, this.cookieAttributes());
}
private cookieAttributes(): CookieOptions {
return {
httpOnly: true,
secure: this.cookie.secure,
sameSite: "strict",
path: this.cookie.path,
};
}
}
Regras: o cookie (smaug_refresh) é sempre HttpOnly + SameSite=Strict + Path + Max-Age;
clearCookie repete os mesmos atributos; INVALID_REFRESH_TOKEN apaga o cookie, já
REFRESH_TOKEN_CONFLICT (409) mantém; logout/logoutAll apagam e respondem 204. O corpo de
sucesso de register (201), login e refresh (200) é { accessToken, tokenType: "Bearer", expiresIn, user } — o refresh token nunca vai no corpo.
Anatomia canônica — rotas
presentation/routes/one-time-revenue.routes.ts (arquivo completo):
import { Router } from "express";
import { OneTimeRevenueController } from "@src/presentation/controllers/one-time-revenue.controller";
import { validateRequest } from "@src/presentation/middlewares/validate-request.middleware";
import {
createOneTimeRevenueSchema,
updateOneTimeRevenueSchema,
} from "@src/application/dtos/one-time-revenue.dto";
export function createOneTimeRevenueRoutes(controller: OneTimeRevenueController): Router {
const router = Router();
router.post("/", validateRequest(createOneTimeRevenueSchema), controller.create.bind(controller));
router.get("/", controller.list.bind(controller));
router.put(
"/:id",
validateRequest(updateOneTimeRevenueSchema),
controller.update.bind(controller),
);
router.delete("/:id", controller.delete.bind(controller));
return router;
}
Regras:
Uma factory por feature:
export function create<Feature>Routes(controller): Router. Só export nomeado, semdefault.const router = Router();→ registra →return router;.Handler é sempre
controller.<metodo>.bind(controller).Os schemas vêm de
@src/application/dtos/<feature>.dto, não são declarados aqui.validateRequest(schema)antes de handler de escrita;validateQuery(schema)antes de consulta. Não há validação de:id.Ação de domínio vira sub-rota:
router.post("/:id/terminate", controller.terminate.bind(controller)).Middleware que depende de configuração ou de outro objeto chega por parâmetro da factory, não por import do container:
createAuthRoutes(controller, options)recebeauthenticate,authenticateIfPresent,trustedOriginerateLimiters(AuthRoutesOptions), e ligacookieParser()+requireTrustedOrigin(trustedOrigin)só no próprio sub-router:export function createAuthRoutes( controller: AuthController, options: AuthRoutesOptions, ): Router { const router = Router(); const { authenticate, authenticateIfPresent, trustedOrigin, rateLimiters } = options; // O cookie de refresh só existe para /auth: o parser fica neste sub-router. router.use(cookieParser()); router.use(requireTrustedOrigin(trustedOrigin)); router.post( "/register", rateLimiters.register, validateRequest(registerSchema), controller.register.bind(controller), ); router.post( "/login", rateLimiters.loginPerIp, rateLimiters.loginPerEmail, validateRequest(loginSchema), controller.login.bind(controller), ); router.post("/refresh", rateLimiters.refresh, controller.refresh.bind(controller)); router.post("/logout", authenticateIfPresent, controller.logout.bind(controller)); router.post("/logout-all", authenticate, controller.logoutAll.bind(controller)); return router; }O rate limiter vem antes do
validateRequest(tentativa inválida também conta).
routes/index.ts — o único barril do projeto
const router = Router();
const authenticateMiddleware = authenticate(authenticateRequestUseCase);
router.get("/health", healthController);
router.use(
"/auth",
createAuthRoutes(authController, {
authenticate: authenticateMiddleware,
authenticateIfPresent: authenticateIfPresent(authenticateRequestUseCase),
trustedOrigin: env.CORS_ORIGIN,
rateLimiters: createAuthRateLimiters(
container.resolve<Logger>("Logger"),
authConfig.rateLimits,
),
}),
);
router.use("/users", authenticateMiddleware, createUserRoutes(userController));
router.use(
"/expenses/categories",
authenticateMiddleware,
createExpenseCategoryRoutes(expenseCategoryController),
);
// … /expenses/one-time, /expenses/installment, /expenses/recurring,
// /revenues/one-time, /revenues/fixed — todos com authenticateMiddleware
router.use("/data", authenticateMiddleware, createDataRoutes(dataController));
router.use("/revenues", authenticateMiddleware, createRevenueQueryRoutes(revenueQueryController));
router.use("/expenses", authenticateMiddleware, createExpenseQueryRoutes(expenseQueryController));
export default router;
A ordem é funcional, não estética: /health e /auth ficam abertos (é por /auth/register
que se cria o primeiro usuário; /auth/logout-all exige Bearer dentro do próprio sub-router); os
prefixos específicos vêm antes; os catch-alls /revenues e /expenses ficam por último, senão
engolem /revenues/one-time e companhia. Um único authenticateMiddleware é criado no topo e
reaproveitado em todas as montagens protegidas, inclusive /users (que hoje só tem
GET /users/me). Os controllers e o authenticateRequestUseCase são os consts exportados por
container.ts.
Middlewares (presentation/middlewares/, os 6)
Todos são funções exportadas — sem classe, sem decorator. Dois são handlers diretos (log e erro), os demais são factories que devolvem um handler.
authenticate.middleware.ts— a autenticação do projeto. Duas factories que recebem oAuthenticateRequestUseCase(não um repositório) e leemAuthorization: Bearer <token>:authenticate(useCase)exige token válido de sessão ativa; qualquer falha vira401 { error: "UNAUTHORIZED", message: "Authentication required" }, sem dizer o motivo nem logar o token.authenticateIfPresent(useCase)preenche quando há Bearer válido e segue de qualquer forma (só oPOST /auth/logout, que aceita cookie ou token já expirado).
Declara a module augmentation dos dois campos:
declare module "express-serve-static-core" { interface Request { userId?: string; sessionId?: string; } }require-trusted-origin.middleware.ts—requireTrustedOrigin(allowedOrigin):Origindiferente deCORS_ORIGIN→403 ORIGIN_NOT_ALLOWED;Originausente segue (cliente que não é navegador). Só no sub-router/auth.rate-limit.middleware.ts—createAuthRateLimiters(logger, limits)devolve{ register, loginPerIp, loginPerEmail, refresh }(express-rate-limit, store em memória, chave pelo IP — e por IP + e-mail normalizado nologinPerEmail). O 429 segue o formato de erro do projeto (TOO_MANY_REQUESTS) comRetry-After, e logaauth.rate_limitedemwarn. O IP só é o do cliente comTRUST_PROXYconfigurado (verserver.ts).validate-request.middleware.ts—validateRequest(schema)evalidateQuery(schema). Achatam os issues do Zod emRecord<string, string[]>(chaveissue.path.join(".") || "_root") e respondem400 { error: "VALIDATION_ERROR", message: "Invalid request body", details: formatted }.validateRequestsobrescrevereq.body = result.data(o controller recebe dado já coagido).request-logger.middleware.ts— resolve oLoggerdo container, cronometra comprocess.hrtime.bigint()e loga no eventores.on("finish").error-handler.middleware.ts— handler de 4 argumentos, sempre o último do app. Loga e devolve500 { error: "Internal Server Error" }. Ele não conheceDomainError: toda falha que o cliente precisa entender tem que ser mapeada no controller.
Montagem em infrastructure/http/server.ts (a ordem importa):
if (authConfig.trustProxy !== null) {
app.set("trust proxy", authConfig.trustProxy);
}
app.use(
cors({
origin: env.CORS_ORIGIN,
credentials: true,
exposedHeaders: ["Content-Disposition"],
}),
);
app.use(express.json());
app.use(express.text({ type: "text/csv", limit: "5mb" }));
app.use(requestLoggerMiddleware);
app.use(routes);
app.use(errorHandlerMiddleware); // sempre por último
O cookieParser() não é global: fica no sub-router /auth, o único que lê cookie.
Checklist
- Controller como classe, com métodos
async (req,res,next): Promise<void>. -
req.userId!ereq.params.id as string. - Cadeia
instanceofcobrindo todos os erros que o service pode lançar, cada um comreturn;. -
next(error)no fim de todocatch. - Status conforme a tabela; delete devolve 204 com
res.send(). - Rota com factory
create<Feature>Routes(controller)e.bind(controller). - Schemas importados do DTO.
- Montagem em
routes/index.tsantes dos catch-alls, com oauthenticateMiddlewarejá existente (não crie outroauthenticate(...)). - Código de erro novo (inclusive de middleware) pareado em
web/infra/api-error.ts. - Teste de controller via
describeControllerContract(skillserver-testes). - Teste de integração cobrindo cada status (skill
server-testes).
Não faça
- Não transforme métodos do controller em propriedades arrow (quebraria o padrão do
.bind). - Não declare schema Zod no arquivo de rota.
- Não deixe erro de negócio cair no
errorHandlerMiddleware— vira 500 genérico. - Não monte um prefixo novo depois dos catch-alls
/revenues//expenses. - Não instancie controller ou service na rota: importe o const do
container.ts. - Não leia
env/authConfigdentro de controller ou middleware: receba por construtor ou parâmetro de factory (sóroutes/index.tseserver.tsleem a configuração). - Não ponha o refresh token no corpo da resposta, nem cookie fora do
AuthController. - Não diferencie o motivo do 401 do
authenticate(token ausente, expirado, sessão revogada…): a resposta é sempre a mesma. - Não importe
cookie-parser/express-rate-limitfora depresentation/(regrahttp-middleware-libs-only-in-presentation).
Inconsistências conhecidas
PUT /:idnas receitas vsPATCH /:idnas despesas. Para recurso novo, escolha pelo semântico (substituição total →PUT; parcial →PATCH) e mantenha coerente com oweb/features/*/servicescorrespondente, que os testes de service assertam por assinatura.- O construtor do
UserControllerchama a dependência deuserService, nãoservice. AuthController.logout/logoutAllnão têm cadeiainstanceof: a revogação é idempotente e não lança erro de domínio.validateQuerygravavalidatedQueryno request, mas nenhum controller lê;ExpenseQueryControllerre-parseiareq.querycom o próprio schema. Não replique a dupla validação: escolha uma.