Create your own
Lesson illustration

Building a Validated FastAPI Endpoint with Typed Responses

Hello. In the previous lesson, you learned to overlap independent I/O operations—such as retrieval and conversation loading—without blocking Python’s event loop. Now we put that service logic behind a real HTTP boundary.

By the end of this lesson, you will be able to build a FastAPI endpoint that accepts a validated JSON request, calls an async service function, and returns a deliberately typed JSON response. This is the minimal production-shaped unit behind many AI features: “ask a question,” “summarize this document,” or “classify this support ticket.”


An endpoint is a contract, not just a Python function

An API endpoint makes a promise to its client:

  • Where to send the request: a URL path such as /v1/answers
  • How to send it: an HTTP method such as POST
  • What input is acceptable: a JSON request schema
  • What success looks like: a JSON response schema
  • How invalid input is reported: a structured client error

For an AI-answer feature, a client might send:

{
  "question": "How do I reset my VPN access?",
  "conversation_id": "d9e591b5-03cf-45c4-af18-ab8966826f77"
}

The server should not receive an unstructured dict and hope downstream code handles every missing field, invalid identifier, or accidental client typo. Instead, FastAPI uses your Python type annotations and Pydantic models as an executable API contract.

The request lifecycle is:

  1. FastAPI receives the HTTP request and parses its JSON body.
  2. Pydantic validates the JSON against the request model.
  3. If validation succeeds, FastAPI calls your endpoint with a typed Python object.
  4. Your endpoint awaits the application service.
  5. FastAPI validates and serializes the returned value into the declared response shape.

If step 2 fails, your service function is not called. FastAPI returns a 422 Unprocessable Entity response describing which field was invalid. That keeps malformed input at the boundary rather than letting it fail unpredictably in retrieval or model-provider code.

Read the official FastAPI guide’s “Create your data model,” “Declare it as a parameter,” and “Results” sections. It shows how a Pydantic model becomes a validated request body and generates API documentation automatically.

Request Body - FastAPI

Read FastAPI’s “Request Body” guide to connect Pydantic request models to endpoint parameters. Focus on the boundary behavior FastAPI provides before your handler runs.

In the sections “Create your data model,” “Declare it as a parameter,” and “Results,” read from model declaration through validation results. Notice that fields without defaults are required, while fields with a default value are optional.


Build a small typed AI-answer API

Create a project directory and virtual environment, then install FastAPI and Uvicorn:

python -m venv .venv

Activate the environment using the command appropriate for your shell, then run:

python -m pip install fastapi uvicorn

Create a file named main.py with the following application:

from uuid import UUID, uuid4

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

app = FastAPI(title="Support Answer API")


class AnswerRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    question: str = Field(
        min_length=1,
        max_length=4_000,
        description="The user's support question.",
    )
    conversation_id: UUID | None = None


class SourceCitation(BaseModel):
    document_id: UUID
    title: str
    page: int | None = Field(default=None, ge=1)


class AnswerResponse(BaseModel):
    request_id: UUID
    answer: str
    sources: list[SourceCitation]


async def answer_question_service(request: AnswerRequest) -> AnswerResponse:
    """
    Application-layer placeholder.

    In later lessons, this will prepare context, retrieve documents,
    call an LLM, and attach real source metadata.
    """
    return AnswerResponse(
        request_id=uuid4(),
        answer=(
            f"Support answer placeholder for: {request.question}"
        ),
        sources=[],
    )


@app.post("/v1/answers")
async def create_answer(request: AnswerRequest) -> AnswerResponse:
    return await answer_question_service(request)

This is compact code, but its boundaries are doing several important jobs.

The request model owns client-controlled input

AnswerRequest describes exactly what this endpoint accepts from a client:

class AnswerRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    question: str = Field(min_length=1, max_length=4_000)
    conversation_id: UUID | None = None

