Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

bobbin/xrpc,bobbin/types/lexicons: implement repo.getReposByRepoDids

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 23, 2026, 6:51 PM +0300) commit 201bd0af parent 6e01d5d9 change-id youwnopq
+291
+53
bobbin/crates/xrpc/src/lib.rs
··· 175 175 "/xrpc/sh.tangled.repo.getRepoByRepoDid", 176 176 get(get_repo_by_repo_did), 177 177 ) 178 + .route( 179 + "/xrpc/sh.tangled.repo.getReposByRepoDids", 180 + get(get_repos_by_repo_dids), 181 + ) 178 182 .route("/xrpc/sh.tangled.actor.getProfile", get(get_profile)) 179 183 .route("/xrpc/sh.tangled.actor.getProfiles", get(get_profiles)) 180 184 .route("/xrpc/sh.tangled.repo.getIssue", get(get_issue)) ··· 1313 1317 bulk_fetch::<RepoRecord, Repo<DefaultStr>>(&state, uris).await 1314 1318 } 1315 1319 1320 + async fn get_repos_by_repo_dids( 1321 + State(state): State<AppState>, 1322 + RawQuery(query): RawQuery, 1323 + ) -> Result<Response, XrpcError> { 1324 + let dids = collect_repeated(query.as_deref(), BULK_REPO_DIDS_KEY); 1325 + if dids.is_empty() { 1326 + return Err(XrpcError::InvalidParams("at least one did required".into())); 1327 + } 1328 + if dids.len() > BULK_LIMIT { 1329 + return Err(XrpcError::InvalidParams(format!( 1330 + "at most {BULK_LIMIT} dids per request" 1331 + ))); 1332 + } 1333 + let dids = dids 1334 + .iter() 1335 + .map(|s| { 1336 + Did::<DefaultStr>::new_owned(s) 1337 + .map_err(|_| XrpcError::InvalidParams(format!("invalid did: {s}"))) 1338 + }) 1339 + .collect::<Result<Vec<_>, _>>()?; 1340 + let mut uris: Vec<AtUri<DefaultStr>> = Vec::new(); 1341 + for did in &dids { 1342 + if let Some(ident) = state.resolver.lookup_by_repo_did(did).await { 1343 + uris.push( 1344 + AtUri::<DefaultStr>::from_parts_owned( 1345 + ident.owner.as_str(), 1346 + RepoRecord::NSID, 1347 + ident.rkey.as_str(), 1348 + ) 1349 + .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"), 1350 + ); 1351 + } 1352 + } 1353 + bulk_stream::<RepoRecord, Repo<DefaultStr>>(&state, uris) 1354 + } 1355 + 1316 1356 async fn get_profiles( 1317 1357 State(state): State<AppState>, 1318 1358 RawQuery(query): RawQuery, ··· 1338 1378 } 1339 1379 1340 1380 const BULK_REPOS_KEY: &str = "repos"; 1381 + const BULK_REPO_DIDS_KEY: &str = "dids"; 1341 1382 const BULK_PROFILES_KEY: &str = "actors"; 1342 1383 const BULK_ISSUES_KEY: &str = "issues"; 1343 1384 const BULK_PULLS_KEY: &str = "pulls"; ··· 1646 1687 bad.as_ref() 1647 1688 ))); 1648 1689 } 1690 + bulk_stream::<R, V>(state, parsed) 1691 + } 1692 + 1693 + fn bulk_stream<R, V>( 1694 + state: &AppState, 1695 + parsed: Vec<AtUri<DefaultStr>>, 1696 + ) -> Result<Response, XrpcError> 1697 + where 1698 + R: XrpcResp, 1699 + V: serde::de::DeserializeOwned + Serialize + NormalizeRepoRefs + Send + 'static, 1700 + { 1701 + let nsid = nsid_static(R::NSID); 1649 1702 let permit = state.heavy_permit()?; 1650 1703 let items = parsed 1651 1704 .into_iter()
+132
bobbin/crates/xrpc/tests/bulk.rs
··· 546 546 let raw = String::from_utf8(bytes.to_vec()).unwrap(); 547 547 assert_eq!(raw.matches("\"$type\"").count(), 1, "body: {raw}"); 548 548 } 549 + 550 + #[tokio::test] 551 + async fn get_repos_by_repo_dids_returns_resolved_repos() { 552 + let h = Harness::new().await; 553 + h.state 554 + .resolver 555 + .observe( 556 + did("did:plc:nel"), 557 + rkey("abalone"), 558 + Some(did("did:plc:limpet")), 559 + ) 560 + .await; 561 + h.state 562 + .resolver 563 + .observe( 564 + did("did:plc:teq"), 565 + rkey("coral"), 566 + Some(did("did:plc:coral")), 567 + ) 568 + .await; 569 + h.mount( 570 + &did("did:plc:nel"), 571 + &nsid("sh.tangled.repo"), 572 + &rkey("abalone"), 573 + repo_body("abalone"), 574 + ) 575 + .await; 576 + h.mount( 577 + &did("did:plc:teq"), 578 + &nsid("sh.tangled.repo"), 579 + &rkey("coral"), 580 + repo_body("coral"), 581 + ) 582 + .await; 583 + let app = router(h.state.clone()); 584 + let (status, body) = json_response( 585 + app.oneshot(bulk_request( 586 + "sh.tangled.repo.getReposByRepoDids", 587 + "dids", 588 + &["did:plc:limpet", "did:plc:coral"], 589 + )) 590 + .await 591 + .unwrap(), 592 + ) 593 + .await; 594 + assert_eq!(status, StatusCode::OK, "{body}"); 595 + let items = body["items"].as_array().unwrap(); 596 + assert_eq!(items.len(), 2, "{body}"); 597 + let names: Vec<&str> = items 598 + .iter() 599 + .map(|v| v["value"]["name"].as_str().unwrap()) 600 + .collect(); 601 + assert!(names.contains(&"abalone")); 602 + assert!(names.contains(&"coral")); 603 + } 604 + 605 + #[tokio::test] 606 + async fn get_repos_by_repo_dids_skips_unobserved_dids() { 607 + let h = Harness::new().await; 608 + h.state 609 + .resolver 610 + .observe( 611 + did("did:plc:nel"), 612 + rkey("abalone"), 613 + Some(did("did:plc:limpet")), 614 + ) 615 + .await; 616 + h.mount( 617 + &did("did:plc:nel"), 618 + &nsid("sh.tangled.repo"), 619 + &rkey("abalone"), 620 + repo_body("abalone"), 621 + ) 622 + .await; 623 + let app = router(h.state.clone()); 624 + let (status, body) = json_response( 625 + app.oneshot(bulk_request( 626 + "sh.tangled.repo.getReposByRepoDids", 627 + "dids", 628 + &["did:plc:limpet", "did:plc:ghost"], 629 + )) 630 + .await 631 + .unwrap(), 632 + ) 633 + .await; 634 + // unknown dids are skipped, same as missing bulk records 635 + assert_eq!(status, StatusCode::OK, "{body}"); 636 + let items = body["items"].as_array().unwrap(); 637 + assert_eq!(items.len(), 1, "{body}"); 638 + assert_eq!(items[0]["value"]["name"], "abalone"); 639 + } 640 + 641 + #[tokio::test] 642 + async fn get_repos_by_repo_dids_rejects_bad_requests() { 643 + let h = Harness::new().await; 644 + let app = router(h.state.clone()); 645 + // no dids at all 646 + let resp = app 647 + .clone() 648 + .oneshot( 649 + Request::builder() 650 + .uri("/xrpc/sh.tangled.repo.getReposByRepoDids") 651 + .body(Body::empty()) 652 + .unwrap(), 653 + ) 654 + .await 655 + .unwrap(); 656 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 657 + // not a did 658 + let resp = app 659 + .clone() 660 + .oneshot(bulk_request( 661 + "sh.tangled.repo.getReposByRepoDids", 662 + "dids", 663 + &["not-a-did"], 664 + )) 665 + .await 666 + .unwrap(); 667 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 668 + // over the bulk limit 669 + let dids: Vec<String> = (0..51).map(|i| format!("did:plc:d{i}")).collect(); 670 + let refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); 671 + let resp = app 672 + .oneshot(bulk_request( 673 + "sh.tangled.repo.getReposByRepoDids", 674 + "dids", 675 + &refs, 676 + )) 677 + .await 678 + .unwrap(); 679 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 680 + }
+46
lexicons/repo/getReposByRepoDids.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getReposByRepoDids", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["dids"], 10 + "properties": { 11 + "dids": { 12 + "type": "array", 13 + "items": { "type": "string", "format": "did" }, 14 + "maxLength": 50, 15 + "description": "Repo DIDs to fetch. DIDs that resolve to none are omitted from the response." 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["items"], 24 + "properties": { 25 + "items": { 26 + "type": "array", 27 + "items": { "type": "ref", "ref": "#listItem" } 28 + } 29 + } 30 + } 31 + } 32 + }, 33 + "listItem": { 34 + "type": "object", 35 + "required": ["uri", "value"], 36 + "properties": { 37 + "uri": { "type": "string", "format": "at-uri" }, 38 + "cid": { "type": "string", "format": "cid" }, 39 + "value": { 40 + "type": "unknown", 41 + "description": "sh.tangled.repo record" 42 + } 43 + } 44 + } 45 + } 46 + }
+1
web/src/lib/api/lexicons/index.ts
··· 87 87 export * as ShTangledRepoForkStatus from "./types/sh/tangled/repo/forkStatus.js"; 88 88 export * as ShTangledRepoForkSync from "./types/sh/tangled/repo/forkSync.js"; 89 89 export * as ShTangledRepoGetDefaultBranch from "./types/sh/tangled/repo/getDefaultBranch.js"; 90 + export * as ShTangledRepoGetReposByRepoDids from "./types/sh/tangled/repo/getReposByRepoDids.js"; 90 91 export * as ShTangledRepoHiddenRef from "./types/sh/tangled/repo/hiddenRef.js"; 91 92 export * as ShTangledRepoIssue from "./types/sh/tangled/repo/issue.js"; 92 93 export * as ShTangledRepoIssueComment from "./types/sh/tangled/repo/issue/comment.js";
+59
web/src/lib/api/lexicons/types/sh/tangled/repo/getReposByRepoDids.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _listItemSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("sh.tangled.repo.getReposByRepoDids#listItem"), 8 + ), 9 + cid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.cidString()), 10 + uri: /*#__PURE__*/ v.resourceUriString(), 11 + /** 12 + * sh.tangled.repo record 13 + */ 14 + value: /*#__PURE__*/ v.unknown(), 15 + }); 16 + const _mainSchema = /*#__PURE__*/ v.query( 17 + "sh.tangled.repo.getReposByRepoDids", 18 + { 19 + params: /*#__PURE__*/ v.object({ 20 + /** 21 + * Repo DIDs to fetch. DIDs that resolve to none are omitted from the response. 22 + * @minLength 1 23 + * @maxLength 50 24 + */ 25 + dids: /*#__PURE__*/ v.constrain( 26 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), 27 + [/*#__PURE__*/ v.arrayLength(1, 50)], 28 + ), 29 + }), 30 + output: { 31 + type: "lex", 32 + schema: /*#__PURE__*/ v.object({ 33 + get items() { 34 + return /*#__PURE__*/ v.array(listItemSchema); 35 + }, 36 + }), 37 + }, 38 + }, 39 + ); 40 + 41 + type listItem$schematype = typeof _listItemSchema; 42 + type main$schematype = typeof _mainSchema; 43 + 44 + export interface listItemSchema extends listItem$schematype {} 45 + export interface mainSchema extends main$schematype {} 46 + 47 + export const listItemSchema = _listItemSchema as listItemSchema; 48 + export const mainSchema = _mainSchema as mainSchema; 49 + 50 + export interface ListItem extends v.InferInput<typeof listItemSchema> {} 51 + 52 + export interface $params extends v.InferInput<mainSchema["params"]> {} 53 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 54 + 55 + declare module "@atcute/lexicons/ambient" { 56 + interface XRPCQueries { 57 + "sh.tangled.repo.getReposByRepoDids": mainSchema; 58 + } 59 + }