//// The request context: cohesive service records composed at the root //// (`wiring`), plus the request helpers every handler shares. A raw resource //// (host string, client, store) never crosses into a domain module; only //// capabilities do. import atproto/xrpc.{type Client} import crate_server/catalog/source as catalog_source import crate_server/catalog_deps import crate_server/catalog_index import crate_server/discogs_client import crate_server/follow_index import crate_server/identity_resolver import crate_server/known_users import crate_server/oauth/authed import crate_server/oauth/config.{type Config} import crate_server/oauth/session_store import crate_server/oauth/sessions.{type OauthSession, type Store} import crate_server/readiness import crate_server/shelf_index import gleam/json import gleam/option.{type Option, None, Some} import wisp.{type Request, type Response} /// Serving the frontend: where static files live, which origin we are. pub type Web { Web(static_directory: String, base_url: String) } /// The shared atproto transport, the Slingshot resolver host, and the /// TTL-cached batch identity resolver built over it. pub type Atproto { Atproto( client: Client, resolver: String, identity: identity_resolver.Resolver, ) } /// Everything Discogs: the app credential, per-user tokens, and a text sender /// for its non-XRPC API. pub type Discogs { Discogs( auth: Option(discogs_client.Auth), creds: Store, send: discogs_client.Sender, ) } pub type Context { Context( web: Web, atproto: Atproto, discogs: Discogs, catalog: catalog_deps.Deps, known_users: known_users.Store, catalog_index: catalog_index.Store, /// The crate-native social graph; see `follow_index`. follow_index: follow_index.Store, /// Wired dark (C1+C2 of the appview-first roadmap): no read path /// consumes this yet. See `shelf_index`. shelf_index: shelf_index.Store, /// The resolution layer's read port: candidate release rows (chain edges /// included) plus a public adoption tally. See `catalog/source`. variant_source: catalog_source.Source, readiness: readiness.Check, oauth: Config, ) } /// An optional response field as a zero-or-one element list, so handlers can /// `list.flatten` it into a `json.object` instead of casing at every call site. pub fn optional_field( name: String, value: Option(String), ) -> List(#(String, json.Json)) { case value { Some(v) -> [#(name, json.string(v))] None -> [] } } /// XRPC error body: a PascalCase `error` name derived from the status plus the /// human-readable `message`. pub fn error_json(status: Int, message: String) -> Response { json.object([ #("error", json.string(error_name(status))), #("message", json.string(message)), ]) |> json.to_string |> wisp.json_response(status) } fn error_name(status: Int) -> String { case status { 400 -> "InvalidRequest" 401 -> "AuthenticationRequired" 404 -> "NotFound" 409 -> "Conflict" 413 -> "PayloadTooLarge" 429 -> "RateLimitExceeded" 502 -> "UpstreamFailure" 503 -> "ServiceUnavailable" _ -> "InternalServerError" } } pub fn require_session( req: Request, ctx: Context, next: fn(String, OauthSession) -> Response, ) -> Response { case session_store.get_with_id(ctx.oauth.sessions, req) { Some(#(id, session)) -> next(id, session) None -> error_json(401, "not logged in") } } pub fn with_pds_client( ctx: Context, id: String, session: OauthSession, next: fn(Client, OauthSession) -> Response, ) -> Response { case authed.prepare(ctx.oauth, id, session) { Error(e) -> error_json(502, "auth failed: " <> e) Ok(#(client, current)) -> next(client, current) } }