Skip to content
Merged
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
41 changes: 40 additions & 1 deletion .claude/docs/architectural_patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,50 @@ Request → Middleware → APIRouter → Endpoint function → Service layer →
- All DB access is async: `AsyncSession` injected via `Depends(get_async_session)`
- Plugin routes are mounted dynamically by the plugin loader at startup

### Endpoint modules: single file or package

A module under `sparkth/api/v1/` takes one of two shapes. `sparkth/api/v1/api.py` mounts
each one the same way — `include_router(<module>.router, prefix=...)` — so the shape is
an internal organisation choice and changing it never moves a URL.

**Single file** (`auth.py`, `llm.py`, `analytics.py`, `file_parser.py`, `user_plugins.py`)
— a module that declares `router` at the top level. The default for a small surface with
no request/response models of its own.

**Package** (`user/`, `language/`, `permissions/`, `whitelist/`) — reach for this once a
module owns its own pydantic models, or once its routes outgrow one readable file:

```
sparkth/api/v1/<name>/
__init__.py # exports `router`; registers this domain's exception → HTTP mappings
routes.py # the endpoint functions (a routes/ package when they need splitting)
schemas.py # request/response models owned by this module
```

- `__init__.py` re-exports `router` and carries `__all__ = ["router"]`. This is a
deliberate exception to the "avoid re-exports" rule: it keeps `api.py`'s mounting
uniform across both shapes.
- It is also where `register_exception_handler(ExcClass, status_code)` calls live, so a
domain's exception → status mapping sits beside the routes that raise it (see section
8, and `permissions/__init__.py` / `whitelist/__init__.py`). A package with no domain
exceptions of its own says so in its docstring rather than leaving readers guessing.
- `schemas.py` holds only the models that module owns. Models shared across domains —
`UserBase`, `Token`, `UserLogin`, and `User`, which `auth` returns from register and
login as well as `user/` from `/user/me` — stay in the root `sparkth/schemas.py`; a
package imports them from there rather than duplicating them. Ownership is decided by
who imports the model, not by which routes feel closest to it: pulling a shared model
into a package makes every other domain import that package, and importing any module
from it executes its `__init__.py` and therefore its `routes.py`, which turns a later
import in the other direction into a circular one.
- Route paths come from the prefix in `api.py`, never from the package name, so
converting a file to a package is a pure refactor: the OpenAPI document, the generated
frontend client, and every URL stay byte-identical.

---

## 4. Dependency Injection via FastAPI `Depends`

**Files:** `sparkth/api/v1/auth.py`, `sparkth/api/v1/user.py`, `sparkth/core/db.py`
**Files:** `sparkth/api/v1/auth.py`, `sparkth/api/v1/user/routes.py`, `sparkth/core/db.py`

Auth and DB session are injected uniformly:

