diff --git a/docs/xrpc/README.md b/docs/xrpc/README.md --- a/docs/xrpc/README.md +++ b/docs/xrpc/README.md @@ -7,4 +7,5 @@ - [com.atproto.*](atproto.md): standard AT Protocol endpoints - [systems.gaze.hydrant.*](hydrant.md): hydrant-specific extensions - [blue.microcosm.identity.*](identity.md): verified mini-doc identity lookups +- [blue.microcosm.repo.*](repo.md): at-uri based record lookups - [blue.microcosm.links.*](backlinks.md): backlinks (requires `--features backlinks`) diff --git a/docs/xrpc/atproto.md b/docs/xrpc/atproto.md --- a/docs/xrpc/atproto.md +++ b/docs/xrpc/atproto.md @@ -5,7 +5,7 @@ these are standard atproto endpoints. you can look at [the atproto api reference](https://docs.bsky.app/docs/category/http-reference) for more info. the following are implemented currently: -- `com.atproto.repo.getRecord` +- `com.atproto.repo.getRecord` (also see `blue.microcosm.repo.getRecordByUri`) - `com.atproto.repo.listRecords` - `com.atproto.repo.describeRepo` (also see `systems.gaze.hydrant.describeRepo`) - `com.atproto.sync.getRepo` (`since` parameter not implemented!) diff --git a/docs/xrpc/repo.md b/docs/xrpc/repo.md new file mode 100644 --- /dev/null +++ b/docs/xrpc/repo.md @@ -0,0 +1,27 @@ +--- +title: blue.microcosm.repo.* +--- + +these queries are ergonomic variants of record lookup APIs. + +## blue.microcosm.repo.getRecordByUri + +alias of `com.bad-example.repo.getUriRecord` with intention to stabilize under this name. + +| param | required | description | +| :--- | :--- | :--- | +| `at_uri` | yes | the at-uri of the record. the identifier can be a DID or atproto handle, and the collection and rkey segments must be present. | +| `cid` | no | optional: the CID of the version of the record. if a newer version exists, returns not found. | + +returns `{ uri, cid, value }`. + +## com.bad-example.repo.getUriRecord + +ergonomic complement to `com.atproto.repo.getRecord` which accepts an `at-uri` instead of individual `repo` / `collection` / `rkey` params. + +| param | required | description | +| :--- | :--- | :--- | +| `at_uri` | yes | the at-uri of the record. the identifier can be a DID or atproto handle, and the collection and rkey segments must be present. | +| `cid` | no | optional: the CID of the version of the record. if a newer version exists, returns not found. | + +returns `{ uri, cid, value }`. diff --git a/src/api/xrpc/get_record_by_uri.rs b/src/api/xrpc/get_record_by_uri.rs new file mode 100644 --- /dev/null +++ b/src/api/xrpc/get_record_by_uri.rs @@ -0,0 +1,219 @@ +use jacquard_api::com_atproto::repo::get_record::{GetRecordError, GetRecordOutput}; +use jacquard_common::{ + IntoStatic, + cowstr::ToCowStr, + types::{ + cid::Cid, + ident::AtIdentifier, + nsid::Nsid, + recordkey::{RecordKey, Rkey}, + string::AtUri, + }, + xrpc::{XrpcEndpoint, XrpcMethod, XrpcRequest, XrpcResp}, +}; + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecordLookup { + repo: AtIdentifier<'static>, + collection: Nsid<'static>, + rkey: RecordKey>, +} + +pub struct BlueMicrocosmGetRecordByUriResponse; +impl XrpcResp for BlueMicrocosmGetRecordByUriResponse { + const NSID: &'static str = "blue.microcosm.repo.getRecordByUri"; + const ENCODING: &'static str = "application/json"; + type Output<'de> = GetRecordOutput<'de>; + type Err<'de> = GetRecordError<'de>; +} + +pub struct BadExampleGetUriRecordResponse; +impl XrpcResp for BadExampleGetUriRecordResponse { + const NSID: &'static str = "com.bad-example.repo.getUriRecord"; + const ENCODING: &'static str = "application/json"; + type Output<'de> = GetRecordOutput<'de>; + type Err<'de> = GetRecordError<'de>; +} + +#[derive(Serialize, Deserialize, jacquard_derive::IntoStatic)] +pub struct GetRecordByUriRequestData<'i> { + #[serde(borrow)] + pub at_uri: AtUri<'i>, + #[serde(default)] + #[serde(borrow)] + pub cid: Option>, +} + +impl XrpcRequest for GetRecordByUriRequestData<'_> { + type Response = BlueMicrocosmGetRecordByUriResponse; + const NSID: &'static str = Self::Response::NSID; + const METHOD: XrpcMethod = XrpcMethod::Query; +} + +pub struct BlueMicrocosmGetRecordByUri; +impl XrpcEndpoint for BlueMicrocosmGetRecordByUri { + const PATH: &'static str = "/xrpc/blue.microcosm.repo.getRecordByUri"; + const METHOD: XrpcMethod = XrpcMethod::Query; + type Request<'de> = GetRecordByUriRequestData<'de>; + type Response = BlueMicrocosmGetRecordByUriResponse; +} + +#[derive(Serialize, Deserialize, jacquard_derive::IntoStatic)] +pub struct BadExampleGetUriRecordRequestData<'i> { + #[serde(borrow)] + pub at_uri: AtUri<'i>, + #[serde(default)] + #[serde(borrow)] + pub cid: Option>, +} + +impl XrpcRequest for BadExampleGetUriRecordRequestData<'_> { + type Response = BadExampleGetUriRecordResponse; + const NSID: &'static str = Self::Response::NSID; + const METHOD: XrpcMethod = XrpcMethod::Query; +} + +pub struct BadExampleGetUriRecord; +impl XrpcEndpoint for BadExampleGetUriRecord { + const PATH: &'static str = "/xrpc/com.bad-example.repo.getUriRecord"; + const METHOD: XrpcMethod = XrpcMethod::Query; + type Request<'de> = BadExampleGetUriRecordRequestData<'de>; + type Response = BadExampleGetUriRecordResponse; +} + +fn record_lookup_from_at_uri(at_uri: &AtUri<'_>) -> Result { + let Some(collection) = at_uri.collection() else { + return Err("at-uri must include a collection"); + }; + let Some(rkey) = at_uri.rkey() else { + return Err("at-uri must include an rkey"); + }; + + Ok(RecordLookup { + repo: at_uri.authority().clone().into_static(), + collection: collection.clone().into_static(), + rkey: rkey.clone().into_static(), + }) +} + +fn matches_requested_cid(record_cid: &Cid<'_>, requested_cid: Option<&Cid<'_>>) -> bool { + requested_cid.is_none_or(|cid| cid.as_str() == record_cid.as_str()) +} + +async fn get_record_by_uri( + hydrant: Hydrant, + at_uri: AtUri<'static>, + cid: Option>, + nsid: &'static str, +) -> Result>, XrpcErrorResponse>> { + let lookup = record_lookup_from_at_uri(&at_uri).map_err(|e| bad_request(nsid, e))?; + let repo = hydrant + .repos + .resolve(&lookup.repo) + .await + .map_err(|e| internal_error(nsid, e))?; + let record = repo + .get_record(lookup.collection.as_str(), lookup.rkey.as_ref()) + .await + .map_err(|e| internal_error(nsid, e))?; + let Some(record) = record else { + return Err(XrpcErrorResponse { + status: StatusCode::NOT_FOUND, + error: XrpcError::Xrpc(GetRecordError::RecordNotFound(None)), + }); + }; + + if !matches_requested_cid(&record.cid, cid.as_ref()) { + return Err(XrpcErrorResponse { + status: StatusCode::NOT_FOUND, + error: XrpcError::Xrpc(GetRecordError::RecordNotFound(None)), + }); + } + + Ok(Json(GetRecordOutput { + uri: AtUri::from_parts_owned( + record.did.as_str(), + lookup.collection.as_str(), + lookup.rkey.as_ref(), + ) + .unwrap(), + cid: Some(Cid::Str(record.cid.to_cowstr().into_static())), + value: record.value, + extra_data: Default::default(), + })) +} + +pub async fn handle_blue_microcosm( + State(hydrant): State, + ExtractXrpc(req): ExtractXrpc, +) -> Result>, XrpcErrorResponse>> { + get_record_by_uri( + hydrant, + req.at_uri, + req.cid, + BlueMicrocosmGetRecordByUriResponse::NSID, + ) + .await +} + +pub async fn handle_bad_example( + State(hydrant): State, + ExtractXrpc(req): ExtractXrpc, +) -> Result>, XrpcErrorResponse>> { + get_record_by_uri( + hydrant, + req.at_uri, + req.cid, + BadExampleGetUriRecordResponse::NSID, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_lookup_extracts_all_parts() { + let at_uri = AtUri::new("at://bad-example.com/app.bsky.feed.post/3k4duaz5vfs2w").unwrap(); + let lookup = record_lookup_from_at_uri(&at_uri).unwrap(); + + assert_eq!(lookup.repo.as_str(), "bad-example.com"); + assert_eq!(lookup.collection.as_str(), "app.bsky.feed.post"); + assert_eq!(lookup.rkey.as_ref(), "3k4duaz5vfs2w"); + } + + #[test] + fn record_lookup_rejects_missing_collection() { + let at_uri = AtUri::new("at://bad-example.com").unwrap(); + let err = record_lookup_from_at_uri(&at_uri).unwrap_err(); + + assert_eq!(err, "at-uri must include a collection"); + } + + #[test] + fn record_lookup_rejects_missing_rkey() { + let at_uri = AtUri::new("at://bad-example.com/app.bsky.feed.post").unwrap(); + let err = record_lookup_from_at_uri(&at_uri).unwrap_err(); + + assert_eq!(err, "at-uri must include an rkey"); + } + + #[test] + fn matching_cid_is_accepted() { + assert!(matches_requested_cid( + &Cid::str("bafyrecord"), + Some(&Cid::str("bafyrecord")), + )); + } + + #[test] + fn mismatched_cid_is_rejected() { + assert!(!matches_requested_cid( + &Cid::str("bafyrecord"), + Some(&Cid::str("bafyother")), + )); + } +} diff --git a/src/api/xrpc/mod.rs b/src/api/xrpc/mod.rs --- a/src/api/xrpc/mod.rs +++ b/src/api/xrpc/mod.rs @@ -55,6 +55,8 @@ #[cfg(feature = "indexer")] mod get_record; #[cfg(feature = "indexer")] +mod get_record_by_uri; +#[cfg(feature = "indexer")] mod get_repo; #[cfg(feature = "indexer")] mod list_records; @@ -96,6 +98,14 @@ #[cfg(feature = "indexer")] let r = if blocks_available { r.route(GetRecordRequest::PATH, get(get_record::handle)) + .route( + get_record_by_uri::BlueMicrocosmGetRecordByUri::PATH, + get(get_record_by_uri::handle_blue_microcosm), + ) + .route( + get_record_by_uri::BadExampleGetUriRecord::PATH, + get(get_record_by_uri::handle_bad_example), + ) .route(ListRecordsRequest::PATH, get(list_records::handle)) .route(GetRepoRequest::PATH, get(get_repo::handle)) } else {