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. Exercise 1 — Serializer
  2. Exercise 2 — Layers
  3. Exercise 3 — Contract
  4. Exercise 4 — Contract test
  5. Professor's summary

Exercise 1 — Serializer

python
class SeatRefSerializer(serializers.Serializer):
    sector = serializers.CharField(max_length=50)
    row = serializers.CharField(max_length=10)
    number = serializers.IntegerField(min_value=1)

class ReserveInputSerializer(serializers.Serializer):
    event = serializers.UUIDField()
    seats = SeatRefSerializer(many=True, min_length=1, max_length=8)  # limits
    promo_code = serializers.CharField(max_length=32, required=False, write_only=True)

    def validate_event(self, value):
        if not Event.objects.filter(uuid=value).exists():   # trivial reference
            raise serializers.ValidationError("The event does not exist")
        return value
  1. Messages per case: empty/oversized lists → "This field must have between 1 and 8 items"; format → an error per index naming the concrete field; uuid → "The event does not exist" (a business message, not Invalid pk "...").
  2. "Past event" queries mutable state that could change between validate and action (a race): the service decides with the event locked (10). The serializer is shape; the service, the world.

Exercise 2 — Layers

  1. Broken JSON → 400 (DRF parser, syntax layer); wrong types → 400 with per-field errors (shape layer); seat taken → 409 with type: seat-already-reserved (business layer, from 10/13's DomainError).
  2. "Seat free" in the serializer reintroduces check-then-act without a lock: two requests validate fine and both create — exactly the 03/10 race that the constraint converts into a late 409 with bad UX. Mutable business validation goes under a lock, in the service.

Exercise 3 — Contract

  1. The usual bad guesses: viewsets with multiple serializers (list vs detail), FileField/binary, undocumented 4xx errors. The YAML points them out.
  2. @extend_schema(request=ReserveInputSerializer, responses={201: ReservationOut, 409: ProblemDetail, 422: ProblemDetail}, summary="Create reservation") — and the webhook with request=GatewayWebhookIn, responses={200: None}.
  3. --fail-on-warn forces documenting what the AutoSchema can't deduce: every fixed warning is an endpoint the frontend consumes without asking you.

Exercise 4 — Contract test

python
def test_reservation_matches_contract(self, client, ...):
    resp = client.post("/api/reservations/", payload)
    self.assertEqual(resp.status_code, 201)
    self.assertEqual(set(resp.data), set(ReservationOut.Meta.fields))  # schema keys
  1. With the extracted examples and jsonschema.validate(resp.data, example_schema): docs lie when code evolves without updating them — the test makes lying break CI.

Professor's summary

  • Layers: syntax → shape → business; business under a lock, not in the serializer.
  • Serializer with explicit limits, explicit fields and domain messages.
  • OpenAPI generated from code + extend_schema where it guesses; --fail-on-warn in CI.
  • One contract test per critical endpoint: docs stop lying.

Next: Lesson 16 — gRPC, GraphQL, WebSockets/SSE.