4. Endpoints that take input
Your FastAPI app from section 3 has two endpoints, but neither one takes input. This page teaches main.py to do real work: read input from the URL path, read input from the query string, read input from the request body, and validate every shape with Pydantic before the route's code runs.
Pick up the main.py from section 3. The sections that follow extend it one parameter style at a time, then introduce Pydantic models for request bodies and responses.
Path parameters
Path parameters extract values from the URL path. They're perfect for resource IDs: /articles/123 has article ID 123 as a path parameter.
# Add to your existing imports:
from fastapi import HTTPException
# Simulated database
articles_db = {
1: {"id": 1, "title": "FastAPI Tutorial", "source": "internal"},
2: {"id": 2, "title": "REST API Design", "source": "internal"},
3: {"id": 3, "title": "PostgreSQL Basics", "source": "internal"}
}
@app.get("/articles/{article_id}")
def get_article(article_id: int):
if article_id not in articles_db:
raise HTTPException(status_code=404, detail="Article not found")
return articles_db[article_id]
The curly braces in /articles/{article_id} mark a path parameter; whatever appears in that position of the URL is extracted and passed to the function. The article_id: int annotation tells FastAPI to coerce the captured string to an integer before the function runs, and to return a 422 if that conversion fails, so /articles/abc never reaches the function body. When the ID parses but the article doesn't exist, HTTPException short-circuits with a proper HTTP status and a JSON detail field, rather than returning a 200 with an error message tucked inside.
Test the endpoint:
curl http://localhost:8000/articles/1
# Response: {"id":1,"title":"FastAPI Tutorial","source":"internal"}
curl http://localhost:8000/articles/999
# Response: {"detail":"Article not found"}
curl http://localhost:8000/articles/abc
# Response: {"detail":[{"type":"int_parsing","loc":["path","article_id"],"msg":"Input should be a valid integer..."}]}
Notice FastAPI's validation: requesting /articles/abc returns a detailed error message explaining that article_id must be an integer. You didn't write validation code, FastAPI generated it from the type hint.
Query parameters
Query parameters provide optional filtering and configuration. They appear after the ? in URLs: /articles?category=tech&limit=10.
# Add to your existing imports:
from fastapi import Query
@app.get("/articles")
def list_articles(
category: str | None = None,
source: str | None = None,
limit: int = Query(20, ge=1, le=100)
):
# Start with all articles
results = list(articles_db.values())
# Apply filters if provided
if category:
results = [a for a in results if a.get("category") == category]
if source:
results = [a for a in results if a.get("source") == source]
# Apply limit
results = results[:limit]
return {
"articles": results,
"count": len(results),
"filters": {"category": category, "source": source, "limit": limit}
}
str | None = None makes category and source optional. If the URL doesn't include the parameter, FastAPI passes None and the corresponding filter is skipped. Query(20, ge=1, le=100) applies the contract from section 3: omit ?limit= and 20 arrives, request less than 1 or more than 100 and FastAPI returns a 422 before the route body runs. The body then applies whichever filters were actually provided and slices the result to the requested limit.
Test with various query combinations:
# All articles
curl http://localhost:8000/articles
# Filter by source
curl "http://localhost:8000/articles?source=internal"
# Combine filters
curl "http://localhost:8000/articles?source=internal&limit=2"
FastAPI automatically validates query parameter types. If you request ?limit=abc, you get a validation error explaining that limit must be an integer.
The same validation rejects ?limit=0 and ?limit=101, so the implemented endpoint now matches the maximum-results promise in the interface sketch.
Request and response models with Pydantic
Pydantic models define the shape of request bodies and responses. They provide automatic validation, type conversion, and documentation generation. This is FastAPI's superpower: type-safe APIs without manual validation code.
# Add to your existing imports:
from pydantic import BaseModel, Field
from datetime import datetime, timezone
class ArticleCreate(BaseModel):
"""Schema for creating new articles."""
title: str = Field(..., min_length=1, max_length=500)
description: str
url: str
source: str
category: str
class ArticleResponse(BaseModel):
"""Schema for article responses."""
id: int
title: str
description: str
url: str
source: str
category: str
published_at: datetime
created_at: datetime
@app.post("/articles", response_model=ArticleResponse, status_code=201)
def create_article(article: ArticleCreate):
# Generate ID (in real app, database does this)
new_id = max(articles_db.keys()) + 1 if articles_db else 1
# Create article with timestamps
new_article = {
"id": new_id,
**article.model_dump(),
"published_at": datetime.now(timezone.utc),
"created_at": datetime.now(timezone.utc)
}
articles_db[new_id] = new_article
return new_article
Two models do the talking. ArticleCreate describes the shape clients send in: Field(..., min_length=1, max_length=500) makes title required and bounds its length, and the rest of the fields are required strings simply by being declared. ArticleResponse describes what the API hands back, adding the id and the two timestamps that the server, not the client, owns.
Wiring the models to the endpoint is one line of work. response_model=ArticleResponse validates the return value against the response schema and feeds the same schema into /docs, and status_code=201 returns Created instead of FastAPI's default 200. The article: ArticleCreate parameter is the entire request-body contract: FastAPI parses the JSON body, validates it against the model, returns a 422 with field-specific detail if anything's off, and only then calls the function with a fully-typed article object.
Test creating an article:
# Valid request
curl -X POST http://localhost:8000/articles \
-H "Content-Type: application/json" \
-d '{
"title": "New FastAPI Article",
"description": "Learn FastAPI quickly",
"url": "https://example.com/fastapi",
"source": "internal",
"category": "technology"
}'
# Response: {"id":4,"title":"New FastAPI Article",...}
# Invalid request (missing required field)
curl -X POST http://localhost:8000/articles \
-H "Content-Type: application/json" \
-d '{"title": "Incomplete Article"}'
# Response: {"detail":[{"type":"missing","loc":["body","description"],"msg":"Field required"}]}
The invalid request returns a clear error: the description field is required but missing. FastAPI generated that check from the Pydantic model; no validation code was written by hand.
Pydantic models also serve as the documentation: ArticleCreate appears in /docs with its field types and constraints, so a developer trying to call POST /articles can see the expected shape without reading the source. The model is both validator and contract.
Note on the POST endpoint. The news aggregator you build in the rest of this chapter serves articles fetched from NewsAPI and the Guardian, not articles posted by clients. POST /articles appears here as the simplest way to introduce Pydantic request models; section 5 removes it when the database replaces the in-memory store.
Next, in section 5, you will wire SQLAlchemy into the same app via a get_db dependency and replace the in-memory article store with a real database.