DANK's web server is a FastAPI app in src/dank/api/, which web runs with
uvicorn:
uv run web # reloads on changes under src/dank
uv run web --no-reload --port 8081web takes --config, --host, --port and --no-reload. OpenAPI is served
at /api/openapi.json, with interactive docs at /api/docs. The aiohttp server
it replaced was removed in DEN-51;
aiohttp is still DANK's HTTP client for collection.
Every feature has three layers:
- Domain (
model.py): DANK's rules as NamedTuples and pure functions. - Application (
service.py): a service class that runs the use cases, plus aProtocolfor each outside thing it needs (its ports). - Edge and adapters: the router and Pydantic schemas turn HTTP into service
calls; adapters implement the ports against ClickHouse, Postgres or
config.toml.
Dependencies point inward: the edge and adapters import the service and model, never the other way round.
src/dank/
errors.py DankError, InvalidInput, NotFound, Conflict, Busy,
TooManyRequests; FieldProblem for an error's fields
sources/ Sources people manage, outside api/ because the CLIs
use them too
model.py Source, normalized(), compile_sources(), encoding
service.py SourceService, port SourceRepository
db/sources.py PostgresSourceRepository
api/
app.py create_app(): routers, error handlers, guard, headers
body_limit.py 413 for bodies over 1 MiB, before they are read
lifespan.py Resources, opened at startup and closed at shutdown
deps.py ResourcesDep, SettingsDep, PostgresSessionDep, single_values
errors.py the error envelope
guards.py same-origin JSON rule for unsafe methods
headers.py Cache-Control defaults, HEAD for every GET
schemas.py ApiModel, ErrorBody, FieldError
serve.py uvicorn and watchfiles behind web
openapi.py dank-openapi: the schema file the reader's types come from
sources/
router.py GET /api/reader/sources
schemas.py SourcesResponse, one variant per source kind
deps.py picks the adapters; tests override them here
service.py SourceService, ports SourceCatalog and CollectionStats
model.py SourceSummary, XActivity, summarize(), source_kind()
clickhouse.py ClickHouseCollectionStats
config_catalog.py ConfigSourceCatalog, over Settings.sources
source_settings/
router.py GET, POST and PUT /api/reader/source-settings
schemas.py SourceBody, SourceOut, SourceListResponse
deps.py picks the repository; tests override it here
posts/
router.py GET /api/reader/posts, /post and /x/authors
schemas.py Query models; PostOut, PostDetailOut, XAuthorsResponse
deps.py picks the adapters; tests override them here
service.py PostService, ports PostStore, SearchEmbeddings, Clock
model.py ReaderFilters, reader_filters(), cursors, PostRow
clickhouse.py ClickHousePostStore, over the reader helpers
embeddings.py CachedSearchEmbeddings: cache first, model in a thread
connections/
router.py GET /api/reader/connections and /connection
schemas.py Query models; ConnectionsResponse, ConnectionOut
deps.py picks the adapter; tests override it here
service.py ConnectionService, port ConnectionStore
model.py ConnectionScope, ConnectionKey and their rules
clickhouse.py ClickHouseConnectionStore, over the reader helpers
source_icons/
router.py GET /api/reader/source-icon, a PNG
schemas.py SourceIconQuery
deps.py picks the adapters; tests override them here
service.py SourceIconService, ports IconCache, CollectedDomains
model.py source_icon_url(), which domains may have an icon
clickhouse.py ClickHouseCollectedDomains
files/
router.py GET /reader and /reader/..., the redirect to /
static.py ReaderFiles, FileRoots, the /assets mount and the reader
model.py client_route(), which paths load the reader
youtube/
router.py GET and POST /api/reader/youtube/...
schemas.py Query models; YouTubeItemOut, PlaylistOut and the rest
deps.py picks the adapters; tests override them here
service.py YouTubeService, VideoCaptureService; ports YouTubeStore,
VideoCollector; CollectionLocks, CollectionLimits
model.py YouTubeFilters, PlaylistPage, cursors, transcript_view()
clickhouse.py ClickHouseYouTubeStore, over the reader helpers
collector.py ScrapeVideoCollector, CollectionGate, the real waits
refresh/
router.py GET and POST /api/reader/refresh
schemas.py RefreshRequest, one scope variant per kind; RefreshJobOut
deps.py picks the adapters; tests override them here
service.py RefreshService, resolve_scope, ports RefreshTargets, RefreshQueue
model.py RefreshPlan, RefreshJob, scope and history rules
clickhouse.py ClickHouseRefreshTargets
manager.py RefreshManager, running_refresh, port CollectionWorker
worker.py ProcessWorker, one spawned process per job
A feature skips files it has nothing for: no model.py without rules, no
adapter without I/O.
- Features are modules. Each lives in
dank/api/<feature>/with the same file names, so route groups can be ported in parallel without touching each other's files. - Dependencies point inward.
model.pyandservice.pyimport no FastAPI, Starlette, Pydantic, SQLAlchemy, ClickHouse, aiohttp ordank.webcode.run-linters.shchecks this. - Pydantic at the edge, NamedTuples inside. Request and response models
extend
ApiModel(extra="forbid", frozen). Routes never return dicts or NamedTuples, and services never take Pydantic models. A convention test checks through OpenAPI that every route returns a strict model. Wide projections are the one exception inside; see Projections. Pages and files have no JSON to model; see Pages and files. - Controllers are thin. A route lets Pydantic parse the input, calls one
service method and shapes the result: no SQL, no
try/except, no business rules. Routes are plain functions on anAPIRouter. - Ports only where I/O happens. A service declares a
Protocolfor a database, the clock, a model, the network or a subprocess. Pure code calls pure code directly. Adapters match a port by shape and never inherit. - Repositories for writes, read stores for reads. ClickHouse reads go through read stores that return domain values or projections, never rows. Postgres app state, starting with sources, gets repositories.
- One aggregate, one transaction. A repository method opens and commits its own transaction. There is no unit-of-work class until a use case must change two aggregates together.
- Errors are domain facts, translated once. Services raise
DankErrorsubclasses;api/errors.pymaps them, and FastAPI's own errors, to one envelope. Only edge code raisesHTTPException. - Resources come from the lifespan. The ClickHouse client, Postgres
sessions, refresh workers, collection locks and limiters, and settings are
opened in the lifespan and reached through
Annotateddependencies. There are no module-level clients. - Fakes over mocks, real databases over fakes of SQL. See Testing.
Every error under /api/ has the same body. error is a message the reader can
show, code is stable for clients to match on, and fields lists invalid
inputs:
{
"error": "The request is invalid.",
"code": "invalid_request",
"fields": [
{"path": "body.n", "code": "int_parsing", "message": "Input should be a valid integer, unable to parse string as an integer"}
]
}| Raised by | Status | code |
|---|---|---|
| Pydantic validation | 400 | invalid_request, with fields |
InvalidInput |
400 | invalid_input, or a subclass's code, with fields when it names them |
NotFound |
404 | not_found |
Conflict |
409 | conflict, or a subclass's code |
Busy |
503 | busy |
TooManyRequests |
429 | too_many_requests |
| Unsafe method from another origin | 403 | forbidden |
| Unsafe method with a body over 1 MiB | 413 | content_too_large |
| Unsafe method without a JSON body | 415 | unsupported_media_type |
| Unknown path or method | 404, 405 | not_found, method_not_allowed |
| Anything else (ClickHouse is down) | 503 | unavailable, logged |
The 1 MiB limit matches aiohttp's default, which the API had before DEN-51.
BodyLimit checks Content-Length before reading anything, then counts bytes
as they arrive, so a chunked body or an understated length can't get past it.
Validation errors use 400 rather than FastAPI's 422, as the reader expects.
OpenAPI declares the envelope as every route's default response, in place of
FastAPI's 422, so the reader's generated types know it.
A route that needs resources but runs without the lifespan, such as a
test missing a dependency override, answers 503.
Everywhere else, the same errors answer with the same status and error as
plain text. No page or file has ever answered in JSON.
A route takes its query as Annotated[Model, Query()], which gives OpenAPI one
parameter per field. The model extends ApiModel.
- Repeatable keys are
tuple[str, ...]fields. - Unknown keys fail through
extra="forbid". A route that has always ignored them says so withextra="ignore". - Repeated scalar keys need
dependencies=[Depends(single_values(Model))]on the route. Starlette keeps only a key's last value, so Pydantic never sees the repeat. - Shape is Pydantic's. Types and closed sets of values fail as
invalid_requestand name their field infields. - Rules are the domain's. A rule across fields, or one whose wording the
reader shows, lives in
model.pyand raisesInvalidInput, soerrorkeeps that wording. The model hands its values to the rule from a method such asquery.filters(), called by the route. - Keep a field as text when Pydantic is looser than the rule. Pydantic takes
5.0as an integer and a timestamp as a date. Run aBeforeValidatoron the rule, or validate the text in the domain.InvalidInputis aValueError, so Pydantic reports a rule that a validator calls as a field error. - Absent can differ from empty. Use
| None = Nonewhen sending a key empty must fail while leaving it out is fine.
A route declares its shape as Annotated[str, Path(pattern=...)], so a bad value
fails as invalid_request and OpenAPI shows the rule. Keep Starlette's default
converter, which can't match a slash; never use :path. uvicorn decodes the path
before routing, so %2F is a slash too, and finds no route.
POST /api/reader/youtube/metadata/{video_id} and /transcript/{video_id} ask
YouTube about one video that DANK already knows, then save the answer. Asking
about an unknown ID fails before anything waits or connects. So that readers
can't pile up behind a slow provider:
- One collection of each kind runs at a time. Its
asyncio.LockandRequestLimitermake up aCollectionGate, and the lifespan owns one for each kind. A request that waited reuses what the first one saved. - The service bounds each wait with
CollectionLimits, and running out raisesBusy. Metadata gets 15 seconds for the lock and the collection together. Transcripts get 1 second for the lock, then the transcript timeout plus 5 seconds for the lookup. - Tests override
collection_limitswith short waits and hold a collection open with the fake collector, so nothing has to sleep for the real waits.
Each body is declared as CollectBody, an empty object. The reader sends {}.
Posts, connections and YouTube items are the exception to NamedTuples inside. The
helpers in dank/web/reader_*.py build one deeply nested, JSON-shaped dict per
post, with keys that are absent rather than null. model.py names the dict
(PostPayload, ItemPayload) and schemas.py gives it its contract. The
aiohttp server that also needed the dicts is gone, so they could now become
NamedTuples.
YouTube's keys are always present, even when null, so its routes need none of the following. To keep absent fields absent:
- Declare a field that may be absent with a
Nonedefault, and one that is always present, even as null, without a default. - Build the response with
model_validateon the dict, so a missing key stays unset. Don't passNonefor it. - Set
response_model_exclude_unset=Trueon the route.
The models forbid unknown keys, so a helper that gains a field answers 503 until its schema declares it. Check a schema change against real data, because fixtures miss what scrapes actually saved.
guards.same_origin_json runs before every route and acts only on unsafe
methods. It rejects Sec-Fetch-Site: cross-site, and an Origin other than
the scheme and Host this server sees. The body must be application/json.
The local HTTPS proxy checks the browser's https:// origin itself, then
rewrites Origin to http:// before forwarding and adds
X-Forwarded-Proto: https. The guard therefore ignores forwarded headers, and
uvicorn runs with proxy_headers=False so that a proxy on the same machine
can't change the scheme. The rule needs no configuration natively, in Docker or
behind the proxy.
FastAPI parses a JSON body before it runs dependencies, so malformed JSON answers 400 even from another origin. Nothing has run by then.
/api/*responses getCache-Control: no-storeunless the route sets its own. Starlette answers unexpected errors outside every middleware, so that handler sets the header itself.- HTML responses get no-cache headers.
HEADruns the matchingGETroute and drops the body, except on the file mounts, which answer it themselves. Run as aGET, aHEADon a video would read the whole file only to throw it away.
These routes answer with HTML, files or images, so they have no response model.
The /reader redirect stays out of OpenAPI with include_in_schema=False. /api/reader/source-icon stays in and declares
image/png.
/assets/ is a Starlette StaticFiles mount, and the reader is a StaticFiles
app set as the router's fallback, so it answers any path no route or mount
matched, however late the route was added. Both
meet what audio and video need, as tests/api/files checks over HTTP: single and
open-ended ranges, If-Range, If-None-Match, If-Modified-Since, 416 past the
end, and HEAD without reading the file. No responder of our own was needed.
ReaderFiles adds only what is the reader's:
- A missing path falls back to
index.htmlonly if it has no extension and stays inside the build, so a missing bundle answers 404 rather than HTML. index.htmlisno-storeeven on a 304, which has no content type for the HTML header rule to catch.- A checkout with no reader build answers 404 rather than failing.
- Unmatched
/api/...and/assetspaths keep their 404, whatever the method, rather than loading the reader or answering 405. - Bundles live under
/bundle/, because/assets/holds downloaded media.
/reader and /reader/... redirect to the same path and query at / with a
relative Location, because behind the HTTPS proxy this server sees plain HTTP.
For the same reason redirect_slashes is off: /assets is a 404, not a redirect to an http:// address.
The Jinja viewer that / and /post used to serve was retired in
DEN-75, and the reader moved
to / in DEN-77.
Both servers run the same ClickHouse startup steps, in order, from
dank/web/startup.py: reddit.sql, categories.sql, then YouTube, link and
reference storage. The refresh manager starts after them. At shutdown uvicorn gives in-flight
requests 10 seconds, then the lifespan waits for refresh workers (about 95
seconds). That stays within the 110-second stop_grace_period in
compose.yaml.
The lifespan owns one RefreshManager through running_refresh: it restores
data_dir/reader-refresh.json, runs the scheduler task and calls close() at
shutdown. RefreshService is the use case the routes call, built per request
like any other; it resolves a scope through RefreshTargets, then submits it to
the manager through RefreshQueue.
- Running a job is a port.
ProcessWorkerspawns a process per job, whose entry point isdank/web/refresh_worker.py, and route tests swap it for a fake. The history file isn't a port, since a temporary file is as easy as a fake and more real. - A scope is JSON-shaped. It is saved and answered with keys absent rather than null, as in Projections. A job is a NamedTuple around it.
- Rejections keep their wording.
resolve_scopeturns a plainValueErrorfromurlsplitorconfigured_feedsintoInvalidInput, so it answers 400 with their words rather than 503. Their messages can say what to configure. - Restoring is strict. The API's models forbid what they don't know, so
restored_jobsleaves out any saved job they couldn't describe, rather than letting one answer 503 for the whole list. Unknown fields are dropped. - A worker stops in one of two ways. Shutdown signals it and waits up to 90
seconds, then kills its process group. If the server dies or reloads first,
which
watchfilesforces after 5 seconds,watch_ownersees the pipe close and stops the collection. The next start marks the job interrupted.
dank-openapi writes the app's schema to reader/openapi.json without starting
a server, and the reader generates reader/src/lib/api.gen.ts from it. Run both
with make openapi after changing a route or a model, and commit the two files.
tests/api/test_openapi_file.py fails while the schema is stale. Two models with
one name get mangled schema names, so give each response model a unique name.
A Literal is worth declaring wherever the reader relies on a closed set: it
becomes a union type there. See the reader's API types.
Tests live in tests/api/, mirroring the package. unittest.mock is banned
there by tests/api/ruff.toml.
-
Domain tests call pure functions with plain values. Nothing is faked.
-
Route tests call the real app over
httpx.AsyncClientwithASGITransport. Only driven adapters are replaced, with onedependency_overridesline each, by fakes in the feature'sfakes.pythat hold plain values and are type-checked against their port. -
Repository tests run Postgres repositories against a real database, and are marked
postgres; see Postgres migrations.tests/db/test_sources.pyraces real transactions, which a fake can't. -
Adapter tests run each adapter against a real, throwaway ClickHouse database. They are marked
clickhouseand skipped unless--clickhouse-urlnames a loopback server:docker run -d --name dank-test-clickhouse \ -e CLICKHOUSE_SKIP_USER_SETUP=1 -p 127.0.0.1:18123:8123 \ clickhouse/clickhouse-server:25.8 uv run pytest tests/api --clickhouse-url=http://default:@127.0.0.1:18123
Each test gets a new database with
schema.sqland the startup steps applied, and drops it afterwards. The network guard allows that one address. -
Projection tests do the same over HTTP: the real router and service, with the real store swapped in for the fake. Each test states its rows and checks the JSON, as in
tests/api/posts/test_payloads.py. -
Process tests use real processes, signals and pipes, as in
tests/api/refresh/test_worker.py.tests/api/refresh/test_lifecycle.pyruns whole jobs through real workers, collecting from a feed server on the same machine.
Rules: no AsyncMock, patch or monkeypatch.setattr on DANK modules; no
assertions on SQL text, call counts or call order. A test that needs a patch
points to a missing port.
Semantic search reads web_embedding_cache, so no adapter test loads the model;
date those rows from the clock, because the table's TTL drops old ones.
- Write a route test under
tests/api/<feature>/and watch it fail. - Put pure logic in
model.pyas NamedTuples and functions. - Put SQL behind a read store in
clickhouse.pywhose methods return domain values. - Write the use case in
service.py, gathering independent reads. - Declare query, body and response models in
schemas.py, extendingApiModel. See Query models and Projections. - Write the route, wire adapters in
deps.py, and mount the router inapp.py. Addsingle_valuesif the route takes a query model. - Cover any new SQL with adapter tests in the ClickHouse lane.
Controller classes, base repository or service classes, a unit of work, a
dependency-injection library, an event bus, a mapping library,
pydantic-settings or environment variables, generic CRUD routes and API
version prefixes. Each waits for a concrete need.
GET /api/reader/categories returns configured category names as
{ "categories": ["science"] }. Choices come from categories.definitions
in the loaded config.toml; this endpoint does not access ClickHouse.
Reader feed and post detail payloads include categories, an array of
{ "name": "science", "confidence": 0.9, "similarity": 0.95 } objects.
An empty array means no saved assignment. The scores describe the matching
training example's confidence and cosine similarity, not a new probability.
Repeat category on /api/reader/posts (and X author listings) to match any
selected exact, case-sensitive name. Category filtering combines with other
filters using AND and is bound into pagination cursors. Category choices do
not trigger training or processing. Both servers provide these routes.