Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
# Runtime configuration
FABLE_ENV=production
FABLE_HOST=0.0.0.0
FABLE_BIND_ADDRESS=127.0.0.1
FABLE_PORT=8000
FABLE_DB_PATH=fable.sqlite3
CORS_ORIGINS=["*"]
SECRET_KEY=change-this-in-production-to-a-secure-random-token
CORS_ORIGINS=[]
FABLE_MAX_REQUEST_BODY_BYTES=262144
FABLE_AUTH_RATE_WINDOW_SECONDS=900
FABLE_AUTH_REGISTER_LIMIT_PER_IP=5
FABLE_AUTH_LOGIN_LIMIT_PER_IP=60
FABLE_AUTH_LOGIN_LIMIT_PER_ACCOUNT_IP=10

# iOS Xcode Release build setting; replace with the deployed HTTPS API root.
FABLE_API_BASE_URL=

# Docker bind-mount ownership; use the host user's IDs when they differ from 1000.
FABLE_UID=1000
Expand Down
45 changes: 44 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Install backend dependencies
run: pip install -r backend/requirements.txt pytest
- name: Run backend tests
run: pytest backend/test_main.py -v
run: PYTHONPATH=.:backend pytest backend/test_main.py -v

docker-build-verification:
runs-on: ubuntu-latest
Expand All @@ -30,3 +30,46 @@ jobs:
uses: actions/checkout@v4
- name: Build backend image
run: docker build -t fable-backend:test -f backend/Dockerfile .

ios-verification:
runs-on: macos-15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Verify Release API URL guard
run: |
if CONFIGURATION=Release FABLE_API_BASE_URL=http://127.0.0.1:8000/api/v1 scripts/validate_ios_release_config.sh; then
echo "The Release URL guard accepted an HTTP loopback URL."
exit 1
fi
if CONFIGURATION=Release FABLE_API_BASE_URL=https://127.1/api/v1 scripts/validate_ios_release_config.sh; then
echo "The Release URL guard accepted an abbreviated loopback address."
exit 1
fi
if CONFIGURATION=Release FABLE_API_BASE_URL= scripts/validate_ios_release_config.sh; then
echo "The Release URL guard accepted a missing API URL."
exit 1
fi
CONFIGURATION=Release FABLE_API_BASE_URL=https://api.example.com/api/v1 scripts/validate_ios_release_config.sh
- name: Install iOS Simulator runtime
run: xcodebuild -downloadPlatform iOS
- name: Build and test iOS app
run: >-
xcodebuild
-project frontend/FableApp.xcodeproj
-scheme FableApp
-destination 'platform=iOS Simulator,name=iPhone 16'
-derivedDataPath build/DerivedData
CODE_SIGNING_ALLOWED=NO
test
- name: Verify Release build
run: >-
xcodebuild
-project frontend/FableApp.xcodeproj
-scheme FableApp
-configuration Release
-destination 'generic/platform=iOS Simulator'
-derivedDataPath build/ReleaseDerivedData
FABLE_API_BASE_URL=https://api.example.com/api/v1
CODE_SIGNING_ALLOWED=NO
build
58 changes: 58 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Project Instructions

## Project

Fable is a native iOS reading and writing app with a FastAPI backend. Keep the client usable with its locally persisted state when the backend is unavailable.

## Tech Stack

- iOS 17+ app built with SwiftUI; Xcode project: `frontend/FableApp.xcodeproj`.
- Swift language mode is 5.0 in the project build settings.
- Local client persistence uses SwiftData. HTTP requests use URLSession.
- Backend uses Python 3.11, FastAPI, Pydantic v2, and SQLite.
- API dependencies and supported minimum versions are listed in `backend/requirements.txt`.

## Architecture

- `FableApp.swift` installs the SwiftData model container; `ContentView.swift` coordinates authentication state and app tabs.
- Feature views live under `frontend/FableApp/Features/`. Keep views focused on presentation and route state changes or network work through the existing store, manager, or service.
- `StoryStore` coordinates catalog, reading, shelf, and local persistence state. `AuthManager` coordinates sessions; `KeychainStore` stores credentials.
- Keep API transport behind `StoryAPIServiceProtocol`; its implementation is `StoryAPIService`.
- Backend requests flow from `backend/api/v1/endpoints/` through Pydantic schemas and `backend/services/` to SQLite via `backend/core/database.py`.
- Keep Swift Codable models and Pydantic DTO aliases aligned with the camelCase JSON API contract.
- SQLite tables are initialized and compatibly extended in `backend/core/database.py`. Preserve existing data when changing schema.

## Project Structure

- `frontend/FableApp/App/` — app lifecycle and root navigation
- `frontend/FableApp/Core/` — shared theme, components, cache, and security
- `frontend/FableApp/Features/` — Auth, Library, Reader, Shelf, and Write
- `backend/api/v1/` — versioned API router and endpoints
- `backend/models/`, `backend/schemas/`, `backend/services/` — backend data and application layers
- `docs/` — architecture, plans, runbooks, and project documentation

## Code Style

