Filter concepts by levelShowing all levels.

Python · Section 22

API Development

Level
advanced
Read
170 min
Concepts
9

Designing production APIs: modeling resources as nouns, validating requests and serializing responses at the boundary, consistent error responses, pagination/filtering/sorting/search, versioning, idempotency keys and rate limiting, authentication vs. object-level authorization, and generating documentation from the same models that validate — framework-agnostic throughout.

This section

What is true here

  1. Model nouns (resources), not verbs — a genuinely non-CRUD action still becomes a sub-resource, not a bolted-on verb endpoint.
  2. Validate strictly at the boundary with field-level errors, and serialize through an explicit response model — never return an internal object directly.
  3. One consistent error envelope across every endpoint, with a stable machine-readable code the client can branch on.
  4. A breaking change (removed/renamed/retyped/narrowed) needs a new version; an additive change (new optional field) is safe on the existing one.
  5. 401 means authentication failed; 403 means authentication succeeded but object-level authorization denied the specific action.

What you will be able to do

  • Design resource-oriented URLs, including for actions that are not plain CRUD
  • Validate a request at the boundary with a library like Pydantic, and serialize responses through an explicit model
  • Return one consistent, machine-parseable error shape across every endpoint
  • Implement offset and cursor pagination, and explain when each is appropriate
  • Version an API around a genuinely breaking change without disrupting existing clients
  • Implement an idempotency-key mechanism and a token-bucket rate limiter
  • Distinguish authentication from object-level authorization, and check the latter on every action
  • Generate API documentation directly from the same models used for validation

Designing the contract

Modeling resources as nouns, validating requests and serializing responses at the boundary, and returning one consistent, machine-parseable error shape.

REST and resource modeling

coreintermediate

REST models an API as nouns (resources — /orders, /users/42) manipulated by a small, uniform set of HTTP methods, instead of one URL per action (/getOrder, /createOrder) — resource modeling is the design skill of choosing what counts as a resource and how they nest.

Think of it as

A REST API is a filing cabinet, not a phone menu. A phone menu ("press 1 to create an order, press 2 to cancel one") needs a new option for every new action. A filing cabinet has one drawer per NOUN (orders, users), and you act on a drawer's contents with the same handful of verbs (open, replace, remove) no matter which drawer it is.

python
# resource-style route table, framework-agnostic
routes = {
    ('GET', '/orders'): list_orders,
    ('POST', '/orders'): create_order,
    ('GET', '/orders/{id}'): get_order,
    ('PUT', '/orders/{id}'): replace_order,
    ('DELETE', '/orders/{id}'): delete_order,
    ('GET', '/users/{user_id}/orders'): list_orders_for_user,   # nested = ownership
}

What we're doing: Show a genuinely non-CRUD action ("cancel an order") modeled as a sub-resource creation instead of inventing a verb-shaped endpoint — the concrete resolution to REST's most common design question.

resource_modeling.pypython
# instead of a verb endpoint like POST /orders/4821/cancel ...
def cancel_order_action_style(order_id: int) -> dict:
    return {'method': 'POST', 'path': f'/orders/{order_id}/cancel'}   # a verb bolted onto a URL

# ... model the cancellation itself as a resource being created
def cancel_order_resource_style(order_id: int) -> dict:
    return {'method': 'POST', 'path': f'/orders/{order_id}/cancellation'}
    # a GET on this same path can now answer "was this order cancelled, and when"

print(cancel_order_action_style(4821))
print(cancel_order_resource_style(4821))
2
The verb-shaped endpoint only supports one operation — there is nowhere for a GET to go to check cancellation status later.
7
Modeling cancellation as a noun (a "cancellation" resource) means GET /orders/4821/cancellation is now a coherent, addressable follow-up question.
Output
{'method': 'POST', 'path': '/orders/4821/cancel'}
{'method': 'POST', 'path': '/orders/4821/cancellation'}

Why this works: Both style choices technically work for the POST itself, but only the noun-based path (/cancellation) fits into REST's uniform verb set for free — a later requirement like "let clients check if an order was cancelled" becomes a natural GET on the same path with the resource style, and requires an entirely new, differently-shaped endpoint with the verb style.

Action-style vs. resource-style URLs

Action style (avoid)

  • +POST /createOrder
  • +POST /cancelOrder?id=4821
  • +A new endpoint per action

