Appearance
Frontend Migration Guide - HTTP Contract V1 (Gryd)
Este documento descreve o contrato HTTP oficial para V1 e o de/para completo para atualizar o framework frontend sem alterar versão de API.
1. Premissas
- A versão permanece V1.
- A base URL permanece
/api/v1. - A mudança é de shape de request/response e de padronização de erro, não de versionamento.
- O backend segue:
- Sucesso: payload direto (
T,PagedResult<T>, ou vazio em204). - Erro:
ProblemDetails(application/problem+json).
- Sucesso: payload direto (
2. Decisão de contrato (resumo executivo)
Antes
- Muitos endpoints retornavam envelope interno
Result<T>no HTTP (isSuccess,data,errorMessage,errorCode,errors). - Erros variavam entre envelope e formatos diferentes.
Agora (padrão único)
- Sucesso HTTP: payload direto, sem envelope.
- Erro HTTP:
ProblemDetailscomstatus/title/detaileextensionspadronizadas.
3. Contrato de sucesso (V1)
3.1 Entidade única
200 OK- Body:
T
json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe"
}3.2 Listagem paginada
200 OK- Body:
PagedResult<T>
json
{
"items": [{ "id": "..." }],
"totalCount": 150,
"pageNumber": 1,
"pageSize": 20,
"totalPages": 8,
"hasNext": true,
"hasPrevious": false
}3.3 Criação
201 Created- Header:
Location - Body:
Tcriado
3.4 Update
200 OK- Body:
Tatualizado
3.5 Delete
204 No Content- Sem body
3.6 Comandos sem payload de retorno
200 OK(body pode estar vazio)- ou
204 No Contentquando aplicável
4. Contrato de erro (ProblemDetails)
4.1 Shape canônico
json
{
"type": "https://gryd.io/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "User not found",
"instance": "/api/v1/users/550e8400-e29b-41d4-a716-446655440000",
"traceId": "00-2d6a...",
"code": "USER_NOT_FOUND",
"errors": {
"email": ["Email is required"]
}
}4.2 Campos relevantes para frontend
detail: mensagem principal para UX.code: chave de regra de negócio (branching de UI).errors: erros detalhados (array, objeto por campo, ou estrutura serializada).traceId: correlação com logs/suporte.
4.3 Mapeamento de status por sufixo de code
*_NOT_FOUND->404*_ALREADY_EXISTS,*_ALREADY_ASSIGNED,*_CONFLICT->409*_FORBIDDEN,*_ACCESS_DENIED->403*_UNAUTHORIZED->401*_UNPROCESSABLE->422- sem sufixo reconhecido ->
400
4.4 Codigos canonicos de autenticacao/autorizacao
INVALID_CREDENTIALS->401TOKEN_MISSING,TOKEN_INVALID,TOKEN_EXPIRED,TOKEN_REVOKED,SESSION_INVALIDATED->401REFRESH_TOKEN_INVALID,REFRESH_TOKEN_EXPIRED,REFRESH_TOKEN_REVOKED->401AUTH_RISK_FORBIDDEN->403(bloqueio por politica de risco/zero-trust)
5. De/Para (frontend)
5.1 Envelope de sucesso
| Antes | Agora |
|---|---|
response.isSuccess | usar status HTTP (2xx) |
response.data | body já é o payload final |
response.errorMessage | problem.detail |
response.errorCode | problem.code |
response.errors | problem.errors |
5.2 Endpoints CRUD (5 padrões)
| Endpoint | Antes | Agora |
|---|---|---|
GET /resource | Result<PagedResult<T>> | PagedResult<T> |
GET /resource/{id} | Result<T> | T |
POST /resource | Result<T> | 201 + T + Location |
PUT /resource/{id} | Result<T> | T |
DELETE /resource/{id} | Result | 204 sem body |
5.3 Erros
| Antes | Agora |
|---|---|
| formatos mistos | ProblemDetails único |
checagem por isSuccess=false | checagem por status HTTP >= 400 |
parsing errorMessage/errors variável | parsing padrão detail/code/errors/traceId |
6. Requests padrão para cenários não-CRUD simples
Para módulos avançados (bulk, workflows, filtros ricos), o backend disponibiliza contratos padrão.
6.1 PagedAndSortedRequest
json
{
"page": 1,
"pageSize": 20,
"sortBy": "createdAt",
"sortDirection": "Ascending",
"search": "john",
"includeDeleted": false
}6.2 BulkRequest<T>
json
{
"items": [
{ "id": "..." }
]
}6.3 OperationResponse<T> (opcional para workflows)
json
{
"data": {},
"message": "Operation completed",
"code": "OP_SUCCESS",
"meta": {}
}6.4 BulkOperationResponse
json
{
"total": 10,
"succeeded": 8,
"failed": 2,
"failures": [
{ "itemKey": "u-1", "reason": "Already assigned", "code": "USER_ALREADY_ASSIGNED" }
]
}7. Contratos TypeScript recomendados
ts
export type ApiSuccess<T> = T;
export interface ProblemDetailsRaw {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
traceId?: string; // extension
code?: string; // extension
errors?: unknown; // extension
[key: string]: unknown;
}
export interface ApiError {
status: number;
message: string;
code?: string;
traceId?: string;
errors?: unknown;
raw: ProblemDetailsRaw;
}
export interface PagedResult<T> {
items: T[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
}
export interface PagedAndSortedRequest {
page?: number;
pageSize?: number;
sortBy?: string;
sortDirection?: "Ascending" | "Descending";
search?: string;
includeDeleted?: boolean;
}
export interface BulkRequest<T> {
items: T[];
}
export interface OperationResponse<T> {
data: T;
message?: string;
code?: string;
meta?: Record<string, unknown>;
}
export interface BulkItemFailure {
itemKey: string;
reason: string;
code?: string;
}
export interface BulkOperationResponse {
total: number;
succeeded: number;
failed: number;
failures: BulkItemFailure[];
}8. Adapter HTTP (obrigatório no framework frontend)
8.1 Regra de sucesso
2xx: retornar body direto.204: retornarundefined.
8.2 Regra de erro
>= 400: normalizar paraApiErrorlendoProblemDetails.
8.3 Exemplo axios
ts
import axios, { AxiosError } from "axios";
export const api = axios.create({
baseURL: "/api/v1",
});
function normalizeError(error: AxiosError): ApiError {
const raw = (error.response?.data ?? {}) as ProblemDetailsRaw;
return {
status: raw.status ?? error.response?.status ?? 500,
message: raw.detail ?? raw.title ?? "Unexpected error",
code: raw.code as string | undefined,
traceId: raw.traceId as string | undefined,
errors: raw.errors,
raw,
};
}
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => Promise.reject(normalizeError(error))
);8.4 Exemplo fetch wrapper
ts
export async function request<T>(input: RequestInfo, init?: RequestInit): Promise<T | undefined> {
const response = await fetch(input, init);
if (response.status === 204) return undefined;
const hasBody = response.headers.get("content-length") !== "0";
const payload = hasBody ? await response.json().catch(() => undefined) : undefined;
if (response.ok) return payload as T;
const raw = (payload ?? {}) as ProblemDetailsRaw;
throw {
status: raw.status ?? response.status,
message: raw.detail ?? raw.title ?? "Unexpected error",
code: raw.code,
traceId: raw.traceId,
errors: raw.errors,
raw,
} satisfies ApiError;
}9. Estratégia para formulários (validação)
errors pode chegar como:
- array de mensagens
- objeto por campo (
{ field: [msg1, msg2] }) - string serializada
Recomendação:
- Criar normalizador
mapFieldErrors(errors: unknown): Record<string, string[]>. - Exibir
detailcomo mensagem geral. - Exibir mensagens por campo quando
errorstiver estrutura compatível.
10. Impacto no framework frontend (itens obrigatórios)
- Remover parser de envelope
Result<T>em sucesso. - Alterar client base para usar payload direto.
- Centralizar normalização de
ProblemDetails. - Atualizar adapters de CRUD para aceitar
204sem body. - Mapear
codepara regras de UX (ex.: conflito, not found). - Usar
traceIdem logging/tela de suporte. - Atualizar SDK/mocks/testes de contrato.
- Revisar handlers de React Query/SWR para não acessar
response.data.data. - Revisar middlewares globais de erro (toasts, tracking, retry).
- Manter sempre a base URL em
/api/v1.
11. De/Para de código (exemplos rápidos)
Antes
ts
const res = await api.get<Result<UserDto>>(`/users/${id}`);
if (!res.data.isSuccess) throw new Error(res.data.errorMessage);
return res.data.data;Agora
ts
const res = await api.get<UserDto>(`/users/${id}`);
return res.data;Delete
ts
await api.delete(`/users/${id}`); // sucesso esperado: 204Erro
ts
try {
await api.post(`/users`, payload);
} catch (e) {
const err = e as ApiError;
console.error(err.code, err.message, err.traceId, err.errors);
}12. Matriz de QA para front
Executar pelo menos:
- CRUD completo (list/get/create/update/delete) em módulo de referência.
- Fluxo com erro
404,409,403,401,422,400. - Formulário com erro de validação e mapeamento por campo.
- Telas com paginação (
PagedResult<T>). - Rotas de comando sem payload e com
204. - Logs com
traceIddisponíveis no erro.
13. Evolução do contrato de sessão — bloco capabilities (Épico 889, F6/D5)
GET /api/v1/auth/session ganhou um bloco capabilities, computado no servidor, que diz à UI qual trilho usar (escopado vs admin global) para users e tenants — sem que o cliente reimplemente a regra de autorização.
json
{
"userId": "…",
"email": "…",
"roles": ["…"],
"permissions": ["…"],
"capabilities": {
"users": { "scopes": ["global"], "default": "global" },
"tenants": { "scopes": ["tenant"], "default": "tenant" }
}
}Revisto pelo Épico 925 (F4). O bloco nasceu com
scopesingular eown; hoje é um mapa por domínio comscopes[]+default,ownviroutenant, e um domínio sem alcance é ausente do mapa. Como nenhum cliente chegou a consumir a forma antiga, a mudança foi feita antes de existir consumidor — e não há alias de compatibilidade.
- Aditivo, não-breaking em relação ao v0: os campos existentes (
roles,permissions, …) permanecem; o bloco é novo e extensível (domínio novo entra como chave nova). - Os valores são strings minúsculas (padrão v5), do vocabulário
none | tenant | group | global; trate valores desconhecidos degradando para o trilho mais restrito. - A UI roteia por capability, nunca por permissão crua. Contrato completo, semântica de cada valor e exemplos de payload em session-capabilities-contract.md.
14. Breaking change — despromoção do Admin de tenant (Épico 889, F8/D6·D10)
Antes, o role Admin de qualquer tenant carregava admin:system (wildcard) → todo Admin de tenant era super admin global. A partir de F8, o Admin de tenant recebe um conjunto granular (users, roles e permissions CRUD + read:tenants) e admin:system fica só com o operador da plataforma. Tenants já existentes são migrados automaticamente no deploy (idempotente).
Impacto no cliente: um Admin de tenant comum deixa de acessar o trilho /api/v1/admin/* (403), a política de MFA do tenant e o SSO enterprise. Ele mantém a gestão escopada do próprio tenant.
- A UI não precisa mudar código se já roteia por capability (seção 13): o
/sessionpassa a anunciarusers.default: "tenant"etenants.default: "tenant"para esses admins, e o componente troca do trilho/admin/*para o escopado sozinho. - Tokens emitidos antes da migração são invalidados (401) → basta refazer login/refresh.
A migração de startup que reparava bancos anteriores a essa mudança foi removida com o ADR 0007 (US 3.5), junto com o runbook dela: as migrations do GrydAuth foram consolidadas numa única inicial, então não há mais banco anterior a trazer para frente — um banco recriado já nasce no catálogo atual.
15. Escopo de grupo (scope=group) — Épico 925 / ADR 0006
O que muda no contrato quando uma tela passa a poder ler o grupo inteiro em vez de só o tenant do token. Detalhe completo em group-scope-contract.md; aqui fica o resumo de migração.
15.1 Aditivo — não quebra nada que existe hoje
| Item | Forma |
|---|---|
| Intenção de escopo | ?scope=tenant|group na rota existente, nunca um id de tenant |
| Bloco de resposta | scope: { mode, groupId, groupName, tenantCount, truncated? } |
| Rotas | nenhuma nova — sem /consolidado, sem handler gêmeo |
Uma rota que não declara ser scope-capable só serve o padrão (tenant) e recusa qualquer outro valor com 400. O bloco scope é ausente/null nessas rotas, que hoje são a maioria: não trate a ausência como erro.
15.2 Três códigos de erro novos
| Situação | HTTP | code |
|---|---|---|
Valor de ?scope= desconhecido | 400 | SCOPE_INVALID |
| Sem o direito no tenant do grupo | 403 | SCOPE_NOT_AUTHORISED |
| O tenant do token não pertence a grupo nenhum | 422 | SCOPE_GROUP_UNAVAILABLE |
Os três respondem com type: https://gryd.io/errors/scope-error; title/detail são localizados (pt-BR, en, es-ES). Branche em code, nunca no texto. O 422 existe separado do 403 de propósito: "não há grupo" não é problema de permissão, e a UI deve esconder o seletor em vez de mandar o usuário pedir um direito que não mudaria nada.
15.3 Breaking change — vocabulário de capabilities
Coberto pela seção 13: scope singular virou scopes[] + default por domínio, own virou tenant, e um domínio sem alcance é ausente do mapa. Sem alias de compatibilidade, porque nenhum cliente chegou a consumir a forma antiga.
15.4 Breaking change — troca de tenant exige vínculo
POST /auth/switch-tenant exige um vínculo UserTenant ativo no tenant alvo, para todo mundo. Pertencer ao grupo do alvo não substitui o vínculo, e admin:system também não.
A permissão switch:any-group-tenant, que autorizava a troca por herança de grupo, deixou de existir (ADR 0007, A8). Ela nunca funcionou de ponta a ponta: só o SwitchTenantCommandHandler conhecia a regra, enquanto o middleware valida acesso a tenant por lookup de vínculo — a troca era aceita, emitia token para o irmão, e a requisição seguinte respondia 403.
Impacto no cliente:
- A tela de seleção de tenant deve continuar oferecendo apenas o que vem em
availableTenants. Esse conjunto sempre foi o dos vínculos; agora ele é também exatamente o conjunto que o switch aceita. - Quem trocava de tenant por herança de grupo precisa de um vínculo explícito na filial. Na prática ninguém dependia disso em produção, porque o token resultante já não servia para nada.
- Leitura consolidada não muda.
?scope=groupcontinua resolvendo o direito no tenant do grupo (ADR 0006, G4);read:group-analyticseread:group-dataseguem intactos.
16. Administração global e alcance cross-tenant — Épico 986 / ADR 0007
A §15.4 acima cobre uma das seis mudanças de comportamento deste épico (a troca de tenant). As outras cinco tocam permissão, nome de role e códigos de erro, e a lista completa — com o que fazer antes do deploy — está em modules/auth/epic-986-breaking-changes.
O que um cliente HTTP precisa saber, em resumo:
| Situação | Antes | Depois |
|---|---|---|
/admin/users e /admin/tenants com a permissão global | dependia também de uma coluna por usuário que não tinha API | a permissão basta — ninguém perde acesso |
X-Tenant-ID de um tenant sem vínculo | podia passar para quem tinha admin:system | 403, para todo mundo |
POST /auth/switch-tenant sem vínculo | 200 + token que falhava depois | 403 TENANT_FORBIDDEN (§15.4) |
| Role do operador da plataforma | Admin | SystemAdmin — ajuste quem casa role por nome |
DELETE /roles/{id}/permissions/{permId} em role de sistema do catálogo | só Admin recusava | 409 CONFLICT para as quatro |
| Conceder permissão de alcance de plataforma a uma role sem possuí-la | 200 | 403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED |
Não há relogin forçado: nenhuma migração deste épico sobe token_version.
17. Conclusão
- O frontend passa a consumir um contrato HTTP mais REST e previsível.
- O backend mantém V1 e
/api/v1. - A principal adaptação é remover dependência do envelope
Result<T>e padronizar tratamento deProblemDetails.