333 lines
10 KiB
Rust
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(¬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(_)));
|
|
}
|
|
}
|