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.
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.
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.
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.
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
@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
{
"url": "https://example.com/long-article-path",
"custom_code": "my-link" // optional
}
{
"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.
/api/v1/ in every route. New breaking changes go to /api/v2/.
Old versions stay live until clients migrate.
Deprecated endpoints return a Sunset header with the removal date
and a Link header pointing to the replacement.
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.
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)
- 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
- 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.
{
"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 |
@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.
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"
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.
Pure functions, validators, serializers. Run in <2 seconds.
Use pytest with fixtures for database mocking.
Hit real endpoints with Flask/FastAPI test clients. Test against an in-memory SQLite database with seeded data.
After deploy, hit the live URL and verify status codes.
Run via GitHub Actions on every push to main.
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
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.
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.
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.
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.