diff --git a/openspec/changes/add-graphql-member-api/design.md b/openspec/changes/add-graphql-member-api/design.md new file mode 100644 index 0000000..6befde6 --- /dev/null +++ b/openspec/changes/add-graphql-member-api/design.md @@ -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) diff --git a/openspec/changes/add-graphql-member-api/proposal.md b/openspec/changes/add-graphql-member-api/proposal.md new file mode 100644 index 0000000..68a96ee --- /dev/null +++ b/openspec/changes/add-graphql-member-api/proposal.md @@ -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/) diff --git a/openspec/changes/add-graphql-member-api/specs/database-layer/spec.md b/openspec/changes/add-graphql-member-api/specs/database-layer/spec.md new file mode 100644 index 0000000..98cd750 --- /dev/null +++ b/openspec/changes/add-graphql-member-api/specs/database-layer/spec.md @@ -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 diff --git a/openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md b/openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md new file mode 100644 index 0000000..9e33e0c --- /dev/null +++ b/openspec/changes/add-graphql-member-api/specs/graphql-api/spec.md @@ -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 diff --git a/openspec/changes/add-graphql-member-api/specs/member-crud/spec.md b/openspec/changes/add-graphql-member-api/specs/member-crud/spec.md new file mode 100644 index 0000000..afd6d80 --- /dev/null +++ b/openspec/changes/add-graphql-member-api/specs/member-crud/spec.md @@ -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 diff --git a/openspec/changes/add-graphql-member-api/specs/project-setup/spec.md b/openspec/changes/add-graphql-member-api/specs/project-setup/spec.md new file mode 100644 index 0000000..746234e --- /dev/null +++ b/openspec/changes/add-graphql-member-api/specs/project-setup/spec.md @@ -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 ` 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 diff --git a/openspec/changes/add-graphql-member-api/tasks.md b/openspec/changes/add-graphql-member-api/tasks.md new file mode 100644 index 0000000..7ba6182 --- /dev/null +++ b/openspec/changes/add-graphql-member-api/tasks.md @@ -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 +```