···11+//! XRPC handlers.
22+//!
33+//! Uses `jacquard-axum`'s `ExtractXrpc` extractor to deserialise query parameters
44+//! directly into the lexicon-generated request types.
55+66+use axum::{
77+ Json,
88+ extract::State,
99+ http::StatusCode,
1010+ response::{IntoResponse, Response},
1111+};
1212+use jacquard_api::com_atproto::sync::get_repo_status::{GetRepoStatusOutput, GetRepoStatusRequest};
1313+use jacquard_axum::ExtractXrpc;
1414+use serde_json::json;
1515+1616+use crate::storage::{DbRef, error::StorageError};
1717+1818+pub enum GetRepoStatusError {
1919+ StorageError,
2020+ RepoNotFound,
2121+}
2222+2323+impl From<StorageError> for GetRepoStatusError {
2424+ fn from(e: StorageError) -> Self {
2525+ tracing::error!("Storage error: {e:#}");
2626+ Self::StorageError
2727+ }
2828+}
2929+3030+impl IntoResponse for GetRepoStatusError {
3131+ fn into_response(self) -> Response {
3232+ match self {
3333+ GetRepoStatusError::StorageError => (
3434+ StatusCode::INTERNAL_SERVER_ERROR,
3535+ Json(json!({
3636+ "error": "InternalError",
3737+ "message": "Storage issue",
3838+ })),
3939+ ),
4040+ GetRepoStatusError::RepoNotFound => (
4141+ StatusCode::BAD_REQUEST,
4242+ Json(json!({
4343+ "error": "RepoNotFound",
4444+ "message": "the requested repo does not exist or has not been indexed.",
4545+ })),
4646+ ),
4747+ }
4848+ .into_response()
4949+ }
5050+}
5151+5252+/// Handler for `GET /xrpc/com.atproto.sync.getRepoStatus`.
5353+///
5454+/// Returns the active/status of the given repo. Returns 404 (XRPC RepoNotFound)
5555+/// if the DID has never been indexed.
5656+///
5757+/// `active` reflects whether the account is usable. `status` carries the
5858+/// reason when inactive ("takendown", "suspended", "deactivated", "deleted").
5959+/// `rev` is currently omitted (TODO: store latest rev in RepoRecord).
6060+pub async fn get_repo_status(
6161+ State(db): State<DbRef>,
6262+ ExtractXrpc(req): ExtractXrpc<GetRepoStatusRequest>,
6363+) -> Result<Json<GetRepoStatusOutput<'static>>, GetRepoStatusError> {
6464+ let (info, prev) =
6565+ crate::storage::repo::get(&db, req.did.clone())?.ok_or(GetRepoStatusError::RepoNotFound)?;
6666+6767+ Ok(Json(GetRepoStatusOutput {
6868+ did: req.did,
6969+ rev: prev.map(|p| p.rev),
7070+ active: info.status.is_active(),
7171+ status: info.status.status().map(|s| s.to_owned().into()),
7272+ extra_data: None,
7373+ }))
7474+}
-157
src/server/handler.rs
···11-//! XRPC handlers.
22-//!
33-//! Uses `jacquard-axum`'s `ExtractXrpc` extractor to deserialise query parameters
44-//! directly into the lexicon-generated request types.
55-//!
66-//! TODO: xrpc-style error handling
77-88-use axum::{
99- Json,
1010- extract::State,
1111- http::StatusCode,
1212- response::{IntoResponse, Response},
1313-};
1414-use jacquard_api::com_atproto::sync::{
1515- get_repo_status::{GetRepoStatusOutput, GetRepoStatusRequest},
1616- list_repos_by_collection::{ListReposByCollectionOutput, ListReposByCollectionRequest, Repo},
1717-};
1818-use jacquard_axum::ExtractXrpc;
1919-use jacquard_common::types::string::Did;
2020-use serde_json::json;
2121-2222-use crate::storage::{DbRef, error::StorageError};
2323-2424-pub enum ListReposByCollectionError {
2525- BadCursor,
2626- StorageError,
2727-}
2828-2929-impl From<StorageError> for ListReposByCollectionError {
3030- fn from(e: StorageError) -> Self {
3131- tracing::error!("Storage error: {e:#}");
3232- Self::StorageError
3333- }
3434-}
3535-3636-impl IntoResponse for ListReposByCollectionError {
3737- fn into_response(self) -> Response {
3838- match self {
3939- ListReposByCollectionError::BadCursor => (
4040- StatusCode::BAD_REQUEST,
4141- Json(json!({
4242- "error": "InvalidRequest",
4343- "message": "the provided cursor was not valid",
4444- })),
4545- ),
4646- ListReposByCollectionError::StorageError => (
4747- StatusCode::INTERNAL_SERVER_ERROR,
4848- Json(json!({
4949- "error": "InternalError",
5050- "message": "Storage issue",
5151- })),
5252- ),
5353- }
5454- .into_response()
5555- }
5656-}
5757-5858-/// Handler for `GET /xrpc/com.atproto.sync.listReposByCollection`.
5959-///
6060-/// Performs a cursor-paginated prefix scan over the rbc keyspace, returning
6161-/// up to `limit` DIDs that have at least one record in `collection`.
6262-///
6363-/// The cursor is the last DID from the previous page. On each request we
6464-/// scan for `limit + 1` results: if the extra result appears there is a next
6565-/// page, and we return the last DID of the current page as the next cursor.
6666-///
6767-/// the `limit` parameter is clamped at 10,000 instead of 2,000 as defined in
6868-/// the lexicon, because bluesky's own collectiondir only clamps at 10k.
6969-pub async fn list_repos_by_collection(
7070- State(db): State<DbRef>,
7171- ExtractXrpc(req): ExtractXrpc<ListReposByCollectionRequest>,
7272-) -> Result<Json<ListReposByCollectionOutput<'static>>, ListReposByCollectionError> {
7373- let limit = req.limit.unwrap_or(500).clamp(1, 10_000) as usize;
7474-7575- // Parse the cursor as a DID, if one was provided.
7676- let cursor = req
7777- .cursor
7878- .map(Did::new_owned)
7979- .transpose()
8080- .map_err(|_| ListReposByCollectionError::BadCursor)?;
8181-8282- let (dids, next) = crate::storage::list_by::scan_rbc(&db, req.collection, cursor, limit)?;
8383-8484- let repos = dids
8585- .into_iter()
8686- .map(|did| Repo {
8787- did,
8888- extra_data: None,
8989- })
9090- .collect();
9191-9292- let next_cursor = next.map(|cursor| cursor.into());
9393-9494- Ok(Json(ListReposByCollectionOutput {
9595- cursor: next_cursor,
9696- repos,
9797- extra_data: None,
9898- }))
9999-}
100100-101101-pub enum GetRepoStatusError {
102102- StorageError,
103103- RepoNotFound,
104104-}
105105-106106-impl From<StorageError> for GetRepoStatusError {
107107- fn from(e: StorageError) -> Self {
108108- tracing::error!("Storage error: {e:#}");
109109- Self::StorageError
110110- }
111111-}
112112-113113-impl IntoResponse for GetRepoStatusError {
114114- fn into_response(self) -> Response {
115115- match self {
116116- GetRepoStatusError::StorageError => (
117117- StatusCode::INTERNAL_SERVER_ERROR,
118118- Json(json!({
119119- "error": "InternalError",
120120- "message": "Storage issue",
121121- })),
122122- ),
123123- GetRepoStatusError::RepoNotFound => (
124124- StatusCode::BAD_REQUEST,
125125- Json(json!({
126126- "error": "RepoNotFound",
127127- "message": "the requested repo does not exist or has not been indexed.",
128128- })),
129129- ),
130130- }
131131- .into_response()
132132- }
133133-}
134134-135135-/// Handler for `GET /xrpc/com.atproto.sync.getRepoStatus`.
136136-///
137137-/// Returns the active/status of the given repo. Returns 404 (XRPC RepoNotFound)
138138-/// if the DID has never been indexed.
139139-///
140140-/// `active` reflects whether the account is usable. `status` carries the
141141-/// reason when inactive ("takendown", "suspended", "deactivated", "deleted").
142142-/// `rev` is currently omitted (TODO: store latest rev in RepoRecord).
143143-pub async fn get_repo_status(
144144- State(db): State<DbRef>,
145145- ExtractXrpc(req): ExtractXrpc<GetRepoStatusRequest>,
146146-) -> Result<Json<GetRepoStatusOutput<'static>>, GetRepoStatusError> {
147147- let (info, prev) =
148148- crate::storage::repo::get(&db, req.did.clone())?.ok_or(GetRepoStatusError::RepoNotFound)?;
149149-150150- Ok(Json(GetRepoStatusOutput {
151151- did: req.did,
152152- rev: prev.map(|p| p.rev),
153153- active: info.status.is_active(),
154154- status: info.status.status().map(|s| s.to_owned().into()),
155155- extra_data: None,
156156- }))
157157-}
+96
src/server/list_repos_by_collection.rs
···11+//! XRPC handlers.
22+//!
33+//! Uses `jacquard-axum`'s `ExtractXrpc` extractor to deserialise query parameters
44+//! directly into the lexicon-generated request types.
55+66+use axum::{
77+ Json,
88+ extract::State,
99+ http::StatusCode,
1010+ response::{IntoResponse, Response},
1111+};
1212+use jacquard_api::com_atproto::sync::list_repos_by_collection::{
1313+ ListReposByCollectionOutput, ListReposByCollectionRequest, Repo,
1414+};
1515+use jacquard_axum::ExtractXrpc;
1616+use jacquard_common::types::string::Did;
1717+use serde_json::json;
1818+1919+use crate::storage::{DbRef, error::StorageError};
2020+2121+pub enum ListReposByCollectionError {
2222+ BadCursor,
2323+ StorageError,
2424+}
2525+2626+impl From<StorageError> for ListReposByCollectionError {
2727+ fn from(e: StorageError) -> Self {
2828+ tracing::error!("Storage error: {e:#}");
2929+ Self::StorageError
3030+ }
3131+}
3232+3333+impl IntoResponse for ListReposByCollectionError {
3434+ fn into_response(self) -> Response {
3535+ match self {
3636+ ListReposByCollectionError::BadCursor => (
3737+ StatusCode::BAD_REQUEST,
3838+ Json(json!({
3939+ "error": "InvalidRequest",
4040+ "message": "the provided cursor was not valid",
4141+ })),
4242+ ),
4343+ ListReposByCollectionError::StorageError => (
4444+ StatusCode::INTERNAL_SERVER_ERROR,
4545+ Json(json!({
4646+ "error": "InternalError",
4747+ "message": "Storage issue",
4848+ })),
4949+ ),
5050+ }
5151+ .into_response()
5252+ }
5353+}
5454+5555+/// Handler for `GET /xrpc/com.atproto.sync.listReposByCollection`.
5656+///
5757+/// Performs a cursor-paginated prefix scan over the rbc keyspace, returning
5858+/// up to `limit` DIDs that have at least one record in `collection`.
5959+///
6060+/// The cursor is the last DID from the previous page. On each request we
6161+/// scan for `limit + 1` results: if the extra result appears there is a next
6262+/// page, and we return the last DID of the current page as the next cursor.
6363+///
6464+/// the `limit` parameter is clamped at 10,000 instead of 2,000 as defined in
6565+/// the lexicon, because bluesky's own collectiondir only clamps at 10k.
6666+pub async fn list_repos_by_collection(
6767+ State(db): State<DbRef>,
6868+ ExtractXrpc(req): ExtractXrpc<ListReposByCollectionRequest>,
6969+) -> Result<Json<ListReposByCollectionOutput<'static>>, ListReposByCollectionError> {
7070+ let limit = req.limit.unwrap_or(500).clamp(1, 10_000) as usize;
7171+7272+ // Parse the cursor as a DID, if one was provided.
7373+ let cursor = req
7474+ .cursor
7575+ .map(Did::new_owned)
7676+ .transpose()
7777+ .map_err(|_| ListReposByCollectionError::BadCursor)?;
7878+7979+ let (dids, next) = crate::storage::list_by::scan_rbc(&db, req.collection, cursor, limit)?;
8080+8181+ let repos = dids
8282+ .into_iter()
8383+ .map(|did| Repo {
8484+ did,
8585+ extra_data: None,
8686+ })
8787+ .collect();
8888+8989+ let next_cursor = next.map(|cursor| cursor.into());
9090+9191+ Ok(Json(ListReposByCollectionOutput {
9292+ cursor: next_cursor,
9393+ repos,
9494+ extra_data: None,
9595+ }))
9696+}
+10-4
src/server/mod.rs
···33//! Serves XRPC endpoints via axum routers built with `jacquard-axum`'s
44//! `IntoRouter` helper.
5566-mod handler;
66+mod get_repo_status;
77+mod list_repos_by_collection;
88+99+use get_repo_status::get_repo_status;
1010+use list_repos_by_collection::list_repos_by_collection;
711812use std::net::SocketAddr;
913···1822/// Build and serve the axum application on `addr`.
1923///
2024/// Routes:
2121-/// GET /xrpc/com.atproto.sync.listReposByCollection
2225/// GET /xrpc/com.atproto.sync.getRepoStatus
2626+/// GET /xrpc/com.atproto.sync.listReposByCollection
2327pub async fn serve(addr: SocketAddr, db: DbRef) -> Result<()> {
2424- let app = ListReposByCollectionRequest::into_router(handler::list_repos_by_collection)
2525- .merge(GetRepoStatusRequest::into_router(handler::get_repo_status))
2828+ let app = GetRepoStatusRequest::into_router(get_repo_status)
2929+ .merge(ListReposByCollectionRequest::into_router(
3030+ list_repos_by_collection,
3131+ ))
2632 .with_state(db);
27332834 let listener = tokio::net::TcpListener::bind(addr).await?;