Files
tuipub/src/micropub.rs
T

333 lines
10 KiB
Rust

//! 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<SyndicationTarget>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Note {
pub title: Option<String>,
pub content: String,
pub tags: Vec<String>,
/// `None` leaves syndication to the server's default. `Some(vec![])` means none.
pub syndicate_to: Option<Vec<String>>,
}
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<String> {
let mut out: Vec<String> = 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<reqwest::blocking::Response, Error> {
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::<ErrorBody>(&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<reqwest::blocking::Response, Error> {
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<ServerConfig, Error> {
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::<ServerConfig>()
{
cfg.syndicate_to = s.syndicate_to;
}
Ok(cfg)
}
/// Publish a note. Returns the post's permalink.
pub fn create(&self, note: &Note) -> Result<String, Error> {
let resp = self
.http
.post(self.endpoint.clone())
.bearer_auth(&self.token)
.header("Accept", "application/json")
.json(&note.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(&note).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(_)));
}
}