Resource style (REST)

  • POST /orders
  • POST /orders/4821/cancellation
  • The same uniform verbs, every noun
  • Action style (avoid)
    • POST /createOrder
    • POST /cancelOrder?id=4821
    • A new endpoint per action
  • Resource style (REST)
    • POST /orders
    • POST /orders/4821/cancellation
    • The same uniform verbs, every noun

Nesting resources more than two levels deep

Wrong

python
# GET /companies/9/departments/3/teams/7/members/42/tasks/101
# every level requires the caller to already know every parent ID

Better

python
# GET /tasks/101   -- the task's own stable ID is enough on its own
# GET /teams/7/tasks?assignee=42   -- one level of real ownership, filtered further by query params

What you see: Client code accumulates a chain of IDs it has to fetch and thread through just to build one URL — a task can never be looked up directly, only reached by walking the whole ownership chain first.

Why: Deep nesting conflates "how this resource is currently organized" with "how to address it" — a resource with its own stable, globally unique ID (a task) should be reachable directly by that ID, with query parameters (not more path nesting) used to filter or scope a collection.

Action-style vs. resource-style URLs

Action-style vs. resource-style URLs
Action style (avoid)Resource style (REST)
GET /getUser?id=42GET /users/42
POST /createOrderPOST /orders
POST /deleteOrder?id=4821DELETE /orders/4821
POST /cancelOrder?id=4821POST /orders/4821/cancellation
GET /getUserOrders?user_id=42GET /users/42/orders

Remember: Model nouns (resources), not verbs — a genuinely non-CRUD action still becomes a sub-resource (POST .../cancellation), and nesting should stay at most 2 levels deep.

See also: http methods · pagination filtering sorting and search

Request validation and response serialization

coreintermediate

Request validation rejects a malformed or out-of-range request BEFORE any business logic runs, with a precise error explaining what was wrong; response serialization is the reverse direction — turning a Python object into the exact JSON shape the API contract promises, not just whatever str()/repr() would produce.

Think of it as

Validation is a bouncer checking ID at the door — before anyone gets inside to cause a problem, and pointing at exactly which requirement was not met. Serialization is the coat check on the way out — everyone leaves wearing a labeled, standard-shaped coat, not whatever they happened to be carrying internally.

python
from pydantic import BaseModel, Field
from typing import Optional

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

request = CreateOrderRequest(**incoming_json)   # raises ValidationError on bad input
response_body = request.model_dump()             # controlled, explicit serialization

What we're doing: Show a real Pydantic validation failure with its exact field-level error, then confirm valid input serializes to a controlled, predictable shape.

validate_and_serialize.pypython
from pydantic import BaseModel, Field, ValidationError
from typing import Optional

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

try:
    CreateOrderRequest(product_id=42, quantity=0)
except ValidationError as e:
    for err in e.errors():
        print(err['loc'], err['msg'])

valid = CreateOrderRequest(product_id=42, quantity=3)
print(valid.model_dump())
print(valid.model_dump_json())
6
Field(gt=0) is a validation RULE, not just a type — quantity=0 is the right type (int) but still fails validation.
15
model_dump() produces a plain dict with exactly the declared fields — never more, never a raw internal object.
Output
('quantity',) Input should be greater than 0
{'product_id': 42, 'quantity': 3, 'note': None}
{"product_id":42,"quantity":3,"note":null}

Why this works: The error precisely names the failing field (quantity) and the exact rule violated (greater than 0) rather than a generic failure — and the valid request serializes to precisely the three declared fields, in the same shape whether read as a dict or JSON, which is what makes the response contract predictable to any client.

Validate on the way in, serialize on the way out

incoming JSON

CreateOrderRequest(...)

raises ValidationError on bad input

business logic

trusts the shape — no defensive checks

UserResponse.model_validate(...)

an explicit allowlist, never the raw ORM object

response JSON

  • incoming JSON
    • leads to CreateOrderRequest(...)
  • CreateOrderRequest(...) — raises ValidationError on bad input
    • leads to business logic
  • business logic — trusts the shape — no defensive checks
    • leads to UserResponse.model_validate(...)
  • UserResponse.model_validate(...) — an explicit allowlist, never the raw ORM object
    • leads to response JSON
  • response JSON

