tuipub: terminal Micropub client (IndieAuth PKCE login, compose and publish notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+332
@@ -0,0 +1,332 @@
|
||||
//! 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(¬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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user