Here:

  • question is required, because it has no default value.
  • The Field constraints reject an empty string and constrain request size.
  • conversation_id is optional because it defaults to None.
  • UUID ensures the identifier has the expected format before your service uses it.
  • extra="forbid" rejects unknown fields rather than silently ignoring them.

The last choice is particularly useful for command-style endpoints. If a web client accidentally sends conversationId instead of conversation_id, silently ignoring it can create confusing behavior. Rejecting the malformed request makes the integration error visible quickly.

Do not place trusted identity, tenant identifiers, roles, or authorization flags in this public request model. In a production API, those values should come from authentication and authorization middleware, not from client-supplied JSON. You will build those boundaries in the security module.

The response model owns what the client may see

AnswerResponse is a separate contract:

class AnswerResponse(BaseModel):
    request_id: UUID
    answer: str
    sources: list[SourceCitation]

This tells a frontend developer exactly what a successful answer request returns. It also prepares the API for grounded answers: each source has only the metadata the client needs to display a citation, rather than an entire internal database record or raw retrieved chunk.

Notice the deliberate separation:

ModelDirectionPurpose
AnswerRequestClient to serverValidate client-provided question and optional conversation reference
AnswerResponseServer to clientPromise an answer, a traceable request ID, and display-safe citations
SourceCitationNested response dataDescribe a source without exposing document internals

A response model is not merely documentation. FastAPI uses the declared return type to validate the result, generate OpenAPI schema, serialize JSON, and filter output to the allowed shape. If the endpoint claims it returns AnswerResponse but your application returns an incompatible object, that represents a server-side bug—not a client mistake.


Keep the endpoint thin

The endpoint itself is intentionally small:

@app.post("/v1/answers")
async def create_answer(request: AnswerRequest) -> AnswerResponse:
    return await answer_question_service(request)

Each part has a separate responsibility:

  • @app.post("/v1/answers") registers the route and HTTP method.
  • request: AnswerRequest tells FastAPI to take this parameter from the JSON request body and validate it.
  • async def lets the endpoint await non-blocking work, such as the concurrent retrieval preparation from the previous lesson.
  • -> AnswerResponse declares the response contract.
  • answer_question_service() contains application behavior rather than HTTP-specific concerns.

This separation becomes valuable as the service grows. An endpoint should not contain provider SDK calls, SQL statements, prompt construction, and response transformation all in one function. Keeping it as an adapter between HTTP and application logic makes the service easier to test and to reuse from another interface, such as a background job or workflow.

For now, the application service returns a placeholder. Later, its internal shape will resemble:

async def answer_question_service(request: AnswerRequest) -> AnswerResponse:
    # 1. Load allowed conversational context and retrieve authorized evidence.
    # 2. Construct a prompt using that evidence.
    # 3. Await the model-provider call.
    # 4. Map the internal result to the public AnswerResponse contract.
    ...

The important boundary remains unchanged: validated AnswerRequest enters; typed AnswerResponse leaves.


Run and inspect the endpoint

Start the development server from the directory containing main.py:

uvicorn main:app --reload

The --reload option restarts the development server when you save code. It is convenient locally; production deployments use a more controlled process.

Send a valid request from a second terminal:

curl -X POST "http://127.0.0.1:8000/v1/answers" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "How do I reset my VPN access?"
  }'

The exact UUID will differ, but the response shape should look like this:

{
  "request_id": "2d3f44f8-9b57-4e42-9e83-61ce6ec60cbf",
  "answer": "Support answer placeholder for: How do I reset my VPN access?",
  "sources": []
}

Now send invalid input:

curl -X POST "http://127.0.0.1:8000/v1/answers" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "",
    "unexpected_option": true
  }'

FastAPI should return 422. Its error response identifies the invalid question and the unexpected field. Neither request reaches answer_question_service.

This distinction matters operationally:

