//! Micropub client: `q=config` and create. use reqwest::{StatusCode, blocking::Client as Http}; use serde::Deserialize; use serde_json::{Value, json}; use url::Url; #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct SyndicationTarget { pub uid: String, pub name: String, } #[derive(Debug, Clone, Default, Deserialize)] pub struct ServerConfig { #[serde(rename = "syndicate-to", default)] pub syndicate_to: Vec, } #[derive(Debug, Clone, Default, PartialEq)] pub struct Note { pub title: Option, pub content: String, pub tags: Vec, /// `None` leaves syndication to the server's default. `Some(vec![])` means none. pub syndicate_to: Option>, } impl Note { /// Micropub JSON create body. JSON (not form encoding) is the only way to send /// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere". pub fn to_json(&self) -> Value { let mut props = serde_json::Map::new(); props.insert("content".into(), json!([self.content])); if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) { props.insert("name".into(), json!([t])); } if !self.tags.is_empty() { props.insert("category".into(), json!(self.tags)); } let mut body = json!({ "type": ["h-entry"], "properties": props }); if let Some(targets) = &self.syndicate_to { body["mp-syndicate-to"] = json!(targets); } body } } /// Split "a, #b,, a" into ["a", "b"]. pub fn parse_tags(input: &str) -> Vec { let mut out: Vec = Vec::new(); for tag in input.split(',') { let tag = tag.trim().trim_start_matches('#').trim(); if !tag.is_empty() && !out.iter().any(|t| t.eq_ignore_ascii_case(tag)) { out.push(tag.to_string()); } } out } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("not authorized: {0}. Run `tuipub login` again.")] Unauthorized(String), #[error("{error}: {description}")] Server { status: StatusCode, error: String, description: String, }, #[error("server returned {0} without a Location header")] NoLocation(StatusCode), #[error("network error: {0}")] Http(#[from] reqwest::Error), } #[derive(Deserialize)] struct ErrorBody { error: String, #[serde(default)] error_description: String, } pub struct Client { http: Http, endpoint: Url, token: String, } impl Client { pub fn new(http: Http, endpoint: Url, token: String) -> Self { Client { http, endpoint, token, } } fn check(resp: reqwest::blocking::Response) -> Result { let status = resp.status(); if status.is_success() { return Ok(resp); } let body = resp.text().unwrap_or_default(); let (error, description) = match serde_json::from_str::(&body) { Ok(b) => (b.error, b.error_description), Err(_) => ( status.canonical_reason().unwrap_or("error").to_string(), body.chars().take(200).collect(), ), }; if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || error == "insufficient_scope" { let why = if description.is_empty() { error } else { description }; return Err(Error::Unauthorized(why)); } Err(Error::Server { status, error, description, }) } fn query(&self, q: &str) -> Result { let mut url = self.endpoint.clone(); url.query_pairs_mut().append_pair("q", q); let resp = self .http .get(url) .bearer_auth(&self.token) .header("Accept", "application/json") .send()?; Self::check(resp) } pub fn config(&self) -> Result { let mut cfg: ServerConfig = self.query("config")?.json()?; // syndicate-to is optional in q=config; some servers only answer q=syndicate-to. if cfg.syndicate_to.is_empty() && let Ok(resp) = self.query("syndicate-to") && let Ok(s) = resp.json::() { cfg.syndicate_to = s.syndicate_to; } Ok(cfg) } /// Publish a note. Returns the post's permalink. pub fn create(&self, note: &Note) -> Result { let resp = self .http .post(self.endpoint.clone()) .bearer_auth(&self.token) .header("Accept", "application/json") .json(¬e.to_json()) .send()?; let resp = Self::check(resp)?; let status = resp.status(); let location = resp .headers() .get("location") .and_then(|v| v.to_str().ok()) .ok_or(Error::NoLocation(status))?; Ok(self .endpoint .join(location) .map(|u| u.to_string()) .unwrap_or_else(|_| location.to_string())) } } #[cfg(test)] mod tests { use super::*; use httpmock::prelude::*; fn client(server: &MockServer) -> Client { Client::new( Http::new(), Url::parse(&server.url("/micropub")).unwrap(), "tok".into(), ) } #[test] fn tags_are_trimmed_and_deduped() { assert_eq!( parse_tags(" rust, #tui,,Rust , indieweb "), vec!["rust", "tui", "indieweb"] ); assert!(parse_tags(" , ").is_empty()); } #[test] fn note_json_shape() { let note = Note { title: Some("Hi".into()), content: "Hello **world**".into(), tags: vec!["a".into()], syndicate_to: Some(vec![]), }; assert_eq!( note.to_json(), json!({ "type": ["h-entry"], "properties": {"content": ["Hello **world**"], "name": ["Hi"], "category": ["a"]}, "mp-syndicate-to": [] }) ); let bare = Note { content: "x".into(), title: Some(String::new()), ..Default::default() }; assert_eq!( bare.to_json(), json!({"type": ["h-entry"], "properties": {"content": ["x"]}}) ); } #[test] fn config_reads_targets() { let server = MockServer::start(); let m = server.mock(|when, then| { when.method(GET) .path("/micropub") .query_param("q", "config") .header("authorization", "Bearer tok"); then.status(200).json_body(json!({ "media-endpoint": "https://example.com/micropub/media", "syndicate-to": [{"uid": "gotosocial", "name": "GoToSocial"}], "post-types": [] })); }); let cfg = client(&server).config().unwrap(); m.assert(); assert_eq!( cfg.syndicate_to, vec![SyndicationTarget { uid: "gotosocial".into(), name: "GoToSocial".into() }] ); } #[test] fn config_falls_back_to_syndicate_to_query() { let server = MockServer::start(); server.mock(|when, then| { when.method(GET) .path("/micropub") .query_param("q", "config"); then.status(200).json_body(json!({})); }); server.mock(|when, then| { when.method(GET) .path("/micropub") .query_param("q", "syndicate-to"); then.status(200) .json_body(json!({"syndicate-to": [{"uid": "x", "name": "X"}]})); }); assert_eq!(client(&server).config().unwrap().syndicate_to.len(), 1); } #[test] fn create_posts_json_and_resolves_location() { let server = MockServer::start(); let note = Note { content: "hello".into(), syndicate_to: Some(vec!["gts".into()]), ..Default::default() }; let m = server.mock(|when, then| { when.method(POST) .path("/micropub") .header("authorization", "Bearer tok") .json_body(note.to_json()); then.status(201).header("Location", "/notes/hello"); }); let url = client(&server).create(¬e).unwrap(); m.assert(); assert_eq!(url, server.url("/notes/hello")); } #[test] fn create_maps_errors() { let server = MockServer::start(); server.mock(|when, then| { when.method(POST).path("/micropub"); then.status(403).json_body(json!({"error": "insufficient_scope", "error_description": "Token lacks create scope"})); }); let err = client(&server) .create(&Note { content: "x".into(), ..Default::default() }) .unwrap_err(); assert!( matches!(err, Error::Unauthorized(ref d) if d.contains("create scope")), "{err}" ); let server = MockServer::start(); server.mock(|when, then| { when.method(POST).path("/micropub"); then.status(400).json_body( json!({"error": "invalid_request", "error_description": "Content is required"}), ); }); let err = client(&server).create(&Note::default()).unwrap_err(); assert_eq!(err.to_string(), "invalid_request: Content is required"); let server = MockServer::start(); server.mock(|when, then| { when.method(POST).path("/micropub"); then.status(201); }); let err = client(&server) .create(&Note { content: "x".into(), ..Default::default() }) .unwrap_err(); assert!(matches!(err, Error::NoLocation(_))); } }