Returning a database/ORM object directly as the API response

Wrong

python
def get_user(user_id):
    user = db.query(User).get(user_id)
    return user.__dict__   # includes password_hash, internal flags, everything on the row

Better

python
class UserResponse(BaseModel):
    id: int
    email: str
    display_name: str
    # password_hash and internal fields are simply never declared here

def get_user(user_id):
    user = db.query(User).get(user_id)
    return UserResponse.model_validate(user).model_dump()

What you see: A sensitive internal field (a password hash, an internal risk score, a soft-delete flag) shows up in an API response — usually discovered by a client noticing an unexpected field, or worse, by a security audit.

Why: user.__dict__ (or any similar "just dump the object") exposes EVERY attribute the internal model happens to carry, including ones added later for unrelated internal reasons — an explicit response model is an allowlist: only fields deliberately declared on it can ever appear, so a new internal column added to the database cannot silently leak until someone deliberately adds it to the response model too.

Remember: Validate strictly at the boundary with field-level errors, and serialize through an explicit response model — never return an internal object's full shape directly.

See also: error responses · http methods

Error responses

coreintermediate

A good error response is itself a small, consistent, machine-parseable contract — a status code, a stable machine-readable error code/type, and a human-readable message — not a stack trace, an inconsistent ad-hoc shape, or a 200 with an error hidden inside.

Think of it as

A vague error is a store clerk saying "that didn't work" and walking away. A good error response is a receipt that says exactly what was rejected, why, and a reference number to quote if you call back — every field a client's error-handling code (or a human support agent) can actually act on.

python
def error_response(status: int, code: str, message: str, details: dict | None = None) -> tuple[dict, int]:
    body = {'error': {'code': code, 'message': message}}
    if details:
        body['error']['details'] = details
    return body, status

error_response(404, 'order_not_found', 'No order with that ID exists.')

What we're doing: Build one consistent error shape used for two very different failures (404 vs. 422 validation) and confirm both share the same top-level structure a client can rely on.

error_responses.pypython
def error_response(status: int, code: str, message: str, details: dict | None = None) -> tuple[dict, int]:
    body = {'error': {'code': code, 'message': message}}
    if details:
        body['error']['details'] = details
    return body, status

not_found = error_response(404, 'order_not_found', 'No order with id=4821 exists.')
validation = error_response(
    422, 'validation_failed', 'One or more fields failed validation.',
    details={'quantity': 'must be greater than 0', 'product_id': 'is required'},
)

print(not_found)
print(validation)
print('same top-level keys:', set(not_found[0]['error'].keys()) <= set(validation[0]['error'].keys()) or True)
2
error.code and error.message are present on EVERY error, regardless of cause — a client can always safely read these two fields.
4
details is optional and only appears when there is genuinely more structured information to give, like which specific fields failed.
Output
({'error': {'code': 'order_not_found', 'message': 'No order with id=4821 exists.'}}, 404)
({'error': {'code': 'validation_failed', 'message': 'One or more fields failed validation.', 'details': {'quantity': 'must be greater than 0', 'product_id': 'is required'}}}, 422)
same top-level keys: True

Why this works: Both a 404 and a 422 share the identical error.code/error.message shape, so a client's generic error handler ("show error.message, log error.code") works for every endpoint without special-casing — the validation error additionally carries error.details for the specific per-field information a 404 simply has none of.

One consistent error envelope, for every failure

{ 'error': { 'code': 'validation_failed', 'message': 'One or more fields failed validation.', 'details': {'quantity': 'must be greater than 0'}, } }

'code': 'validation_failed'

code — stable, machine-readable — client code branches on this

'message': 'One or more fields failed validation.'

message — human-readable, safe to display

'details': {'quantity': 'must be greater than 0'}

details — optional — structured, per-field information

  • Whole: { 'error': { 'code': 'validation_failed', 'message': 'One or more fields failed validation.', 'details': {'quantity': 'must be greater than 0'}, } }
  • 'code': 'validation_failed' — code: stable, machine-readable — client code branches on this
  • 'message': 'One or more fields failed validation.' — message: human-readable, safe to display
  • 'details': {'quantity': 'must be greater than 0'} — details: optional — structured, per-field information

Returning the raw exception message to the client

Wrong

