Monorepo for Tangled tangled.org
1

Configure Feed

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

core / bobbin / crates / xrpc / src / enrich.rs
13 kB 372 lines
1use std::collections::HashSet; 2 3use axum::{ 4 Json, 5 body::{Body, to_bytes}, 6 extract::State, 7 http::{Request, StatusCode}, 8 response::Response, 9}; 10use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static}; 11use jacquard_common::DefaultStr; 12use jacquard_common::types::did::Did; 13use jacquard_common::types::ident::AtIdentifier; 14use jacquard_common::types::string::AtUri; 15use serde::Deserialize; 16use serde_json::{Map, Value, json}; 17use tower::ServiceExt; 18 19use crate::recordpath::{parse_record_path, walk_path}; 20use crate::{AppState, SubjectShape, XrpcError, mirror_kind, subject_shape}; 21 22#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] 23#[serde(rename_all = "camelCase")] 24pub enum Aggregation { 25 Count, 26 DistinctAuthors, 27 Viewer, 28} 29 30impl Aggregation { 31 fn key(self) -> &'static str { 32 match self { 33 Aggregation::Count => "count", 34 Aggregation::DistinctAuthors => "distinctAuthors", 35 Aggregation::Viewer => "viewer", 36 } 37 } 38} 39 40#[derive(Debug, Deserialize)] 41pub struct LinkDescriptor { 42 /// constellation link source 43 source: String, 44 #[serde(rename = "type")] 45 aggregation: Aggregation, 46} 47 48impl LinkDescriptor { 49 fn parts(&self) -> Result<(&str, &str, Aggregation), XrpcError> { 50 match self.source.split_once(':') { 51 Some((collection, path)) if !collection.is_empty() && !path.is_empty() => { 52 Ok((collection, path, self.aggregation)) 53 } 54 _ => Err(XrpcError::InvalidParams(format!( 55 "enrich {:?}: expected \"collection:path\"", 56 self.source 57 ))), 58 } 59 } 60} 61 62#[derive(Debug, Deserialize)] 63pub struct EnrichInput { 64 xrpc: String, 65 #[serde(default)] 66 params: Option<Map<String, Value>>, 67 enrich: Vec<LinkDescriptor>, 68 #[serde(default)] 69 sources: Option<Vec<String>>, 70 #[serde(default)] 71 viewer: Option<Did>, 72} 73 74pub async fn enrich( 75 State(state): State<AppState>, 76 Json(input): Json<EnrichInput>, 77) -> Result<Json<Value>, XrpcError> { 78 for descriptor in &input.enrich { 79 validate_descriptor(descriptor)?; 80 if descriptor.aggregation == Aggregation::Viewer && input.viewer.is_none() { 81 return Err(descriptor_error( 82 descriptor, 83 "viewer aggregation requires a viewer param", 84 )); 85 } 86 } 87 88 let inner = run_inner(&state, &input.xrpc, input.params.unwrap_or_default()).await?; 89 90 let mut refs: Vec<SubjectRef> = Vec::new(); 91 let mut seen: HashSet<SubjectRef> = HashSet::new(); 92 match &input.sources { 93 Some(sources) => { 94 for (i, path) in sources.iter().enumerate() { 95 let segs = parse_record_path(path) 96 .map_err(|e| XrpcError::InvalidParams(format!("sources[{i}] {path:?}: {e}")))?; 97 for node in walk_path(&segs, [&inner]) { 98 collect_ref(node, &mut refs, &mut seen); 99 } 100 } 101 } 102 None => discover_refs(&inner, &mut refs, &mut seen), 103 } 104 105 let mut stats = Map::new(); 106 for reference in &refs { 107 let mut per_ref = Map::new(); 108 for descriptor in &input.enrich { 109 let (.., aggregation) = descriptor.parts()?; 110 let Some(kind) = resolve_descriptor(descriptor, reference)? else { 111 continue; 112 }; 113 let key = EdgeKey::new(kind, reference.clone()); 114 let result = match aggregation { 115 Aggregation::Count => Value::from(state.edges.count(&key)), 116 Aggregation::DistinctAuthors => { 117 Value::from(state.edges.count_distinct_authors(&key)) 118 } 119 Aggregation::Viewer => { 120 // viewer presence is validated up front 121 let viewer = input.viewer.as_ref().expect("viewer param present"); 122 match state.edges.viewer_source(&key, viewer.as_str()) { 123 Some(uri) => Value::String(uri.to_string()), 124 None => Value::Null, 125 } 126 } 127 }; 128 let entry = per_ref 129 .entry(descriptor.source.clone()) 130 .or_insert_with(|| Value::Object(Map::new())); 131 if let Value::Object(counts) = entry { 132 counts.insert(aggregation.key().to_owned(), result); 133 } 134 } 135 if !per_ref.is_empty() { 136 stats.insert(subject_string(reference), Value::Object(per_ref)); 137 } 138 } 139 140 Ok(Json(json!({ "output": inner, "stats": stats }))) 141} 142 143fn descriptor_error(descriptor: &LinkDescriptor, msg: &str) -> XrpcError { 144 XrpcError::InvalidParams(format!("enrich {}: {msg}", descriptor.source)) 145} 146 147fn validate_descriptor(descriptor: &LinkDescriptor) -> Result<(), XrpcError> { 148 let (collection, path, _) = descriptor.parts()?; 149 match path { 150 "subject" => subject_shape(collection) 151 .map(|_| ()) 152 .ok_or_else(|| descriptor_error(descriptor, "unknown collection")), 153 ".repo" => mirror_kind(collection) 154 .map(|_| ()) 155 .ok_or_else(|| descriptor_error(descriptor, "collection has no author index")), 156 _ if path.starts_with('.') => Err(descriptor_error( 157 descriptor, 158 "envelope field not index-backed; try .repo", 159 )), 160 _ => Err(descriptor_error( 161 descriptor, 162 "path not index-backed; only `subject` and `.repo` are supported", 163 )), 164 } 165} 166 167/// the edge kind to look up for one reference, or None if the descriptor's shape 168/// doesn't apply to this ref kind, eg. a did-only descriptor asked about an at-uri 169fn resolve_descriptor( 170 descriptor: &LinkDescriptor, 171 reference: &SubjectRef, 172) -> Result<Option<jacquard_common::types::nsid::Nsid<DefaultStr>>, XrpcError> { 173 let (collection, path, _) = descriptor.parts()?; 174 match path { 175 "subject" => { 176 let Some((nsid, shape)) = subject_shape(collection) else { 177 return Err(descriptor_error(descriptor, "unknown collection")); 178 }; 179 Ok(shape_accepts(shape, reference).then(|| nsid_static(nsid))) 180 } 181 ".repo" => { 182 let Some(kind) = mirror_kind(collection) else { 183 return Err(descriptor_error( 184 descriptor, 185 "collection has no author index", 186 )); 187 }; 188 Ok(matches!(reference, SubjectRef::Did(_)).then(|| nsid_static(kind))) 189 } 190 _ => unreachable!("validated up front"), 191 } 192} 193 194/// mismatch means skip, not reject 195fn shape_accepts(shape: SubjectShape, reference: &SubjectRef) -> bool { 196 match (shape, reference) { 197 (SubjectShape::BareDid, SubjectRef::Did(_)) => true, 198 (SubjectShape::Collection(expected), SubjectRef::Uri(uri)) => { 199 uri.collection().is_some_and(|c| c.as_ref() == expected) 200 } 201 (SubjectShape::OneOfCollections(allowed), SubjectRef::Uri(uri)) 202 | (SubjectShape::BareDidOrOneOfCollections(allowed), SubjectRef::Uri(uri)) => uri 203 .collection() 204 .is_some_and(|c| allowed.contains(&c.as_ref())), 205 (SubjectShape::BareDidOrOneOfCollections(_), SubjectRef::Did(_)) => true, 206 (SubjectShape::AnyAtUri, SubjectRef::Uri(_)) => true, 207 _ => false, 208 } 209} 210 211fn subject_string(reference: &SubjectRef) -> String { 212 match reference { 213 SubjectRef::Did(d) => d.as_ref().to_owned(), 214 SubjectRef::Uri(u) => u.as_ref().to_owned(), 215 } 216} 217 218/// a value counts as a reference if it is a did string or an at-uri string 219/// with collection and rkey, or a {uri, cid} strong ref 220fn collect_ref(value: &Value, refs: &mut Vec<SubjectRef>, seen: &mut HashSet<SubjectRef>) { 221 let candidate = match value { 222 Value::String(s) => Some(s.as_str()), 223 Value::Object(o) => o 224 .get("uri") 225 .and_then(Value::as_str) 226 .filter(|_| o.contains_key("cid")), 227 _ => None, 228 }; 229 let Some(candidate) = candidate else { return }; 230 let reference = if candidate.starts_with("did:") { 231 Did::<DefaultStr>::new_owned(candidate) 232 .ok() 233 .map(SubjectRef::Did) 234 } else if candidate.starts_with("at://") { 235 AtUri::<DefaultStr>::new_owned(candidate) 236 .ok() 237 .filter(|u| { 238 matches!(u.authority(), AtIdentifier::Did(_)) 239 && u.collection().is_some() 240 && u.rkey().is_some() 241 }) 242 .map(SubjectRef::Uri) 243 } else { 244 None 245 }; 246 if let Some(reference) = reference 247 && seen.insert(reference.clone()) 248 { 249 refs.push(reference); 250 } 251} 252 253fn discover_refs(value: &Value, refs: &mut Vec<SubjectRef>, seen: &mut HashSet<SubjectRef>) { 254 collect_ref(value, refs, seen); 255 match value { 256 Value::Array(items) => { 257 for item in items { 258 discover_refs(item, refs, seen); 259 } 260 } 261 Value::Object(map) => { 262 for v in map.values() { 263 discover_refs(v, refs, seen); 264 } 265 } 266 _ => {} 267 } 268} 269 270async fn run_inner( 271 state: &AppState, 272 nsid: &str, 273 params: Map<String, Value>, 274) -> Result<Value, XrpcError> { 275 let qs = encode_params(&params); 276 let uri = format!("/xrpc/{nsid}?{qs}"); 277 let request = Request::builder() 278 .method("GET") 279 .uri(&uri) 280 .body(Body::empty()) 281 .map_err(|e| XrpcError::Internal(format!("inner request: {e}")))?; 282 let response = state 283 .self_router() 284 .oneshot(request) 285 .await 286 .map_err(|e| XrpcError::Internal(format!("inner dispatch: {e}")))?; 287 if response.status() == StatusCode::NOT_FOUND { 288 let bytes = to_bytes(response.into_body(), usize::MAX) 289 .await 290 .map_err(|e| XrpcError::Internal(format!("inner response: {e}")))?; 291 return Err(bytes 292 .is_empty() 293 .then(|| XrpcError::InvalidParams(format!("unknown or unenrichable query: {nsid}"))) 294 .unwrap_or(XrpcError::NotFound)); 295 } 296 finish(response).await 297} 298 299fn encode_params(params: &Map<String, Value>) -> String { 300 let mut out = url::form_urlencoded::Serializer::new(String::new()); 301 for (key, value) in params { 302 match value { 303 Value::Array(items) => { 304 for item in items { 305 if let Some(scalar) = scalar_str(item) { 306 out.append_pair(key, &scalar); 307 } 308 } 309 } 310 _ => { 311 if let Some(scalar) = scalar_str(value) { 312 out.append_pair(key, &scalar); 313 } 314 } 315 } 316 } 317 out.finish() 318} 319 320fn scalar_str(value: &Value) -> Option<String> { 321 match value { 322 Value::String(s) => Some(s.clone()), 323 Value::Number(n) => Some(n.to_string()), 324 Value::Bool(b) => Some(b.to_string()), 325 _ => None, 326 } 327} 328 329async fn finish(resp: Response) -> Result<Value, XrpcError> { 330 let status = resp.status(); 331 let bytes = to_bytes(resp.into_body(), usize::MAX) 332 .await 333 .map_err(|e| XrpcError::Internal(format!("inner response: {e}")))?; 334 if !status.is_success() { 335 let msg = String::from_utf8_lossy(&bytes).into_owned(); 336 return Err(match status { 337 StatusCode::BAD_REQUEST => XrpcError::InvalidParams(msg), 338 StatusCode::NOT_FOUND => XrpcError::NotFound, 339 StatusCode::SERVICE_UNAVAILABLE => XrpcError::Overloaded, 340 _ => XrpcError::UpstreamUnavailable(format!("inner query ({status}): {msg}")), 341 }); 342 } 343 serde_json::from_slice(&bytes) 344 .map_err(|e| XrpcError::Internal(format!("inner response decode: {e}"))) 345} 346 347#[cfg(test)] 348mod tests { 349 use super::*; 350 use serde_json::json; 351 352 #[test] 353 fn collects_dids_uris_and_strong_refs() { 354 let mut refs = Vec::new(); 355 let mut seen = HashSet::new(); 356 collect_ref(&json!("did:plc:abc"), &mut refs, &mut seen); 357 collect_ref( 358 &json!("at://did:plc:abc/sh.tangled.repo/x"), 359 &mut refs, 360 &mut seen, 361 ); 362 collect_ref( 363 &json!({"uri": "at://did:plc:abc/sh.tangled.repo/y", "cid": "bafy"}), 364 &mut refs, 365 &mut seen, 366 ); 367 collect_ref(&json!("at://did:plc:abc"), &mut refs, &mut seen); 368 collect_ref(&json!("oppi.li"), &mut refs, &mut seen); 369 collect_ref(&json!("did:plc:abc"), &mut refs, &mut seen); 370 assert_eq!(refs.len(), 3); 371 } 372}