From 0ddc168a32c7bf8a9a63a94ec7287efc48341e0d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 12 Dec 2025 20:51:04 -0500 Subject: [PATCH 1/5] checkpoint --- backend/alembic/env.py | 6 +-- backend/{src => app}/__init__.py | 0 backend/{src => app}/__main__.py | 6 +-- backend/app/admin/__init__.py | 0 .../routes/admin.py => app/admin/routes.py} | 14 +++--- .../{src/models => app/auth}/RefreshToken.py | 6 +-- backend/app/auth/__init__.py | 0 .../auth/repository.py} | 6 +-- .../routes/auth.py => app/auth/routes.py} | 22 +++++----- .../schemas/auth.py => app/auth/schemas.py} | 8 ++-- .../services/auth.py => app/auth/service.py} | 16 +++---- backend/{src => app}/config.py | 4 +- backend/{src/models => app/core}/Base.py | 0 backend/{src => app}/core/__init__.py | 0 .../base.py => app/core/base_repository.py} | 2 +- .../base.py => app/core/base_schema.py} | 0 .../common.py => app/core/common_schemas.py} | 4 +- backend/{src => app}/core/constants.py | 0 backend/{src => app}/core/database.py | 2 +- backend/{src => app}/core/dependencies.py | 12 +++--- backend/{src => app}/core/enums.py | 0 .../errors.py => app/core/error_schemas.py} | 2 +- backend/{src => app}/core/exceptions.py | 0 .../health.py => app/core/health_routes.py} | 6 +-- backend/{src => app}/core/logging.py | 2 +- backend/{src => app}/core/rate_limit.py | 2 +- backend/{src => app}/core/responses.py | 2 +- backend/{src => app}/core/security.py | 2 +- backend/{src => app}/factory.py | 16 +++---- backend/{src => app}/middleware/__init__.py | 2 +- .../{src => app}/middleware/correlation.py | 0 backend/{src/models => app/user}/User.py | 6 +-- backend/app/user/__init__.py | 0 .../user.py => app/user/repository.py} | 6 +-- .../routes/user.py => app/user/routes.py} | 10 ++--- .../schemas/user.py => app/user/schemas.py} | 6 +-- .../services/user.py => app/user/service.py} | 12 +++--- backend/src/models/__init__.py | 23 ---------- backend/src/repositories/__init__.py | 15 ------- backend/src/routes/__init__.py | 17 -------- backend/src/schemas/__init__.py | 43 ------------------- backend/src/services/__init__.py | 13 ------ 42 files changed, 91 insertions(+), 202 deletions(-) rename backend/{src => app}/__init__.py (100%) rename backend/{src => app}/__main__.py (70%) create mode 100644 backend/app/admin/__init__.py rename backend/{src/routes/admin.py => app/admin/routes.py} (92%) rename backend/{src/models => app/auth}/RefreshToken.py (96%) create mode 100644 backend/app/auth/__init__.py rename backend/{src/repositories/refresh_token.py => app/auth/repository.py} (97%) rename backend/{src/routes/auth.py => app/auth/routes.py} (88%) rename backend/{src/schemas/auth.py => app/auth/schemas.py} (92%) rename backend/{src/services/auth.py => app/auth/service.py} (94%) rename backend/{src => app}/config.py (98%) rename backend/{src/models => app/core}/Base.py (100%) rename backend/{src => app}/core/__init__.py (100%) rename backend/{src/repositories/base.py => app/core/base_repository.py} (98%) rename backend/{src/schemas/base.py => app/core/base_schema.py} (100%) rename backend/{src/schemas/common.py => app/core/common_schemas.py} (87%) rename backend/{src => app}/core/constants.py (100%) rename backend/{src => app}/core/database.py (99%) rename backend/{src => app}/core/dependencies.py (93%) rename backend/{src => app}/core/enums.py (100%) rename backend/{src/schemas/errors.py => app/core/error_schemas.py} (93%) rename backend/{src => app}/core/exceptions.py (100%) rename backend/{src/routes/health.py => app/core/health_routes.py} (94%) rename backend/{src => app}/core/logging.py (98%) rename backend/{src => app}/core/rate_limit.py (97%) rename backend/{src => app}/core/responses.py (96%) rename backend/{src => app}/core/security.py (99%) rename backend/{src => app}/factory.py (90%) rename backend/{src => app}/middleware/__init__.py (57%) rename backend/{src => app}/middleware/correlation.py (100%) rename backend/{src/models => app/user}/User.py (93%) create mode 100644 backend/app/user/__init__.py rename backend/{src/repositories/user.py => app/user/repository.py} (96%) rename backend/{src/routes/user.py => app/user/routes.py} (89%) rename backend/{src/schemas/user.py => app/user/schemas.py} (97%) rename backend/{src/services/user.py => app/user/service.py} (96%) delete mode 100644 backend/src/models/__init__.py delete mode 100644 backend/src/repositories/__init__.py delete mode 100644 backend/src/routes/__init__.py delete mode 100644 backend/src/schemas/__init__.py delete mode 100644 backend/src/services/__init__.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index f00421d..b532fea 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -10,9 +10,9 @@ from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from src.config import settings -from src.models import Base -from src.core.enums import SafeEnum +from config import settings +from models import Base +from core.enums import SafeEnum config = context.config diff --git a/backend/src/__init__.py b/backend/app/__init__.py similarity index 100% rename from backend/src/__init__.py rename to backend/app/__init__.py diff --git a/backend/src/__main__.py b/backend/app/__main__.py similarity index 70% rename from backend/src/__main__.py rename to backend/app/__main__.py index c58208c..9fca783 100644 --- a/backend/src/__main__.py +++ b/backend/app/__main__.py @@ -4,15 +4,15 @@ """ import uvicorn -from src.config import settings -from src.factory import create_app +from config import settings +from factory import create_app app = create_app() if __name__ == "__main__": uvicorn.run( - "src.__main__:app", + "__main__:app", host = settings.HOST, port = settings.PORT, reload = settings.RELOAD, diff --git a/backend/app/admin/__init__.py b/backend/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/admin.py b/backend/app/admin/routes.py similarity index 92% rename from backend/src/routes/admin.py rename to backend/app/admin/routes.py index 23bbb23..4af5524 100644 --- a/backend/src/routes/admin.py +++ b/backend/app/admin/routes.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -admin.py +routes.py """ from uuid import UUID @@ -13,28 +13,28 @@ status, ) -from src.config import ( +from config import ( settings, UserRole, ) -from src.core.dependencies import ( +from core.dependencies import ( DBSession, RequireRole, ) -from src.core.responses import ( +from core.responses import ( AUTH_401, CONFLICT_409, FORBIDDEN_403, NOT_FOUND_404, ) -from src.schemas.user import ( +from user.schemas import ( AdminUserCreate, UserListResponse, UserResponse, UserUpdateAdmin, ) -from src.models.User import User -from src.services.user import UserService +from user.User import User +from user.service import UserService router = APIRouter(prefix = "/admin", tags = ["admin"]) diff --git a/backend/src/models/RefreshToken.py b/backend/app/auth/RefreshToken.py similarity index 96% rename from backend/src/models/RefreshToken.py rename to backend/app/auth/RefreshToken.py index c895496..f827bb4 100644 --- a/backend/src/models/RefreshToken.py +++ b/backend/app/auth/RefreshToken.py @@ -21,20 +21,20 @@ relationship, ) -from src.config import ( +from config import ( DEVICE_ID_MAX_LENGTH, DEVICE_NAME_MAX_LENGTH, IP_ADDRESS_MAX_LENGTH, TOKEN_HASH_LENGTH, ) -from src.models.Base import ( +from core.Base import ( Base, TimestampMixin, UUIDMixin, ) if TYPE_CHECKING: - from src.models.User import User + from user.User import User class RefreshToken(Base, UUIDMixin, TimestampMixin): diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/repositories/refresh_token.py b/backend/app/auth/repository.py similarity index 97% rename from backend/src/repositories/refresh_token.py rename to backend/app/auth/repository.py index d90e166..e97c04c 100644 --- a/backend/src/repositories/refresh_token.py +++ b/backend/app/auth/repository.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -refresh_token.py +repository.py """ from uuid import UUID @@ -9,8 +9,8 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from src.models.RefreshToken import RefreshToken -from src.repositories.base import BaseRepository +from .RefreshToken import RefreshToken +from core.base_repository import BaseRepository class RefreshTokenRepository(BaseRepository[RefreshToken]): diff --git a/backend/src/routes/auth.py b/backend/app/auth/routes.py similarity index 88% rename from backend/src/routes/auth.py rename to backend/app/auth/routes.py index 76bf77f..360139f 100644 --- a/backend/src/routes/auth.py +++ b/backend/app/auth/routes.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -auth.py +routes.py """ from typing import Annotated @@ -17,27 +17,27 @@ OAuth2PasswordRequestForm, ) -from src.config import settings -from src.core.dependencies import ( +from config import settings +from core.dependencies import ( ClientIP, CurrentUser, DBSession, ) -from src.core.security import ( +from core.security import ( clear_refresh_cookie, set_refresh_cookie, ) -from src.core.rate_limit import limiter -from src.core.exceptions import TokenError -from src.schemas.auth import ( +from core.rate_limit import limiter +from core.exceptions import TokenError +from .schemas import ( PasswordChange, TokenResponse, TokenWithUserResponse, ) -from src.schemas.user import UserResponse -from src.services.auth import AuthService -from src.services.user import UserService -from src.core.responses import AUTH_401 +from user.schemas import UserResponse +from .service import AuthService +from user.service import UserService +from core.responses import AUTH_401 router = APIRouter(prefix = "/auth", tags = ["auth"]) diff --git a/backend/src/schemas/auth.py b/backend/app/auth/schemas.py similarity index 92% rename from backend/src/schemas/auth.py rename to backend/app/auth/schemas.py index 3227e9e..f534792 100644 --- a/backend/src/schemas/auth.py +++ b/backend/app/auth/schemas.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -auth.py +schemas.py """ from pydantic import ( @@ -8,12 +8,12 @@ EmailStr, ) -from src.config import ( +from config import ( PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, ) -from src.schemas.base import BaseSchema -from src.schemas.user import UserResponse +from core.base_schema import BaseSchema +from user.schemas import UserResponse class LoginRequest(BaseSchema): diff --git a/backend/src/services/auth.py b/backend/app/auth/service.py similarity index 94% rename from backend/src/services/auth.py rename to backend/app/auth/service.py index 94f4df6..876fca6 100644 --- a/backend/src/services/auth.py +++ b/backend/app/auth/service.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -auth.py +service.py """ import uuid6 @@ -8,25 +8,25 @@ AsyncSession, ) -from src.core.exceptions import ( +from core.exceptions import ( InvalidCredentials, TokenError, TokenRevokedError, ) -from src.core.security import ( +from core.security import ( hash_token, create_access_token, create_refresh_token, verify_password_with_timing_safety, ) -from src.models.User import User -from src.repositories.user import UserRepository -from src.repositories.refresh_token import RefreshTokenRepository -from src.schemas.auth import ( +from user.User import User +from user.repository import UserRepository +from .repository import RefreshTokenRepository +from .schemas import ( TokenResponse, TokenWithUserResponse, ) -from src.schemas.user import UserResponse +from user.schemas import UserResponse class AuthService: diff --git a/backend/src/config.py b/backend/app/config.py similarity index 98% rename from backend/src/config.py rename to backend/app/config.py index 977e57a..27e6f80 100644 --- a/backend/src/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ SettingsConfigDict, ) -from src.core.constants import ( +from core.constants import ( API_PREFIX, API_VERSION, DEVICE_ID_MAX_LENGTH, @@ -32,7 +32,7 @@ PASSWORD_MIN_LENGTH, TOKEN_HASH_LENGTH, ) -from src.core.enums import ( +from core.enums import ( Environment, HealthStatus, SafeEnum, diff --git a/backend/src/models/Base.py b/backend/app/core/Base.py similarity index 100% rename from backend/src/models/Base.py rename to backend/app/core/Base.py diff --git a/backend/src/core/__init__.py b/backend/app/core/__init__.py similarity index 100% rename from backend/src/core/__init__.py rename to backend/app/core/__init__.py diff --git a/backend/src/repositories/base.py b/backend/app/core/base_repository.py similarity index 98% rename from backend/src/repositories/base.py rename to backend/app/core/base_repository.py index 928907d..bfeae00 100644 --- a/backend/src/repositories/base.py +++ b/backend/app/core/base_repository.py @@ -14,7 +14,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.Base import Base +from models.Base import Base ModelT = TypeVar("ModelT", bound = Base) diff --git a/backend/src/schemas/base.py b/backend/app/core/base_schema.py similarity index 100% rename from backend/src/schemas/base.py rename to backend/app/core/base_schema.py diff --git a/backend/src/schemas/common.py b/backend/app/core/common_schemas.py similarity index 87% rename from backend/src/schemas/common.py rename to backend/app/core/common_schemas.py index de0f2ed..479ced4 100644 --- a/backend/src/schemas/common.py +++ b/backend/app/core/common_schemas.py @@ -3,8 +3,8 @@ common.py """ -from src.config import HealthStatus -from src.schemas.base import BaseSchema +from config import HealthStatus +from schemas.base import BaseSchema class HealthResponse(BaseSchema): diff --git a/backend/src/core/constants.py b/backend/app/core/constants.py similarity index 100% rename from backend/src/core/constants.py rename to backend/app/core/constants.py diff --git a/backend/src/core/database.py b/backend/app/core/database.py similarity index 99% rename from backend/src/core/database.py rename to backend/app/core/database.py index e6733f9..03ffeef 100644 --- a/backend/src/core/database.py +++ b/backend/app/core/database.py @@ -21,7 +21,7 @@ ) from sqlalchemy.orm import Session, sessionmaker -from src.config import settings +from config import settings class DatabaseSessionManager: diff --git a/backend/src/core/dependencies.py b/backend/app/core/dependencies.py similarity index 93% rename from backend/src/core/dependencies.py rename to backend/app/core/dependencies.py index 428148b..a97eb18 100644 --- a/backend/src/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -13,22 +13,22 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession -from src.config import ( +from config import ( API_PREFIX, TokenType, UserRole, ) -from src.core.database import get_db_session -from src.core.exceptions import ( +from core.database import get_db_session +from core.exceptions import ( InactiveUser, PermissionDenied, TokenError, TokenRevokedError, UserNotFound, ) -from src.models.User import User -from src.core.security import decode_access_token -from src.repositories.user import UserRepository +from models.User import User +from core.security import decode_access_token +from repositories.user import UserRepository oauth2_scheme = OAuth2PasswordBearer( diff --git a/backend/src/core/enums.py b/backend/app/core/enums.py similarity index 100% rename from backend/src/core/enums.py rename to backend/app/core/enums.py diff --git a/backend/src/schemas/errors.py b/backend/app/core/error_schemas.py similarity index 93% rename from backend/src/schemas/errors.py rename to backend/app/core/error_schemas.py index cd06f87..30d23ab 100644 --- a/backend/src/schemas/errors.py +++ b/backend/app/core/error_schemas.py @@ -4,7 +4,7 @@ """ from pydantic import Field -from src.schemas.base import BaseSchema +from schemas.base import BaseSchema class ErrorDetail(BaseSchema): diff --git a/backend/src/core/exceptions.py b/backend/app/core/exceptions.py similarity index 100% rename from backend/src/core/exceptions.py rename to backend/app/core/exceptions.py diff --git a/backend/src/routes/health.py b/backend/app/core/health_routes.py similarity index 94% rename from backend/src/routes/health.py rename to backend/app/core/health_routes.py index 8149e35..8cc0c41 100644 --- a/backend/src/routes/health.py +++ b/backend/app/core/health_routes.py @@ -10,15 +10,15 @@ import redis.asyncio as redis from sqlalchemy import text -from src.config import ( +from config import ( settings, HealthStatus, ) -from src.schemas.common import ( +from schemas.common import ( HealthResponse, HealthDetailedResponse, ) -from src.core.database import sessionmanager +from core.database import sessionmanager router = APIRouter(tags = ["health"]) diff --git a/backend/src/core/logging.py b/backend/app/core/logging.py similarity index 98% rename from backend/src/core/logging.py rename to backend/app/core/logging.py index c17c38f..515ac3f 100644 --- a/backend/src/core/logging.py +++ b/backend/app/core/logging.py @@ -9,7 +9,7 @@ import structlog from structlog.types import Processor -from src.config import ( +from config import ( settings, Environment, ) diff --git a/backend/src/core/rate_limit.py b/backend/app/core/rate_limit.py similarity index 97% rename from backend/src/core/rate_limit.py rename to backend/app/core/rate_limit.py index ac3d5a7..34efe55 100644 --- a/backend/src/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -8,7 +8,7 @@ from slowapi.util import get_remote_address from starlette.requests import Request -from src.config import settings +from config import settings def get_identifier(request: Request) -> str: diff --git a/backend/src/core/responses.py b/backend/app/core/responses.py similarity index 96% rename from backend/src/core/responses.py rename to backend/app/core/responses.py index e1d6f56..3805633 100644 --- a/backend/src/core/responses.py +++ b/backend/app/core/responses.py @@ -5,7 +5,7 @@ from typing import Any -from src.schemas.errors import ErrorDetail +from schemas.errors import ErrorDetail AUTH_401: dict[int | str, diff --git a/backend/src/core/security.py b/backend/app/core/security.py similarity index 99% rename from backend/src/core/security.py rename to backend/app/core/security.py index 46ce402..5065dc6 100644 --- a/backend/src/core/security.py +++ b/backend/app/core/security.py @@ -18,7 +18,7 @@ from fastapi import Response from pwdlib import PasswordHash -from src.config import ( +from config import ( API_PREFIX, settings, TokenType, diff --git a/backend/src/factory.py b/backend/app/factory.py similarity index 90% rename from backend/src/factory.py rename to backend/app/factory.py index b6ba2be..8f293e9 100644 --- a/backend/src/factory.py +++ b/backend/app/factory.py @@ -12,14 +12,14 @@ from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded -from src.config import settings, Environment, API_PREFIX -from src.core.database import sessionmanager -from src.core.exceptions import BaseAppException -from src.core.logging import configure_logging -from src.core.rate_limit import limiter -from src.middleware.correlation import CorrelationIdMiddleware -from src.schemas.common import AppInfoResponse -from src.routes import ( +from config import settings, Environment, API_PREFIX +from core.database import sessionmanager +from core.exceptions import BaseAppException +from core.logging import configure_logging +from core.rate_limit import limiter +from middleware.correlation import CorrelationIdMiddleware +from schemas.common import AppInfoResponse +from routes import ( admin_router, auth_router, health_router, diff --git a/backend/src/middleware/__init__.py b/backend/app/middleware/__init__.py similarity index 57% rename from backend/src/middleware/__init__.py rename to backend/app/middleware/__init__.py index d51221e..c592164 100644 --- a/backend/src/middleware/__init__.py +++ b/backend/app/middleware/__init__.py @@ -3,7 +3,7 @@ __init__.py """ -from src.middleware.correlation import CorrelationIdMiddleware +from middleware.correlation import CorrelationIdMiddleware __all__ = [ diff --git a/backend/src/middleware/correlation.py b/backend/app/middleware/correlation.py similarity index 100% rename from backend/src/middleware/correlation.py rename to backend/app/middleware/correlation.py diff --git a/backend/src/models/User.py b/backend/app/user/User.py similarity index 93% rename from backend/src/models/User.py rename to backend/app/user/User.py index 4053548..a39f595 100644 --- a/backend/src/models/User.py +++ b/backend/app/user/User.py @@ -14,21 +14,21 @@ relationship, ) -from src.config import ( +from config import ( EMAIL_MAX_LENGTH, FULL_NAME_MAX_LENGTH, PASSWORD_HASH_MAX_LENGTH, SafeEnum, UserRole, ) -from src.models.Base import ( +from core.Base import ( Base, TimestampMixin, UUIDMixin, ) if TYPE_CHECKING: - from src.models.RefreshToken import RefreshToken + from auth.RefreshToken import RefreshToken class User(Base, UUIDMixin, TimestampMixin): diff --git a/backend/app/user/__init__.py b/backend/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/repositories/user.py b/backend/app/user/repository.py similarity index 96% rename from backend/src/repositories/user.py rename to backend/app/user/repository.py index 53155d0..e1adfc9 100644 --- a/backend/src/repositories/user.py +++ b/backend/app/user/repository.py @@ -1,14 +1,14 @@ """ ⒸAngelaMos | 2025 -user.py +repository.py """ from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.User import User -from src.repositories.base import BaseRepository +from .User import User +from core.base_repository import BaseRepository class UserRepository(BaseRepository[User]): diff --git a/backend/src/routes/user.py b/backend/app/user/routes.py similarity index 89% rename from backend/src/routes/user.py rename to backend/app/user/routes.py index bac7501..48fdca0 100644 --- a/backend/src/routes/user.py +++ b/backend/app/user/routes.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -user.py +routes.py """ from uuid import UUID @@ -10,21 +10,21 @@ status, ) -from src.core.dependencies import ( +from core.dependencies import ( CurrentUser, DBSession, ) -from src.core.responses import ( +from core.responses import ( AUTH_401, CONFLICT_409, NOT_FOUND_404, ) -from src.schemas.user import ( +from .schemas import ( UserCreate, UserResponse, UserUpdate, ) -from src.services.user import UserService +from .service import UserService router = APIRouter(prefix = "/users", tags = ["users"]) diff --git a/backend/src/schemas/user.py b/backend/app/user/schemas.py similarity index 97% rename from backend/src/schemas/user.py rename to backend/app/user/schemas.py index 290f8e4..be4c9e3 100644 --- a/backend/src/schemas/user.py +++ b/backend/app/user/schemas.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -user.py +schemas.py """ from pydantic import ( @@ -9,13 +9,13 @@ field_validator, ) -from src.config import ( +from config import ( UserRole, FULL_NAME_MAX_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, ) -from src.schemas.base import ( +from core.base_schema import ( BaseSchema, BaseResponseSchema, ) diff --git a/backend/src/services/user.py b/backend/app/user/service.py similarity index 96% rename from backend/src/services/user.py rename to backend/app/user/service.py index e47bfad..cda537d 100644 --- a/backend/src/services/user.py +++ b/backend/app/user/service.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -user.py +service.py """ from uuid import UUID @@ -8,16 +8,16 @@ AsyncSession, ) -from src.core.exceptions import ( +from core.exceptions import ( EmailAlreadyExists, InvalidCredentials, UserNotFound, ) -from src.core.security import ( +from core.security import ( hash_password, verify_password, ) -from src.schemas.user import ( +from .schemas import ( AdminUserCreate, UserCreate, UserListResponse, @@ -25,8 +25,8 @@ UserUpdate, UserUpdateAdmin, ) -from src.models.User import User -from src.repositories.user import UserRepository +from .User import User +from .repository import UserRepository class UserService: diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py deleted file mode 100644 index 228f275..0000000 --- a/backend/src/models/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -AngelaMos | 2025 -__init__.py -""" - -from src.models.Base import ( - Base, - UUIDMixin, - SoftDeleteMixin, - TimestampMixin, -) -from src.models.User import User -from src.models.RefreshToken import RefreshToken - - -__all__ = [ - "Base", - "RefreshToken", - "SoftDeleteMixin", - "TimestampMixin", - "UUIDMixin", - "User", -] diff --git a/backend/src/repositories/__init__.py b/backend/src/repositories/__init__.py deleted file mode 100644 index 297371c..0000000 --- a/backend/src/repositories/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -AngelaMos | 2025 -__init__.py -""" - -from src.repositories.base import BaseRepository -from src.repositories.user import UserRepository -from src.repositories.refresh_token import RefreshTokenRepository - - -__all__ = [ - "BaseRepository", - "RefreshTokenRepository", - "UserRepository", -] diff --git a/backend/src/routes/__init__.py b/backend/src/routes/__init__.py deleted file mode 100644 index dbf95da..0000000 --- a/backend/src/routes/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -AngelaMos | 2025 -__init__.py -""" - -from src.routes.admin import router as admin_router -from src.routes.auth import router as auth_router -from src.routes.health import router as health_router -from src.routes.user import router as user_router - - -__all__ = [ - "admin_router", - "auth_router", - "health_router", - "user_router", -] diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py deleted file mode 100644 index 76381ef..0000000 --- a/backend/src/schemas/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -AngelaMos | 2025 -__init__.py -""" - -from src.schemas.auth import ( - LoginRequest, - PasswordChange, - PasswordResetConfirm, - PasswordResetRequest, - RefreshTokenRequest, - TokenResponse, - TokenWithUserResponse, -) -from src.schemas.base import ( - BaseResponseSchema, - BaseSchema, -) -from src.schemas.user import ( - UserCreate, - UserListResponse, - UserResponse, - UserUpdate, - UserUpdateAdmin, -) - - -__all__ = [ - "BaseResponseSchema", - "BaseSchema", - "LoginRequest", - "PasswordChange", - "PasswordResetConfirm", - "PasswordResetRequest", - "RefreshTokenRequest", - "TokenResponse", - "TokenWithUserResponse", - "UserCreate", - "UserListResponse", - "UserResponse", - "UserUpdate", - "UserUpdateAdmin", -] diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py deleted file mode 100644 index 9546699..0000000 --- a/backend/src/services/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -AngelaMos | 2025 -__init__.py -""" - -from src.services.auth import AuthService -from src.services.user import UserService - - -__all__ = [ - "AuthService", - "UserService", -] From 708cb819d0f6caac83eac522ba22430a9962122f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 12 Dec 2025 21:25:39 -0500 Subject: [PATCH 2/5] DDD refactor: domain-based structure + dependency injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructured to DDD domains (user/, auth/, admin/, core/) - Renamed src/ → app/ to match FastAPI conventions - Removed all src.* import prefixes - Converted services to DI pattern (removed @staticmethod) - Created dependencies.py in each domain for service injection - Updated all routes to inject services via Depends() - Kept capital letter model naming (User.py, RefreshToken.py) - Updated Docker, alembic, and test files - Set PYTHONPATH=/app/app for clean imports Import style: - Relative within domain: from .User import User - No prefix for core: from core.database import ... - Domain name for cross-domain: from user.repository import ... --- backend/alembic/env.py | 4 +- backend/app/admin/routes.py | 27 +++++----- backend/app/auth/dependencies.py | 21 ++++++++ backend/app/auth/routes.py | 28 +++++------ backend/app/auth/service.py | 45 ++++++++--------- backend/app/factory.py | 12 ++--- backend/app/user/dependencies.py | 21 ++++++++ backend/app/user/routes.py | 19 +++----- backend/app/user/service.py | 65 +++++++++++-------------- backend/conftest.py | 14 +++--- backend/tests/integration/test_auth.py | 4 +- backend/tests/integration/test_users.py | 2 +- infra/docker/fastapi.dev | 5 +- infra/docker/fastapi.prod | 7 +-- 14 files changed, 149 insertions(+), 125 deletions(-) create mode 100644 backend/app/auth/dependencies.py create mode 100644 backend/app/user/dependencies.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index b532fea..b9579ee 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -11,8 +11,10 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from config import settings -from models import Base +from core.Base import Base from core.enums import SafeEnum +from user.User import User +from auth.RefreshToken import RefreshToken config = context.config diff --git a/backend/app/admin/routes.py b/backend/app/admin/routes.py index 4af5524..08051e4 100644 --- a/backend/app/admin/routes.py +++ b/backend/app/admin/routes.py @@ -17,10 +17,7 @@ settings, UserRole, ) -from core.dependencies import ( - DBSession, - RequireRole, -) +from core.dependencies import RequireRole from core.responses import ( AUTH_401, CONFLICT_409, @@ -34,7 +31,7 @@ UserUpdateAdmin, ) from user.User import User -from user.service import UserService +from user.dependencies import UserServiceDep router = APIRouter(prefix = "/admin", tags = ["admin"]) @@ -51,7 +48,7 @@ }, ) async def list_users( - db: DBSession, + user_service: UserServiceDep, _: AdminOnly, page: int = Query(default = 1, ge = 1), @@ -64,7 +61,7 @@ async def list_users( """ List all users (admin only) """ - return await UserService.list_users(db, page, size) + return await user_service.list_users(page, size) @router.post( @@ -78,14 +75,14 @@ async def list_users( }, ) async def create_user( - db: DBSession, + user_service: UserServiceDep, _: AdminOnly, user_data: AdminUserCreate, ) -> UserResponse: """ Create a new user (admin only, bypasses registration) """ - return await UserService.admin_create_user(db, user_data) + return await user_service.admin_create_user(user_data) @router.get( @@ -98,14 +95,14 @@ async def create_user( }, ) async def get_user( - db: DBSession, + user_service: UserServiceDep, _: AdminOnly, user_id: UUID, ) -> UserResponse: """ Get user by ID (admin only) """ - return await UserService.get_user_by_id(db, user_id) + return await user_service.get_user_by_id(user_id) @router.patch( @@ -119,7 +116,7 @@ async def get_user( }, ) async def update_user( - db: DBSession, + user_service: UserServiceDep, _: AdminOnly, user_id: UUID, user_data: UserUpdateAdmin, @@ -127,7 +124,7 @@ async def update_user( """ Update user (admin only) """ - return await UserService.admin_update_user(db, user_id, user_data) + return await user_service.admin_update_user(user_id, user_data) @router.delete( @@ -140,11 +137,11 @@ async def update_user( }, ) async def delete_user( - db: DBSession, + user_service: UserServiceDep, _: AdminOnly, user_id: UUID, ) -> None: """ Delete user (admin only, hard delete) """ - await UserService.admin_delete_user(db, user_id) + await user_service.admin_delete_user(user_id) diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..312deaf --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import AuthService + + +def get_auth_service(db: DBSession) -> AuthService: + """ + Dependency to inject AuthService instance + """ + return AuthService(db) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index 360139f..d6cee83 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -21,7 +21,6 @@ from core.dependencies import ( ClientIP, CurrentUser, - DBSession, ) from core.security import ( clear_refresh_cookie, @@ -35,8 +34,8 @@ TokenWithUserResponse, ) from user.schemas import UserResponse -from .service import AuthService -from user.service import UserService +from .dependencies import AuthServiceDep +from user.dependencies import UserServiceDep from core.responses import AUTH_401 @@ -52,7 +51,7 @@ async def login( request: Request, response: Response, - db: DBSession, + auth_service: AuthServiceDep, ip: ClientIP, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], @@ -60,8 +59,7 @@ async def login( """ Login with email and password """ - result, refresh_token = await AuthService.login( - db, + result, refresh_token = await auth_service.login( email=form_data.username, password=form_data.password, ip_address=ip, @@ -76,7 +74,7 @@ async def login( responses = {**AUTH_401} ) async def refresh_token( - db: DBSession, + auth_service: AuthServiceDep, ip: ClientIP, refresh_token: str | None = Cookie(None), ) -> TokenResponse: @@ -85,8 +83,7 @@ async def refresh_token( """ if not refresh_token: raise TokenError("Refresh token required") - return await AuthService.refresh_tokens( - db, + return await auth_service.refresh_tokens( refresh_token, ip_address = ip ) @@ -99,7 +96,7 @@ async def refresh_token( ) async def logout( response: Response, - db: DBSession, + auth_service: AuthServiceDep, refresh_token: str | None = Cookie(None), ) -> None: """ @@ -107,21 +104,21 @@ async def logout( """ if not refresh_token: raise TokenError("Refresh token required") - await AuthService.logout(db, refresh_token) + await auth_service.logout(refresh_token) clear_refresh_cookie(response) @router.post("/logout-all", responses = {**AUTH_401}) async def logout_all( response: Response, - db: DBSession, + auth_service: AuthServiceDep, current_user: CurrentUser, ) -> dict[str, int]: """ Logout from all devices """ - count = await AuthService.logout_all(db, current_user) + count = await auth_service.logout_all(current_user) clear_refresh_cookie(response) return {"revoked_sessions": count} @@ -140,15 +137,14 @@ async def get_current_user(current_user: CurrentUser) -> UserResponse: responses = {**AUTH_401} ) async def change_password( - db: DBSession, + user_service: UserServiceDep, current_user: CurrentUser, data: PasswordChange, ) -> None: """ Change current user password """ - await UserService.change_password( - db, + await user_service.change_password( current_user, data.current_password, data.new_password, diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 876fca6..0db8ea3 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -33,9 +33,11 @@ class AuthService: """ Business logic for authentication operations """ - @staticmethod + def __init__(self, session: AsyncSession) -> None: + self.session = session + async def authenticate( - session: AsyncSession, + self, email: str, password: str, device_id: str | None = None, @@ -47,7 +49,7 @@ async def authenticate( """ Authenticate user and create tokens """ - user = await UserRepository.get_by_email(session, email) + user = await UserRepository.get_by_email(self.session, email) hashed_password = user.hashed_password if user else None is_valid, new_hash = await verify_password_with_timing_safety( @@ -61,7 +63,7 @@ async def authenticate( raise InvalidCredentials() if new_hash: - await UserRepository.update_password(session, user, new_hash) + await UserRepository.update_password(self.session, user, new_hash) access_token = create_access_token(user.id, user.token_version) @@ -69,7 +71,7 @@ async def authenticate( raw_refresh, token_hash, expires_at = create_refresh_token(user.id, family_id) await RefreshTokenRepository.create_token( - session, + self.session, user_id = user.id, token_hash = token_hash, family_id = family_id, @@ -81,9 +83,8 @@ async def authenticate( return access_token, raw_refresh, user - @staticmethod async def login( - session: AsyncSession, + self, email: str, password: str, device_id: str | None = None, @@ -94,8 +95,7 @@ async def login( """ Login and return tokens with user data """ - access_token, refresh_token, user = await AuthService.authenticate( - session, + access_token, refresh_token, user = await self.authenticate( email, password, device_id, @@ -109,9 +109,8 @@ async def login( ) return response, refresh_token - @staticmethod async def refresh_tokens( - session: AsyncSession, + self, refresh_token: str, device_id: str | None = None, device_name: str | None = None, @@ -124,7 +123,7 @@ async def refresh_tokens( """ token_hash = hash_token(refresh_token) stored_token = await RefreshTokenRepository.get_by_hash( - session, + self.session, token_hash ) @@ -133,7 +132,7 @@ async def refresh_tokens( if stored_token.is_revoked: await RefreshTokenRepository.revoke_family( - session, + self.session, stored_token.family_id ) raise TokenRevokedError() @@ -142,13 +141,13 @@ async def refresh_tokens( raise TokenError(message = "Refresh token expired") user = await UserRepository.get_by_id( - session, + self.session, stored_token.user_id ) if user is None or not user.is_active: raise TokenError(message = "User not found or inactive") - await RefreshTokenRepository.revoke_token(session, stored_token) + await RefreshTokenRepository.revoke_token(self.session, stored_token) access_token = create_access_token(user.id, user.token_version) @@ -157,7 +156,7 @@ async def refresh_tokens( ) await RefreshTokenRepository.create_token( - session, + self.session, user_id = user.id, token_hash = new_hash, family_id = stored_token.family_id, @@ -169,9 +168,8 @@ async def refresh_tokens( return TokenResponse(access_token = access_token) - @staticmethod async def logout( - session: AsyncSession, + self, refresh_token: str, ) -> None: """ @@ -181,19 +179,18 @@ async def logout( """ token_hash = hash_token(refresh_token) stored_token = await RefreshTokenRepository.get_by_hash( - session, + self.session, token_hash ) if stored_token and not stored_token.is_revoked: await RefreshTokenRepository.revoke_token( - session, + self.session, stored_token ) - @staticmethod async def logout_all( - session: AsyncSession, + self, user: User, ) -> int: """ @@ -201,8 +198,8 @@ async def logout_all( Returns count of revoked sessions """ - await UserRepository.increment_token_version(session, user) + await UserRepository.increment_token_version(self.session, user) return await RefreshTokenRepository.revoke_all_user_tokens( - session, + self.session, user.id ) diff --git a/backend/app/factory.py b/backend/app/factory.py index 8f293e9..4a8066b 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -18,13 +18,11 @@ from core.logging import configure_logging from core.rate_limit import limiter from middleware.correlation import CorrelationIdMiddleware -from schemas.common import AppInfoResponse -from routes import ( - admin_router, - auth_router, - health_router, - user_router, -) +from core.common_schemas import AppInfoResponse +from core.health_routes import router as health_router +from user.routes import router as user_router +from auth.routes import router as auth_router +from admin.routes import router as admin_router @asynccontextmanager diff --git a/backend/app/user/dependencies.py b/backend/app/user/dependencies.py new file mode 100644 index 0000000..631bd79 --- /dev/null +++ b/backend/app/user/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import UserService + + +def get_user_service(db: DBSession) -> UserService: + """ + Dependency to inject UserService instance + """ + return UserService(db) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] diff --git a/backend/app/user/routes.py b/backend/app/user/routes.py index 48fdca0..4616804 100644 --- a/backend/app/user/routes.py +++ b/backend/app/user/routes.py @@ -10,10 +10,7 @@ status, ) -from core.dependencies import ( - CurrentUser, - DBSession, -) +from core.dependencies import CurrentUser from core.responses import ( AUTH_401, CONFLICT_409, @@ -24,7 +21,7 @@ UserResponse, UserUpdate, ) -from .service import UserService +from .dependencies import UserServiceDep router = APIRouter(prefix = "/users", tags = ["users"]) @@ -37,13 +34,13 @@ responses = {**CONFLICT_409}, ) async def create_user( - db: DBSession, + user_service: UserServiceDep, user_data: UserCreate, ) -> UserResponse: """ Register a new user """ - return await UserService.create_user(db, user_data) + return await user_service.create_user(user_data) @router.get( @@ -55,14 +52,14 @@ async def create_user( }, ) async def get_user( - db: DBSession, + user_service: UserServiceDep, user_id: UUID, _: CurrentUser, ) -> UserResponse: """ Get user by ID """ - return await UserService.get_user_by_id(db, user_id) + return await user_service.get_user_by_id(user_id) @router.patch( @@ -71,11 +68,11 @@ async def get_user( responses = {**AUTH_401}, ) async def update_current_user( - db: DBSession, + user_service: UserServiceDep, current_user: CurrentUser, user_data: UserUpdate, ) -> UserResponse: """ Update current user profile """ - return await UserService.update_user(db, current_user, user_data) + return await user_service.update_user(current_user, user_data) diff --git a/backend/app/user/service.py b/backend/app/user/service.py index cda537d..e6e92f0 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -33,55 +33,54 @@ class UserService: """ Business logic for user operations """ - @staticmethod + def __init__(self, session: AsyncSession) -> None: + self.session = session + async def create_user( - session: AsyncSession, + self, user_data: UserCreate, ) -> UserResponse: """ Register a new user """ - if await UserRepository.email_exists(session, user_data.email): + if await UserRepository.email_exists(self.session, user_data.email): raise EmailAlreadyExists(user_data.email) hashed = await hash_password(user_data.password) user = await UserRepository.create_user( - session, + self.session, email = user_data.email, hashed_password = hashed, full_name = user_data.full_name, ) return UserResponse.model_validate(user) - @staticmethod async def get_user_by_id( - session: AsyncSession, + self, user_id: UUID, ) -> UserResponse: """ Get user by ID """ - user = await UserRepository.get_by_id(session, user_id) + user = await UserRepository.get_by_id(self.session, user_id) if not user: raise UserNotFound(str(user_id)) return UserResponse.model_validate(user) - @staticmethod async def get_user_model_by_id( - session: AsyncSession, + self, user_id: UUID, ) -> User: """ Get user model by ID (for internal use) """ - user = await UserRepository.get_by_id(session, user_id) + user = await UserRepository.get_by_id(self.session, user_id) if not user: raise UserNotFound(str(user_id)) return user - @staticmethod async def update_user( - session: AsyncSession, + self, user: User, user_data: UserUpdate, ) -> UserResponse: @@ -90,15 +89,14 @@ async def update_user( """ update_dict = user_data.model_dump(exclude_unset = True) updated_user = await UserRepository.update( - session, + self.session, user, **update_dict ) return UserResponse.model_validate(updated_user) - @staticmethod async def change_password( - session: AsyncSession, + self, user: User, current_password: str, new_password: str, @@ -111,26 +109,24 @@ async def change_password( raise InvalidCredentials() hashed = await hash_password(new_password) - await UserRepository.update_password(session, user, hashed) + await UserRepository.update_password(self.session, user, hashed) - @staticmethod async def deactivate_user( - session: AsyncSession, + self, user: User, ) -> UserResponse: """ Deactivate user account """ updated = await UserRepository.update( - session, + self.session, user, is_active = False ) return UserResponse.model_validate(updated) - @staticmethod async def list_users( - session: AsyncSession, + self, page: int, size: int, ) -> UserListResponse: @@ -139,11 +135,11 @@ async def list_users( """ skip = (page - 1) * size users = await UserRepository.get_multi( - session, + self.session, skip = skip, limit = size ) - total = await UserRepository.count(session) + total = await UserRepository.count(self.session) return UserListResponse( items = [UserResponse.model_validate(u) for u in users], total = total, @@ -151,20 +147,19 @@ async def list_users( size = size, ) - @staticmethod async def admin_create_user( - session: AsyncSession, + self, user_data: AdminUserCreate, ) -> UserResponse: """ Admin creates a new user """ - if await UserRepository.email_exists(session, user_data.email): + if await UserRepository.email_exists(self.session, user_data.email): raise EmailAlreadyExists(user_data.email) hashed = await hash_password(user_data.password) user = await UserRepository.create( - session, + self.session, email = user_data.email, hashed_password = hashed, full_name = user_data.full_name, @@ -174,16 +169,15 @@ async def admin_create_user( ) return UserResponse.model_validate(user) - @staticmethod async def admin_update_user( - session: AsyncSession, + self, user_id: UUID, user_data: UserUpdateAdmin, ) -> UserResponse: """ Admin updates a user """ - user = await UserRepository.get_by_id(session, user_id) + user = await UserRepository.get_by_id(self.session, user_id) if not user: raise UserNotFound(str(user_id)) @@ -191,29 +185,28 @@ async def admin_update_user( if "email" in update_dict: existing = await UserRepository.get_by_email( - session, + self.session, update_dict["email"] ) if existing and existing.id != user_id: raise EmailAlreadyExists(update_dict["email"]) updated_user = await UserRepository.update( - session, + self.session, user, **update_dict ) return UserResponse.model_validate(updated_user) - @staticmethod async def admin_delete_user( - session: AsyncSession, + self, user_id: UUID, ) -> None: """ Admin deletes a user (hard delete) """ - user = await UserRepository.get_by_id(session, user_id) + user = await UserRepository.get_by_id(self.session, user_id) if not user: raise UserNotFound(str(user_id)) - await UserRepository.delete(session, user) + await UserRepository.delete(self.session, user) diff --git a/backend/conftest.py b/backend/conftest.py index 136f55a..3e66866 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -27,16 +27,16 @@ ) from sqlalchemy.pool import StaticPool -from src.core.security import ( +from core.security import ( hash_password, create_access_token, ) -from src.config import UserRole -from src.core.database import get_db_session +from config import UserRole +from core.database import get_db_session -from src.models.Base import Base -from src.models.User import User -from src.models.RefreshToken import RefreshToken +from core.Base import Base +from user.User import User +from auth.RefreshToken import RefreshToken @pytest_asyncio.fixture(scope = "session", loop_scope = "session") @@ -83,7 +83,7 @@ async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]: """ Async HTTP client with DB session override """ - from src.__main__ import app + from __main__ import app async def override_get_db(): yield db_session diff --git a/backend/tests/integration/test_auth.py b/backend/tests/integration/test_auth.py index ddebfe3..1528be4 100644 --- a/backend/tests/integration/test_auth.py +++ b/backend/tests/integration/test_auth.py @@ -6,8 +6,8 @@ import pytest from httpx import AsyncClient -from src.models.User import User -from src.models.RefreshToken import RefreshToken +from models.User import User +from models.RefreshToken import RefreshToken URL_LOGIN = "/v1/auth/login" diff --git a/backend/tests/integration/test_users.py b/backend/tests/integration/test_users.py index fb7c473..30c6a12 100644 --- a/backend/tests/integration/test_users.py +++ b/backend/tests/integration/test_users.py @@ -6,7 +6,7 @@ import pytest from httpx import AsyncClient -from src.models.User import User +from models.User import User URL_USERS = "/v1/admin/users" diff --git a/infra/docker/fastapi.dev b/infra/docker/fastapi.dev index 595ee84..9adbf65 100644 --- a/infra/docker/fastapi.dev +++ b/infra/docker/fastapi.dev @@ -16,7 +16,8 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ UV_COMPILE_BYTECODE=0 \ - UV_LINK_MODE=copy + UV_LINK_MODE=copy \ + PYTHONPATH=/app/app COPY pyproject.toml uv.lock* ./ @@ -30,4 +31,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \ EXPOSE 8000 -CMD ["sh", "-c", "uv run alembic upgrade head && uv run uvicorn src.__main__:app --host 0.0.0.0 --port 8000 --reload"] +CMD ["sh", "-c", "uv run alembic upgrade head && uv run uvicorn __main__:app --host 0.0.0.0 --port 8000 --reload"] diff --git a/infra/docker/fastapi.prod b/infra/docker/fastapi.prod index 74713af..363f993 100644 --- a/infra/docker/fastapi.prod +++ b/infra/docker/fastapi.prod @@ -40,13 +40,14 @@ RUN groupadd -g 1001 appgroup && \ WORKDIR /app COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv -COPY --from=builder --chown=appuser:appgroup /app/src /app/src +COPY --from=builder --chown=appuser:appgroup /app/app /app/app COPY --from=builder --chown=appuser:appgroup /app/alembic /app/alembic COPY --from=builder --chown=appuser:appgroup /app/alembic.ini /app/alembic.ini ENV PATH="/app/.venv/bin:$PATH" \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/app USER appuser @@ -55,4 +56,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 -CMD ["sh", "-c", "alembic upgrade head && gunicorn src.__main__:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 --max-requests 1000 --max-requests-jitter 100 --access-logfile - --error-logfile -"] +CMD ["sh", "-c", "alembic upgrade head && gunicorn __main__:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8000 --max-requests 1000 --max-requests-jitter 100 --access-logfile - --error-logfile -"] From 0c00a021fc10275d68a21113d82ff61d0d259c95 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 12 Dec 2025 21:47:09 -0500 Subject: [PATCH 3/5] fix: update test imports to use new DDD structure --- backend/tests/integration/test_auth.py | 4 ++-- backend/tests/integration/test_users.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/integration/test_auth.py b/backend/tests/integration/test_auth.py index 1528be4..689b497 100644 --- a/backend/tests/integration/test_auth.py +++ b/backend/tests/integration/test_auth.py @@ -6,8 +6,8 @@ import pytest from httpx import AsyncClient -from models.User import User -from models.RefreshToken import RefreshToken +from user.User import User +from auth.RefreshToken import RefreshToken URL_LOGIN = "/v1/auth/login" diff --git a/backend/tests/integration/test_users.py b/backend/tests/integration/test_users.py index 30c6a12..48f845d 100644 --- a/backend/tests/integration/test_users.py +++ b/backend/tests/integration/test_users.py @@ -6,7 +6,7 @@ import pytest from httpx import AsyncClient -from models.User import User +from user.User import User URL_USERS = "/v1/admin/users" From 9185184bd8844f72884cb6c7d2ac391113efb957 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 14 Dec 2025 11:13:05 -0500 Subject: [PATCH 4/5] fix: update remaining imports and linter configs for DDD structure --- .gitignore | 1 - backend/app/config.py | 22 + backend/app/core/base_repository.py | 4 +- backend/app/core/common_schemas.py | 4 +- backend/app/core/dependencies.py | 10 +- backend/app/core/error_schemas.py | 11 +- backend/app/core/health_routes.py | 6 +- backend/app/core/responses.py | 2 +- backend/app/middleware/__init__.py | 2 +- backend/pyproject.toml | 31 +- docs/research/CLAUDE-CODE-PROGRAMMATIC.md | 1054 +++++++++++++++++ .../CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md | 725 ++++++++++++ 12 files changed, 1842 insertions(+), 30 deletions(-) create mode 100644 docs/research/CLAUDE-CODE-PROGRAMMATIC.md create mode 100644 docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md diff --git a/.gitignore b/.gitignore index 75b63bd..0c26523 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ venv *.env *.cache *.egg -.angela __pycache__/ *.py[cod] diff --git a/backend/app/config.py b/backend/app/config.py index 27e6f80..b54b6c2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,28 @@ ) +__all__ = [ + "API_PREFIX", + "API_VERSION", + "DEVICE_ID_MAX_LENGTH", + "DEVICE_NAME_MAX_LENGTH", + "EMAIL_MAX_LENGTH", + "FULL_NAME_MAX_LENGTH", + "IP_ADDRESS_MAX_LENGTH", + "PASSWORD_HASH_MAX_LENGTH", + "PASSWORD_MAX_LENGTH", + "PASSWORD_MIN_LENGTH", + "TOKEN_HASH_LENGTH", + "Environment", + "HealthStatus", + "SafeEnum", + "Settings", + "TokenType", + "UserRole", + "get_settings", + "settings", +] + _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent _ENV_FILE = _PROJECT_ROOT / ".env" diff --git a/backend/app/core/base_repository.py b/backend/app/core/base_repository.py index bfeae00..030ea6a 100644 --- a/backend/app/core/base_repository.py +++ b/backend/app/core/base_repository.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -base.py +base_repository.py """ from collections.abc import Sequence @@ -14,7 +14,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from models.Base import Base +from .Base import Base ModelT = TypeVar("ModelT", bound = Base) diff --git a/backend/app/core/common_schemas.py b/backend/app/core/common_schemas.py index 479ced4..c47029b 100644 --- a/backend/app/core/common_schemas.py +++ b/backend/app/core/common_schemas.py @@ -1,10 +1,10 @@ """ ⒸAngelaMos | 2025 -common.py +common_schemas.py """ from config import HealthStatus -from schemas.base import BaseSchema +from .base_schema import BaseSchema class HealthResponse(BaseSchema): diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index a97eb18..dfa8096 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -18,17 +18,17 @@ TokenType, UserRole, ) -from core.database import get_db_session -from core.exceptions import ( +from .database import get_db_session +from .exceptions import ( InactiveUser, PermissionDenied, TokenError, TokenRevokedError, UserNotFound, ) -from models.User import User -from core.security import decode_access_token -from repositories.user import UserRepository +from user.User import User +from .security import decode_access_token +from user.repository import UserRepository oauth2_scheme = OAuth2PasswordBearer( diff --git a/backend/app/core/error_schemas.py b/backend/app/core/error_schemas.py index 30d23ab..2879543 100644 --- a/backend/app/core/error_schemas.py +++ b/backend/app/core/error_schemas.py @@ -3,8 +3,9 @@ errors.py """ -from pydantic import Field -from schemas.base import BaseSchema +from typing import ClassVar +from pydantic import Field, ConfigDict +from core.base_schema import BaseSchema class ErrorDetail(BaseSchema): @@ -14,8 +15,8 @@ class ErrorDetail(BaseSchema): detail: str = Field(..., description = "Human readable error message") type: str = Field(..., description = "Exception class name") - model_config = { - "json_schema_extra": { + model_config: ClassVar[ConfigDict] = ConfigDict( + json_schema_extra = { "examples": [ { "detail": "User with id '123' not found", @@ -23,4 +24,4 @@ class ErrorDetail(BaseSchema): } ] } - } + ) diff --git a/backend/app/core/health_routes.py b/backend/app/core/health_routes.py index 8cc0c41..b02a8ee 100644 --- a/backend/app/core/health_routes.py +++ b/backend/app/core/health_routes.py @@ -1,6 +1,6 @@ """ ⒸAngelaMos | 2025 -health.py +health_routes.py """ from fastapi import ( @@ -14,11 +14,11 @@ settings, HealthStatus, ) -from schemas.common import ( +from .common_schemas import ( HealthResponse, HealthDetailedResponse, ) -from core.database import sessionmanager +from .database import sessionmanager router = APIRouter(tags = ["health"]) diff --git a/backend/app/core/responses.py b/backend/app/core/responses.py index 3805633..4f7b013 100644 --- a/backend/app/core/responses.py +++ b/backend/app/core/responses.py @@ -5,7 +5,7 @@ from typing import Any -from schemas.errors import ErrorDetail +from .error_schemas import ErrorDetail AUTH_401: dict[int | str, diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py index c592164..f50bdec 100644 --- a/backend/app/middleware/__init__.py +++ b/backend/app/middleware/__init__.py @@ -3,7 +3,7 @@ __init__.py """ -from middleware.correlation import CorrelationIdMiddleware +from .correlation import CorrelationIdMiddleware __all__ = [ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0942e9a..071a175 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -51,12 +51,12 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["app"] [tool.ruff] target-version = "py312" line-length = 88 -src = ["src"] +src = ["app"] exclude = ["alembic"] [tool.ruff.lint] @@ -92,9 +92,10 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["S101", "ARG001"] "conftest.py" = ["S107"] -"src/core/rate_limit.py" = ["S110"] -"src/config.py" = ["F401"] -"src/schemas/**/*.py" = ["RUF012"] +"app/core/rate_limit.py" = ["S110"] +"app/config.py" = ["F401"] +"app/**/schemas.py" = ["RUF012"] +"app/core/error_schemas.py" = ["RUF012"] [tool.mypy] python_version = "3.12" @@ -111,7 +112,7 @@ module = ["tests.*", "conftest"] ignore_errors = true [[tool.mypy.overrides]] -module = ["src.core.logging"] +module = ["core.logging"] disable_error_code = ["no-any-return"] [[tool.mypy.overrides]] @@ -126,21 +127,29 @@ module = [ ignore_missing_imports = true [[tool.mypy.overrides]] -module = ["src.config"] +module = ["config"] implicit_reexport = true [[tool.mypy.overrides]] -module = ["src.core.enums", "src.core.security"] +module = ["core.enums", "core.security"] disable_error_code = ["return-value", "no-any-return"] [[tool.mypy.overrides]] -module = ["src.repositories.*"] +module = ["user.repository", "auth.repository", "core.base_repository"] disable_error_code = ["return-value", "no-any-return", "attr-defined"] [[tool.mypy.overrides]] -module = ["src.factory"] +module = ["user.service", "auth.service"] +disable_error_code = ["no-any-return"] + +[[tool.mypy.overrides]] +module = ["factory"] disable_error_code = ["arg-type"] +[[tool.mypy.overrides]] +module = ["auth.routes"] +disable_error_code = ["misc"] + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true @@ -187,7 +196,9 @@ disable = [ "C0304", # final-newline-missing "C0305", # trailing-newlines "C0411", # wrong-import-order + "C0412", # ungrouped-imports (style preference) "E0401", # import-error (uuid6/structlog/pwdlib not found by pylint) + "E0611", # no-name-in-module (false positive for config re-exports) "E1102", # not-callable (false positive for SQLAlchemy func.now/count) "E1136", # unsubscriptable-object (false positive for generics) "R0801", # similar-lines diff --git a/docs/research/CLAUDE-CODE-PROGRAMMATIC.md b/docs/research/CLAUDE-CODE-PROGRAMMATIC.md new file mode 100644 index 0000000..f0192e8 --- /dev/null +++ b/docs/research/CLAUDE-CODE-PROGRAMMATIC.md @@ -0,0 +1,1054 @@ +# Claude Code Programmatic Usage Research + +**Research Date:** 2025-12-14 +**Purpose:** Comprehensive investigation of using Claude Code programmatically as a backend API for FastAPI applications + +--- + +## Executive Summary + +Claude Code can be used programmatically through three primary approaches: + +1. **CLI with `-p` flag** - Direct subprocess calls for simple automation +2. **Claude Agent SDK** - Production-ready library (Python/TypeScript) that wraps the CLI +3. **FastAPI Integration** - REST API wrapper around Claude Code execution + +**Key Finding:** The Claude Agent SDK (formerly Claude Code SDK) is the recommended approach for FastAPI backend integration. It spawns the Claude CLI as a subprocess and handles authentication, tool execution, session management, and streaming automatically. + +**Context Access:** Claude Code can access database/application context through: +- **Direct prompt strings** (simple, works immediately) +- **MCP servers** (advanced, for repeated database access and efficient token usage) +- **Combination approach** (MCP for data sources, prompts for specific queries) + +**Authentication:** Production deployments should use `ANTHROPIC_API_KEY` environment variable for pay-per-use API billing. Subscription-based authentication requires browser OAuth and is not suitable for headless server environments. + +--- + +## 1. Calling Claude Code from Python Backend + +### Solution Summary + +Use the **Claude Agent SDK for Python** rather than raw subprocess calls. The SDK provides a production-ready abstraction over the Claude Code CLI with built-in tool execution, session management, and streaming support. + +### Detailed Analysis + +#### Understanding the Architecture + +The Claude Agent SDK follows this architecture: + +``` +Your FastAPI App → Claude Agent SDK (Python) → Claude CLI (subprocess) → Anthropic API +``` + +> "The SDK spawns the CLI as a subprocess; the CLI talks to the Anthropic API." - [Claude Code Python SDK](https://adrianomelo.com/posts/claude-code-python-sdk.html) + +The SDK abstracts subprocess management, JSON streaming, and message parsing while providing a rich type system for structured interactions. + +#### Installation and Setup + +**Step 1: Install Claude Code CLI** + +```bash +# macOS/Linux/WSL +curl -fsSL https://claude.ai/install.sh | bash + +# Or via npm +npm install -g @anthropic-ai/claude-code + +# Or via Homebrew (macOS) +brew install --cask claude-code +``` + +**Step 2: Install Claude Agent SDK** + +```bash +pip install claude-agent-sdk +``` + +**Step 3: Set Authentication** + +```bash +export ANTHROPIC_API_KEY=your-api-key-here +``` + +Get your API key from the [Anthropic Console](https://console.anthropic.com/). + +#### Basic Usage Pattern + +```python +import asyncio +from claude_agent_sdk import query, ClaudeAgentOptions + +async def main(): + async for message in query( + prompt="Find and fix the bug in auth.py", + options=ClaudeAgentOptions(allowed_tools=["Read", "Edit", "Bash"]) + ): + print(message) + +asyncio.run(main()) +``` + +#### FastAPI Integration Example + +```python +from fastapi import FastAPI, BackgroundTasks +from pydantic import BaseModel +from claude_agent_sdk import query, ClaudeAgentOptions +import asyncio + +app = FastAPI() + +class PromptRequest(BaseModel): + prompt: str + context: str | None = None + +class ClaudeResponse(BaseModel): + content: str + session_id: str | None = None + +@app.post("/api/claude/generate", response_model=ClaudeResponse) +async def generate_content(request: PromptRequest): + full_prompt = f"{request.context}\n\n{request.prompt}" if request.context else request.prompt + + result_content = "" + session_id = None + + async for message in query( + prompt=full_prompt, + options=ClaudeAgentOptions( + allowed_tools=["Read", "Write", "Edit", "Bash"], + permission_mode="bypassPermissions" + ) + ): + if hasattr(message, 'content'): + result_content += str(message.content) + if hasattr(message, 'session_id'): + session_id = message.session_id + + return ClaudeResponse(content=result_content, session_id=session_id) +``` + +### Alternative Approach: Direct CLI Usage + +For simpler use cases, you can call the CLI directly using the `-p` flag: + +```python +import subprocess +import json + +def call_claude_cli(prompt: str) -> dict: + """ + Call Claude Code CLI with -p flag for headless execution + """ + proc = subprocess.run( + ["claude", "-p", prompt, "--output-format", "json"], + capture_output=True, + text=True, + timeout=300 + ) + + if proc.returncode == 0: + return json.loads(proc.stdout) + else: + raise RuntimeError(f"Claude CLI failed: {proc.stderr}") + +result = call_claude_cli("What files are in this directory?") +``` + +**CLI Flags for Programmatic Use:** + +| Flag | Purpose | +|------|---------| +| `-p` or `--print` | Non-interactive mode, prints response and exits | +| `--output-format json` | Structured JSON output for parsing | +| `--output-format stream-json` | Streaming JSON events | +| `--max-turns N` | Limit agentic turns in non-interactive mode | +| `--verbose` | Enable verbose logging for debugging | +| `--system-prompt "text"` | Replace entire system prompt | +| `--append-system-prompt "text"` | Add to default prompt | + +### Production Best Practices + +1. **Use the SDK over raw subprocess calls** - Better error handling, type safety, session management +2. **Set timeouts** - Prevent hanging requests +3. **Handle streaming** - Process responses incrementally for better UX +4. **Use async/await** - Leverage FastAPI's async capabilities +5. **Implement retry logic** - Handle API rate limits and transient failures +6. **Monitor token usage** - Track costs and optimize context + +--- + +## 2. Database/Context Access for Programmatic Claude Code + +### Solution Summary + +Claude Code has two primary methods for accessing database and application context: + +1. **Direct Prompt Strings** - Pass context directly in the prompt (simple, immediate) +2. **MCP Servers** - Connect to databases via Model Context Protocol (efficient, reusable) + +For production FastAPI backends, a **hybrid approach** works best: use MCP servers for persistent database connections and direct prompts for specific query parameters. + +### Detailed Analysis + +#### Method 1: Passing Context via Prompt Strings + +**How It Works:** + +> "Claude Code supports several methods for providing data: copy and paste directly into prompts (the most common approach), piping into Claude Code (e.g., cat foo.txt | claude) for logs, CSVs, and large data, or telling Claude to pull data via bash commands." - [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) + +**Example: FastAPI Backend Loading Database Context** + +```python +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession +from claude_agent_sdk import query, ClaudeAgentOptions + +@app.post("/api/generate-email") +async def generate_email(user_id: int, db: AsyncSession): + user = await db.execute( + select(User).where(User.id == user_id) + ) + user_data = user.scalar_one() + + context = f""" + User Information: + - Name: {user_data.name} + - Email: {user_data.email} + - Purchase History: {user_data.orders} + - Preferences: {user_data.preferences} + """ + + prompt = "Write a personalized marketing email for this user" + + async for message in query( + prompt=f"{context}\n\n{prompt}", + options=ClaudeAgentOptions(allowed_tools=[]) + ): + # Process response + pass +``` + +**Advantages:** +- Simple and straightforward +- Works immediately without setup +- Full control over context + +**Disadvantages:** +- Repetitive data loading for similar queries +- Can consume large amounts of tokens +- No caching or optimization + +> "Loading entire directories into Claude for every request can be very expensive." - [Claude Context Optimization](https://github.com/zilliztech/claude-context) + +#### Method 2: MCP Server for Database Access + +**How It Works:** + +> "Claude Code can connect to hundreds of external tools and data sources through the Model Context Protocol (MCP), an open source standard for AI-tool integrations, giving Claude Code access to tools, databases, and APIs." - [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) + +**MCP Architecture:** + +``` +Claude Code (Client) → MCP Server → Your Database/API +``` + +The MCP server exposes database operations as "tools" that Claude Code can call programmatically. + +**Example: Database MCP Server** + +> "DBHub by Bytebase is an open-source MCP server that can expose databases (Postgres, MySQL, etc.) to Claude, allowing Claude to directly query the database for schema info or even data." - [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) + +**Setting Up MCP Server for PostgreSQL:** + +```python +from claude_agent_sdk import query, ClaudeAgentOptions + +async def main(): + async for message in query( + prompt="Find emails of 10 random users who used feature ENG-4521", + options=ClaudeAgentOptions( + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_URL": "postgresql://user:pass@localhost:5432/db" + } + } + } + ) + ): + print(message) +``` + +**Token Efficiency:** + +> "Instead of loading entire directories into Claude for every request, Claude Context efficiently stores your codebase in a vector database and only uses related code in context to keep costs manageable." - [Claude Context](https://github.com/zilliztech/claude-context) + +**Context Usage Comparison:** + +- **With all MCP tools enabled:** 143k/200k tokens (72%) - MCP tools consuming 82.0k tokens (41.0%) +- **With selective MCP tools:** 67k/200k tokens (34%) - MCP tools taking only 5.7k tokens (2.8%) + +#### Method 3: Hybrid Approach (Recommended for FastAPI) + +**Best Practice Pattern:** + +1. Configure MCP server for database connection (one-time setup) +2. Pass specific query parameters via prompt +3. Let Claude use MCP tools to fetch relevant data +4. Claude processes and returns results + +**Example Implementation:** + +```python +from fastapi import FastAPI +from claude_agent_sdk import query, ClaudeAgentOptions + +app = FastAPI() + +@app.post("/api/claude/analyze-users") +async def analyze_users(feature_id: str): + async for message in query( + prompt=f"Analyze user engagement for feature {feature_id} using the database", + options=ClaudeAgentOptions( + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_URL": os.getenv("DATABASE_URL") + } + } + }, + allowed_tools=["Read", "Bash"] + ) + ): + # Process streaming response + pass +``` + +### When to Use Which Approach + +| Use Case | Recommended Method | Reason | +|----------|-------------------|--------| +| One-off queries with small data | Direct prompt | Simple, no setup needed | +| Repeated database queries | MCP server | Token efficient, reusable | +| Large datasets | MCP server | Avoids context window limits | +| Mixed sources (DB + files + APIs) | Hybrid | Best of both worlds | +| Production FastAPI backend | Hybrid | Flexible and efficient | + +--- + +## 3. MCP Server Architecture for Programmatic Usage + +### Solution Summary + +When using Claude Code programmatically via subprocess/SDK, you do **NOT** need to mount MCP servers on your FastAPI app (no `app.mount("/mcp", mcp)`). Instead, MCP servers are configured **within the Claude Agent SDK options** and run as separate processes that Claude Code connects to directly. + +### Detailed Analysis + +#### Understanding "Who Calls Who" + +**Critical Distinction:** + +1. **fastapi-mcp** - Exposes your FastAPI endpoints AS MCP tools FOR Claude to call +2. **Claude Agent SDK mcp_servers option** - Connects Claude TO external MCP servers + +These are two different patterns: + +**Pattern A: Claude calls your FastAPI tools (via fastapi-mcp)** +``` +User → Claude Desktop → MCP Proxy → Your FastAPI App (with fastapi-mcp) +``` + +**Pattern B: Your FastAPI app calls Claude (which uses MCP servers)** +``` +User → Your FastAPI App → Claude Agent SDK → Claude CLI → External MCP Server → Database +``` + +For your use case (FastAPI backend calling Claude Code), you want **Pattern B**. + +#### MCP Server Architecture Explanation + +> "The Model Context Protocol defines a client-server architecture for tool use. An MCP server wraps an external service or data source behind a common protocol (with defined actions, or 'tools'), while an MCP client (like Claude Code or other AI agent frameworks) connects to the server to invoke those tools." - [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) + +**Call Flow:** + +``` +1. User clicks button in React +2. React → FastAPI endpoint +3. FastAPI → Claude Agent SDK (query function) +4. SDK spawns Claude CLI subprocess +5. Claude CLI connects to MCP server (separate process) +6. MCP server queries PostgreSQL +7. Results flow back through the chain +``` + +> "Client sends user message to the model. Model analyzes context and decides to call a tool exposed by MCP (or multiple tools). Client forwards the tool call to the MCP server over the chosen transport. Server executes the tool and returns results. Model receives tool output and composes the final answer to the user." - [Create a MCP Server for Claude Code](https://www.cometapi.com/create-a-mcp-server-for-claude-code/) + +#### Configuration in FastAPI Backend + +**You configure MCP servers in the SDK options, not on the FastAPI app:** + +```python +from fastapi import FastAPI +from claude_agent_sdk import query, ClaudeAgentOptions +import os + +app = FastAPI() + +@app.post("/api/generate") +async def generate_content(prompt: str): + async for message in query( + prompt=prompt, + options=ClaudeAgentOptions( + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_URL": os.getenv("DATABASE_URL") + } + }, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"] + } + } + ) + ): + yield message +``` + +**No `app.mount("/mcp", mcp)` needed** because: +- MCP servers are spawned as child processes by the Claude CLI +- They communicate via stdio/SSE/HTTP transports +- They're not part of your FastAPI application's HTTP routes + +#### When Would You Use fastapi-mcp? + +You would use `fastapi-mcp` if you wanted to **expose your FastAPI endpoints to Claude Desktop/CLI as tools**: + +```python +from fastapi import FastAPI +from fastapi_mcp import FastApiMCP + +app = FastAPI() + +@app.get("/users/{user_id}") +async def get_user(user_id: int): + return {"user": user_id} + +mcp = FastApiMCP(app=app) +mcp.mount() + +``` + +Then configure Claude Desktop to use your API: + +```json +{ + "mcpServers": { + "my-api": { + "command": "mcp-proxy", + "args": ["http://localhost:8000/mcp"] + } + } +} +``` + +> "The mcp-proxy proxies requests to a remote MCP server over SSE transport, wrapping your real server and translating Claude's SSE-based requests into regular HTTP requests that your FastAPI app understands." - [FastAPI-MCP Quick Start](https://thedocs.io/fastapi_mcp/quick_start/) + +**This is NOT what you want for your use case.** You want Claude Code to call external MCP servers (like database), not to call your FastAPI app. + +#### Claude Code as Both MCP Client and Server + +Interestingly, Claude Code can act as both: + +> "You can run claude mcp serve to expose Claude Code while Claude Code itself connects to GitHub and Postgres MCP servers. This means Claude Code can act as **both** an MCP client and an MCP server simultaneously, allowing for layered agent architectures." - [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) + +But for your FastAPI backend use case, Claude Code is purely an MCP **client** connecting to database/tool servers. + +--- + +## 4. Production Deployment Considerations + +### Solution Summary + +For production deployment of Claude Code in a FastAPI backend: + +1. **Container Strategy:** Same container as FastAPI (simpler) or separate container (better isolation) +2. **Authentication:** Use `ANTHROPIC_API_KEY` environment variable (not subscription-based OAuth) +3. **Billing:** API pay-per-use is recommended for production over subscription plans +4. **Headless Mode:** Fully supported via environment variable authentication + +### Detailed Analysis + +#### Container Deployment Options + +**Option 1: Same Container (Recommended for MVP)** + +```dockerfile +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y curl && \ + curl -fsSL https://claude.ai/install.sh | bash + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +ENV ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} +ENV DATABASE_URL=${DATABASE_URL} + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**Advantages:** +- Simpler deployment +- Fewer network hops +- Lower latency + +**Disadvantages:** +- Larger container image +- Tight coupling + +**Option 2: Separate Container (Recommended for Production)** + +```yaml + +services: + backend: + build: ./backend + environment: + - CLAUDE_SERVICE_URL=http://claude-service:8001 + depends_on: + - claude-service + - postgres + + claude-service: + build: ./claude-service + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - ./workspace:/workspace + + postgres: + image: postgres:16 + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} +``` + +**Claude Service Implementation:** + +```python + +from fastapi import FastAPI +from claude_agent_sdk import query, ClaudeAgentOptions + +app = FastAPI() + +@app.post("/execute") +async def execute_claude(prompt: str, context: dict = None): + async for message in query( + prompt=prompt, + options=ClaudeAgentOptions( + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": {"POSTGRES_URL": os.getenv("DATABASE_URL")} + } + } + ) + ): + yield message +``` + +**Advantages:** +- Better isolation +- Independent scaling +- Cleaner architecture +- Can reuse claude-service across multiple apps + +#### Authentication in Containerized Environments + +**Critical Finding:** Subscription-based authentication (Claude Pro/Max) is NOT suitable for production containers. + +> "The SDK also supports authentication via third-party API providers like Amazon Bedrock (set CLAUDE_CODE_USE_BEDROCK=1 environment variable) and Google Vertex AI (set CLAUDE_CODE_USE_VERTEX=1 environment variable)." - [Claude Code SDK Python Authentication](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) + +**Authentication Hierarchy:** + +1. **ANTHROPIC_API_KEY (Recommended for Production)** + ```bash + export ANTHROPIC_API_KEY=sk-ant-api03-... + ``` + - Pay-per-use billing + - Headless compatible + - No browser OAuth required + - Works in containers + +2. **Subscription (Claude Pro/Max) - NOT for containers** + - Requires browser login (`claude auth login`) + - OAuth flow with callback + - Not suitable for headless servers + - Container deployment issues + +3. **Long-lived Access Tokens (Alternative)** + ```bash + claude setup-token + export CLAUDE_CODE_OAUTH_TOKEN= + ``` + - Works in containers + - Linked to subscription + - Can be mounted as secret + +**Docker Volume Mounting (Alternative to Environment Variables):** + +> "For Docker containers, you can mount the credential file as a read-only volume using `docker run -v ~/.config/claude-code/auth.json:/root/.config/claude-code/auth.json:ro -it my-dev-image`." - [Claude Code Docker Complete Guide](https://smartscope.blog/en/generative-ai/claude/claude-code-docker-guide/) + +**Recommended Production Pattern:** + +```dockerfile +FROM python:3.12-slim + +RUN curl -fsSL https://claude.ai/install.sh | bash +RUN pip install claude-agent-sdk + +ENV ANTHROPIC_API_KEY="" + +CMD ["python", "app.py"] +``` + +```bash +docker run -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY myapp +``` + +#### Headless Authentication Issues (2025 Status) + +**Known Issues:** + +> "There are known issues with headless mode authentication: Any time I attempt to run claude via claude -p 'test' it returns Invalid API key · Fix external API key. In a non-interactive environment, the CLI still requires executing /login even though an API key is provided." - [GitHub Issue #5666](https://github.com/anthropics/claude-code/issues/5666) + +**Workaround:** + +The Claude Agent SDK handles authentication better than raw CLI calls. Use the SDK instead of direct `claude -p` commands: + +```python + +from claude_agent_sdk import query, ClaudeAgentOptions +import os + +os.environ["ANTHROPIC_API_KEY"] = "your-key" + +async for message in query( + prompt="test", + options=ClaudeAgentOptions(setting_sources=None) +): + print(message) +``` + +**Verification:** + +> "Run /status in Claude Code periodically to verify your current authentication method. To verify if an API key is set as an environment variable, run /status in Claude Code." - [Managing API Key Environment Variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) + +#### Cost Comparison: Subscription vs API + +**Claude Max Subscription:** +- $100/month → 5x usage of Pro +- $200/month → 20x usage of Pro (Opus 4.5 access) + +**Anthropic API:** +- Claude Opus 4.5: $5.00/M input tokens, $25.00/M output tokens +- Claude Sonnet 4.5: $3.00/M input, $15.00/M output (≤200K context) +- Claude Sonnet 4.5: $6.00/M input, $22.50/M output (>200K context) + +**Value Analysis:** + +> "If you max out the $100 Max plan, that's approximately $7,400 worth of API usage equivalent, 74x the amount you pay. You can get $150 worth of API usage for the price of $20 a month with a Claude Pro subscription." - [LLM Subscriptions vs APIs Value](https://www.asad.pw/llm-subscriptions-vs-apis-value-for-money/) + +**Recommendation:** + +> "API is best for production deployments, automated pipelines, backends, or when you want to control exact spend. The API removes per-seat fees and allows unlimited developers behind a single deployment, but you pay per token." - [Claude Code Usage Limits and Subscription Plans](https://www.geeky-gadgets.com/claude-code-usage-limits-pricing-plans-guide-sept-2025/) + +**When to Use Each:** + +| Scenario | Recommendation | Reason | +|----------|----------------|--------| +| Development | Claude Max Subscription | Predictable costs, generous limits | +| CI/CD Automation | Anthropic API | Headless compatible | +| Production Backend | Anthropic API | Pay-per-use, scales with traffic | +| Multiple Developers | Anthropic API | No per-seat fees | +| High-volume | Anthropic API | Better cost control | +| Prototype/MVP | Claude Max Subscription | Faster setup | + +--- + +## 5. Complete Architecture Pattern + +### End-to-End Flow + +``` +┌─────────────┐ +│ React │ +│ Frontend │ +└──────┬──────┘ + │ 1. User clicks button + │ + ▼ +┌─────────────────────────────────────┐ +│ FastAPI Backend │ +│ ┌──────────────────────────────┐ │ +│ │ 2. Load context from │ │ +│ │ PostgreSQL │ │ +│ │ │ │ +│ │ user_data = await db.query() │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 3. Call Claude Agent SDK │ │ +│ │ │ │ +│ │ async for msg in query( │ │ +│ │ prompt=prompt, │ │ +│ │ options={ │ │ +│ │ mcp_servers={ │ │ +│ │ "postgres": {...} │ │ +│ │ } │ │ +│ │ } │ │ +│ │ ) │ │ +│ └──────────────────────────────┘ │ +└──────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Claude Agent SDK │ +│ (Python Library) │ +└──────────┬──────────────┘ + │ 4. Spawns subprocess + │ + ▼ +┌─────────────────────────┐ +│ Claude Code CLI │ +│ (claude binary) │ +└──────────┬──────────────┘ + │ 5. API request + │ + ▼ +┌─────────────────────────┐ +│ Anthropic API │ +│ (Claude Opus/Sonnet) │ +└──────────┬──────────────┘ + │ + │ 6. Needs data + │ + ▼ +┌─────────────────────────┐ +│ MCP Server │ +│ (Database Proxy) │ +└──────────┬──────────────┘ + │ 7. SQL query + │ + ▼ +┌─────────────────────────┐ +│ PostgreSQL Database │ +└─────────────────────────┘ +``` + +### Complete Implementation Example + +```python + +from fastapi import FastAPI, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from claude_agent_sdk import query, ClaudeAgentOptions +import os +import json + +app = FastAPI() + +DATABASE_URL = os.getenv("DATABASE_URL") +engine = create_async_engine(DATABASE_URL) +AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class GenerateRequest(BaseModel): + user_id: int + prompt: str + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session + +@app.post("/api/generate-email") +async def generate_email(request: GenerateRequest): + async with AsyncSessionLocal() as db: + result = await db.execute( + select(User).where(User.id == request.user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + context = f""" + User Profile: + - Name: {user.name} + - Email: {user.email} + - Join Date: {user.created_at} + - Total Orders: {len(user.orders)} + - Preferences: {user.preferences} + """ + + full_prompt = f"{context}\n\n{request.prompt}" + + async def stream_response(): + async for message in query( + prompt=full_prompt, + options=ClaudeAgentOptions( + allowed_tools=[], + permission_mode="bypassPermissions", + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_URL": DATABASE_URL + } + } + } + ) + ): + if hasattr(message, 'content'): + yield json.dumps({"content": message.content}) + "\n" + + return StreamingResponse(stream_response(), media_type="application/x-ndjson") + +@app.post("/api/analyze-database") +async def analyze_database(query_prompt: str): + async def stream_response(): + async for message in query( + prompt=f"Using the postgres MCP server, {query_prompt}", + options=ClaudeAgentOptions( + mcp_servers={ + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_URL": os.getenv("DATABASE_URL") + } + } + } + ) + ): + if hasattr(message, 'content'): + yield json.dumps({"data": message.content}) + "\n" + + return StreamingResponse(stream_response(), media_type="application/x-ndjson") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +### React Frontend Integration + +```typescript + +import { useState } from 'react'; + +export function EmailGenerator() { + const [loading, setLoading] = useState(false); + const [content, setContent] = useState(''); + + const handleGenerate = async () => { + setLoading(true); + setContent(''); + + const response = await fetch('/api/generate-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 123, + prompt: 'Write a personalized welcome email' + }) + }); + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader!.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n').filter(Boolean); + + for (const line of lines) { + const data = JSON.parse(line); + setContent(prev => prev + data.content); + } + } + + setLoading(false); + }; + + return ( +
+ +
{content}
+
+ ); +} +``` + +### Docker Compose Setup + +```yaml + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: myapp + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + backend: + build: ./backend + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@postgres:5432/myapp + depends_on: + - postgres + ports: + - "8000:8000" + volumes: + - ./backend:/app + + frontend: + build: ./frontend + environment: + - VITE_API_URL=http://localhost:8000 + ports: + - "5173:5173" + volumes: + - ./frontend:/app + +volumes: + postgres_data: +``` + +### Backend Dockerfile + +```dockerfile + +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y curl nodejs npm git && \ + curl -fsSL https://claude.ai/install.sh | bash && \ + apt-get clean + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONUNBUFFERED=1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +--- + +## Key Takeaways + +### 1. Use the Claude Agent SDK (Not Raw CLI) + +The SDK provides production-ready abstractions over subprocess management, authentication, streaming, and error handling. It's the recommended approach for FastAPI backends. + +### 2. MCP Servers Are Optional But Powerful + +For simple prompts with small context, pass data directly in the prompt. For repeated database access or large datasets, configure MCP servers within the SDK options. + +### 3. MCP Servers Are NOT Mounted on FastAPI + +You don't use `app.mount("/mcp", mcp)` for this use case. MCP servers are configured in the `ClaudeAgentOptions` and run as separate processes that Claude Code connects to. + +### 4. Use API Key Authentication for Production + +Set `ANTHROPIC_API_KEY` environment variable. Subscription-based authentication (Claude Pro/Max) requires browser OAuth and doesn't work well in containerized headless environments. + +### 5. API Billing Is Better for Production + +Pay-per-use API billing scales better than subscriptions for production backends. It removes per-seat fees and provides precise cost control. + +### 6. Streaming Is Important for UX + +Implement streaming responses to show progress to users. The SDK supports async streaming out of the box. + +### 7. Session Management Enables Context + +Use session IDs to maintain conversation context across multiple requests. This enables multi-turn interactions. + +--- + +## Sources and References + +### Official Documentation +- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/api/agent-sdk/overview) - Official SDK documentation +- [Claude Code CLI Reference](https://code.claude.com/docs/en/cli-reference) - CLI flags and programmatic usage +- [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) - MCP server configuration +- [Managing API Key Environment Variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code) - Authentication methods +- [Using Claude Code with your Pro or Max plan](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) - Subscription integration + +### Technical Guides +- [Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices) - Official best practices for agentic coding +- [Building Agents with the Claude Agent SDK](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk) - Agent development patterns +- [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) - MCP standard explanation + +### Integration Examples +- [claude-code-fastapi by e2b-dev](https://github.com/e2b-dev/claude-code-fastapi) - FastAPI wrapper for Claude Code with E2B sandboxes +- [claude-code-api by codingworkflow](https://github.com/codingworkflow/claude-code-api) - OpenAI-compatible API gateway +- [fastapi-nextjs-docker-github-actions-reference](https://github.com/raveenb/fastapi-nextjs-docker-github-actions-reference) - Full-stack reference with CI/CD + +### MCP Resources +- [FastAPI-MCP by tadata-org](https://github.com/tadata-org/fastapi_mcp) - Expose FastAPI endpoints as MCP tools +- [How to Use FastAPI MCP Server](https://huggingface.co/blog/lynn-mikami/fastapi-mcp-server) - Integration guide +- [Model Context Protocol Servers](https://github.com/modelcontextprotocol/servers) - Official MCP server implementations + +### Cost and Deployment +- [Claude Pricing Explained](https://intuitionlabs.ai/articles/claude-pricing-plans-api-costs) - Subscription vs API comparison +- [LLM Subscriptions vs APIs Value](https://www.asad.pw/llm-subscriptions-vs-apis-value-for-money/) - Cost analysis +- [Claude Code Docker Complete Guide](https://smartscope.blog/en/generative-ai/claude/claude-code-docker-guide/) - Containerization patterns +- [Configure Claude Code | Docker Docs](https://docs.docker.com/ai/sandboxes/claude-code/) - Official Docker integration + +### Community Resources +- [Claude Code Python SDK Guide](https://adrianomelo.com/posts/claude-code-python-sdk.html) - SDK architecture explanation +- [A Practical Guide to the Python Claude Code SDK](https://www.eesel.ai/blog/python-claude-code-sdk) - 2025 SDK guide +- [Optimising MCP Server Context Usage](https://scottspence.com/posts/optimising-mcp-server-context-usage-in-claude-code) - Token optimization +- [Claude Code as an MCP Server](https://www.ksred.com/claude-code-as-an-mcp-server-an-interesting-capability-worth-understanding/) - Dual role explanation + +### GitHub Issues +- [Issue #5666: Invalid API Key in headless mode](https://github.com/anthropics/claude-code/issues/5666) - Authentication challenges +- [Issue #7100: Document Headless/Remote Authentication](https://github.com/anthropics/claude-code/issues/7100) - CI/CD authentication +- [Issue #1736: Re-authenticating in Docker container](https://github.com/anthropics/claude-code/issues/1736) - Container deployment + +--- + +**Research Completed:** 2025-12-14 +**Last Updated:** 2025-12-14 +**Status:** Comprehensive - Ready for implementation diff --git a/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md b/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md new file mode 100644 index 0000000..cd3f9c7 --- /dev/null +++ b/docs/research/CLAUDE-MAX-SUBSCRIPTION-HEADLESS.md @@ -0,0 +1,725 @@ +# Claude Max Subscription Programmatic Access Research + +**Research Date:** 2025-12-14 +**Focus:** Using Claude Max ($200/month) subscription programmatically in headless FastAPI backend environments +**Critical Question:** Can we use Max subscription instead of paying additional API costs? + +--- + +## Executive Summary + +**ANSWER: YES, BUT WITH SIGNIFICANT CAVEATS** + +Claude Max subscriptions CAN be used programmatically in headless environments through Claude Code's OAuth authentication system. However, this approach exists in a **policy gray area** and has several production limitations: + +### Key Findings: +1. **Technical Feasibility:** ✅ Possible via `CLAUDE_CODE_OAUTH_TOKEN` environment variable +2. **Official Support:** ⚠️ Limited - designed for interactive use, headless support is undocumented +3. **Terms of Service:** ⚠️ Unclear if automated usage violates ToS for consumer subscriptions +4. **Production Viability:** ⚠️ OAuth tokens expire (8-12 hours), requiring refresh mechanisms +5. **Cost Savings:** ✅ Significant - Max subscription vs. per-token API pricing + +--- + +## Solution Summary + +Claude Max subscriptions provide programmatic access through **Claude Code** using OAuth 2.0 authentication. You can authenticate in headless environments by: + +1. Running `claude setup-token` to generate long-lived OAuth tokens +2. Injecting tokens via `CLAUDE_CODE_OAUTH_TOKEN` environment variable in Docker containers +3. Mounting `~/.claude/.credentials.json` as a volume for persistent authentication +4. Using the unofficial `claude_max` Python package that wraps this authentication + +**However:** This approach is NOT officially documented for production server use and may violate consumer subscription terms of service. Anthropic's official position is that API usage should use the separate Anthropic API with commercial terms. + +--- + +## Detailed Analysis + +### 1. Authentication Methods for Headless Environments + +#### Option A: OAuth Token Environment Variable + +**How it works:** +```bash +# Generate token interactively +claude setup-token + +# Export token for headless use +export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-your-token-here" + +# Run Claude Code programmatically +claude status +``` + +**Token Format:** +- Access tokens: `sk-ant-oat01-...` (expires in 8-12 hours) +- Refresh tokens: `sk-ant-ort01-...` (longer-lived, but also expires) + +**Docker Usage:** +```bash +docker run --rm -it \ + -e CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \ + -v $(pwd):/app \ + your-fastapi-image +``` + +**Sources:** +- [Setup Container Authentication - Claude Did This](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide) +- [GitHub Issue #7100 - Headless Authentication Documentation](https://github.com/anthropics/claude-code/issues/7100) +- [Claude Code SDK Docker Repository](https://github.com/cabinlab/claude-code-sdk-docker) + +--- + +#### Option B: Mount Authentication Credentials Volume + +**Directory Structure:** +``` +~/.claude/ +├── .credentials.json # OAuth tokens (access + refresh) +├── settings.local.json # User preferences +└── [project data] +``` + +**Docker Compose Example:** +```yaml +services: + fastapi: + image: your-fastapi-image + volumes: + - ~/.claude:/root/.claude:ro # Mount read-only for security + - ./app:/app +``` + +**Advantages:** +- Automatic token refresh handled by Claude Code +- No need to manually extract tokens +- More secure than environment variables + +**Disadvantages:** +- Requires initial interactive authentication +- Credentials tied to host machine +- Not suitable for cloud deployments without pre-setup + +**Sources:** +- [Docker Docs - Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/) +- [GitHub Issue #1736 - Avoiding Re-authentication](https://github.com/anthropics/claude-code/issues/1736) +- [Medium - Running Claude Code in Docker Containers](https://medium.com/rigel-computer-com/running-claude-code-in-docker-containers-one-project-one-container-1601042bf49c) + +--- + +#### Option C: claude_max Python Package + +**What it is:** +An unofficial Python package published to PyPI (June 15, 2025) that programmatically accesses Claude Code's authentication system to use Max subscriptions for API-style completions. + +**How it works:** +- Implements OAuth 2.0 with PKCE security +- Extracts authentication from Claude Code +- Provides API-compatible interface using subscription credits + +**Usage Pattern:** +```python +from claude_max import ClaudeMax + +# Initialize with Max subscription credentials +client = ClaudeMax() + +# Make API-style calls using subscription +response = client.complete( + model="claude-opus-4-5", + messages=[{"role": "user", "content": "Hello"}] +) +``` + +**Critical Warning:** +> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation, with the only workaround involving fragile OAuth token extraction that may violate ToS." + +**Sources:** +- [How I Built claude_max - Substack Article](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock) +- [Maximizing Claude Max Subscription - Deeplearning.fr](https://deeplearning.fr/maximizing-your-claude-max-subscription-complete-guide-to-automated-workflows-with-claude-code-and-windsurf/) + +--- + +### 2. Mobile Apps and Browser Wrappers Authentication + +**Research Question:** How do mobile Claude apps use Max subscription if not through API? + +**Finding:** Mobile apps and browser extensions use the **same OAuth 2.0 flow** as Claude Code: + +1. User logs in with claude.ai credentials +2. OAuth authorization flow with PKCE +3. Receives access token (`sk-ant-oat01-...`) and refresh token (`sk-ant-ort01-...`) +4. Stores tokens locally for subsequent requests +5. Automatically refreshes when access token expires + +**Key Insight:** +Mobile apps are **consumer-facing interactive applications**, which aligns with the Max subscription terms of service. A headless FastAPI backend is a **server-to-server automation**, which may NOT align with consumer subscription terms. + +**Authentication Endpoint:** +``` +POST https://console.anthropic.com/v1/oauth/token +{ + "grant_type": "refresh_token", + "refresh_token": "sk-ant-ort01-...", + "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +} +``` + +**Sources:** +- [Claude Code Provider - Roo Code Documentation](https://docs.roocode.com/providers/claude-code) +- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh) +- [Unlock Claude API from Claude Pro/Max](https://www.alif.web.id/posts/claude-oauth-api-key) + +--- + +### 3. Long-Lived Access Tokens from Claude Max + +#### Token Lifespan + +| Token Type | Prefix | Lifespan | Purpose | +|------------|--------|----------|---------| +| Access Token | `sk-ant-oat01-...` | 8-12 hours | Authenticate API requests | +| Refresh Token | `sk-ant-ort01-...` | Days to weeks | Obtain new access tokens | +| API Key | `sk-ant-api03-...` | Indefinite | Anthropic API (separate billing) | + +#### `claude setup-token` Command + +**Purpose:** Generate long-lived OAuth tokens for headless/CI/CD environments + +**Usage:** +```bash +# Interactive setup +claude setup-token + +# Output: +# "Your OAuth token: sk-ant-oat01-ABCxyz..." +# "Save this token securely - it provides full access to your account" + +# Use in environment +export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-ABCxyz..." +``` + +**Known Issues:** +- Tokens still expire after 8-12 hours +- No official documentation for production use +- Refresh token handling required for long-running services + +**Bug Reports:** +> "OAuth tokens expire during long-running autonomous tasks, causing 401 authentication_error failures that require manual /login intervention." + +**Sources:** +- [GitHub Issue #8938 - setup-token Not Enough to Authenticate](https://github.com/anthropics/claude-code/issues/8938) +- [GitHub Issue #12447 - OAuth Token Expiration Disrupts Workflows](https://github.com/anthropics/claude-code/issues/12447) +- [Elixir Mix Task Documentation](https://hexdocs.pm/claude_agent_sdk/Mix.Tasks.Claude.SetupToken.html) + +--- + +### 4. Subscription Usage vs API Usage + +#### How to Verify You're Using Subscription (Not API) + +**Method 1: `/status` Command** +```bash +claude status + +# Expected output for subscription: +# Authentication: Claude Max Subscription +# Usage: 45 of 900 messages remaining (resets in 3h 22m) +# Cost: Included in subscription + +# Expected output for API: +# Authentication: API Key +# Usage: $12.45 this month +# Cost: Pay-per-token +``` + +**Method 2: Check Environment Variables** +```bash +# Priority order (first found wins): +# 1. ANTHROPIC_API_KEY → Uses API (costs money) +# 2. CLAUDE_CODE_OAUTH_TOKEN → Uses subscription +# 3. ~/.claude/.credentials.json → Uses subscription + +# Ensure API key is NOT set: +echo $ANTHROPIC_API_KEY +# Should be empty for subscription use +``` + +**Method 3: Check Billing Dashboard** + +Subscription usage shows as: +- **$0.00 per request** in API console +- Messages count against 5-hour rolling window +- No per-token charges + +API usage shows as: +- **$X.XX per request** based on token count +- Cumulative monthly charges +- Detailed token breakdown + +**Rate Limits Comparison:** + +| Plan | Messages (5hr) | Prompts (5hr) | Weekly Capacity | +|------|---------------|---------------|-----------------| +| Max 5x ($100) | ~225 | 50-200 | 140-280hr Sonnet / 15-35hr Opus | +| Max 20x ($200) | ~900 | 200-800 | 240-480hr Sonnet / 24-40hr Opus | +| API | Unlimited* | Unlimited* | Based on tier/spending | + +*API has separate rate limits based on tier + +**Important Note:** +> "Both Pro and Max plans offer usage limits that are shared across Claude and Claude Code, meaning all activity in both tools counts against the same usage limits." + +**Sources:** +- [Using Claude Code with Pro or Max Plan - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) +- [About Claude's Max Plan Usage - Claude Help](https://support.claude.com/en/articles/11014257-about-claude-s-max-plan-usage) +- [GitHub Issue #1721 - Need Usage Gauge](https://github.com/anthropics/claude-code/issues/1721) +- [GitHub Issue #1287 - Misleading Cost Command Output](https://github.com/anthropics/claude-code/issues/1287) + +--- + +### 5. Terms of Service and Policy Analysis + +#### Official Anthropic Position + +**Subscription vs. API Separation:** +> "A paid Claude subscription enhances your chat experience but doesn't include access to the Claude API or Console, requiring separate sign-up for API usage." + +**Consumer vs. Commercial Terms:** +> "The consumer terms updates apply to users on Claude Free, Pro, and Max plans (including when they use Claude Code), but they do not apply to services under Commercial Terms, including API use." + +**Key Implication:** +Max subscriptions fall under **consumer terms**, which are designed for interactive human use. Headless server automation may be considered outside the intended use case. + +#### Policy Gray Area + +**The Problem:** +- Claude Code technically supports headless mode +- `CLAUDE_CODE_OAUTH_TOKEN` exists for automation +- But terms of service don't explicitly permit automated usage for consumer subscriptions + +**Community Concern:** +> "Claude Max subscribers pay $200/month, yet there's no official way to use subscriptions for automation... it's unclear if token extraction workarounds violate ToS. This situation undermines the value proposition of Claude Max for developers who want to integrate Claude Code into workflows." + +**Feature Request (GitHub Issue #1454):** +Title: "Feature Request: Machine to Machine Authentication for Claude Max Subscriptions" + +Status: Open (no official response confirming or denying legitimacy) + +#### Risk Assessment for Production Use + +| Risk Factor | Level | Mitigation | +|-------------|-------|------------| +| Account suspension | Medium | Use for personal projects, not enterprise | +| Token expiration | High | Implement refresh token logic | +| Policy changes | Medium | Monitor Anthropic announcements | +| Lack of support | High | No SLA for subscription-based automation | +| ToS violation | Unknown | Consult legal/Anthropic directly | + +**Recommended Approach:** +1. **For personal/development:** Use Max subscription with awareness of limitations +2. **For production/enterprise:** Use official Anthropic API with commercial terms +3. **For cost optimization:** Evaluate if Max subscription ($200/month) covers your usage vs. API costs + +**Sources:** +- [Feature Request #1454 - Machine to Machine Auth](https://github.com/anthropics/claude-code/issues/1454) +- [Why Pay Separately for API - Claude Help](https://support.anthropic.com/en/articles/9876003-i-have-a-paid-claude-subscription-pro-max-team-or-enterprise-plans-why-do-i-have-to-pay-separately-to-use-the-claude-api-and-console) +- [Updates to Consumer Terms - Anthropic News](https://www.anthropic.com/news/updates-to-our-consumer-terms) +- [Claude vs Claude API vs Claude Code - 16x Engineer](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code) + +--- + +## Alternative Approaches (If Subscription Headless Doesn't Work) + +### Option 1: Hybrid Architecture + +**Design:** +- FastAPI backend uses official Anthropic API for production +- Claude Code (Max subscription) used for development/testing only +- Separate billing but predictable costs + +**Cost Structure:** +- Development: $200/month Max subscription +- Production: Pay-per-token API (budget based on usage) + +--- + +### Option 2: WebSocket Proxy to Local Claude Code + +**Architecture:** +``` +FastAPI Backend (Server) + ↓ WebSocket Connection +Local Claude Code Instance (Developer Machine) + ↓ OAuth Authentication +Claude Max Subscription +``` + +**Advantages:** +- Definitely uses Max subscription +- No ToS concerns (interactive use) + +**Disadvantages:** +- Not suitable for production deployment +- Requires developer machine always running +- Single point of failure + +--- + +### Option 3: Official Enterprise Plan + +**What it is:** +Enterprise plans may have different terms allowing automated usage. + +**Next Steps:** +Contact Anthropic sales to inquire about: +- Enterprise API access using subscription model +- Custom rate limits +- Commercial terms for automated workflows + +**Sources:** +- [Claude Pricing - Anthropic](https://claude.com/pricing) + +--- + +## Production Implementation Guide + +### If Proceeding with Max Subscription Headless (Despite Risks) + +#### Step 1: Generate OAuth Tokens + +```bash +# On development machine +claude setup-token + +# Save output securely +# Access token: sk-ant-oat01-... +# Refresh token: sk-ant-ort01-... (from ~/.claude/.credentials.json) +``` + +#### Step 2: Docker Container Setup + +**Dockerfile:** +```dockerfile +FROM python:3.12-slim + +# Install Claude Code +RUN pip install claude-code + +# Copy application +COPY ./app /app +WORKDIR /app + +# Environment variable will be injected at runtime +ENV CLAUDE_CODE_OAUTH_TOKEN="" + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**docker-compose.yml:** +```yaml +version: '3.8' + +services: + fastapi: + build: . + environment: + # CRITICAL: Do NOT set ANTHROPIC_API_KEY + # It takes precedence over OAuth token + CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} + volumes: + - ./app:/app + ports: + - "8000:8000" + +secrets: + claude_oauth_token: + file: ./secrets/claude_oauth_token.txt +``` + +#### Step 3: Token Refresh Mechanism + +**Python Implementation:** +```python +import httpx +import json +from pathlib import Path + +class ClaudeMaxAuth: + def __init__(self): + self.credentials_path = Path.home() / ".claude" / ".credentials.json" + self.access_token = None + self.refresh_token = None + self._load_credentials() + + def _load_credentials(self): + """Load tokens from credentials file or environment""" + if self.credentials_path.exists(): + with open(self.credentials_path) as f: + creds = json.load(f) + oauth = creds.get("claudeAiOauth", {}) + self.access_token = oauth.get("accessToken") + self.refresh_token = oauth.get("refreshToken") + + async def refresh_access_token(self): + """Refresh expired access token""" + async with httpx.AsyncClient() as client: + response = await client.post( + "https://console.anthropic.com/v1/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + } + ) + response.raise_for_status() + data = response.json() + self.access_token = data["access_token"] + # Update credentials file + self._save_credentials() + + def _save_credentials(self): + """Save updated tokens back to credentials file""" + # Implementation details... + pass +``` + +**Usage in FastAPI:** +```python +from fastapi import FastAPI, Depends +from claude_max import ClaudeMaxAuth + +app = FastAPI() +auth = ClaudeMaxAuth() + +async def get_claude_client(): + """Dependency that ensures fresh tokens""" + # Check if token needs refresh (implement logic) + if auth.token_expired(): + await auth.refresh_access_token() + return auth + +@app.post("/api/chat") +async def chat( + request: ChatRequest, + claude: ClaudeMaxAuth = Depends(get_claude_client) +): + # Use claude.access_token for requests + pass +``` + +#### Step 4: Monitoring and Fallback + +**Monitor subscription usage:** +```python +import subprocess + +def check_subscription_status(): + """Check Claude Code subscription status""" + result = subprocess.run( + ["claude", "status"], + capture_output=True, + text=True + ) + # Parse output to check remaining quota + return result.stdout +``` + +**Implement fallback to API:** +```python +async def make_claude_request(prompt: str): + """Try subscription first, fallback to API""" + try: + # Try subscription + response = await request_via_subscription(prompt) + return response + except QuotaExceededError: + # Fallback to API + logging.warning("Subscription quota exceeded, using API") + return await request_via_api(prompt) +``` + +--- + +## Critical Production Considerations + +### 1. Token Expiration Handling + +**Problem:** +Access tokens expire every 8-12 hours, causing service interruptions. + +**Solutions:** +- Implement automatic refresh before expiration +- Use refresh token rotation +- Monitor token validity and proactively refresh +- Have API key fallback for emergencies + +### 2. Rate Limit Management + +**Max Subscription Limits:** +- 900 messages / 5 hours (Max 20x plan) +- 200-800 prompts / 5 hours for Claude Code + +**Strategies:** +- Implement request queuing +- Track usage against 5-hour rolling window +- Return 429 errors when approaching limit +- Cache responses to reduce requests + +### 3. Shared Quota Between Web and Code + +**Critical Issue:** +> "Usage limits are shared between Claude Code and web claude.ai usage" + +**Implications:** +- If you use claude.ai in browser, it reduces FastAPI quota +- No way to reserve capacity for backend only +- Unpredictable availability during high web usage + +**Mitigation:** +- Use separate Claude account for backend +- Monitor total usage across all channels +- Set up alerts for high usage + +### 4. No Service Level Agreement (SLA) + +**Risk:** +- No guaranteed uptime for subscription-based access +- No support for programmatic usage issues +- Changes can break implementation without notice + +**Mitigation:** +- Don't use for mission-critical services +- Always have API fallback +- Monitor Anthropic announcements + +--- + +## Cost-Benefit Analysis + +### Scenario 1: Light Usage (< $200/month API cost) + +**Recommendation:** Use Anthropic API directly + +**Reasoning:** +- Simpler implementation +- Official support +- Commercial terms +- Predictable costs + +### Scenario 2: Heavy Usage ($200-$1000/month API cost) + +**Recommendation:** Max subscription for development, API for production + +**Reasoning:** +- Max subscription saves development costs +- API provides production reliability +- Total cost still lower than pure API +- Clear separation of concerns + +### Scenario 3: Very Heavy Usage (> $1000/month API cost) + +**Recommendation:** Contact Anthropic for Enterprise plan + +**Reasoning:** +- Custom pricing available +- Potentially subscription-style billing for automation +- Dedicated support +- SLA guarantees + +--- + +## Final Recommendations + +### ✅ Use Max Subscription Headless If: +- Personal project or internal tool +- Comfortable with policy gray area +- Can handle occasional service disruptions +- Have technical ability to implement token refresh +- Usage fits within Max limits ($200/month tier) + +### ❌ Do NOT Use Max Subscription Headless If: +- Production customer-facing service +- Enterprise/commercial application +- Need SLA guarantees +- Usage exceeds Max limits +- Uncomfortable with potential ToS violations + +### ✅ Recommended Approach: +1. **Development:** Use Max subscription ($200/month) +2. **Staging:** Use Max subscription with monitoring +3. **Production:** Use official Anthropic API with commercial terms +4. **Cost Optimization:** Evaluate usage patterns after 1 month + +--- + +## Authoritative Sources Summary + +### Official Documentation: +- [Using Claude Code with Pro or Max - Claude Help](https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan) +- [Docker Configure Claude Code](https://docs.docker.com/ai/sandboxes/claude-code/) +- [Claude Code Development Containers](https://code.claude.com/docs/en/devcontainer) + +### Community Resources: +- [GitHub - claude-code-sdk-docker](https://github.com/cabinlab/claude-code-sdk-docker) +- [Setup Container Authentication Guide](https://claude-did-this.com/claude-hub/getting-started/setup-container-guide) +- [GitHub - claude-token-refresh Tool](https://github.com/RavenStorm-bit/claude-token-refresh) + +### Technical Analysis: +- [How I Built claude_max - Substack](https://idsc2025.substack.com/p/how-i-built-claude_max-to-unlock) +- [Claude vs Claude API vs Claude Code](https://eval.16x.engineer/blog/claude-vs-claude-api-vs-claude-code) + +### GitHub Issues (Feature Requests & Bugs): +- [Issue #1454 - Machine to Machine Auth for Max](https://github.com/anthropics/claude-code/issues/1454) +- [Issue #7100 - Document Headless Authentication](https://github.com/anthropics/claude-code/issues/7100) +- [Issue #12447 - OAuth Token Expiration](https://github.com/anthropics/claude-code/issues/12447) +- [Issue #8938 - setup-token Not Enough](https://github.com/anthropics/claude-code/issues/8938) + +--- + +## Open Questions (Require Official Anthropic Response) + +1. **Is programmatic use of Max subscriptions permitted under consumer ToS?** + - Status: Unclear + - Action: Submit support ticket to Anthropic + +2. **Will Max subscriptions ever support official headless/server authentication?** + - Status: Feature request open (Issue #1454) + - Action: Monitor GitHub issues + +3. **What is the intended use case for `claude setup-token` command?** + - Status: Undocumented + - Action: Request official documentation + +4. **Are there Enterprise plans with subscription-style pricing for automation?** + - Status: Unknown + - Action: Contact Anthropic sales + +--- + +## Conclusion + +**YES, you CAN use Claude Max subscription programmatically in headless FastAPI backends** through OAuth token authentication, but this approach: + +1. ✅ **Works technically** - Multiple methods available +2. ⚠️ **Exists in policy gray area** - ToS unclear on automated usage +3. ⚠️ **Requires token refresh implementation** - Not zero-maintenance +4. ⚠️ **Has production limitations** - No SLA, shared quotas, expiring tokens +5. ✅ **Saves significant costs** - $200/month vs potentially thousands in API fees + +**Recommended path forward:** +1. Prototype with Max subscription to prove concept +2. Measure actual usage patterns +3. Calculate API costs for production scale +4. **If costs < $200/month:** Switch to official API +5. **If costs > $200/month:** Continue with Max but implement robust fallback +6. **If costs >> $1000/month:** Contact Anthropic for Enterprise pricing + +**Critical action item:** Submit support ticket to Anthropic asking explicitly if programmatic use of Max subscriptions for headless server environments is permitted under current ToS. + +--- + +**Research Completed:** 2025-12-14 +**Last Updated:** 2025-12-14 +**Next Review:** Monitor GitHub issues and Anthropic announcements monthly From 20a2755a7f626f989e3b0d4ccfdeec616f1f717c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 14 Dec 2025 13:56:12 -0500 Subject: [PATCH 5/5] refactor complete --- .github/workflows/lint.yml | 9 +++++---- backend/app/admin/py.typed | 0 backend/app/auth/py.typed | 0 backend/app/core/py.typed | 0 backend/app/middleware/py.typed | 0 backend/app/py.typed | 0 backend/app/user/py.typed | 0 7 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 backend/app/admin/py.typed create mode 100644 backend/app/auth/py.typed create mode 100644 backend/app/core/py.typed create mode 100644 backend/app/middleware/py.typed create mode 100644 backend/app/py.typed create mode 100644 backend/app/user/py.typed diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3d27e01..539acc3 100755 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -47,7 +47,7 @@ jobs: id: pylint run: | echo "Running pylint..." - if pylint src > pylint-output.txt 2>&1; then + if pylint app > pylint-output.txt 2>&1; then echo "PYLINT_PASSED=true" >> $GITHUB_ENV echo "No pylint errors found!" else @@ -61,7 +61,7 @@ jobs: id: ruff run: | echo "Running ruff check..." - if ruff check . > ruff-output.txt 2>&1; then + if ruff check app > ruff-output.txt 2>&1; then echo "RUFF_PASSED=true" >> $GITHUB_ENV echo "No ruff errors found!" else @@ -75,14 +75,15 @@ jobs: id: mypy run: | echo "Running mypy..." - if mypy . > mypy-output.txt 2>&1; then + cd app + if mypy . > ../mypy-output.txt 2>&1; then echo "MYPY_PASSED=true" >> $GITHUB_ENV echo "No mypy errors found" else echo "MYPY_PASSED=false" >> $GITHUB_ENV echo "Mypy found issues" fi - cat mypy-output.txt + cat ../mypy-output.txt continue-on-error: true - name: Create Lint Summary diff --git a/backend/app/admin/py.typed b/backend/app/admin/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/py.typed b/backend/app/auth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/py.typed b/backend/app/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/py.typed b/backend/app/middleware/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/py.typed b/backend/app/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/user/py.typed b/backend/app/user/py.typed new file mode 100644 index 0000000..e69de29