Agent Skills: API Contract - Typed Frontend/Backend Contracts

>-

UncategorizedID: cleanexpo/nodejs-starter-v1/api-contract

Install this agent skill to your local

pnpm dlx add-skill https://github.com/CleanExpo/NodeJS-Starter-V1/tree/HEAD/.skills/custom/api-contract

Skill Files

Browse the full folder contents for api-contract.

Download Skill

Loading file tree…

.skills/custom/api-contract/SKILL.md

Skill Metadata

Name
api-contract
Description
">-"

API Contract - Typed Frontend/Backend Contracts

Ensures every API endpoint has a typed contract: Pydantic models on the backend, Zod response schemas on the frontend, and documented error responses. Bridges the gap between apps/backend/src/api/ and apps/web/lib/api/.

Description

Enforces type-safe API contracts across the FastAPI backend and Next.js frontend. Every endpoint must declare a Pydantic response_model, have a matching Zod schema on the frontend, and document error responses via OpenAPI. Prevents schema drift, untyped responses, and manual type definitions by mandating a single source of truth through the Contract Triangle pattern.

When to Apply

Positive Triggers

  • Creating or modifying API endpoints (FastAPI routes)
  • Adding frontend API calls (apiClient.get/post/put/patch/delete)
  • Reviewing type safety between backend responses and frontend consumers
  • Documenting API error responses
  • Planning API versioning or deprecation
  • User mentions: "API contract", "endpoint", "response type", "OpenAPI", "schema"

Negative Triggers

  • Validating user input forms (use data-validation instead)
  • Classifying error codes (use error-taxonomy instead)
  • Implementing retry logic or resilience (use retry-strategy when available)
  • Working on WebSocket or real-time protocols (separate concern)

Core Directives

The Contract Triangle

Backend (Pydantic)  ←──  Contract  ──→  Frontend (Zod)
        ↓                                     ↓
   response_model                      z.infer<typeof schema>
        ↓                                     ↓
   OpenAPI spec                        Type-safe apiClient

Every endpoint must have:

  1. Backend: Pydantic response_model on the route decorator
  2. Frontend: Zod schema validating the response shape
  3. Documentation: Error responses listed in responses={} dict

Field Name Convention

Use snake_case consistently across both sides:

# Backend (Pydantic)
class DocumentItem(BaseModel):
    created_at: datetime
    error_code: str
// Frontend (Zod) — mirror the snake_case
const documentItemSchema = z.object({
  created_at: z.string().datetime(),
  error_code: z.string(),
});

Do NOT convert to camelCase on the frontend. The API contract is snake_case end-to-end.


Backend Patterns (FastAPI + Pydantic)

Schema Location Convention

The project already uses two patterns. Standardise to:

| Schema Scope | Location | When to Use | |-------------|---------|-------------| | Route-specific | Inline in route file | Used by 1 endpoint only (e.g., DocumentCreateRequest) | | Shared across routes | apps/backend/src/api/schemas/{domain}.py | Used by 2+ endpoints or referenced by frontend | | Domain models | apps/backend/src/models/ | Business logic models, not request/response shapes |

Existing examples:

  • Inline: apps/backend/src/api/routes/documents.py (DocumentItem, DocumentListResponse)
  • Shared: apps/backend/src/api/schemas/workflow_builder.py (WorkflowNodeResponse, etc.)
  • Domain: apps/backend/src/models/contractor.py (Contractor, Location, ErrorResponse)

Response Model on Every Route

Every route MUST declare response_model:

# GOOD: Explicit response model
@router.get(
    "",
    response_model=DocumentListResponse,
    summary="List documents",
    responses={
        401: {"model": ErrorResponse, "description": "Unauthorised"},
        500: {"model": ErrorResponse, "description": "Internal error"},
    },
)
async def list_documents(...) -> DocumentListResponse:
    ...

# BAD: Untyped dict response
@router.get("/data")
async def get_data() -> dict:
    return {"stuff": "things"}

Standard Response Wrappers

Use consistent wrapper patterns for list endpoints:

from pydantic import BaseModel, Field


class PaginationInfo(BaseModel):
    """Reusable pagination metadata."""

    total: int = Field(description="Total matching items")
    limit: int = Field(description="Results per page")
    offset: int = Field(description="Current offset")
    pages: int = Field(description="Total number of pages")


