Skip to content

Commit 5a9b290

Browse files
authored
refactor: api standardization and auth hardening (#39)
* refactor(api): standardize CRUD patterns and error handling for events, fields, and tags * feat(auth): implement dynamic redirect URIs and harden OAuth state with nonces
1 parent 92387b4 commit 5a9b290

12 files changed

Lines changed: 82 additions & 35 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ DATABASE_URL=postgresql+psycopg2://evsy:evsy@db:5432/evsy
1515
# For local dev use http://localhost:3000
1616
FRONTEND_URL=http://localhost:3000
1717

18+
# Public URL of the backend (used for building OAuth callback URLs)
19+
# For local dev use http://localhost:8000
20+
# IMPORTANT: In production, this must match the actual backend domain.
21+
BACKEND_URL=http://localhost:8000
22+
1823

1924
# ========================
2025
# Frontend Configuration

backend/app/api/v1/routes/fields.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@
1919
description="Create a new field that can be associated with events.",
2020
responses={
2121
201: {"description": "Field created successfully"},
22-
400: {"description": "Validation error"},
22+
400: {"description": "Validation error or field already exists"},
2323
},
2424
)
2525
def create_field_route(field: FieldCreate, db: Session = Depends(get_db)):
26-
return field_crud.create_field(db=db, field=field)
26+
try:
27+
return field_crud.create_field(db=db, field=field)
28+
except ValueError as e:
29+
raise HTTPException(
30+
status_code=status.HTTP_400_BAD_REQUEST,
31+
detail={
32+
"code": "duplicate_resource",
33+
"message": str(e),
34+
},
35+
) from e
2736

2837

2938
@router.get(
@@ -79,22 +88,31 @@ def get_field_route(
7988
responses={
8089
200: {"description": "Field updated successfully"},
8190
404: {"description": "Field not found"},
82-
400: {"description": "Validation error"},
91+
400: {"description": "Validation error or field already exists"},
8392
},
8493
)
8594
def update_field_route(
8695
field_id: int, field: FieldCreate, db: Session = Depends(get_db)
8796
):
88-
db_field = field_crud.update_field(db=db, field_id=field_id, field=field)
89-
if db_field is None:
97+
try:
98+
db_field = field_crud.update_field(db=db, field_id=field_id, field=field)
99+
if db_field is None:
100+
raise HTTPException(
101+
status_code=status.HTTP_404_NOT_FOUND,
102+
detail={
103+
"code": "resource_not_found",
104+
"message": f"Field with id {field_id} not found",
105+
},
106+
)
107+
return db_field
108+
except ValueError as e:
90109
raise HTTPException(
91-
status_code=status.HTTP_404_NOT_FOUND,
110+
status_code=status.HTTP_400_BAD_REQUEST,
92111
detail={
93-
"code": "resource_not_found",
94-
"message": f"Field with id {field_id} not found",
112+
"code": "duplicate_resource",
113+
"message": str(e),
95114
},
96-
)
97-
return db_field
115+
) from e
98116

99117

100118
@router.delete(

backend/app/api/v1/routes/tags.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@
1919
description="Create a tag manually. Typically, tags are created automatically when creating or updating an event.",
2020
responses={
2121
201: {"description": "Tag created successfully"},
22-
400: {"description": "Validation error"},
22+
400: {"description": "Validation error or tag already exists"},
2323
},
2424
)
2525
def create_tag_route(tag: TagCreate, db: Session = Depends(get_db)):
26-
return tag_crud.create_tag(db=db, tag=tag)
26+
try:
27+
return tag_crud.create_tag(db=db, tag=tag)
28+
except ValueError as e:
29+
raise HTTPException(
30+
status_code=status.HTTP_400_BAD_REQUEST,
31+
detail={
32+
"code": "duplicate_resource",
33+
"message": str(e),
34+
},
35+
) from e
2736

2837

