GraphQL API + OpenAPI
Run two API styles on one app. A single FastAPI process serves:
- a REST side at
/v1/taskswith OpenAPI/Swagger UI generated for free at/docs, and - a GraphQL side at
/graphql(with GraphiQL) built code-first with Strawberry — queries, a mutation, and aDataLoaderthat batches a nested lookup to dodge the N+1 problem.
By the end you’ll have felt both contracts against the same Task domain: the fixed-shape, cacheable REST resource, and the client-shaped GraphQL graph.
What you’ll practice
Section titled “What you’ll practice”- Defining GraphQL types code-first from Python type hints (
@strawberry.type), the inverse of the schema-first.graphqlworkflow in Apollo /gqlgen. - Writing async query and mutation resolvers.
- Killing the N+1 problem with a per-request
strawberry.dataloader.DataLoader. - Mounting Strawberry’s
GraphQLRouteron FastAPI alongside REST routes whose OpenAPI schema comes from nothing but type hints.
Requirements
Section titled “Requirements”- A
uvproject withfastapi,uvicorn,strawberry-graphql. - REST:
GET /v1/tasks,GET /v1/tasks/{id}, served with auto OpenAPI at/docs. - GraphQL at
/graphql: atasksquery, atask(id)query, acreateTaskmutation, and anassigneefield onTaskresolved through aDataLoader. - An in-memory store so the focus stays on the API surface, not a database.
The worked solution
Section titled “The worked solution”One small uv project. The REST router and the GraphQL schema share the same
service layer, so you can compare the two styles fetching identical data.
Directorygraphql-api/
- pyproject.toml uv deps + project config
Directoryapp/
- init .py
- main.py FastAPI app, mounts REST + GraphQL
- service.py in-memory task & user stores
- rest.py REST router (auto OpenAPI)
- graphql_api.py Strawberry types, resolvers, DataLoader
pyproject.toml
Section titled “pyproject.toml”[project]name = "graphql-api"version = "0.1.0"requires-python = ">=3.13"dependencies = [ "fastapi>=0.115", "uvicorn>=0.34", "strawberry-graphql>=0.250",]
[dependency-groups]dev = ["ruff", "ty"]You’d create this with:
uv init graphql-api && cd graphql-apiuv add fastapi uvicorn strawberry-graphqluv add --dev ruff tyservice.py — the shared domain
Section titled “service.py — the shared domain”Deliberately boring: dict-backed stores so the lesson is the API, not persistence.
The key detail is users_by_ids — a batch lookup that the DataLoader will call
once per request instead of once per task.
from dataclasses import dataclass
@dataclassclass Task: id: int title: str status: str assignee_id: int | None
@dataclassclass User: id: int name: str
_USERS: dict[int, User] = { 1: User(1, "Alice"), 2: User(2, "Bob"),}
_TASKS: dict[int, Task] = { 1: Task(1, "Ship v2", "open", 1), 2: Task(2, "Write docs", "open", 2), 3: Task(3, "Triage bugs", "done", 1),}_next_id = 4
def list_tasks(status: str | None = None, limit: int = 20) -> list[Task]: rows = [t for t in _TASKS.values() if status is None or t.status == status] return rows[:limit]
def get_task(task_id: int) -> Task | None: return _TASKS.get(task_id)
def create_task(title: str, assignee_id: int | None) -> Task: global _next_id task = Task(_next_id, title, "open", assignee_id) _TASKS[_next_id] = task _next_id += 1 return task
def users_by_ids(ids: list[int]) -> list[User]: """Batch lookup — one call resolves many user keys. The DataLoader uses this.""" return [_USERS[i] for i in ids if i in _USERS]rest.py — the REST side (free OpenAPI)
Section titled “rest.py — the REST side (free OpenAPI)”A plain APIRouter. The Pydantic response models and type hints are all FastAPI
needs to generate the OpenAPI 3.1 schema and the Swagger UI — no annotations, no spec
file. response_model doubles as an output filter.
from fastapi import APIRouter, HTTPExceptionfrom pydantic import BaseModel
from app import service
router = APIRouter(prefix="/v1/tasks", tags=["tasks"])
class TaskOut(BaseModel): id: int title: str status: str assignee_id: int | None
@router.get("", summary="List tasks")async def list_tasks(status: str | None = None, limit: int = 20) -> list[TaskOut]: """List tasks, optionally filtered by status. Auto-documented at /docs.""" return [TaskOut(**vars(t)) for t in service.list_tasks(status, limit)]
@router.get("/{task_id}", summary="Get a task by ID")async def get_task(task_id: int) -> TaskOut: task = service.get_task(task_id) if task is None: raise HTTPException(404, "task not found") return TaskOut(**vars(task))graphql_api.py — the Strawberry side
Section titled “graphql_api.py — the Strawberry side”Code-first: each @strawberry.type class is a GraphQL type, derived from the
annotations. The assignee field on Task is a resolver that defers to a
DataLoader, so requesting assignee across a list of tasks fires one batched
user lookup, not one per task.
import strawberryfrom strawberry.dataloader import DataLoaderfrom strawberry.fastapi import GraphQLRouter
from app import service
# --- DataLoader batch function: one call resolves many keys, in key order ---async def load_users(keys: list[int]) -> list["User | None"]: rows = service.users_by_ids(keys) # the single batched lookup by_id = {u.id: User(id=u.id, name=u.name) for u in rows} return [by_id.get(k) for k in keys] # MUST match length + order of keys
@strawberry.typeclass User: id: int name: str
@strawberry.typeclass Task: id: int title: str status: str assignee_id: int | None
@strawberry.field async def assignee(self, info: strawberry.Info) -> User | None: if self.assignee_id is None: return None # batched + cached for the lifetime of this request return await info.context["user_loader"].load(self.assignee_id)
@strawberry.inputclass CreateTaskInput: title: str assignee_id: int | None = None
def _to_gql(t: service.Task) -> Task: return Task(id=t.id, title=t.title, status=t.status, assignee_id=t.assignee_id)
@strawberry.typeclass Query: @strawberry.field async def tasks(self, status: str | None = None, limit: int = 20) -> list[Task]: return [_to_gql(t) for t in service.list_tasks(status, limit)]
@strawberry.field async def task(self, id: int) -> Task | None: t = service.get_task(id) return _to_gql(t) if t else None
@strawberry.typeclass Mutation: @strawberry.mutation async def create_task(self, input: CreateTaskInput) -> Task: return _to_gql(service.create_task(input.title, input.assignee_id))
schema = strawberry.Schema(query=Query, mutation=Mutation)
# Fresh DataLoader per request — never a module-level singleton (it caches).async def get_context() -> dict[str, object]: return {"user_loader": DataLoader(load_fn=load_users)}
graphql_router: GraphQLRouter = GraphQLRouter(schema, context_getter=get_context)main.py — both styles, one app
Section titled “main.py — both styles, one app”GraphQLRouter is an APIRouter, so REST and GraphQL just include_router onto the
same FastAPI(). REST gets /docs; GraphQL gets GraphiQL at /graphql.
from fastapi import FastAPI
from app.graphql_api import graphql_routerfrom app.rest import router as rest_router
app = FastAPI( title="Task API", version="1.0.0", description="One app, two styles: REST (auto OpenAPI) + GraphQL (Strawberry).",)
app.include_router(rest_router) # REST + OpenAPI at /docsapp.include_router(graphql_router, prefix="/graphql") # GraphQL + GraphiQL
@app.get("/")async def root() -> dict[str, str]: return {"rest_docs": "/docs", "graphql": "/graphql"}Run it
Section titled “Run it”-
Install and start the dev server:
Terminal window uv run fastapi dev app/main.py -
Open the REST docs — Swagger UI, generated entirely from the type hints:
http://localhost:8000/docs -
Open GraphiQL and run a query that exercises the DataLoader (note
assigneeon every task resolves through a single batched lookup):http://localhost:8000/graphql{tasks(status: "open") {idtitlestatusassignee { id name }}} -
Run the mutation (GraphQL enums/inputs are unquoted; here
assigneeIdis a plain int):mutation {createTask(input: { title: "Review PR", assigneeId: 2 }) {idtitleassignee { name }}} -
Hit the same data over REST to feel the contrast — fixed shape, no field picking:
Terminal window curl http://localhost:8000/v1/tasks/1curl "http://localhost:8000/v1/tasks?status=open&limit=10" -
Lint and type-check:
Terminal window uv run ruff check .uv run ty check . # mypy works here too, if you prefer the mature option