class PaginatedResponse(BaseModel):
    """Generic paginated response base. Subclass with typed `data` field."""

    pagination: PaginationInfo

Subclass for each domain:

class DocumentListResponse(BaseModel):
    data: list[DocumentItem]
    pagination: PaginationInfo

Error Response Contract

Every route should document error responses using the ErrorResponse model from error-taxonomy:

from src.models.contractor import ErrorResponse

@router.post(
    "",
    response_model=DocumentItem,
    status_code=201,
    responses={
        401: {"model": ErrorResponse, "description": "Unauthorised"},
        422: {"model": ErrorResponse, "description": "Validation error"},
        409: {"model": ErrorResponse, "description": "Duplicate resource"},
    },
)

OpenAPI Metadata

Ensure every route has:

@router.get(
    "/path",
    response_model=ResponseType,           # Required
    summary="Short description",            # Required (appears in sidebar)
    description="Detailed description",     # Recommended
    tags=["Domain"],                        # Required (groups in docs)
    responses={...},                        # Required (error documentation)
)

The FastAPI app already generates OpenAPI at /docs (Swagger) and /redoc.


Frontend Patterns (Zod + apiClient)

Response Schema Convention

Create Zod schemas that mirror backend Pydantic models:

// apps/web/lib/api/schemas/documents.ts
import * as z from 'zod';

// Mirror of backend DocumentItem
const documentItemSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string().nullable(),
  metadata: z.record(z.unknown()),
  created_at: z.string(),
  updated_at: z.string(),
});

// Mirror of backend PaginationInfo
const paginationSchema = z.object({
  total: z.number(),
  limit: z.number(),
  offset: z.number(),
  pages: z.number(),
});

// Mirror of backend DocumentListResponse
const documentListResponseSchema = z.object({
  data: z.array(documentItemSchema),
  pagination: paginationSchema,
});

// Infer types — never define manually
type DocumentItem = z.infer<typeof documentItemSchema>;
type DocumentListResponse = z.infer<typeof documentListResponseSchema>;

export {
  documentItemSchema,
  documentListResponseSchema,
  type DocumentItem,
  type DocumentListResponse,
};

Schema File Location

apps/web/lib/api/
├── client.ts              # Existing fetch wrapper
├── auth.ts                # Existing auth API
└── schemas/               # NEW: Response schemas
    ├── common.ts          # Pagination, ErrorResponse
    ├── documents.ts       # Document schemas
    ├── workflows.ts       # Workflow schemas
    ├── agents.ts          # Agent/discovery schemas
    └── contractors.ts     # Contractor schemas

Validated API Calls

Wrap apiClient calls with Zod parsing for runtime safety:

import { apiClient } from '@/lib/api/client';
import {
  documentListResponseSchema,
  type DocumentListResponse,
} from '@/lib/api/schemas/documents';

export async function listDocuments(
  params?: { limit?: number; offset?: number }
): Promise<DocumentListResponse> {
  const query = new URLSearchParams();
  if (params?.limit) query.set('limit', String(params.limit));
  if (params?.offset) query.set('offset', String(params.offset));

  const raw = await apiClient.get(`/api/documents?${query}`);
  return documentListResponseSchema.parse(raw);
}

Error Response Schema

Mirror the backend ErrorResponse on the frontend:

// apps/web/lib/api/schemas/common.ts
import * as z from 'zod';

const errorResponseSchema = z.object({
  detail: z.string(),
  error_code: z.string().optional(),
  severity: z.enum(['fatal', 'error', 'warning']).optional(),
  field: z.string().optional(),
});

type ErrorResponse = z.infer<typeof errorResponseSchema>;

export { errorResponseSchema, type ErrorResponse };

Contract Checklist

When adding or modifying an API endpoint:

Backend

  • [ ] Route has response_model declared
  • [ ] Route has summary and tags
  • [ ] Error responses documented in responses={} dict
  • [ ] Pydantic model has Field(description=...) on all fields
  • [ ] Schema uses ConfigDict(from_attributes=True) if mapping from ORM

Frontend

  • [ ] Matching Zod schema exists in apps/web/lib/api/schemas/
  • [ ] Types inferred via z.infer, not manually defined
  • [ ] API call parses response through Zod schema
  • [ ] Field names match backend snake_case exactly