Expand Down
127 changes: 121 additions & 6 deletions frontend/lib/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/languages": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Languages
* @description Return every supported language and the platform default.
*/
get: operations["list_languages_api_v1_languages_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/llm/configs": {
parameters: {
query?: never;
Expand Down Expand Up @@ -1126,19 +1146,25 @@ export interface paths {
* Get User
* @description Fetch the current authenticated user from the JWT token.
*
* ``is_admin`` is derived here from whether the user holds the global ``admin``
* role; it is not a stored column.
*
* Raises:
* HTTPException: If no user is authenticated.
* ``is_admin`` is derived from whether the user holds the global ``admin`` role;
* it is not a stored column. ``language`` is the raw stored preference — ``None``
* when the user never chose one.
*/
get: operations["get_user_api_v1_user_me_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
/**
* Update User Language
* @description Set or clear the current user's preferred language.
*
* The tag is validated against the supported-language allowlist by
* ``UserLanguageUpdate``, so an unsupported value is a 422 before reaching here.
* An explicit ``null`` clears the preference.
*/
patch: operations["update_user_language_api_v1_user_me_patch"];
trace?: never;
};
"/api/v1/whitelist/": {
Expand Down Expand Up @@ -1939,6 +1965,28 @@ export interface components {
/** Team Name */
team_name?: string | null;
};
/**
* SupportedLanguage
* @description One language the platform can generate content in.
*/
SupportedLanguage: {
/** Code */
code: string;
/** Name */
name: string;
/** Native Name */
native_name: string;
};
/**
* SupportedLanguages
* @description The full allowlist plus the default applied to users who never chose.
*/
SupportedLanguages: {
/** Default */
default: string;
/** Languages */
languages: components["schemas"]["SupportedLanguage"][];
};
/**
* SyncFolderRequest
* @description Request to sync a Google Drive folder.
Expand Down Expand Up @@ -2011,6 +2059,8 @@ export interface components {
* @default false
*/
is_admin: boolean;
/** Language */
language?: string | null;
/** Name */
name: string;
/** Username */
Expand All @@ -2030,6 +2080,18 @@ export interface components {
/** Username */
username: string;
};
/**
* UserLanguageUpdate
* @description Body of ``PATCH /user/me``. Validated here so a bad tag is a 422.
*
* ``language`` is required, because ``None`` is a meaningful value here rather
* than a missing one: an explicit ``null`` clears the stored preference, while
* omitting the field is a 422 rather than a no-op.
*/
UserLanguageUpdate: {
/** Language */
language: string | null;
};
/** UserLogin */
UserLogin: {
/** Password */
Expand Down Expand Up @@ -3149,6 +3211,26 @@ export interface operations {
};
};
};
list_languages_api_v1_languages_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SupportedLanguages"];
};
};
};
};
list_llm_configs_api_v1_llm_configs_get: {
parameters: {
query?: {
Expand Down Expand Up @@ -4226,6 +4308,39 @@ export interface operations {
};
};
};
update_user_language_api_v1_user_me_patch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserLanguageUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["User"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_whitelist_api_v1_whitelist__get: {
parameters: {
query?: never;
Expand Down
3 changes: 2 additions & 1 deletion sparkth/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import APIRouter

from sparkth.api.v1 import analytics, auth, file_parser, llm, permissions, user, user_plugins, whitelist
from sparkth.api.v1 import analytics, auth, file_parser, language, llm, permissions, user, user_plugins, whitelist

api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(language.router, prefix="/languages", tags=["Languages"])
api_router.include_router(user_plugins.router, prefix="/user-plugins", tags=["User Plugins"])
api_router.include_router(file_parser.router, prefix="/parser", tags=["File Parser"])
api_router.include_router(whitelist.router, prefix="/whitelist", tags=["Whitelist"])
Expand Down
9 changes: 9 additions & 0 deletions sparkth/api/v1/language/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Supported-languages API package.

Exports the router. The package has no domain exceptions of its own, so nothing is
registered with the exception-handler registry here.
"""

from sparkth.api.v1.language.routes import router

__all__ = ["router"]
31 changes: 31 additions & 0 deletions sparkth/api/v1/language/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Read-only endpoint exposing the languages a user may choose from.

The allowlist is defined once in core config and served from here rather than
duplicated client-side — the frontend language picker and the static-UI
translation layer both read it from this endpoint.

Unauthenticated, and the only endpoint outside ``auth`` that is: the translation
layer has to render the login, register and password-reset pages, which have no
token yet, and gating it there would force the frontend to carry the duplicate
list this endpoint exists to remove. What it serves is a compile-time constant —
the supported-language table and the platform default — so there is no user data,
no database read, and nothing to enumerate.
"""

from fastapi import APIRouter

from sparkth.api.v1.language.schemas import SupportedLanguage, SupportedLanguages
from sparkth.lib.language import SUPPORTED_LANGUAGES
from sparkth.lib.settings import get_settings

router = APIRouter()


@router.get("", response_model=SupportedLanguages)
async def list_languages() -> SupportedLanguages:
"""Return every supported language and the platform default."""
languages = [
SupportedLanguage(code=code, name=info.name, native_name=info.native_name)
for code, info in SUPPORTED_LANGUAGES.items()
]
return SupportedLanguages(languages=languages, default=get_settings().DEFAULT_LANGUAGE)
18 changes: 18 additions & 0 deletions sparkth/api/v1/language/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Pydantic models for the supported-languages API."""

from pydantic import BaseModel


class SupportedLanguage(BaseModel):
"""One language the platform can generate content in."""

code: str
name: str
native_name: str


class SupportedLanguages(BaseModel):
"""The full allowlist plus the default applied to users who never chose."""

languages: list[SupportedLanguage]
default: str
35 changes: 0 additions & 35 deletions sparkth/api/v1/user.py

This file was deleted.

9 changes: 9 additions & 0 deletions sparkth/api/v1/user/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Current-user API package.

Exports the router. The package has no domain exceptions of its own, so nothing is
registered with the exception-handler registry here.
"""

from sparkth.api.v1.user.routes import router

__all__ = ["router"]
Loading
Loading