Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ DATABASE_URL=postgresql+asyncpg://your_db_user:your_db_password@db:5432/your_db_

# SQLログ(開発時のみ true にする。本番は false のまま)
SQL_ECHO=false

# Supabase JWT検証(Supabase Dashboard > Settings > API > JWT Secret)
SUPABASE_JWT_SECRET=your_supabase_jwt_secret

# Google Places API キー
GOOGLE_PLACES_API_KEY=your_google_places_api_key
54 changes: 54 additions & 0 deletions app/config/dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.database import get_session
from app.repositories.user_repository import UserRepository
from app.repositories.trip_repository import TripRepository
from app.repositories.trip_member_repository import TripMemberRepository
from app.repositories.spot_repository import SpotRepository
from app.repositories.candidate_spot_repository import CandidateSpotRepository
from app.repositories.candidate_reaction_repository import CandidateReactionRepository
from app.repositories.itinerary_activity_repository import ItineraryActivityRepository
from app.usecases.user_usecase import UserUsecase
from app.usecases.trip_usecase import TripUsecase
from app.usecases.trip_member_usecase import TripMemberUsecase
from app.usecases.candidate_spot_usecase import CandidateSpotUsecase
from app.usecases.candidate_reaction_usecase import CandidateReactionUsecase
from app.usecases.itinerary_usecase import ItineraryUsecase


def get_user_usecase(db: AsyncSession = Depends(get_session)) -> UserUsecase:
return UserUsecase(UserRepository(db))


def get_trip_usecase(db: AsyncSession = Depends(get_session)) -> TripUsecase:
return TripUsecase(TripRepository(db), TripMemberRepository(db))


def get_trip_member_usecase(db: AsyncSession = Depends(get_session)) -> TripMemberUsecase:
return TripMemberUsecase(TripRepository(db), TripMemberRepository(db))


def get_candidate_spot_usecase(db: AsyncSession = Depends(get_session)) -> CandidateSpotUsecase:
return CandidateSpotUsecase(
CandidateSpotRepository(db),
SpotRepository(db),
TripMemberRepository(db),
)


def get_candidate_reaction_usecase(
db: AsyncSession = Depends(get_session),
) -> CandidateReactionUsecase:
return CandidateReactionUsecase(
CandidateReactionRepository(db),
CandidateSpotRepository(db),
TripMemberRepository(db),
)


def get_itinerary_usecase(db: AsyncSession = Depends(get_session)) -> ItineraryUsecase:
return ItineraryUsecase(
ItineraryActivityRepository(db),
TripRepository(db),
TripMemberRepository(db),
)
13 changes: 13 additions & 0 deletions app/config/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

DATABASE_URL: str
SQL_ECHO: bool = False
SUPABASE_JWT_SECRET: str
GOOGLE_PLACES_API_KEY: str = ""


settings = Settings()
23 changes: 23 additions & 0 deletions app/config/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import uuid
import jwt
from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.config.env import settings
from app.exceptions.app_exceptions import UnauthorizedException

_bearer = HTTPBearer()


async def get_current_user_id(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
) -> uuid.UUID:
try:
payload = jwt.decode(
credentials.credentials,
settings.SUPABASE_JWT_SECRET,
algorithms=["HS256"],
options={"verify_aud": False},
)
return uuid.UUID(payload["sub"])
except (jwt.InvalidTokenError, KeyError, ValueError):
raise UnauthorizedException()
Comment on lines +8 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and examine its content
find . -type f -name "jwt.py" -o -name "env.py" -o -name "dependency.py" | head -20

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 141


🏁 Script executed:

# List the app/config directory structure
ls -la app/config/ 2>/dev/null || echo "app/config/ not found"

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 366


🏁 Script executed:

# Check the git structure to understand the repo layout
git ls-files app/ | grep -E "(jwt|config|env)" | head -20

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 135


🏁 Script executed:

cat -n app/config/jwt.py

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 937


🏁 Script executed:

cat -n app/config/env.py

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 481


🏁 Script executed:

cat -n app/config/dependency.py

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 2629


🏁 Script executed:

find . -name ".env*" -o -name "env*example*" | head -10

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 89


🏁 Script executed:

# Check how HTTPBearer and jwt.decode are used in the codebase
rg "HTTPBearer\|verify_aud" -A 2 -B 2

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 57


🌐 Web query:

FastAPI HTTPBearer auto_error default behavior missing header

💡 Result:

