Skip to content

Commit 4450252

Browse files
ImTotemclaude
andcommitted
feat(graphql): migrate REST to Strawberry GraphQL
Phase 1-3 of REST → GraphQL migration: - Add strawberry-graphql[fastapi] dependency - GraphQL endpoint at /graphql with GraphiQL UI - GqlContext: JWT auth from cookie/header, DI via FastAPI Depends - AppErrorExtension: AppException + ValidationError → GraphQL errors Queries: members, member, memberFilters, tracks, me, links, link, linkFilters Mutations: createLink, updateLink, toggleLink, deleteLink Service layer unchanged — resolvers call existing services. REST endpoints kept alongside for gradual frontend migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4347ff commit 4450252

File tree

10 files changed

+521
-0
lines changed

10 files changed

+521
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"sqlalchemy>=2.0",
2121
"psycopg[binary]>=3.1",
2222
"alembic>=1.13",
23+
"strawberry-graphql[fastapi]>=0.235.0",
2324
]
2425

2526
[project.optional-dependencies]

src/bcsd_api/graphql/__init__.py

Whitespace-only changes.

src/bcsd_api/graphql/context.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from dataclasses import dataclass
2+
3+
from fastapi import Depends, Request
4+
from sqlalchemy import Connection
5+
6+
from bcsd_api.auth import token as jwt_token
7+
from bcsd_api.config import Settings
8+
from bcsd_api.dependencies import (
9+
get_conn,
10+
get_link_repo,
11+
get_member_repo,
12+
get_settings,
13+
)
14+
from bcsd_api.exception import Unauthorized
15+
from bcsd_api.member.pg_repository import PgMemberRepository
16+
from bcsd_api.shorten.pg_repository import PgLinkRepository
17+
18+
19+
@dataclass
20+
class GqlContext:
21+
conn: Connection
22+
member_repo: PgMemberRepository
23+
link_repo: PgLinkRepository
24+
user: dict | None
25+
26+
27+
def _try_auth(request: Request, settings: Settings) -> dict | None:
28+
header = request.headers.get("Authorization", "")
29+
if header.startswith("Bearer "):
30+
raw = header[7:]
31+
else:
32+
raw = request.cookies.get(settings.cookie_name, "")
33+
if not raw:
34+
return None
35+
try:
36+
return jwt_token.decode_token(
37+
raw, settings.jwt_secret, settings.jwt_algorithm,
38+
)
39+
except Exception:
40+
return None
41+
42+
43+
def require_user(ctx: GqlContext) -> dict:
44+
if not ctx.user:
45+
raise Unauthorized("authentication required")
46+
return ctx.user
47+
48+
49+
async def context_getter(
50+
request: Request,
51+
settings: Settings = Depends(get_settings),
52+
conn: Connection = Depends(get_conn),
53+
member_repo: PgMemberRepository = Depends(get_member_repo),
54+
link_repo: PgLinkRepository = Depends(get_link_repo),
55+
) -> GqlContext:
56+
user = _try_auth(request, settings)
57+
return GqlContext(
58+
conn=conn,
59+
member_repo=member_repo,
60+
link_repo=link_repo,
61+
user=user,
62+
)

src/bcsd_api/graphql/errors.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from graphql import GraphQLError
2+
from pydantic import ValidationError
3+
from strawberry.extensions import SchemaExtension
4+
5+
from bcsd_api.exception.base import AppException
6+
7+
8+
class AppErrorExtension(SchemaExtension):
9+
def on_operation(self):
10+
yield
11+
result = self.execution_context.result
12+
if not result:
13+
return
14+
if not result.errors:
15+
return
16+
result.errors = [_map_error(e) for e in result.errors]
17+
18+
19+
def _map_error(err: GraphQLError) -> GraphQLError:
20+
orig = err.original_error
21+
if isinstance(orig, AppException):
22+
return _from_app(orig)
23+
if isinstance(orig, ValidationError):
24+
return _from_validation(orig)
25+
return err
26+
27+
28+
def _from_app(exc: AppException) -> GraphQLError:
29+
return GraphQLError(
30+
message=exc.message,
31+
extensions={
32+
"error_code": exc.error_code,
33+
"status_code": exc.status_code,
34+
},
35+
)
36+
37+
38+
def _from_validation(exc: ValidationError) -> GraphQLError:
39+
return GraphQLError(
40+
message=str(exc),
41+
extensions={
42+
"error_code": "VALIDATION_ERROR",
43+
"status_code": 400,
44+
},
45+
)

