From 27a3d167db6cb3befb11793136676ce1906bedf9 Mon Sep 17 00:00:00 2001 From: dawn Date: Mon, 20 Jul 2026 21:10:09 +0300 Subject: [PATCH] bobbin/xrpc,bobbin/types/lexicons: implement repo.getReposByRepoDids Signed-off-by: dawn --- bobbin/crates/xrpc/src/lib.rs | 53 +++++++ bobbin/crates/xrpc/tests/bulk.rs | 135 ++++++++++++++++++ lexicons/repo/getReposByRepoDids.json | 46 ++++++ web/src/lib/api/lexicons/index.ts | 1 + .../sh/tangled/repo/getReposByRepoDids.ts | 59 ++++++++ 5 files changed, 294 insertions(+) create mode 100644 lexicons/repo/getReposByRepoDids.json create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/repo/getReposByRepoDids.ts diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index 99d09ead..bd60f006 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -175,6 +175,10 @@ pub fn router(state: AppState) -> Router { "/xrpc/sh.tangled.repo.getRepoByRepoDid", get(get_repo_by_repo_did), ) + .route( + "/xrpc/sh.tangled.repo.getReposByRepoDids", + get(get_repos_by_repo_dids), + ) .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) @@ -1313,6 +1317,42 @@ async fn get_repos( bulk_fetch::>(&state, uris).await } +async fn get_repos_by_repo_dids( + State(state): State, + RawQuery(query): RawQuery, +) -> Result { + let dids = collect_repeated(query.as_deref(), BULK_REPO_DIDS_KEY); + if dids.is_empty() { + return Err(XrpcError::InvalidParams("at least one did required".into())); + } + if dids.len() > BULK_LIMIT { + return Err(XrpcError::InvalidParams(format!( + "at most {BULK_LIMIT} dids per request" + ))); + } + let dids = dids + .iter() + .map(|s| { + Did::::new_owned(s) + .map_err(|_| XrpcError::InvalidParams(format!("invalid did: {s}"))) + }) + .collect::, _>>()?; + let mut uris: Vec> = Vec::new(); + for did in &dids { + if let Some(ident) = state.resolver.lookup_by_repo_did(did).await { + uris.push( + AtUri::::from_parts_owned( + ident.owner.as_str(), + RepoRecord::NSID, + ident.rkey.as_str(), + ) + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"), + ); + } + } + bulk_stream::>(&state, uris) +} + async fn get_profiles( State(state): State, RawQuery(query): RawQuery, @@ -1338,6 +1378,7 @@ async fn get_pulls( } const BULK_REPOS_KEY: &str = "repos"; +const BULK_REPO_DIDS_KEY: &str = "dids"; const BULK_PROFILES_KEY: &str = "actors"; const BULK_ISSUES_KEY: &str = "issues"; const BULK_PULLS_KEY: &str = "pulls"; @@ -1646,6 +1687,18 @@ where bad.as_ref() ))); } + bulk_stream::(state, parsed) +} + +fn bulk_stream( + state: &AppState, + parsed: Vec>, +) -> Result +where + R: XrpcResp, + V: serde::de::DeserializeOwned + Serialize + NormalizeRepoRefs + Send + 'static, +{ + let nsid = nsid_static(R::NSID); let permit = state.heavy_permit()?; let items = parsed .into_iter() diff --git a/bobbin/crates/xrpc/tests/bulk.rs b/bobbin/crates/xrpc/tests/bulk.rs index 39a812bb..b8a18635 100644 --- a/bobbin/crates/xrpc/tests/bulk.rs +++ b/bobbin/crates/xrpc/tests/bulk.rs @@ -546,3 +546,138 @@ async fn bulk_items_serialize_a_single_type_key() { let raw = String::from_utf8(bytes.to_vec()).unwrap(); assert_eq!(raw.matches("\"$type\"").count(), 1, "body: {raw}"); } + +#[tokio::test] +async fn get_repos_by_repo_dids_returns_resolved_repos() { + let h = Harness::new().await; + h.state + .resolver + .observe( + did("did:plc:nel"), + rkey("abalone"), + Some(did("did:plc:limpet")), + None, + ) + .await; + h.state + .resolver + .observe( + did("did:plc:teq"), + rkey("coral"), + Some(did("did:plc:coral")), + None, + ) + .await; + h.mount( + &did("did:plc:nel"), + &nsid("sh.tangled.repo"), + &rkey("abalone"), + repo_body("abalone"), + ) + .await; + h.mount( + &did("did:plc:teq"), + &nsid("sh.tangled.repo"), + &rkey("coral"), + repo_body("coral"), + ) + .await; + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(bulk_request( + "sh.tangled.repo.getReposByRepoDids", + "dids", + &["did:plc:limpet", "did:plc:coral"], + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 2, "{body}"); + let names: Vec<&str> = items + .iter() + .map(|v| v["value"]["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"abalone")); + assert!(names.contains(&"coral")); +} + +#[tokio::test] +async fn get_repos_by_repo_dids_skips_unobserved_dids() { + let h = Harness::new().await; + h.state + .resolver + .observe( + did("did:plc:nel"), + rkey("abalone"), + Some(did("did:plc:limpet")), + None, + ) + .await; + h.mount( + &did("did:plc:nel"), + &nsid("sh.tangled.repo"), + &rkey("abalone"), + repo_body("abalone"), + ) + .await; + let app = router(h.state.clone()); + let (status, body) = json_response( + app.oneshot(bulk_request( + "sh.tangled.repo.getReposByRepoDids", + "dids", + &["did:plc:limpet", "did:plc:ghost"], + )) + .await + .unwrap(), + ) + .await; + // unknown dids are skipped, same as missing bulk records + assert_eq!(status, StatusCode::OK, "{body}"); + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{body}"); + assert_eq!(items[0]["value"]["name"], "abalone"); +} + +#[tokio::test] +async fn get_repos_by_repo_dids_rejects_bad_requests() { + let h = Harness::new().await; + let app = router(h.state.clone()); + // no dids at all + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/sh.tangled.repo.getReposByRepoDids") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + // not a did + let resp = app + .clone() + .oneshot(bulk_request( + "sh.tangled.repo.getReposByRepoDids", + "dids", + &["not-a-did"], + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + // over the bulk limit + let dids: Vec = (0..51).map(|i| format!("did:plc:d{i}")).collect(); + let refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); + let resp = app + .oneshot(bulk_request( + "sh.tangled.repo.getReposByRepoDids", + "dids", + &refs, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} diff --git a/lexicons/repo/getReposByRepoDids.json b/lexicons/repo/getReposByRepoDids.json new file mode 100644 index 00000000..e4775edc --- /dev/null +++ b/lexicons/repo/getReposByRepoDids.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "sh.tangled.repo.getReposByRepoDids", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "required": ["dids"], + "properties": { + "dids": { + "type": "array", + "items": { "type": "string", "format": "did" }, + "maxLength": 50, + "description": "Repo DIDs to fetch. DIDs that resolve to none are omitted from the response." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "ref", "ref": "#listItem" } + } + } + } + } + }, + "listItem": { + "type": "object", + "required": ["uri", "value"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "value": { + "type": "unknown", + "description": "sh.tangled.repo record" + } + } + } + } +} diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index a59f2eee..bea826a2 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -87,6 +87,7 @@ export * as ShTangledRepoDiff from "./types/sh/tangled/repo/diff.js"; export * as ShTangledRepoForkStatus from "./types/sh/tangled/repo/forkStatus.js"; export * as ShTangledRepoForkSync from "./types/sh/tangled/repo/forkSync.js"; export * as ShTangledRepoGetDefaultBranch from "./types/sh/tangled/repo/getDefaultBranch.js"; +export * as ShTangledRepoGetReposByRepoDids from "./types/sh/tangled/repo/getReposByRepoDids.js"; export * as ShTangledRepoHiddenRef from "./types/sh/tangled/repo/hiddenRef.js"; export * as ShTangledRepoIssue from "./types/sh/tangled/repo/issue.js"; export * as ShTangledRepoIssueComment from "./types/sh/tangled/repo/issue/comment.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/getReposByRepoDids.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/getReposByRepoDids.ts new file mode 100644 index 00000000..7f080e26 --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/getReposByRepoDids.ts @@ -0,0 +1,59 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _listItemSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.repo.getReposByRepoDids#listItem"), + ), + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), + uri: /*#__PURE__*/ v.resourceUriString(), + /** + * sh.tangled.repo record + */ + value: /*#__PURE__*/ v.unknown(), +}); +const _mainSchema = /*#__PURE__*/ v.query( + "sh.tangled.repo.getReposByRepoDids", + { + params: /*#__PURE__*/ v.object({ + /** + * Repo DIDs to fetch. DIDs that resolve to none are omitted from the response. + * @minLength 1 + * @maxLength 50 + */ + dids: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), + [/*#__PURE__*/ v.arrayLength(1, 50)], + ), + }), + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + get items() { + return /*#__PURE__*/ v.array(listItemSchema); + }, + }), + }, + }, +); + +type listItem$schematype = typeof _listItemSchema; +type main$schematype = typeof _mainSchema; + +export interface listItemSchema extends listItem$schematype {} +export interface mainSchema extends main$schematype {} + +export const listItemSchema = _listItemSchema as listItemSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface ListItem extends v.InferInput {} + +export interface $params extends v.InferInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCQueries { + "sh.tangled.repo.getReposByRepoDids": mainSchema; + } +} -- 2.51.2