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>
409 lines
11 KiB
Markdown
409 lines
11 KiB
Markdown
# 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
|