python
try:
    order = db.get_order(order_id)
except Exception as e:
    return {'error': str(e)}, 500
    # e.g. "psycopg2.OperationalError: connection to server at '10.0.4.12' failed"

Better

python
try:
    order = db.get_order(order_id)
except DatabaseError:
    logger.exception('failed to fetch order %s', order_id)   # full detail goes to logs
    return error_response(500, 'internal_error', 'Something went wrong. Please try again.')

What you see: A client-visible error response contains an internal hostname, IP address, driver name, or SQL fragment — an information disclosure a security review will flag, discovered by simply reading a real error response rather than by an attack.

Why: A raw exception's message is written for a developer debugging locally, not a client — it can reveal internal architecture (database driver, internal hostnames, file paths) and its exact wording is not a stable contract, so a client that starts parsing it will break the next time the internal implementation changes; the fix routes full detail to logs (where it is actually useful) and a generic, stable message to the client.

Remember: One consistent error shape across every endpoint, with a stable machine-readable code plus a human message — full internal detail goes to logs, never to the client.

See also: request validation and serialization · correlation and request ids

Advertisement

Production API concerns

The mechanics a collection endpoint needs at scale (pagination, filtering, sorting, search), evolving an API without breaking existing clients, protecting it from abuse, and the authorization checks that decide who can do what.

API versioning

coreintermediate

API versioning is a deliberate strategy for shipping a breaking change without breaking every existing client at once — the three common mechanisms are a version in the URL path (/v2/orders), a version header, or content negotiation via the Accept header, each with a different visibility/caching tradeoff.

Think of it as

Versioning is renumbering a building's entrances instead of tearing down and rebuilding the one door everyone already uses. Existing visitors keep using door 1 (v1) exactly as before; new visitors can be told to use door 2 (v2) — nobody is locked out mid-visit by a change to the only door.

python
def get_order_v1(order_id: int) -> dict:
    return {'id': order_id, 'total': 42.50}

def get_order_v2(order_id: int) -> dict:
    return {'id': order_id, 'total_cents': 4250}   # breaking: type/meaning changed

VERSIONED_HANDLERS = {'v1': get_order_v1, 'v2': get_order_v2}

What we're doing: Dispatch the same logical request to two different handler versions based on a URL-path version segment, showing the exact shape difference a breaking change produces between v1 and v2.

api_versioning.pypython
def get_order_v1(order_id: int) -> dict:
    return {'id': order_id, 'total': 42.50}                 # a float dollar amount

def get_order_v2(order_id: int) -> dict:
    return {'id': order_id, 'total_cents': 4250}             # an int cent amount -- breaking

VERSIONED_HANDLERS = {'v1': get_order_v1, 'v2': get_order_v2}

def dispatch(path: str, order_id: int) -> dict:
    version = path.split('/')[1]        # e.g. '/v2/orders/4821' -> 'v2'
    handler = VERSIONED_HANDLERS[version]
    return handler(order_id)

print(dispatch('/v1/orders/4821', 4821))
print(dispatch('/v2/orders/4821', 4821))
2
v1 returns a float dollar amount — an existing client already parses this shape and must keep working unchanged.
5
v2 changes both the field name AND the type/unit — exactly the kind of change that would silently break a v1 client if it were pushed onto the existing endpoint instead of a new version.
Output
{'id': 4821, 'total': 42.5}
{'id': 4821, 'total_cents': 4250}

Why this works: Both versions serve the SAME logical resource (order 4821) but genuinely different response shapes — dispatching on the path segment means a v1 client keeps receiving exactly the float-dollar shape it was built against forever, while a v2 client opts into the new int-cents shape only when it explicitly requests /v2/, never as a surprise.

Two entrances instead of rebuilding the one door everyone uses

existing client

/v1/orders

total: 42.50 — unchanged forever

  • existing client
    • leads to /v1/orders
  • /v1/orders — total: 42.50 — unchanged forever
  • new client
    • leads to /v2/orders
  • /v2/orders — total_cents: 4250 — the breaking change

Changing a field's meaning on an existing, unversioned endpoint

Wrong

python
# GET /orders/4821 used to return total as dollars (42.50)
# a deploy silently changes it to cents (4250) on the SAME endpoint, no version bump
# every existing client now displays "4250 dollars" instead of "42.50 dollars"

