Files
clubber/openspec/changes/add-graphql-member-api/design.md
T
gurixandClaude f3f5e0282c feat: add OpenSpec proposal for GraphQL member API
Create comprehensive OpenSpec proposal for implementing a minimal GraphQL API
for member management with flexible validation requirements.

Key features:
- Only firstName required for member creation
- Conditional validation (email/phone format validated only when provided)
- Full CRUD operations via GraphQL
- SQLite database with Alembic migrations
- FastAPI + Strawberry GraphQL integration

Includes 4 capability specs:
- project-setup: Python project structure with uv dependency management
- database-layer: SQLAlchemy async models with nullable fields
- graphql-api: Strawberry schema with minimal required fields
- member-crud: Business logic with conditional validation

Implementation broken down into 20 discrete tasks across 5 phases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:29:06 +01:00

456 lines
14 KiB
Markdown

# Design Document: GraphQL Member API
**Change ID:** `add-graphql-member-api`
**Status:** Draft
**Created:** 2025-11-20
## Architecture Overview
This change establishes a layered architecture following the pattern documented in project.md:
```
┌─────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ GraphQL Endpoint (/graphql) │
│ Strawberry Schema │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ GraphQL Resolvers │
│ Query: getMember, listMembers │
│ Mutation: create, update, delete │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Service Layer (Optional) │
│ Business logic & validation │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Data Layer (SQLAlchemy) │
│ Member model, database sessions │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Database (SQLite) │
│ members table, alembic_version │
└─────────────────────────────────────────┘
```
## Key Design Decisions
### 1. Project Structure
**Decision:** Use src/ layout with separate modules for each layer
```
src/
├── __init__.py
├── main.py # FastAPI app, startup logic
├── config.py # Configuration management
├── database.py # Database connection, session factory
├── models/
│ ├── __init__.py
│ └── member.py # SQLAlchemy Member model
├── schemas/
│ ├── __init__.py
│ └── member.py # Strawberry GraphQL types
└── resolvers/
├── __init__.py
└── member.py # GraphQL query/mutation resolvers
```
**Rationale:**
- Clear separation of concerns
- Easy to navigate and understand
- Supports future growth (add events, payments, etc.)
- Aligns with Python community best practices
**Alternatives considered:**
- Flat structure: Rejected, doesn't scale beyond ~5 files
- Feature-based modules (members/, events/): Deferred until we have multiple features
### 2. Database Schema
**Decision:** Single `members` table with embedded address fields
```sql
CREATE TABLE members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100),
street VARCHAR(200),
apartment_number VARCHAR(20),
zip VARCHAR(20),
city VARCHAR(100),
country VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Rationale:**
- Simple query performance (no joins needed)
- Address is not a reusable entity in this domain
- Satisfies all current requirements
- Easy to migrate to separate table later if needed
**Alternatives considered:**
- Separate `addresses` table: Over-engineering for current needs
- JSON column for address: Loses type safety and query capability
- No apartment_number field: Addresses in many countries need this
### 3. GraphQL Schema Design
**Decision:** Input types separate from output types
```graphql
type Member {
id: ID!
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateMemberInput {
firstName: String!
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
input UpdateMemberInput {
id: ID!
firstName: String
lastName: String
street: String
apartmentNumber: String
zip: String
city: String
country: String
email: String
phone: String
}
```
**Rationale:**
- Strawberry best practice
- Input validation separate from output shape
- Only firstName required in CreateMemberInput (enables minimal member creation)
- Optional fields in both inputs enable partial/incremental data entry
- Clear contract for API consumers
**Alternatives considered:**
- Single type for input/output: Doesn't work in GraphQL (different field requirements)
- Nested Address input type: Adds complexity without clear benefit
### 4. Validation Strategy
**Decision:** Conditional validation with minimal required fields
1. **GraphQL layer:** Type system enforces only firstName as required
2. **Python layer:** Conditional format validation (email/phone only when provided)
3. **Database layer:** SQLAlchemy constraints (only firstName NOT NULL, length limits on all)
**Rationale:**
- Minimal friction for initial member creation (only firstName required)
- Validation still protects data quality when fields are provided
- Supports incremental data entry workflows
- GraphQL type system prevents invalid types
- Python validation gives clear error messages for bad formats
**Conditional validation logic:**
```python
def validate_email(email: Optional[str]) -> None:
"""Only validate format if email is provided."""
if email is not None and email != "":
if not EMAIL_REGEX.match(email):
raise ValidationError(f"Invalid email format: {email}")
def validate_phone(phone: Optional[str]) -> None:
"""Only validate format if phone is provided."""
if phone is not None and phone != "":
if not PHONE_REGEX.match(phone):
raise ValidationError(f"Invalid phone format: {phone}")
```
**Email validation pattern:**
```python
EMAIL_REGEX = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
```
**Phone validation pattern:**
```python
PHONE_REGEX = r'^\+?[1-9]\d{1,14}$' # E.164 format (international standard)
```
**Alternatives considered:**
- All fields required: Rejected, too restrictive for real-world data entry
- No validation: Rejected, leads to bad data quality
- Email/phone uniqueness: Explicitly out of scope (business decision)
- Third-party validation library: Overkill for simple formats
### 5. Database Session Management
**Decision:** Async SQLAlchemy sessions with dependency injection
```python
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
```
**Rationale:**
- FastAPI's dependency injection handles lifecycle
- Async/await throughout the stack (no blocking I/O)
- Automatic commit/rollback
- Easy to test (inject mock sessions)
**Alternatives considered:**
- Sync SQLAlchemy: Rejected, blocks event loop
- Manual session management in resolvers: Error-prone, boilerplate
- Context managers in each resolver: Doesn't compose well with FastAPI
### 6. Error Handling
**Decision:** Raise GraphQL-friendly exceptions with clear messages
```python
class MemberNotFoundError(Exception):
"""Raised when member ID doesn't exist"""
pass
# In resolver:
if not member:
raise MemberNotFoundError(f"Member with ID {id} not found")
```
**Rationale:**
- Strawberry automatically converts to GraphQL errors
- Clear error messages help API consumers
- Exceptions are idiomatic Python
**Alternatives considered:**
- Return None for not found: Ambiguous (missing vs. error)
- Custom error types in schema: Over-engineering for simple CRUD
### 7. Seed Data Strategy
**Decision:** Idempotent seed script that checks before inserting
```python
# scripts/seed.py
async def seed_database():
async with async_session_maker() as session:
# Check if sample member exists
result = await session.execute(
select(Member).where(Member.email == "jane.doe@example.com")
)
if result.scalar_one_or_none():
print("Sample member already exists")
return
# Create sample member with minimal required data
# Demonstrates that only firstName is required
member = Member(
first_name="Jane",
last_name="Doe",
email="jane.doe@example.com",
# All other fields (street, zip, city, country, phone) are None
)
session.add(member)
await session.commit()
```
**Rationale:**
- Safe to run multiple times (idempotent)
- Separate script (not in application startup)
- Easy to extend with more sample data
**Alternatives considered:**
- Fixture file (JSON/YAML): More complex to maintain
- Migration with data: Mixing schema and data is anti-pattern
- Application startup seed: Slows every startup unnecessarily
### 8. Testing Strategy
**Decision:** Pytest with async support, focus on resolver tests
```python
# tests/test_member_resolvers.py
@pytest.mark.asyncio
async def test_create_member(db_session):
input_data = CreateMemberInput(...)
member = await create_member(input_data, db_session)
assert member.first_name == "John"
assert member.email == "john@example.com"
```
**Rationale:**
- Pytest is Python standard, excellent async support
- Integration tests via resolver layer (end-to-end within backend)
- Database fixtures for isolated tests
- No need for GraphQL client in tests (test resolvers directly)
**Test coverage targets:**
- Resolvers: 100% (critical business logic)
- Models: Covered by resolver tests
- Validation: 100% (security-relevant)
**Alternatives considered:**
- GraphQL client tests: Slower, more brittle
- Unit test every layer: Diminishing returns for CRUD
- No tests: Rejected, testing is essential
## Data Flow Example: Create Member
1. **Client:** POST to `/graphql` with mutation
```graphql
mutation {
createMember(input: {
firstName: "Jane"
lastName: "Smith"
# ... rest of fields
}) {
id
firstName
email
}
}
```
2. **FastAPI:** Routes to Strawberry schema
3. **Strawberry:** Validates input against CreateMemberInput type
4. **Resolver:** `create_member(input, db_session)`
- Validates email format with regex
- Validates phone format with regex
- Creates SQLAlchemy Member instance
- Adds to session
5. **SQLAlchemy:** Generates INSERT statement
```sql
INSERT INTO members (first_name, last_name, ...)
VALUES (?, ?, ...)
```
6. **Database:** Executes insert, returns generated ID
7. **Resolver:** Returns Member instance
8. **Strawberry:** Serializes Member to GraphQL response
9. **Client:** Receives JSON response
```json
{
"data": {
"createMember": {
"id": "1",
"firstName": "Jane",
"email": "jane@example.com"
}
}
}
```
## Configuration Management
**Decision:** Environment-based configuration with sensible defaults
```python
# src/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./clubber.db"
debug: bool = False
class Config:
env_file = ".env"
settings = Settings()
```
**Rationale:**
- 12-factor app principles
- Easy to override for testing (TEST_DATABASE_URL)
- Pydantic validation for config values
- Sensible defaults for development
## Security Considerations
**Current scope (v1):**
- Input validation (SQL injection via ORM protection)
- Email/phone format validation
- No sensitive data exposure (all fields are readable)
**Deferred to future changes:**
- Authentication/authorization
- Rate limiting
- CORS configuration (localhost only)
- GDPR data handling (right to erasure, data export)
## Performance Considerations
**Current scope:**
- Single-threaded SQLite (acceptable for <1000 members)
- No pagination (listMembers returns all)
- No caching layer
- No query optimization
**Future optimizations (when needed):**
- Add pagination (offset/limit or cursor-based)
- Migrate to PostgreSQL for concurrent writes
- Add Redis caching for read-heavy queries
- Optimize N+1 queries with DataLoader
## Migration Path (Future PostgreSQL)
**Design ensures future migration:**
- SQLAlchemy dialect-agnostic code
- No SQLite-specific SQL
- Async sessions work with asyncpg
- Alembic migrations portable
**Migration steps (future):**
1. Change DATABASE_URL to PostgreSQL
2. Run `alembic upgrade head` on PostgreSQL
3. Export SQLite data with `sqlite3 .dump`
4. Import to PostgreSQL with `psql`
5. Update deployment configuration
## Open Questions
None at this stage. All design decisions are sufficient for v1 implementation.
## References
- [FastAPI Dependency Injection](https://fastapi.tiangolo.com/tutorial/dependencies/)
- [Strawberry GraphQL Best Practices](https://strawberry.rocks/docs/guides/best-practices)
- [SQLAlchemy 2.0 Async](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
- [Alembic Tutorial](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
- [E.164 Phone Number Format](https://en.wikipedia.org/wiki/E.164)