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

Add getDistinct XRPC equivalent to REST /links/distinct-dids

Simple conversion of the existing endpoint from REST to XRPC.
Consequtively marked the pre-existing REST endpoint as deprecated.

# Conflicts:
# constellation/templates/try-it-macros.html.j2

+245 -15
+80
constellation/src/server/mod.rs
··· 125 125 }), 126 126 ) 127 127 .route( 128 + // deprecated 128 129 "/links", 129 130 get({ 130 131 let store = store.clone(); ··· 136 137 }), 137 138 ) 138 139 .route( 140 + "/xrpc/blue.microcosm.links.getDistinct", 141 + get({ 142 + let store = store.clone(); 143 + move |accept, query| async { 144 + spawn_blocking(|| get_distinct(accept, query, store)) 145 + .await 146 + .map_err(to500)? 147 + } 148 + }), 149 + ) 150 + // deprecated 151 + .route( 139 152 "/links/distinct-dids", 140 153 get({ 141 154 let store = store.clone(); ··· 158 171 } 159 172 }), 160 173 ) 174 + // deprecated 161 175 .route( 162 176 "/links/all", 163 177 get({ ··· 601 615 #[serde(skip_serializing)] 602 616 query: GetLinkItemsQuery, 603 617 } 618 + #[deprecated] 604 619 fn get_links( 605 620 accept: ExtractAccept, 606 621 query: axum_extra::extract::Query<GetLinkItemsQuery>, // supports multiple param occurrences ··· 661 676 GetLinkItemsResponse { 662 677 total: paged.total, 663 678 linking_records: paged.items, 679 + cursor, 680 + query: (*query).clone(), 681 + }, 682 + )) 683 + } 684 + 685 + #[derive(Clone, Deserialize)] 686 + struct GetDistinctItemsQuery { 687 + subject: String, 688 + source: String, 689 + cursor: Option<OpaqueApiCursor>, 690 + limit: Option<u64>, 691 + // TODO: allow reverse (er, forward) order as well 692 + } 693 + #[derive(Template, Serialize)] 694 + #[template(path = "get-distinct.html.j2")] 695 + struct GetDistinctItemsResponse { 696 + // what does staleness mean? 697 + // - new links have appeared. would be nice to offer a `since` cursor to fetch these. and/or, 698 + // - links have been deleted. hmm. 699 + total: u64, 700 + linking_dids: Vec<Did>, 701 + cursor: Option<OpaqueApiCursor>, 702 + #[serde(skip_serializing)] 703 + query: GetDistinctItemsQuery, 704 + } 705 + fn get_distinct( 706 + accept: ExtractAccept, 707 + query: Query<GetDistinctItemsQuery>, 708 + store: impl LinkReader, 709 + ) -> Result<impl IntoResponse, http::StatusCode> { 710 + let until = query 711 + .cursor 712 + .clone() 713 + .map(|oc| ApiCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST)) 714 + .transpose()? 715 + .map(|c| c.next); 716 + 717 + let limit = query.limit.unwrap_or(DEFAULT_CURSOR_LIMIT); 718 + if limit > DEFAULT_CURSOR_LIMIT_MAX { 719 + return Err(http::StatusCode::BAD_REQUEST); 720 + } 721 + 722 + let Some((collection, path)) = query.source.split_once(':') else { 723 + return Err(http::StatusCode::BAD_REQUEST); 724 + }; 725 + let path = format!(".{path}"); 726 + 727 + let paged = store 728 + .get_distinct_dids(&query.subject, &collection, &path, limit, until) 729 + .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?; 730 + 731 + let cursor = paged.next.map(|next| { 732 + ApiCursor { 733 + version: paged.version, 734 + next, 735 + } 736 + .into() 737 + }); 738 + 739 + Ok(acceptable( 740 + accept, 741 + GetDistinctItemsResponse { 742 + total: paged.total, 743 + linking_dids: paged.items, 664 744 cursor, 665 745 query: (*query).clone(), 666 746 },
+2 -2
constellation/src/storage/mem_store.rs
··· 396 396 ) -> Result<HashMap<String, HashMap<String, CountsByCount>>> { 397 397 let data = self.0.lock().unwrap(); 398 398 let mut out: HashMap<String, HashMap<String, CountsByCount>> = HashMap::new(); 399 - if let Some(asdf) = data.targets.get(&Target::new(target)) { 400 - for (Source { collection, path }, linkers) in asdf { 399 + if let Some(source_linker_pairs) = data.targets.get(&Target::new(target)) { 400 + for (Source { collection, path }, linkers) in source_linker_pairs { 401 401 let records = linkers.iter().flatten().count() as u64; 402 402 let distinct_dids = linkers 403 403 .iter()
+49
constellation/templates/get-distinct.html.j2
··· 1 + {% extends "base.html.j2" %} 2 + {% import "try-it-macros.html.j2" as try_it %} 3 + 4 + {% block title %}DIDs{% endblock %} 5 + {% block description %}Distinct DIDs with records in {{ query.source }} linking to {{ query.subject }} {% endblock %} 6 + 7 + {% block content %} 8 + 9 + {% call try_it::get_distinct(query.subject, query.source) %} 10 + 11 + <h2> 12 + Distinct DIDs with links to <code>{{ query.subject }}</code> from <code>{{ query.source }}</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>{{ total|human_number }} distinct DIDs</strong> with links to <code>{{ query.subject }}</code> from <code>{{ query.source }}</code></p> 19 + 20 + <ul> 21 + <li>See direct backlinks at <code>/xrpc/blue.microcosm.links.getBacklinks</code>: <a href="/xrpc/blue.microcosm.links.getBacklinks?subject={{ query.subject|urlencode }}&source={{ query.source|urlencode }}">/xrpc/blue.microcosm.links.getBacklinks?subject={{ query.subject }}&source={{ query.source }}</a></li> 22 + <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> 23 + </ul> 24 + 25 + <h3>DIDs, most recent first:</h3> 26 + 27 + {% for did in linking_dids %} 28 + <pre style="display: block; margin: 1em 2em" class="code"><strong>DID</strong>: {{ did.0 }} 29 + -> see <a href="/links/all?target={{ did.0|urlencode }}">links to this DID</a> 30 + -> browse <a href="https://pdsls.dev/at://{{ did.0 }}">this DID record</a></pre> 31 + {% endfor %} 32 + 33 + {% if let Some(c) = cursor %} 34 + <form method="get" action="/xrpc/blue.microcosm.links.getDistinct"> 35 + <input type="hidden" name="subject" value="{{ query.subject }}" /> 36 + <input type="hidden" name="source" value="{{ query.source }}" /> 37 + <input type="hidden" name="cursor" value={{ c|json|safe }} /> 38 + <button type="submit">next page&hellip;</button> 39 + </form> 40 + {% else %} 41 + <button disabled><em>end of results</em></button> 42 + {% endif %} 43 + 44 + <details> 45 + <summary>Raw JSON response</summary> 46 + <pre class="code">{{ self|tojson }}</pre> 47 + </details> 48 + 49 + {% endblock %}
+45
constellation/templates/hello.html.j2
··· 83 83 ) %} 84 84 85 85 86 + <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getCounts</code></h3> 87 + 88 + <p>The total number of links pointing at a given target.</p> 89 + 90 + <h4>Query parameters:</h4> 91 + 92 + <ul> 93 + <li><code>subject</code>: required, must url-encode. The target being linked to. Example: <code>did:plc:vc7f4oafdgxsihk4cry2xpze</code> or <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></li> 94 + <li><code>source</code>: required. Collection and path specification for the primary link. Example: <code>app.bsky.feed.like:subject.uri</code></li> 95 + </ul> 96 + 97 + <p style="margin-bottom: 0"><strong>Try it:</strong></p> 98 + {% call try_it::get_counts("did:plc:vc7f4oafdgxsihk4cry2xpze", "app.bsky.graph.block:subject.uri") %} 99 + 100 + 101 + <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getDistinct</code></h3> 102 + 103 + <p>A list of distinct DIDs (identities) with links to a target.</p> 104 + 105 + <h4>Query parameters:</h4> 106 + 107 + <ul> 108 + <li><code>subject</code>: required, must url-encode. The target being linked to. Example: <code>did:plc:vc7f4oafdgxsihk4cry2xpze</code> or <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></li> 109 + <li><code>source</code>: required. Collection and path specification for the primary link. Example: <code>app.bsky.feed.like:subject.uri</code></li> 110 + <li><code>limit</code>: optional. Number of results to return. Default: <code>16</code>. Maximum: <code>100</code></li> 111 + <li><code>cursor</code>: optional, see Definitions.</li> 112 + </ul> 113 + 114 + <p style="margin-bottom: 0"><strong>Try it:</strong></p> 115 + {% call try_it::get_distinct("at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r", "app.bsky.feed.like:subject.uri") %} 116 + 117 + 86 118 <h3 class="route"><code>GET /links</code></h3> 87 119 88 120 <p>A list of records linking to a target.</p> ··· 104 136 <p style="margin-bottom: 0"><strong>Try it:</strong></p> 105 137 {% call try_it::links("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like", ".subject.uri", [""], 16) %} 106 138 139 + <h3 class="route"><code>GET /xrpc/blue.microcosm.links.getBacklinks</code></h3> 140 + 141 + <p>A list of distinct DIDs (identities) with links to a target.</p> 142 + 143 + <h4>Query parameters:</h4> 144 + 145 + <ul> 146 + <li><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></li> 147 + <li><code>source</code>: required, must url-encode. Example: <code>app.bsky.feed.post:.subject.uri</code></li> 148 + </ul> 149 + 150 + <p style="margin-bottom: 0"><strong>Try it:</strong></p> 151 + {% call try_it::get_distinct("at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r", "app.bsky.feed.like:.subject.uri") %} 107 152 108 153 <h3 class="route"><code>GET /links/distinct-dids</code></h3> 109 154
+10
constellation/templates/try-it-macros.html.j2
··· 112 112 </form> 113 113 {% endmacro %} 114 114 115 + {% macro get_distinct(subject, source) %} 116 + <form method="get" action="/xrpc/blue.microcosm.links.getDistinct"> 117 + <pre class="code"><strong>GET</strong> /xrpc/blue.microcosm.links.getDistinct 118 + ?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="subject" /> 119 + &source= <input type="text" name="source" value="{{ source }}" placeholder="source" /> 120 + <button type="submit">get links</button> 121 + </pre> 122 + </form> 123 + {% endmacro %} 124 + 115 125 {% macro links_count(target, collection, path) %} 116 126 <form method="get" action="/links/count"> 117 127 <pre class="code"><strong>GET</strong> /links/count
+3 -13
lexicons/blue.microcosm/links/getBacklinks.json
··· 7 7 "description": "a list of records linking to any record, identity, or uri", 8 8 "parameters": { 9 9 "type": "params", 10 - "required": [ 11 - "subject", 12 - "source" 13 - ], 10 + "required": ["subject", "source"], 14 11 "properties": { 15 12 "subject": { 16 13 "type": "string", ··· 42 39 "encoding": "application/json", 43 40 "schema": { 44 41 "type": "object", 45 - "required": [ 46 - "total", 47 - "records" 48 - ], 42 + "required": ["total", "records"], 49 43 "properties": { 50 44 "total": { 51 45 "type": "integer", ··· 68 62 }, 69 63 "linkRecord": { 70 64 "type": "object", 71 - "required": [ 72 - "did", 73 - "collection", 74 - "rkey" 75 - ], 65 + "required": ["did", "collection", "rkey"], 76 66 "properties": { 77 67 "did": { 78 68 "type": "string",
+56
lexicons/blue.microcosm/links/getDistinct.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "blue.microcosm.links.getDistinct", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "a list of distinct dids with a specific records linking to a target at a specified path", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["subject", "source"], 11 + "properties": { 12 + "subject": { 13 + "type": "string", 14 + "format": "uri", 15 + "description": "the target being linked to (at-uri, did, or uri)" 16 + }, 17 + "source": { 18 + "type": "string", 19 + "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "minimum": 1, 24 + "maximum": 100, 25 + "default": 16, 26 + "description": "number of results to return" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["total", "linking_dids"], 35 + "properties": { 36 + "total": { 37 + "type": "integer", 38 + "description": "total number of matching links" 39 + }, 40 + "linking_dids": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "did" 45 + } 46 + }, 47 + "cursor": { 48 + "type": "string", 49 + "description": "pagination cursor" 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }