use std::io;
use axum::Router;
use axum::body::Body;
use axum::http::header::{CONTENT_TYPE, COOKIE, SET_COOKIE};
use axum::http::{Method, Request};
use reqwest::header::{COOKIE as REQWEST_COOKIE, SET_COOKIE as REQWEST_SET_COOKIE};
use serde_json::Value;
use tower::ServiceExt as _;
const SESSION_COOKIE_NAME: &str = "zebflow_session";
const MAX_LOCAL_BODY_BYTES: usize = 64 * 1024 * 1024;
enum Transport {
Http(reqwest::Client),
Local(Box<Router>),
}
pub struct Instance {
base: String,
token: String,
transport: Transport,
}
impl Instance {
pub fn new(base: &str, token: &str) -> Result<Self, io::Error> {
Ok(Self {
base: base.trim_end_matches('/').to_string(),
token: token.to_string(),
transport: Transport::Http(build_http_client()?),
})
}
pub fn local(base: &str, token: &str, router: Router) -> Self {
Self {
base: base.trim_end_matches('/').to_string(),
token: token.to_string(),
transport: Transport::Local(Box::new(router)),
}
}
pub fn is_local(&self) -> bool {
matches!(self.transport, Transport::Local(_))
}
pub fn with_token(self, token: &str) -> Self {
Self {
token: token.to_string(),
..self
}
}
pub fn base_url(&self) -> &str {
&self.base
}
pub async fn reachable(&self) -> bool {
match &self.transport {
Transport::Local(_) => true,
Transport::Http(http) => http
.get(format!("{}/health", self.base))
.send()
.await
.map(|response| response.status().is_success())
.unwrap_or(false),
}
}
pub async fn get_json(&self, path: &str) -> Result<Value, io::Error> {
self.send(Method::GET, path, None).await
}
pub async fn post_json(&self, path: &str, body: &Value) -> Result<Value, io::Error> {
self.send(Method::POST, path, Some(body)).await
}
pub async fn authenticate(
&self,
identifier: &str,
password: &str,
) -> Result<String, io::Error> {
let form =
serde_urlencoded::to_string([("identifier", identifier), ("password", password)])
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))?;
let (status, cookies, _) = match &self.transport {
Transport::Http(http) => {
let response = http
.post(format!("{}/login", self.base))
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(form)
.send()
.await
.map_err(transport_error)?;
let status = response.status().as_u16();
let cookies = response
.headers()
.get_all(REQWEST_SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok().map(str::to_string))
.collect::<Vec<_>>();
(status, cookies, String::new())
}
Transport::Local(router) => {
let request = Request::builder()
.method(Method::POST)
.uri("/login")
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.map_err(|err| io::Error::other(err.to_string()))?;
let response = router
.as_ref()
.clone()
.oneshot(request)
.await
.map_err(|err| io::Error::other(err.to_string()))?;
let status = response.status().as_u16();
let cookies = response
.headers()
.get_all(SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok().map(str::to_string))
.collect::<Vec<_>>();
(status, cookies, String::new())
}
};
match cookies
.iter()
.find_map(|raw| session_token_from_set_cookie(raw))
{
Some(token) if !token.is_empty() => Ok(token),
_ if status == 401 => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"invalid credentials",
)),
_ => Err(io::Error::other(format!(
"login did not return a session cookie (HTTP {status})"
))),
}
}
async fn send(
&self,
method: Method,
path: &str,
body: Option<&Value>,
) -> Result<Value, io::Error> {
let (status, text) = match &self.transport {
Transport::Http(http) => {
let mut request = http
.request(method, format!("{}{path}", self.base))
.header(REQWEST_COOKIE, self.cookie());
if let Some(body) = body {
request = request.json(body);
}
let response = request.send().await.map_err(transport_error)?;
let status = response.status().as_u16();
(status, response.text().await.map_err(transport_error)?)
}
Transport::Local(router) => {
let mut builder = Request::builder()
.method(method)
.uri(path)
.header(COOKIE, self.cookie());
if body.is_some() {
builder = builder.header(CONTENT_TYPE, "application/json");
}
let payload = match body {
Some(value) => Body::from(serde_json::to_vec(value).map_err(|err| {
io::Error::new(io::ErrorKind::InvalidData, err.to_string())
})?),
None => Body::empty(),
};
let request = builder
.body(payload)
.map_err(|err| io::Error::other(err.to_string()))?;
let response = router
.as_ref()
.clone()
.oneshot(request)
.await
.map_err(|err| io::Error::other(err.to_string()))?;
let status = response.status().as_u16();
let bytes = axum::body::to_bytes(response.into_body(), MAX_LOCAL_BODY_BYTES)
.await
.map_err(|err| io::Error::other(err.to_string()))?;
(status, String::from_utf8_lossy(&bytes).into_owned())
}
};
read_json(status, &text)
}
pub async fn end_session(&self) -> bool {
match &self.transport {
Transport::Http(http) => http
.post(format!("{}/logout", self.base))
.header(REQWEST_COOKIE, self.cookie())
.send()
.await
.map(|response| {
response.status().is_success() || response.status().is_redirection()
})
.unwrap_or(false),
Transport::Local(_) => true,
}
}
pub async fn delete_json(&self, path: &str, body: &Value) -> Result<Value, io::Error> {
self.send(Method::DELETE, path, Some(body)).await
}
fn cookie(&self) -> String {
format!("{SESSION_COOKIE_NAME}={}", self.token)
}
}
pub async fn login(base: &str, identifier: &str, password: &str) -> Result<String, io::Error> {
Instance::new(base, "")?
.authenticate(identifier, password)
.await
}
fn build_http_client() -> Result<reqwest::Client, io::Error> {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| io::Error::other(err.to_string()))
}
fn session_token_from_set_cookie(raw: &str) -> Option<String> {
let first = raw.split(';').next()?.trim();
let (name, value) = first.split_once('=')?;
(name.trim() == SESSION_COOKIE_NAME).then(|| value.trim().to_string())
}
fn read_json(status: u16, body: &str) -> Result<Value, io::Error> {
let parsed = serde_json::from_str::<Value>(body).ok();
let success = (200..300).contains(&status);
if success
&& let Some(value) = parsed.as_ref()
&& value.get("ok").and_then(Value::as_bool) != Some(false)
{
return Ok(parsed.expect("checked above"));
}
let message = parsed
.as_ref()
.and_then(|value| value.get("error"))
.map(describe_api_error)
.unwrap_or_else(|| body.trim().chars().take(300).collect());
let kind = match status {
401 => io::ErrorKind::PermissionDenied,
403 => io::ErrorKind::PermissionDenied,
404 => io::ErrorKind::NotFound,
_ => io::ErrorKind::Other,
};
Err(io::Error::new(kind, format!("HTTP {status}: {message}")))
}
fn describe_api_error(error: &Value) -> String {
if let Some(text) = error.as_str() {
return text.to_string();
}
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("request failed");
match error.get("code").and_then(Value::as_str) {
Some(code) if !code.is_empty() => format!("{message} ({code})"),
_ => message.to_string(),
}
}
fn transport_error(err: reqwest::Error) -> io::Error {
io::Error::new(io::ErrorKind::ConnectionRefused, err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_token_is_read_from_the_platform_cookie_only() {
assert_eq!(
session_token_from_set_cookie("zebflow_session=abc123; Path=/; HttpOnly"),
Some("abc123".to_string())
);
assert_eq!(session_token_from_set_cookie("other=abc123; Path=/"), None);
}
#[test]
fn api_errors_read_as_message_and_code() {
assert_eq!(
describe_api_error(&serde_json::json!("login required")),
"login required"
);
assert_eq!(
describe_api_error(
&serde_json::json!({"code": "HUB_ASSET_MISSING", "message": "no such package"})
),
"no such package (HUB_ASSET_MISSING)"
);
}
}