By default, FastAPI's HTTPBearer (with auto_error=True, the default) raises an HTTPException when the Authorization header is missing or lacks a valid Bearer token/credentials. Prior to FastAPI 0.122.0 (released 2025-11-24), this was a 403 Forbidden with detail "Not authenticated". Since FastAPI 0.122.0, it raises a 401 Unauthorized with detail "Not authenticated", aligning with HTTP standards (RFC 6750) for missing authentication information. If auto_error=False, it returns None instead of raising an error, allowing manual handling (useful for optional auth). Official docs confirm: "By default, if the HTTP Bearer token is not provided (in an Authorization header), HTTPBearer will automatically cancel the request and send the client an error." Source code (current master): if no authorization/scheme/credentials and auto_error=True, raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated").

Citations:


🌐 Web query:

Supabase JWT claims aud iss requirements verification

💡 Result:

Supabase JWTs require specific aud (audience) and iss (issuer) claims that must be verified for authentication and authorization, particularly for Row Level Security (RLS) and API access. Required Claims: - iss: string, always present, format "https://.supabase.co/auth/v1". Must match the project's issuer URL. - aud: string or string[] (e.g., "authenticated", "anon", or array), identifies the intended recipient. Verification Requirements: When validating JWTs: 1. Verify iss matches your Supabase project's issuer (e.g., https://project-ref.supabase.co/auth/v1). 2. Validate aud matches expected values like "authenticated" (user tokens), "anon" (public), or "service_role" (admin). 3. Use Supabase's JWKS endpoint (https://.supabase.co/auth/v1/.well-known/jwks.json) for signature verification. 4. Check expiration (exp), issued at (iat), and other required claims. Supabase services automatically verify these claims. For custom validation, use libraries like supabase.auth.getClaims or language-specific JWT verifiers ensuring iss, aud, signature, and expiration. Note: aud can be a string or array; code must handle both.

Citations:


🏁 Script executed:

cat -n .env.example

Repository: Onzoro-rento/Travel-App-Backend

Length of output: 777


Supabase JWT の aud / iss クレーム検証を有効にしてください。

verify_aud=False により audience クレーム検証が無効化されており、署名が有効な別用途のトークン(例:anon スコープ)も受け入れる可能性があります。Supabase では JWT に必須の issaud クレームが含まれ、これらの検証はセキュリティ上必須です。

修正が必要な箇所

app/config/jwt.py: verify_aud=False を削除し、audienceissuer パラメータで Supabase の要件に合わせた検証を追加してください。HTTPBearer(auto_error=False) への変更と credentials is None チェックの追加により、エラーハンドリングを統一できます。

app/config/env.py: SUPABASE_JWT_ISSUERSUPABASE_JWT_AUDIENCE 設定を追加してください。

.env.example: 上記設定項目を追加し、値の例を示してください( SUPABASE_JWT_ISSUER=https://your-project-ref.supabase.co/auth/v1SUPABASE_JWT_AUDIENCE=authenticated など)。

参考: Supabase JWT Claims Reference, Supabase JWT verification

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 12-12: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 23-23: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/config/jwt.py` around lines 8 - 23, Remove the insecure
options={"verify_aud": False} from jwt.decode in get_current_user_id and instead
pass audience=settings.SUPABASE_JWT_AUDIENCE and
issuer=settings.SUPABASE_JWT_ISSUER to enable aud/iss checks (keep using
settings.SUPABASE_JWT_SECRET and algorithms=["HS256"]); change _bearer =
HTTPBearer() to _bearer = HTTPBearer(auto_error=False) and add a guard in
get_current_user_id to raise UnauthorizedException when credentials is None or
credentials.credentials is missing; also add SUPABASE_JWT_ISSUER and
SUPABASE_JWT_AUDIENCE to your settings (app/config/env.py) and .env.example with
example values so the new audience/issuer parameters can be populated.

15 changes: 3 additions & 12 deletions app/infrastructure/database.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import os
from typing import AsyncGenerator
from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from typing import AsyncGenerator
from app.config.env import settings

Base = declarative_base()

# 非同期用の asyncpg を指定
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://myuser:mypassword@db:5432/travel_app_db",
)

engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("SQL_ECHO", "false").lower() == "true",
)
engine = create_async_engine(settings.DATABASE_URL, echo=settings.SQL_ECHO)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)


Expand Down
31 changes: 12 additions & 19 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,28 @@
from contextlib import asynccontextmanager
from app.infrastructure.database import init_db, engine
import app.models # noqa: F401 — モデルを Base.metadata に登録するために必要
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from app.infrastructure.database import init_db, engine
import app.models # noqa: F401
from app.exceptions.app_exceptions import AppException
from app.exceptions.handlers import app_exception_handler, validation_exception_handler
from app.routers import users, trips, trip_members, candidates, reactions, itinerary


@asynccontextmanager
async def lifespan(app: FastAPI):
# ① 【起動時】お店を開ける前の準備(yield の前)
print("アプリケーションの起動処理を開始...")
await init_db() # ← DBのテーブルを作成したり、接続の準備をする

yield # ② 【営業中】ここでAPIサーバーが立ち上がり、ユーザーからのアクセスを受け付け始める!

# ③ 【終了時】サーバーを停止したときの片付け(yield の後)
print("シャットダウンします。DBの接続を閉じます...")
await init_db()
yield
await engine.dispose()
Comment on lines +13 to 15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

DB エンジン破棄を finally で保証してください。

lifespan 中に例外が流れると、yield 後の engine.dispose() が実行されない可能性があります。シャットダウン時の接続解放は finally に入れておく方が安全です。

修正案
 async def lifespan(app: FastAPI):
     await init_db()
-    yield
-    await engine.dispose()
+    try:
+        yield
+    finally:
+        await engine.dispose()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await init_db()
yield
await engine.dispose()
await init_db()
try:
yield
finally:
await engine.dispose()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/main.py` around lines 13 - 15, The DB engine disposal must be guaranteed
even if an exception occurs during lifespan; modify the lifespan/context manager
around init_db() and yield so that engine.dispose() is called from a finally
block. Concretely, in the function that currently calls await init_db(); yield;
await engine.dispose() (referencing init_db and engine.dispose), wrap the yield
inside try/finally: call await init_db() before the try, perform yield inside
the try, and call await engine.dispose() in the finally to ensure disposal on
errors or shutdown.



app = FastAPI(lifespan=lifespan)

app.add_exception_handler(AppException, app_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)


@app.get("/")
def read_root():
return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
app.include_router(users.router, prefix="/api/v1")
app.include_router(trips.router, prefix="/api/v1")
app.include_router(trip_members.router, prefix="/api/v1")
app.include_router(candidates.router, prefix="/api/v1")
app.include_router(reactions.router, prefix="/api/v1")
app.include_router(itinerary.router, prefix="/api/v1")
58 changes: 58 additions & 0 deletions app/routers/candidates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import uuid
from fastapi import APIRouter, Depends, Query, status
from app.config.jwt import get_current_user_id
from app.config.dependency import get_candidate_spot_usecase
from app.usecases.candidate_spot_usecase import CandidateSpotUsecase
from app.schemas.requests.candidate_spot import (
CandidateSpotCreateRequest,
CandidateSpotStatusUpdateRequest,
)
from app.schemas.responses.candidate_spot import CandidateSpotResponse, CandidateSpotListResponse

router = APIRouter(prefix="/trips", tags=["candidates"])


@router.post(
"/{trip_id}/candidates",
response_model=CandidateSpotResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_candidate(
trip_id: uuid.UUID,
request: CandidateSpotCreateRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateSpotUsecase = Depends(get_candidate_spot_usecase),
):
return await usecase.add(trip_id, current_user_id, request)


@router.get("/{trip_id}/candidates", response_model=CandidateSpotListResponse)
async def list_candidates(
trip_id: uuid.UUID,
candidate_status: str | None = Query(default=None,alias="status"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateSpotUsecase = Depends(get_candidate_spot_usecase),
):
items = await usecase.get_list(trip_id, current_user_id, candidate_status)
return {"data": items}


@router.patch("/{trip_id}/candidates/{candidate_id}", response_model=CandidateSpotResponse)
async def update_candidate_status(
trip_id: uuid.UUID,
candidate_id: uuid.UUID,
request: CandidateSpotStatusUpdateRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateSpotUsecase = Depends(get_candidate_spot_usecase),
):
return await usecase.update_status(trip_id, candidate_id, current_user_id, request)


@router.delete("/{trip_id}/candidates/{candidate_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_candidate(
trip_id: uuid.UUID,
candidate_id: uuid.UUID,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateSpotUsecase = Depends(get_candidate_spot_usecase),
):
await usecase.delete(trip_id, candidate_id, current_user_id)
68 changes: 68 additions & 0 deletions app/routers/itinerary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import uuid
from fastapi import APIRouter, Depends, status
from app.config.jwt import get_current_user_id
from app.config.dependency import get_itinerary_usecase
from app.usecases.itinerary_usecase import ItineraryUsecase
from app.schemas.requests.itinerary import (
ActivityCreateRequest,
ActivityUpdateRequest,
ActivityReorderRequest,
)
from app.schemas.responses.itinerary import ActivityResponse, ItineraryResponse, ReorderResponse

router = APIRouter(prefix="/trips", tags=["itinerary"])


@router.get("/{trip_id}/itinerary", response_model=ItineraryResponse)
async def get_itinerary(
trip_id: uuid.UUID,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: ItineraryUsecase = Depends(get_itinerary_usecase),
):
return await usecase.get_itinerary(trip_id, current_user_id)


@router.post(
"/{trip_id}/itinerary",
response_model=ActivityResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_activity(
trip_id: uuid.UUID,
request: ActivityCreateRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: ItineraryUsecase = Depends(get_itinerary_usecase),
):
return await usecase.add_activity(trip_id, current_user_id, request)


@router.patch("/{trip_id}/itinerary/{activity_id}", response_model=ActivityResponse)
async def update_activity(
trip_id: uuid.UUID,
activity_id: uuid.UUID,
request: ActivityUpdateRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: ItineraryUsecase = Depends(get_itinerary_usecase),
):
return await usecase.update_activity(trip_id, activity_id, current_user_id, request)


@router.delete("/{trip_id}/itinerary/{activity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_activity(
trip_id: uuid.UUID,
activity_id: uuid.UUID,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: ItineraryUsecase = Depends(get_itinerary_usecase),
):
await usecase.delete_activity(trip_id, activity_id, current_user_id)


@router.patch("/{trip_id}/itinerary/reorder", response_model=ReorderResponse)
async def reorder_activities(
trip_id: uuid.UUID,
request: ActivityReorderRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: ItineraryUsecase = Depends(get_itinerary_usecase),
):
updated_count = await usecase.reorder(trip_id, current_user_id, request)
return {"updated_count": updated_count}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
37 changes: 37 additions & 0 deletions app/routers/reactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import uuid
from fastapi import APIRouter, Depends, Query, status
from app.config.jwt import get_current_user_id
from app.config.dependency import get_candidate_reaction_usecase
from app.usecases.candidate_reaction_usecase import CandidateReactionUsecase
from app.schemas.requests.candidate_reaction import CandidateReactionUpsertRequest
from app.schemas.responses.candidate_reaction import CandidateReactionResponse

router = APIRouter(prefix="/trips", tags=["reactions"])


@router.put(
"/{trip_id}/candidates/{candidate_id}/reactions",
response_model=CandidateReactionResponse,
)
async def upsert_reaction(
trip_id: uuid.UUID,
candidate_id: uuid.UUID,
request: CandidateReactionUpsertRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateReactionUsecase = Depends(get_candidate_reaction_usecase),
):
return await usecase.upsert(trip_id, candidate_id, current_user_id, request)


@router.delete(
"/{trip_id}/candidates/{candidate_id}/reactions",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_reaction(
trip_id: uuid.UUID,
candidate_id: uuid.UUID,
emoji_type: str = Query(),
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: CandidateReactionUsecase = Depends(get_candidate_reaction_usecase),
):
await usecase.delete(trip_id, candidate_id, current_user_id, emoji_type)
40 changes: 40 additions & 0 deletions app/routers/trip_members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import uuid
from fastapi import APIRouter, Depends, status
from app.config.jwt import get_current_user_id
from app.config.dependency import get_trip_member_usecase
from app.usecases.trip_member_usecase import TripMemberUsecase
from app.schemas.requests.trip import TripJoinRequest
from app.schemas.requests.trip_member import TripMemberRoleUpdateRequest
from app.schemas.responses.trip_member import TripMemberResponse

router = APIRouter(prefix="/trips", tags=["trip_members"])


@router.post("/join", response_model=TripMemberResponse, status_code=status.HTTP_201_CREATED)
async def join_trip(
request: TripJoinRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: TripMemberUsecase = Depends(get_trip_member_usecase),
):
return await usecase.join(request.invite_code, current_user_id)


@router.patch("/{trip_id}/members/{user_id}", response_model=TripMemberResponse)
async def update_member_role(
trip_id: uuid.UUID,
user_id: uuid.UUID,
request: TripMemberRoleUpdateRequest,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: TripMemberUsecase = Depends(get_trip_member_usecase),
):
return await usecase.update_role(trip_id, user_id, current_user_id, request)


@router.delete("/{trip_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
trip_id: uuid.UUID,
user_id: uuid.UUID,
current_user_id: uuid.UUID = Depends(get_current_user_id),
usecase: TripMemberUsecase = Depends(get_trip_member_usecase),
):
await usecase.remove(trip_id, user_id, current_user_id)
Loading