Typorithm · medium

Public response models are a separate boundary

pythonfastapiindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Public response models are a separate boundary

0%

Concept and contract

Project an internal record to the fields the caller is allowed to receive.

The data needed inside a service is often larger than its public response. A storage record can contain an owner identifier or private note that a caller should not receive. A response model makes the emitted representation explicit and lets tests assert that projection.

The example retrieves an owned synthetic record and returns only a PublicRole representation. The internal object includes a private_note. A response_model selects the declared public fields and validates them before JSON is emitted. The local test checks both the accepted fields and the absence of internal data.

Mechanism and worked example

The route returns an ordinary mapping and FastAPI applies PublicRole.

The route returns an ordinary mapping and FastAPI applies PublicRole. A missing record raises HTTPException rather than returning an invented role. The helper reports the exact response so a leaked field is observable, not filtered away again by test code.

Returning a Response or JSONResponse directly takes a different path: the framework treats it as an already prepared response. Do not assume the normal model projection will sanitize such a response. The counterexample deliberately returns JSONResponse(record), which exposes private_note and fails the expected public contract.

Complete worked example

python

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

class PublicRole(BaseModel):
    id: int
    title: str

def inspect_example(role_id):
    records = {1: {"id": 1, "title": "QA", "country": "IL", "private_note": "demo-only"},
               2: {"id": 2, "title": "API", "country": "UA", "private_note": "hidden"}}
    app = FastAPI()
    @app.get("/roles/{role_id}", response_model=PublicRole)
    def read(role_id: int):
        if role_id not in records:
            raise HTTPException(status_code=404, detail="Role not found")
        record = records[role_id]
        return record
    with TestClient(app) as client:
        response = client.get(f"/roles/{role_id}")
        return {"status": response.status_code, "body": response.json()}
Reasoning
The route returns an ordinary mapping and FastAPI applies PublicRole. A missing record raises HTTPException rather than returning an invented role. The helper reports the exact response so a leaked field is observable, not filtered away again by test code. Returning a Response or JSONResponse directly takes a different path: the framework treats it as an already prepared response. Do not assume the normal model projection will sanitize such a response. The counterexample deliberately returns JSONResponse(record), which exposes private_note and fails the expected public contract.

Focused decision: use the complete example for context

python

class PublicRole(BaseModel):
    id: int
    title: str

@app.get("/roles/{role_id}", response_model=PublicRole)
def read(role_id: int):
    return records[role_id]
Focused decision
Reconstruct this focused decision after the warm-up. The complete imports, model or application, and test client remain in the worked example. Typing records practice; only the downloadable computer lab executes Python.

Counterexamples and common mistakes

A response schema is not object-level authorization.

A response schema is not object-level authorization. Filtering private_note would still be insufficient if one user could read another user record. Likewise, declaring a field optional does not make its underlying source safe. Design access control and public representation as separate checks.

Avoid having tests reconstruct PublicRole from the received JSON before comparing it. That could strip the leaked field and make the test falsely pass. Compare the actual response keys and values, including deliberately sensitive synthetic canaries, at the HTTP boundary.

Controlled mistake: observe the actual wrong behavior

python

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

class PublicRole(BaseModel):
    id: int
    title: str

def inspect_example(role_id):
    records = {1: {"id": 1, "title": "QA", "country": "IL", "private_note": "demo-only"},
               2: {"id": 2, "title": "API", "country": "UA", "private_note": "hidden"}}
    app = FastAPI()
    @app.get("/roles/{role_id}", response_model=PublicRole)
    def read(role_id: int):
        if role_id not in records:
            raise HTTPException(status_code=404, detail="Role not found")
        record = records[role_id]
        return JSONResponse(record)
    with TestClient(app) as client:
        response = client.get(f"/roles/{role_id}")
        return {"status": response.status_code, "body": response.json()}
Reasoning
A response schema is not object-level authorization. Filtering private_note would still be insufficient if one user could read another user record. Likewise, declaring a field optional does not make its underlying source safe. Design access control and public representation as separate checks. Avoid having tests reconstruct PublicRole from the received JSON before comparing it. That could strip the leaked field and make the test falsely pass. Compare the actual response keys and values, including deliberately sensitive synthetic canaries, at the HTTP boundary.

Independent practice: one bounded change

Include the record country in the public response.

