//! XRPC read endpoints (appview query layer). //! //! All read endpoints are backed by the SQLite projection — records in //! `record_json`, handles in `identities`, profiles in `profiles` — so the read //! path needs no Hydrant handle. Endpoints mount via `jacquard-axum`'s //! [`IntoRouter`] and share [`AppState`] (SQLite pool + identity resolver + bsky //! client). //! //! In addition to the `space.polymodel.*` endpoints below, this module mounts a //! fixed, structural allowlist of non-polymodel XRPC methods as a single-origin //! passthrough proxy (PM-47): the direct `com.atproto.repo.*` record operations //! plus `com.atproto.identity.resolveHandle` and `app.bsky.actor.getProfile`, //! dispatched over their generated typed structs to the user's PDS / public //! AppView. Every other NSID has no route and 404s, and upstream XRPC errors are //! forwarded with their real status + body. pub mod error; pub mod ssr; pub mod state; pub mod views; mod actor; mod content_type; mod downloads; mod drafts; mod graph; mod ldraw; mod library; mod proxy; mod writes; #[cfg(test)] mod tests; use axum::Router; use jacquard_axum::IntoRouter; use jacquard_common::xrpc::XrpcEndpoint; use polymodel_api::space_polymodel::{ actor::{ bootstrap_profile::BootstrapProfileRequest, get_profile::GetProfileRequest, get_session::GetSessionRequest, }, graph::{ create_follow::CreateFollowRequest, create_like::CreateLikeRequest, create_save::CreateSaveRequest, create_tag::CreateTagRequest, delete_follow::DeleteFollowRequest, delete_like::DeleteLikeRequest, delete_save::DeleteSaveRequest, delete_tag::DeleteTagRequest, get_list::GetListRequest, }, library::{ delete_thing::DeleteThingRequest, get_author_things::GetAuthorThingsRequest, get_feed::GetFeedRequest, get_model::GetModelRequest, get_model_load_bundle::GetModelLoadBundleRequest, get_part_file::GetPartFileRequest, get_thing::GetThingRequest, publish_thing::PublishThingRequest, search_things::SearchThingsRequest, stage_file::StageFileRequest, update_thing::UpdateThingRequest, }, }; use polymodel_api::app_bsky::actor::get_profile::GetProfileRequest as AppBskyGetProfileRequest; use polymodel_api::com_atproto::identity::resolve_handle::ResolveHandleRequest; use polymodel_api::com_atproto::repo::{ create_record::CreateRecordRequest, delete_record::DeleteRecordRequest, describe_repo::DescribeRepoRequest, get_record::GetRecordRequest, list_records::ListRecordsRequest, put_record::PutRecordRequest, upload_blob::UploadBlobRequest, }; use self::state::AppState; /// Build the appview XRPC router with the given state applied. /// /// Returns a state-bound `Router`; merged with the OAuth routes and the Dioxus SSR router in `main`, where `.with_state` is applied once. pub fn router() -> Router { Router::new() .merge(GetThingRequest::into_router(library::get_thing)) .merge(GetModelRequest::into_router(library::get_model)) .merge(GetAuthorThingsRequest::into_router( library::get_author_things, )) .merge(GetFeedRequest::into_router(library::get_feed)) .merge(SearchThingsRequest::into_router(library::search_things)) .merge(GetListRequest::into_router(graph::get_list)) .merge(GetProfileRequest::into_router(actor::get_profile)) .merge(BootstrapProfileRequest::into_router( writes::bootstrap_profile, )) .route( StageFileRequest::PATH, axum::routing::post(writes::stage_file), ) .merge(PublishThingRequest::into_router(writes::publish_thing)) .merge(UpdateThingRequest::into_router(writes::update_thing)) .merge(DeleteThingRequest::into_router(writes::delete_thing)) .merge(CreateFollowRequest::into_router(writes::create_follow)) .merge(DeleteFollowRequest::into_router(writes::delete_follow)) .merge(CreateLikeRequest::into_router(writes::create_like)) .merge(DeleteLikeRequest::into_router(writes::delete_like)) .merge(CreateSaveRequest::into_router(writes::create_save)) .merge(DeleteSaveRequest::into_router(writes::delete_save)) .merge(CreateTagRequest::into_router(writes::create_tag)) .merge(DeleteTagRequest::into_router(writes::delete_tag)) // PM-47 passthrough proxy: a structural allowlist of non-polymodel XRPC // methods forwarded to the user's PDS / public AppView over their // generated typed request structs. Any other NSID has no route → 404. .merge(GetRecordRequest::into_router(proxy::repo_get_record)) .merge(ListRecordsRequest::into_router(proxy::repo_list_records)) .merge(DescribeRepoRequest::into_router(proxy::repo_describe_repo)) .merge(CreateRecordRequest::into_router(proxy::repo_create_record)) .merge(PutRecordRequest::into_router(proxy::repo_put_record)) .merge(DeleteRecordRequest::into_router(proxy::repo_delete_record)) .merge(UploadBlobRequest::into_router(proxy::repo_upload_blob)) .merge(ResolveHandleRequest::into_router( proxy::identity_resolve_handle, )) .merge(AppBskyGetProfileRequest::into_router( proxy::actor_get_profile, )) // getSession takes no parameters, so it bypasses ExtractXrpc (which // decodes the query string; a unit-struct request from an empty query is // rejected by serde_html_form). It uses Jacquard's API/headless optional // extractor so the documented x-jacquard-session fallback is confined to // this session bridge surface; other public read endpoints keep the // browser-oriented optional extractor semantics. .route( GetSessionRequest::PATH, axum::routing::get(actor::get_session), ) .merge(GetPartFileRequest::into_router(downloads::get_part_file)) .merge(GetModelLoadBundleRequest::into_router( ldraw::get_model_load_bundle, )) .route( "/app/model-load-bundle", axum::routing::post(ldraw::app_model_load_bundle), ) .route( "/app/ldraw/resources/{binding}", axum::routing::get(ldraw::fetch_resource), ) // PM-43 app-internal draft store + image upload. Not federated lexicons, // so these use a plain `/app/*` namespace (cookie-authenticated, // same-origin) rather than `/xrpc/space.polymodel.*`. .route( "/app/drafts", axum::routing::post(drafts::create_draft).get(drafts::list_drafts_handler), ) .route( "/app/drafts/{draft_id}", axum::routing::put(drafts::put_draft) .get(drafts::get_draft_handler) .delete(drafts::delete_draft_handler), ) .route( "/app/drafts/{draft_id}/publish", axum::routing::post(drafts::publish_draft), ) .route("/app/images", axum::routing::post(drafts::upload_image)) // Per-request timing: logs total server-side handling time for every // appview request, *including* the OAuth session-restore extractor that // runs before each handler body. Use this to localize latency (server // handler vs. extractor vs. a client firing the request late). .layer(axum::middleware::from_fn(time_request)) } async fn time_request( req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { let method = req.method().clone(); let path = req.uri().path().to_string(); let start = std::time::Instant::now(); let response = next.run(req).await; let elapsed_ms = start.elapsed().as_millis(); let status = response.status().as_u16(); if elapsed_ms >= 500 { tracing::warn!(%method, path, status, elapsed_ms, "slow appview request"); } else { tracing::info!(%method, path, status, elapsed_ms, "appview request"); } response }