src/bcsd_api/graphql/schema.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import strawberry
2+
3+
from bcsd_api.member import resolvers as member_resolvers
4+
from bcsd_api.member.types import (
5+
FiltersType,
6+
MeType,
7+
MemberDetailType,
8+
MemberFilterInput,
9+
PagedMembers,
10+
)
11+
from bcsd_api.shorten import resolvers as link_resolvers
12+
from bcsd_api.shorten.types import (
13+
CreateLinkInput,
14+
LinkDetailType,
15+
LinkFilterInput,
16+
LinkFiltersType,
17+
LinkType,
18+
PagedLinks,
19+
UpdateLinkInput,
20+
)
21+
22+
from .errors import AppErrorExtension
23+
24+
25+
@strawberry.type
26+
class Query:
27+
@strawberry.field
28+
def health(self) -> str:
29+
return "ok"
30+
31+
members: PagedMembers = strawberry.field(resolver=member_resolvers.resolve_members)
32+
member: MemberDetailType = strawberry.field(resolver=member_resolvers.resolve_member)
33+
member_filters: FiltersType = strawberry.field(resolver=member_resolvers.resolve_filters)
34+
tracks: list[str] = strawberry.field(resolver=member_resolvers.resolve_tracks)
35+
me: MeType = strawberry.field(resolver=member_resolvers.resolve_me)
36+
37+
links: PagedLinks = strawberry.field(resolver=link_resolvers.resolve_links)
38+
link: LinkDetailType = strawberry.field(resolver=link_resolvers.resolve_link)
39+
link_filters: LinkFiltersType = strawberry.field(resolver=link_resolvers.resolve_link_filters)
40+
41+
42+
@strawberry.type
43+
class Mutation:
44+
create_link: LinkType = strawberry.mutation(resolver=link_resolvers.resolve_create)
45+
update_link: LinkType = strawberry.mutation(resolver=link_resolvers.resolve_update)
46+
toggle_link: LinkType = strawberry.mutation(resolver=link_resolvers.resolve_toggle)
47+
delete_link: bool = strawberry.mutation(resolver=link_resolvers.resolve_delete)
48+
49+
50+
schema = strawberry.Schema(
51+
query=Query,
52+
mutation=Mutation,
53+
extensions=[AppErrorExtension],
54+
)

src/bcsd_api/main.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,20 @@ def create_app() -> FastAPI:
7575
app.include_router(track_router)
7676
app.include_router(shorten_router)
7777
app.include_router(redirect_router)
78+
_mount_graphql(app)
7879
return app
7980

8081

82+
def _mount_graphql(app: FastAPI) -> None:
83+
from strawberry.fastapi import GraphQLRouter
84+
85+
from .graphql.context import context_getter
86+
from .graphql.schema import schema
87+
88+
router = GraphQLRouter(schema, context_getter=context_getter)
89+
app.include_router(router, prefix="/graphql")
90+
91+
8192
app = create_app()
8293

8394

