//! HTTPS request/response structures and endpoints.
use data_encoding::BASE64;
use educe::Educe;
use crate ::{
common::ClientInfo,
utils::{debug::debug_slice_length, time::Duration},
};
pub (crate ) mod endpoint;
pub mod directory;
pub mod work_directory;
/// HTTPS error for when a request failed or timed out.
#[ derive(Debug, thiserror::Error)]
#[ cfg_attr(feature = "uniffi" , derive(uniffi::Error))]
#[ cfg_attr(
feature = "wasm" ,
derive(tsify::Tsify, serde::Deserialize),
serde(
tag = "type" ,
content = "details" ,
rename_all = "kebab-case" ,
rename_all_fields = "camelCase"
),
tsify(from_wasm_abi)
)]
pub enum HttpsError {
/// The request was invalid, e.g. an invalid header name has been used. This usually indicates an error in
/// construction of the request from libthreema.
#[ error("Invalid HTTPs request, details: {0}" )]
InvalidRequest(String),
/// Server unreachable
#[ error("HTTPs server unreachable, details: {0}" )]
Unreachable(String),
/// Server did not respond in time
#[ error("HTTPs server did not respond in time, details: {0}" )]
Timeout(String),
/// The response was invalid, e.g. it was incomplete or otherwise faulty in any way.
#[ error("Invalid HTTPs response, details: {0}" )]
InvalidResponse(String),
/// A different, unclassified error occurred.
#[ error("Unclassified error, details: {0}" )]
Unclassified(String),
}
/// HTTPS request method
#[ derive(Debug, PartialEq, Eq)]
#[ cfg_attr(feature = "uniffi" , derive(uniffi::Enum ))]
#[ cfg_attr(
feature = "wasm" ,
derive(tsify::Tsify, serde::Serialize),
serde(rename_all = "kebab-case" ),
tsify(into_wasm_abi)
)]
pub enum HttpsMethod {
/// Make a DELETE request
Delete,
/// Make a GET request
Get,
/// Make a POST request
Post,
/// Make a PUT request
Put,
}
#[ derive(Default)]
pub (crate ) struct HttpsHeadersBuilder {
authorization: Option<String>,
accept: Option<&'static str>,
}
impl HttpsHeadersBuilder {
pub (crate ) fn basic_auth(mut self , username: &str, password: &str) -> Self {
let encoded_credentials = BASE64.encode(format!("{username}:{password}" ).as_bytes());
let _ = self .authorization.replace(format!("Basic {encoded_credentials}" ));
self
}
pub (crate ) fn accept(mut self , accept: &'static str) -> Self {
let _ = self .accept.replace(accept);
self
}
pub (crate ) fn build(self , client_info: &ClientInfo) -> Vec<HttpsHeader> {
[
("user-agent" , Some(client_info.to_user_agent())),
("authorization" , self .authorization.clone()),
("accept" , self .accept.map(ToOwned::to_owned)),
]
.into_iter()
.filter_map(|(name, value)| {
value.map(|value| HttpsHeader {
name: name.to_owned(),
value,
})
})
.collect()
}
}
/// HTTPS header
#[ cfg_attr(test, derive(Debug, PartialEq))]
#[ cfg_attr(feature = "uniffi" , derive(uniffi::Record))]
#[ cfg_attr(
feature = "wasm" ,
derive(tsify::Tsify, serde::Serialize),
serde(rename_all = "camelCase" ),
tsify(into_wasm_abi)
)]
pub struct HttpsHeader {
/// HTTPS header name
pub name: String,
/// HTTPS header value
pub value: String,
}
/// An HTTPS request.
///
/// When handling this struct, run the following steps:
///
/// 1. Make an asynchronous HTTPS request from the provided parameters and make sure it gets cancelled after
/// `timeout`.¹
/// 2. Let `result` be the result of this HTTPS request, which can be either an HTTPS response or an error
/// (due to a timeout or another kind of error).
/// 3. Convert `result` into a [`HttpsResult`] and report it back to the appropriate task or protocol.
///
/// # IMPORTANT implementation notes
///
/// Any 3xx status codes (i.e. redirects) must be handled by the underlying client without reporting them back
/// to libthreema as a result!
///
/// The underlying client is responsible for applying SPKIs without any further notice!
#[ derive(Educe)]
#[ educe(Debug)]
#[ cfg_attr(feature = "uniffi" , derive(uniffi::Record))]
pub struct HttpsRequest {
/// Maximum amount of time the request is allowed to take before it should be aborted.
pub timeout: Duration,
/// HTTPS request URL.
pub url: String,
/// HTTPS request method.
pub method: HttpsMethod,
/// HTTPS headers to be used in the request.
#[ educe(Debug(ignore))]
pub headers: Vec<HttpsHeader>,
/// HTTPS body to be used in the request.
#[ educe(Debug(method = debug_slice_length))]
pub body: Vec<u8>,
}
/// An HTTPS response.
#[ derive(Educe)]
#[ educe(Debug)]
#[ cfg_attr(feature = "uniffi" , derive(uniffi::Record))]
pub struct HttpsResponse {
/// HTTPS response status code.
pub status: u16,
/// HTTPS body of the response.
#[ educe(Debug(method = debug_slice_length))]
pub body: Vec<u8>,
}
/// HTTPS response result
pub type HttpsResult = Result<HttpsResponse, HttpsError>;
/// Implementations for the CLI
#[ cfg(feature = "cli" )]
pub mod cli {
use tracing::trace;
use super ::{HttpsError, HttpsHeader, HttpsMethod, HttpsRequest, HttpsResponse, HttpsResult};
/// Default HTTPS client (builder) for the CLI.
pub fn https_client_builder() -> reqwest::ClientBuilder {
reqwest::ClientBuilder::new().user_agent("libthreema" )
}
impl From<HttpsMethod> for reqwest::Method {
fn from(method: HttpsMethod) -> Self {
match method {
HttpsMethod::Delete => reqwest::Method::DELETE,
HttpsMethod::Get => reqwest::Method::GET,
HttpsMethod::Post => reqwest::Method::POST,
HttpsMethod::Put => reqwest::Method::PUT,
}
}
}
struct HttpsHeaderMap(reqwest::header::HeaderMap);
impl TryFrom<Vec<HttpsHeader>> for HttpsHeaderMap {
type Error = HttpsError;
fn try_from(headers: Vec<HttpsHeader>) -> Result<Self , Self ::Error> {
let mut map = reqwest::header::HeaderMap::new();
for header in headers {
let name = reqwest::header::HeaderName::try_from(header.name)
.map_err(|_| HttpsError::InvalidRequest("Invalid header name" .into()))?;
let value = reqwest::header::HeaderValue::try_from(header.value)
.map_err(|_| HttpsError::InvalidRequest("Invalid header value" .into()))?;
let _ = map.insert(name, value);
}
Ok(HttpsHeaderMap(map))
}
}
impl HttpsRequest {
/// Send the HTTPS request and await the response.
///
/// # Errors
///
/// Returns [`HttpsError`] for all possible reasons.
#[ tracing::instrument(skip_all, fields(?self ))]
pub async fn send(self , http_client: &reqwest::Client) -> HttpsResult {
// Send request and await response
let request = http_client
.request(reqwest::Method::from(self .method), self .url)
.timeout(self .timeout)
.headers(HttpsHeaderMap::try_from(self .headers)?.0 )
.body(self .body);
trace!(?request, "Sending request" );
let response = request.send().await ?;
trace!(?response, "Got response" );
let status = response.status();
let body = response.bytes().await ?.to_vec();
trace!(body_length = body.len(), "Got body" );
// Await the body, convert to response
Ok(HttpsResponse {
status: status.as_u16(),
body,
})
}
}
impl From<reqwest::Error> for HttpsError {
fn from(error: reqwest::Error) -> Self {
if error.is_builder() || error.is_request() {
return Self ::InvalidRequest(error.to_string());
}
if error.is_timeout() {
return Self ::Timeout(error.to_string());
}
if error.is_body() || error.is_decode() {
return Self ::InvalidResponse(error.to_string());
}
Self ::Unreachable(error.to_string())
}
}
}
#[ cfg(test)]
mod tests {
use rstest::rstest;
use super ::*;
#[ test]
fn https_headers_default() {
let headers = HttpsHeadersBuilder::default().build(&ClientInfo::Libthreema);
let expected = vec![HttpsHeader {
name: "user-agent" .to_owned(),
value: format!("libthreema/{version}" , version = env!("CARGO_PKG_VERSION" )),
}];
assert_eq!(headers, expected);
}
#[ rstest]
#[ case(ClientInfo::Android {
version: "1.2.3" .to_owned(),
locale: String::default(),
device_model: String::default(),
os_version: String::default(),
}, "Threema Android/1.2.3" )]
#[ rstest]
#[ case(ClientInfo::Ios {
version: "2.3.4" .to_owned(),
locale: String::default(),
device_model: String::default(),
os_version: String::default(),
}, "Threema iOS/2.3.4" )]
#[ rstest]
#[ case(ClientInfo::Desktop {
version: "3.4.5" .to_owned(),
locale: String::default(),
renderer_name: String::default(),
renderer_version: String::default(),
os_name: String::default(),
os_architecture: String::default(),
}, "Threema Desktop/3.4.5" )]
#[ rstest]
fn https_headers_user_agent(#[ case] client_info: ClientInfo, #[ case] user_agent: String) {
let headers = HttpsHeadersBuilder::default().build(&client_info);
let expected = vec![HttpsHeader {
name: "user-agent" .to_owned(),
value: user_agent,
}];
assert_eq!(headers, expected);
}
#[ test]
fn https_headers_basic_auth() {
let headers = HttpsHeadersBuilder::default()
.basic_auth("klo" , "bürste" )
.build(&ClientInfo::Libthreema);
let expected = vec![
HttpsHeader {
name: "user-agent" .to_owned(),
value: format!("libthreema/{version}" , version = env!("CARGO_PKG_VERSION" )),
},
HttpsHeader {
name: "authorization" .to_owned(),
value: "Basic a2xvOmLDvHJzdGU=" .to_owned(),
},
];
assert_eq!(headers, expected);
}
#[ test]
fn https_headers_accept() {
let headers = HttpsHeadersBuilder::default()
.accept("Nüscht" )
.build(&ClientInfo::Libthreema);
let expected = vec![
HttpsHeader {
name: "user-agent" .to_owned(),
value: format!("libthreema/{version}" , version = env!("CARGO_PKG_VERSION" )),
},
HttpsHeader {
name: "accept" .to_owned(),
value: "Nüscht" .to_owned(),
},
];
assert_eq!(headers, expected);
}
#[ test]
fn https_headers_combination() {
let headers = HttpsHeadersBuilder::default()
.basic_auth("klo" , "bürste" )
.accept("Nüscht" )
.build(&ClientInfo::Libthreema);
let expected = vec![
HttpsHeader {
name: "user-agent" .to_owned(),
value: format!("libthreema/{version}" , version = env!("CARGO_PKG_VERSION" )),
},
HttpsHeader {
name: "authorization" .to_owned(),
value: "Basic a2xvOmLDvHJzdGU=" .to_owned(),
},
HttpsHeader {
name: "accept" .to_owned(),
value: "Nüscht" .to_owned(),
},
];
assert_eq!(headers, expected);
}
}
Messung V0.5 in Prozent C=93 H=88 G=90
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-22)
¤
*© Formatika GbR, Deutschland