ProvenPin Install

FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators: working versions (tested 2026-10-15)

Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.

passed5 passed · 0 failed2026-10-15 13:32 UTC
Status
passed
Last passed
2026-10-15 13:32 UTC
Tests
5 / 5
Language
Python
Runtime
Python 3.13.16
pinned versions4
fastapi@0.143.1pydantic@2.14.1httpx@0.28.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

The code

Exactly the files that passed. Your agent can pull them with get_recipe("pydantic-v2-discriminated-unions-fastapi") (install), or see them on GitHub.

requirements.txt
fastapi==0.143.1
pydantic==2.14.1
httpx==0.28.1
pytest==9.1.1
app.py
from typing import Annotated, Literal

from fastapi import FastAPI
from pydantic import AfterValidator, BaseModel, Field

app = FastAPI()


def luhn(v: str) -> str:
    total = 0
    for i, ch in enumerate(reversed(v)):
        d = int(ch)
        if i % 2:  # double every second digit from the right
            d = d * 2 - 9 if d > 4 else d * 2
        total += d
    if total % 10:
        raise ValueError("Luhn checksum failed")
    return v


class Card(BaseModel):
    kind: Literal["card"]
    number: Annotated[str, Field(pattern=r"^\d{12,19}$"), AfterValidator(luhn)]


class Bank(BaseModel):
    kind: Literal["bank"]
    iban: Annotated[str, Field(min_length=15, max_length=34)]


# The discriminator must be a Literal on every branch; a plain Union
# would try each branch and produce noisier errors.
Payment = Annotated[Card | Bank, Field(discriminator="kind")]


class Order(BaseModel):
    amount: Annotated[int, Field(gt=0)]
    payment: Payment


@app.post("/orders")
def create_order(order: Order):
    return {"ok": True, "kind": order.payment.kind}
tests/test_app.py
from fastapi.testclient import TestClient

from app import app

client = TestClient(app)


def card(**overrides):
    return {"kind": "card", "number": "4111111111111111", **overrides}


def test_valid_card_and_bank():
    r = client.post("/orders", json={"amount": 500, "payment": card()})
    assert r.status_code == 200
    assert r.json() == {"ok": True, "kind": "card"}

    bank = {"kind": "bank", "iban": "DE89370400440532013000"}
    r = client.post("/orders", json={"amount": 5, "payment": bank})
    assert r.json()["kind"] == "bank"


def test_unknown_tag_is_422_union_tag_invalid():
    r = client.post("/orders", json={"amount": 1, "payment": {"kind": "crypto"}})
    assert r.status_code == 422
    err = r.json()["detail"][0]
    assert err["type"] == "union_tag_invalid"
    assert err["loc"][:2] == ["body", "payment"]


def test_missing_discriminator_is_union_tag_not_found():
    r = client.post("/orders", json={"amount": 1, "payment": {"number": "4111111111111111"}})
    assert r.status_code == 422
    assert r.json()["detail"][0]["type"] == "union_tag_not_found"


def test_branch_field_error_loc_includes_tag():
    r = client.post("/orders", json={"amount": 1, "payment": card(number="41x1")})
    err = r.json()["detail"][0]
    assert err["loc"] == ["body", "payment", "card", "number"]
    assert err["type"] == "string_pattern_mismatch"


def test_annotated_luhn_validator_runs_after_pattern():
    r = client.post("/orders", json={"amount": 1, "payment": card(number="4111111111111112")})
    err = r.json()["detail"][0]
    assert err["type"] == "value_error"
    assert "Luhn" in err["msg"]
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.