use std::{collections::BTreeMap, future::Future, marker::PhantomData, pin::Pin};
use serde::de::DeserializeOwned;
use tower::{Service, ServiceExt};
use crate::{
config::{AuthStrategy, SdkConfig},
encoding::{DecodedResponse, decode_response, encode_request},
error::SdkError,
generated::{self, GeneratedOperationDescriptor},
transport::{SdkRequest, SdkResponse, Transport},
};
#[derive(Debug, Clone, Copy)]
pub struct Unconfigured;
#[derive(Debug, Clone, Copy)]
pub struct Configured<T>(PhantomData<T>);
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OperationInput {
pub body: Option<Vec<u8>>,
pub headers: BTreeMap<String, String>,
pub path_params: BTreeMap<String, String>,
pub query_params: BTreeMap<String, Vec<String>>,
}
impl OperationInput {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn builder() -> OperationInputBuilder {
OperationInputBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct OperationInputBuilder {
body: Option<Vec<u8>>,
headers: BTreeMap<String, String>,
path_params: BTreeMap<String, String>,
query_params: BTreeMap<String, Vec<String>>,
}
impl OperationInputBuilder {
#[must_use]
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = Some(body.into());
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
#[must_use]
pub fn path_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.path_params.insert(name.into(), value.into());
self
}
#[must_use]
pub fn query_param_single(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.query_params.insert(name.into(), vec![value.into()]);
self
}
#[must_use]
pub fn query_param(mut self, name: impl Into<String>, values: Vec<String>) -> Self {
self.query_params.insert(name.into(), values);
self
}
#[must_use]
pub fn build(self) -> OperationInput {
OperationInput {
body: self.body,
headers: self.headers,
path_params: self.path_params,
query_params: self.query_params,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct OperationCall<'sdk, T: Transport + Clone> {
descriptor: &'static GeneratedOperationDescriptor,
sdk: &'sdk FerriskeySdk<T>,
}
impl<T: Transport + Clone> OperationCall<'_, T> {
#[must_use]
pub const fn descriptor(&self) -> &'static GeneratedOperationDescriptor {
self.descriptor
}
pub fn to_request(&self, input: OperationInput) -> Result<SdkRequest, SdkError> {
encode_request(self.descriptor, input)
}
pub fn execute(
&self,
input: OperationInput,
) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
where
<T as Service<SdkRequest>>::Future: Send,
{
Box::pin(async move {
let request = self.to_request(input)?;
self.sdk.execute(request).await
})
}
pub fn execute_decoded(
&self,
input: OperationInput,
) -> Pin<Box<dyn Future<Output = Result<DecodedResponse, SdkError>> + Send + '_>>
where
<T as Service<SdkRequest>>::Future: Send,
{
Box::pin(async move {
let response = self.execute(input).await?;
decode_response(self.descriptor, response)
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct TagClient<'sdk, T: Transport + Clone> {
sdk: &'sdk FerriskeySdk<T>,
tag: &'static str,
}
impl<T: Transport + Clone> TagClient<'_, T> {
#[must_use]
pub const fn tag(&self) -> &'static str {
self.tag
}
pub fn descriptors(&self) -> impl Iterator<Item = &'static GeneratedOperationDescriptor> + '_ {
generated::OPERATION_DESCRIPTORS.iter().filter(move |descriptor| descriptor.tag == self.tag)
}
#[must_use]
pub fn operation(&self, operation_id: &str) -> Option<OperationCall<'_, T>> {
self.descriptors()
.find(|descriptor| descriptor.operation_id == operation_id)
.map(|descriptor| OperationCall { descriptor, sdk: self.sdk })
}
}
#[derive(Clone, Debug)]
pub struct FerriskeySdk<T: Transport + Clone> {
config: SdkConfig,
transport: T,
}
impl<T: Transport + Clone> FerriskeySdk<T> {
#[must_use]
pub const fn new(config: SdkConfig, transport: T) -> Self {
Self { config, transport }
}
#[must_use]
pub const fn builder(config: SdkConfig) -> FerriskeySdkBuilder<T, Unconfigured> {
FerriskeySdkBuilder { config, transport: None, _state: PhantomData }
}
#[must_use]
pub const fn config(&self) -> &SdkConfig {
&self.config
}
#[must_use]
pub const fn transport(&self) -> &T {
&self.transport
}
#[must_use]
pub const fn operations(&self) -> &'static [GeneratedOperationDescriptor] {
generated::OPERATION_DESCRIPTORS
}
#[must_use]
pub const fn tag(&self, tag: &'static str) -> TagClient<'_, T> {
TagClient { sdk: self, tag }
}
#[must_use]
pub fn operation(&self, operation_id: &str) -> Option<OperationCall<'_, T>> {
generated::OPERATION_DESCRIPTORS
.iter()
.find(|descriptor| descriptor.operation_id == operation_id)
.map(|descriptor| OperationCall { descriptor, sdk: self })
}
pub fn execute_operation(
&self,
operation_id: &str,
input: OperationInput,
) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
where
<T as Service<SdkRequest>>::Future: Send,
{
let resolved_operation = self.operation(operation_id);
let requested_operation_id = operation_id.to_string();
Box::pin(async move {
let Some(operation) = resolved_operation else {
return Err(SdkError::UnknownOperation { operation_id: requested_operation_id });
};
operation.execute(input).await
})
}
pub fn prepare_request(&self, mut request: SdkRequest) -> Result<SdkRequest, SdkError> {
request.path = resolve_url(self.config.base_url(), &request.path)?;
if request.requires_auth {
match self.config.auth() {
AuthStrategy::Bearer(token) => {
request.headers.insert("authorization".to_string(), format!("Bearer {token}"));
}
AuthStrategy::None => return Err(SdkError::MissingAuth),
}
}
Ok(request)
}
pub fn execute(
&self,
request: SdkRequest,
) -> Pin<Box<dyn Future<Output = Result<SdkResponse, SdkError>> + Send + '_>>
where
<T as Service<SdkRequest>>::Future: Send,
{
let transport = self.transport.clone();
Box::pin(async move {
let prepared_request = self.prepare_request(request)?;
transport.oneshot(prepared_request).await.map_err(SdkError::Transport)
})
}
pub fn execute_json<Output>(
&self,
request: SdkRequest,
expected_status: u16,
) -> Pin<Box<dyn Future<Output = Result<Output, SdkError>> + Send + '_>>
where
Output: DeserializeOwned + Send + 'static,
<T as Service<SdkRequest>>::Future: Send,
{
Box::pin(async move {
let response = self.execute(request).await?;
if response.status != expected_status {
return Err(SdkError::UnexpectedStatus {
expected: expected_status,
actual: response.status,
});
}
serde_json::from_slice(&response.body).map_err(SdkError::Decode)
})
}
}
#[derive(Debug)]
pub struct FerriskeySdkBuilder<T: Transport + Clone, S> {
config: SdkConfig,
transport: Option<T>,
_state: PhantomData<S>,
}
impl<T: Transport + Clone> FerriskeySdkBuilder<T, Unconfigured> {
#[must_use]
pub fn transport(mut self, transport: T) -> FerriskeySdkBuilder<T, Configured<T>> {
self.transport = Some(transport);
FerriskeySdkBuilder { config: self.config, transport: self.transport, _state: PhantomData }
}
}
impl<T: Transport + Clone> FerriskeySdkBuilder<T, Configured<T>> {
#[must_use]
#[expect(clippy::expect_used)]
pub fn build(self) -> FerriskeySdk<T> {
FerriskeySdk {
config: self.config,
transport: self.transport.expect("transport must be set in Configured state"),
}
}
}
pub trait SdkExt: Sized {
type Transport: Transport + Clone;
fn with_transport(
config: SdkConfig,
transport: Self::Transport,
) -> FerriskeySdk<Self::Transport>;
}
impl<T: Transport + Clone> SdkExt for FerriskeySdk<T> {
type Transport = T;
fn with_transport(config: SdkConfig, transport: T) -> Self {
Self::new(config, transport)
}
}
fn resolve_url(base_url: &str, path: &str) -> Result<String, SdkError> {
if path.starts_with("http://") || path.starts_with("https://") {
return Ok(path.to_string());
}
let trimmed_base = base_url.trim_end_matches('/');
let trimmed_path = path.trim_start_matches('/');
if trimmed_base.is_empty() || trimmed_path.is_empty() {
return Err(SdkError::InvalidUrl { base_url: base_url.to_string(), path: path.to_string() });
}
Ok(format!("{trimmed_base}/{trimmed_path}"))
}