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>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user