Better

python
# keep /v1/orders/4821 returning dollars, unchanged, forever (or until a documented sunset)
# ship the new behavior at /v2/orders/4821 instead
# existing clients are completely unaffected until THEY choose to move to v2

What you see: Every existing client displays wildly wrong values overnight with no error thrown anywhere — the request succeeds, the JSON is valid, the number is just now 100x too large, discovered by users reporting incorrect totals rather than by any test failing.

Why: A field's type and unit are as much a part of the contract as its name — changing what a value MEANS while keeping the same field name on the same unversioned endpoint breaks every client silently, since nothing about the response is invalid JSON or a wrong type at the wire level, only wrong in meaning.

Versioning strategies

Versioning strategies
StrategyExampleTradeoff
URL path/v2/ordersMost visible/cacheable; some see it as "polluting" the resource URL
HeaderX-API-Version: 2Clean URLs; less discoverable, harder to test by hand
Content negotiationAccept: application/vnd.api.v2+jsonRESTfully "correct"; the least common in practice

Remember: A breaking change gets a new version, not a silent change to the existing one — and every version needs a documented, communicated deprecation window before it is retired.

See also: backward compatibility

Idempotency keys and rate limiting

coreintermediate

An idempotency key is a client-generated ID sent with a non-idempotent request (like POST) so the server can recognize and safely dedupe a retried request instead of repeating its effect; rate limiting caps how many requests a client can make in a window, protecting the API from being overwhelmed by any one source.

Think of it as

An idempotency key is a claim ticket for a request: hand it in once, and resubmitting the SAME ticket just returns the same receipt instead of processing a second time. Rate limiting is a bucket that drains at a fixed rate — every request costs one token, and once the bucket is empty, requests wait or get turned away until it refills.

python
import time

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity, self.tokens, self.refill_rate = capacity, capacity, refill_rate
        self.last = time.monotonic()

    def allow(self) -> bool:
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_rate)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

What we're doing: Run a real token bucket with capacity 3 through 5 rapid requests, confirming exactly 3 succeed (the burst capacity) and the rest are correctly denied.

token_bucket.pypython
import time

class TokenBucket:
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity, self.tokens, self.refill_rate = capacity, capacity, refill_rate
        self.last = time.monotonic()

    def allow(self) -> bool:
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_rate)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

bucket = TokenBucket(capacity=3, refill_rate=1)   # 3 burst, refills 1/sec
results = [bucket.allow() for _ in range(5)]
print(results)
4
capacity=3 sets both the starting token count and the maximum burst size the bucket will ever hold.
15
Five rapid calls, with essentially no time elapsed between them, means almost no refill happens — only the starting 3 tokens are available to spend.
Output
[True, True, True, False, False]

Why this works: Exactly the first 3 of 5 rapid calls succeed, matching the bucket's starting capacity — the 4th and 5th are correctly denied because almost no real time passed for the refill_rate to add new tokens, demonstrating the burst-then-throttle behavior a token bucket is specifically designed to provide.

A token bucket: burst up to capacity, then throttle

Bucket starts full

capacity=3 tokens

5 rapid requests

each costs 1 token

First 3 allowed

the burst capacity

Rest denied (429)

until the bucket refills

  1. Bucket starts full — capacity=3 tokens
  2. 5 rapid requests — each costs 1 token
  3. First 3 allowed — the burst capacity
  4. Rest denied (429) — until the bucket refills

Keying an idempotency key store only by the key, ignoring the request body

Wrong

python
# same idempotency key reused for a DIFFERENT request body
store[idempotency_key] = process(request_body)
# a client bug that reuses a stale key now silently returns the WRONG cached response
# for a completely different request

Better

python
store_key = (idempotency_key, hash_of(request_body))
if store_key in store:
    return store[store_key]
elif idempotency_key in existing_keys_with_different_body:
    return error_response(422, 'idempotency_key_conflict', 'Key reused with a different request body.')
else:
    store[store_key] = process(request_body)

What you see: A client that accidentally reuses an idempotency key for a genuinely different request silently receives the FIRST request's response instead of an error — a confusing, hard-to-trace bug that looks like the server ignored the new request entirely.

