mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 05:28:28 +09:00
518 lines
16 KiB
Rust
518 lines
16 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
use crate::api::generated::GeneratedClient;
|
||
use crate::{config::AdminConfig, session::Session};
|
||
use progenitor_client::ClientInfo;
|
||
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
|
||
use reqwest::{Method, RequestBuilder};
|
||
use serde::Serialize;
|
||
use serde::de::DeserializeOwned;
|
||
|
||
#[derive(Debug)]
|
||
pub enum ApiError {
|
||
Network(String),
|
||
Http { status: u16, message: String },
|
||
Parse(String),
|
||
}
|
||
|
||
pub type ApiResult<T> = Result<T, ApiError>;
|
||
|
||
const INTERNAL_ADMIN_PROXY_CLIENT_IP: &str = "127.0.0.1";
|
||
|
||
pub trait ApiResultExt<T> {
|
||
fn log_error(self, context: &str) -> Option<T>;
|
||
}
|
||
|
||
impl<T> ApiResultExt<T> for ApiResult<T> {
|
||
fn log_error(self, context: &str) -> Option<T> {
|
||
match self {
|
||
Ok(value) => Some(value),
|
||
Err(error) => {
|
||
tracing::warn!(%context, %error, "admin API request failed");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct AdminApiClient {
|
||
generated: GeneratedClient,
|
||
}
|
||
|
||
impl AdminApiClient {
|
||
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
|
||
let generated = GeneratedClient::new_with_client(
|
||
&config.api_endpoint,
|
||
http_client.clone(),
|
||
build_session_headers(config, session),
|
||
);
|
||
Self { generated }
|
||
}
|
||
|
||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||
let query = query_params
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter(|(_, value)| !value.is_empty())
|
||
.map(|(key, value)| {
|
||
format!(
|
||
"{}={}",
|
||
urlencoding::encode(key),
|
||
urlencoding::encode(value)
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("&");
|
||
if !query.is_empty() {
|
||
url.push('?');
|
||
url.push_str(&query);
|
||
}
|
||
url
|
||
}
|
||
|
||
fn request(
|
||
&self,
|
||
method: Method,
|
||
path: &str,
|
||
query_params: Option<&[(&str, &str)]>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<RequestBuilder> {
|
||
let url = self.build_url(path, query_params);
|
||
Ok(self
|
||
.generated
|
||
.client()
|
||
.request(method, &url)
|
||
.header("Content-Type", "application/json")
|
||
.headers(self.headers_with_reason(audit_log_reason)?))
|
||
}
|
||
|
||
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
|
||
let mut headers = self.generated.inner().clone();
|
||
if let Some(reason) = audit_log_reason {
|
||
headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?);
|
||
}
|
||
Ok(headers)
|
||
}
|
||
|
||
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
|
||
builder
|
||
.send()
|
||
.await
|
||
.map_err(|e| ApiError::Network(e.to_string()))
|
||
}
|
||
|
||
async fn send_json<B: Serialize + ?Sized>(
|
||
&self,
|
||
method: Method,
|
||
path: &str,
|
||
body: Option<&B>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<reqwest::Response> {
|
||
let builder = self.request(method, path, None, audit_log_reason)?;
|
||
let builder = match body {
|
||
Some(body) => builder.json(body),
|
||
None => builder,
|
||
};
|
||
Self::send_request(builder).await
|
||
}
|
||
|
||
pub async fn get<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
query_params: Option<&[(&str, &str)]>,
|
||
) -> ApiResult<T> {
|
||
let response =
|
||
Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn post<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
) -> ApiResult<T> {
|
||
self.post_with_reason(path, body, None).await
|
||
}
|
||
|
||
pub async fn post_typed<T, B>(&self, path: &str, body: &B) -> ApiResult<T>
|
||
where
|
||
T: DeserializeOwned,
|
||
B: Serialize + ?Sized,
|
||
{
|
||
self.post_typed_with_reason(path, body, None).await
|
||
}
|
||
|
||
pub async fn post_typed_with_reason<T, B>(
|
||
&self,
|
||
path: &str,
|
||
body: &B,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T>
|
||
where
|
||
T: DeserializeOwned,
|
||
B: Serialize + ?Sized,
|
||
{
|
||
let response = self
|
||
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T> {
|
||
let response = self
|
||
.send_json(Method::POST, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||
self.post_void_with_reason(path, body, None).await
|
||
}
|
||
|
||
pub async fn post_void_with_reason(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<()> {
|
||
let response = self
|
||
.send_json(Method::POST, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_void_response(response).await
|
||
}
|
||
|
||
pub async fn patch<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
) -> ApiResult<T> {
|
||
self.patch_with_reason(path, body, None).await
|
||
}
|
||
|
||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T> {
|
||
let response = self
|
||
.send_json(Method::PATCH, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn patch_typed_with_reason<T, B>(
|
||
&self,
|
||
path: &str,
|
||
body: &B,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T>
|
||
where
|
||
T: DeserializeOwned,
|
||
B: Serialize + ?Sized,
|
||
{
|
||
let response = self
|
||
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T> {
|
||
let response = self
|
||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn put_typed_with_reason<T, B>(
|
||
&self,
|
||
path: &str,
|
||
body: &B,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T>
|
||
where
|
||
T: DeserializeOwned,
|
||
B: Serialize + ?Sized,
|
||
{
|
||
let response = self
|
||
.send_json(Method::PUT, path, Some(body), audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
pub async fn put_void_with_reason(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<()> {
|
||
let response = self
|
||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_void_response(response).await
|
||
}
|
||
|
||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||
self.delete_void_with_reason(path, body, None).await
|
||
}
|
||
|
||
pub async fn delete_void_with_reason(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<()> {
|
||
let response = self
|
||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_void_response(response).await
|
||
}
|
||
|
||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||
&self,
|
||
path: &str,
|
||
body: Option<&serde_json::Value>,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<T> {
|
||
let response = self
|
||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||
.await?;
|
||
Self::parse_response(response).await
|
||
}
|
||
|
||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||
Self::check_response_status(response).await.map(drop)
|
||
}
|
||
|
||
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
|
||
if response.status().is_success() {
|
||
return Ok(response);
|
||
}
|
||
let status = response.status().as_u16();
|
||
let message = response.text().await.map_err(|error| {
|
||
ApiError::Network(format!("failed to read error response body: {error}"))
|
||
})?;
|
||
Err(ApiError::Http { status, message })
|
||
}
|
||
|
||
pub(crate) fn generated(&self) -> &GeneratedClient {
|
||
&self.generated
|
||
}
|
||
|
||
pub(crate) fn generated_with_reason(
|
||
&self,
|
||
audit_log_reason: Option<&str>,
|
||
) -> ApiResult<GeneratedClient> {
|
||
Ok(GeneratedClient::new_with_client(
|
||
self.generated.baseurl(),
|
||
self.generated.client().clone(),
|
||
self.headers_with_reason(audit_log_reason)?,
|
||
))
|
||
}
|
||
|
||
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
|
||
where
|
||
T: DeserializeOwned,
|
||
U: Serialize,
|
||
{
|
||
let json = serde_json::to_value(value).map_err(|e| ApiError::Parse(e.to_string()))?;
|
||
serde_json::from_value(json).map_err(|e| ApiError::Parse(e.to_string()))
|
||
}
|
||
|
||
pub(crate) fn generated_error<E: std::fmt::Debug>(
|
||
&self,
|
||
error: progenitor_client::Error<E>,
|
||
) -> ApiError {
|
||
match error.status() {
|
||
Some(status) => ApiError::Http {
|
||
status: status.as_u16(),
|
||
message: format!("{error:?}"),
|
||
},
|
||
None => ApiError::Network(format!("{error:?}")),
|
||
}
|
||
}
|
||
|
||
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
|
||
let response = Self::check_response_status(response).await?;
|
||
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||
String::new()
|
||
} else {
|
||
response
|
||
.text()
|
||
.await
|
||
.map_err(|e| ApiError::Network(e.to_string()))?
|
||
};
|
||
if text.is_empty() {
|
||
return serde_json::from_value(serde_json::Value::Null)
|
||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||
}
|
||
serde_json::from_str(&text).map_err(|e| ApiError::Parse(e.to_string()))
|
||
}
|
||
}
|
||
|
||
fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap {
|
||
let mut headers = HeaderMap::new();
|
||
let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
|
||
.expect("failed to build generated API Authorization header");
|
||
auth_value.set_sensitive(true);
|
||
headers.insert(AUTHORIZATION, auth_value);
|
||
headers.extend(build_proxy_client_ip_headers(config));
|
||
headers
|
||
}
|
||
|
||
pub(crate) fn with_proxy_client_ip_header(
|
||
builder: RequestBuilder,
|
||
config: &AdminConfig,
|
||
) -> RequestBuilder {
|
||
match proxy_client_ip_header_name(config) {
|
||
Some(name) => builder.header(name, INTERNAL_ADMIN_PROXY_CLIENT_IP),
|
||
None => builder,
|
||
}
|
||
}
|
||
|
||
fn build_proxy_client_ip_headers(config: &AdminConfig) -> HeaderMap {
|
||
let mut headers = HeaderMap::new();
|
||
if let Some(name) = proxy_client_ip_header_name(config) {
|
||
headers.insert(
|
||
name,
|
||
HeaderValue::from_static(INTERNAL_ADMIN_PROXY_CLIENT_IP),
|
||
);
|
||
}
|
||
headers
|
||
}
|
||
|
||
fn proxy_client_ip_header_name(config: &AdminConfig) -> Option<HeaderName> {
|
||
if !config.proxy.trust_client_ip_header {
|
||
return None;
|
||
}
|
||
match HeaderName::from_bytes(config.proxy.client_ip_header_name.as_bytes()) {
|
||
Ok(name) => Some(name),
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
header = %config.proxy.client_ip_header_name,
|
||
%error,
|
||
"invalid admin client IP header name; internal API calls may be rejected"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for ApiError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::Network(msg) => write!(f, "network error: {msg}"),
|
||
Self::Http { status, message } => write!(f, "HTTP {status}: {message}"),
|
||
Self::Parse(msg) => write!(f, "parse error: {msg}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn audit_log_reason_header(reason: &str) -> ApiResult<HeaderValue> {
|
||
let mut value = HeaderValue::from_bytes(reason.as_bytes())
|
||
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
|
||
value.set_sensitive(true);
|
||
Ok(value)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::{Value, json};
|
||
|
||
#[test]
|
||
fn audit_log_reason_header_keeps_utf8_bytes() {
|
||
let reason = "§ 3 Regel – wiederholt 日本";
|
||
let value = audit_log_reason_header(reason).expect("valid reason header");
|
||
assert_eq!(value.as_bytes(), reason.as_bytes());
|
||
assert!(value.is_sensitive());
|
||
assert!(audit_log_reason_header("line one\nline two").is_err());
|
||
}
|
||
|
||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||
axum::http::Response::builder()
|
||
.status(status)
|
||
.body(body)
|
||
.expect("valid response")
|
||
.into()
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn parses_successful_json_and_empty_responses() {
|
||
for (status, body, expected) in [
|
||
(200, r#"{"value":1}"#, json!({"value": 1})),
|
||
(201, "[1,2]", json!([1, 2])),
|
||
(202, "null", Value::Null),
|
||
(200, "", Value::Null),
|
||
(204, "ignored body", Value::Null),
|
||
] {
|
||
let actual: Value = AdminApiClient::parse_response(response(status, body))
|
||
.await
|
||
.expect("valid response body");
|
||
assert_eq!(actual, expected, "HTTP {status}: {body}");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn empty_responses_preserve_null_deserialization_errors() {
|
||
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
|
||
.expect_err("null is not a list")
|
||
.to_string();
|
||
for (status, body) in [(200, ""), (204, "ignored body")] {
|
||
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
|
||
.await
|
||
.expect_err("missing list");
|
||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn malformed_json_preserves_deserialization_errors() {
|
||
for body in [" ", "{", "not JSON"] {
|
||
let expected = serde_json::from_str::<Value>(body)
|
||
.expect_err("malformed JSON")
|
||
.to_string();
|
||
let error = AdminApiClient::parse_response::<Value>(response(200, body))
|
||
.await
|
||
.expect_err("malformed response");
|
||
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn void_responses_do_not_parse_successful_bodies() {
|
||
for status in [200, 201, 202, 204] {
|
||
AdminApiClient::parse_void_response(response(status, "not JSON"))
|
||
.await
|
||
.expect("successful void response");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn typed_and_void_responses_preserve_http_errors() {
|
||
for status in [302, 400, 403, 404, 500] {
|
||
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
|
||
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
|
||
.await
|
||
.map(drop);
|
||
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
|
||
for result in [typed, empty] {
|
||
let error = result.expect_err("unsuccessful response");
|
||
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|