456 lines
14 KiB
Markdown
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)
|