244 lines
9.1 KiB
Rust
244 lines
9.1 KiB
Rust
//! Mattermost's REST calls, one connection per request, with the token in the `Authorization`
|
|
//! header. A 429 waits as asked; a 5xx is tried twice more; a 401 or 403 is `Auth` at once.
|
|
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use bxcore::secrets::Secret;
|
|
use serde::Deserialize;
|
|
|
|
use crate::config::valid_id;
|
|
use crate::http::{self, Response, rate_limit_wait};
|
|
use crate::mm::{Me, MmError, Post, Since, json, since_list};
|
|
use crate::net::Connector;
|
|
use crate::ws::conn::host_header;
|
|
|
|
/// The pause before trying a call again after a 5xx.
|
|
pub const RETRY_5XX: Duration = Duration::from_millis(500);
|
|
/// How many times a call is tried again after a 5xx, and waits after a 429.
|
|
pub const RETRIES: u32 = 2;
|
|
/// How much of an error body is kept for the message.
|
|
const BODY_KEPT: usize = 200;
|
|
|
|
pub struct Client {
|
|
connector: Connector,
|
|
token: Secret,
|
|
timeout: Duration,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Channel {
|
|
id: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct User {
|
|
username: String,
|
|
}
|
|
|
|
/// A Mattermost username: 1 to 64 characters of `a-z`, `0-9`, `.`, `-` and `_`.
|
|
fn valid_username(name: &str) -> bool {
|
|
(1..=64).contains(&name.len())
|
|
&& name.bytes().all(|b| {
|
|
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_')
|
|
})
|
|
}
|
|
|
|
impl Client {
|
|
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
|
|
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
|
|
// Store the three.
|
|
Client {
|
|
connector,
|
|
token,
|
|
timeout,
|
|
}
|
|
}
|
|
|
|
pub fn connector(&self) -> &Connector {
|
|
// The Connector.
|
|
&self.connector
|
|
}
|
|
|
|
pub fn token(&self) -> &Secret {
|
|
// The Secret.
|
|
&self.token
|
|
}
|
|
|
|
fn once(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Response, MmError> {
|
|
// 1. `self.connector.connect(self.timeout)`, then `set_read_timeout(Some(self.timeout))`.
|
|
let mut stream = self
|
|
.connector
|
|
.connect(self.timeout)
|
|
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
|
stream
|
|
.set_read_timeout(Some(self.timeout))
|
|
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
|
// 2. Headers: Authorization "Bearer <token>" (the only use of `expose`), Accept
|
|
// "application/json", and Content-Type "application/json" when there is a body. Host is
|
|
// `host_header(self.connector.server())`.
|
|
let auth = format!("Bearer {}", self.token.expose());
|
|
let mut headers: Vec<(&str, &str)> = vec![
|
|
("Authorization", auth.as_str()),
|
|
("Accept", "application/json"),
|
|
];
|
|
if body.is_some() {
|
|
headers.push(("Content-Type", "application/json"));
|
|
}
|
|
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
|
|
http::request(
|
|
&mut stream,
|
|
method,
|
|
&host_header(self.connector.server()),
|
|
path,
|
|
&headers,
|
|
body,
|
|
)
|
|
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))
|
|
}
|
|
|
|
/// One call, tried again as the module comment says; the body of a 2xx answer.
|
|
fn call(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Vec<u8>, MmError> {
|
|
// Loop over `once`: 2xx -> the body. 401 or 403 -> Auth(status) at once. 429 -> after
|
|
// RETRIES waits already, RateLimited(wait); else sleep `rate_limit_wait(&head,
|
|
// SystemTime::now())` and try again. 5xx -> up to RETRIES more tries, sleeping RETRY_5XX
|
|
// before each. Anything else -> Status(status, the first BODY_KEPT characters of the body,
|
|
// lossy UTF-8).
|
|
let mut tries: u32 = 0;
|
|
loop {
|
|
let answer = self.once(method, path, body)?;
|
|
let status = answer.head.status;
|
|
if (200..300).contains(&status) {
|
|
return Ok(answer.body);
|
|
}
|
|
match status {
|
|
401 | 403 => return Err(MmError::Auth(status)),
|
|
429 => {
|
|
let wait = rate_limit_wait(&answer.head, SystemTime::now());
|
|
if tries < RETRIES {
|
|
tries += 1;
|
|
std::thread::sleep(wait);
|
|
continue;
|
|
}
|
|
return Err(MmError::RateLimited(wait));
|
|
}
|
|
_ if status >= 500 => {
|
|
if tries < RETRIES {
|
|
tries += 1;
|
|
std::thread::sleep(RETRY_5XX);
|
|
continue;
|
|
}
|
|
return Err(status_error(status, &answer.body));
|
|
}
|
|
_ => return Err(status_error(status, &answer.body)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `GET /api/v4/users/me`: who this token is.
|
|
pub fn me(&self) -> Result<Me, MmError> {
|
|
// GET /api/v4/users/me into Me; an id that is not `valid_id` or an empty username is Json.
|
|
let body = self.call("GET", "/api/v4/users/me", None)?;
|
|
let me: Me = json(&body)?;
|
|
if !valid_id(&me.id) || me.username.is_empty() {
|
|
return Err(MmError::Json(
|
|
"the user me has no id or no username".to_string(),
|
|
));
|
|
}
|
|
Ok(me)
|
|
}
|
|
|
|
/// `POST /api/v4/posts`: a post in `channel`, in the thread of `root` (empty: top level).
|
|
pub fn create_post(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError> {
|
|
// POST /api/v4/posts with {"channel_id", "root_id", "message"}; the answer is a Post,
|
|
// `check`ed.
|
|
let payload = serde_json::json!({
|
|
"channel_id": channel,
|
|
"root_id": root,
|
|
"message": message
|
|
})
|
|
.to_string();
|
|
let body = self.call("POST", "/api/v4/posts", Some(payload.as_bytes()))?;
|
|
let post: Post = json(&body)?;
|
|
post.check()
|
|
}
|
|
|
|
/// `GET /api/v4/channels/{channel}/posts?since=<ms>`: the posts created after `since`.
|
|
pub fn posts_since(&self, channel: &str, since: i64) -> Result<Since, MmError> {
|
|
// A channel that is not `valid_id` is Json, and nothing is sent. Otherwise GET
|
|
// /api/v4/channels/<channel>/posts?since=<since>, through `since_list`.
|
|
if !valid_id(channel) {
|
|
return Err(MmError::Json(
|
|
"a channel path must be a Mattermost id".to_string(),
|
|
));
|
|
}
|
|
let body = self.call(
|
|
"GET",
|
|
&format!("/api/v4/channels/{channel}/posts?since={since}"),
|
|
None,
|
|
)?;
|
|
since_list(&body, since)
|
|
}
|
|
|
|
/// `POST /api/v4/channels/direct`: the id of the direct channel between two users.
|
|
pub fn direct_channel(&self, a: &str, b: &str) -> Result<String, MmError> {
|
|
// POST /api/v4/channels/direct with the JSON array [a, b]; the answer has an "id", which
|
|
// must be `valid_id` (else Json).
|
|
let payload = serde_json::to_vec(&serde_json::json!([a, b]))
|
|
.map_err(|e| MmError::Json(e.to_string()))?;
|
|
let body = self.call("POST", "/api/v4/channels/direct", Some(payload.as_slice()))?;
|
|
let channel: Channel = json(&body)?;
|
|
if !valid_id(&channel.id) {
|
|
return Err(MmError::Json("the direct channel has no id".to_string()));
|
|
}
|
|
Ok(channel.id)
|
|
}
|
|
|
|
/// `PUT /api/v4/posts/{post}/patch`: replace the post's text.
|
|
pub fn patch_post(&self, post: &str, message: &str) -> Result<(), MmError> {
|
|
// A post that is not `valid_id` is Json, and nothing is sent. Otherwise PUT
|
|
// /api/v4/posts/<post>/patch with {"message": message}, through `call`; the answer is a
|
|
// Post, `check`ed and dropped.
|
|
if !valid_id(post) {
|
|
return Err(MmError::Json(
|
|
"a post path must be a Mattermost id".to_string(),
|
|
));
|
|
}
|
|
let payload = serde_json::json!({ "message": message }).to_string();
|
|
let body = self.call(
|
|
"PUT",
|
|
&format!("/api/v4/posts/{post}/patch"),
|
|
Some(payload.as_bytes()),
|
|
)?;
|
|
json::<Post>(&body)?.check().map(|_| ())
|
|
}
|
|
|
|
/// `GET /api/v4/users/{user}`: the user's username.
|
|
pub fn username(&self, user: &str) -> Result<String, MmError> {
|
|
// A user that is not `valid_id` is Json. Otherwise GET /api/v4/users/<user>; a username
|
|
// that is not `valid_username` is Json.
|
|
if !valid_id(user) {
|
|
return Err(MmError::Json(
|
|
"a user path must be a Mattermost id".to_string(),
|
|
));
|
|
}
|
|
let body = self.call("GET", &format!("/api/v4/users/{user}"), None)?;
|
|
let user: User = json(&body)?;
|
|
if !valid_username(&user.username) {
|
|
return Err(MmError::Json("the user has no valid username".to_string()));
|
|
}
|
|
Ok(user.username)
|
|
}
|
|
}
|
|
|
|
fn status_error(status: u16, body: &[u8]) -> MmError {
|
|
// Any non-2xx, non-4xx-that-isn't-401/403/429/5xx: the status and the first `BODY_KEPT`
|
|
// characters, lossy UTF-8.
|
|
MmError::Status(
|
|
status,
|
|
String::from_utf8_lossy(body)
|
|
.chars()
|
|
.take(BODY_KEPT)
|
|
.collect(),
|
|
)
|
|
}
|