Monorepo for Tangled
0

Configure Feed

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

bobbin/xrpc: serve exactly one $type on record values

Signed-off-by: teq <teqed@shatteredsky.net>

quilling.dev (Jul 14, 2026, 4:41 PM EDT) 74ef448e fdafaa66

+88 -16
+29 -16
bobbin/crates/xrpc/src/lib.rs
··· 729 729 } 730 730 } 731 731 732 + struct Deduped<T>(T); 733 + 734 + impl<T: Serialize> Serialize for Deduped<T> { 735 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 736 + where 737 + S: serde::Serializer, 738 + { 739 + serde_json::to_value(&self.0) 740 + .map_err(serde::ser::Error::custom)? 741 + .serialize(serializer) 742 + } 743 + } 744 + 732 745 #[derive(Serialize)] 733 746 #[serde(rename_all = "camelCase")] 734 747 struct RecordView<V> { ··· 1316 1329 async fn get_repo( 1317 1330 State(state): State<AppState>, 1318 1331 XrpcQuery(q): XrpcQuery<GetRepoQuery>, 1319 - ) -> Result<Json<RepoGetRecordOutput<DefaultStr>>, XrpcError> { 1332 + ) -> Result<Json<Deduped<RepoGetRecordOutput<DefaultStr>>>, XrpcError> { 1320 1333 let (body, value) = fetch::<RepoRecord, Repo<DefaultStr>>(&state, &q.repo).await?; 1321 - Ok(Json(RepoGetRecordOutput { 1334 + Ok(Json(Deduped(RepoGetRecordOutput { 1322 1335 cid: Some(body.cid.clone()), 1323 1336 uri: body.uri.clone(), 1324 1337 value, 1325 - })) 1338 + }))) 1326 1339 } 1327 1340 1328 1341 async fn get_repo_by_repo_did( 1329 1342 State(state): State<AppState>, 1330 1343 XrpcQuery(q): XrpcQuery<GetRepoByRepoDidQuery>, 1331 - ) -> Result<Json<RepoGetRecordOutput<DefaultStr>>, XrpcError> { 1344 + ) -> Result<Json<Deduped<RepoGetRecordOutput<DefaultStr>>>, XrpcError> { 1332 1345 let ident = state 1333 1346 .resolver 1334 1347 .lookup_by_repo_did(&q.repo_did) ··· 1341 1354 ) 1342 1355 .expect("Did and Rkey newtypes already validated, at-uri assembly cannot fail"); 1343 1356 let (body, value) = fetch_from_uri::<RepoRecord, Repo<DefaultStr>>(&state, uri).await?; 1344 - Ok(Json(RepoGetRecordOutput { 1357 + Ok(Json(Deduped(RepoGetRecordOutput { 1345 1358 cid: Some(body.cid.clone()), 1346 1359 uri: body.uri.clone(), 1347 1360 value, 1348 - })) 1361 + }))) 1349 1362 } 1350 1363 1351 1364 async fn get_profile( 1352 1365 State(state): State<AppState>, 1353 1366 XrpcQuery(q): XrpcQuery<GetProfileQuery>, 1354 - ) -> Result<Json<ProfileGetRecordOutput<DefaultStr>>, XrpcError> { 1367 + ) -> Result<Json<Deduped<ProfileGetRecordOutput<DefaultStr>>>, XrpcError> { 1355 1368 let (body, value) = fetch::<ProfileRecord, Profile<DefaultStr>>(&state, &q.actor).await?; 1356 - Ok(Json(ProfileGetRecordOutput { 1369 + Ok(Json(Deduped(ProfileGetRecordOutput { 1357 1370 cid: Some(body.cid.clone()), 1358 1371 uri: body.uri.clone(), 1359 1372 value, 1360 - })) 1373 + }))) 1361 1374 } 1362 1375 1363 1376 async fn get_issue( 1364 1377 State(state): State<AppState>, 1365 1378 XrpcQuery(q): XrpcQuery<GetIssueQuery>, 1366 - ) -> Result<Json<IssueGetRecordOutput<DefaultStr>>, XrpcError> { 1379 + ) -> Result<Json<Deduped<IssueGetRecordOutput<DefaultStr>>>, XrpcError> { 1367 1380 let (body, value) = fetch::<IssueRecord, Issue<DefaultStr>>(&state, &q.issue).await?; 1368 - Ok(Json(IssueGetRecordOutput { 1381 + Ok(Json(Deduped(IssueGetRecordOutput { 1369 1382 cid: Some(body.cid.clone()), 1370 1383 uri: body.uri.clone(), 1371 1384 value, 1372 - })) 1385 + }))) 1373 1386 } 1374 1387 1375 1388 async fn get_pull( 1376 1389 State(state): State<AppState>, 1377 1390 XrpcQuery(q): XrpcQuery<GetPullQuery>, 1378 - ) -> Result<Json<PullGetRecordOutput<DefaultStr>>, XrpcError> { 1391 + ) -> Result<Json<Deduped<PullGetRecordOutput<DefaultStr>>>, XrpcError> { 1379 1392 let (body, value) = fetch::<PullRecord, Pull<DefaultStr>>(&state, &q.pull).await?; 1380 - Ok(Json(PullGetRecordOutput { 1393 + Ok(Json(Deduped(PullGetRecordOutput { 1381 1394 cid: Some(body.cid.clone()), 1382 1395 uri: body.uri.clone(), 1383 1396 value, 1384 - })) 1397 + }))) 1385 1398 } 1386 1399 1387 1400 async fn get_repos( ··· 1657 1670 Some((Ok::<Vec<u8>, Infallible>(head), st)) 1658 1671 } 1659 1672 PagePhase::Body { first } => match st.items.next().await { 1660 - Some(Ok(view)) => match serde_json::to_vec(&view) { 1673 + Some(Ok(view)) => match serde_json::to_vec(&Deduped(&view)) { 1661 1674 Ok(encoded) => { 1662 1675 let mut chunk = Vec::with_capacity(encoded.len() + 1); 1663 1676 if !first {
+25
bobbin/crates/xrpc/tests/bulk.rs
··· 521 521 .unwrap(); 522 522 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 523 523 } 524 + 525 + #[tokio::test] 526 + async fn bulk_items_serialize_a_single_type_key() { 527 + let h = Harness::new().await; 528 + h.mount( 529 + &did("did:plc:teq"), 530 + &nsid("sh.tangled.repo"), 531 + &rkey("abalone"), 532 + repo_body("abalone"), 533 + ) 534 + .await; 535 + let app = router(h.state.clone()); 536 + let resp = app 537 + .oneshot(bulk_request( 538 + "sh.tangled.repo.getRepos", 539 + "repos", 540 + &["at://did:plc:teq/sh.tangled.repo/abalone"], 541 + )) 542 + .await 543 + .unwrap(); 544 + assert_eq!(resp.status(), StatusCode::OK); 545 + let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); 546 + let raw = String::from_utf8(bytes.to_vec()).unwrap(); 547 + assert_eq!(raw.matches("\"$type\"").count(), 1, "body: {raw}"); 548 + }
+34
bobbin/crates/xrpc/tests/cold_start.rs
··· 697 697 .unwrap(); 698 698 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 699 699 } 700 + 701 + #[tokio::test] 702 + async fn record_values_serialize_a_single_type_key() { 703 + let server = MockServer::start().await; 704 + 705 + mount_record( 706 + &server, 707 + &did("did:plc:teq"), 708 + &nsid("sh.tangled.repo"), 709 + &rkey("r1"), 710 + json!({ 711 + "$type": "sh.tangled.repo", 712 + "name": "clam", 713 + "knot": "oyster.cafe", 714 + "createdAt": "2026-05-01T00:00:00Z" 715 + }), 716 + ) 717 + .await; 718 + 719 + let app = router(fresh_app(&Url::parse(&server.uri()).unwrap()).await); 720 + let resp = app 721 + .oneshot(xrpc_request( 722 + "sh.tangled.repo.getRepo", 723 + "repo", 724 + "at://did:plc:teq/sh.tangled.repo/r1", 725 + )) 726 + .await 727 + .unwrap(); 728 + assert_eq!(resp.status(), StatusCode::OK); 729 + let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); 730 + let raw = String::from_utf8(bytes.to_vec()).unwrap(); 731 + assert_eq!(raw.matches("\"$type\"").count(), 1, "body: {raw}"); 732 + assert!(raw.contains("\"$type\":\"sh.tangled.repo\""), "body: {raw}"); 733 + }