Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

Compare changes

Choose any two refs to compare.

Changed files
+453 -26
constellation
src
server
lexicons
+31
constellation/src/lib.rs
··· 56 56 pub records: u64, 57 57 pub distinct_dids: u64, 58 58 } 59 + 60 + // XRPC Error response, as specified at https://atproto.com/specs/xrpc#error-responses 61 + #[derive(Debug, Serialize)] 62 + pub struct XError { 63 + pub error: String, 64 + pub message: Option<String>, 65 + } 66 + 67 + #[macro_export] 68 + macro_rules! xrpcError { 69 + ($status:expr, $error_name:expr) => { 70 + ( 71 + $status, 72 + axum::Json(XError { 73 + error: format!($error_name), 74 + message: None, 75 + }), 76 + ) 77 + .into_response() 78 + }; 79 + ($status:expr, $error_name:expr, $message:expr) => { 80 + ( 81 + $status, 82 + axum::Json(XError { 83 + error: format!($error_name), 84 + message: Some($message), 85 + }), 86 + ) 87 + .into_response() 88 + }; 89 + }
+92 -26
constellation/src/server/mod.rs
··· 1 1 use askama::Template; 2 2 use axum::{ 3 - extract::{Query, Request}, 3 + body::Body, 4 + extract::{FromRequest, Query, Request}, 4 5 http::{self, header}, 5 6 middleware::{self, Next}, 6 7 response::{IntoResponse, Response}, ··· 17 18 use tokio::task::spawn_blocking; 18 19 use tokio_util::sync::CancellationToken; 19 20 20 - use crate::storage::{LinkReader, StorageStats}; 21 - use crate::{CountsByCount, Did, RecordId}; 21 + use crate::{ 22 + storage::{LinkReader, StorageStats}, 23 + xrpcError, CountsByCount, Did, RecordId, XError, 24 + }; 22 25 23 26 mod acceptable; 24 27 mod filters; ··· 32 35 DEFAULT_CURSOR_LIMIT 33 36 } 34 37 35 - fn to500(e: tokio::task::JoinError) -> http::StatusCode { 38 + fn to500(e: tokio::task::JoinError) -> Response { 36 39 eprintln!("handler error: {e}"); 37 - http::StatusCode::INTERNAL_SERVER_ERROR 40 + xrpcError!( 41 + http::StatusCode::INTERNAL_SERVER_ERROR, 42 + "InternalError", 43 + format!("The server encountered an internal error, and could not fulfill your request.") 44 + ) 38 45 } 39 46 40 47 pub async fn serve<S, A>(store: S, addr: A, stay_alive: CancellationToken) -> anyhow::Result<()> ··· 48 55 "/", 49 56 get({ 50 57 let store = store.clone(); 51 - move |accept| async { 52 - spawn_blocking(|| hello(accept, store)) 53 - .await 54 - .map_err(to500)? 55 - } 58 + move |accept| async { spawn_blocking(|| hello(accept, store)).await.map_err(to500) } 56 59 }), 57 60 ) 58 61 .route( ··· 62 65 move |accept, query| async { 63 66 spawn_blocking(|| get_many_to_many_counts(accept, query, store)) 64 67 .await 65 - .map_err(to500)? 68 + .map_err(to500) 66 69 } 67 70 }), 68 71 ) ··· 73 76 move |accept, query| async { 74 77 spawn_blocking(|| count_links(accept, query, store)) 75 78 .await 76 - .map_err(to500)? 79 + .map_err(to500) 77 80 } 78 81 }), 79 82 ) ··· 84 87 move |accept, query| async { 85 88 spawn_blocking(|| count_distinct_dids(accept, query, store)) 86 89 .await 87 - .map_err(to500)? 90 + .map_err(to500) 88 91 } 89 92 }), 90 93 ) ··· 93 96 get({ 94 97 let store = store.clone(); 95 98 move |accept, query| async { 96 - spawn_blocking(|| get_backlinks(accept, query, store)) 99 + spawn_blocking(|| get_backlinks(accept, query, store).map_err(|e| e)) 97 100 .await 98 - .map_err(to500)? 101 + .map_err(to500) 99 102 } 100 103 }), 101 104 ) ··· 106 109 move |accept, query| async { 107 110 spawn_blocking(|| get_links(accept, query, store)) 108 111 .await 109 - .map_err(to500)? 112 + .map_err(to500) 110 113 } 111 114 }), 112 115 ) ··· 117 120 move |accept, query| async { 118 121 spawn_blocking(|| get_distinct_dids(accept, query, store)) 119 122 .await 120 - .map_err(to500)? 123 + .map_err(to500) 121 124 } 122 125 }), 123 126 ) ··· 129 132 move |accept, query| async { 130 133 spawn_blocking(|| count_all_links(accept, query, store)) 131 134 .await 132 - .map_err(to500)? 135 + .map_err(to500) 133 136 } 134 137 }), 135 138 ) ··· 140 143 move |accept, query| async { 141 144 spawn_blocking(|| explore_links(accept, query, store)) 142 145 .await 143 - .map_err(to500)? 146 + .map_err(to500) 144 147 } 145 148 }), 146 149 ) 150 + .layer(middleware::from_fn(format_error_body)) 147 151 .layer(tower_http::cors::CorsLayer::permissive()) 148 - .layer(middleware::from_fn(add_lables)) 152 + .layer(middleware::from_fn(add_labels)) 149 153 .layer(MetricLayer::default()); 150 154 151 155 let listener = TcpListener::bind(addr).await?; ··· 157 161 Ok(()) 158 162 } 159 163 160 - async fn add_lables(request: Request, next: Next) -> Response { 164 + async fn format_error_body(request: Request, next: Next) -> Response { 165 + let res = next.run(request).await; 166 + if res.status().is_success() { 167 + return res; 168 + } 169 + let (o_parts, o_body) = res.into_parts(); 170 + 171 + let body_string = match String::from_request(Request::new(o_body), &()).await { 172 + Ok(s) => s, 173 + Err(e) => { 174 + eprintln!("Failed to extract error body as string: {}", e); 175 + return xrpcError!( 176 + http::StatusCode::INTERNAL_SERVER_ERROR, 177 + "ErrorFormatting", 178 + format!("Failed to extract error body as string: {}", e) 179 + ); 180 + } 181 + }; 182 + 183 + let first_char = body_string.get(..1).unwrap(); 184 + 185 + if first_char.eq("{") { 186 + return Response::from_parts(o_parts, Body::from(body_string)); 187 + } else { 188 + return Response::from_parts( 189 + o_parts, 190 + axum::Json(XError { 191 + error: format!("GenericError"), 192 + message: Some(body_string), 193 + }) 194 + .into_response() 195 + .into_body(), 196 + ); 197 + } 198 + } 199 + 200 + async fn add_labels(request: Request, next: Next) -> Response { 161 201 let origin = request 162 202 .headers() 163 203 .get(header::ORIGIN) ··· 424 464 #[serde(skip_serializing)] 425 465 path: String, 426 466 } 467 + 427 468 fn get_backlinks( 428 469 accept: ExtractAccept, 429 470 query: axum_extra::extract::Query<GetBacklinksQuery>, // supports multiple param occurrences 430 471 store: impl LinkReader, 431 - ) -> Result<impl IntoResponse, http::StatusCode> { 472 + ) -> Result<impl IntoResponse, Response<Body>> { 432 473 let until = query 433 474 .cursor 434 475 .clone() 435 - .map(|oc| ApiCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST)) 476 + .map(|oc| { 477 + ApiCursor::try_from(oc).map_err(|err| { 478 + xrpcError!( 479 + http::StatusCode::INTERNAL_SERVER_ERROR, 480 + "ParamsError", 481 + format!("{}", err) 482 + ) 483 + }) 484 + }) 436 485 .transpose()? 437 486 .map(|c| c.next); 438 487 439 488 let limit = query.limit; 440 489 if limit > DEFAULT_CURSOR_LIMIT_MAX { 441 - return Err(http::StatusCode::BAD_REQUEST); 490 + return Ok(xrpcError!( 491 + http::StatusCode::BAD_REQUEST, 492 + "LimitOverMax", 493 + format!( 494 + "The provided limit parameter is greater than the maximum of {}", 495 + DEFAULT_CURSOR_LIMIT_MAX 496 + ) 497 + )); 442 498 } 443 499 444 500 let filter_dids: HashSet<Did> = HashSet::from_iter( ··· 451 507 ); 452 508 453 509 let Some((collection, path)) = query.source.split_once(':') else { 454 - return Err(http::StatusCode::BAD_REQUEST); 510 + return Ok(xrpcError!( 511 + http::StatusCode::BAD_REQUEST, 512 + "InvalidPath", 513 + format!("The provided path parameter does not contain a colon.") 514 + )); 455 515 }; 456 516 let path = format!(".{path}"); 457 517 ··· 464 524 until, 465 525 &filter_dids, 466 526 ) 467 - .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?; 527 + .map_err(|err| { 528 + xrpcError!( 529 + http::StatusCode::BAD_REQUEST, 530 + "QueryError", 531 + format!("{}", err) 532 + ) 533 + })?; 468 534 469 535 let cursor = paged.next.map(|next| { 470 536 ApiCursor {
+29
lexicons/blue/microcosm/links/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.defs", 4 + "defs": { 5 + "recordId": { 6 + "type": "object", 7 + "description": "Describes a record's location.", 8 + "required": [ 9 + "did", 10 + "collection", 11 + "rkey" 12 + ], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did" 17 + }, 18 + "collection": { 19 + "type": "string", 20 + "format": "nsid" 21 + }, 22 + "rkey": { 23 + "type": "string", 24 + "format": "record-key" 25 + } 26 + } 27 + } 28 + } 29 + }
+82
lexicons/blue/microcosm/links/getBacklinks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getBacklinks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "A list of records linking to any record, identity, or uri.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "subject", 12 + "source" 13 + ], 14 + "properties": { 15 + "subject": { 16 + "type": "string", 17 + "description": "Can be an AT-URI, plain DID, or regular URI" 18 + }, 19 + "source": { 20 + "type": "string", 21 + "description": "Filter links only from this link source. eg.: `app.bsky.feed.like:subject.uri`" 22 + }, 23 + "did": { 24 + "type": "array", 25 + "items": { 26 + "type": "string", 27 + "format": "did" 28 + } 29 + }, 30 + "limit": { 31 + "type": "integer", 32 + "default": 16, 33 + "maximum": 100 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "application/json", 39 + "schema": { 40 + "type": "object", 41 + "required": [ 42 + "total", 43 + "records" 44 + ], 45 + "properties": { 46 + "total": { 47 + "type": "integer" 48 + }, 49 + "records": { 50 + "type": "array", 51 + "items": { 52 + "type": "ref", 53 + "ref": "blue.microcosm.links.defs#recordId" 54 + } 55 + }, 56 + "cursor": { 57 + "type": "string" 58 + } 59 + } 60 + } 61 + }, 62 + "errors": [ 63 + { 64 + "name": "QueryError", 65 + "description": "An error was encountered while querying the database." 66 + }, 67 + { 68 + "name": "ParamsError", 69 + "description": "An error was encountered while handling query parameters." 70 + }, 71 + { 72 + "name": "LimitOverMax", 73 + "description": "The provided `limit` parameter is greater than the configured maximum." 74 + }, 75 + { 76 + "name": "InvalidPath", 77 + "description": "The provided `path` parameter is invalid." 78 + } 79 + ] 80 + } 81 + } 82 + }
+90
lexicons/blue/microcosm/links/getManyToManyCounts.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getManyToManyCounts", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Gets a list of records linked by other records at `pathToOther`, where `source` in the linking record is a link to `subject`.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "subject", 12 + "source", 13 + "pathToOther" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "string", 18 + "description": "Example: `at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r`" 19 + }, 20 + "source": { 21 + "type": "string", 22 + "description": "Example: `app.bsky.feed.like:subject.uri`" 23 + }, 24 + "pathToOther": { 25 + "description": "Path to the secondary link in the linking record. Example: `otherThing.uri`", 26 + "type": "string" 27 + }, 28 + "did": { 29 + "type": "array", 30 + "items": { 31 + "type": "string", 32 + "format": "did" 33 + } 34 + }, 35 + "otherSubject": { 36 + "type": "string" 37 + }, 38 + "cursor": { 39 + "type": "string" 40 + }, 41 + "limit": { 42 + "type": "integer", 43 + "default": 16, 44 + "maximum": 100 45 + } 46 + } 47 + }, 48 + "output": { 49 + "encoding": "application/json", 50 + "schema": { 51 + "type": "object", 52 + "required": [ 53 + "counts_by_other_subject" 54 + ], 55 + "properties": { 56 + "counts_by_other_subject": { 57 + "type": "array", 58 + "items": { 59 + "type": "ref", 60 + "ref": "#otherSubjectCount" 61 + } 62 + }, 63 + "cursor": { 64 + "type": "string" 65 + } 66 + } 67 + } 68 + } 69 + }, 70 + "otherSubjectCount": { 71 + "type": "object", 72 + "required": [ 73 + "subject", 74 + "total", 75 + "distinct" 76 + ], 77 + "properties": { 78 + "subject": { 79 + "type": "string" 80 + }, 81 + "total": { 82 + "type": "integer" 83 + }, 84 + "distinct": { 85 + "type": "integer" 86 + } 87 + } 88 + } 89 + } 90 + }
+70
lexicons/com/bad-example/identity/resolveMiniDoc.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.bad-example.identity.resolveMiniDoc", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Like com.atproto.identity.resolveIdentity but instead of the full didDoc it returns an atproto-relevant subset.", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "identifier" 12 + ], 13 + "properties": { 14 + "identifier": { 15 + "type": "string", 16 + "format": "at-identifier", 17 + "description": "Handle or DID to resolve." 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "ref", 25 + "ref": "#miniDoc" 26 + } 27 + }, 28 + "errors": [ 29 + { 30 + "name": "HandleNotFound", 31 + "description": "The resolution process confirmed that the handle does not resolve to any DID." 32 + }, 33 + { 34 + "name": "DidNotFound", 35 + "description": "The DID resolution process confirmed that there is no current DID." 36 + }, 37 + { 38 + "name": "DidDeactivated", 39 + "description": "The DID previously existed, but has been deactivated." 40 + } 41 + ] 42 + }, 43 + "miniDoc": { 44 + "type": "object", 45 + "required": [ 46 + "did", 47 + "handle", 48 + "pds", 49 + "signing_key" 50 + ], 51 + "properties": { 52 + "did": { 53 + "type": "string", 54 + "format": "did" 55 + }, 56 + "handle": { 57 + "type": "string", 58 + "format": "handle" 59 + }, 60 + "pds": { 61 + "type": "string", 62 + "format": "uri" 63 + }, 64 + "signing_key": { 65 + "type": "string" 66 + } 67 + } 68 + } 69 + } 70 + }
+59
lexicons/com/bad-example/repo/getUriRecord.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.bad-example.repo.getUriRecord", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "at_uri" 12 + ], 13 + "properties": { 14 + "at_uri": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "The at-uri of the record. The identifier can be a DID or an atproto handle, and the collection and rkey segments must be present." 18 + }, 19 + "cid": { 20 + "type": "string", 21 + "format": "cid", 22 + "description": "The CID of the version of the record. If not specified, then return the most recent version." 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": [ 31 + "uri", 32 + "value" 33 + ], 34 + "properties": { 35 + "uri": { 36 + "type": "string", 37 + "format": "at-uri" 38 + }, 39 + "cid": { 40 + "type": "string", 41 + "format": "cid" 42 + }, 43 + "value": { 44 + "type": "unknown" 45 + } 46 + } 47 + } 48 + }, 49 + "errors": [ 50 + { 51 + "name": "RecordNotFound" 52 + }, 53 + { 54 + "name": "InvalidRequest" 55 + } 56 + ] 57 + } 58 + } 59 + }