Questo articolo mostra un percorso chiaro per esporre un singolo modello, Post
, tramite GraphQL in Python. Useremo FastAPI per il server HTTP e Strawberry per GraphQL. Copriremo schema, resolver, paginazione “page-based”, ordinamento per publishedAt
, validazione con Pydantic, autorizzazione tramite context e CORS sull'endpoint /graphql
.
Stack e struttura
- Server HTTP: FastAPI
- GraphQL: Strawberry
- Validazione: Pydantic
- Persistenza: repository (in-memory per l'esempio; facilmente sostituibile con SQL/NoSQL)
- CORS: middleware nativo di FastAPI
Schema GraphQL: tipo Post e operazioni
Uno schema essenziale con tipo Post
, wrapper di paginazione e operazioni CRUD.
"""Rappresenta un contenuto pubblicato."""
type Post {
id: ID!
title: String!
body: String!
publishedAt: String
createdAt: String!
updatedAt: String!
}
"""Metadati di paginazione (page-based)."""
type PaginatorInfo {
count: Int!
currentPage: Int!
lastPage: Int!
perPage: Int!
total: Int!
hasMorePages: Boolean!
}
"""Risultato paginato dei Post."""
type PostPaginator {
data: [Post!]!
paginatorInfo: PaginatorInfo!
}
type Query {
posts(page: Int = 1, perPage: Int = 10, sort: String = "desc"): PostPaginator!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!, publishedAt: String): Post!
updatePost(id: ID!, title: String, body: String, publishedAt: String): Post!
deletePost(id: ID!): Boolean!
}
Dominio e repository
Definiamo l'entità di dominio e l'interfaccia del repository. In seguito potrai sostituire l'implementazione con SQLAlchemy o un driver NoSQL.
# domain.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Protocol, Tuple, List, Dict, Any
@dataclass
class PostEntity:
id: str
title: str
body: str
published_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class PostRepository(Protocol):
def list(self, page: int, per_page: int, sort_asc: bool) -> Tuple[List[PostEntity], int]: ...
def find_by_id(self, post_id: str) -> Optional[PostEntity]: ...
def create(self, data: Dict[str, Any]) -> PostEntity: ...
def update(self, post_id: str, patch: Dict[str, Any]) -> PostEntity: ...
def delete(self, post_id: str) -> bool: ...
Tipi GraphQL con Strawberry
Mappiamo i campi Python in camelCase per l'API GraphQL.
# schema.py
from __future__ import annotations
from typing import List, Optional
import strawberry
@strawberry.type
class Post:
id: strawberry.ID
title: str
body: str
created_at: str = strawberry.field(name="createdAt")
updated_at: str = strawberry.field(name="updatedAt")
published_at: Optional[str] = strawberry.field(name="publishedAt", default=None)
@strawberry.type
class PaginatorInfo:
count: int
current_page: int = strawberry.field(name="currentPage")
last_page: int = strawberry.field(name="lastPage")
per_page: int = strawberry.field(name="perPage")
total: int
has_more_pages: bool = strawberry.field(name="hasMorePages")
@strawberry.type
class PostPaginator:
data: List[Post]
paginator_info: PaginatorInfo = strawberry.field(name="paginatorInfo")
Mapper: dal dominio al tipo GraphQL
# mappers.py
from __future__ import annotations
from datetime import datetime
from typing import Optional
from domain import PostEntity
from schema import Post
def _to_iso(dt: Optional[datetime]) -> Optional[str]:
if not dt:
return None
s = dt.astimezone().isoformat(timespec="seconds")
return s.replace("+00:00", "Z")
def map_post(entity: PostEntity) -> Post:
return Post(
id=entity.id,
title=entity.title,
body=entity.body,
created_at=_to_iso(entity.created_at) or "",
updated_at=_to_iso(entity.updated_at) or "",
published_at=_to_iso(entity.published_at),
)
Resolver: Query e Mutation
Resolver semplici: validano input, applicano l'autorizzazione tramite context, delegano al repository e costruiscono i metadati di paginazione.
# resolvers.py
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import strawberry
from pydantic import BaseModel, Field, field_validator
from strawberry.types import Info
from mappers import map_post
from schema import Post, PostPaginator, PaginatorInfo
class CreatePostModel(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(min_length=1)
publishedAt: Optional[str] = None
@field_validator("publishedAt")
@classmethod
def _validate_dt(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
datetime.fromisoformat(v.replace("Z", "+00:00"))
return v
class UpdatePostModel(BaseModel):
id: str
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
body: Optional[str] = Field(default=None, min_length=1)
publishedAt: Optional[str] = None
@field_validator("publishedAt")
@classmethod
def _validate_dt(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
datetime.fromisoformat(v.replace("Z", "+00:00"))
return v
def _require_user(info: Info) -> None:
if not info.context.get("user"):
raise PermissionError("Unauthorized")
@strawberry.type
class Query:
@strawberry.field
def posts(self, info: Info, page: int = 1, per_page: int = 10, sort: str = "desc") -> PostPaginator:
repo = info.context["repo"]
asc = str(sort).lower() == "asc"
items, total = repo.list(page=page, per_page=per_page, sort_asc=asc)
last_page = max(1, (total + per_page - 1) // per_page)
return PostPaginator(
data=[map_post(p) for p in items],
paginator_info=PaginatorInfo(
count=len(items),
current_page=page,
last_page=last_page,
per_page=per_page,
total=total,
has_more_pages=page < last_page,
),
)
@strawberry.field
def post(self, info: Info, id: strawberry.ID) -> Optional[Post]:
repo = info.context["repo"]
found = repo.find_by_id(str(id))
return map_post(found) if found else None
@strawberry.type
class Mutation:
@strawberry.field
def create_post(
self,
info: Info,
title: str,
body: str,
published_at: Optional[str] = strawberry.UNSET,
) -> Post:
_require_user(info)
payload = CreatePostModel(
title=title,
body=body,
publishedAt=published_at if published_at is not strawberry.UNSET else None,
)
now = datetime.now(timezone.utc)
pub = (
datetime.fromisoformat(payload.publishedAt.replace("Z", "+00:00"))
if payload.publishedAt
else None
)
created = info.context["repo"].create(
{
"title": payload.title,
"body": payload.body,
"published_at": pub,
"created_at": now,
"updated_at": now,
}
)
return map_post(created)
@strawberry.field
def update_post(
self,
info: Info,
id: strawberry.ID,
title: Optional[str] = None,
body: Optional[str] = None,
published_at: Optional[str] = None,
) -> Post:
_require_user(info)
payload = UpdatePostModel(
id=str(id), title=title, body=body, publishedAt=published_at
)
patch = {}
if payload.title is not None:
patch["title"] = payload.title
if payload.body is not None:
patch["body"] = payload.body
if payload.publishedAt is not None:
patch["published_at"] = (
datetime.fromisoformat(payload.publishedAt.replace("Z", "+00:00"))
if payload.publishedAt
else None
)
patch["updated_at"] = datetime.now(timezone.utc)
updated = info.context["repo"].update(payload.id, patch)
return map_post(updated)
@strawberry.field
def delete_post(self, info: Info, id: strawberry.ID) -> bool:
_require_user(info)
return info.context["repo"].delete(str(id))
Server FastAPI e CORS su /graphql
Montiamo il router GraphQL, abilitiamo CORS e passiamo nel context sia l'utente autenticato sia il repository.
# app.py
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from strawberry.fastapi import GraphQLRouter
import strawberry
from resolvers import Query, Mutation
schema = strawberry.Schema(query=Query, mutation=Mutation)
async def get_context(req: Request) -> dict:
token = req.headers.get("authorization", "")
user = {"id": "u1"} if token else None
repo = req.app.state.repo
return {"user": user, "repo": repo}
graphql_app = GraphQLRouter(schema, context_getter=get_context)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["authorization", "content-type"],
max_age=600,
)
app.include_router(graphql_app, prefix="/graphql")
@app.on_event("startup")
async def startup() -> None:
from repo_inmemory import InMemoryPostRepo
app.state.repo = InMemoryPostRepo()
Repository in-memory (sostituibile con DB)
Ordinamento per published_at
(con fallback) e paginazione per pagina.
# repo_inmemory.py
from __future__ import annotations
from datetime import datetime
from typing import List, Tuple, Dict, Any, Optional
import uuid
from domain import PostEntity, PostRepository
class InMemoryPostRepo(PostRepository): # type: ignore[misc]
def __init__(self) -> None:
self._rows: Dict[str, PostEntity] = {}
def list(self, page: int, per_page: int, sort_asc: bool) -> Tuple[List[PostEntity], int]:
rows = list(self._rows.values())
rows.sort(
key=lambda r: (r.published_at or datetime.min, r.id),
reverse=not sort_asc,
)
total = len(rows)
start = max(0, (page - 1) * per_page)
end = start + per_page
return rows[start:end], total
def find_by_id(self, post_id: str) -> Optional[PostEntity]:
return self._rows.get(post_id)
def create(self, data: Dict[str, Any]) -> PostEntity:
pid = str(uuid.uuid4())
entity = PostEntity(
id=pid,
title=data["title"],
body=data["body"],
published_at=data.get("published_at"),
created_at=data["created_at"],
updated_at=data["updated_at"],
)
self._rows[pid] = entity
return entity
def update(self, post_id: str, patch: Dict[str, Any]) -> PostEntity:
cur = self._rows.get(post_id)
if not cur:
raise KeyError("not found")
for k, v in patch.items():
setattr(cur, k, v)
self._rows[post_id] = cur
return cur
def delete(self, post_id: str) -> bool:
return self._rows.pop(post_id, None) is not None
Esempi dal punto di vista del client
Le query richiedono solo i campi necessari; le mutation ritornano l'oggetto aggiornato oppure un boolean per l'eliminazione (niente subselection su Boolean!
).
query {
posts(page: 1, perPage: 5, sort: "desc") {
data { id title publishedAt }
paginatorInfo { currentPage lastPage perPage total hasMorePages count }
}
}
query {
post(id: "123") { id title body publishedAt }
}
mutation {
createPost(title: "Titolo", body: "Testo", publishedAt: "2025-01-01T00:00:00Z") {
id title publishedAt
}
}
mutation {
updatePost(id: "123", title: "Nuovo titolo") { id title updatedAt }
}
mutation {
deletePost(id: "123")
}
Note su validazione, autorizzazione, CORS
- Validazione: Pydantic centralizza le regole (lunghezze, formati, date ISO 8601) e semplifica i messaggi di errore.
- Autorizzazione: risolvi l'identità a livello di middleware e passa l'utente nel context dei resolver.
- CORS: abilita il preflight
OPTIONS
, includiauthorization
negli header ammessi, elenca gli origin reali quando usi credenziali.
Conclusione
Con pochi componenti mirati si ottiene un'API GraphQL in Python focalizzata su Post
: schema pulito, resolver snelli, paginazione e ordinamento prevedibili, validazione e autorizzazione esplicite e CORS configurato per scenari reali. Questa base è facile da estendere ad altri modelli mantenendo coerenza e qualità del codice.