src/bcsd_api/member/resolvers.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from strawberry.types import Info
2+
3+
from bcsd_api.filter.members import MemberFilter
4+
from bcsd_api.graphql.context import GqlContext, require_user
5+
6+
from . import service
7+
from .schema import MemberDetail, MemberResponse
8+
from .types import (
9+
FiltersType,
10+
MeType,
11+
MemberDetailType,
12+
MemberFilterInput,
13+
MemberType,
14+
PagedMembers,
15+
)
16+
17+
18+
def _to_filter(inp: MemberFilterInput) -> MemberFilter:
19+
return MemberFilter(
20+
page=inp.page, size=inp.size,
21+
sort_by=inp.sort_by, sort_order=inp.sort_order,
22+
status=inp.status, track=inp.track,
23+
team=inp.team, payment_status=inp.payment_status,
24+
name=inp.name,
25+
)
26+
27+
28+
def _to_member(m: MemberResponse) -> MemberType:
29+
return MemberType(
30+
id=m.id, name=m.name, email=m.email,
31+
status=m.status, track=m.track,
32+
team=m.team, payment_status=m.payment_status,
33+
)
34+
35+
36+
def _to_detail(m: MemberDetail) -> MemberDetailType:
37+
return MemberDetailType(
38+
id=m.id, name=m.name, email=m.email,
39+
status=m.status, track=m.track,
40+
team=m.team, payment_status=m.payment_status,
41+
department=m.department, student_id=m.student_id,
42+
school_email=m.school_email, phone=m.phone,
43+
join_date=m.join_date, last_updated=m.last_updated,
44+
)
45+
46+
47+
def resolve_members(
48+
info: Info[GqlContext, None],
49+
filter: MemberFilterInput | None = None,
50+
) -> PagedMembers:
51+
ctx = info.context
52+
require_user(ctx)
53+
filt = _to_filter(filter) if filter else MemberFilter()
54+
paged = service.list_members(ctx.member_repo, filt)
55+
items = [_to_member(m) for m in paged.items]
56+
return PagedMembers(
57+
items=items, total=paged.total,
58+
page=paged.page, size=paged.size,
59+
)
60+
61+
62+
def resolve_member(info: Info[GqlContext, None], id: str) -> MemberDetailType:
63+
require_user(info.context)
64+
m = service.get_member(info.context.member_repo, id)
65+
return _to_detail(m)
66+
67+
68+
def resolve_filters(info: Info[GqlContext, None]) -> FiltersType:
69+
f = service.get_filters(info.context.conn)
70+
return FiltersType(
71+
tracks=f.tracks,
72+
statuses=f.statuses,
73+
payment_statuses=f.payment_statuses,
74+
)
75+
76+
77+
def resolve_tracks(info: Info[GqlContext, None]) -> list[str]:
78+
from sqlalchemy import select
79+
80+
from bcsd_api.tables import tracks
81+
82+
rows = info.context.conn.execute(select(tracks.c.name))
83+
return [row.name for row in rows]
84+
85+
86+
def resolve_me(info: Info[GqlContext, None]) -> MeType:
87+
user = require_user(info.context)
88+
return MeType(id=user["sub"], email=user["email"])

src/bcsd_api/member/types.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import strawberry
2+
3+
4+
@strawberry.type
5+
class MemberType:
6+
id: str
7+
name: str
8+
email: str
9+
status: str
10+
track: str
11+
team: str
12+
payment_status: str
13+
14+
15+
@strawberry.type
16+
class MemberDetailType(MemberType):
17+
department: str
18+
student_id: str
19+
school_email: str
20+
phone: str
21+
join_date: str
22+
last_updated: str
23+
24+
25+
@strawberry.type
26+
class FiltersType:
27+
tracks: list[str]
28+
statuses: list[str]
29+
payment_statuses: list[str]
30+
31+
32+
@strawberry.type
33+
class MeType:
34+
id: str
35+
email: str
36+
37+
38+
@strawberry.type
39+
class PagedMembers:
40+
items: list[MemberType]
41+
total: int
42+
page: int
43+
size: int
44+
45+
46+
@strawberry.input
47+
class MemberFilterInput:
48+
page: int = 1
49+
size: int = 20
50+
sort_by: str = "id"
51+
sort_order: str = "asc"
52+
status: str | None = None
53+
track: str | None = None
54+
team: str | None = None
55+
payment_status: str | None = None
56+
name: str | None = None

0 commit comments

Comments
 (0)