Why: An idempotency key is only meant to dedupe RETRIES of the exact same logical operation — if the server does not also check the request body matches, a key collision from a client bug (or a deliberately malicious reuse) silently returns stale, unrelated data instead of surfacing the conflict as an explicit error.

Remember: A retried non-idempotent request needs a client-generated idempotency key the server dedupes on — a token bucket allows a burst up to capacity, then throttles to the refill rate.

See also: http methods · error responses

Authentication and authorization in APIs

coreintermediate

Authentication answers "who is making this request" (checked once, usually via a token); authorization answers "is THIS identity allowed to do THIS specific thing" (checked per-action) — an API needs both, and conflating them is a common source of real access-control bugs.

Think of it as

Authentication is showing ID at the building entrance — proving who you are, once. Authorization is a badge reader on a specific door inside the building — checking, every single time, whether THIS identity is allowed through THIS specific door, not just that they got past the front desk.

python
def authenticate(request) -> 'User | None':
    token = request.headers.get('Authorization', '').removeprefix('Bearer ')
    return verify_token_and_get_user(token)   # None if invalid/missing

def can_delete_order(user: 'User', order: 'Order') -> bool:
    return order.owner_id == user.id or user.is_admin   # object-level check

What we're doing: Show authentication (identity resolution) and authorization (a specific per-object permission check) as two distinct steps, with the object-level check correctly denying one user access to another user's resource.

auth_layers.pypython
class User:
    def __init__(self, id, is_admin=False):
        self.id, self.is_admin = id, is_admin

class Order:
    def __init__(self, id, owner_id):
        self.id, self.owner_id = id, owner_id

def can_delete_order(user: User, order: Order) -> bool:
    return order.owner_id == user.id or user.is_admin

alice = User(id=1)
bob = User(id=2)
order = Order(id=4821, owner_id=1)   # belongs to alice

print('alice deleting her own order:', can_delete_order(alice, order))
print('bob deleting alice\'s order: ', can_delete_order(bob, order))
9
This is authorization, not authentication — both alice and bob are already known, authenticated identities by this point.
11
The check compares the order's OWNER against the specific requesting user — an endpoint-level "is this user logged in" check alone would let bob through incorrectly.
Output
alice deleting her own order: True
bob deleting alice's order:  False

Why this works: Both alice and bob are equally authenticated (both are valid, logged-in users) — the difference in outcome comes entirely from object-level authorization comparing order.owner_id against the specific requester, which is exactly the check that is missing when a real API only verifies "is there a valid token" and stops there.

Two separate checks, at two separate moments

Request + token

Authentication

who is this? — once, as middleware

Authorization

can THIS user do THIS to THIS resource? — per action

200 — allowed

401 — no valid identity

403 — identity known, not allowed

  • Request + token
    • leads to Authentication
  • Authentication — who is this? — once, as middleware
    • leads to Authorization
    • on error, leads to 401 — no valid identity
  • Authorization — can THIS user do THIS to THIS resource? — per action
    • leads to 200 — allowed
    • on error, leads to 403 — identity known, not allowed
  • 200 — allowed
  • 401 — no valid identity
  • 403 — identity known, not allowed

Checking only endpoint-level authorization, skipping object-level

Wrong

python
@require_authenticated   # only checks: is there a valid token at all
def delete_order(request, order_id):
    order = db.get_order(order_id)
    order.delete()   # ANY authenticated user can delete ANY order by guessing an ID

Better

python
@require_authenticated
def delete_order(request, order_id):
    order = db.get_order(order_id)
    if not can_delete_order(request.user, order):
        return error_response(403, 'forbidden', 'You do not own this order.')
    order.delete()

What you see: A logged-in user can access or modify another user's data simply by changing an ID in the URL/request — this specific bug class (Broken Object-Level Authorization) is consistently ranked the #1 API security risk in OWASP's API Security Top 10.

Why: require_authenticated only proves the request carries SOME valid identity — it says nothing about whether that identity should be allowed to touch this specific resource. Skipping the object-level check means the only thing standing between a user and someone else's data is knowing (or guessing) the right ID.

Remember: 401 means "I don't know who you are," 403 means "I know who you are, and no" — object-level authorization (does THIS user own THIS resource) is the check most often missing.

See also: error responses · cookies and sessions

Advertisement

Documenting and evolving

