// 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 = Result; const INTERNAL_ADMIN_PROXY_CLIENT_IP: &str = "127.0.0.1"; pub trait ApiResultExt { fn log_error(self, context: &str) -> Option; } impl ApiResultExt for ApiResult { fn log_error(self, context: &str) -> Option { 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::>() .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 { 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 { 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 { builder .send() .await .map_err(|e| ApiError::Network(e.to_string())) } async fn send_json( &self, method: Method, path: &str, body: Option<&B>, audit_log_reason: Option<&str>, ) -> ApiResult { 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( &self, path: &str, query_params: Option<&[(&str, &str)]>, ) -> ApiResult { let response = Self::send_request(self.request(Method::GET, path, query_params, None)?).await?; Self::parse_response(response).await } pub async fn post( &self, path: &str, body: Option<&serde_json::Value>, ) -> ApiResult { self.post_with_reason(path, body, None).await } pub async fn post_typed(&self, path: &str, body: &B) -> ApiResult where T: DeserializeOwned, B: Serialize + ?Sized, { self.post_typed_with_reason(path, body, None).await } pub async fn post_typed_with_reason( &self, path: &str, body: &B, audit_log_reason: Option<&str>, ) -> ApiResult 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( &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_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( &self, path: &str, body: Option<&serde_json::Value>, ) -> ApiResult { self.patch_with_reason(path, body, None).await } pub async fn patch_with_reason( &self, path: &str, body: Option<&serde_json::Value>, audit_log_reason: Option<&str>, ) -> ApiResult { 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( &self, path: &str, body: &B, audit_log_reason: Option<&str>, ) -> ApiResult 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( &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_response(response).await } pub async fn put_typed_with_reason( &self, path: &str, body: &B, audit_log_reason: Option<&str>, ) -> ApiResult 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( &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_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 { 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 { Ok(GeneratedClient::new_with_client( self.generated.baseurl(), self.generated.client().clone(), self.headers_with_reason(audit_log_reason)?, )) } pub(crate) fn generated_value(&self, value: U) -> ApiResult 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( &self, error: progenitor_client::Error, ) -> ApiError { match error.status() { Some(status) => ApiError::Http { status: status.as_u16(), message: format!("{error:?}"), }, None => ApiError::Network(format!("{error:?}")), } } async fn parse_response(response: reqwest::Response) -> ApiResult { 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 { 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 { 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::>(Value::Null) .expect_err("null is not a list") .to_string(); for (status, body) in [(200, ""), (204, "ignored body")] { let error = AdminApiClient::parse_response::>(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::(body) .expect_err("malformed JSON") .to_string(); let error = AdminApiClient::parse_response::(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::(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}")); } } } } }