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)
|
||||
@@ -0,0 +1,208 @@
|
||||
# Proposal: Add GraphQL Member API
|
||||
|
||||
**Change ID:** `add-graphql-member-api`
|
||||
**Status:** Draft
|
||||
**Created:** 2025-11-20
|
||||
**Author:** System
|
||||
|
||||
## Overview
|
||||
|
||||
This proposal implements the foundational GraphQL API for member management in the Clubber application. It establishes the complete Python project infrastructure and delivers basic CRUD operations for member records through a GraphQL interface backed by SQLite.
|
||||
|
||||
## Motivation
|
||||
|
||||
**Why now?**
|
||||
- The project currently has only documentation; we need working code to validate the architecture
|
||||
- Member management is the core domain entity for a club/society management system
|
||||
- A simple, working API provides a foundation for iterative development
|
||||
|
||||
**What problem does this solve?**
|
||||
- Enables creating, reading, updating, and deleting member records
|
||||
- Provides a type-safe GraphQL API for future client applications
|
||||
- Establishes project structure, tooling, and development patterns
|
||||
- Validates technology stack choices (FastAPI, Strawberry, SQLAlchemy)
|
||||
|
||||
**Why this approach?**
|
||||
- GraphQL provides flexible queries and strong typing for client needs
|
||||
- Strawberry integrates naturally with Python type hints and FastAPI
|
||||
- SQLAlchemy offers database-agnostic code for future PostgreSQL migration
|
||||
- Alembic ensures schema changes are versioned and reproducible
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
**Project Infrastructure:**
|
||||
- Python project setup with uv dependency management
|
||||
- pyproject.toml with all dependencies (FastAPI, Strawberry, SQLAlchemy, Alembic)
|
||||
- Source directory structure (models, schemas, resolvers, services)
|
||||
- Development tooling (black, ruff, isort, pytest)
|
||||
- Testing infrastructure with pytest-asyncio
|
||||
|
||||
**Database Layer:**
|
||||
- SQLAlchemy async models for Member entity
|
||||
- Alembic configuration and initial migration
|
||||
- SQLite database for development
|
||||
- Database connection and session management
|
||||
|
||||
**GraphQL API:**
|
||||
- Strawberry GraphQL schema definition
|
||||
- FastAPI application with GraphQL endpoint
|
||||
- Member type with all fields (name, address, contact)
|
||||
- Query resolvers (getMember, listMembers)
|
||||
- Mutation resolvers (createMember, updateMember, deleteMember)
|
||||
|
||||
**Member Management:**
|
||||
- Member model with:
|
||||
- First name (required)
|
||||
- Last name (optional)
|
||||
- Address fields (street, apartment number, zip, city, country) - all optional
|
||||
- Email address (optional, format validated when provided, non-unique)
|
||||
- Phone number (optional, format validated when provided, non-unique)
|
||||
- CRUD operations with conditional validation
|
||||
- Only firstName required for member creation
|
||||
- Database seed script with one sample member
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- MCP server integration (future enhancement)
|
||||
- Authentication and authorization (future security layer)
|
||||
- Member status tracking (active/inactive/honorary)
|
||||
- Membership and payment tracking (separate features)
|
||||
- Email/phone uniqueness constraints (may add later if needed)
|
||||
- PostgreSQL support (database-agnostic code only)
|
||||
- API rate limiting or advanced security
|
||||
- Frontend/client implementation
|
||||
- Deployment configuration
|
||||
|
||||
### Dependencies
|
||||
|
||||
- No dependencies on other changes (this is foundational)
|
||||
- Future changes will build on this API structure
|
||||
|
||||
## Changes
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Four new capabilities with detailed specifications:
|
||||
|
||||
1. **project-setup** - Python project foundation
|
||||
- Location: `openspec/changes/add-graphql-member-api/specs/project-setup/spec.md`
|
||||
- Establishes pyproject.toml, directory structure, tooling
|
||||
|
||||
2. **database-layer** - SQLAlchemy models and migrations
|
||||
- Location: `openspec/changes/add-graphql-member-api/specs/database-layer/spec.md`
|
||||
- Defines Member model, Alembic setup, database connections
|
||||
|
||||
3. **graphql-api** - GraphQL schema and FastAPI integration
|
||||
- Location: `openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md`
|
||||
- Strawberry schema, types, resolvers, FastAPI endpoint
|
||||
|
||||
4. **member-crud** - Member business operations
|
||||
- Location: `openspec/changes/add-graphql-member-api/specs/member-crud/spec.md`
|
||||
- Create, read, update, delete operations with validation
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None (no existing code to modify)
|
||||
|
||||
### Removed Capabilities
|
||||
|
||||
None
|
||||
|
||||
## Impact Analysis
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
None (this is the first implementation)
|
||||
|
||||
### Migration Path
|
||||
|
||||
Not applicable (greenfield development)
|
||||
|
||||
### Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Technology stack mismatch | High | Align with project.md specifications; validate with simple implementation first |
|
||||
| Database schema changes | Medium | Use Alembic from the start; follow migration best practices |
|
||||
| Validation complexity | Low | Start with simple format validation; enhance later if needed |
|
||||
| Over-engineering | Medium | Keep implementation minimal; no premature abstractions |
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- SQLite is single-writer; acceptable for <1000 members initially
|
||||
- No pagination in v1 (listMembers returns all); add when needed
|
||||
- Database connection pooling deferred until PostgreSQL migration
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- No authentication in v1 (explicitly out of scope)
|
||||
- Basic input validation (email/phone format)
|
||||
- SQL injection protected by SQLAlchemy ORM
|
||||
- GDPR compliance deferred to future auth/privacy features
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] All code passes `ruff` and `black` checks
|
||||
- [ ] All tests pass with `pytest`
|
||||
- [ ] Database migrations apply successfully with `alembic upgrade head`
|
||||
- [ ] GraphQL schema is introspectable via GraphiQL
|
||||
- [ ] Sample member exists in database after seed script
|
||||
- [ ] All CRUD operations work via GraphQL playground
|
||||
- [ ] Documentation includes setup instructions
|
||||
|
||||
### Validation Steps
|
||||
|
||||
1. Clone repository and navigate to project root
|
||||
2. Run `uv sync` to install dependencies
|
||||
3. Run `alembic upgrade head` to create database
|
||||
4. Run `python scripts/seed.py` to create sample member
|
||||
5. Start server with `uv run uvicorn src.main:app --reload`
|
||||
6. Open GraphiQL at http://localhost:8000/graphql
|
||||
7. Execute queries and mutations to verify CRUD operations
|
||||
8. Run `pytest` to verify all tests pass
|
||||
|
||||
### Metrics
|
||||
|
||||
- Code coverage: Minimum 80% for new code
|
||||
- GraphQL schema: 100% of spec requirements implemented
|
||||
- Database migrations: Zero manual SQL required
|
||||
- Setup time: <5 minutes from clone to running server
|
||||
|
||||
## Timeline
|
||||
|
||||
This proposal includes tasks.md with ~15-20 discrete work items, estimated to be completable in sequence. No explicit timeline commitment per OpenSpec conventions.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### REST API instead of GraphQL
|
||||
|
||||
**Rejected because:**
|
||||
- GraphQL provides better flexibility for future client needs
|
||||
- Type safety and introspection are valuable for development
|
||||
- Project.md explicitly specifies Strawberry GraphQL
|
||||
|
||||
### Skip Alembic, use create_all()
|
||||
|
||||
**Rejected because:**
|
||||
- Migrations are essential for production schema evolution
|
||||
- Small cost now, significant benefit later
|
||||
- Aligns with project.md best practices
|
||||
|
||||
### PostgreSQL from the start
|
||||
|
||||
**Rejected because:**
|
||||
- Adds complexity without immediate value
|
||||
- SQLite sufficient for development and testing
|
||||
- Database-agnostic code enables future migration
|
||||
|
||||
## References
|
||||
|
||||
- [project.md](/openspec/project.md) - Technology stack and conventions
|
||||
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
||||
- [Strawberry GraphQL](https://strawberry.rocks/)
|
||||
- [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)
|
||||
- [Alembic](https://alembic.sqlalchemy.org/)
|
||||
@@ -0,0 +1,274 @@
|
||||
# Spec: Database Layer
|
||||
|
||||
**Capability:** database-layer
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2025-11-20
|
||||
|
||||
## Overview
|
||||
|
||||
This capability defines the data persistence layer using SQLAlchemy 2.0 ORM with async support, Alembic for schema migrations, and SQLite for development storage.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SQLAlchemy Member model with async support
|
||||
|
||||
The system MUST define a Member SQLAlchemy model with async session support for persisting member data with name, address, and contact information.
|
||||
|
||||
**Model fields:**
|
||||
- `id`: Integer primary key (auto-increment)
|
||||
- `first_name`: String (max 100 chars, required)
|
||||
- `last_name`: String (max 100 chars, optional)
|
||||
- `street`: String (max 200 chars, optional)
|
||||
- `apartment_number`: String (max 20 chars, optional)
|
||||
- `zip`: String (max 20 chars, optional)
|
||||
- `city`: String (max 100 chars, optional)
|
||||
- `country`: String (max 100 chars, optional)
|
||||
- `email`: String (max 255 chars, optional)
|
||||
- `phone`: String (max 50 chars, optional)
|
||||
- `created_at`: DateTime (auto-set on creation)
|
||||
- `updated_at`: DateTime (auto-update on modification)
|
||||
|
||||
#### Scenario: Member model is defined with proper constraints
|
||||
|
||||
**Given** src/models/member.py exists
|
||||
**When** the Member class is inspected
|
||||
**Then** it inherits from SQLAlchemy Base
|
||||
**And** tablename is "members"
|
||||
**And** first_name has nullable=False
|
||||
**And** all other data fields (last_name, email, phone, address fields) have nullable=True
|
||||
**And** string fields have length constraints via String(N)
|
||||
|
||||
#### Scenario: Timestamps are automatically managed
|
||||
|
||||
**Given** a new Member instance is created
|
||||
**When** the instance is added to session and committed
|
||||
**Then** created_at is set to current UTC timestamp
|
||||
**And** updated_at is set to current UTC timestamp
|
||||
**When** the instance is later modified and committed
|
||||
**Then** updated_at is updated to new UTC timestamp
|
||||
**And** created_at remains unchanged
|
||||
|
||||
#### Scenario: Model supports async operations
|
||||
|
||||
**Given** Member model is defined
|
||||
**When** async database session is used
|
||||
**Then** CRUD operations execute without blocking event loop
|
||||
**And** SQLAlchemy async patterns are followed (select, add, commit)
|
||||
|
||||
### Requirement: Database connection and session management
|
||||
|
||||
The system MUST provide async database connection factory and session management with proper lifecycle handling.
|
||||
|
||||
**Connection configuration:**
|
||||
- Async engine using aiosqlite for SQLite
|
||||
- Connection pooling disabled for SQLite (single-writer)
|
||||
- Echo mode configurable via DEBUG setting
|
||||
|
||||
#### Scenario: Async engine is created on application startup
|
||||
|
||||
**Given** src/database.py defines engine initialization
|
||||
**When** FastAPI app starts
|
||||
**Then** async engine is created with database_url from config
|
||||
**And** engine is configured for SQLite with aiosqlite driver
|
||||
**And** SQL echo is enabled if DEBUG=True
|
||||
|
||||
#### Scenario: Session factory provides isolated sessions
|
||||
|
||||
**Given** async_session_maker is defined
|
||||
**When** resolver requests database session
|
||||
**Then** new AsyncSession is created from factory
|
||||
**And** session is isolated from other concurrent requests
|
||||
**And** session is properly closed after request completes
|
||||
|
||||
#### Scenario: Session lifecycle is managed via dependency injection
|
||||
|
||||
**Given** get_db_session() dependency is defined
|
||||
**When** FastAPI resolver depends on db_session
|
||||
**Then** session is yielded for resolver use
|
||||
**And** session is committed if no exceptions occur
|
||||
**And** session is rolled back if exceptions occur
|
||||
**And** session is closed in finally block
|
||||
|
||||
### Requirement: Alembic database migration setup
|
||||
|
||||
The system MUST use Alembic for version-controlled schema migrations with async support for SQLite database.
|
||||
|
||||
**Alembic configuration:**
|
||||
- Migrations stored in `migrations/versions/`
|
||||
- Environment configured for async operations
|
||||
- Migration template includes docstring and revision metadata
|
||||
|
||||
#### Scenario: Alembic is initialized with project structure
|
||||
|
||||
**Given** alembic init migrations was run
|
||||
**When** migrations/ directory is inspected
|
||||
**Then** migrations/env.py exists with async configuration
|
||||
**And** migrations/versions/ directory exists for migration files
|
||||
**And** alembic.ini contains database connection template
|
||||
|
||||
#### Scenario: Initial migration creates members table
|
||||
|
||||
**Given** Alembic is configured
|
||||
**When** developer runs `alembic revision --autogenerate -m "create members table"`
|
||||
**Then** new migration file is generated in migrations/versions/
|
||||
**And** upgrade() function contains CREATE TABLE for members
|
||||
**And** downgrade() function contains DROP TABLE for members
|
||||
**And** all Member model columns are included
|
||||
|
||||
#### Scenario: Migrations are applied to database
|
||||
|
||||
**Given** migration files exist in migrations/versions/
|
||||
**When** developer runs `alembic upgrade head`
|
||||
**Then** all pending migrations are executed in order
|
||||
**And** members table is created in database
|
||||
**And** alembic_version table tracks current revision
|
||||
**And** command exits with success code
|
||||
|
||||
#### Scenario: Migrations are reversible
|
||||
|
||||
**Given** database is at current migration head
|
||||
**When** developer runs `alembic downgrade -1`
|
||||
**Then** most recent migration is reversed
|
||||
**And** members table is dropped (for initial migration)
|
||||
**And** alembic_version is updated to previous revision
|
||||
|
||||
### Requirement: Database initialization on application startup
|
||||
|
||||
The system MUST verify database connectivity and schema readiness when FastAPI application starts.
|
||||
|
||||
#### Scenario: Application startup checks database connection
|
||||
|
||||
**Given** FastAPI app has startup event handler
|
||||
**When** application starts
|
||||
**Then** database engine connection is tested
|
||||
**And** exception is raised if database is unreachable
|
||||
**And** startup log message confirms database ready
|
||||
|
||||
#### Scenario: Database file is created if missing
|
||||
|
||||
**Given** SQLite database file does not exist
|
||||
**When** application starts
|
||||
**Then** database file is created automatically
|
||||
**And** schema tables exist after migrations run
|
||||
**And** application continues startup normally
|
||||
|
||||
### Requirement: Test database fixtures with isolation
|
||||
|
||||
The system MUST provide pytest fixtures for database testing with transaction rollback to ensure test isolation.
|
||||
|
||||
#### Scenario: Test database session fixture is available
|
||||
|
||||
**Given** tests/conftest.py defines db_session fixture
|
||||
**When** test function requests db_session parameter
|
||||
**Then** isolated AsyncSession is provided
|
||||
**And** session uses in-memory SQLite database (`:memory:`)
|
||||
**And** schema is created before test runs
|
||||
**And** all changes are rolled back after test completes
|
||||
|
||||
#### Scenario: Test database is independent from development database
|
||||
|
||||
**Given** tests use db_session fixture
|
||||
**When** tests create or modify data
|
||||
**Then** changes are not visible in development database
|
||||
**And** test data does not persist between test runs
|
||||
**And** tests can run in parallel without interference
|
||||
|
||||
### Requirement: Database query helpers for common operations
|
||||
|
||||
The system MUST provide reusable query patterns for fetching, creating, updating, and deleting members.
|
||||
|
||||
#### Scenario: Get member by ID query
|
||||
|
||||
**Given** member exists in database with id=1
|
||||
**When** query executes `select(Member).where(Member.id == 1)`
|
||||
**Then** Member instance is returned
|
||||
**When** query executes with non-existent id
|
||||
**Then** None is returned (not exception)
|
||||
|
||||
#### Scenario: List all members query
|
||||
|
||||
**Given** multiple members exist in database
|
||||
**When** query executes `select(Member).order_by(Member.last_name.nulls_last(), Member.first_name)`
|
||||
**Then** all members are returned as list
|
||||
**And** members are sorted by last name (nulls last), then first name
|
||||
**And** empty list is returned if no members exist
|
||||
|
||||
#### Scenario: Update member fields
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** member attributes are modified and session commits
|
||||
**Then** database record is updated
|
||||
**And** updated_at timestamp is refreshed
|
||||
**And** created_at timestamp is unchanged
|
||||
|
||||
#### Scenario: Delete member from database
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** session executes delete(member) and commits
|
||||
**Then** member is removed from database
|
||||
**And** subsequent queries for that id return None
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## Cross-References
|
||||
|
||||
### Depends On
|
||||
- **project-setup** - Requires SQLAlchemy, Alembic, and project structure
|
||||
|
||||
### Enables
|
||||
- **graphql-api** - Provides data models for GraphQL schema
|
||||
- **member-crud** - Provides persistence layer for operations
|
||||
|
||||
### Related To
|
||||
None
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Async patterns:** Use `await session.execute(select(...))` not `session.query(...)`
|
||||
2. **SQLAlchemy 2.0:** Follow modern declarative mapping with Mapped[T] type hints
|
||||
3. **Migration safety:** Always review autogenerated migrations before applying
|
||||
4. **Connection pooling:** Disable for SQLite (use NullPool), enable for PostgreSQL
|
||||
5. **Timestamp handling:** Use `onupdate=func.now()` for updated_at field
|
||||
6. **Test isolation:** Use `@pytest.fixture(scope="function")` for db_session
|
||||
|
||||
## Database Schema (SQL Reference)
|
||||
|
||||
```sql
|
||||
CREATE TABLE members (
|
||||
id INTEGER NOT NULL 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 NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL PRIMARY KEY
|
||||
);
|
||||
```
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] Member model defined in src/models/member.py
|
||||
- [ ] Database connection setup in src/database.py
|
||||
- [ ] Alembic initialized with migrations/ directory
|
||||
- [ ] Initial migration creates members table
|
||||
- [ ] `alembic upgrade head` executes successfully
|
||||
- [ ] SQLite database file created with correct schema
|
||||
- [ ] Async session dependency injection works in FastAPI
|
||||
- [ ] Test fixtures provide isolated database sessions
|
||||
- [ ] All timestamp fields auto-populate correctly
|
||||
- [ ] CRUD operations execute without blocking
|
||||
@@ -0,0 +1,408 @@
|
||||
# Spec: GraphQL API
|
||||
|
||||
**Capability:** graphql-api
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2025-11-20
|
||||
|
||||
## Overview
|
||||
|
||||
This capability defines the GraphQL API layer using Strawberry GraphQL integrated with FastAPI, providing type-safe schema, queries, and mutations for member management.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Strawberry GraphQL schema with Member type
|
||||
|
||||
The system MUST define a GraphQL schema using Strawberry with Member object type mapping to database model fields.
|
||||
|
||||
**Member GraphQL type fields:**
|
||||
- `id`: ID! (non-null unique identifier)
|
||||
- `firstName`: String! (non-null)
|
||||
- `lastName`: String (nullable)
|
||||
- `street`: String (nullable)
|
||||
- `apartmentNumber`: String (nullable)
|
||||
- `zip`: String (nullable)
|
||||
- `city`: String (nullable)
|
||||
- `country`: String (nullable)
|
||||
- `email`: String (nullable)
|
||||
- `phone`: String (nullable)
|
||||
- `createdAt`: DateTime! (non-null timestamp)
|
||||
- `updatedAt`: DateTime! (non-null timestamp)
|
||||
|
||||
#### Scenario: Member type is defined with Strawberry decorator
|
||||
|
||||
**Given** src/schemas/member.py exists
|
||||
**When** Member class is decorated with @strawberry.type
|
||||
**Then** GraphQL type "Member" is registered in schema
|
||||
**And** all fields use camelCase naming (GraphQL convention)
|
||||
**And** field types match SQLAlchemy model types
|
||||
**And** snake_case database fields map to camelCase GraphQL fields
|
||||
|
||||
#### Scenario: GraphQL schema is introspectable
|
||||
|
||||
**Given** FastAPI app with GraphQL endpoint is running
|
||||
**When** client accesses /graphql endpoint
|
||||
**Then** GraphiQL playground is displayed
|
||||
**And** schema introspection reveals Member type
|
||||
**And** all fields and their types are documented
|
||||
|
||||
### Requirement: Input types for mutations
|
||||
|
||||
The system MUST define separate input types for create and update operations with appropriate field requirements.
|
||||
|
||||
**CreateMemberInput fields (only firstName required):**
|
||||
- `firstName`: String!
|
||||
- `lastName`: String
|
||||
- `street`: String
|
||||
- `apartmentNumber`: String
|
||||
- `zip`: String
|
||||
- `city`: String
|
||||
- `country`: String
|
||||
- `email`: String
|
||||
- `phone`: String
|
||||
|
||||
**UpdateMemberInput fields (id required, all others optional):**
|
||||
- `id`: ID!
|
||||
- `firstName`: String
|
||||
- `lastName`: String
|
||||
- `street`: String
|
||||
- `apartmentNumber`: String
|
||||
- `zip`: String
|
||||
- `city`: String
|
||||
- `country`: String
|
||||
- `email`: String
|
||||
- `phone`: String
|
||||
|
||||
#### Scenario: CreateMemberInput enforces required fields
|
||||
|
||||
**Given** CreateMemberInput type is defined
|
||||
**When** client sends mutation without firstName
|
||||
**Then** GraphQL validation error is returned
|
||||
**And** error message indicates firstName is required
|
||||
**When** client sends mutation with only firstName
|
||||
**Then** input validation passes
|
||||
**And** member is created with only firstName populated
|
||||
|
||||
#### Scenario: UpdateMemberInput allows partial updates
|
||||
|
||||
**Given** UpdateMemberInput type is defined
|
||||
**When** client sends mutation with only id and email
|
||||
**Then** only email field is updated in database
|
||||
**And** all other fields remain unchanged
|
||||
**When** client sends mutation without id
|
||||
**Then** GraphQL validation error is returned
|
||||
|
||||
### Requirement: Query resolvers for reading members
|
||||
|
||||
The system MUST provide GraphQL query resolvers for fetching individual members and listing all members.
|
||||
|
||||
**Query operations:**
|
||||
- `member(id: ID!): Member` - Get single member by ID
|
||||
- `members: [Member!]!` - List all members
|
||||
|
||||
#### Scenario: Query single member by ID
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** client executes query:
|
||||
```graphql
|
||||
query {
|
||||
member(id: 1) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
}
|
||||
}
|
||||
```
|
||||
**Then** response contains member data
|
||||
**And** response matches GraphQL Member type structure
|
||||
|
||||
#### Scenario: Query member with non-existent ID
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** client executes query `member(id: 999)`
|
||||
**Then** response returns null for member field
|
||||
**And** no error is raised (null is valid for nullable return)
|
||||
|
||||
#### Scenario: List all members
|
||||
|
||||
**Given** multiple members exist in database
|
||||
**When** client executes query:
|
||||
```graphql
|
||||
query {
|
||||
members {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
```
|
||||
**Then** response contains array of all members
|
||||
**And** members are sorted by last name, first name
|
||||
**When** no members exist
|
||||
**Then** response contains empty array
|
||||
|
||||
### Requirement: Mutation resolvers for modifying members
|
||||
|
||||
The system MUST provide GraphQL mutation resolvers for creating, updating, and deleting members.
|
||||
|
||||
**Mutation operations:**
|
||||
- `createMember(input: CreateMemberInput!): Member!` - Create new member
|
||||
- `updateMember(input: UpdateMemberInput!): Member!` - Update existing member
|
||||
- `deleteMember(id: ID!): Boolean!` - Delete member
|
||||
|
||||
#### Scenario: Create new member mutation
|
||||
|
||||
**Given** valid CreateMemberInput is provided
|
||||
**When** client executes mutation:
|
||||
```graphql
|
||||
mutation {
|
||||
createMember(input: {
|
||||
firstName: "Jane"
|
||||
lastName: "Smith"
|
||||
street: "123 Main St"
|
||||
zip: "12345"
|
||||
city: "Springfield"
|
||||
country: "USA"
|
||||
email: "jane@example.com"
|
||||
phone: "+15551234567"
|
||||
}) {
|
||||
id
|
||||
firstName
|
||||
email
|
||||
}
|
||||
}
|
||||
```
|
||||
**Then** new member is persisted to database
|
||||
**And** response contains newly created member with generated id
|
||||
**And** createdAt and updatedAt are populated
|
||||
|
||||
#### Scenario: Create member with minimal data (firstName only)
|
||||
|
||||
**Given** CreateMemberInput with only firstName is provided
|
||||
**When** client executes mutation:
|
||||
```graphql
|
||||
mutation {
|
||||
createMember(input: {
|
||||
firstName: "John"
|
||||
}) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
email
|
||||
}
|
||||
}
|
||||
```
|
||||
**Then** new member is created in database
|
||||
**And** firstName is "John"
|
||||
**And** lastName, email, phone, and address fields are null
|
||||
**And** response contains member with null optional fields
|
||||
|
||||
#### Scenario: Create member with invalid input
|
||||
|
||||
**Given** CreateMemberInput has invalid email format
|
||||
**When** client executes createMember mutation
|
||||
**Then** GraphQL error is returned
|
||||
**And** error message indicates validation failure
|
||||
**And** no database record is created
|
||||
|
||||
#### Scenario: Update existing member mutation
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** client executes mutation:
|
||||
```graphql
|
||||
mutation {
|
||||
updateMember(input: {
|
||||
id: 1
|
||||
email: "newemail@example.com"
|
||||
phone: "+15559876543"
|
||||
}) {
|
||||
id
|
||||
email
|
||||
phone
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
```
|
||||
**Then** member email and phone are updated in database
|
||||
**And** updatedAt timestamp is refreshed
|
||||
**And** all other fields remain unchanged
|
||||
|
||||
#### Scenario: Update non-existent member
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** client executes updateMember with id=999
|
||||
**Then** GraphQL error is returned
|
||||
**And** error message indicates "Member not found"
|
||||
|
||||
#### Scenario: Delete member mutation
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** client executes mutation:
|
||||
```graphql
|
||||
mutation {
|
||||
deleteMember(id: 1)
|
||||
}
|
||||
```
|
||||
**Then** member is removed from database
|
||||
**And** response returns true
|
||||
**When** query attempts to fetch deleted member
|
||||
**Then** response returns null
|
||||
|
||||
#### Scenario: Delete non-existent member
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** client executes deleteMember(id: 999)
|
||||
**Then** GraphQL error is returned
|
||||
**And** error message indicates "Member not found"
|
||||
|
||||
### Requirement: FastAPI integration with GraphQL endpoint
|
||||
|
||||
The system MUST integrate Strawberry GraphQL schema with FastAPI application at /graphql path with GraphiQL playground enabled.
|
||||
|
||||
#### Scenario: GraphQL endpoint is mounted on FastAPI app
|
||||
|
||||
**Given** src/main.py creates FastAPI app
|
||||
**When** Strawberry schema is created from Query and Mutation classes
|
||||
**Then** GraphQLRouter is created with schema
|
||||
**And** router is mounted at /graphql path
|
||||
**And** GraphiQL is enabled for development
|
||||
|
||||
#### Scenario: GraphQL playground is accessible
|
||||
|
||||
**Given** FastAPI app is running
|
||||
**When** browser navigates to http://localhost:8000/graphql
|
||||
**Then** GraphiQL interface is displayed
|
||||
**And** schema documentation is available
|
||||
**And** queries can be executed interactively
|
||||
|
||||
#### Scenario: GraphQL endpoint accepts POST requests
|
||||
|
||||
**Given** GraphQL endpoint is configured
|
||||
**When** client sends POST to /graphql with query in body
|
||||
**Then** query is executed against schema
|
||||
**And** JSON response is returned with data or errors
|
||||
|
||||
### Requirement: Error handling with meaningful messages
|
||||
|
||||
The system MUST provide clear, actionable error messages for validation failures, not found errors, and server errors.
|
||||
|
||||
#### Scenario: Input validation error provides field-level details
|
||||
|
||||
**Given** createMember mutation receives invalid email
|
||||
**When** mutation executes
|
||||
**Then** GraphQL error includes message "Invalid email format"
|
||||
**And** error path indicates which input field failed
|
||||
**And** HTTP status code is 400 (Bad Request)
|
||||
|
||||
#### Scenario: Not found error provides resource context
|
||||
|
||||
**Given** member query requests non-existent id=999
|
||||
**When** query executes
|
||||
**Then** error message is "Member with ID 999 not found"
|
||||
**And** error type indicates resource not found
|
||||
|
||||
#### Scenario: Database error is handled gracefully
|
||||
|
||||
**Given** database connection fails during query
|
||||
**When** query executes
|
||||
**Then** GraphQL error indicates server error
|
||||
**And** internal error details are logged
|
||||
**And** client receives generic "Internal server error" message
|
||||
**And** HTTP status code is 500
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## Cross-References
|
||||
|
||||
### Depends On
|
||||
- **project-setup** - Requires FastAPI and Strawberry dependencies
|
||||
- **database-layer** - Requires Member model and database sessions
|
||||
|
||||
### Enables
|
||||
- **member-crud** - Provides API interface for business operations
|
||||
|
||||
### Related To
|
||||
None
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Naming convention:** Use camelCase for GraphQL fields (firstName), snake_case for Python (first_name)
|
||||
2. **Type conversion:** Strawberry automatically converts between Python and GraphQL types
|
||||
3. **Async resolvers:** All resolvers must be async functions for database operations
|
||||
4. **Dependency injection:** Use FastAPI's Depends() for database session in resolvers
|
||||
5. **Error handling:** Raise Python exceptions, Strawberry converts to GraphQL errors
|
||||
6. **GraphiQL:** Disable in production by setting `graphiql=False`
|
||||
|
||||
## GraphQL Schema (SDL Reference)
|
||||
|
||||
```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
|
||||
}
|
||||
|
||||
type Query {
|
||||
member(id: ID!): Member
|
||||
members: [Member!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createMember(input: CreateMemberInput!): Member!
|
||||
updateMember(input: UpdateMemberInput!): Member!
|
||||
deleteMember(id: ID!): Boolean!
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] Member Strawberry type defined in src/schemas/member.py
|
||||
- [ ] CreateMemberInput and UpdateMemberInput defined
|
||||
- [ ] Query class with member and members resolvers
|
||||
- [ ] Mutation class with create, update, delete resolvers
|
||||
- [ ] GraphQL schema created in src/main.py
|
||||
- [ ] /graphql endpoint mounted on FastAPI app
|
||||
- [ ] GraphiQL accessible at http://localhost:8000/graphql
|
||||
- [ ] All queries and mutations execute successfully
|
||||
- [ ] Error messages are clear and actionable
|
||||
- [ ] Schema introspection shows all types correctly
|
||||
@@ -0,0 +1,363 @@
|
||||
# Spec: Member CRUD Operations
|
||||
|
||||
**Capability:** member-crud
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2025-11-20
|
||||
|
||||
## Overview
|
||||
|
||||
This capability defines the business logic for creating, reading, updating, and deleting member records with validation, error handling, and sample data seeding.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Create member with validation
|
||||
|
||||
The system MUST allow creating new members with only firstName required, optionally validating email and phone format when provided.
|
||||
|
||||
**Validation rules:**
|
||||
- firstName: Required field, must be non-empty string
|
||||
- Email (when provided): Must match regex pattern `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
- Phone (when provided): Must match E.164 format `^\+?[1-9]\d{1,14}$`
|
||||
- All other fields (lastName, address fields, email, phone) are optional
|
||||
- Email and phone do NOT require uniqueness checking
|
||||
- Format validation only applies when field is provided (not null/empty)
|
||||
|
||||
#### Scenario: Create member with valid data
|
||||
|
||||
**Given** client provides valid member data:
|
||||
```python
|
||||
{
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"street": "123 Main Street",
|
||||
"apartment_number": "Apt 4B",
|
||||
"zip": "12345",
|
||||
"city": "Springfield",
|
||||
"country": "USA",
|
||||
"email": "john.doe@example.com",
|
||||
"phone": "+15551234567"
|
||||
}
|
||||
```
|
||||
**When** createMember mutation executes
|
||||
**Then** new Member record is created in database
|
||||
**And** member.id is auto-generated
|
||||
**And** member.created_at is set to current timestamp
|
||||
**And** member.updated_at is set to current timestamp
|
||||
**And** member object is returned to client
|
||||
|
||||
#### Scenario: Create member with invalid email format
|
||||
|
||||
**Given** client provides email "invalid-email"
|
||||
**When** createMember mutation executes
|
||||
**Then** validation error is raised
|
||||
**And** error message is "Invalid email format: invalid-email"
|
||||
**And** no database record is created
|
||||
|
||||
#### Scenario: Create member with invalid phone format
|
||||
|
||||
**Given** client provides phone "123-456"
|
||||
**When** createMember mutation executes
|
||||
**Then** validation error is raised
|
||||
**And** error message is "Invalid phone format: 123-456"
|
||||
**And** no database record is created
|
||||
|
||||
#### Scenario: Create member with duplicate email (allowed)
|
||||
|
||||
**Given** member exists with email "john@example.com"
|
||||
**When** client creates another member with email "john@example.com"
|
||||
**Then** new member is created successfully
|
||||
**And** both members coexist in database
|
||||
**And** no uniqueness constraint error occurs
|
||||
|
||||
#### Scenario: Create member without apartment number
|
||||
|
||||
**Given** client omits apartment_number field
|
||||
**When** createMember mutation executes
|
||||
**Then** member is created with apartment_number=None
|
||||
**And** all other fields are populated correctly
|
||||
|
||||
#### Scenario: Create member with only firstName (minimal data)
|
||||
|
||||
**Given** client provides only first_name="Jane"
|
||||
**When** createMember mutation executes
|
||||
**Then** new Member record is created in database
|
||||
**And** member.first_name is "Jane"
|
||||
**And** member.last_name is None
|
||||
**And** member.email is None
|
||||
**And** member.phone is None
|
||||
**And** all address fields (street, zip, city, country, apartment_number) are None
|
||||
**And** created_at and updated_at are populated
|
||||
|
||||
#### Scenario: Create member with optional email (valid format)
|
||||
|
||||
**Given** client provides first_name="John" and email="john@example.com"
|
||||
**When** createMember mutation executes
|
||||
**Then** email format is validated
|
||||
**And** member is created with validated email
|
||||
**And** other optional fields are None
|
||||
|
||||
#### Scenario: Create member with optional email (invalid format)
|
||||
|
||||
**Given** client provides first_name="John" and email="invalid"
|
||||
**When** createMember mutation executes
|
||||
**Then** validation error is raised
|
||||
**And** error message is "Invalid email format: invalid"
|
||||
**And** no database record is created
|
||||
|
||||
### Requirement: Read member by ID
|
||||
|
||||
The system MUST allow fetching a single member by unique identifier with all fields populated.
|
||||
|
||||
#### Scenario: Get existing member by ID
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** member(id=1) query executes
|
||||
**Then** Member object is returned
|
||||
**And** all fields match database record
|
||||
**And** timestamps are in ISO 8601 format
|
||||
|
||||
#### Scenario: Get member with non-existent ID
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** member(id=999) query executes
|
||||
**Then** None is returned
|
||||
**And** no exception is raised
|
||||
|
||||
### Requirement: List all members with sorting
|
||||
|
||||
The system MUST allow fetching all members sorted alphabetically by last name (with nulls last), then first name.
|
||||
|
||||
#### Scenario: List members in sorted order
|
||||
|
||||
**Given** members exist:
|
||||
- id=1: firstName="Alice", lastName="Smith"
|
||||
- id=2: firstName="Bob", lastName="Jones"
|
||||
- id=3: firstName="Charlie", lastName="Smith"
|
||||
- id=4: firstName="Diana", lastName=None
|
||||
**When** members query executes
|
||||
**Then** members are returned in order: Jones, Smith (Alice), Smith (Charlie), Diana (null lastName)
|
||||
**And** sorting is case-insensitive
|
||||
**And** members with null lastName appear at the end
|
||||
|
||||
#### Scenario: List members when database is empty
|
||||
|
||||
**Given** no members exist in database
|
||||
**When** members query executes
|
||||
**Then** empty list is returned
|
||||
**And** no error is raised
|
||||
|
||||
#### Scenario: List members returns all fields
|
||||
|
||||
**Given** members exist in database
|
||||
**When** members query requests all fields
|
||||
**Then** each member includes id, name, address, contact, timestamps
|
||||
**And** no fields are null except apartmentNumber (if not provided)
|
||||
|
||||
### Requirement: Update member with partial field changes
|
||||
|
||||
The system MUST allow updating specific member fields while preserving unchanged fields and refreshing the updated_at timestamp.
|
||||
|
||||
#### Scenario: Update member email
|
||||
|
||||
**Given** member exists with id=1, email="old@example.com"
|
||||
**When** updateMember mutation executes with:
|
||||
```python
|
||||
{"id": 1, "email": "new@example.com"}
|
||||
```
|
||||
**Then** member.email is updated to "new@example.com"
|
||||
**And** member.updated_at is refreshed to current timestamp
|
||||
**And** member.created_at remains unchanged
|
||||
**And** all other fields remain unchanged
|
||||
|
||||
#### Scenario: Update multiple fields simultaneously
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** updateMember mutation provides phone, street, and city
|
||||
**Then** all three fields are updated
|
||||
**And** other fields remain unchanged
|
||||
**And** updated_at is refreshed
|
||||
|
||||
#### Scenario: Update member with invalid email
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** updateMember mutation provides email="invalid"
|
||||
**Then** validation error is raised
|
||||
**And** database record is not modified
|
||||
**And** error message indicates invalid email format
|
||||
|
||||
#### Scenario: Update non-existent member
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** updateMember(id=999) mutation executes
|
||||
**Then** error is raised
|
||||
**And** error message is "Member with ID 999 not found"
|
||||
|
||||
#### Scenario: Update with no field changes
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** updateMember mutation provides only id (no other fields)
|
||||
**Then** no fields are modified
|
||||
**And** updated_at is NOT refreshed (no actual changes)
|
||||
|
||||
### Requirement: Delete member from system
|
||||
|
||||
The system MUST allow permanent deletion of member records by ID.
|
||||
|
||||
#### Scenario: Delete existing member
|
||||
|
||||
**Given** member exists with id=1
|
||||
**When** deleteMember(id=1) mutation executes
|
||||
**Then** member is removed from database
|
||||
**And** mutation returns true
|
||||
**When** subsequent query for id=1 executes
|
||||
**Then** None is returned
|
||||
|
||||
#### Scenario: Delete non-existent member
|
||||
|
||||
**Given** no member exists with id=999
|
||||
**When** deleteMember(id=999) mutation executes
|
||||
**Then** error is raised
|
||||
**And** error message is "Member with ID 999 not found"
|
||||
**And** mutation returns false or raises exception
|
||||
|
||||
### Requirement: Seed database with sample member
|
||||
|
||||
The system MUST provide an idempotent script to populate database with one sample member for development and testing.
|
||||
|
||||
**Sample member data:**
|
||||
- first_name: "Jane"
|
||||
- last_name: "Doe"
|
||||
- street: None (demonstrates optional address fields)
|
||||
- apartment_number: None
|
||||
- zip: None
|
||||
- city: None
|
||||
- country: None
|
||||
- email: "jane.doe@example.com"
|
||||
- phone: None (demonstrates optional phone)
|
||||
|
||||
#### Scenario: Seed script creates sample member
|
||||
|
||||
**Given** database schema exists (migrations applied)
|
||||
**And** no member exists with email "jane.doe@example.com"
|
||||
**When** seed script executes
|
||||
**Then** sample member is created in database
|
||||
**And** script prints "Sample member created successfully"
|
||||
**And** script exits with code 0
|
||||
|
||||
#### Scenario: Seed script is idempotent
|
||||
|
||||
**Given** sample member already exists with email "jane.doe@example.com"
|
||||
**When** seed script executes again
|
||||
**Then** no new member is created
|
||||
**And** existing member is not modified
|
||||
**And** script prints "Sample member already exists"
|
||||
**And** script exits with code 0
|
||||
|
||||
#### Scenario: Seed script verifies sample member
|
||||
|
||||
**Given** seed script has completed
|
||||
**When** query executes for email "jane.doe@example.com"
|
||||
**Then** member exists with all sample data fields populated
|
||||
**And** created_at and updated_at timestamps are valid
|
||||
|
||||
### Requirement: Input validation with clear error messages
|
||||
|
||||
The system MUST validate all inputs and provide actionable error messages identifying the validation failure.
|
||||
|
||||
#### Scenario: Empty required field error
|
||||
|
||||
**Given** createMember mutation has firstName=""
|
||||
**When** mutation executes
|
||||
**Then** validation error is raised
|
||||
**And** error message is "firstName cannot be empty"
|
||||
|
||||
#### Scenario: Field length exceeds maximum
|
||||
|
||||
**Given** createMember mutation has firstName with 150 characters
|
||||
**When** mutation executes
|
||||
**Then** validation error is raised
|
||||
**And** error message indicates maximum length of 100 characters
|
||||
|
||||
#### Scenario: Multiple validation errors
|
||||
|
||||
**Given** createMember mutation has invalid email AND invalid phone
|
||||
**When** mutation executes
|
||||
**Then** validation errors for both fields are returned
|
||||
**And** error messages clearly identify each invalid field
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## Cross-References
|
||||
|
||||
### Depends On
|
||||
- **project-setup** - Requires Python environment and testing infrastructure
|
||||
- **database-layer** - Requires Member model and database sessions
|
||||
- **graphql-api** - Requires GraphQL resolvers for API interface
|
||||
|
||||
### Enables
|
||||
None (this is a terminal capability - enables end-user features)
|
||||
|
||||
### Related To
|
||||
None
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Validation location:** Implement in resolvers before database operations
|
||||
2. **Regex compilation:** Compile email/phone patterns once at module level
|
||||
3. **Error types:** Use custom exception classes (MemberNotFoundError, ValidationError)
|
||||
4. **Transaction handling:** Database session auto-commits on success, rolls back on exception
|
||||
5. **Testing:** Use pytest fixtures for database setup, test each scenario independently
|
||||
6. **Seed script:** Use asyncio.run() to execute async database operations
|
||||
|
||||
## Validation Patterns (Python)
|
||||
|
||||
```python
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
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}$')
|
||||
|
||||
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}")
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] Create member with valid data succeeds
|
||||
- [ ] Create member with only firstName succeeds (all other fields null)
|
||||
- [ ] Invalid email format raises ValidationError (when provided)
|
||||
- [ ] Invalid phone format raises ValidationError (when provided)
|
||||
- [ ] Email/phone validation skipped when fields are null/empty
|
||||
- [ ] Duplicate emails are allowed (no uniqueness constraint)
|
||||
- [ ] Get member by ID returns correct data
|
||||
- [ ] Get non-existent member returns None
|
||||
- [ ] List members returns sorted results (nulls last for lastName)
|
||||
- [ ] Update member modifies only specified fields
|
||||
- [ ] Update refreshes updated_at timestamp
|
||||
- [ ] Update non-existent member raises error
|
||||
- [ ] Delete member removes record from database
|
||||
- [ ] Delete non-existent member raises error
|
||||
- [ ] Seed script creates sample member with some null fields
|
||||
- [ ] Seed script is idempotent (safe to run multiple times)
|
||||
- [ ] All error messages are clear and actionable
|
||||
@@ -0,0 +1,235 @@
|
||||
# Spec: Project Setup
|
||||
|
||||
**Capability:** project-setup
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2025-11-20
|
||||
|
||||
## Overview
|
||||
|
||||
This capability establishes the foundational Python project structure, dependency management, tooling configuration, and development environment for the Clubber application.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Python project structure with uv dependency management
|
||||
|
||||
The project MUST use Python 3.11+ with uv for fast, reliable dependency management and provide a standard src/ layout for code organization.
|
||||
|
||||
#### Scenario: Developer initializes new development environment
|
||||
|
||||
**Given** a developer has cloned the repository
|
||||
**When** they run `uv sync`
|
||||
**Then** all dependencies are installed in a virtual environment
|
||||
**And** the environment is ready for development within 30 seconds
|
||||
|
||||
#### Scenario: Developer runs the application
|
||||
|
||||
**Given** dependencies are installed
|
||||
**When** developer runs `uv run uvicorn src.main:app --reload`
|
||||
**Then** the FastAPI server starts on http://localhost:8000
|
||||
**And** GraphQL playground is available at http://localhost:8000/graphql
|
||||
|
||||
### Requirement: Project configuration in pyproject.toml
|
||||
|
||||
The project MUST define all metadata, dependencies, and tool configurations in pyproject.toml following modern Python packaging standards.
|
||||
|
||||
**Dependencies required:**
|
||||
- fastapi >= 0.104.0 (Web framework)
|
||||
- strawberry-graphql[fastapi] >= 0.215.0 (GraphQL integration)
|
||||
- sqlalchemy[asyncio] >= 2.0.0 (ORM with async support)
|
||||
- aiosqlite >= 0.19.0 (Async SQLite driver)
|
||||
- alembic >= 1.12.0 (Database migrations)
|
||||
- pydantic-settings >= 2.0.0 (Configuration management)
|
||||
- uvicorn[standard] >= 0.24.0 (ASGI server)
|
||||
|
||||
**Development dependencies required:**
|
||||
- pytest >= 7.4.0 (Test framework)
|
||||
- pytest-asyncio >= 0.21.0 (Async test support)
|
||||
- black >= 23.0.0 (Code formatting)
|
||||
- ruff >= 0.1.0 (Linting)
|
||||
- isort >= 5.12.0 (Import sorting)
|
||||
|
||||
#### Scenario: Dependencies are declared with version constraints
|
||||
|
||||
**Given** pyproject.toml exists
|
||||
**When** developer inspects [project.dependencies]
|
||||
**Then** all required packages are listed with minimum versions
|
||||
**And** version constraints allow patch/minor updates
|
||||
|
||||
#### Scenario: Development tools are configured
|
||||
|
||||
**Given** pyproject.toml contains tool configurations
|
||||
**When** developer runs `black .`
|
||||
**Then** code is formatted with line length 88
|
||||
**When** developer runs `ruff check .`
|
||||
**Then** code is linted against configured rules
|
||||
|
||||
### Requirement: Source directory structure following layered architecture
|
||||
|
||||
The project MUST organize code into src/ directory with clear separation of concerns across API, business logic, and data layers.
|
||||
|
||||
**Required directory structure:**
|
||||
```
|
||||
src/
|
||||
├── __init__.py
|
||||
├── main.py # FastAPI application, startup/shutdown
|
||||
├── config.py # Settings and configuration
|
||||
├── database.py # Database connection, session factory
|
||||
├── models/ # SQLAlchemy ORM models
|
||||
│ ├── __init__.py
|
||||
│ └── member.py
|
||||
├── schemas/ # Strawberry GraphQL types
|
||||
│ ├── __init__.py
|
||||
│ └── member.py
|
||||
└── resolvers/ # GraphQL query/mutation resolvers
|
||||
├── __init__.py
|
||||
└── member.py
|
||||
```
|
||||
|
||||
#### Scenario: Code is organized by architectural layer
|
||||
|
||||
**Given** the src/ directory exists
|
||||
**When** a developer navigates the codebase
|
||||
**Then** models/ contains only SQLAlchemy ORM definitions
|
||||
**And** schemas/ contains only Strawberry GraphQL type definitions
|
||||
**And** resolvers/ contains only GraphQL resolver functions
|
||||
**And** each module has clear, single responsibility
|
||||
|
||||
#### Scenario: Main application entry point is defined
|
||||
|
||||
**Given** src/main.py exists
|
||||
**When** the file is imported
|
||||
**Then** it exports a FastAPI `app` instance
|
||||
**And** app includes GraphQL route at /graphql
|
||||
**And** app includes startup event to verify database connection
|
||||
|
||||
### Requirement: Testing infrastructure with pytest
|
||||
|
||||
The project MUST provide pytest configuration for running async tests with database fixtures and code coverage reporting.
|
||||
|
||||
#### Scenario: Async tests can be executed
|
||||
|
||||
**Given** pytest and pytest-asyncio are installed
|
||||
**When** developer runs `pytest`
|
||||
**Then** all tests in tests/ directory are discovered
|
||||
**And** async test functions execute correctly
|
||||
**And** test results are displayed with pass/fail status
|
||||
|
||||
#### Scenario: Database fixtures are available for tests
|
||||
|
||||
**Given** tests/conftest.py defines database fixtures
|
||||
**When** a test function requests `db_session` fixture
|
||||
**Then** an isolated test database session is provided
|
||||
**And** session is rolled back after test completion
|
||||
**And** no test data persists between test runs
|
||||
|
||||
### Requirement: Code quality tooling configuration
|
||||
|
||||
The project MUST configure black, ruff, and isort for consistent code formatting and linting with settings in pyproject.toml.
|
||||
|
||||
**Black configuration:**
|
||||
- Line length: 88 characters
|
||||
- Target version: Python 3.11
|
||||
- Skip string normalization: false
|
||||
|
||||
**Ruff configuration:**
|
||||
- Line length: 88 characters
|
||||
- Select: E, F, W, I (pycodestyle, pyflakes, warnings, isort)
|
||||
- Ignore: E501 (line too long, handled by black)
|
||||
|
||||
**Isort configuration:**
|
||||
- Profile: black (compatible settings)
|
||||
- Multi-line output: 3 (vertical hanging indent)
|
||||
|
||||
#### Scenario: Code formatting is enforced
|
||||
|
||||
**Given** black is configured in pyproject.toml
|
||||
**When** developer runs `black --check .`
|
||||
**Then** all Python files are checked for formatting
|
||||
**And** exit code is 0 if all files are formatted correctly
|
||||
**And** exit code is 1 if any files need formatting
|
||||
|
||||
#### Scenario: Code quality checks pass
|
||||
|
||||
**Given** ruff is configured in pyproject.toml
|
||||
**When** developer runs `ruff check .`
|
||||
**Then** all Python files are linted
|
||||
**And** no errors are reported for compliant code
|
||||
**And** clear error messages are shown for violations
|
||||
|
||||
### Requirement: Environment configuration with .env support
|
||||
|
||||
The project MUST support environment-based configuration using .env files with pydantic-settings for type-safe config values.
|
||||
|
||||
#### Scenario: Default configuration works for development
|
||||
|
||||
**Given** no .env file exists
|
||||
**When** application starts
|
||||
**Then** it uses default SQLite database path
|
||||
**And** it runs in debug mode
|
||||
**And** application starts successfully
|
||||
|
||||
#### Scenario: Environment variables override defaults
|
||||
|
||||
**Given** .env file contains `DATABASE_URL=sqlite+aiosqlite:///./test.db`
|
||||
**When** application loads configuration
|
||||
**Then** settings.database_url equals "sqlite+aiosqlite:///./test.db"
|
||||
**And** custom database path is used
|
||||
|
||||
#### Scenario: Configuration is type-safe
|
||||
|
||||
**Given** src/config.py defines Settings class
|
||||
**When** invalid configuration value is provided
|
||||
**Then** pydantic validation raises clear error
|
||||
**And** application fails fast on startup
|
||||
|
||||
### Requirement: Development scripts for common tasks
|
||||
|
||||
The project MUST provide executable scripts for database seeding and common development tasks.
|
||||
|
||||
**Required scripts:**
|
||||
- scripts/seed.py - Populate database with sample member
|
||||
|
||||
#### Scenario: Seed script creates sample data
|
||||
|
||||
**Given** database schema exists from migrations
|
||||
**When** developer runs `uv run python scripts/seed.py`
|
||||
**Then** sample member is created in database
|
||||
**And** script is idempotent (safe to run multiple times)
|
||||
**And** success message is displayed
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
None (new capability)
|
||||
|
||||
## Cross-References
|
||||
|
||||
### Depends On
|
||||
None (foundational capability)
|
||||
|
||||
### Enables
|
||||
- **database-layer** - Requires project structure and dependencies
|
||||
- **graphql-api** - Requires FastAPI app and Strawberry installation
|
||||
- **member-crud** - Requires complete project infrastructure
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Dependency resolution:** Use `uv add <package>` to ensure lock file is updated
|
||||
2. **Python version:** Minimum 3.11 for modern async features and performance
|
||||
3. **Import ordering:** Isort configured to work with black (no conflicts)
|
||||
4. **Testing:** Pytest configuration in pyproject.toml, not separate pytest.ini
|
||||
5. **Scripts:** Use `uv run` prefix to execute in correct virtual environment
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] `uv sync` completes without errors
|
||||
- [ ] `uv run pytest` discovers and runs tests (even if no tests exist yet)
|
||||
- [ ] `uv run black --check .` passes
|
||||
- [ ] `uv run ruff check .` passes
|
||||
- [ ] `uv run uvicorn src.main:app --reload` starts server
|
||||
- [ ] pyproject.toml includes all required dependencies
|
||||
- [ ] src/ directory structure matches specification
|
||||
- [ ] .env.example file documents available settings
|
||||
@@ -0,0 +1,461 @@
|
||||
# Implementation Tasks: Add GraphQL Member API
|
||||
|
||||
**Change ID:** `add-graphql-member-api`
|
||||
**Status:** Draft
|
||||
**Created:** 2025-11-20
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
This change is implemented through ~18 discrete, verifiable tasks. Each task delivers user-visible progress and includes validation steps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Project Foundation
|
||||
|
||||
### Task 1: Initialize Python project with uv
|
||||
|
||||
**Objective:** Create pyproject.toml and configure uv for dependency management
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv init` to create basic project structure
|
||||
2. Edit pyproject.toml to set name="clubber", version="0.1.0"
|
||||
3. Set Python requirement to ">=3.11"
|
||||
4. Add project metadata (description, authors, license)
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv sync` completes successfully
|
||||
- [ ] pyproject.toml contains project metadata
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add core dependencies
|
||||
|
||||
**Objective:** Install FastAPI, Strawberry, SQLAlchemy, and Alembic
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv add fastapi uvicorn[standard]`
|
||||
2. Run `uv add strawberry-graphql[fastapi]`
|
||||
3. Run `uv add sqlalchemy[asyncio] aiosqlite`
|
||||
4. Run `uv add alembic pydantic-settings`
|
||||
|
||||
**Validation:**
|
||||
- [ ] All packages appear in pyproject.toml dependencies
|
||||
- [ ] `uv sync` resolves dependencies without conflicts
|
||||
- [ ] uv.lock file is generated
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add development dependencies
|
||||
|
||||
**Objective:** Install testing and code quality tools
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv add --dev pytest pytest-asyncio`
|
||||
2. Run `uv add --dev black ruff isort`
|
||||
|
||||
**Validation:**
|
||||
- [ ] Development packages in [tool.uv.dev-dependencies] or similar
|
||||
- [ ] `uv run pytest --version` works
|
||||
- [ ] `uv run black --version` works
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Configure development tools in pyproject.toml
|
||||
|
||||
**Objective:** Set up black, ruff, and isort configurations
|
||||
|
||||
**Steps:**
|
||||
1. Add [tool.black] section with line-length=88, target-version=["py311"]
|
||||
2. Add [tool.ruff] section with line-length=88, select=["E", "F", "W", "I"]
|
||||
3. Add [tool.isort] section with profile="black"
|
||||
4. Add [tool.pytest.ini_options] with asyncio_mode="auto"
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run black --check .` runs (passes even if no code yet)
|
||||
- [ ] `uv run ruff check .` runs
|
||||
- [ ] Tool configurations are in pyproject.toml
|
||||
|
||||
**Dependencies:** Task 3
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Create src directory structure
|
||||
|
||||
**Objective:** Set up layered architecture directories
|
||||
|
||||
**Steps:**
|
||||
1. Create directories: `src/models/`, `src/schemas/`, `src/resolvers/`
|
||||
2. Create empty `__init__.py` in each directory
|
||||
3. Create `src/main.py`, `src/config.py`, `src/database.py` as placeholders
|
||||
|
||||
**Validation:**
|
||||
- [ ] Directory structure matches design.md
|
||||
- [ ] All directories have __init__.py files
|
||||
- [ ] `ls -R src/` shows complete structure
|
||||
|
||||
**Dependencies:** Task 1
|
||||
|
||||
**Can run in parallel with:** Task 2, Task 3
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Database Layer
|
||||
|
||||
### Task 6: Create configuration management
|
||||
|
||||
**Objective:** Implement Settings class with environment variable support
|
||||
|
||||
**Steps:**
|
||||
1. In `src/config.py`, create Settings class using pydantic-settings
|
||||
2. Add fields: database_url (default: "sqlite+aiosqlite:///./clubber.db"), debug (default: False)
|
||||
3. Configure .env file loading
|
||||
4. Create `.env.example` with documented settings
|
||||
|
||||
**Validation:**
|
||||
- [ ] Settings() instantiates with defaults
|
||||
- [ ] DATABASE_URL environment variable overrides default
|
||||
- [ ] .env.example exists and documents all settings
|
||||
|
||||
**Dependencies:** Task 2, Task 5
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Set up database connection and session factory
|
||||
|
||||
**Objective:** Create async SQLAlchemy engine and session management
|
||||
|
||||
**Steps:**
|
||||
1. In `src/database.py`, import SQLAlchemy async components
|
||||
2. Create async_engine using settings.database_url
|
||||
3. Create async_session_maker with AsyncSession
|
||||
4. Implement get_db_session() dependency for FastAPI
|
||||
5. Create declarative Base for models
|
||||
|
||||
**Validation:**
|
||||
- [ ] async_engine is created without errors
|
||||
- [ ] async_session_maker is callable
|
||||
- [ ] get_db_session yields AsyncSession
|
||||
|
||||
**Dependencies:** Task 6
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Define Member SQLAlchemy model
|
||||
|
||||
**Objective:** Create database model for members table with minimal required fields
|
||||
|
||||
**Steps:**
|
||||
1. In `src/models/member.py`, import SQLAlchemy components
|
||||
2. Define Member class inheriting from Base
|
||||
3. Add id field (primary key, auto-increment)
|
||||
4. Add first_name field (nullable=False, required)
|
||||
5. Add optional fields with nullable=True: last_name, street, apartment_number, zip, city, country, email, phone
|
||||
6. Add timestamps: created_at, updated_at with defaults
|
||||
7. Set __tablename__ = "members"
|
||||
|
||||
**Validation:**
|
||||
- [ ] Member class has all fields defined
|
||||
- [ ] Only first_name has nullable=False
|
||||
- [ ] All other data fields have nullable=True
|
||||
- [ ] Field types and length constraints match spec
|
||||
- [ ] `from src.models.member import Member` works
|
||||
|
||||
**Dependencies:** Task 7
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Initialize Alembic for migrations
|
||||
|
||||
**Objective:** Set up Alembic configuration for database versioning
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv run alembic init migrations`
|
||||
2. Edit `migrations/env.py` to import Base and use async operations
|
||||
3. Edit `alembic.ini` to use config.py for database URL
|
||||
4. Update env.py to reference all models (import src.models.member)
|
||||
|
||||
**Validation:**
|
||||
- [ ] `migrations/` directory exists
|
||||
- [ ] `alembic.ini` is configured
|
||||
- [ ] `uv run alembic current` executes without errors
|
||||
|
||||
**Dependencies:** Task 8
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Create initial database migration
|
||||
|
||||
**Objective:** Generate migration to create members table
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv run alembic revision --autogenerate -m "create members table"`
|
||||
2. Review generated migration in `migrations/versions/`
|
||||
3. Verify upgrade() creates members table with all columns
|
||||
4. Verify downgrade() drops members table
|
||||
|
||||
**Validation:**
|
||||
- [ ] Migration file exists in migrations/versions/
|
||||
- [ ] Migration includes all Member model fields
|
||||
- [ ] `uv run alembic upgrade head` creates database
|
||||
- [ ] clubber.db file exists with members table
|
||||
|
||||
**Dependencies:** Task 9
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: GraphQL API Layer
|
||||
|
||||
### Task 11: Define Strawberry GraphQL types
|
||||
|
||||
**Objective:** Create GraphQL schema types with minimal required fields
|
||||
|
||||
**Steps:**
|
||||
1. In `src/schemas/member.py`, import strawberry
|
||||
2. Define @strawberry.type Member with camelCase fields (only firstName, createdAt, updatedAt as non-null)
|
||||
3. Define @strawberry.input CreateMemberInput (only firstName required)
|
||||
4. Define @strawberry.input UpdateMemberInput (all fields optional except id)
|
||||
5. Map snake_case Python to camelCase GraphQL with field aliases
|
||||
|
||||
**Validation:**
|
||||
- [ ] Member type has all fields from spec with correct nullability
|
||||
- [ ] CreateMemberInput requires only firstName
|
||||
- [ ] All other fields are optional (nullable GraphQL types)
|
||||
- [ ] `from src.schemas.member import Member` works
|
||||
|
||||
**Dependencies:** Task 2
|
||||
|
||||
**Can run in parallel with:** Database layer tasks (Task 6-10)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Implement validation helpers
|
||||
|
||||
**Objective:** Create conditional validation functions for optional fields
|
||||
|
||||
**Steps:**
|
||||
1. Create `src/validation.py` (or add to schemas)
|
||||
2. Define EMAIL_PATTERN and PHONE_PATTERN regex
|
||||
3. Implement validate_email(email: Optional[str]) with conditional logic (only validate if provided)
|
||||
4. Implement validate_phone(phone: Optional[str]) with conditional logic (only validate if provided)
|
||||
5. Implement validate_first_name(first_name: str) to ensure non-empty
|
||||
6. Create ValidationError custom exception
|
||||
|
||||
**Validation:**
|
||||
- [ ] validate_email("test@example.com") passes
|
||||
- [ ] validate_email(None) passes (no validation)
|
||||
- [ ] validate_email("invalid") raises ValidationError
|
||||
- [ ] validate_phone("+15551234567") passes
|
||||
- [ ] validate_phone(None) passes (no validation)
|
||||
- [ ] validate_phone("123") raises ValidationError
|
||||
- [ ] validate_first_name("") raises ValidationError
|
||||
|
||||
**Dependencies:** Task 11
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Implement Query resolvers
|
||||
|
||||
**Objective:** Create GraphQL query resolvers for reading members
|
||||
|
||||
**Steps:**
|
||||
1. In `src/resolvers/member.py`, import necessary components
|
||||
2. Define Query class with @strawberry.type
|
||||
3. Implement member(id: ID) -> Optional[Member] resolver
|
||||
4. Implement members() -> List[Member] resolver with sorting
|
||||
5. Use get_db_session dependency for database access
|
||||
|
||||
**Validation:**
|
||||
- [ ] Resolvers are async functions
|
||||
- [ ] Database queries use SQLAlchemy 2.0 select() syntax
|
||||
- [ ] Resolvers convert DB models to GraphQL types
|
||||
|
||||
**Dependencies:** Task 11, Task 12
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Implement Mutation resolvers
|
||||
|
||||
**Objective:** Create GraphQL mutation resolvers for modifying members
|
||||
|
||||
**Steps:**
|
||||
1. In `src/resolvers/member.py`, define Mutation class
|
||||
2. Implement createMember(input: CreateMemberInput) -> Member
|
||||
3. Implement updateMember(input: UpdateMemberInput) -> Member
|
||||
4. Implement deleteMember(id: ID) -> bool
|
||||
5. Add validation calls in createMember and updateMember
|
||||
6. Implement error handling for not found cases
|
||||
|
||||
**Validation:**
|
||||
- [ ] createMember validates email and phone
|
||||
- [ ] updateMember allows partial updates
|
||||
- [ ] deleteMember raises error for non-existent ID
|
||||
- [ ] All mutations use database session correctly
|
||||
|
||||
**Dependencies:** Task 13
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Integrate GraphQL with FastAPI
|
||||
|
||||
**Objective:** Mount Strawberry schema on FastAPI app
|
||||
|
||||
**Steps:**
|
||||
1. In `src/main.py`, import FastAPI and Strawberry components
|
||||
2. Create FastAPI app instance
|
||||
3. Create Strawberry schema from Query and Mutation
|
||||
4. Create GraphQLRouter with schema and graphiql=True
|
||||
5. Mount router at app.add_route("/graphql", ...)
|
||||
6. Add startup event to test database connection
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run uvicorn src.main:app --reload` starts server
|
||||
- [ ] http://localhost:8000/graphql shows GraphiQL
|
||||
- [ ] Schema introspection shows Member type and operations
|
||||
|
||||
**Dependencies:** Task 14
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Testing and Sample Data
|
||||
|
||||
### Task 16: Create test infrastructure
|
||||
|
||||
**Objective:** Set up pytest fixtures for database testing
|
||||
|
||||
**Steps:**
|
||||
1. Create `tests/` directory with `__init__.py`
|
||||
2. Create `tests/conftest.py` with fixtures
|
||||
3. Implement async_db_session fixture using in-memory SQLite
|
||||
4. Implement test database setup/teardown
|
||||
5. Configure pytest-asyncio in pyproject.toml
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run pytest --collect-only` finds tests directory
|
||||
- [ ] Fixtures can be imported by test files
|
||||
- [ ] Test database is isolated from development database
|
||||
|
||||
**Dependencies:** Task 3, Task 10
|
||||
|
||||
---
|
||||
|
||||
### Task 17: Write tests for CRUD operations
|
||||
|
||||
**Objective:** Verify all member operations work correctly including minimal creation
|
||||
|
||||
**Steps:**
|
||||
1. Create `tests/test_member_crud.py`
|
||||
2. Write test_create_member_success (with all fields)
|
||||
3. Write test_create_member_minimal (only firstName)
|
||||
4. Write test_create_member_invalid_email (conditional validation)
|
||||
5. Write test_create_member_no_validation_when_null (email=None doesn't validate)
|
||||
6. Write test_get_member_by_id
|
||||
7. Write test_list_members_sorted (including members with null lastName)
|
||||
8. Write test_update_member_partial
|
||||
9. Write test_delete_member
|
||||
10. Write test_validation_errors
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run pytest` runs all tests
|
||||
- [ ] All tests pass including minimal member creation
|
||||
- [ ] Conditional validation tests pass (null fields skip validation)
|
||||
- [ ] Coverage for resolvers is >80%
|
||||
|
||||
**Dependencies:** Task 16, Task 15
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Create database seed script
|
||||
|
||||
**Objective:** Implement idempotent script to create sample member with partial data
|
||||
|
||||
**Steps:**
|
||||
1. Create `scripts/` directory
|
||||
2. Create `scripts/seed.py` with async main function
|
||||
3. Check if member with email "jane.doe@example.com" exists
|
||||
4. If not, create sample member with firstName, lastName, email (address and phone fields null)
|
||||
5. Demonstrates that only firstName is required, other fields optional
|
||||
6. Add asyncio.run() to execute main()
|
||||
7. Make script executable and add shebang
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run python scripts/seed.py` creates sample member
|
||||
- [ ] Sample member has firstName, lastName, email populated
|
||||
- [ ] Sample member has null values for address and phone fields
|
||||
- [ ] Running script twice doesn't create duplicate
|
||||
- [ ] Sample member is queryable via GraphQL
|
||||
- [ ] Script prints confirmation message
|
||||
|
||||
**Dependencies:** Task 10, Task 15
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Validation and Documentation
|
||||
|
||||
### Task 19: Run code quality checks
|
||||
|
||||
**Objective:** Ensure all code meets formatting and linting standards
|
||||
|
||||
**Steps:**
|
||||
1. Run `uv run black .` to format all code
|
||||
2. Run `uv run isort .` to sort imports
|
||||
3. Run `uv run ruff check .` to verify linting
|
||||
4. Fix any reported issues
|
||||
|
||||
**Validation:**
|
||||
- [ ] `uv run black --check .` exits with code 0
|
||||
- [ ] `uv run ruff check .` reports no errors
|
||||
- [ ] All Python files are consistently formatted
|
||||
|
||||
**Dependencies:** Task 4, Task 18
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Verify end-to-end functionality
|
||||
|
||||
**Objective:** Test complete user workflow via GraphQL playground
|
||||
|
||||
**Steps:**
|
||||
1. Start server with `uv run uvicorn src.main:app --reload`
|
||||
2. Open http://localhost:8000/graphql
|
||||
3. Execute createMember mutation with valid data
|
||||
4. Execute members query to list all members
|
||||
5. Execute updateMember mutation to change email
|
||||
6. Execute deleteMember mutation to remove member
|
||||
7. Verify error handling with invalid inputs
|
||||
|
||||
**Validation:**
|
||||
- [ ] All GraphQL operations work via playground
|
||||
- [ ] Sample member exists after seed script
|
||||
- [ ] Error messages are clear and helpful
|
||||
- [ ] Database persists data between server restarts
|
||||
|
||||
**Dependencies:** Task 18, Task 19
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Total tasks:** 20
|
||||
**Parallel opportunities:**
|
||||
- Tasks 2, 3, 5 can start together after Task 1
|
||||
- Task 11 can run in parallel with Tasks 6-10
|
||||
- Tasks 16-18 can run in parallel
|
||||
|
||||
**Critical path:** Task 1 → Task 2 → Task 6 → Task 7 → Task 8 → Task 9 → Task 10 → Task 15 → Task 20
|
||||
|
||||
**Estimated completion:** Tasks build incrementally; each phase validates previous work.
|
||||
|
||||
**Verification:** After Task 20, run:
|
||||
```bash
|
||||
uv run pytest # All tests pass
|
||||
uv run alembic upgrade head # Database schema current
|
||||
uv run python scripts/seed.py # Sample data loaded
|
||||
uv run uvicorn src.main:app --reload # Server starts
|
||||
# Open http://localhost:8000/graphql # GraphiQL works
|
||||
```
|
||||
Reference in New Issue
Block a user