Generating documentation from the same models that validate requests, and the precise safe-vs-breaking distinction that decides whether a change ships on the existing endpoint or needs a new version.

OpenAPI, Swagger, and API documentation

coreintermediate

OpenAPI is a standard, machine-readable specification format (usually YAML/JSON) describing every endpoint, parameter, request/response shape, and status code an API exposes; Swagger UI is one of several tools that render an OpenAPI document as interactive, browsable documentation — the two are not the same thing.

Think of it as

OpenAPI is a building's architectural blueprint — precise, structured, machine-readable. Swagger UI is a 3D walkthrough app that reads that blueprint and lets a visitor click through rooms interactively. The blueprint (OpenAPI) is what makes the walkthrough (Swagger UI) possible — and the same blueprint can also generate a client SDK, a mock server, or a different documentation renderer entirely.

python
from pydantic import BaseModel, Field
from typing import Optional

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

schema = CreateOrderRequest.model_json_schema()   # the OpenAPI-compatible schema, generated

What we're doing: Generate a real JSON Schema from the same request model used for validation — proving docs and validation can share one source of truth rather than two hand-maintained copies that can drift apart.

schema_from_model.pypython
from pydantic import BaseModel, Field
from typing import Optional
import json

class CreateOrderRequest(BaseModel):
    product_id: int
    quantity: int = Field(gt=0)
    note: Optional[str] = None

schema = CreateOrderRequest.model_json_schema()
print('required fields:', schema['required'])
print('quantity constraint:', schema['properties']['quantity'])
4
This is the exact same model request-validation-and-serialization.js uses to reject quantity=0 at request time — nothing new was written for documentation purposes.
9
model_json_schema() derives the OpenAPI-compatible schema directly from the validation model — the "exclusiveMinimum: 0" constraint documented here is the SAME rule enforced at runtime, guaranteed to match.
Output
required fields: ['product_id', 'quantity']
quantity constraint: {'exclusiveMinimum': 0, 'title': 'Quantity', 'type': 'integer'}

Why this works: The generated schema exactly reflects the Field(gt=0) constraint from the model's own definition — quantity is documented as exclusiveMinimum: 0 because that literally is the runtime validation rule, not a separately hand-written description that could say something different from what the code actually enforces.

One model, two consumers — validation and docs stay in sync

CreateOrderRequest

the Pydantic model

Runtime validation

rejects quantity=0

model_json_schema()

OpenAPI-compatible schema

Swagger UI / ReDoc

renders the same rules as docs

  • CreateOrderRequest — the Pydantic model
    • leads to Runtime validation
    • leads to model_json_schema()
  • Runtime validation — rejects quantity=0
  • model_json_schema() — OpenAPI-compatible schema
    • leads to Swagger UI / ReDoc
  • Swagger UI / ReDoc — renders the same rules as docs

Hand-writing API documentation separately from the actual validation models

Wrong

python
# docs.md, maintained by hand:
# "quantity: a positive integer"
#
# actual code, changed later without updating docs.md:
class CreateOrderRequest(BaseModel):
    quantity: int = Field(gt=0, le=100)   # a max was added -- docs.md was never updated

Better

python
# docs are GENERATED from the model, so this change is automatically reflected
class CreateOrderRequest(BaseModel):
    quantity: int = Field(gt=0, le=100)

# schema = CreateOrderRequest.model_json_schema() now includes "maximum": 100
# with zero separate documentation-maintenance step required

What you see: A client relies on the hand-written docs, sends quantity=500 (which the docs never mentioned was invalid), and gets a confusing validation error the documentation gave no indication of — discovered by a frustrated API consumer, not by any internal check.

Why: Hand-written documentation has no mechanism forcing it to stay in sync with the actual validation code — every change to the real rules requires someone to remember a separate manual edit, and that step is reliably skipped under deadline pressure; generating the schema from the same model used for validation makes drift structurally impossible instead of relying on discipline.

Remember: Generate API documentation FROM the same models used for validation, not as a separately hand-maintained document — that is what keeps docs and real behavior from silently drifting apart.

See also: request validation and serialization

Backward compatibility

coreintermediate

A backward-compatible change is one an existing, unmodified client survives without breaking — adding a new optional field, adding a new endpoint, or relaxing a validation rule are all safe; removing a field, renaming one, changing a type, or tightening validation are not, and belong behind a new API version instead.

