0
2
web: Groups, pools and private galleries call grain-serve's methods
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain-serve/src/spaces/pool.rs+9−9
| @@ -1,4 +1,4 @@ | ||
//! Groups' pools: `social.grain.group.pool.*`. | ||
//! Groups' pools: `social.grain.group.*`. | ||
//! | ||
//! A group's pool is one space of type `social.grain.group`, anchored on | ||
//! the group, keyed `self`, which the group's host made. Its members | ||
| @@ -40,14 +40,14 @@ use serde_json::{Value, json}; | ||
use std::collections::HashMap; | ||
use std::sync::Arc; | ||
| ||
pub const CREATE: &str = "social.grain.group.pool.createGallery"; | ||
pub const DELETE: &str = "social.grain.group.pool.deleteGallery"; | ||
pub const GET: &str = "social.grain.group.pool.getGallery"; | ||
pub const LIST: &str = "social.grain.group.pool.listGalleries"; | ||
pub const FEED: &str = "social.grain.group.pool.listFeed"; | ||
pub const COMMENT: &str = "social.grain.group.pool.createComment"; | ||
pub const UNCOMMENT: &str = "social.grain.group.pool.deleteComment"; | ||
pub const FAVORITE: &str = "social.grain.group.pool.putFavorite"; | ||
pub const CREATE: &str = "social.grain.group.createGallery"; | ||
pub const DELETE: &str = "social.grain.group.deleteGallery"; | ||
pub const GET: &str = "social.grain.group.getGallery"; | ||
pub const LIST: &str = "social.grain.group.listGalleries"; | ||
pub const FEED: &str = "social.grain.group.listFeed"; | ||
pub const COMMENT: &str = "social.grain.group.createComment"; | ||
pub const UNCOMMENT: &str = "social.grain.group.deleteComment"; | ||
pub const FAVORITE: &str = "social.grain.group.putFavorite"; | ||
| ||
const COMMENTS: &str = "social.grain.comment"; | ||
const FAVORITES: &str = "social.grain.favorite"; | ||
crates/grain-serve/src/spaces/support.rs+3−3
| @@ -1,6 +1,6 @@ | ||
//! Whether a PDS serves permissioned spaces, and so whether its accounts | ||
//! can make private galleries and take part in groups: | ||
//! `social.grain.privateGallery.getSpaceSupport`, and what a login asks | ||
//! `social.grain.server.getSpaceSupport`, and what a login asks | ||
//! for ([`crate::login`]). | ||
//! | ||
//! `community.lexicon.service.describe` is asked first, and its list of | ||
| @@ -30,7 +30,7 @@ use std::collections::{HashMap, HashSet}; | ||
use std::sync::Arc; | ||
use std::time::Duration; | ||
| ||
pub const GET: &str = "social.grain.privateGallery.getSpaceSupport"; | ||
pub const GET: &str = "social.grain.server.getSpaceSupport"; | ||
| ||
const DESCRIBE: &str = "community.lexicon.service.describe"; | ||
| ||
| @@ -170,7 +170,7 @@ pub(crate) fn index(app: &App) -> Result<&crate::index::Index, Failure> { | ||
app.index.as_deref().ok_or_else(none) | ||
} | ||
| ||
/// `social.grain.privateGallery.getSpaceSupport`: whether the caller's | ||
/// `social.grain.server.getSpaceSupport`: whether the caller's | ||
/// PDS, as its DID document names it, serves spaces. | ||
pub async fn get_space_support( | ||
State(app): State<Arc<App>>, | ||
crates/grain-serve/tests/health.rs+123−1
| @@ -1,5 +1,6 @@ | ||
//! The server answers atproto's health check with its version, and its | ||
//! DID document, and a method it does not serve as XRPC refuses one. | ||
//! DID document, and a method it does not serve as XRPC refuses one; and | ||
//! it serves every method the web calls. | ||
| ||
mod common; | ||
| ||
| @@ -66,3 +67,124 @@ async fn a_method_not_served_is_not_implemented() { | ||
assert_eq!(body["error"], "MethodNotImplemented"); | ||
} | ||
} | ||
| ||
/// The XRPC methods the web's sources under `web/src` call, by the NSID | ||
/// each passes as the first argument of `$lib/xrpc`'s `query`, `call` or | ||
/// `upload` (or a name it imports one as, such as `xrpcQuery`), each with | ||
/// whether it is asked as a query. Its generated lexicons are left out. | ||
fn methods_the_web_calls() -> Vec<(String, String, bool)> { | ||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src"); | ||
let mut files = vec![]; | ||
let mut dirs = vec![src.clone()]; | ||
while let Some(dir) = dirs.pop() { | ||
for entry in std::fs::read_dir(&dir).unwrap() { | ||
let path = entry.unwrap().path(); | ||
if path.is_dir() { | ||
if path != src.join("lib/lexicons") { | ||
dirs.push(path); | ||
} | ||
} else if path | ||
.extension() | ||
.is_some_and(|e| ["ts", "js", "svelte"].contains(&e.to_str().unwrap())) | ||
{ | ||
files.push(path); | ||
} | ||
} | ||
} | ||
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$'; | ||
let mut methods = vec![]; | ||
for file in files { | ||
let text = std::fs::read_to_string(&file).unwrap(); | ||
let name = file.strip_prefix(&src).unwrap().display().to_string(); | ||
for (at, _) in text.match_indices('(') { | ||
// The callee, and its type arguments, before the parenthesis. | ||
let mut head = text[..at].trim_end(); | ||
if head.ends_with('>') { | ||
let mut depth = 0; | ||
let mut open = None; | ||
for (i, c) in head.char_indices().rev() { | ||
match c { | ||
'>' => depth += 1, | ||
'<' => depth -= 1, | ||
_ => {} | ||
} | ||
if depth == 0 { | ||
open = Some(i); | ||
break; | ||
} | ||
} | ||
let Some(open) = open else { continue }; | ||
head = &head[..open]; | ||
} | ||
let start = head | ||
.char_indices() | ||
.rev() | ||
.find(|(_, c)| !ident(*c)) | ||
.map_or(0, |(i, c)| i + c.len_utf8()); | ||
if head[..start].ends_with('.') { | ||
continue; | ||
} | ||
let callee = head[start..].to_ascii_lowercase(); | ||
let query = callee.ends_with("query"); | ||
if !(query || callee.ends_with("call") || callee.ends_with("upload")) { | ||
continue; | ||
} | ||
// Its first argument, if that is a string naming a method. | ||
let arg = text[at + 1..].trim_start(); | ||
let Some(quote) = arg.chars().next().filter(|c| "\"'`".contains(*c)) else { | ||
continue; | ||
}; | ||
let Some(end) = arg[1..].find(quote) else { | ||
continue; | ||
}; | ||
let nsid = &arg[1..1 + end]; | ||
let segments = nsid.split('.').collect::<Vec<_>>(); | ||
let nsid_shaped = segments.len() >= 3 | ||
&& segments | ||
.iter() | ||
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); | ||
if nsid_shaped { | ||
methods.push((name.clone(), nsid.to_string(), query)); | ||
} | ||
} | ||
} | ||
methods.sort(); | ||
methods.dedup(); | ||
methods | ||
} | ||
| ||
#[tokio::test] | ||
async fn every_method_the_web_calls_is_served() { | ||
let methods = methods_the_web_calls(); | ||
// The scan finds the web's calls, so that a scan that finds none | ||
// does not pass for one whose calls are all served. | ||
assert!(methods.len() > 40, "found only {methods:?}"); | ||
// A server that keeps an index and has a DID, as the web's does: one | ||
// with neither answers 501 for the methods that need them. | ||
let ctx = grain::env::Ctx::new(grain::env::Env::from_vars(|_| None)); | ||
let mut app = grain_serve::App::bare(ctx); | ||
app.index = Some(std::sync::Arc::new( | ||
grain_serve::index::Index::memory().unwrap(), | ||
)); | ||
app.did = Some("did:web:grain.test".into()); | ||
let s = common::served_app(app).await; | ||
let mut unserved = vec![]; | ||
for (file, nsid, query) in &methods { | ||
let path = format!("/xrpc/{nsid}"); | ||
let (status, _) = match query { | ||
true => s.get(&path).await, | ||
false => s.post(&path, &json!({})).await, | ||
}; | ||
// 501 is a method the server does not serve at all, 405 one it | ||
// serves as the other kind. | ||
if status == 501 || status == 405 { | ||
let kind = if *query { "query" } else { "procedure" }; | ||
unserved.push(format!("{file}: {nsid} as a {kind} ({status})")); | ||
} | ||
} | ||
assert!( | ||
unserved.is_empty(), | ||
"web/src calls methods grain-serve does not serve:\n{}", | ||
unserved.join("\n") | ||
); | ||
} | ||
crates/grain-serve/tests/spaces.rs+6−6
| @@ -17,7 +17,7 @@ use reqwest::header::COOKIE; | ||
use serde_json::{Value, json}; | ||
use web::{Jar, Site, site, site_starting_groups}; | ||
| ||
const SUPPORT: &str = "/xrpc/social.grain.privateGallery.getSpaceSupport"; | ||
const SUPPORT: &str = "/xrpc/social.grain.server.getSpaceSupport"; | ||
| ||
#[tokio::test] | ||
async fn a_login_on_a_pds_that_serves_spaces_asks_for_them() { | ||
| @@ -710,11 +710,11 @@ async fn a_group_is_joined_read_and_left_through_its_host() { | ||
assert_eq!(found.unwrap()["viewer"]["acting"], true, "{listed}"); | ||
// Its own pool is its feed, read with a web session it has none of. | ||
let token = group | ||
.token(&site.app.ctx, aud, "social.grain.group.pool.listFeed") | ||
.token(&site.app.ctx, aud, "social.grain.group.listFeed") | ||
.await; | ||
let (status, feed) = as_group | ||
.get_with( | ||
"/xrpc/social.grain.group.pool.listFeed", | ||
"/xrpc/social.grain.group.listFeed", | ||
&format!("Bearer {token}"), | ||
) | ||
.await; | ||
| @@ -777,12 +777,12 @@ async fn a_group_is_joined_read_and_left_through_its_host() { | ||
| ||
impl Browser { | ||
async fn pool(&self, site: &Site, method: &str, body: Value) -> (u16, Value) { | ||
let path = format!("/xrpc/social.grain.group.pool.{method}"); | ||
let path = format!("/xrpc/social.grain.group.{method}"); | ||
site.served.post_headed(&path, &self.headers(), &body).await | ||
} | ||
| ||
async fn pool_get(&self, site: &Site, method: &str, query: &str) -> (u16, Value) { | ||
let path = format!("/xrpc/social.grain.group.pool.{method}?{query}"); | ||
let path = format!("/xrpc/social.grain.group.{method}?{query}"); | ||
site.served.get_headed(&path, &self.headers()).await | ||
} | ||
} | ||
| @@ -1015,7 +1015,7 @@ async fn a_pool_is_read_and_written_by_its_groups_members_alone() { | ||
); | ||
let (status, _) = site | ||
.served | ||
.get(&format!("/xrpc/social.grain.group.pool.listGalleries?{at}")) | ||
.get(&format!("/xrpc/social.grain.group.listGalleries?{at}")) | ||
.await; | ||
assert_eq!(status, 401); | ||
| ||
crates/grain-serve/tests/write.rs+2−2
| @@ -447,8 +447,8 @@ async fn a_private_gallery_waits_for_a_pds_that_serves_spaces() { | ||
let (a, headers) = logged_in(&site).await; | ||
let (_, other) = logged_in(&site).await; | ||
let method = |name: &str| format!("/xrpc/social.grain.privateGallery.{name}"); | ||
let path = method("getSpaceSupport"); | ||
let (status, found) = site.served.get_headed(&path, &refs(&headers)).await; | ||
let path = "/xrpc/social.grain.server.getSpaceSupport"; | ||
let (status, found) = site.served.get_headed(path, &refs(&headers)).await; | ||
assert_eq!( | ||
(status, &found["supported"]), | ||
(200, &json!(false)), | ||
harness/lexicons.mjs+3−3
| @@ -89,14 +89,14 @@ const closed = new Set([ | ||
// write: each made an array of objects in a pull of its own, with the | ||
// code on both sides. | ||
const PENDING = new Set([ | ||
"social.grain.group.pool.deleteComment#main.input.rkey", | ||
"social.grain.group.pool.deleteGallery#main.input.rkey", | ||
"social.grain.group.deleteComment#main.input.rkey", | ||
"social.grain.group.deleteGallery#main.input.rkey", | ||
"social.grain.privateGallery.addMember#main.input.space", | ||
"social.grain.privateGallery.addMember#main.input.actor", | ||
"social.grain.privateGallery.createPrivateGallery#main.input.members", | ||
"social.grain.privateGallery.createPrivateGallery#main.input.members[]", | ||
"social.grain.privateGallery.defs#photoInput.alt", | ||
"social.grain.privateGallery.getSpaceSupport#main.output.missing", | ||
"social.grain.server.getSpaceSupport#main.output.missing", | ||
"social.grain.admin.defs#reviewItem.classifiers", | ||
"social.grain.gallery.defs#cameraItem.thumbs", | ||
"social.grain.gallery.defs#locationItem.h3Cells", | ||
lexicons/social/grain/group/pool/defs.jsondeleted+0−92
| @@ -1,92 +0,0 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "social.grain.group.pool.defs", | ||
"defs": { | ||
"poolFeedItem": { | ||
"type": "object", | ||
"required": ["space", "group", "did", "rkey", "title", "photoCount", "items"], | ||
"properties": { | ||
"space": { "type": "string" }, | ||
"group": { "type": "string", "format": "did", "description": "Whose pool it is in." }, | ||
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { | ||
"type": "string", | ||
"description": "The author's Grain profile picture, a public record of their own." | ||
}, | ||
"rkey": { "type": "string" }, | ||
"title": { "type": "string" }, | ||
"description": { "type": "string" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"photoCount": { "type": "integer" }, | ||
"items": { | ||
"type": "array", | ||
"items": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" } | ||
}, | ||
"uri": { | ||
"type": "string", | ||
"format": "at-uri", | ||
"description": "What a favourite or a comment names." | ||
}, | ||
"favCount": { "type": "integer" }, | ||
"viewerFav": { "type": "string" }, | ||
"commentCount": { "type": "integer" } | ||
} | ||
}, | ||
"poolGallery": { | ||
"type": "object", | ||
"required": ["space", "did", "rkey", "title", "photoCount"], | ||
"properties": { | ||
"space": { "type": "string" }, | ||
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { | ||
"type": "string", | ||
"description": "The author's Grain profile picture, a public record of their own." | ||
}, | ||
"rkey": { "type": "string" }, | ||
"title": { "type": "string" }, | ||
"description": { "type": "string" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"photoCount": { "type": "integer" }, | ||
"cover": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" }, | ||
"uri": { | ||
"type": "string", | ||
"format": "at-uri", | ||
"description": "What a favourite or a comment names." | ||
}, | ||
"favCount": { "type": "integer" }, | ||
"viewerFav": { "type": "string" }, | ||
"commentCount": { "type": "integer" } | ||
} | ||
}, | ||
"poolComment": { | ||
"type": "object", | ||
"required": ["uri", "rkey", "text", "author"], | ||
"properties": { | ||
"uri": { "type": "string", "format": "at-uri" }, | ||
"rkey": { "type": "string" }, | ||
"text": { "type": "string" }, | ||
"facets": { | ||
"type": "array", | ||
"items": { "type": "ref", "ref": "app.bsky.richtext.facet" } | ||
}, | ||
"replyTo": { "type": "string", "format": "at-uri" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"author": { "type": "ref", "ref": "#poolAuthor" } | ||
} | ||
}, | ||
"poolAuthor": { | ||
"type": "object", | ||
"required": ["did"], | ||
"properties": { | ||
"did": { "type": "string", "format": "did" }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { "type": "string" } | ||
} | ||
} | ||
} | ||
} | ||
lexicons/social/grain/group/pool/createComment.json → lexicons/social/grain/group/createComment.jsonrenamed+1−1
| @@ -1,6 +1,6 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "social.grain.group.pool.createComment", | ||
"id": "social.grain.group.createComment", | ||
"defs": { | ||
"main": { | ||
"type": "procedure", | ||
lexicons/social/grain/group/pool/createGallery.json → lexicons/social/grain/group/createGallery.jsonrenamed+1−1
| @@ -1,6 +1,6 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "social.grain.group.pool.createGallery", | ||
"id": "social.grain.group.createGallery", | ||
"defs": { | ||
"main": { | ||
"type": "procedure", | ||
lexicons/social/grain/group/defs.json+86−0
| @@ -159,6 +159,92 @@ | ||
"type": "string" | ||
} | ||
} | ||
}, | ||
"poolFeedItem": { | ||
"type": "object", | ||
"required": ["space", "group", "did", "rkey", "title", "photoCount", "items"], | ||
"properties": { | ||
"space": { "type": "string" }, | ||
"group": { "type": "string", "format": "did", "description": "Whose pool it is in." }, | ||
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { | ||
"type": "string", | ||
"description": "The author's Grain profile picture, a public record of their own." | ||
}, | ||
"rkey": { "type": "string" }, | ||
"title": { "type": "string" }, | ||
"description": { "type": "string" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"photoCount": { "type": "integer" }, | ||
"items": { | ||
"type": "array", | ||
"items": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" } | ||
}, | ||
"uri": { | ||
"type": "string", | ||
"format": "at-uri", | ||
"description": "What a favourite or a comment names." | ||
}, | ||
"favCount": { "type": "integer" }, | ||
"viewerFav": { "type": "string" }, | ||
"commentCount": { "type": "integer" } | ||
} | ||
}, | ||
"poolGallery": { | ||
"type": "object", | ||
"required": ["space", "did", "rkey", "title", "photoCount"], | ||
"properties": { | ||
"space": { "type": "string" }, | ||
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { | ||
"type": "string", | ||
"description": "The author's Grain profile picture, a public record of their own." | ||
}, | ||
"rkey": { "type": "string" }, | ||
"title": { "type": "string" }, | ||
"description": { "type": "string" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"photoCount": { "type": "integer" }, | ||
"cover": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" }, | ||
"uri": { | ||
"type": "string", | ||
"format": "at-uri", | ||
"description": "What a favourite or a comment names." | ||
}, | ||
"favCount": { "type": "integer" }, | ||
"viewerFav": { "type": "string" }, | ||
"commentCount": { "type": "integer" } | ||
} | ||
}, | ||
"poolComment": { | ||
"type": "object", | ||
"required": ["uri", "rkey", "text", "author"], | ||
"properties": { | ||
"uri": { "type": "string", "format": "at-uri" }, | ||
"rkey": { "type": "string" }, | ||
"text": { "type": "string" }, | ||
"facets": { | ||
"type": "array", | ||
"items": { "type": "ref", "ref": "app.bsky.richtext.facet" } | ||
}, | ||
"replyTo": { "type": "string", "format": "at-uri" }, | ||
"createdAt": { "type": "string", "format": "datetime" }, | ||
"author": { "type": "ref", "ref": "#poolAuthor" } | ||
} | ||
}, | ||
"poolAuthor": { | ||
"type": "object", | ||
"required": ["did"], | ||
"properties": { | ||
"did": { "type": "string", "format": "did" }, | ||
"handle": { "type": "string" }, | ||
"displayName": { "type": "string" }, | ||
"avatar": { "type": "string" } | ||
} | ||
} | ||
} | ||
} | ||