use std::collections::BTreeMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
use crate::provider::{CachedToken, CliProvider};
pub struct Session {
pub base_url: String,
pub api_key: String,
pub headers: Vec<(String, String)>,
pub provider: Option<CliProvider>,
}
impl Drop for Session {
fn drop(&mut self) {
self.api_key.zeroize();
}
}
pub struct AppState {
pub client: reqwest::Client,
pub session: Mutex<Option<Session>>,
pub token_cache: Mutex<Option<CachedToken>>,
}
impl Default for AppState {
fn default() -> Self {
Self {
client: build_client(),
session: Mutex::new(None),
token_cache: Mutex::new(None),
}
}
}
#[derive(Serialize, Clone)]
pub struct ConnectionInfo {
pub base_url: String,
pub header_names: Vec<String>,
}
#[derive(Serialize)]
pub struct ProxyResponse {
pub status: u16,
pub ok: bool,
pub headers: BTreeMap<String, String>,
pub json: Option<serde_json::Value>,
pub text: String,
}
#[derive(Deserialize)]
pub struct RequestArgs {
pub method: String,
pub path: String,
#[serde(default)]
pub query: Vec<(String, String)>,
#[serde(default)]
pub body: Option<serde_json::Value>,
}
pub fn normalize_base_url(base_url: &str) -> String {
base_url.trim().trim_end_matches('/').to_string()
}
pub fn join_url(base_url: &str, path: &str) -> String {
if path.is_empty() || path == "/" {
format!("{base_url}/")
} else if path.starts_with('/') {
format!("{base_url}{path}")
} else {
format!("{base_url}/{path}")
}
}
pub fn apply_auth(
mut req: reqwest::RequestBuilder,
api_key: &str,
headers: &[(String, String)],
) -> reqwest::RequestBuilder {
if !api_key.trim().is_empty() {
req = req.bearer_auth(api_key);
}
for (name, value) in headers {
if !name.trim().is_empty() {
req = req.header(name, value);
}
}
req
}
pub fn parse_method(method: &str) -> Result<reqwest::Method, String> {
method
.parse::<reqwest::Method>()
.map_err(|_| format!("Invalid HTTP method: {method}"))
}
pub async fn execute(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
extra_headers: &[(String, String)],
args: &RequestArgs,
) -> Result<ProxyResponse, String> {
let method = parse_method(&args.method)?;
let url = join_url(base_url, &args.path);
let mut req = client.request(method, &url);
if !args.query.is_empty() {
req = req.query(&args.query);
}
req = apply_auth(req, api_key, extra_headers);
if let Some(body) = &args.body {
req = req.json(body);
}
let resp = req
.send()
.await
.map_err(|e| format!("Request to {url} failed: {e}"))?;
let status = resp.status();
let mut headers: BTreeMap<String, String> = BTreeMap::new();
for (name, value) in resp.headers().iter() {
let key = name.as_str().to_ascii_lowercase();
let val = value.to_str().unwrap_or("").to_string();
headers
.entry(key)
.and_modify(|existing| {
existing.push_str(", ");
existing.push_str(&val);
})
.or_insert(val);
}
let content_type = headers
.get("content-type")
.cloned()
.unwrap_or_default()
.to_ascii_lowercase();
let text = resp
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
let json = if content_type.contains("application/json") && !text.is_empty() {
serde_json::from_str::<serde_json::Value>(&text).ok()
} else {
None
};
Ok(ProxyResponse {
status: status.as_u16(),
ok: status.is_success(),
headers,
json,
text,
})
}
pub fn build_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(concat!("hanko-admin/", env!("CARGO_PKG_VERSION")))
.build()
.expect("failed to build HTTP client")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_trailing_slashes() {
assert_eq!(
normalize_base_url("http://localhost:8001/"),
"http://localhost:8001"
);
assert_eq!(
normalize_base_url("http://localhost:8001///"),
"http://localhost:8001"
);
assert_eq!(normalize_base_url(" http://x/admin "), "http://x/admin");
}
#[test]
fn joins_paths() {
assert_eq!(join_url("http://x/admin", "/users"), "http://x/admin/users");
assert_eq!(join_url("http://x/admin", "/"), "http://x/admin/");
assert_eq!(join_url("http://x/admin", ""), "http://x/admin/");
assert_eq!(join_url("http://x", "users"), "http://x/users");
}
#[tokio::test]
async fn sends_bearer_and_custom_headers_with_query() {
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::GET)
.path("/users")
.query_param("page", "2")
.header("authorization", "Bearer secret-key")
.header("cf-access-client-id", "cf-id");
then.status(200)
.header("content-type", "application/json")
.header("x-total-count", "42")
.body("[]");
})
.await;
let client = build_client();
let args = RequestArgs {
method: "GET".into(),
path: "/users".into(),
query: vec![("page".into(), "2".into())],
body: None,
};
let headers = vec![("CF-Access-Client-Id".to_string(), "cf-id".to_string())];
let resp = execute(&client, &server.base_url(), "secret-key", &headers, &args)
.await
.expect("request should succeed");
mock.assert_async().await;
assert_eq!(resp.status, 200);
assert!(resp.ok);
assert_eq!(
resp.headers.get("x-total-count").map(String::as_str),
Some("42")
);
assert!(resp.json.is_some());
}
#[tokio::test]
async fn omits_authorization_header_when_key_is_empty() {
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock_async(|when, then| {
when.method(httpmock::Method::GET)
.path("/")
.matches(|req| {
req.headers
.as_ref()
.map(|h| {
!h.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("authorization"))
})
.unwrap_or(true)
})
.header("cf-access-client-id", "cf-id");
then.status(200).body("ok");
})
.await;
let client = build_client();
let args = RequestArgs {
method: "GET".into(),
path: "/".into(),
query: vec![],
body: None,
};
let headers = vec![("CF-Access-Client-Id".to_string(), "cf-id".to_string())];
let resp = execute(&client, &server.base_url(), "", &headers, &args)
.await
.expect("request should succeed without an API key");
mock.assert_async().await;
assert_eq!(resp.status, 200);
}
#[tokio::test]
async fn error_status_is_returned_not_erred() {
let server = httpmock::MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(httpmock::Method::GET).path("/users");
then.status(401)
.header("content-type", "application/json")
.body(r#"{"code":401,"message":"unauthorized"}"#);
})
.await;
let client = build_client();
let args = RequestArgs {
method: "GET".into(),
path: "/users".into(),
query: vec![],
body: None,
};
let resp = execute(&client, &server.base_url(), "bad", &[], &args)
.await
.expect("HTTP errors must not be transport errors");
assert_eq!(resp.status, 401);
assert!(!resp.ok);
assert_eq!(resp.json.unwrap()["message"], "unauthorized");
}
}