Pular para conteúdo

0016 — Contrato de erro único (RFC 7807 / problem+json)

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-07-06

Contexto

A auditoria §6 (constituição 0.17.35, arquitetura destilada dos pilotos) apontou que o auth montava corpos de erro ad-hoc — HttpResponse.status(...).body(Map.of("error", ..., "message", ...)) — espalhados por controllers e filtros (~50 pontos, ~28 códigos distintos). A norma da casa (bi-transporte-xls, decisão 0017) é um corpo único RFC 7807 (application/problem+json) renderizado por um ErrorResponseProcessor que @Replaces o HateoasErrorResponseProcessor default.

Dois fatos do auth impedem uma cópia literal do modelo dos BIs:

  1. Códigos de máquina são load-bearing. O error (ex.: invalid_token, user_exists, cliente_not_found) é consumido pela Central e travado em teste. O processor dos BIs deriva o código do status.name() — o que colapsaria os ~28 códigos (vários sob o mesmo status: missing_token e invalid_token são ambos 401).
  2. Os endpoints públicos (AuthController) são consumidos por browser e hoje adicionam CORS a toda resposta — inclusive erros — via withCors(...). Um ErrorResponseProcessor global não adiciona CORS; sem ele, o browser bloquearia a leitura do corpo de erro.

Decisão

Adotar o problem+json como contrato único de erro, com três peças em comum/error:

  • ProblemDetail — record RFC 7807 (type, title, status, detail) + a extensão code (o antigo error, machine-code estável). Desde a const 0.28.0 vem da lib br.com.xadm:xadm-comum-web (br.com.xadm.comum.web.ProblemDetail), não mais do fork local — ver Atualização abaixo.
  • ApiException extends HttpStatusException — carrega status + code. Diverge do modelo classe-por-status dos BIs de propósito (o auth tem ~28 códigos, muitos sob o mesmo status → o código é carregado pela exceção, não derivado do status). Fica local (é domínio do auth) e implementa br.com.xadm.comum.web.PortadoraDeProblema — o ponto de extensão pelo qual o processor da lib lê o machine-code (ver Atualização).
  • UnifiedErrorResponseProcessor — renderiza os erros gerados pelo framework (validação, 404, exceções) em problem+json. Desde a const 0.28.0 vem da lib (xadm-comum-web:0.3.0): lê o code da PortadoraDeProblema na causa-raiz (fallback status.name()) e monta a lista de campos no Bean Validation. O fork local foi removido — ver Atualização.

Fluxo por superfície:

Superfície Como sinaliza erro Quem renderiza
Filtros (AdminAuthFilter, AppUserAuthFilter, SetupAuthFilter) throw ApiException processor global
Controllers admin/setup/apps throw ApiException processor global
AuthController (público, browser) throw ApiException @Error local → problem+json + CORS

O @Error(exception = ApiException.class) local no AuthController é o ponto-chave: ele renderiza problem+json com os headers CORS (withCors), preservando o contrato do browser que o processor global não daria. Por ser local, só vale para as rotas do próprio controller — os demais seguem no processor global (sem CORS, correto: admin tem seu próprio CORS filter; apps/setup não são browser).

Exclusões deliberadas (não são erros, não viram problem+json):

  • Relatório 502 do SetupController — corpo de sucesso (relatório de provisão) devolvido diretamente; respostas retornadas não passam pelo processor, então seguem preservadas.
  • 403 {status: PENDENTE|SPAM} do client-login — sinal de fluxo que o app consome (não uma falha); modelado como AuthLoginService.ClientLoginResult, não como ApiException.

Consequências

  • Contrato de wire: todo erro de API agora é application/problem+json com {type, title, status, detail, code}; o code preserva o machine-code antigo. Travado por PabastLoginControllerTest.erroDeLogin_saiComoProblemJsonComCors (erro público = problem+json + CORS) e pelos testes de contrato do AdminController (assertam code).
  • Item 1 (§6): AppUserAuthFilter e SetupAuthFilter migrados para @ServerFilter + @RequestFilter — completa o "trabalho futuro" previsto na decisão 0015.
  • Item 4 (§6): o AuthController (615 → ~270 linhas) virou fino — HTTP (rate limit, CORS, mapeamento) no controller, negócio no novo AuthLoginService; DTOs e o contrato FirebaseTokenValidator/FirebaseClaims extraídos para arquivos próprios.
  • Item 2 (§6): trava de arquitetura ArchitectureTest (ArchUnit) — ver ArchitectureTest; sem ciclos entre features, comum independente.

