Jamie Software Lab
Home / Projects / API Engineering
Python Flask FastAPI REST OpenAPI

API Engineering

A deep dive into how I design, document, test, and secure REST APIs. Covers Flask and FastAPI services, OpenAPI/Swagger documentation, JWT authentication, versioning strategies, and structured error handling.

Frameworks Flask + FastAPI
Documentation OpenAPI 3.0
Auth JWT (HS256)
Test coverage 87%
Tests passing Coverage 87% OpenAPI valid Python 3.12

Framework Comparison

I use Flask for lightweight services where control over middleware matters, and FastAPI for services that benefit from automatic validation and OpenAPI generation.

// flask
Flask

Micro-framework. Full control over request lifecycle. Used for Server Pulse, Link Shortener, and Media Downloader. Pairs with Gunicorn in production. I add validation, error handling, and CORS manually : which forces understanding of each layer.

// fastapi
FastAPI

ASGI framework with Pydantic validation built in. Auto-generates OpenAPI docs. Used for the Market Snapshot price API. Dependency injection handles auth cleanly. Async support means I can serve WebSocket + REST from the same process.

Example Endpoint Documentation

Every API I build follows a consistent pattern: typed request/response schemas, structured errors, and version-prefixed routes.

python : FastAPI endpoint
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

router = APIRouter(prefix="/api/v1")

class QuoteResponse(BaseModel):
    symbol: str = Field(..., example="CSCO")
    price: float = Field(..., ge=0, example=62.45)
    currency: str = Field(default="USD")
    timestamp: str

@router.get("/quote/{symbol}", response_model=QuoteResponse)
async def get_quote(symbol: str, user=Depends(get_current_user)):
    """Fetch the latest stock quote for a given ticker symbol."""
    symbol = symbol.upper().strip()
    if not symbol.isalpha() or len(symbol) > 5:
        raise HTTPException(status_code=400, detail="Invalid ticker symbol")
    quote = await fetch_quote(symbol)
    if not quote:
        raise HTTPException(status_code=404, detail="Symbol not found")
    return quote
python : Flask endpoint
@app.route("/api/v1/shorten", methods=["POST"])
@require_auth
@rate_limit(max_requests=30, window=60)
def create_short_link():
    """Create a new shortened URL. Requires JWT in Authorization header."""
    data = request.get_json(silent=True)
    if not data or not data.get("url"):
        return jsonify({"error": "Missing 'url' field"}), 400

    url = data["url"].strip()
    if not url.startswith(("http://", "https://")):
        return jsonify({"error": "URL must use http or https"}), 400

    short = generate_short_code()
    db.execute("INSERT INTO links (code, url, user_id) VALUES (?, ?, ?)",
               (short, url, g.user_id))
    return jsonify({"short_url": f"https://jamieblair.co.uk/s/{short}"}), 201

Request / Response Structure

→ REQUEST
POST /api/v1/shorten
{
  "url": "https://example.com/long-article-path",
  "custom_code": "my-link"  // optional
}
← RESPONSE 201
Created
{
  "short_url": "https://jamieblair.co.uk/s/my-link",
  "created_at": "2026-03-02T14:30:00Z",
  "expires_in": null
}

API Versioning Strategy

All APIs use URL path versioning (/api/v1/). It's explicit, visible in logs, and easy to route at the proxy layer.

01
Path prefix

/api/v1/ in every route. New breaking changes go to /api/v2/. Old versions stay live until clients migrate.

02
Deprecation headers

Deprecated endpoints return a Sunset header with the removal date and a Link header pointing to the replacement.

03
Schema evolution

Additive changes (new optional fields) don't bump the version. Removals or type changes always require a new major version.

Authentication Patterns

JWT with short-lived access tokens and longer refresh tokens. Secrets are loaded from environment variables, never hardcoded.

JWT Authentication Flow
Client
POST /login
Auth Service
validate credentials
JWT Issued
access + refresh
Protected Route
Bearer token
Token Refresh Flow
Access Token Expired
401 Unauthorized
Client
POST /refresh
Validate Refresh
check exp + jti
New Access Token
15 min TTL
python : JWT token generation
import os, jwt
from datetime import datetime, timedelta, timezone

# Secret loaded from environment : never hardcoded
JWT_SECRET = os.environ["JWT_SECRET"]
JWT_ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)

def create_access_token(user_id: int) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "iat": now,
        "exp": now + ACCESS_TTL,
        "type": "access",
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
🔐
Security Decisions
  • Short access tokens (15 min) : limits the damage window if a token leaks
  • Refresh tokens rotated on use : old refresh tokens are invalidated after exchange
  • Passwords hashed with PBKDF2-SHA256 : 600,000 iterations, per-user salt
  • No tokens in URL params : always in Authorization header or httpOnly cookie
