Verified by Garnet Grid

How to Implement API-First Architecture: Design, Versioning, and Testing

Build APIs that last. Covers OpenAPI spec design, versioning strategies, authentication patterns, rate limiting, and contract testing for enterprise APIs.

API-first means designing your API contract before writing any implementation code. This approach eliminates the “build it and see” cycle that creates breaking changes, inconsistent naming, and developer frustration.


Step 1: Design the Contract First

1.1 OpenAPI Specification

# openapi.yaml
openapi: 3.1.0
info:
  title: Customer Management API
  version: 2.0.0
  description: Manage customer records and their associated orders.

servers:
  - url: https://api.company.com/v2
    description: Production
  - url: https://api-staging.company.com/v2
    description: Staging

paths:
  /customers:
    get:
      summary: List customers
      operationId: listCustomers
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: per_page
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: sort
          in: query
          schema: { type: string, enum: [created_at, name, revenue] }
      responses:
        '200':
          description: Customer list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerList'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create a customer
      operationId: createCustomer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCustomer'
      responses:
        '201':
          description: Customer created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Customer'

components:
  schemas:
    Customer:
      type: object
      required: [id, name, email, created_at]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: "Acme Corp"
        email:
          type: string
          format: email
        revenue:
          type: number
          format: double
        created_at:
          type: string
          format: date-time

    CreateCustomer:
      type: object
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        email:
          type: string
          format: email

    CustomerList:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Customer'
        meta:
          $ref: '#/components/schemas/Pagination'

    Pagination:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        total_pages: { type: integer }

1.2 Validate Before Building

# Validate your OpenAPI spec
npx @redocly/cli lint openapi.yaml

# Generate API docs
npx @redocly/cli build-docs openapi.yaml -o docs/index.html

# Generate client SDK
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ./sdk

Step 2: Choose Your Versioning Strategy

StrategyURL PathHeaderQuery Param
Example/v2/customersAPI-Version: 2?version=2
Discoverability✅ Obvious❌ Hidden⚠️ Easy to forget
Caching✅ Cache-friendly❌ Breaks caches✅ Cache-friendly
ImplementationSimple routingMiddlewareMiddleware
Recommendation✅ Best for RESTGood for internalAvoid

Breaking vs Non-Breaking Changes

Non-Breaking (no version bump):
  ✅ Adding a new optional field to a response
  ✅ Adding a new endpoint
  ✅ Adding a new optional query parameter
  ✅ Deprecating a field (but keeping it)

Breaking (requires version bump):
  ❌ Removing a field from a response
  ❌ Renaming a field
  ❌ Changing a field's type
  ❌ Making an optional field required
  ❌ Changing error response format

Step 3: Implement Authentication

JWT + API Key Pattern

from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import HTTPBearer, APIKeyHeader
import jwt

app = FastAPI()
bearer = HTTPBearer()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

async def authenticate(
    token: str = Depends(bearer),
    api_key: str = Security(api_key_header)
):
    """Support both JWT and API Key authentication"""
    if api_key:
        # Validate API key against database
        key_record = await db.api_keys.find_one({"key": api_key, "active": True})
        if not key_record:
            raise HTTPException(401, "Invalid API key")
        return {"type": "api_key", "client": key_record["client_id"]}

    if token:
        try:
            payload = jwt.decode(token.credentials, SECRET, algorithms=["HS256"])
            return {"type": "jwt", "user_id": payload["sub"]}
        except jwt.InvalidTokenError:
            raise HTTPException(401, "Invalid token")

    raise HTTPException(401, "Authentication required")

@app.get("/v2/customers")
async def list_customers(auth=Depends(authenticate)):
    # auth contains the authenticated identity
    ...

Step 4: Implement Rate Limiting

from fastapi import Request
from collections import defaultdict
import time

# Simple in-memory rate limiter
class RateLimiter:
    def __init__(self, max_requests: int = 100, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests = defaultdict(list)

    def is_allowed(self, client_id: str) -> tuple[bool, dict]:
        now = time.time()
        # Clean old entries
        self.requests[client_id] = [
            t for t in self.requests[client_id]
            if now - t < self.window
        ]

        remaining = self.max_requests - len(self.requests[client_id])

        if remaining <= 0:
            return False, {
                "X-RateLimit-Limit": str(self.max_requests),
                "X-RateLimit-Remaining": "0",
                "X-RateLimit-Reset": str(int(self.requests[client_id][0] + self.window))
            }

        self.requests[client_id].append(now)
        return True, {
            "X-RateLimit-Limit": str(self.max_requests),
            "X-RateLimit-Remaining": str(remaining - 1),
        }

rate_limiter = RateLimiter(max_requests=100, window_seconds=60)

Step 5: Contract Testing

# Schemathesis — automated API contract testing
# Install: pip install schemathesis

import schemathesis

schema = schemathesis.from_url(
    "https://api-staging.company.com/openapi.yaml"
)

@schema.parametrize()
def test_api_contract(case):
    """
    Automatically generates test cases from your OpenAPI spec.
    Tests: valid inputs, edge cases, invalid inputs, auth requirements.
    """
    response = case.call()
    case.validate_response(response)
# CLI alternative
schemathesis run https://api-staging.company.com/openapi.yaml \
  --checks all \
  --base-url https://api-staging.company.com \
  --header "Authorization: Bearer $TOKEN"

API Design Checklist

  • OpenAPI spec written and validated before coding
  • Consistent naming (camelCase or snake_case — pick one)
  • Pagination on all list endpoints
  • Versioning strategy chosen (URL path recommended)
  • Authentication (JWT + API Key support)
  • Rate limiting with standard headers
  • Error responses follow RFC 7807 (Problem Details)
  • Contract tests running in CI
  • API documentation auto-generated from spec
  • Breaking change policy documented

:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For API architecture reviews, visit garnetgrid.com. :::