- Follow the existing feature-based organization and local naming patterns.
- Swift types and files use PascalCase; Python modules and functions use `snake_case`.
- Use async/await for asynchronous client and provider I/O.
- Validate backend input through Pydantic request models. Derive ownership and other server-controlled values from the authenticated session.
- Keep account-scoped reads and writes filtered by the authenticated user in backend services.
- Do not place credentials or real secrets in source, fixtures, or documentation.

## Verification and Common Tasks

- Backend tests: `pytest backend/test_main.py -v`
- Start the backend from `backend/`: `uvicorn main:app --reload --host 127.0.0.1 --port 8000`
- Open the iOS project on macOS: `open frontend/FableApp.xcodeproj`; select the `FableApp` scheme and an iOS 17+ simulator in Xcode.
- GitHub Actions currently runs the backend test suite and builds the Docker image. The Swift `Features/*/Tests` files are in-app diagnostic helpers, not an Xcode XCTest target.

## Documentation Notes

Some older documentation describes strict MVC or lists a `Package.swift`; the current app root uses `StoryStore` and the repository has no Swift package manifest. Confirm implementation details in source before relying on older architecture diagrams or file inventories.

## Git

- Use a feature branch; do not push commits directly to `main`.
- Recent commit subjects use concise conventional prefixes such as `feat:`, `fix:`, and `docs:`.
- Keep commits focused and list the exact changed file paths when reporting them.
11 changes: 8 additions & 3 deletions backend/api/v1/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Header, status
from fastapi import APIRouter, Header, Request, status
from schemas import (
RegisterRequest,
LoginRequest,
Expand All @@ -10,15 +10,20 @@
ReadingStatsDTO,
)
from services import auth_service, story_service, reading_stats
from services.auth_rate_limit import enforce_auth_rate_limit

router = APIRouter()

@router.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
def register(req: RegisterRequest):
def register(req: RegisterRequest, request: Request):
client_host = request.client.host if request.client else "unknown"
enforce_auth_rate_limit("register", client_host)
return auth_service.register_user(req)

@router.post("/auth/login", response_model=AuthResponse)
def login(req: LoginRequest):
def login(req: LoginRequest, request: Request):
client_host = request.client.host if request.client else "unknown"
enforce_auth_rate_limit("login", client_host, req.email)
return auth_service.login_user(req)

@router.get("/auth/me", response_model=UserDTO)
Expand Down
12 changes: 6 additions & 6 deletions backend/api/v1/endpoints/gutenberg.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@

@router.post("/stories/ingest/{gutenberg_id}", response_model=StoryDTO, status_code=status.HTTP_201_CREATED)
async def ingest_gutenberg_book(
gutenberg_id: int,
genre: Optional[str] = Query(default="Folklore")
gutenberg_id: int = Path(ge=1, le=2_147_483_647),
genre: Optional[str] = Query(default="Folklore", max_length=50),
):
return await gutenberg_service.ingest_gutenberg_book(gutenberg_id=gutenberg_id, genre=genre)

@router.get("/public/gutenberg", response_model=list[StoryDTO])
async def get_gutenberg_stories(
topic: Optional[str] = Query(default="folklore", description="Topic or genre filter for Gutenberg"),
search: Optional[str] = Query(default=None, description="Search term across title and author")
topic: Optional[str] = Query(default="folklore", max_length=50, description="Topic or genre filter for Gutenberg"),
search: Optional[str] = Query(default=None, max_length=200, description="Search term across title and author"),
):
return await gutenberg_service.get_gutenberg_stories(topic=topic, search=search)


@router.get("/public/gutenberg/{gutenberg_id}", response_model=StoryDTO)
async def get_gutenberg_story(gutenberg_id: int = Path(ge=1)):
async def get_gutenberg_story(gutenberg_id: int = Path(ge=1, le=2_147_483_647)):
return await gutenberg_service.get_gutenberg_story_by_id(gutenberg_id)


@router.get("/public/gutenberg/{gutenberg_id}/chapters", response_model=list[ChapterDTO])
async def get_gutenberg_chapters(gutenberg_id: int = Path(ge=1)):
async def get_gutenberg_chapters(gutenberg_id: int = Path(ge=1, le=2_147_483_647)):
return await gutenberg_service.get_gutenberg_chapters(gutenberg_id=gutenberg_id)
18 changes: 13 additions & 5 deletions backend/api/v1/endpoints/stories.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Header, status
from fastapi import APIRouter, Header, Query, status

from schemas import (
StoryDTO,
Expand All @@ -17,11 +17,19 @@

@router.get("/stories", response_model=list[StoryDTO])
def get_stories(
genre: Optional[str] = None,
search: Optional[str] = None,
since: Optional[datetime] = None
genre: Optional[str] = Query(default=None, max_length=50),
search: Optional[str] = Query(default=None, max_length=200),
since: Optional[datetime] = None,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=1_000_000),
):
return story_service.get_stories(genre=genre, search=search, since=since)
return story_service.get_stories(
genre=genre,
search=search,
since=since,
limit=limit,
offset=offset,
)

@router.get("/genres", response_model=list[GenreDTO])
def get_genres():
Expand Down
Loading
Loading