Alternativas consideradas

  • Classe-por-status (modelo literal dos BIs): rejeitado — colapsaria os ~28 machine-codes num código por status, quebrando o contrato consumido pela Central.
  • Processor global adiciona CORS: rejeitado — CORS * em todo erro estaria errado para /api/admin/** (origens específicas via AdminApiCorsFilter) e é irrelevante para apps/setup.
  • Confiar no @CrossOrigin para o CORS do erro (sem @Error local): não escolhido — o comportamento do CORS filter sobre respostas de exceção não é garantido entre versões; o @Error local é explícito e testado.
  • Converter só o processor, deixar os Map.of ad-hoc: rejeitado pelo dono — o pedido foi contrato uniforme, não meia-adoção.

Atualização — adoção do xadm-comum-web (constituição 0.28.0)

Na migração 0.18.4→0.28.0 o auth passou a consumir a lib da casa (br.com.xadm:xadm-comum-web, decisão 0019 do central) em vez de manter o fork local do contrato de erro:

  • ProblemDetail: adotada (record idêntico; drop-in). Fork local removido.
  • UnifiedErrorResponseProcessor: adotado a partir de xadm-comum-web:0.3.0. A 0.2.0 não servia (derivava o code sempre de status.name(), colapsando os ~28 machine-codes do auth); o gap virou feedback ao central, e a 0.3.0 fechou a pendência: o processor lê o machine-code via PortadoraDeProblema na causa-raiz — a nossa ApiException a implementa — e carimba a lista de campos no Bean Validation. O fork local do processor foi removido; o contrato de erro do framework é 100% da lib.
  • ApiException: fica local (é domínio do auth: os 28 codes), agora implementando PortadoraDeProblema.

/health — resolvido na lib (xadm-comum-web:0.4.0). A dep traz um HealthController @Controller("/health") (para apps sem micronaut/management). Na 0.3.0 ele colidia com o /health do micronaut-management do auth → duas rotas GET /health → o Micronaut respondia 400 ("More than 1 route matched"), quebrando o healthcheck. Micronaut não remove a rota de um @Controller de lib (nem @Replaces nem bean-exclude); virou feedback ao central. A 0.4.0 tornou o HealthController condicional (@Requires(missingBeans = io.micronaut.management.endpoint.health.HealthEndpoint)): em app com management (o auth) ele se cala, e o /health DB-aware (JDBC/disk/deadlock + /health/liveness·/health/readiness) do management vale. endpoints.health.enabled: true. Regressão travada em HealthRouteTest (/health == 200 {status:UP}). Emenda 2026-09-11: depois disto o /health foi rebaixado ao flat da lib (e708fdb, 2026-08-21) e, com a xadm-comum-web 0.6.1 (6f4138b, 2026-08-26), o HealthController/InfoController da lib ficaram incondicionais e o management teve /health e /info desligados (endpoints.health.enabled: false). O /health de hoje é o flat {status, versao, flavor, commit} da lib, 200 fixo, igual em jar e native — não há mais /health/liveness·/health/readiness.

micronaut.security.enabled: false — postura do hub, não limite da lib. A lib traz micronaut-security transitivo (para o @Secured do seu HealthController). Isso é by-design: a casa padroniza todo app consumindo o framework de segurança (xadm-seguranca). O auth é o caso especial (CLAUDE.md): é o emissor de JWT / provedor de JWKS — a coisa contra a qual a security dos outros apps valida —, então não age como resource-server do framework; autentica por @ServerFilter próprio (Admin/Setup/AppUser). Desligar a security é a postura correta do hub, não um contorno de bug. Um app comum (com security ligado) consome a lib sem essa flag.