Include the record country in the public response. Add country to PublicRole and keep private_note excluded. This is a small contract expansion, not permission to return the complete internal record or remove response_model. A client should receive the country only because it is deliberately declared.

Run two known IDs and a missing ID. Check that both successful bodies contain country and neither contains private_note. Preserve the existing identifier and title values. If adding a public field changes a stored object, explain why; this exercise needs no storage mutation.

Step 1: Expose country deliberately through PublicRole while keeping private_note private. Step 2: Retain the working imports and application structure. Change the stated policy, run the supplied practice cases, then explain the newly accepted or rejected boundary.

Practice starter: solve before reading the solution

python

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

class PublicRole(BaseModel):
    id: int
    title: str

def inspect_example(role_id):
    records = {1: {"id": 1, "title": "QA", "country": "IL", "private_note": "demo-only"},
               2: {"id": 2, "title": "API", "country": "UA", "private_note": "hidden"}}
    app = FastAPI()
    @app.get("/roles/{role_id}", response_model=PublicRole)
    def read(role_id: int):
        if role_id not in records:
            raise HTTPException(status_code=404, detail="Role not found")
        record = records[role_id]
        return record
    with TestClient(app) as client:
        response = client.get(f"/roles/{role_id}")
        return {"status": response.status_code, "body": response.json()}

# Independent task: Expose country deliberately through PublicRole while keeping private_note private.
Reasoning
Expose country deliberately through PublicRole while keeping private_note private.
  • Hint 1: Find the one field or branch that owns this policy.
  • Hint 2: Compare the first changed acceptance case with the retained baseline.

Solution, tests and trade-offs

The solution declares country on PublicRole, so the same route now emits that field.

The solution declares country on PublicRole, so the same route now emits that field. The internal mapping remains unchanged, the note remains private and missing IDs still produce 404. Both positive and negative observations establish the intended behavior.

Contract expansions can still affect strict clients, caches and generated SDKs. In production, review compatibility and versioning rather than assuming every added field is harmless. Test failure behavior for invalid stored data as well; response validation failures indicate a service problem and should not be exposed as raw internal traces.

Practice solution and acceptance checks

python

from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

class PublicRole(BaseModel):
    id: int
    title: str
    country: str

def inspect_example(role_id):
    records = {1: {"id": 1, "title": "QA", "country": "IL", "private_note": "demo-only"},
               2: {"id": 2, "title": "API", "country": "UA", "private_note": "hidden"}}
    app = FastAPI()
    @app.get("/roles/{role_id}", response_model=PublicRole)
    def read(role_id: int):
        if role_id not in records:
            raise HTTPException(status_code=404, detail="Role not found")
        record = records[role_id]
        return record
    with TestClient(app) as client:
        response = client.get(f"/roles/{role_id}")
        return {"status": response.status_code, "body": response.json()}
Reasoning
The solution declares country on PublicRole, so the same route now emits that field. The internal mapping remains unchanged, the note remains private and missing IDs still produce 404. Both positive and negative observations establish the intended behavior. Contract expansions can still affect strict clients, caches and generated SDKs. In production, review compatibility and versioning rather than assuming every added field is harmless. Test failure behavior for invalid stored data as well; response validation failures indicate a service problem and should not be exposed as raw internal traces.

Interview reasoning and next step

Explain why a direct JSONResponse bypasses this normal projection and show the synthetic note that catches the leak.

Explain why a direct JSONResponse bypasses this normal projection and show the synthetic note that catches the leak. Then name the additional ownership check a multi-user endpoint would need. A response model alone cannot prove the complete security of a route.

The next lesson introduces reusable dependencies. They can supply validated context or an authorized principal, but their lifetime and per-request caching need to be understood before using them for expensive or stateful resources.

Optional stretch — after the core task works: Add one independently reasoned boundary case and explain its expected value before running it.

Computer practice: https://applydjinn.com/typorithm/python-api-lab-20261105.zip. Use an isolated Python environment and the pinned dependencies. Run verify.py --track fastapi, then edit the working exercise baseline and run --practice fa-responses. The reference suite rejects meaningful counterexamples and checks values, types and unchanged inputs. All data is owned and all HTTP clients are in-process. No production database, feed, account or paid service is used.

Project an internal record to the fields the caller is allowed to receive.