feat: implement GraphQL member management API
Implement complete member management system with GraphQL API per OpenSpec proposal. Only firstName is required; all other fields are optional with conditional validation. Key features: - GraphQL CRUD operations (create, read, update, delete members) - SQLAlchemy 2.0 async with SQLite database - Alembic database migrations - Conditional validation (email/phone validated only when provided) - Member fields: firstName (required), lastName, address, email, phone (all optional) - Database seed script with sample member - FastAPI with Strawberry GraphQL integration Implementation details: - Python 3.11+ with uv package manager - Async database sessions throughout - Proper error handling and validation - Code quality: black, isort, ruff - Comprehensive end-to-end testing completed All 20 tasks from OpenSpec proposal completed successfully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings with environment variable support."""
|
||||
|
||||
database_url: str = "sqlite+aiosqlite:///./clubber.db"
|
||||
debug: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Create async engine
|
||||
async_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
# Declarative base for models
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# Dependency for FastAPI
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide database session for FastAPI dependency injection."""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import strawberry
|
||||
from fastapi import FastAPI
|
||||
from strawberry.fastapi import GraphQLRouter
|
||||
|
||||
from src.resolvers.member import Mutation, Query
|
||||
|
||||
# Create Strawberry schema
|
||||
schema = strawberry.Schema(query=Query, mutation=Mutation)
|
||||
|
||||
# Create GraphQL router
|
||||
graphql_app = GraphQLRouter(schema)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Clubber API",
|
||||
description="Member management system for non-profit societies",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Mount GraphQL endpoint
|
||||
app.include_router(graphql_app, prefix="/graphql")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"message": "Clubber API",
|
||||
"graphql_endpoint": "/graphql",
|
||||
"docs": "/docs",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.models.member import Member
|
||||
|
||||
__all__ = ["Member"]
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.database import Base
|
||||
|
||||
|
||||
class Member(Base):
|
||||
"""Member model for club/society management."""
|
||||
|
||||
__tablename__ = "members"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# Required field
|
||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Optional fields
|
||||
last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
street: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
apartment_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
country: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=func.now(), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=func.now(),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Member(id={self.id}, first_name='{self.first_name}', last_name='{self.last_name}')>"
|
||||
@@ -0,0 +1,194 @@
|
||||
from typing import List, Optional
|
||||
|
||||
import strawberry
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.database import async_session_maker
|
||||
from src.models.member import Member as MemberModel
|
||||
from src.schemas.member import CreateMemberInput, Member, UpdateMemberInput
|
||||
from src.validation import validate_email, validate_first_name, validate_phone
|
||||
|
||||
|
||||
class MemberNotFoundError(Exception):
|
||||
"""Raised when a member is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Query:
|
||||
@strawberry.field
|
||||
async def member(self, id: int) -> Optional[Member]:
|
||||
"""Get a single member by ID."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if db_member is None:
|
||||
return None
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.field
|
||||
async def members(self) -> List[Member]:
|
||||
"""List all members sorted by last name (nulls last), then first name."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).order_by(
|
||||
MemberModel.last_name.nulls_last(), MemberModel.first_name
|
||||
)
|
||||
)
|
||||
db_members = result.scalars().all()
|
||||
|
||||
return [
|
||||
Member(
|
||||
id=m.id,
|
||||
first_name=m.first_name,
|
||||
last_name=m.last_name,
|
||||
street=m.street,
|
||||
apartment_number=m.apartment_number,
|
||||
zip=m.zip,
|
||||
city=m.city,
|
||||
country=m.country,
|
||||
email=m.email,
|
||||
phone=m.phone,
|
||||
created_at=m.created_at,
|
||||
updated_at=m.updated_at,
|
||||
)
|
||||
for m in db_members
|
||||
]
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Mutation:
|
||||
@strawberry.mutation
|
||||
async def create_member(self, input: CreateMemberInput) -> Member:
|
||||
"""Create a new member with validation."""
|
||||
# Validate required field
|
||||
validate_first_name(input.first_name)
|
||||
|
||||
# Conditional validation (only if fields are provided)
|
||||
validate_email(input.email)
|
||||
validate_phone(input.phone)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Create new member
|
||||
db_member = MemberModel(
|
||||
first_name=input.first_name,
|
||||
last_name=input.last_name,
|
||||
street=input.street,
|
||||
apartment_number=input.apartment_number,
|
||||
zip=input.zip,
|
||||
city=input.city,
|
||||
country=input.country,
|
||||
email=input.email,
|
||||
phone=input.phone,
|
||||
)
|
||||
|
||||
session.add(db_member)
|
||||
await session.commit()
|
||||
await session.refresh(db_member)
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
async def update_member(self, input: UpdateMemberInput) -> Member:
|
||||
"""Update an existing member."""
|
||||
async with async_session_maker() as session:
|
||||
# Get member
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == input.id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if db_member is None:
|
||||
raise MemberNotFoundError(f"Member with ID {input.id} not found")
|
||||
|
||||
# Validate if fields are being updated
|
||||
if input.first_name is not None:
|
||||
validate_first_name(input.first_name)
|
||||
validate_email(input.email)
|
||||
validate_phone(input.phone)
|
||||
|
||||
# Update fields if provided
|
||||
if input.first_name is not None:
|
||||
db_member.first_name = input.first_name
|
||||
if input.last_name is not None:
|
||||
db_member.last_name = input.last_name
|
||||
if input.street is not None:
|
||||
db_member.street = input.street
|
||||
if input.apartment_number is not None:
|
||||
db_member.apartment_number = input.apartment_number
|
||||
if input.zip is not None:
|
||||
db_member.zip = input.zip
|
||||
if input.city is not None:
|
||||
db_member.city = input.city
|
||||
if input.country is not None:
|
||||
db_member.country = input.country
|
||||
if input.email is not None:
|
||||
db_member.email = input.email
|
||||
if input.phone is not None:
|
||||
db_member.phone = input.phone
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(db_member)
|
||||
|
||||
return Member(
|
||||
id=db_member.id,
|
||||
first_name=db_member.first_name,
|
||||
last_name=db_member.last_name,
|
||||
street=db_member.street,
|
||||
apartment_number=db_member.apartment_number,
|
||||
zip=db_member.zip,
|
||||
city=db_member.city,
|
||||
country=db_member.country,
|
||||
email=db_member.email,
|
||||
phone=db_member.phone,
|
||||
created_at=db_member.created_at,
|
||||
updated_at=db_member.updated_at,
|
||||
)
|
||||
|
||||
@strawberry.mutation
|
||||
async def delete_member(self, id: int) -> bool:
|
||||
"""Delete a member by ID."""
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(MemberModel).where(MemberModel.id == id)
|
||||
)
|
||||
db_member = result.scalar_one_or_none()
|
||||
|
||||
if db_member is None:
|
||||
raise MemberNotFoundError(f"Member with ID {id} not found")
|
||||
|
||||
await session.delete(db_member)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import strawberry
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Member:
|
||||
"""GraphQL Member type."""
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: Optional[str]
|
||||
street: Optional[str]
|
||||
apartment_number: Optional[str]
|
||||
zip: Optional[str]
|
||||
city: Optional[str]
|
||||
country: Optional[str]
|
||||
email: Optional[str]
|
||||
phone: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class CreateMemberInput:
|
||||
"""Input type for creating a member - only firstName required."""
|
||||
|
||||
first_name: str
|
||||
last_name: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
apartment_number: Optional[str] = None
|
||||
zip: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class UpdateMemberInput:
|
||||
"""Input type for updating a member - all fields optional except id."""
|
||||
|
||||
id: int
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
street: Optional[str] = None
|
||||
apartment_number: Optional[str] = None
|
||||
zip: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
@@ -0,0 +1,33 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when input validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Compiled regex patterns
|
||||
EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||
PHONE_PATTERN = re.compile(r"^\+?[1-9]\d{1,14}$") # E.164 format
|
||||
|
||||
|
||||
def validate_first_name(first_name: str) -> None:
|
||||
"""Ensure firstName is provided and non-empty."""
|
||||
if not first_name or first_name.strip() == "":
|
||||
raise ValidationError("firstName cannot be empty")
|
||||
|
||||
|
||||
def validate_email(email: Optional[str]) -> None:
|
||||
"""Validate email format only if email is provided (not None or empty)."""
|
||||
if email is not None and email != "":
|
||||
if not EMAIL_PATTERN.match(email):
|
||||
raise ValidationError(f"Invalid email format: {email}")
|
||||
|
||||
|
||||
def validate_phone(phone: Optional[str]) -> None:
|
||||
"""Validate phone format only if phone is provided (not None or empty)."""
|
||||
if phone is not None and phone != "":
|
||||
if not PHONE_PATTERN.match(phone):
|
||||
raise ValidationError(f"Invalid phone format: {phone}")
|
||||
Reference in New Issue
Block a user