⚙️
Implementation Notes
  • JWT_SECRET loaded from env var, validated at startup
  • Token type claim prevents refresh tokens being used as access tokens
  • Rate limit on /login : 5 attempts per minute per IP
  • Constant-time comparison for password verification via hmac.compare_digest

Error Handling Strategy

Every API returns structured JSON errors with a consistent shape. No stack traces or internal details leak to clients.

json : Standard error response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid ticker symbol",
    "field": "symbol",
    "request_id": "req_8f3a2b1c"
  }
}
HTTP Status Error Code When
400 VALIDATION_ERROR Missing or malformed request fields
401 AUTH_REQUIRED No token or expired token
403 FORBIDDEN Valid token but insufficient permissions
404 NOT_FOUND Requested resource does not exist
429 RATE_LIMITED Too many requests from this IP
500 INTERNAL_ERROR Unhandled server error : logged, not exposed
python : Global error handler
@app.errorhandler(Exception)
def handle_exception(exc):
    """Catch-all: log the real error, return a safe response."""
    request_id = getattr(g, "request_id", "unknown")

    if isinstance(exc, HTTPException):
        return jsonify({
            "error": {
                "code": exc.description,
                "message": str(exc),
                "request_id": request_id,
            }
        }), exc.code

    # Log the full traceback server-side
    app.logger.error("Unhandled: %s [%s]", exc, request_id, exc_info=True)

    # Return a generic message : never expose internal details
    return jsonify({
        "error": {
            "code": "INTERNAL_ERROR",
            "message": "Something went wrong",
            "request_id": request_id,
        }
    }), 500

OpenAPI Documentation

FastAPI generates OpenAPI 3.0 schemas automatically. Flask services use flask-smorest or hand-written specs validated against the openapi-spec-validator package.

yaml : OpenAPI 3.0 spec excerpt
openapi: "3.0.3"
info:
  title: "Market Snapshot API"
  version: "1.0.0"
  description: "Live stock quotes and daily snapshots"
paths:
  /api/v1/quote/{symbol}:
    get:
      summary: "Get live quote"
      parameters:
        - name: symbol
          in: path
          required: true
          schema: { type: string, pattern: "^[A-Z]{1,5}$" }
      responses:
        "200":
          description: "Success"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QuoteResponse" }
        "400":
          description: "Invalid symbol"
        "404":
          description: "Symbol not found"
📖
Documentation Strategy

Every endpoint gets: a human-readable summary, typed request/response schemas, example values, and documented error codes. The spec is version-controlled alongside the code and validated in CI. FastAPI generates it from type hints; Flask specs are authored in YAML and tested with openapi-spec-validator.

Testing Strategy

I follow the test pyramid: most tests are fast unit tests, then integration tests against real databases, and a thin layer of end-to-end smoke tests.

E2E ~5 tests
Integration ~25 tests
Unit ~80 tests
🧪
Unit Tests

Pure functions, validators, serializers. Run in <2 seconds. Use pytest with fixtures for database mocking.

🔗
Integration Tests

Hit real endpoints with Flask/FastAPI test clients. Test against an in-memory SQLite database with seeded data.

🌐
E2E / Smoke

After deploy, hit the live URL and verify status codes. Run via GitHub Actions on every push to main.

python : pytest integration test
def test_create_short_link_requires_auth(client):
    """POST /api/v1/shorten without a token returns 401."""
    resp = client.post("/api/v1/shorten", json={"url": "https://example.com"})
    assert resp.status_code == 401
    assert resp.json["error"]["code"] == "AUTH_REQUIRED"


def test_create_short_link_valid(client, auth_header):
    """POST /api/v1/shorten with valid token creates a link."""
    resp = client.post(
        "/api/v1/shorten",
        json={"url": "https://example.com/article"},
        headers=auth_header,
    )
    assert resp.status_code == 201
    assert "short_url" in resp.json


def test_reject_invalid_url(client, auth_header):
    """POST /api/v1/shorten with non-http URL returns 400."""
    resp = client.post(
        "/api/v1/shorten",
        json={"url": "ftp://not-allowed.com"},
        headers=auth_header,
    )
    assert resp.status_code == 400

Architectural Decisions

01
Why JSON over REST, not GraphQL

The APIs serve simple, predictable resources. REST gives me straightforward caching, simpler debugging, and better tooling for the kind of data I'm returning. GraphQL adds complexity without enough benefit at this scale.

02
Why SQLite for link shortener

Single-server deployment with WAL mode handles concurrent reads well. No need for PostgreSQL when the data fits in a single file. Backup is just a file copy. Migration uses simple SQL scripts.

03
Why Gunicorn + Nginx, not serverless

The APIs are long-running (price streaming, WebSocket chat). Serverless would add cold-start latency and connection management overhead. A Hetzner VPS costs €4/month and handles all services.

04
Why HS256 over RS256 for JWT

Single service : no need for asymmetric keys. HS256 is simpler to manage and sufficient when the token issuer and verifier are the same process. If I add microservices later, I'll migrate to RS256.