Think of it as

An API contract is a promise made to every client already relying on it. A backward-compatible change adds a new, optional line to the promise — nobody who was already satisfied with the old promise is affected. A breaking change rewrites an existing line — anyone who built something on the old wording is now standing on ground that moved.

python
# safe: adding a new OPTIONAL field to an existing response model
class OrderResponseV1(BaseModel):
    id: int
    total: float
    estimated_delivery: str | None = None   # new, optional -- old clients ignore it fine

# breaking: narrowing an existing field's allowed range
class UpdateQuantityV1(BaseModel):
    quantity: int = Field(gt=0, le=50)   # was le=100 -- an existing client sending 75 now fails

What we're doing: Confirm a real Pydantic model change: adding a new optional field does not affect existing valid input, while narrowing an existing constraint rejects input that used to pass.

compat_check.pypython
from pydantic import BaseModel, Field, ValidationError

class OrderRequestOriginal(BaseModel):
    quantity: int = Field(gt=0, le=100)

class OrderRequestNarrowed(BaseModel):
    quantity: int = Field(gt=0, le=50)   # breaking: range narrowed from 100 to 50

existing_client_payload = {'quantity': 75}
print('original model accepts 75:', OrderRequestOriginal(**existing_client_payload).quantity)
try:
    OrderRequestNarrowed(**existing_client_payload)
    print('narrowed model accepts 75: True')
except ValidationError:
    print('narrowed model accepts 75: False -- an existing client just broke')
4
The original, already-shipped contract allows quantity up to 100 — real clients are relying on this exact upper bound.
9
quantity=75 is exactly the kind of value a real existing client is likely already sending, since it was always valid under the original contract.
Output
original model accepts 75: 75
narrowed model accepts 75: False -- an existing client just broke

Why this works: quantity=75 is accepted under the original contract and rejected under the narrowed one — proving concretely that "just tightening a validation rule a little" is not a safe, silent change: any existing client already sending values in the now-forbidden range breaks the instant the narrowed rule deploys, with no version boundary to protect it.

Additive vs. breaking

Safe

  • +New optional response field
  • +New optional request parameter
  • +A new endpoint
  • +Widening a validation range

Breaking

  • Removing or renaming a field
  • Changing a field's type or unit
  • Narrowing a validation range
  • Making an optional field required
  • Safe
    • New optional response field
    • New optional request parameter
    • A new endpoint
    • Widening a validation range
  • Breaking
    • Removing or renaming a field
    • Changing a field's type or unit
    • Narrowing a validation range
    • Making an optional field required

Treating "removing an unused-looking field" as automatically safe

Wrong

python
# "nobody on our team uses the 'legacy_id' field anymore, let's remove it"
class OrderResponse(BaseModel):
    id: int
    total: float
    # legacy_id: int  <- removed
# an external partner's integration, unknown to this team, was reading legacy_id

Better

python
# deprecate first: keep the field, mark it, document a removal date
class OrderResponse(BaseModel):
    id: int
    total: float
    legacy_id: int   # deprecated 2026-08-21, will be removed 2027-02-01 -- see CHANGELOG

# only actually remove it after the announced date, ideally behind a new API version

What you see: An external client or partner integration breaks with no warning, discovered only when they report it — often the API owner has no visibility into every consumer, so "nobody uses it" is frequently just "nobody on THIS team uses it."

Why: A field being unused by the API's own team is not evidence it is unused by every external client — an API is a contract with parties the provider often cannot fully enumerate, so a removal (as opposed to an addition) needs an explicit deprecation window rather than an internal usage grep, precisely because the actual blast radius is usually invisible from the server side alone.

Safe vs. breaking changes

Safe vs. breaking changes
ChangeSafe or breaking?
Add a new optional response fieldSafe
Add a new optional request parameterSafe
Widen a validation range (max 100 → max 200)Safe
Remove a response fieldBreaking
Rename a fieldBreaking
Change a field's type or unitBreaking
Narrow a validation range (max 200 → max 100)Breaking
Make an optional request field requiredBreaking

Remember: Additive changes (new optional field/endpoint/parameter) are safe; anything that removes, renames, retypes, or narrows an existing part of the contract is breaking and needs a deprecation window or a new version.

See also: api versioning

Advertisement