2938
@router.get(

backend/app/modules/auth/oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,5 @@ def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str:
129129

130130

131131
def get_email_from_oauth(login: OAuthLogin) -> str:
132-
redirect_uri = "http://localhost:8000/api/v1/auth/oauth/callback"
132+
redirect_uri = f"{settings.backend_url}/api/v1/auth/oauth/callback"
133133
return exchange_code_for_email(login.provider, login.token, redirect_uri)

backend/app/modules/auth/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import json
3+
import secrets
34

45
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
56
from fastapi.responses import RedirectResponse
@@ -143,6 +144,7 @@ def start_oauth_login(
143144
state_payload = {
144145
"provider": provider,
145146
"redirect": redirect,
147+
"nonce": secrets.token_urlsafe(16),
146148
}
147149
state = base64.urlsafe_b64encode(json.dumps(state_payload).encode()).decode()
148150

backend/app/modules/auth/schemas.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Annotated, Literal, Optional
22

3-
from pydantic import BaseModel, ConfigDict, EmailStr, StringConstraints
3+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints
44

55
PasswordStr = Annotated[str, StringConstraints(min_length=6)]
66
ProviderName = Literal["github", "google"]
@@ -21,7 +21,10 @@ class UserLogin(UserBase):
2121

2222
class OAuthLogin(BaseModel):
2323
provider: ProviderName
24-
token: str
24+
token: str = Field(
25+
...,
26+
description="The authorization code received from the OAuth provider callback.",
27+
)
2528

2629

2730
class UserOut(UserBase):

backend/app/modules/events/crud.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,6 @@ def create_event(db: Session, event: schemas.EventCreate):
2424
return db_event
2525

2626

27-
def get_event(db: Session, event_id: int):
28-
return db.query(models.Event).filter(models.Event.id == event_id).first()
29-
30-
3127
def get_events(db: Session):
3228
return (
3329
db.query(models.Event)
@@ -40,6 +36,10 @@ def get_events(db: Session):
4036
)
4137

4238

39+
def get_event(db: Session, event_id: int):
40+
return db.query(models.Event).filter(models.Event.id == event_id).first()
41+
42+
4343
def update_event(db: Session, event_id: int, event: schemas.EventCreate):
4444
db_event = db.query(models.Event).filter(models.Event.id == event_id).first()
4545
if db_event is None:

backend/app/modules/events/schemas.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,8 @@ class EventCreate(EventBase):
4242
pass
4343

4444

45-
class EventOut(BaseModel):
45+
class EventOut(EventBase):
4646
id: int
47-
name: str
48-
description: Optional[str] = None
49-
links: Optional[List[EventLink]] = None
5047
tags: list[TagOut]
5148
fields: list[FieldOut]
5249
created_at: datetime

backend/app/modules/fields/crud.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from fastapi import HTTPException
21
from sqlalchemy import func
32
from sqlalchemy.exc import IntegrityError
43
from sqlalchemy.orm import Session
@@ -19,9 +18,7 @@ def create_field(db: Session, field: schemas.FieldCreate):
1918
return db_field
2019
except IntegrityError:
2120
db.rollback()
22-
raise HTTPException(
23-
status_code=400, detail=f"Field with name {field.name!r} already exists."
24-
) from None
21+
raise ValueError(f"Field with name {field.name!r} already exists.") from None
2522

2623

2724
def get_fields(db: Session):
@@ -38,9 +35,15 @@ def update_field(db: Session, field_id: int, field: schemas.FieldCreate):
3835
db_field.name = field.name
3936
db_field.description = field.description
4037
db_field.field_type = field.field_type
41-
db.commit()
42-
db.refresh(db_field)
43-
return db_field
38+
try:
39+
db.commit()
40+
db.refresh(db_field)
41+
return db_field
42+
except IntegrityError:
43+
db.rollback()
44+
raise ValueError(
45+
f"Field with name {field.name!r} already exists."
46+
) from None
4447
return None
4548

4649

backend/app/modules/tags/crud.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from sqlalchemy.exc import IntegrityError
12
from sqlalchemy.orm import Session
23

34
from app.shared.models import EventTag
@@ -8,9 +9,13 @@
89
def create_tag(db: Session, tag: schemas.TagCreate):
910
db_tag = models.Tag(id=tag.id, description=tag.description)
1011
db.add(db_tag)
11-
db.commit()
12-
db.refresh(db_tag)
13-
return db_tag
12+
try:
13+
db.commit()
14+
db.refresh(db_tag)
15+
return db_tag
16+
except IntegrityError:
17+
db.rollback()
18+
raise ValueError(f"Tag with id {tag.id!r} already exists.") from None
1419

1520

1621
def get_tags(db: Session):

0 commit comments

Comments
 (0)