SituationTypical resultMeaning
Malformed or invalid request422The client did not satisfy the request contract
Route does not exist404The client requested an unavailable path
Valid request, valid response200The endpoint fulfilled its contract
Service returns data incompatible with its response model500The server violated its own contract

Do not “fix” output-validation failures by weakening your response model. A 500 response in this case is useful: it prevents clients from receiving a response that contradicts the API contract.


Why a distinct response model protects data

It is common for internal objects to contain more data than an API is allowed to expose. For example, an internal user record may have password-related fields, provider request identifiers, raw model output, retrieval scores, or internal diagnostic details. Returning that object directly risks leaking information and makes the public API dependent on private implementation details.

FastAPI’s response model mechanism filters output to the declared shape. The official guide demonstrates this with a user input model that contains a password and an output model that omits it.

Response Model - Return Type

Read FastAPI’s response-model guide for the other half of the endpoint contract: validating, documenting, serializing, and filtering the data your API sends back.

First read the opening “Response Model - Return Type” discussion, especially the behavior of returned data. Then, in “Add an output model,” read the example where UserIn contains a password and UserOut does not. Focus on why an output model is a security boundary, not just a convenience.

When your endpoint returns precisely the declared Pydantic model, as create_answer() does, the return annotation is clear and gives your editor useful type checking.

Use the decorator form when the internal value legitimately has a different type from the public response schema:

from typing import Any

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class PublicUser(BaseModel):
    username: str
    email: str


@app.get("/users/{user_id}", response_model=PublicUser)
async def get_user(user_id: str) -> Any:
    internal_user = {
        "username": "mira",
        "email": "mira@example.com",
        "password_hash": "private",
        "provider_trace_id": "internal-only",
    }
    return internal_user

FastAPI documents and filters this endpoint as PublicUser, so password_hash and provider_trace_id are excluded from the successful response. In a strongly typed codebase, an even better pattern is often to map an internal domain object explicitly into PublicUser. The central rule is stable: the public model, not the internal object, defines the client-visible contract.


Use the generated OpenAPI documentation

Navigate to:

http://127.0.0.1:8000/docs

FastAPI generates Swagger UI from the same models and route declaration used at runtime. You should see POST /v1/answers, its request-body schema, expected 200 response model, and validation-error response.

The Swagger UI view shows how FastAPI exposes a POST endpoint’s request schema, example JSON body, and documented `200` and `422` responses from the application’s type declarations.

Use Try it out to submit a request directly in the browser. Check that the request body accepts question and optional conversation_id, while the response exposes request_id, answer, and sources.

The short FastAPI tutorial segment below demonstrates the same request-model, response-model, and generated-documentation workflow.

Python FastAPI Tutorial: Build a REST API in 15 Minutes

Watch pixegami’s “Python FastAPI Tutorial: Build a REST API in 15 Minutes” for a compact live demonstration of Pydantic-backed request bodies, response models, and Swagger UI.

Watch Pydantic schemas to see a structured JSON body replace a raw query parameter and to observe request and response models in use. Then watch interactive docs to see how /docs exposes and tests the generated API contract.


Key takeaways

A FastAPI endpoint turns a Python service into a reliable client-facing boundary.

  • A Pydantic request model validates JSON before application logic runs.
  • Fields without defaults are required; defaults make fields optional.
  • Constraints such as min_length, max_length, and UUID types stop many invalid inputs at the API boundary.
  • ConfigDict(extra="forbid") makes unrecognized client fields visible instead of silently discarding them.
  • An async endpoint can await the non-blocking service logic developed in the previous lesson.
  • A typed return annotation such as -> AnswerResponse defines the successful response contract.
  • Response models provide documentation, validation, serialization, and an important output-filtering boundary.
  • /docs exposes generated Swagger UI, allowing you to inspect and test the exact API contract.

Next, you will separate configuration from source code and load environment-specific settings without exposing API keys or other secrets.

Can't find a good explanation? Sign up and we'll make it for you

Sign up