Respostas de erro multilíngues (pt-BR + en + es) na API pública

TLDR: Centralizar toda mensagem user-facing da API em arquivos de locale, adicionar as traduções de inglês e espanhol que faltavam, e resolver o idioma da resposta a partir do header Accept-Language para a API pública responder no idioma do chamador.

Contexto

As mensagens de erro user-facing eram inconsistentes:

  • A configuração de i18n já existia (config/application.rb): default_locale = :"pt-BR", available_locales = ["pt-BR", :en], fallbacks = true.
  • ApplicationController#set_locale só lia params[:locale] — ignorava o header Accept-Language, então o chamador não conseguia receber inglês de fato.
  • As mensagens viviam em três padrões concorrentes:
    • via I18n.t — só Auth::Login (v2);
    • inglês hardcoded — API v1 (memberships, apolo_membership, user_profiles), admin_greenfield, current_user_apolo;
    • português hardcoded — services e webhook_email_controller.
  • Não existia tradução em inglês: en.yml tinha apenas hello. As chaves errors.messages.* existiam só em pt-BR.yml, então ?locale=en caía silenciosamente em pt-BR. O fluxo bilíngue não funcionava.
  • Havia um typo em mensagem hardcoded: "Token de autentição inválido" (webhook_email_controller.rb:38).

Objetivos

  • Resolver o idioma da resposta a partir do header Accept-Language, mantendo params[:locale] como override explícito e pt-BR como fallback.
  • Mover toda mensagem de erro/sucesso user-facing da API pública (v1 + v2) para arquivos de locale.
  • Fornecer tradução completa em pt-BR, en e es para todas as chaves migradas, incluindo mensagens de validação/atributo do ActiveRecord expostas ao usuário.
  • Preservar os shapes de resposta existentes ({ error: <string> }, { message: <string> }, { errors: [<string>] }) para não quebrar o frontend — só a origem do texto e o locale mudam.
  • Substituir strings hardcoded por chamadas inline de I18n.t(...), sem helper/concern — os shapes divergem entre endpoints, então um helper compartilhado não agrega.

Fora de escopo

  • Mensagens dos services do Admin/ActiveAdmin (admin_*_service.rb) — UI interna de admin.
  • Mudar o shape do JSON de erro ou introduzir códigos de erro — evitado deliberadamente para manter o contrato do frontend estável.

Decisões de design

  • Resolução de locale: around_action :switch_locale em ApplicationController envolvendo o request em I18n.with_locale, para o locale thread-local nunca vazar entre requests. Precedência: params[:locale] → melhor match do Accept-Language → I18n.default_locale. Só valores em I18n.available_locales são aceitos. O parsing de Accept-Language respeita os pesos q e é feito à mão em ApplicationController, sem gem nova.
  • Namespace de chaves: aninhar sob api.errors.<domínio>.<chave> (ex.: api.errors.common.not_found, api.errors.membership.not_found, api.errors.apolo.missing_token, api.errors.auth.invalid_credentials). As chaves de auth em errors.messages.* foram movidas para esse namespace.
  • Shape da resposta: inalterado. Cada controller mantém o shape atual e só troca o literal por I18n.t("api.errors.<chave>").

Mudanças

Config / infra

  • app/controllers/application_controller.rb — before_action :set_locale vira around_action :switch_locale; resolução de locale a partir de Accept-Language.
  • config/application.rb — es adicionado a I18n.available_locales.

Controllers — trocar strings hardcoded por chaves de i18n

  • application_controller.rb — "Not permitted".
  • admin_greenfield_controller.rb — "Not logged", "Not authorized".
  • concerns/current_user_apolo.rb — "user not authorized or access expired".
  • api/v1/user_profiles_controller.rb — "Not found".
  • api/v1/memberships_controller.rb — "Not found".
  • api/v1/apolo_membership_controller.rb — "Membership does not exist", "Not found", "Missing APOLO Access Token", "Invalid APOLO Access Token".
  • api/v1/me_controller.rb — "Perfil atualizado com sucesso", "Bad credentials".
  • api/v1/webhook_email_controller.rb — "Campos mínimos não enviados: ...", "Token de autentição inválido" (typo corrigido), "Usuário não encontrado", "Já existe um usuário...".
  • api/v1/webhook_controller.rb — "Campos mínimos enviados: ...", "No membership found for ...", "Token de autentição inválido" (typo corrigido).
  • app/models/auth/login.rb — repontar chaves de errors.messages.* para api.errors.auth.*.
  • app/models/onboarding/steps/process.rb — "Onboarding not found for user" e "Step '...' not found in onboarding" para api.errors.onboarding.*.

Arquivos de locale

  • config/locales/pt-BR.yml — namespace api.errors.* com todas as mensagens migradas.
  • config/locales/en.yml — tradução completa de api.*. As mensagens de validação do ActiveRecord (blank, taken, …) expostas por me_controller/onboarding saem em inglês pelos defaults nativos do Rails e nomes de atributo são humanizados automaticamente, então não é preciso espelhar activerecord.
  • config/locales/es.yml (novo) — tradução completa de api.*. Validações do ActiveRecord/Devise vêm dos gems rails-i18n/devise-i18n, já presentes.

Como verificar

  • Teste de request em um endpoint de erro v1 e um v2, afirmando: request default → mensagem pt-BR; Accept-Language: en → mensagem em inglês; ?locale=en sobrepõe o header; locale desconhecido/não suportado → cai em pt-BR.
  • Manual: curl -H "Accept-Language: en" .../api/v1/memberships/0 devolve a mensagem “not found” em inglês; sem o header devolve em pt-BR.
  • grep confirma que não sobrou string user-facing hardcoded nos arquivos de API pública tocados.
  • Suíte completa: make test.

Documentação