Add new get_many_to_many XRPC endpoint #7

open
opened by maxh.site targeting main from maxh.site/microcosm-rs: xrpc_many2many

Added a new XRPC API endpoint to fetch joined record URIs, termed get_many_to_many (we talked about this briefly on Discord already). It is implemented and functions almost identical to the existing get_many_to_many_counts endpoint and handler. Some of its possible flaws like the two step lookup to verify a matching DID is indeed active are duplicated as well. On the plus side, this should make the PR pretty straightforward to review and make it easier to modify both endpoints later on when a more efficient way to validate the status of DIDs is possible.

If you have comments remarks etc. I am happy to work on some parts again.

Changed files
+672 -1
constellation
+1 -1
constellation/src/lib.rs
··· 31 31 } 32 32 } 33 33 34 - #[derive(Debug, PartialEq, Serialize, Deserialize)] 34 + #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 35 35 pub struct RecordId { 36 36 pub did: Did, 37 37 pub collection: String,
+102
constellation/src/server/mod.rs
··· 89 89 }), 90 90 ) 91 91 .route( 92 + "/xrpc/blue.microcosm.links.getManyToMany", 93 + get({ 94 + let store = store.clone(); 95 + move |accept, query| async { 96 + spawn_blocking(|| get_many_to_many(accept, query, store)) 97 + .await 98 + .map_err(to500)? 99 + } 100 + }), 101 + ) 102 + .route( 92 103 "/xrpc/blue.microcosm.links.getBacklinks", 93 104 get({ 94 105 let store = store.clone(); ··· 580 591 accept, 581 592 GetLinkItemsResponse { 582 593 total: paged.total, 594 + linking_records: paged.items, 595 + cursor, 596 + query: (*query).clone(), 597 + }, 598 + )) 599 + } 600 + 601 + #[derive(Clone, Deserialize)] 602 + #[serde(rename_all = "camelCase")] 603 + struct GetManyToManyItemsQuery { 604 + subject: String, 605 + source: String, 606 + /// path to the secondary link in the linking record 607 + path_to_other: String, 608 + /// filter to linking records (join of the m2m) by these DIDs 609 + #[serde(default)] 610 + did: Vec<String>, 611 + /// filter to specific secondary records 612 + #[serde(default)] 613 + other_subject: Vec<String>, 614 + cursor: Option<OpaqueApiCursor>, 615 + #[serde(default = "get_default_cursor_limit")] 616 + limit: u64, 617 + } 618 + #[derive(Template, Serialize)] 619 + #[template(path = "get-many-to-many.html.j2")] 620 + struct GetManyToManyItemsResponse { 621 + linking_records: Vec<(String, Vec<RecordId>)>, 622 + cursor: Option<OpaqueApiCursor>, 623 + #[serde(skip_serializing)] 624 + query: GetManyToManyItemsQuery, 625 + } 626 + fn get_many_to_many( 627 + accept: ExtractAccept, 628 + query: axum_extra::extract::Query<GetManyToManyItemsQuery>, // supports multiple param occurrences 629 + store: impl LinkReader, 630 + ) -> Result<impl IntoResponse, http::StatusCode> { 631 + let after = query 632 + .cursor 633 + .clone() 634 + .map(|oc| ApiKeyedCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST)) 635 + .transpose()? 636 + .map(|c| c.next); 637 + 638 + let limit = query.limit; 639 + if limit > DEFAULT_CURSOR_LIMIT_MAX { 640 + return Err(http::StatusCode::BAD_REQUEST); 641 + } 642 + 643 + let filter_dids: HashSet<Did> = HashSet::from_iter( 644 + query 645 + .did 646 + .iter() 647 + .map(|d| d.trim()) 648 + .filter(|d| !d.is_empty()) 649 + .map(|d| Did(d.to_string())), 650 + ); 651 + 652 + let filter_other_subjects: HashSet<String> = HashSet::from_iter( 653 + query 654 + .other_subject 655 + .iter() 656 + .map(|s| s.trim().to_string()) 657 + .filter(|s| !s.is_empty()), 658 + ); 659 + 660 + let Some((collection, path)) = query.source.split_once(':') else { 661 + return Err(http::StatusCode::BAD_REQUEST); 662 + }; 663 + let path = format!(".{path}"); 664 + 665 + let path_to_other = format!(".{}", query.path_to_other); 666 + 667 + let paged = store 668 + .get_many_to_many( 669 + &query.subject, 670 + collection, 671 + &path, 672 + &path_to_other, 673 + limit, 674 + after, 675 + &filter_dids, 676 + &filter_other_subjects, 677 + ) 678 + .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?; 679 + 680 + let cursor = paged.next.map(|next| ApiKeyedCursor { next }.into()); 681 + 682 + Ok(acceptable( 683 + accept, 684 + GetManyToManyItemsResponse { 583 685 linking_records: paged.items, 584 686 cursor, 585 687 query: (*query).clone(),
+93
constellation/src/storage/mem_store.rs
··· 234 234 .len() as u64) 235 235 } 236 236 237 + fn get_many_to_many( 238 + &self, 239 + target: &str, 240 + collection: &str, 241 + path: &str, 242 + path_to_other: &str, 243 + limit: u64, 244 + after: Option<String>, 245 + filter_dids: &HashSet<Did>, 246 + filter_to_targets: &HashSet<String>, 247 + ) -> Result<PagedOrderedCollection<(String, Vec<RecordId>), String>> { 248 + let empty_res = Ok(PagedOrderedCollection { 249 + items: Vec::new(), 250 + next: None, 251 + }); 252 + 253 + // struct MemStorageData { 254 + // dids: HashMap<Did, bool>, 255 + // targets: HashMap<Target, HashMap<Source, Linkers>>, 256 + // links: HashMap<Did, HashMap<RepoId, Vec<(RecordPath, Target)>>>, 257 + // } 258 + let data = self.0.lock().unwrap(); 259 + 260 + let Some(sources) = data.targets.get(&Target::new(target)) else { 261 + return empty_res; 262 + }; 263 + let Some(linkers) = sources.get(&Source::new(collection, path)) else { 264 + return empty_res; 265 + }; 266 + let path_to_other = RecordPath::new(path_to_other); 267 + 268 + // Convert filter_to_targets to Target objects for comparison 269 + let filter_to_target_objs: HashSet<Target> = 270 + HashSet::from_iter(filter_to_targets.iter().map(|s| Target::new(s))); 271 + 272 + let mut grouped_links: HashMap<Target, Vec<RecordId>> = HashMap::new(); 273 + for (did, rkey) in linkers.iter().flatten().cloned() { 274 + // Filter by DID if filter is provided 275 + if !filter_dids.is_empty() && !filter_dids.contains(&did) { 276 + continue; 277 + } 278 + if let Some(fwd_target) = data 279 + .links 280 + .get(&did) 281 + .unwrap_or(&HashMap::new()) 282 + .get(&RepoId { 283 + collection: collection.to_string(), 284 + rkey: rkey.clone(), 285 + }) 286 + .unwrap_or(&Vec::new()) 287 + .iter() 288 + .find_map(|(path, target)| { 289 + if *path == path_to_other 290 + && (filter_to_target_objs.is_empty() 291 + || filter_to_target_objs.contains(target)) 292 + { 293 + Some(target) 294 + } else { 295 + None 296 + } 297 + }) 298 + { 299 + let record_ids = grouped_links.entry(fwd_target.clone()).or_default(); 300 + record_ids.push(RecordId { 301 + did, 302 + collection: collection.to_string(), 303 + rkey: rkey.0, 304 + }); 305 + } 306 + } 307 + 308 + let mut items = grouped_links 309 + .into_iter() 310 + .map(|(t, r)| (t.0, r)) 311 + .collect::<Vec<_>>(); 312 + 313 + items.sort_by(|(a, _), (b, _)| a.cmp(b)); 314 + 315 + items = items 316 + .into_iter() 317 + .skip_while(|(t, _)| after.as_ref().map(|a| t <= a).unwrap_or(false)) 318 + .take(limit as usize) 319 + .collect(); 320 + 321 + let next = if items.len() as u64 >= limit { 322 + items.last().map(|(t, _)| t.clone()) 323 + } else { 324 + None 325 + }; 326 + 327 + Ok(PagedOrderedCollection { items, next }) 328 + } 329 + 237 330 fn get_links( 238 331 &self, 239 332 target: &str,
+224
constellation/src/storage/mod.rs
··· 104 104 fn get_all_record_counts(&self, _target: &str) 105 105 -> Result<HashMap<String, HashMap<String, u64>>>; 106 106 107 + fn get_many_to_many( 108 + &self, 109 + target: &str, 110 + collection: &str, 111 + path: &str, 112 + path_to_other: &str, 113 + limit: u64, 114 + after: Option<String>, 115 + filter_dids: &HashSet<Did>, 116 + filter_to_targets: &HashSet<String>, 117 + ) -> Result<PagedOrderedCollection<(String, Vec<RecordId>), String>>; 118 + 107 119 fn get_all_counts( 108 120 &self, 109 121 _target: &str, ··· 1551 1563 next: None, 1552 1564 } 1553 1565 ); 1566 + }); 1567 + 1568 + test_each_storage!(get_m2m_empty, |storage| { 1569 + assert_eq!( 1570 + storage.get_many_to_many( 1571 + "a.com", 1572 + "a.b.c", 1573 + ".d.e", 1574 + ".f.g", 1575 + 10, 1576 + None, 1577 + &HashSet::new(), 1578 + &HashSet::new(), 1579 + )?, 1580 + PagedOrderedCollection { 1581 + items: vec![], 1582 + next: None, 1583 + } 1584 + ); 1585 + }); 1586 + 1587 + test_each_storage!(get_m2m_single, |storage| { 1588 + storage.push( 1589 + &ActionableEvent::CreateLinks { 1590 + record_id: RecordId { 1591 + did: "did:plc:asdf".into(), 1592 + collection: "app.t.c".into(), 1593 + rkey: "asdf".into(), 1594 + }, 1595 + links: vec![ 1596 + CollectedLink { 1597 + target: Link::Uri("a.com".into()), 1598 + path: ".abc.uri".into(), 1599 + }, 1600 + CollectedLink { 1601 + target: Link::Uri("b.com".into()), 1602 + path: ".def.uri".into(), 1603 + }, 1604 + CollectedLink { 1605 + target: Link::Uri("b.com".into()), 1606 + path: ".ghi.uri".into(), 1607 + }, 1608 + ], 1609 + }, 1610 + 0, 1611 + )?; 1612 + assert_eq!( 1613 + storage.get_many_to_many( 1614 + "a.com", 1615 + "app.t.c", 1616 + ".abc.uri", 1617 + ".def.uri", 1618 + 10, 1619 + None, 1620 + &HashSet::new(), 1621 + &HashSet::new(), 1622 + )?, 1623 + PagedOrderedCollection { 1624 + items: vec![( 1625 + "b.com".to_string(), 1626 + vec![RecordId { 1627 + did: "did:plc:asdf".into(), 1628 + collection: "app.t.c".into(), 1629 + rkey: "asdf".into(), 1630 + }] 1631 + )], 1632 + next: None, 1633 + } 1634 + ); 1635 + }); 1636 + 1637 + test_each_storage!(get_m2m_filters, |storage| { 1638 + storage.push( 1639 + &ActionableEvent::CreateLinks { 1640 + record_id: RecordId { 1641 + did: "did:plc:asdf".into(), 1642 + collection: "app.t.c".into(), 1643 + rkey: "asdf".into(), 1644 + }, 1645 + links: vec![ 1646 + CollectedLink { 1647 + target: Link::Uri("a.com".into()), 1648 + path: ".abc.uri".into(), 1649 + }, 1650 + CollectedLink { 1651 + target: Link::Uri("b.com".into()), 1652 + path: ".def.uri".into(), 1653 + }, 1654 + ], 1655 + }, 1656 + 0, 1657 + )?; 1658 + storage.push( 1659 + &ActionableEvent::CreateLinks { 1660 + record_id: RecordId { 1661 + did: "did:plc:asdf".into(), 1662 + collection: "app.t.c".into(), 1663 + rkey: "asdf2".into(), 1664 + }, 1665 + links: vec![ 1666 + CollectedLink { 1667 + target: Link::Uri("a.com".into()), 1668 + path: ".abc.uri".into(), 1669 + }, 1670 + CollectedLink { 1671 + target: Link::Uri("b.com".into()), 1672 + path: ".def.uri".into(), 1673 + }, 1674 + ], 1675 + }, 1676 + 1, 1677 + )?; 1678 + storage.push( 1679 + &ActionableEvent::CreateLinks { 1680 + record_id: RecordId { 1681 + did: "did:plc:fdsa".into(), 1682 + collection: "app.t.c".into(), 1683 + rkey: "fdsa".into(), 1684 + }, 1685 + links: vec![ 1686 + CollectedLink { 1687 + target: Link::Uri("a.com".into()), 1688 + path: ".abc.uri".into(), 1689 + }, 1690 + CollectedLink { 1691 + target: Link::Uri("c.com".into()), 1692 + path: ".def.uri".into(), 1693 + }, 1694 + ], 1695 + }, 1696 + 2, 1697 + )?; 1698 + storage.push( 1699 + &ActionableEvent::CreateLinks { 1700 + record_id: RecordId { 1701 + did: "did:plc:fdsa".into(), 1702 + collection: "app.t.c".into(), 1703 + rkey: "fdsa2".into(), 1704 + }, 1705 + links: vec![ 1706 + CollectedLink { 1707 + target: Link::Uri("a.com".into()), 1708 + path: ".abc.uri".into(), 1709 + }, 1710 + CollectedLink { 1711 + target: Link::Uri("c.com".into()), 1712 + path: ".def.uri".into(), 1713 + }, 1714 + ], 1715 + }, 1716 + 3, 1717 + )?; 1718 + 1719 + // Test without filters - should get all records grouped by secondary target 1720 + let result = storage.get_many_to_many( 1721 + "a.com", 1722 + "app.t.c", 1723 + ".abc.uri", 1724 + ".def.uri", 1725 + 10, 1726 + None, 1727 + &HashSet::new(), 1728 + &HashSet::new(), 1729 + )?; 1730 + assert_eq!(result.items.len(), 2); 1731 + assert_eq!(result.next, None); 1732 + // Find b.com group 1733 + let (b_target, b_records) = result.items.iter().find(|(target, _)| target == "b.com").unwrap(); 1734 + assert_eq!(b_target, "b.com"); 1735 + assert_eq!(b_records.len(), 2); 1736 + assert!(b_records.iter().any(|r| r.did.0 == "did:plc:asdf" && r.rkey == "asdf")); 1737 + assert!(b_records.iter().any(|r| r.did.0 == "did:plc:asdf" && r.rkey == "asdf2")); 1738 + // Find c.com group 1739 + let (c_target, c_records) = result.items.iter().find(|(target, _)| target == "c.com").unwrap(); 1740 + assert_eq!(c_target, "c.com"); 1741 + assert_eq!(c_records.len(), 2); 1742 + assert!(c_records.iter().any(|r| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa")); 1743 + assert!(c_records.iter().any(|r| r.did.0 == "did:plc:fdsa" && r.rkey == "fdsa2")); 1744 + 1745 + // Test with DID filter - should only get records from did:plc:fdsa 1746 + let result = storage.get_many_to_many( 1747 + "a.com", 1748 + "app.t.c", 1749 + ".abc.uri", 1750 + ".def.uri", 1751 + 10, 1752 + None, 1753 + &HashSet::from_iter([Did("did:plc:fdsa".to_string())]), 1754 + &HashSet::new(), 1755 + )?; 1756 + assert_eq!(result.items.len(), 1); 1757 + let (target, records) = &result.items[0]; 1758 + assert_eq!(target, "c.com"); 1759 + assert_eq!(records.len(), 2); 1760 + assert!(records.iter().all(|r| r.did.0 == "did:plc:fdsa")); 1761 + 1762 + // Test with target filter - should only get records linking to b.com 1763 + let result = storage.get_many_to_many( 1764 + "a.com", 1765 + "app.t.c", 1766 + ".abc.uri", 1767 + ".def.uri", 1768 + 10, 1769 + None, 1770 + &HashSet::new(), 1771 + &HashSet::from_iter(["b.com".to_string()]), 1772 + )?; 1773 + assert_eq!(result.items.len(), 1); 1774 + let (target, records) = &result.items[0]; 1775 + assert_eq!(target, "b.com"); 1776 + assert_eq!(records.len(), 2); 1777 + assert!(records.iter().all(|r| r.did.0 == "did:plc:asdf")); 1554 1778 }); 1555 1779 }
+143
constellation/src/storage/rocks_store.rs
··· 1122 1122 } 1123 1123 } 1124 1124 1125 + fn get_many_to_many( 1126 + &self, 1127 + target: &str, 1128 + collection: &str, 1129 + path: &str, 1130 + path_to_other: &str, 1131 + limit: u64, 1132 + after: Option<String>, 1133 + filter_dids: &HashSet<Did>, 1134 + filter_to_targets: &HashSet<String>, 1135 + ) -> Result<PagedOrderedCollection<(String, Vec<RecordId>), String>> { 1136 + let collection = Collection(collection.to_string()); 1137 + let path = RPath(path.to_string()); 1138 + 1139 + let target_key = TargetKey(Target(target.to_string()), collection.clone(), path); 1140 + 1141 + let after = after.map(|s| s.parse::<u64>().map(TargetId)).transpose()?; 1142 + 1143 + let Some(target_id) = self.target_id_table.get_id_val(&self.db, &target_key)? else { 1144 + eprintln!("Target not found for {target_key:?}"); 1145 + return Ok(Default::default()); 1146 + }; 1147 + 1148 + let filter_did_ids: HashMap<DidId, bool> = filter_dids 1149 + .iter() 1150 + .filter_map(|did| self.did_id_table.get_id_val(&self.db, did).transpose()) 1151 + .collect::<Result<Vec<DidIdValue>>>()? 1152 + .into_iter() 1153 + .map(|DidIdValue(id, active)| (id, active)) 1154 + .collect(); 1155 + 1156 + let mut filter_to_target_ids: HashSet<TargetId> = HashSet::new(); 1157 + for t in filter_to_targets { 1158 + for (_, target_id) in self.iter_targets_for_target(&Target(t.to_string())) { 1159 + filter_to_target_ids.insert(target_id); 1160 + } 1161 + } 1162 + 1163 + let linkers = self.get_target_linkers(&target_id)?; 1164 + 1165 + // we want to provide many to many which effectively means that we want to show a specific 1166 + // list of reords that is linked to by a specific number of linkers 1167 + let mut grouped_links: BTreeMap<TargetId, Vec<RecordId>> = BTreeMap::new(); 1168 + for (did_id, rkey) in linkers.0 { 1169 + if did_id.is_empty() { 1170 + continue; 1171 + } 1172 + 1173 + if !filter_did_ids.is_empty() && filter_did_ids.get(&did_id) != Some(&true) { 1174 + continue; 1175 + } 1176 + 1177 + // Make sure the current did is active 1178 + let Some(did) = self.did_id_table.get_val_from_id(&self.db, did_id.0)? else { 1179 + eprintln!("failed to look up did from did_id {did_id:?}"); 1180 + continue; 1181 + }; 1182 + let Some(DidIdValue(_, active)) = self.did_id_table.get_id_val(&self.db, &did)? else { 1183 + eprintln!("failed to look up did_value from did_id {did_id:?}: {did:?}: data consistency bug?"); 1184 + continue; 1185 + }; 1186 + if !active { 1187 + continue; 1188 + } 1189 + 1190 + let record_link_key = RecordLinkKey(did_id, collection.clone(), rkey.clone()); 1191 + let Some(targets) = self.get_record_link_targets(&record_link_key)? else { 1192 + continue; 1193 + }; 1194 + 1195 + let Some(fwd_target) = targets 1196 + .0 1197 + .into_iter() 1198 + .filter_map(|RecordLinkTarget(rpath, target_id)| { 1199 + if rpath.0 == path_to_other 1200 + && (filter_to_target_ids.is_empty() 1201 + || filter_to_target_ids.contains(&target_id)) 1202 + { 1203 + Some(target_id) 1204 + } else { 1205 + None 1206 + } 1207 + }) 1208 + .take(1) 1209 + .next() 1210 + else { 1211 + eprintln!("no forward match found."); 1212 + continue; 1213 + }; 1214 + 1215 + // pagination logic mirrors what is currently done in get_many_to_many_counts 1216 + if after.as_ref().map(|a| fwd_target <= *a).unwrap_or(false) { 1217 + continue; 1218 + } 1219 + let page_is_full = grouped_links.len() as u64 >= limit; 1220 + if page_is_full { 1221 + let current_max = grouped_links.keys().next_back().unwrap(); 1222 + if fwd_target > *current_max { 1223 + continue; 1224 + } 1225 + } 1226 + 1227 + // pagination, continued 1228 + let mut should_evict = false; 1229 + let entry = grouped_links.entry(fwd_target.clone()).or_insert_with(|| { 1230 + should_evict = page_is_full; 1231 + Vec::default() 1232 + }); 1233 + entry.push(RecordId { 1234 + did, 1235 + collection: collection.0.clone(), 1236 + rkey: rkey.0, 1237 + }); 1238 + 1239 + if should_evict { 1240 + grouped_links.pop_last(); 1241 + } 1242 + } 1243 + 1244 + let mut items: Vec<(String, Vec<RecordId>)> = Vec::with_capacity(grouped_links.len()); 1245 + for (fwd_target_id, records) in &grouped_links { 1246 + let Some(target_key) = self 1247 + .target_id_table 1248 + .get_val_from_id(&self.db, fwd_target_id.0)? 1249 + else { 1250 + eprintln!("failed to look up target from target_id {fwd_target_id:?}"); 1251 + continue; 1252 + }; 1253 + 1254 + let target_string = target_key.0 .0; 1255 + 1256 + items.push((target_string, records.clone())); 1257 + } 1258 + 1259 + let next = if grouped_links.len() as u64 >= limit { 1260 + grouped_links.keys().next_back().map(|k| format!("{}", k.0)) 1261 + } else { 1262 + None 1263 + }; 1264 + 1265 + Ok(PagedOrderedCollection { items, next }) 1266 + } 1267 + 1125 1268 fn get_links( 1126 1269 &self, 1127 1270 target: &str,
+60
constellation/templates/get-many-to-many.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% import "try-it-macros.html.j2" as try_it %} 3 + 4 + {% block title %}Many-to-Many Links{% endblock %} 5 + {% block description %}All {{ query.source }} records with many-to-many links to {{ query.subject }} joining through {{ query.path_to_other }}{% endblock %} 6 + 7 + {% block content %} 8 + 9 + {% call try_it::get_many_to_many(query.subject, query.source, query.path_to_other, query.did, query.other_subject, query.limit) %} 10 + 11 + <h2> 12 + Many-to-many links to <code>{{ query.subject }}</code> 13 + {% if let Some(browseable_uri) = query.subject|to_browseable %} 14 + <small style="font-weight: normal; font-size: 1rem"><a href="{{ browseable_uri }}">browse record</a></small> 15 + {% endif %} 16 + </h2> 17 + 18 + <p><strong>Many-to-many links</strong> from <code>{{ query.source }}</code> joining through <code>{{ query.path_to_other }}</code></p> 19 + 20 + <ul> 21 + <li>See all links to this target at <code>/links/all</code>: <a href="/links/all?target={{ query.subject|urlencode }}">/links/all?target={{ query.subject }}</a></li> 22 + </ul> 23 + 24 + <h3>Many-to-many links, most recent first:</h3> 25 + 26 + {% for (target, records) in linking_records %} 27 + <h4>Target: <code>{{ target }}</code> <small>(<a href="/links/all?target={{ target|urlencode }}">view all links</a>)</small></h4> 28 + {% for record in records %} 29 + <pre style="display: block; margin: 1em 2em" class="code"><strong>DID</strong>: {{ record.did().0 }} 30 + <strong>Collection</strong>: {{ record.collection }} 31 + <strong>RKey</strong>: {{ record.rkey }} 32 + -> <a href="https://pdsls.dev/at://{{ record.did().0 }}/{{ record.collection }}/{{ record.rkey }}">browse record</a></pre> 33 + {% endfor %} 34 + {% endfor %} 35 + 36 + {% if let Some(c) = cursor %} 37 + <form method="get" action="/xrpc/blue.microcosm.links.getManyToMany"> 38 + <input type="hidden" name="subject" value="{{ query.subject }}" /> 39 + <input type="hidden" name="source" value="{{ query.source }}" /> 40 + <input type="hidden" name="pathToOther" value="{{ query.path_to_other }}" /> 41 + {% for did in query.did %} 42 + <input type="hidden" name="did" value="{{ did }}" /> 43 + {% endfor %} 44 + {% for other in query.other_subject %} 45 + <input type="hidden" name="otherSubject" value="{{ other }}" /> 46 + {% endfor %} 47 + <input type="hidden" name="limit" value="{{ query.limit }}" /> 48 + <input type="hidden" name="cursor" value={{ c|json|safe }} /> 49 + <button type="submit">next page&hellip;</button> 50 + </form> 51 + {% else %} 52 + <button disabled><em>end of results</em></button> 53 + {% endif %} 54 + 55 + <details> 56 + <summary>Raw JSON response</summary> 57 + <pre class="code">{{ self|tojson }}</pre> 58 + </details> 59 + 60 + {% endblock %}
+19
constellation/templates/hello.html.j2
··· 81 81 ) %} 82 82 83 83 84 + <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getManyToMany</code></h3> 85 + 86 + <p>A list of many-to-many join records linking to a target and a secondary target.</p> 87 + 88 + <h4>Query parameters:</h4> 89 + 90 + <ul> 91 + <li><p><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 92 + <li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li> 93 + <li><p><code>pathToOther</code>: required. Path to the secondary link in the many-to-many record. Example: <code>otherThing.uri</code></p></li> 94 + <li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li> 95 + <li><p><code>otherSubject</code>: optional, filter secondary links to specific subjects. Include multiple times to filter by multiple subjects. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li> 96 + <li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li> 97 + </ul> 98 + 99 + <p style="margin-bottom: 0"><strong>Try it:</strong></p> 100 + {% call try_it::get_many_to_many("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", "reply.parent.uri", [""], [""], 16) %} 101 + 102 + 84 103 <h3 class="route"><code>GET /links</code></h3> 85 104 86 105 <p>A list of records linking to a target.</p>
+30
constellation/templates/try-it-macros.html.j2
··· 66 66 </script> 67 67 {% endmacro %} 68 68 69 + {% macro get_many_to_many(subject, source, pathToOther, dids, otherSubjects, limit) %} 70 + <form method="get" action="/xrpc/blue.microcosm.links.getManyToMany"> 71 + <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getManyToMany 72 + ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." /> 73 + &source= <input type="text" name="source" value="{{ source }}" placeholder="app.bsky.feed.like:subject" /> 74 + &pathToOther= <input type="text" name="pathToOther" value="{{ pathToOther }}" placeholder="otherThing" /> 75 + {%- for did in dids %}{% if !did.is_empty() %} 76 + &did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %} 77 + <span id="m2m-did-placeholder"></span> <button id="m2m-add-did">+ did filter</button> 78 + {%- for otherSubject in otherSubjects %}{% if !otherSubject.is_empty() %} 79 + &otherSubject= <input type="text" name="otherSubject" value="{{ otherSubject }}" placeholder="at-uri, did, uri..." />{% endif %}{% endfor %} 80 + <span id="m2m-other-placeholder"></span> <button id="m2m-add-other">+ other subject filter</button> 81 + &limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get many-to-many links</button></pre> 82 + </form> 83 + <script> 84 + const m2mAddDidButton = document.getElementById('m2m-add-did'); 85 + const m2mDidPlaceholder = document.getElementById('m2m-did-placeholder'); 86 + m2mAddDidButton.addEventListener('click', e => { 87 + e.preventDefault(); 88 + const i = document.createElement('input'); 89 + i.placeholder = 'did:plc:...'; 90 + i.name = "did" 91 + const p = m2mAddDidButton.parentNode; 92 + p.insertBefore(document.createTextNode('&did= '), m2mDidPlaceholder); 93 + p.insertBefore(i, m2mDidPlaceholder); 94 + p.insertBefore(document.createTextNode('\n '), m2mDidPlaceholder); 95 + }); 96 + </script> 97 + {% endmacro %} 98 + 69 99 {% macro links(target, collection, path, dids, limit) %} 70 100 <form method="get" action="/links"> 71 101 <pre class="code"><strong>GET</strong> /links