Skip to content
Draft
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
4 changes: 2 additions & 2 deletions api/docs/adr/000-vertical-slice-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ damnit_api/
├── mymdc/ # MyMdC port
├── appdb/ # application-DB engine/session/models
└── graphql/ # transport composition:
└── graphql/ # transport composition only (ADR-007):
├── schema.py # assemble Query/Subscription from feature gql modules
└── directives.py
```
Expand All @@ -104,7 +104,7 @@ damnit_api/
- Feature packages (`runs`, `proposals`, `auth`, `contextfile`) may import `core` and infrastructure (`mymdc`, `appdb`), never each other's internals. Allowed cross-feature edges are explicit and narrow: `auth → proposals` (membership needs proposal metadata) - never the reverse.
- Infrastructure (`mymdc`, `appdb`) imports only `core` and `settings`.
- `graphql/schema.py` and `app.py` may import everything (composition).
- Domain and service modules never import Litestar or Strawberry; framework types appear only in `routers.py`, `gql.py`, `dependencies.py`, and permission classes.
- Domain and service modules never import Litestar (see [ADR-006](006-litestar.md)) or Strawberry; framework types appear only in `routers.py`, `gql.py`, `dependencies.py`, and permission classes.
- Private (`_`-prefixed) functions are module-internal. Anything imported across module boundaries is public API and named accordingly.
- Function-body imports are allowed only in the composition root and for documented, cycle-free lazy loading.

Expand Down
37 changes: 37 additions & 0 deletions api/docs/adr/006-litestar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
date: 2026-07-08
---

# ADR-006 - Web framework: Litestar

## Context and Problem Statement

The API needs an async Python web framework. It has to provide REST routing, ASGI websockets for GraphQL subscriptions, session middleware, dependency injection, and OpenAPI generation.

Two requirements discriminate between candidates. Runtime dependencies are built once at startup and injected into handlers, so the framework must support typed application state with lifespan management and dependency declaration that is not welded to route signatures ([ADR-002](002-no-global-mutable-state.md)). The web framework must also stay at the edges of the codebase, so that domain and service code never imports it ([ADR-000](000-vertical-slice-architecture.md)).

## Considered Options

- FastAPI (the status quo).
- Litestar.

## Decision Outcome

Chosen option: "Litestar", because it provides typed `State`, layered `Provide`-based dependency injection, lifespan context managers, native session middleware, and an official Strawberry integration.

FastAPI satisfies the basics. Its dependency injection is expressed per route through `Depends` in signatures, its application state is an untyped `app.state` namespace, and its authlib OAuth integration is Starlette-specific. Litestar avoids each of these.

### Consequences

- Good: application state is typed and lifespan-managed, and dependencies are declared off the route signatures.
- Good: FastAPI and Starlette are no longer dependencies.
- Bad: the OAuth flow is implemented natively rather than through a framework integration, so it needs its own tests and security review.
- Bad: Litestar has a smaller ecosystem than FastAPI.

## Details

Framework types (`Request`, `ASGIConnection`, `Provide`) stay at the edges: route handlers, dependency providers, and permission classes. The narrow per-slice providers read Litestar's injected `State` and return a single `AppState` attribute, rather than declaring an `AppState` parameter, because `AppState`'s fields are `TYPE_CHECKING`-only forward references and only the composition root may import it ([ADR-002](002-no-global-mutable-state.md)).

Dependency injection resolves by parameter name against the `Provide` map, not by a `Depends` default, so the old `Annotated[T, Depends(...)]` aliases collapse to plain type aliases. Injected collaborators whose type is a union of concrete classes are annotated with `SkipValidation`, because Litestar's msgspec-based signature validation cannot build a decoder for a union of custom types.

FastAPI's `@app.exception_handler` decorators become a Litestar `exception_handlers` mapping. The 401-to-login redirect for known paths is preserved inside the `HTTPException` handler. The uvicorn proxy-headers middleware is dropped in favour of a `trust_forwarded_host` setting that gates use of the `x-forwarded-host` header in the OAuth callback URL only.
38 changes: 38 additions & 0 deletions api/docs/adr/007-graphql-transport-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
date: 2026-07-08
---

# ADR-007 - GraphQL as a transport layer; per-feature schema contributions

## Context and Problem Statement

GraphQL is the API's primary query surface. Left unmanaged, a GraphQL layer attracts logic that belongs elsewhere: domain serialisation ends up inside type definitions, resolvers accumulate data-access code, and one schema module becomes a central coupling point that imports from the whole codebase.

Two structural questions need stable answers. Who owns the types and resolvers? With vertical slices each feature owns its domain, so its GraphQL surface belongs to that slice, not to a central package ([ADR-000](000-vertical-slice-architecture.md)). What does the composition layer do? Something must assemble the feature contributions into one schema, configure scalars and naming, and bind the schema to the web framework.

A hard external constraint applies. The frontend depends on the public schema: snake_case field names, scalar names, and subscription payload shapes. Internal restructuring must not change that schema.

## Considered Options

- A central `graphql` package that owns all types and resolvers.
- Feature-owned GraphQL surfaces, with the composition layer assembling them.

## Decision Outcome

Chosen option: "feature-owned GraphQL surfaces", because it keeps each slice's GraphQL surface inside the slice and reduces the shared layer to composition.

- Each feature exposes a `gql.py` with its Strawberry types and its `Query`/`Subscription` contributions.
- The composition layer merges those contributions, registers scalars, sets `StrawberryConfig(auto_camel_case=False)`, builds the framework controller, and defines the request `Context`.
- The `Context` is a typed object built from injected dependencies; resolvers reach collaborators only through `info.context`, never module imports.
- Serialisation is a domain concern, not a type concern: it lives in framework-free feature modules, and Strawberry types are thin.
- Resolvers are orchestration only: apply permission classes, fetch through the repository ([ADR-005](005-repository-pattern.md)), convert through serialisation, and raise `DamnitWebError` subclasses ([ADR-001](001-error-classes.md)).

### Consequences

- Good: the composition layer imports features; features never import it back, the narrow exception being type-only `Context` annotations under `TYPE_CHECKING`.
- Good: serialisation is unit-testable without Strawberry, and the GraphQL layer is testable against the CSV repository ([ADR-005](005-repository-pattern.md)).
- Bad: the public schema is frozen, so any change to it is deliberate and coordinated with the frontend.

## Details

Pushing a sub-selection down to the data layer is legitimate resolver logic rather than leaked domain code: shaping a `variables(names:)` selection via `info.selected_fields` is genuinely about the transport. The frozen-schema guard regenerates its snapshot only through an explicit environment flag, so an accidental schema change fails the parity test rather than silently updating the golden file.
2 changes: 1 addition & 1 deletion api/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ For more information, see [ADR-000](adr/000-vertical-slice-architecture.md).
| `proposals/` | Proposal metadata and lookup | Proposal models, MyMdC-backed metadata services, path locator (see [ADR-004](adr/004-proposal-path-locator.md)) | Planned | `metadata/` |
| `auth/` | Authentication and authorisation | OAuth flow, sessions, token store, `User`, permission classes, the membership policy | Partial | Policy still in `metadata/services.py` |
| `contextfile/` | Context-file viewing | File reading, watching, its routes | Done | As-is |
| `graphql/` | GraphQL transport only | Schema assembly, context, directives, controller binding - no resolvers, no domain logic | Partial | Assembly still in `shared/gql.py`; resolvers still here |
| `graphql/` | GraphQL transport only | Schema assembly, context, directives, controller binding - no resolvers, no domain logic (see [ADR-007](adr/007-graphql-transport-only.md)) | Partial | Assembly still in `shared/gql.py`; resolvers still here |
| `appdb/` | The app's own database (infrastructure) | Models, engine/session plumbing for `dw_api.sqlite` | Planned | `_db/` |
| `mymdc/` | MyMdC client (infrastructure) | Ports, clients, vendored models | Planned | `_mymdc/` |
| `core/` | Cross-cutting, framework-free | Shared error classes (see [ADR-001](adr/001-error-classes.md)), `DamnitType`, value types (see [ADR-004](adr/004-proposal-path-locator.md)), converters | Planned | `shared/` + `utils.py` |
Expand Down
5 changes: 2 additions & 3 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ maintainers = [
dependencies = [
"pandas~=2.0",
"sqlalchemy[asyncio]~=2.0",
"fastapi~=0.115",
"orjson~=3.8",
"numpy~=2.3",
"matplotlib~=3.7",
"h5py~=3.9",
"strawberry-graphql[fastapi]>=0.283.3",
"strawberry-graphql[litestar]>=0.283.3",
"uvicorn[standard]~=0.29",
"aiosqlite~=0.19",
"scipy~=1.11",
"authlib~=1.3",
"itsdangerous~=2.1",
"httpx~=0.27",
"pydantic~=2.12",
"pydantic-settings~=2.2",
Expand All @@ -39,6 +37,7 @@ dependencies = [
"python-ulid[pydantic]>=3.1.0",
"sqlmodel>=0.0.31",
"pyyaml>=6.0.3",
"litestar~=2.24.0",
]

[dependency-groups]
Expand Down
14 changes: 6 additions & 8 deletions api/src/damnit_api/_db/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
"""FastAPI dependency helpers for database sessions."""
"""Litestar dependency helpers for database sessions."""

from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends, Request
from litestar.datastructures import State
from sqlmodel.ext.asyncio.session import AsyncSession

from ..state import get_app_state


async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
async def get_session(state: State) -> AsyncIterator[AsyncSession]:
"""Provide a database session from the application state."""

async with get_app_state(request).db_sessionmaker() as session:
async with state.app_state.db_sessionmaker() as session: # type: ignore[attr-defined]
yield session


DBSession = Annotated[AsyncSession, Depends(get_session)]
# Plain type alias; Litestar injects by the parameter name `session`.
DBSession = AsyncSession
70 changes: 36 additions & 34 deletions api/src/damnit_api/_logging.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import inspect
import logging
import sys
from typing import TYPE_CHECKING

import colorama
import structlog
import structlog.typing
import ulid
from starlette.middleware.base import BaseHTTPMiddleware
from litestar.middleware.base import MiddlewareProtocol
from litestar.types import ASGIApp, Message, Receive, Scope, Send
from structlog.dev import RichTracebackFormatter
from structlog.stdlib import ProcessorFormatter

if TYPE_CHECKING: # pragma: no cover
from starlette.requests import Request
from starlette.responses import Response


def get_logger(logger_name: str | None = None):
if logger_name:
Expand Down Expand Up @@ -181,52 +177,58 @@ def configure_uvicorn(renderer, shared_processors):
uvicorn.config.LOGGING_CONFIG["loggers"]["uvicorn.access"]["propagate"] = False


class RequestLoggingMiddleware(BaseHTTPMiddleware):
_logger = None
class RequestLoggingMiddleware(MiddlewareProtocol):
"""Log requests and responses via structlog, replacing uvicorn access logs."""

def __init__(self, app: ASGIApp) -> None:
self.app = app
self._logger = None

@property
def logger(self):
if not self._logger:
self._logger = structlog.get_logger(logger_name="damnit_api.access_log")

return self._logger

async def dispatch(self, request: "Request", call_next) -> "Response":
"""Add a middleware to FastAPI that will log requests and responses,
this is used instead of the builtin Uvicorn access logging to better
integrate with structlog"""
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return

structlog.contextvars.bind_contextvars(request_id=str(ulid.ULID()))

info = {
"method": request.method,
"path": request.scope["path"],
"client": request.client,
info: dict = {
"method": scope["method"],
"path": scope["path"],
"client": scope.get("client"),
}

if request.query_params:
info["query_params"] = str(request.query_params)
if query_string := scope.get("query_string"):
info["query_params"] = query_string.decode()

if request.path_params:
info["path_params"] = str(request.path_params)
if path_params := scope.get("path_params"):
info["path_params"] = str(path_params)

logger = self.logger.bind()

logger.info("Request", **info)

response = await call_next(request)
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
status_code: int = message["status"]

if status_code < 400:
response_logger = logger.info
elif status_code < 500:
response_logger = logger.warn
else:
response_logger = logger.error

if response.status_code < 400:
response_logger = logger.info
elif response.status_code < 500:
response_logger = logger.warn
else:
response_logger = logger.error
# Health checks are noisy, so we downgrade their log level
if scope["path"].endswith("/health"):
response_logger = logger.debug

# Health checks are noisy, so we downgrade their log level
if request.url.path.endswith("/health"):
response_logger = logger.debug
response_logger("Response", status_code=status_code)

response_logger("Response", status_code=response.status_code)
await send(message)

return response
await self.app(scope, receive, send_wrapper)
15 changes: 8 additions & 7 deletions api/src/damnit_api/_mymdc/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from typing import Annotated
from litestar.datastructures import State
from litestar.params import SkipValidation

from fastapi import Depends, Request

from ..state import get_app_state
from . import clients


def get_mymdc_client(request: Request) -> "clients.MyMdCClient":
def get_mymdc_client(state: State) -> "clients.MyMdCClient":
"""Provide the MyMdC client from the application state."""
return get_app_state(request).mymdc_client
return state.app_state.mymdc_client # type: ignore[attr-defined]


MyMdCClient = Annotated[clients.MyMdCClient, Depends(get_mymdc_client)]
# `MyMdCClient` is a union of two concrete clients; Litestar's msgspec-based
# signature validation cannot build a decoder for a union of custom types, so
# injection sites skip validation of this app-provided collaborator.
MyMdCClient = SkipValidation[clients.MyMdCClient]
"""Type alias for the MyMdC client dependency."""
5 changes: 3 additions & 2 deletions api/src/damnit_api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .routers import noauth_router, router
from . import dependencies
from .routers import NoAuthOAuthController, OAuthController

__all__ = ["noauth_router", "router"]
__all__ = ["NoAuthOAuthController", "OAuthController", "dependencies"]
76 changes: 45 additions & 31 deletions api/src/damnit_api/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,64 @@
"""Dependency type aliases for the auth module."""
"""Dependency functions and type aliases for the auth module."""

from typing import Annotated
from collections.abc import AsyncIterator

from authlib.integrations.starlette_client import StarletteOAuth2App
from fastapi import Depends, Request
from authlib.integrations import httpx_client
from authlib.integrations.httpx_client import AsyncOAuth2Client
from litestar import Request
from litestar.datastructures import State
from sqlmodel.ext.asyncio.session import AsyncSession

from ..state import get_app_state
from .._mymdc.dependencies import MyMdCClient
from ..state import OAuthClient
from .models import OAuthUserInfo as _OAuthUserInfo
from .models import User as _User
from .token_store import TokenStore


# TODO: Get from settings
def _get_default_redirect_login_uri() -> str:
return "/app/home"


def get_oauth_client(request: Request) -> StarletteOAuth2App:
"""Provide the OAuth client from the application state.

Raises:
RuntimeError: If auth is disabled and no client was built.
"""
client = get_app_state(request).oauth_client
def get_oauth_client(state: State) -> OAuthClient:
client = state.app_state.oauth_client # type: ignore[attr-defined]
if client is None:
msg = "OAuth client is not configured (auth is disabled)."
msg = (
"OAuth client is not configured (settings.auth is None); "
"enable auth settings to use OAuth endpoints."
)
raise RuntimeError(msg)
return client


def get_token_store(request: Request) -> TokenStore:
"""Provide the token store from the application state."""
return get_app_state(request).token_store
async def get_oauth_http_client(
oauth_config: OAuthClient,
) -> AsyncIterator[AsyncOAuth2Client]:
"""Litestar dependency: a short-lived OAuth2 HTTP client, closed by DI."""
client = httpx_client.AsyncOAuth2Client(
client_id=oauth_config.client_id,
client_secret=oauth_config.client_secret,
scope=oauth_config.scope,
)
try:
yield client
finally:
await client.aclose()


def get_token_store(state: State) -> TokenStore:
return state.app_state.token_store # type: ignore[attr-defined]


RedirectURI = Annotated[str, Depends(_get_default_redirect_login_uri)]
"""Type alias for the redirect URI dependency."""
def get_oauth_user_info(request: Request) -> _OAuthUserInfo:
"""Litestar dependency: resolve OAuthUserInfo from the session."""
return _OAuthUserInfo.from_connection(request) # type: ignore[arg-type]

TokenStoreDep = Annotated[TokenStore, Depends(get_token_store)]
"""Type alias for the token store dependency."""

Client = Annotated[StarletteOAuth2App, Depends(get_oauth_client)]
"""Type alias for the OAuth client dependency."""
async def get_user(
request: Request,
mymdc: MyMdCClient,
session: AsyncSession,
) -> _User:
"""Litestar dependency: resolve full User (with proposals) from session + DB."""
return await _User.from_connection(request, mymdc, session) # type: ignore[arg-type]

OAuthUserInfo = Annotated[_OAuthUserInfo, Depends(_OAuthUserInfo.from_connection)]
"""Type alias for the OAuth user info dependency."""

User = Annotated[_User, Depends(_User.from_connection)]
"""Type alias for the full User dependency."""
# Plain type re-exports; consumed by other modules as annotations.
OAuthUserInfo = _OAuthUserInfo
User = _User
Loading