Module 3 · API design

Lesson 15 — Validation and contracts with OpenAPI

Input validation and a contract the client can read without asking you.

Published
In this lesson
  1. Objectives
  2. 1. The validation layers (in order)
  3. 2. Honest serializers
  4. 3. OpenAPI with drf-spectacular
  5. 4. The contract in CI
  6. Self-assessment

Stack: DRF + drf-spectacular · Project: TicketFlow Status: Published Prerequisite: Lesson 14 — Idempotency


Objectives

  1. Order the validation layers: syntax → types → business (and what lives in each).
  2. Write serializers that fail with useful messages (and don't leak fields).
  3. Generate the OpenAPI contract with drf-spectacular and keep it verified in CI.

1. The validation layers (in order)

  1. Syntax/parsing: is it valid JSON? Correct Content-Type? → 400 before touching the domain.
  2. Shape/types: serializer: required fields, types, max_length, choices, formats (email, date). → 400 with per-field errors.
  3. Business: domain rules (seat free, event open, enough credit). → 409/422 with Lesson 13's RFC 7807 type.

The layering mistake: validating business in the serializer (a validate_seats that queries the DB) can be fine for simple cases, but mutual exclusion lives in the transaction (10), not in the validator. Rule: the serializer validates shape and trivial references; the service validates the world's state under a lock.

2. Honest serializers

python
class ReserveInputSerializer(serializers.Serializer):
    event = serializers.UUIDField()                      # public uuid (13)
    seats = serializers.ListField(
        child=serializers.DictField(child=serializers.CharField()),
        min_length=1, max_length=8)                      # explicit limits

    def validate_seats(self, value):
        # shape: each entry has sector/row/number
        ...

Details that separate the senior: min_length/max_length on lists (no 10,000-seat POSTs); write_only for input fields that never come back; read_only on uuid/created_at; error messages in the business's language ("Seat A-12 does not exist in this event", not "Invalid pk"); never accept a client-sent id on creation (mass assignment: explicit fields, never __all__).

3. OpenAPI with drf-spectacular

python
INSTALLED_APPS += ["drf_spectacular"]
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"

# urls.py
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema")),

The contract is generated from code (serializers + viewsets + @extend_schema for the ambiguous parts). @extend_schema(request=ReserveInputSerializer, responses=ReservationOut, parameters=[...]) — wherever the AutoSchema guesses, you declare. The published contract (/api/schema/) is what the frontend consumes (generated TypeScript) and integrators (OpenAPI in Postman/Insomnia).

4. The contract in CI

A contract that isn't verified expires. Pipeline (Lesson 41 previewed):

  1. manage.py spectacular --file schema.yml --fail-on-warn — if an endpoint doesn't document its responses, it fails.
  2. Schema diff in PRs: if it changed, the PR says so in its title (contract change review).
  3. Contract tests: the schema's example is valid against the real response (drf-spectacular audits it; for E2E: schemathesis in Lesson 35).

Self-assessment

  1. Why does seat mutual exclusion NOT live in the serializer's validate_seats?
  2. Which three serializer attributes prevent mass assignment and giant POSTs?
  3. What does an integrator gain from /api/schema/ that they didn't have reading your hand-written docs?
  4. When do you hand-write @extend_schema, and what happens if you never do?
  5. What does --fail-on-warn break in a team, and why is it worth it?

Continue with the exercises. The solutions only after trying it yourself.