Cross-Stack

  • [ ] Field names identical between Pydantic and Zod schemas
  • [ ] Nullable fields use Optional (backend) and .nullable() (frontend)
  • [ ] Date fields use ISO 8601 strings in transit
  • [ ] Pagination follows the PaginationInfo pattern

Versioning Strategy

URL-Based Versioning (When Ready)

When the API needs breaking changes:

# apps/backend/src/api/main.py

# Version 1 (current)
app.include_router(documents.router, prefix="/api/v1", tags=["Documents v1"])

# Version 2 (new)
app.include_router(documents_v2.router, prefix="/api/v2", tags=["Documents v2"])

Deprecation Headers

Mark deprecated endpoints with headers:

from fastapi import Response

@router.get("/old-endpoint", deprecated=True)
async def old_endpoint(response: Response):
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = "2026-06-01"
    response.headers["Link"] = '</api/v2/new-endpoint>; rel="successor-version"'
    return {"data": "still works"}

Breaking Change Rules

A change is breaking if it:

  • Removes a field from a response
  • Changes a field's type
  • Makes an optional request field required
  • Changes the URL path or method
  • Removes an endpoint

A change is non-breaking if it:

  • Adds a new optional field to a response
  • Adds a new optional query parameter
  • Adds a new endpoint
  • Adds a new enum value

Detection Rules

Grep for contract violations:

# Backend: Routes missing response_model
rg "def (get|post|put|patch|delete)_" apps/backend/src/api/routes/ | \
  rg -v "response_model"

# Backend: Routes returning raw dict
rg "-> dict" apps/backend/src/api/routes/

# Frontend: Unvalidated API calls (no .parse())
rg "apiClient\.(get|post|put|patch|delete)" apps/web/ | \
  rg -v "Schema\.parse"

Anti-Patterns

| Pattern | Problem | Correct Approach | | ------- | ------- | ---------------- | | Untyped API responses (-> dict) | No compile-time or runtime validation, silent breakage | Declare response_model on every route with a Pydantic model | | Frontend/backend schema drift | Field mismatches cause runtime errors in production | Mirror Pydantic models with Zod schemas; validate with .parse() | | No response envelope pattern | Inconsistent list responses, missing pagination metadata | Use PaginatedResponse wrapper with data and pagination fields | | Manual type synchronisation | Types fall out of sync as endpoints evolve | Infer frontend types via z.infer<typeof schema>, never define manually | | Missing error response documentation | Consumers cannot handle failures gracefully | Document all error codes in responses={} dict using ErrorResponse model |

Checklist

  • [ ] Pydantic response_model defined on every route decorator
  • [ ] Zod client schemas match backend Pydantic models (field names, types, nullability)
  • [ ] API envelope pattern used for list endpoints (data + pagination)
  • [ ] OpenAPI spec generated with summary, tags, and responses on all routes
  • [ ] Error responses documented using ErrorResponse model
  • [ ] Frontend API calls parse responses through Zod schemas (.parse())

Response Format

[AGENT_ACTIVATED]: API Contract
[PHASE]: {Audit | Implementation | Review}
[STATUS]: {in_progress | complete}

{contract analysis or implementation guidance}

[NEXT_ACTION]: {what to do next}

Integration Points

Error Taxonomy

Error responses use ErrorResponse from error-taxonomy. Every responses={} dict references the canonical error model.

Data Validation

data-validation handles request validation (Zod for forms, Pydantic for bodies). api-contract handles response validation (Zod schemas mirroring Pydantic response models).

Request:  User → Zod (form) → fetch → Pydantic (body) → Service
Response: Service → Pydantic (response_model) → JSON → Zod (parse) → Component

Council of Logic (Shannon Check)

  • Response schemas must be minimal — return only what the frontend needs
  • No duplicating the same schema across multiple files — compose from shared base schemas
  • Pagination wrapper is reusable, not redefined per domain

Australian Localisation (en-AU)

  • Date Format: ISO 8601 in API transit; DD/MM/YYYY in UI display
  • Currency: AUD ($) — amounts as integers (cents) in API, formatted in UI
  • Spelling: serialisation, authorisation, organisation, analyse, centre, colour
  • Timezone: AEST/AEDT — store UTC in database, convert in frontend