use axum::{Json, Router, extract::State, http::StatusCode, routing::get}; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; use crate::{api::MultiQuery, control::Hydrant}; pub fn router() -> Router { Router::new() .route( "/xrpc/blue.microcosm.links.getBacklinks", get(handle_get_backlinks), ) .route( "/xrpc/blue.microcosm.links.getBacklinksCount", get(handle_get_backlinks_count), ) } #[derive(Deserialize)] pub struct GetBacklinksParams { pub subject: String, /// filter by source collection, optionally with a path suffix `collection:path` pub source: Option, #[serde(default)] pub did: Vec, pub limit: Option, pub cursor: Option, pub reverse: Option, } #[derive(Serialize)] pub struct Backlink { pub uri: SmolStr, pub cid: SmolStr, } #[derive(Serialize)] pub struct GetBacklinksOutput { pub backlinks: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, } #[derive(Deserialize)] pub struct GetBacklinksCountParams { pub subject: String, pub source: Option, #[serde(default)] pub did: Vec, } #[derive(Serialize)] pub struct GetBacklinksCountOutput { pub count: u64, } pub async fn handle_get_backlinks( State(hydrant): State, MultiQuery(params): MultiQuery, ) -> Result, StatusCode> { let limit = params.limit.unwrap_or(50).min(100) as usize; let reverse = params.reverse.unwrap_or(false); let cursor_bytes = params .cursor .as_deref() .and_then(|c| data_encoding::BASE64URL_NOPAD.decode(c.as_bytes()).ok()); let mut fetch = hydrant .backlinks .fetch(params.subject) .limit(limit) .reverse(reverse); if !params.did.is_empty() { fetch = fetch.dids(params.did); } if let Some(ref src) = params.source { fetch = fetch.source(src); } if let Some(c) = cursor_bytes { fetch = fetch.cursor(c); } let page = fetch .run() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let next_cursor = page .next_cursor .map(|b| data_encoding::BASE64URL_NOPAD.encode(&b)); let backlinks = page .backlinks .into_iter() .map(|e| Backlink { uri: e.uri, cid: e.cid, }) .collect(); Ok(Json(GetBacklinksOutput { backlinks, cursor: next_cursor, })) } pub async fn handle_get_backlinks_count( State(hydrant): State, MultiQuery(params): MultiQuery, ) -> Result, StatusCode> { let mut count_q = hydrant.backlinks.count(params.subject); if !params.did.is_empty() { count_q = count_q.dids(params.did); } if let Some(ref src) = params.source { count_q = count_q.source(src); } let count = count_q .run() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(GetBacklinksCountOutput { count })) } #[cfg(test)] mod tests { use super::*; #[test] fn backlinks_query_accepts_single_and_repeated_dids() { let fetch: GetBacklinksParams = serde_html_form::from_str( "subject=at%3A%2F%2Ftarget&did=did%3Aplc%3Aone&did=did%3Aweb%3Atwo", ) .unwrap(); assert_eq!(fetch.did, ["did:plc:one", "did:web:two"]); let count: GetBacklinksCountParams = serde_html_form::from_str("subject=at%3A%2F%2Ftarget&did=did%3Aplc%3Aone").unwrap(); assert_eq!(count.did, ["did:plc:one"]); } }