use crate::error::{Error, Result};
use crate::sprite::Sprite;
use crate::types::{
CreateSpriteRequest, CreateSpriteResponse, ListOptions, ListSpritesResponse, SpriteConfig,
SpriteInfo, UrlSettings,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_BASE_URL: &str = "https://api.sprites.dev";
#[derive(Clone)]
pub struct SpritesClient {
inner: Arc<ClientInner>,
}
struct ClientInner {
base_url: String,
token: String,
http: reqwest::Client,
}
impl SpritesClient {
pub fn new(token: impl Into<String>) -> Self {
Self::with_base_url(token, DEFAULT_BASE_URL)
}
pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
let token = token.into();
let base_url = base_url.into().trim_end_matches('/').to_string();
let http = reqwest::Client::new();
Self {
inner: Arc::new(ClientInner {
base_url,
token,
http,
}),
}
}
pub fn with_http_client(token: impl Into<String>, http: reqwest::Client) -> Self {
Self {
inner: Arc::new(ClientInner {
base_url: DEFAULT_BASE_URL.to_string(),
token: token.into(),
http,
}),
}
}
pub fn builder(token: impl Into<String>) -> SpritesClientBuilder {
SpritesClientBuilder::new(token)
}
pub async fn create_token(
fly_macaroon: &str,
org_slug: &str,
invite_code: Option<&str>,
) -> Result<String> {
Self::create_token_with_url(fly_macaroon, org_slug, invite_code, DEFAULT_BASE_URL).await
}
pub async fn create_token_with_url(
fly_macaroon: &str,
org_slug: &str,
invite_code: Option<&str>,
api_url: &str,
) -> Result<String> {
let http = reqwest::Client::new();
let url = format!("{}/v1/tokens", api_url.trim_end_matches('/'));
let mut request = CreateTokenRequest {
fly_macaroon: fly_macaroon.to_string(),
org_slug: org_slug.to_string(),
invite_code: None,
};
if let Some(code) = invite_code {
request.invite_code = Some(code.to_string());
}
let response = http.post(&url).json(&request).send().await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
let token_response: CreateTokenResponse = response.json().await?;
Ok(token_response.token)
}
pub fn base_url(&self) -> &str {
&self.inner.base_url
}
pub fn token(&self) -> &str {
&self.inner.token
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.inner.http
}
pub(crate) fn url(&self, path: &str) -> String {
format!("{}/v1{}", self.inner.base_url, path)
}
pub(crate) fn auth_header(&self) -> String {
format!("Bearer {}", self.inner.token)
}
pub fn sprite(&self, name: impl Into<String>) -> Sprite {
Sprite::new(self.clone(), name.into())
}
pub async fn create(&self, name: impl Into<String>) -> Result<Sprite> {
self.create_with_config(name, None, None).await
}
pub async fn create_with_config(
&self,
name: impl Into<String>,
config: Option<SpriteConfig>,
url_settings: Option<UrlSettings>,
) -> Result<Sprite> {
let name = name.into();
let request = CreateSpriteRequest {
name: name.clone(),
config,
url_settings,
};
let response = self
.http()
.post(self.url("/sprites"))
.header("Authorization", self.auth_header())
.json(&request)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
let _: CreateSpriteResponse = response.json().await?;
Ok(self.sprite(name))
}
pub async fn get(&self, name: &str) -> Result<SpriteInfo> {
let response = self
.http()
.get(self.url(&format!("/sprites/{name}")))
.header("Authorization", self.auth_header())
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::not_found(name));
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
let info: SpriteInfo = response.json().await?;
Ok(info)
}
pub async fn list(&self) -> Result<Vec<SpriteInfo>> {
self.list_all_with_options(ListOptions::default()).await
}
pub async fn list_with_options(&self, options: ListOptions) -> Result<ListSpritesResponse> {
let mut url = self.url("/sprites");
let mut query_params = Vec::new();
if let Some(max) = options.max_results {
query_params.push(format!("max_results={max}"));
}
if let Some(ref token) = options.continuation_token {
query_params.push(format!("continuation_token={token}"));
}
if let Some(ref prefix) = options.prefix {
query_params.push(format!("prefix={prefix}"));
}
if !query_params.is_empty() {
url = format!("{}?{}", url, query_params.join("&"));
}
let response = self.http().get(&url).header("Authorization", self.auth_header()).send().await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
let list: ListSpritesResponse = response.json().await?;
Ok(list)
}
pub async fn list_all_with_options(&self, options: ListOptions) -> Result<Vec<SpriteInfo>> {
let mut all_sprites = Vec::new();
let mut continuation_token = options.continuation_token.clone();
loop {
let opts = ListOptions {
max_results: options.max_results,
continuation_token: continuation_token.clone(),
prefix: options.prefix.clone(),
};
let response = self.list_with_options(opts).await?;
all_sprites.extend(response.sprites);
match (response.has_more, response.next_continuation_token) {
(true, Some(token)) => continuation_token = Some(token),
_ => break,
}
}
Ok(all_sprites)
}
pub async fn delete(&self, name: &str) -> Result<()> {
let response = self
.http()
.delete(self.url(&format!("/sprites/{name}")))
.header("Authorization", self.auth_header())
.send()
.await?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::not_found(name));
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
Ok(())
}
pub async fn version(&self) -> Result<Version> {
let response = self
.http()
.get(self.url("/version"))
.header("Authorization", self.auth_header())
.send()
.await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(Error::api(status.as_u16(), message));
}
let version: Version = response.json().await?;
Ok(version)
}
}
impl std::fmt::Debug for SpritesClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SpritesClient")
.field("base_url", &self.inner.base_url)
.field("token", &"[REDACTED]")
.finish()
}
}
pub struct SpritesClientBuilder {
token: String,
base_url: Option<String>,
http_client: Option<reqwest::Client>,
timeout: Option<Duration>,
}
impl SpritesClientBuilder {
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
base_url: None,
http_client: None,
timeout: None,
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn build(self) -> SpritesClient {
let base_url = self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string();
let http = if let Some(client) = self.http_client {
client
} else if let Some(timeout) = self.timeout {
reqwest::Client::builder()
.timeout(timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
} else {
reqwest::Client::new()
};
SpritesClient {
inner: Arc::new(ClientInner {
base_url,
token: self.token,
http,
}),
}
}
}
#[derive(Serialize)]
struct CreateTokenRequest {
fly_macaroon: String,
org_slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
invite_code: Option<String>,
}
#[derive(Deserialize)]
struct CreateTokenResponse {
token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Version {
pub version: String,
#[serde(default)]
pub api_version: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = SpritesClient::new("test-token");
assert_eq!(client.base_url(), DEFAULT_BASE_URL);
}
#[test]
fn test_client_custom_url() {
let client = SpritesClient::with_base_url("token", "https://custom.api.dev/");
assert_eq!(client.base_url(), "https://custom.api.dev");
}
#[test]
fn test_url_building() {
let client = SpritesClient::new("token");
assert_eq!(
client.url("/sprites"),
"https://api.sprites.dev/v1/sprites"
);
}
}