Skip to content

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 em 204).
    • Erro: ProblemDetails (application/problem+json).

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: ProblemDetails com status/title/detail e extensions padronizadas.

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: T criado

3.4 Update ​

  • 200 OK
  • Body: T atualizado

3.5 Delete ​

  • 204 No Content
  • Sem body

3.6 Comandos sem payload de retorno ​

  • 200 OK (body pode estar vazio)
  • ou 204 No Content quando 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 -> 401
  • TOKEN_MISSING, TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED, SESSION_INVALIDATED -> 401
  • REFRESH_TOKEN_INVALID, REFRESH_TOKEN_EXPIRED, REFRESH_TOKEN_REVOKED -> 401
  • AUTH_RISK_FORBIDDEN -> 403 (bloqueio por politica de risco/zero-trust)

5. De/Para (frontend) ​

5.1 Envelope de sucesso ​

AntesAgora
response.isSuccessusar status HTTP (2xx)
response.databody já é o payload final
response.errorMessageproblem.detail
response.errorCodeproblem.code
response.errorsproblem.errors

5.2 Endpoints CRUD (5 padrões) ​

EndpointAntesAgora
GET /resourceResult<PagedResult<T>>PagedResult<T>
GET /resource/{id}Result<T>T
POST /resourceResult<T>201 + T + Location
PUT /resource/{id}Result<T>T
DELETE /resource/{id}Result204 sem body

5.3 Erros ​

AntesAgora
formatos mistosProblemDetails único
checagem por isSuccess=falsechecagem por status HTTP >= 400
parsing errorMessage/errors variávelparsing 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: retornar undefined.

8.2 Regra de erro ​

  • >= 400: normalizar para ApiError lendo ProblemDetails.

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:

  1. Criar normalizador mapFieldErrors(errors: unknown): Record<string, string[]>.
  2. Exibir detail como mensagem geral.
  3. Exibir mensagens por campo quando errors tiver estrutura compatível.

10. Impacto no framework frontend (itens obrigatórios) ​

  1. Remover parser de envelope Result<T> em sucesso.
  2. Alterar client base para usar payload direto.
  3. Centralizar normalização de ProblemDetails.
  4. Atualizar adapters de CRUD para aceitar 204 sem body.
  5. Mapear code para regras de UX (ex.: conflito, not found).
  6. Usar traceId em logging/tela de suporte.
  7. Atualizar SDK/mocks/testes de contrato.
  8. Revisar handlers de React Query/SWR para não acessar response.data.data.
  9. Revisar middlewares globais de erro (toasts, tracking, retry).
  10. 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: 204

Erro ​

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:

  1. CRUD completo (list/get/create/update/delete) em módulo de referência.
  2. Fluxo com erro 404, 409, 403, 401, 422, 400.
  3. Formulário com erro de validação e mapeamento por campo.
  4. Telas com paginação (PagedResult<T>).
  5. Rotas de comando sem payload e com 204.
  6. Logs com traceId disponí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 scope singular e own; hoje é um mapa por domínio com scopes[] + default, own virou tenant, 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 /session passa a anunciar users.default: "tenant" e tenants.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 ​

ItemForma
Intenção de escopo?scope=tenant|group na rota existente, nunca um id de tenant
Bloco de respostascope: { mode, groupId, groupName, tenantCount, truncated? }
Rotasnenhuma 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çãoHTTPcode
Valor de ?scope= desconhecido400SCOPE_INVALID
Sem o direito no tenant do grupo403SCOPE_NOT_AUTHORISED
O tenant do token não pertence a grupo nenhum422SCOPE_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=group continua resolvendo o direito no tenant do grupo (ADR 0006, G4); read:group-analytics e read:group-data seguem 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çãoAntesDepois
/admin/users e /admin/tenants com a permissão globaldependia também de uma coluna por usuário que não tinha APIa permissão basta — ninguém perde acesso
X-Tenant-ID de um tenant sem vínculopodia passar para quem tinha admin:system403, para todo mundo
POST /auth/switch-tenant sem vínculo200 + token que falhava depois403 TENANT_FORBIDDEN (§15.4)
Role do operador da plataformaAdminSystemAdmin — ajuste quem casa role por nome
DELETE /roles/{id}/permissions/{permId} em role de sistema do catálogosó Admin recusava409 CONFLICT para as quatro
Conceder permissão de alcance de plataforma a uma role sem possuí-la200403 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 de ProblemDetails.

Released under the MIT License.