rename package directory fastapi_oidc_op → porchlight
This commit is contained in:
parent
32b75cf92d
commit
c5a80b51de
49 changed files with 7332 additions and 0 deletions
860
docs/plans/2026-02-11-scaffolding-plan.md
Normal file
860
docs/plans/2026-02-11-scaffolding-plan.md
Normal file
|
|
@ -0,0 +1,860 @@
|
|||
# Project Scaffolding Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Set up the project foundation: tooling (ruff, ty), package structure, configuration, Pydantic models, repository protocols, app factory, and initial test infrastructure.
|
||||
|
||||
**Architecture:** FastAPI app factory pattern with src layout. Pydantic models for data, Protocol-based repository interfaces, env-based configuration via pydantic-settings. All code checked by ruff (broad ruleset) and ty (strict mode).
|
||||
|
||||
**Tech Stack:** Python 3.13, FastAPI, pydantic-settings, ruff, ty, pytest, pytest-asyncio
|
||||
|
||||
**Design Document:** `docs/plans/2026-02-11-oidc-op-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Configure pyproject.toml with dependencies and tooling
|
||||
|
||||
**Files:**
|
||||
- Modify: `pyproject.toml`
|
||||
|
||||
**Step 1: Update pyproject.toml with all sections**
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "fastapi-oidc-op"
|
||||
version = "0.1.0"
|
||||
description = "OIDC OpenID Provider with user management"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.34",
|
||||
"idpyoidc>=5.0",
|
||||
"pydantic-settings>=2.7",
|
||||
"jinja2>=3.1",
|
||||
"fido2>=2.1",
|
||||
"argon2-cffi>=25.1",
|
||||
"motor>=3.7",
|
||||
"aiosqlite>=0.21",
|
||||
"proquint>=0.2",
|
||||
"python-multipart>=0.0.20",
|
||||
"httpx>=0.28",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fastapi-oidc-op = "fastapi_oidc_op.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/fastapi_oidc_op"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.25",
|
||||
"ruff>=0.15",
|
||||
"ty>=0.0.16",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "UP", "B", "SIM", "I", "C4", "RUF"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
docstring-code-format = true
|
||||
|
||||
[tool.ty]
|
||||
python-version = "3.13"
|
||||
src = ["src"]
|
||||
|
||||
[tool.ty.rules]
|
||||
possibly-unresolved-reference = "error"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
```
|
||||
|
||||
**Step 2: Install dependencies**
|
||||
|
||||
Run: `uv sync`
|
||||
Expected: All dependencies resolve and install successfully.
|
||||
|
||||
**Step 3: Verify tools work**
|
||||
|
||||
Run: `uv run ruff check --version && uv run ty --version`
|
||||
Expected: Both print version numbers.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml uv.lock
|
||||
git commit -m "chore: configure project dependencies and tooling (ruff, ty, pytest)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create package structure with __init__.py files
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/oidc/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/authn/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/manage/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/store/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/store/mongodb/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/store/sqlite/__init__.py`
|
||||
- Create: `src/fastapi_oidc_op/invite/__init__.py`
|
||||
- Create: `tests/__init__.py`
|
||||
- Create: `tests/test_store/__init__.py`
|
||||
- Create: `tests/test_authn/__init__.py`
|
||||
- Create: `tests/test_oidc/__init__.py`
|
||||
- Create: `tests/test_manage/__init__.py`
|
||||
|
||||
**Step 1: Create all directories and __init__.py files**
|
||||
|
||||
All `__init__.py` files are empty. Create the directory tree:
|
||||
|
||||
```
|
||||
src/
|
||||
└── fastapi_oidc_op/
|
||||
├── __init__.py
|
||||
├── oidc/
|
||||
│ └── __init__.py
|
||||
├── authn/
|
||||
│ └── __init__.py
|
||||
├── manage/
|
||||
│ └── __init__.py
|
||||
├── store/
|
||||
│ ├── __init__.py
|
||||
│ ├── mongodb/
|
||||
│ │ └── __init__.py
|
||||
│ └── sqlite/
|
||||
│ └── __init__.py
|
||||
└── invite/
|
||||
└── __init__.py
|
||||
tests/
|
||||
├── __init__.py
|
||||
├── test_store/
|
||||
│ └── __init__.py
|
||||
├── test_authn/
|
||||
│ └── __init__.py
|
||||
├── test_oidc/
|
||||
│ └── __init__.py
|
||||
└── test_manage/
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
**Step 2: Verify package is importable**
|
||||
|
||||
Run: `uv run python -c "import fastapi_oidc_op; print('OK')"`
|
||||
Expected: Prints `OK`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ tests/
|
||||
git commit -m "chore: create package structure with src layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement configuration module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/config.py`
|
||||
- Create: `tests/test_config.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_config.py
|
||||
from fastapi_oidc_op.config import Settings, StorageBackend
|
||||
|
||||
|
||||
def test_default_settings() -> None:
|
||||
settings = Settings(
|
||||
issuer="http://localhost:8000",
|
||||
)
|
||||
assert settings.issuer == "http://localhost:8000"
|
||||
assert settings.storage_backend == StorageBackend.SQLITE
|
||||
assert settings.sqlite_path == "data/oidc_op.db"
|
||||
assert settings.manage_client_id == "manage-app"
|
||||
assert settings.invite_ttl == 86400
|
||||
assert settings.theme == "default"
|
||||
|
||||
|
||||
def test_mongodb_settings() -> None:
|
||||
settings = Settings(
|
||||
issuer="http://localhost:8000",
|
||||
storage_backend=StorageBackend.MONGODB,
|
||||
mongodb_uri="mongodb://mongo:27017",
|
||||
mongodb_database="test_db",
|
||||
)
|
||||
assert settings.storage_backend == StorageBackend.MONGODB
|
||||
assert settings.mongodb_uri == "mongodb://mongo:27017"
|
||||
assert settings.mongodb_database == "test_db"
|
||||
|
||||
|
||||
def test_settings_from_env(monkeypatch: "pytest.MonkeyPatch") -> None:
|
||||
import pytest
|
||||
|
||||
monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org")
|
||||
monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb")
|
||||
monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017")
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
assert settings.issuer == "https://op.example.org"
|
||||
assert settings.storage_backend == StorageBackend.MONGODB
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: FAIL - ImportError
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/fastapi_oidc_op/config.py
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class StorageBackend(StrEnum):
|
||||
SQLITE = "sqlite"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = {"env_prefix": "OIDC_OP_"}
|
||||
|
||||
# Core
|
||||
issuer: str
|
||||
debug: bool = False
|
||||
|
||||
# Storage
|
||||
storage_backend: StorageBackend = StorageBackend.SQLITE
|
||||
|
||||
# SQLite
|
||||
sqlite_path: str = "data/oidc_op.db"
|
||||
|
||||
# MongoDB
|
||||
mongodb_uri: str = "mongodb://localhost:27017"
|
||||
mongodb_database: str = "oidc_op"
|
||||
|
||||
# Management RP
|
||||
manage_client_id: str = "manage-app"
|
||||
|
||||
# Magic links
|
||||
invite_ttl: int = 86400 # seconds
|
||||
|
||||
# Theme
|
||||
theme: str = "default"
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_config.py -v`
|
||||
Expected: All 3 tests PASS
|
||||
|
||||
**Step 5: Run ruff and ty**
|
||||
|
||||
Run: `uv run ruff check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ruff format --check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ty check src/fastapi_oidc_op/config.py`
|
||||
Expected: No errors
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fastapi_oidc_op/config.py tests/test_config.py
|
||||
git commit -m "feat: add configuration module with env-based settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement Pydantic models
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/models.py`
|
||||
- Create: `tests/test_models.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_models.py
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi_oidc_op.models import (
|
||||
CredentialType,
|
||||
MagicLink,
|
||||
PasswordCredential,
|
||||
User,
|
||||
WebAuthnCredential,
|
||||
)
|
||||
|
||||
|
||||
def test_user_creation() -> None:
|
||||
user = User(
|
||||
userid="lusab-bansen",
|
||||
username="alice",
|
||||
)
|
||||
assert user.userid == "lusab-bansen"
|
||||
assert user.username == "alice"
|
||||
assert user.preferred_username is None
|
||||
assert user.email is None
|
||||
assert user.active is True
|
||||
assert user.groups == []
|
||||
assert user.created_at is not None
|
||||
assert user.updated_at is not None
|
||||
|
||||
|
||||
def test_user_with_all_fields() -> None:
|
||||
user = User(
|
||||
userid="lusab-bansen",
|
||||
username="alice",
|
||||
preferred_username="Alice W.",
|
||||
given_name="Alice",
|
||||
family_name="Wonderland",
|
||||
nickname="ally",
|
||||
email="alice@example.com",
|
||||
email_verified=True,
|
||||
phone_number="+1234567890",
|
||||
phone_number_verified=False,
|
||||
picture="https://example.com/alice.jpg",
|
||||
locale="en-US",
|
||||
active=True,
|
||||
groups=["admin", "users"],
|
||||
)
|
||||
assert user.given_name == "Alice"
|
||||
assert user.groups == ["admin", "users"]
|
||||
|
||||
|
||||
def test_webauthn_credential() -> None:
|
||||
cred = WebAuthnCredential(
|
||||
user_id="lusab-bansen",
|
||||
credential_id=b"\x01\x02\x03",
|
||||
public_key=b"\x04\x05\x06",
|
||||
sign_count=0,
|
||||
device_name="YubiKey 5",
|
||||
)
|
||||
assert cred.type == CredentialType.WEBAUTHN
|
||||
assert cred.credential_id == b"\x01\x02\x03"
|
||||
assert cred.device_name == "YubiKey 5"
|
||||
|
||||
|
||||
def test_password_credential() -> None:
|
||||
cred = PasswordCredential(
|
||||
user_id="lusab-bansen",
|
||||
password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash",
|
||||
)
|
||||
assert cred.type == CredentialType.PASSWORD
|
||||
assert cred.password_hash.startswith("$argon2")
|
||||
|
||||
|
||||
def test_magic_link() -> None:
|
||||
link = MagicLink(
|
||||
token="abc123def456",
|
||||
username="newuser",
|
||||
)
|
||||
assert link.token == "abc123def456"
|
||||
assert link.username == "newuser"
|
||||
assert link.used is False
|
||||
assert link.created_by is None
|
||||
assert link.expires_at > datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_models.py -v`
|
||||
Expected: FAIL - ImportError
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/fastapi_oidc_op/models.py
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _default_expiry() -> datetime:
|
||||
return datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
|
||||
|
||||
class CredentialType(StrEnum):
|
||||
WEBAUTHN = "webauthn"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
userid: str
|
||||
username: str
|
||||
preferred_username: str | None = None
|
||||
given_name: str | None = None
|
||||
family_name: str | None = None
|
||||
nickname: str | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
phone_number: str | None = None
|
||||
phone_number_verified: bool = False
|
||||
picture: str | None = None
|
||||
locale: str | None = None
|
||||
active: bool = True
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
updated_at: datetime = Field(default_factory=_utcnow)
|
||||
groups: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebAuthnCredential(BaseModel):
|
||||
user_id: str
|
||||
type: CredentialType = CredentialType.WEBAUTHN
|
||||
credential_id: bytes
|
||||
public_key: bytes
|
||||
sign_count: int = 0
|
||||
device_name: str = ""
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class PasswordCredential(BaseModel):
|
||||
user_id: str
|
||||
type: CredentialType = CredentialType.PASSWORD
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
class MagicLink(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
expires_at: datetime = Field(default_factory=_default_expiry)
|
||||
used: bool = False
|
||||
created_by: str | None = None
|
||||
note: str | None = None
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_models.py -v`
|
||||
Expected: All 5 tests PASS
|
||||
|
||||
**Step 5: Run ruff and ty**
|
||||
|
||||
Run: `uv run ruff check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ruff format --check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ty check src/fastapi_oidc_op/models.py`
|
||||
Expected: No errors
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fastapi_oidc_op/models.py tests/test_models.py
|
||||
git commit -m "feat: add Pydantic models for User, Credential, and MagicLink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement repository protocols
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/store/protocols.py`
|
||||
- Create: `tests/test_store/test_protocols.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_store/test_protocols.py
|
||||
from typing import runtime_checkable
|
||||
|
||||
from fastapi_oidc_op.store.protocols import (
|
||||
CredentialRepository,
|
||||
MagicLinkRepository,
|
||||
UserRepository,
|
||||
)
|
||||
|
||||
|
||||
def test_protocols_are_runtime_checkable() -> None:
|
||||
assert runtime_checkable(UserRepository) # type: ignore[arg-type]
|
||||
assert runtime_checkable(CredentialRepository) # type: ignore[arg-type]
|
||||
assert runtime_checkable(MagicLinkRepository) # type: ignore[arg-type]
|
||||
```
|
||||
|
||||
Note: This test just verifies the protocols are importable and runtime-checkable. The actual conformance tests come when we implement the SQLite/MongoDB repositories.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_store/test_protocols.py -v`
|
||||
Expected: FAIL - ImportError
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/fastapi_oidc_op/store/protocols.py
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from fastapi_oidc_op.models import (
|
||||
MagicLink,
|
||||
PasswordCredential,
|
||||
User,
|
||||
WebAuthnCredential,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UserRepository(Protocol):
|
||||
async def create(self, user: User) -> User: ...
|
||||
|
||||
async def get_by_userid(self, userid: str) -> User | None: ...
|
||||
|
||||
async def get_by_username(self, username: str) -> User | None: ...
|
||||
|
||||
async def update(self, user: User) -> User: ...
|
||||
|
||||
async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ...
|
||||
|
||||
async def delete(self, userid: str) -> bool: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CredentialRepository(Protocol):
|
||||
async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ...
|
||||
|
||||
async def create_password(self, credential: PasswordCredential) -> PasswordCredential: ...
|
||||
|
||||
async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]: ...
|
||||
|
||||
async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None: ...
|
||||
|
||||
async def get_password_by_user(self, user_id: str) -> PasswordCredential | None: ...
|
||||
|
||||
async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ...
|
||||
|
||||
async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool: ...
|
||||
|
||||
async def delete_password(self, user_id: str) -> bool: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MagicLinkRepository(Protocol):
|
||||
async def create(self, link: MagicLink) -> MagicLink: ...
|
||||
|
||||
async def get_by_token(self, token: str) -> MagicLink | None: ...
|
||||
|
||||
async def mark_used(self, token: str) -> bool: ...
|
||||
|
||||
async def delete_expired(self) -> int: ...
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_store/test_protocols.py -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run ruff and ty**
|
||||
|
||||
Run: `uv run ruff check src/fastapi_oidc_op/store/protocols.py && uv run ruff format --check src/fastapi_oidc_op/store/protocols.py && uv run ty check src/fastapi_oidc_op/store/protocols.py`
|
||||
Expected: No errors
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fastapi_oidc_op/store/protocols.py tests/test_store/test_protocols.py
|
||||
git commit -m "feat: add repository Protocol interfaces for User, Credential, MagicLink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement userid generation utility
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/userid.py`
|
||||
- Create: `tests/test_userid.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_userid.py
|
||||
import re
|
||||
|
||||
from fastapi_oidc_op.userid import generate_userid
|
||||
|
||||
|
||||
def test_generate_userid_format() -> None:
|
||||
userid = generate_userid()
|
||||
# 32-bit proquint format: xxxxx-xxxxx
|
||||
parts = userid.split("-")
|
||||
assert len(parts) == 2
|
||||
for part in parts:
|
||||
assert len(part) == 5
|
||||
|
||||
|
||||
def test_generate_userid_uniqueness() -> None:
|
||||
ids = {generate_userid() for _ in range(100)}
|
||||
assert len(ids) == 100 # All unique
|
||||
|
||||
|
||||
def test_generate_userid_is_lowercase() -> None:
|
||||
userid = generate_userid()
|
||||
assert userid == userid.lower()
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_userid.py -v`
|
||||
Expected: FAIL - ImportError
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/fastapi_oidc_op/userid.py
|
||||
import secrets
|
||||
|
||||
from proquint import uint2quint
|
||||
|
||||
|
||||
def generate_userid() -> str:
|
||||
"""Generate a unique user identifier in proquint format.
|
||||
|
||||
Returns a 32-bit proquint string like 'lusab-bansen'.
|
||||
"""
|
||||
return uint2quint(secrets.randbits(32))
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_userid.py -v`
|
||||
Expected: All 3 tests PASS
|
||||
|
||||
**Step 5: Run ruff and ty**
|
||||
|
||||
Run: `uv run ruff check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ruff format --check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ty check src/fastapi_oidc_op/userid.py`
|
||||
Expected: No errors (ty may warn about proquint missing type stubs - suppress with `# type: ignore[import-untyped]` if needed)
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fastapi_oidc_op/userid.py tests/test_userid.py
|
||||
git commit -m "feat: add proquint-based userid generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Implement app factory and health endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `src/fastapi_oidc_op/app.py`
|
||||
- Create: `tests/conftest.py`
|
||||
- Create: `tests/test_app.py`
|
||||
- Delete: `main.py` (replaced by app.py)
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from fastapi_oidc_op.app import create_app
|
||||
from fastapi_oidc_op.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings(issuer="http://localhost:8000")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(settings: Settings) -> AsyncIterator[AsyncClient]:
|
||||
app = create_app(settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url=settings.issuer) as ac:
|
||||
yield ac
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_app.py
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def test_health_endpoint(client: AsyncClient) -> None:
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
async def test_app_has_title(client: AsyncClient) -> None:
|
||||
response = await client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["info"]["title"] == "FastAPI OIDC OP"
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_app.py -v`
|
||||
Expected: FAIL - ImportError
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```python
|
||||
# src/fastapi_oidc_op/app.py
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fastapi_oidc_op.config import Settings
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
if settings is None:
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
|
||||
app = FastAPI(
|
||||
title="FastAPI OIDC OP",
|
||||
version="0.1.0",
|
||||
docs_url="/docs" if settings.debug else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.state.settings = settings
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_app.py -v`
|
||||
Expected: All 2 tests PASS
|
||||
|
||||
**Step 5: Delete the old main.py**
|
||||
|
||||
Run: `rm main.py`
|
||||
|
||||
**Step 6: Run ruff and ty on all source files**
|
||||
|
||||
Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/`
|
||||
Expected: No errors
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git rm main.py
|
||||
git add src/fastapi_oidc_op/app.py tests/conftest.py tests/test_app.py
|
||||
git commit -m "feat: add app factory with health endpoint and test infrastructure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Run full quality checks and format
|
||||
|
||||
**Files:**
|
||||
- All files in `src/` and `tests/`
|
||||
|
||||
**Step 1: Format all code with ruff**
|
||||
|
||||
Run: `uv run ruff format src/ tests/`
|
||||
Expected: Files formatted (or already formatted)
|
||||
|
||||
**Step 2: Run ruff lint with auto-fix**
|
||||
|
||||
Run: `uv run ruff check src/ tests/ --fix`
|
||||
Expected: No errors (or safe fixes applied)
|
||||
|
||||
**Step 3: Run ty type check**
|
||||
|
||||
Run: `uv run ty check src/`
|
||||
Expected: No errors
|
||||
|
||||
**Step 4: Run full test suite**
|
||||
|
||||
Run: `uv run pytest -v`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 5: Commit any formatting changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "style: apply ruff formatting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Add pre-commit quality gate script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check.sh`
|
||||
|
||||
**Step 1: Create the check script**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Run all quality checks
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Formatting..."
|
||||
uv run ruff format src/ tests/
|
||||
|
||||
echo "==> Linting..."
|
||||
uv run ruff check src/ tests/ --fix
|
||||
|
||||
echo "==> Type checking..."
|
||||
uv run ty check src/
|
||||
|
||||
echo "==> Testing..."
|
||||
uv run pytest -v
|
||||
|
||||
echo "==> All checks passed!"
|
||||
```
|
||||
|
||||
**Step 2: Make it executable**
|
||||
|
||||
Run: `chmod +x scripts/check.sh`
|
||||
|
||||
**Step 3: Run the script to verify it works**
|
||||
|
||||
Run: `./scripts/check.sh`
|
||||
Expected: All checks pass
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/check.sh
|
||||
git commit -m "chore: add quality check script (ruff, ty, pytest)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
After completing these 9 tasks, the project will have:
|
||||
|
||||
1. **pyproject.toml** - Dependencies, ruff config (broad ruleset), ty config (strict), pytest config
|
||||
2. **Package structure** - Full src layout with all subpackages
|
||||
3. **Configuration** - Pydantic Settings with env vars, storage backend selection
|
||||
4. **Models** - User, WebAuthnCredential, PasswordCredential, MagicLink
|
||||
5. **Repository protocols** - Type-safe interfaces for all data access
|
||||
6. **Userid generation** - Proquint-based unique identifiers
|
||||
7. **App factory** - FastAPI app with health endpoint
|
||||
8. **Test infrastructure** - pytest + httpx async client fixtures
|
||||
9. **Quality tooling** - ruff (lint + format), ty (type check), check script
|
||||
|
||||
The next plan will cover the SQLite repository implementation, followed by authentication, OIDC provider integration, and the management UI.
|
||||
Loading…
Add table
Add a link
Reference in a new issue