Skip to Content
Client LayerAPI Client & Layer

API Client — Centralized Request Layer

Overview

The API client layer provides a centralized, type-safe interface for communicating with backend services. All HTTP requests flow through a single apiClient wrapper that handles:

  • ✅ URL resolution with configurable base
  • ✅ Request/response type safety with TypeScript
  • ✅ Error handling and normalization
  • ✅ Header injection (auth tokens, CORS)
  • ✅ Response validation with Zod schemas

Architecture

┌─────────────────────────────────┐ │ React Components │ │ - Use hook-based API calls │ │ - Import from lib/api/*.ts │ └────────────┬────────────────────┘ ┌────────────▼────────────────────┐ │ API Layer (lib/api/) │ │ - auth-api.ts │ │ - chat-api.ts │ │ - health-api.ts │ │ - admin-api.ts │ │ - contributor-api.ts │ │ - Centralized per domain │ └────────────┬────────────────────┘ ┌────────────▼────────────────────┐ │ Base API Client │ │ (lib/api-client.ts) │ │ - Generic apiClient() │ │ - Error handling │ │ - Header injection │ └────────────┬────────────────────┘ ┌────────────▼────────────────────┐ │ Next.js API Proxy │ │ (next.config.ts) │ └────────────┬────────────────────┘ ┌────────────▼────────────────────┐ │ HTTP Backend │ │ (Rust + Axum) │ └─────────────────────────────────┘

API Client — Base Implementation

File: lib/api-client.ts

export async function apiClient<T = unknown>( endpoint: string, options?: RequestInit ): Promise<T> { const url = resolveApiUrl(endpoint); const headers = buildApiHeaders(); // Auth token injection const res = await fetch(url, { ...options, headers: { "Content-Type": "application/json", ...headers, ...options?.headers, }, }); if (res.status === 204) return undefined as T; // No Content if (!res.ok) { const error = await parseApiError(res); throw error; } return res.json() as Promise<T>; }

Helper Functions

resolveApiUrl(endpoint: string)

  • Prepends NEXT_PUBLIC_API_URL or /api to endpoint
  • Example: /auth/signinhttp://localhost:3001/api/auth/signin

buildApiHeaders()

  • Injects session token from auth context
  • Sets CORS headers if needed
  • Returns: { Authorization: "Bearer <token>" }

parseApiError(response: Response)

  • Normalizes error responses
  • Extracts message from response body
  • Returns typed ApiError with status, code, details

Domain-Based API Modules

Each domain has a dedicated API module that centralizes all requests for that domain.

1. Auth API — lib/api/auth-api.ts

import { apiClient } from "@/lib/api-client"; import { SignInRequest, SignInResponse, SignUpRequest, SignUpResponse, } from "@/types"; import { SignInResponseSchema, SignUpResponseSchema, } from "@/lib/api-types"; export async function signInApi( data: SignInRequest ): Promise<SignInResponse> { const res = await apiClient<unknown>("/auth/signin", { method: "POST", body: JSON.stringify(data), }); const parsed = SignInResponseSchema.safeParse(res); if (!parsed.success) { throw new Error("Invalid sign-in response"); } return parsed.data; } export async function signUpApi( data: SignUpRequest ): Promise<SignUpResponse> { const res = await apiClient<unknown>("/auth/signup", { method: "POST", body: JSON.stringify(data), }); const parsed = SignUpResponseSchema.safeParse(res); if (!parsed.success) { throw new Error("Invalid sign-up response"); } return parsed.data; } export async function signOutApi(): Promise<void> { await apiClient("/auth/signout", { method: "POST" }); }

Usage in Components:

// hooks/use-auth.ts import { signInApi, signUpApi } from "@/lib/api/auth-api"; export function useSignIn() { return useMutation({ mutationFn: signInApi, }); }

2. Health API — lib/api/health-api.ts

import { apiClient } from "@/lib/api-client"; import { HealthResponse } from "@/types"; import { HealthResponseSchema } from "@/lib/api-types"; export async function getRustApiHealth(): Promise<HealthResponse> { const res = await apiClient<unknown>("/health/api"); const parsed = HealthResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid health response"); return parsed.data; } export async function getIntelligenceApiHealth(): Promise<HealthResponse> { const res = await apiClient<unknown>("/health/intelligence"); const parsed = HealthResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid health response"); return parsed.data; }

Usage in Components — CENTRALIZED:

// components/footer/content.tsx import { getRustApiHealth, getIntelligenceApiHealth } from "@/lib/api/health-api"; import { useQuery } from "@/hooks/use-query"; export function FooterContent() { // ✅ Centralized - No inline apiClient calls const RustApiHealth = useQuery<HealthResponse>({ queryKey: ["rust-api-health"], queryFn: getRustApiHealth, }); const PythonApiHealth = useQuery<HealthResponse>({ queryKey: ["python-api-health"], queryFn: getIntelligenceApiHealth, }); return ( <footer> <StatusBadge status={RustApiHealth.data?.status} label="Rust API" /> <StatusBadge status={PythonApiHealth.data?.status} label="Intelligence API" /> </footer> ); }

3. Chat API — lib/api/chat-api.ts

import { apiClient } from "@/lib/api-client"; import { ConversationSummary, ConversationListResponse, ConversationWithMessages, SendMessageRequest, MessageResponse, } from "@/types"; export async function listConversations( cursor?: string ): Promise<ConversationListResponse> { const params = new URLSearchParams(); if (cursor) params.append("cursor", cursor); return apiClient(`/chat/conversations?${params}`); } export async function getConversation( conversationId: string ): Promise<ConversationWithMessages> { return apiClient(`/chat/conversations/${conversationId}`); } export async function sendMessage( conversationId: string, request: SendMessageRequest ): Promise<MessageResponse> { return apiClient(`/chat/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify(request), }); } export async function createConversation( title?: string ): Promise<ConversationSummary> { return apiClient("/chat/conversations", { method: "POST", body: JSON.stringify({ title }), }); }

4. Admin API — lib/api/admin-api.ts

import { apiClient } from "@/lib/api-client"; import { AdminStats, UserListResponse, ListResourcesResponse, } from "@/types"; export async function getAdminStats(): Promise<AdminStats> { return apiClient("/admin/stats"); } export async function listUsers( limit: number = 10, offset: number = 0 ): Promise<UserListResponse> { return apiClient( `/admin/users?limit=${limit}&offset=${offset}` ); } export async function listResources( cursor?: string ): Promise<ListResourcesResponse> { const params = new URLSearchParams(); if (cursor) params.append("cursor", cursor); return apiClient(`/resources?${params}`); } export async function deleteResource(resourceId: string): Promise<void> { await apiClient(`/resources/${resourceId}`, { method: "DELETE", }); }

5. User API — lib/api/user-api.ts

import { apiClient } from "@/lib/api-client"; import { UserResponse, UpdateProfileRequest, ChangePasswordRequest, SessionListResponse, } from "@/types"; export async function fetchCurrentUserApi(): Promise<UserResponse> { const res = await apiClient<unknown>("/user/me"); const parsed = UserResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid user response"); return parsed.data; } export async function updateProfileApi( payload: UpdateProfileRequest ): Promise<UserResponse> { const res = await apiClient<unknown>("/user/update-profile", { method: "PATCH", body: JSON.stringify(payload), }); const parsed = UserResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid response"); return parsed.data; } export async function changePasswordApi( payload: ChangePasswordRequest ): Promise<void> { await apiClient("/user/change-password", { method: "POST", body: JSON.stringify(payload), }); } export async function listSessionsApi(): Promise<SessionListResponse> { const res = await apiClient<unknown>("/user/list-sessions"); const parsed = SessionListResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid sessions response"); return parsed.data; }

6. Contributor API — lib/api/contributor-api.ts

import { apiClient } from "@/lib/api-client"; import { SubmitResourceRequest, SubmissionItem, ReviewRequest, SubmitResourceResponse, } from "@/types"; export async function getMySubmissions( limit: number = 20 ): Promise<SubmissionItem[]> { const res = await apiClient<unknown>( `/resources/submissions/mine?limit=${limit}` ); const parsed = QueueListResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid submissions response"); return parsed.data.items; } export async function submitResource( request: SubmitResourceRequest ): Promise<SubmitResourceResponse> { const res = await apiClient<unknown>("/resources/submissions", { method: "POST", body: JSON.stringify(request), }); const parsed = SubmitResourceResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid response"); return parsed.data; } export async function getSubmissionQueue( status: string = "pending", limit: number = 50 ): Promise<QueueListResponse> { const res = await apiClient<unknown>( `/resources/submissions?status=${status}&limit=${limit}` ); const parsed = QueueListResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid moderation queue"); return parsed.data; } export async function reviewSubmission( submissionId: string, request: ReviewRequest ): Promise<ReviewResponse> { const res = await apiClient<unknown>( `/resources/submissions/${submissionId}/review`, { method: "POST", body: JSON.stringify(request), } ); const parsed = ReviewResponseSchema.safeParse(res); if (!parsed.success) throw new Error("Invalid review response"); return parsed.data; }

7. External API — lib/api/external-api.ts

// External service integrations (not part of OpenTier backend) export async function fetchGithubStarsApi(repo: string): Promise<number> { const response = await fetch( `https://api.github.com/repos/${repo}` ); if (!response.ok) { throw new Error(`Failed to fetch GitHub stars (${response.status})`); } const data = await response.json(); return typeof data?.stargazers_count === "number" ? data.stargazers_count : 0; }

Next.js API Proxy

next.config.ts rewrites /api/* requests to the Rust backend:

// next.config.ts export default { async rewrites() { return { beforeFiles: [ { source: "/api/:path*", destination: `${process.env.BACKEND_URL}/api/:path*`, }, ], }; }, };

Benefits:

  • 🔒 CORS is never an issue (same-origin from browser’s perspective)
  • 🔐 Backend URL is server-side only (not exposed to browser)
  • 🛡️ Next.js can inject request headers or transform responses
  • 📍 Relative URLs throughout the app

Error Handling

lib/api/base.ts

export interface ApiError extends Error { status: number; code?: string; details?: Record<string, unknown>; } export async function parseApiError(response: Response): Promise<ApiError> { let message = response.statusText; let code: string | undefined; try { const body = await response.json(); message = body.message || body.error || message; code = body.code; } catch { // Fallback to status text if body isn't JSON } const error = new Error(message) as ApiError; error.status = response.status; error.code = code; return error; }

Best Practices

✅ DO

  1. Centralize API calls in domain modules

    // ✅ Correct import { getRustApiHealth } from "@/lib/api/health-api";
  2. Validate responses with Zod schemas

    const parsed = HealthResponseSchema.safeParse(response); if (!parsed.success) throw new Error("...");
  3. Use type imports for zero runtime cost

    import type { HealthResponse } from "@/types";
  4. Handle 204 No Content responses

    if (res.status === 204) return undefined as T;

❌ DON’T

  1. Make inline apiClient calls in components

    // ❌ Wrong - Inline in component const data = await apiClient<HealthResponse>("/health/api"); // ✅ Correct - Use centralized function const data = await getRustApiHealth();
  2. Duplicate API logic across files

    // ❌ Don't repeat auth endpoints in multiple files
  3. Skip validation on API responses

    // ❌ Wrong - No validation return res.json() as HealthResponse; // ✅ Correct - Always validate return HealthResponseSchema.parse(res.json());

Type Safety Flow

1. Component calls getRustApiHealth() 2. apiClient() receives response 3. Zod schema validates structure 4. TypeScript infers HealthResponse type 5. Component receives fully typed data ✅

Folder Structure

lib/ ├── api/ │ ├── base.ts # URL resolution, error parsing │ ├── auth-api.ts # Sign in, sign up, session │ ├── chat-api.ts # Conversations, messages │ ├── health-api.ts # Health checks (Rust & Intelligence) │ ├── admin-api.ts # Admin stats, user management │ ├── contributor-api.ts # Submission + moderation queue operations │ ├── user-api.ts # Profile, password, sessions │ ├── external-api.ts # External APIs (GitHub, etc.) │ └── contact-api.ts # Contact form submission ├── api-client.ts # Base apiClient<T>() wrapper ├── api-types.ts # Zod schemas + type re-exports └── ...

Zod Schema Validation

lib/api-types.ts defines Zod schemas for every API response type. Stores validate API responses before writing to state:

// In admin-store.ts: const parsed = AdminStatsSchema.safeParse(data) if (!parsed.success) { set({ error: 'Invalid response from server', isLoading: false }) return } set({ stats: parsed.data })

Complete Type Inventory

Auth types:

SchemaTypeKey fields
SignInRequestSchemaSignInRequestemail, password
SignInResponseSchemaSignInResponseuser_id, email, session_token, expires_at
SignUpRequestSchemaSignUpRequestemail, password, name?, username?
SignUpResponseSchemaSignUpResponseuser_id, email, message

User types:

SchemaTypeKey fields
UserResponseSchemaUserResponseid, email, email_verified, name?, username?, avatar_url?, role, created_at
UpdateProfileRequestSchemaUpdateProfileRequestname?, username?, avatar_url?
ChangePasswordRequestSchemaChangePasswordRequestcurrent_password, new_password
SessionSchemaSessionid, user_id, session_token, expires_at, ip_address?, user_agent?, created_at
SessionListResponseSchemaSessionListResponsesessions: Session[]

Chat types:

SchemaTypeKey fields
MessageRoleSchemaMessageRole"user" | "assistant" | "system"
SourceChunkSchemaSourceChunkchunk_id, document_id, content, relevance_score, document_title?, source_url?
ChatMessageSchemaChatMessageid, role, content, sources[], created_at, parent_id?
ConversationSummarySchemaConversationSummaryid, title?, message_count, last_message_preview?, created_at, updated_at
ConversationListResponseSchemaConversationListResponseconversations[], next_cursor?, total_count

Admin types:

SchemaTypeKey fields
AdminStatsSchemaAdminStatstotal_users, active_users_today, total_conversations, total_messages, total_resources
UserAdminViewid, email, role, email_verified, created_at, deleted_at?
UserListResponseSchemaUserListResponseusers[], total, limit, offset
ResourceItemResponseresource_id, job_id, status, title?, resource_type, is_global, created_at
ListResourcesResponseSchemaListResourcesResponseresources[], total, cursor?
AddResourceRequestresource_type, content, title, is_global, config?: ResourceConfig
UpdateRoleRequestrole: "user" | "admin"

Health Response

interface HealthResponse { status: string // "healthy" version: string // semver uptime_seconds: number }

The client calls GET /api/health/api and GET /api/health/intelligence to display system status in the admin dashboard.

Last updated on