feat(web): ping API ponta-a-ponta via TanStack Query + Zod contract
Fecha loop B+C — Web consome @sar/api-interface em runtime, não só build. - Vite proxy /api → localhost:3000 (zero CORS em dev, mesma URL em prod via Nginx) - api-client.ts: fetch wrapper parseando RFC 9457 problem+json em ApiError - useApiPing: TanStack Query + PingResponseSchema.parse — drift servidor falha alto - FoundationStatus pill na Topbar (verde/vermelho/cinza + Tooltip com requestId) Validado via curl proxy:4200 → 200 ok contratual; /nope → 404 problem+json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
apps/web/src/lib/api-client.ts
Normal file
89
apps/web/src/lib/api-client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Cliente HTTP da SAR Web.
|
||||
//
|
||||
// Responsabilidades:
|
||||
// - Encapsular fetch com base URL relativa (proxy Vite em dev, mesmo origin em prod).
|
||||
// - Parsear RFC 9457 application/problem+json em ApiError estruturado.
|
||||
// - NÃO faz validação Zod — isso é responsabilidade do caller (useQuery + Schema.parse).
|
||||
//
|
||||
// CODING-RULES §05: 422 = validação Zod; 4xx outros = erros de domínio; 5xx = retry pelo
|
||||
// QueryClient (até 2x). O ApiError carrega tudo que o caller precisa pra decidir.
|
||||
|
||||
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
|
||||
|
||||
export interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
requestId?: string;
|
||||
errors?: ReadonlyArray<{ path: string; message: string; code?: string }>;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly problem: ProblemDetails;
|
||||
|
||||
constructor(problem: ProblemDetails) {
|
||||
super(problem.detail ?? problem.title);
|
||||
this.name = 'ApiError';
|
||||
this.status = problem.status;
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options: RequestOptions = {}): Promise<unknown> {
|
||||
const { body, headers, ...rest } = options;
|
||||
|
||||
const init: RequestInit = {
|
||||
...rest,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(path, init);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await toApiError(response);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) return undefined;
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function toApiError(response: Response): Promise<ApiError> {
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
|
||||
if (contentType.includes(PROBLEM_CONTENT_TYPE) || contentType.includes('application/json')) {
|
||||
try {
|
||||
const body = (await response.json()) as ProblemDetails;
|
||||
return new ApiError({
|
||||
type: body.type ?? 'about:blank',
|
||||
title: body.title ?? response.statusText,
|
||||
status: body.status ?? response.status,
|
||||
detail: body.detail,
|
||||
instance: body.instance,
|
||||
requestId: body.requestId,
|
||||
errors: body.errors,
|
||||
});
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
type: 'about:blank',
|
||||
title: response.statusText || 'Request failed',
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
26
apps/web/src/lib/queries/ping.ts
Normal file
26
apps/web/src/lib/queries/ping.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PingResponseSchema, type PingResponse } from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
// useApiPing — prova de conectividade ponta-a-ponta API↔Web.
|
||||
//
|
||||
// O contrato é o schema Zod compartilhado (@sar/api-interface). Qualquer drift
|
||||
// no servidor (campo removido, tipo trocado) falha alto via .parse() ANTES de
|
||||
// chegar nos componentes — o erro vai pra TanStack `error` e mostramos pill 🔴.
|
||||
//
|
||||
// refetchInterval 30s = "sereno" (Visual DNA) — sem flash de loading constante.
|
||||
|
||||
export const PING_QUERY_KEY = ['health', 'ping'] as const;
|
||||
|
||||
export function useApiPing() {
|
||||
return useQuery<PingResponse, Error>({
|
||||
queryKey: PING_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/api/v1/ping');
|
||||
return PingResponseSchema.parse(raw);
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 25_000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user