diff --git a/Cargo.lock b/Cargo.lock index 68e81fa..14812c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "bunny-api-tokio" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "log", @@ -1075,9 +1075,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -1184,6 +1196,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index df35fc2..e293b03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bunny-api-tokio" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["Radical "] license = "MIT" @@ -14,6 +14,8 @@ keywords = [ "tokio", ] +[dev-dependencies] +tokio = { version = "1.45.0", features = ["fs", "rt", "rt-multi-thread", "macros"] } [dependencies] bytes = "1.10.1" @@ -22,4 +24,4 @@ reqwest = { version = "0.12.15", features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } thiserror = "2.0.12" tokio = "1.45.0" -url = "2.5.4" +url = { version = "2.5.4", features = ["serde"] } diff --git a/README.md b/README.md index 34ea671..c47f0c3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # bunny-api-tokio [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Crates.io](https://img.shields.io/crates/v/bunny-api-tokio.svg)](https://crates.io/crates/bunny-api-tokio) +[![Visitors](https://visitor-badge.laobi.icu/badge?page_id=gorb.bunny-api-tokio)](https://git.gorb.app/gorb/bunny-api-tokio) A Rust library providing **asynchronous access to the Bunny CDN API** using Tokio. +## Issues/PRs + +Issues and PRs can be submitted on the [GitHub mirror](https://github.com/gorb-app/bunny-api-tokio) + ## Features - **Async-first**: Built with Tokio for non-blocking API calls. - **Edge Storage API**: Supports Bunny's edge storage operations. diff --git a/src/bunny/mod.rs b/src/bunny/mod.rs new file mode 100644 index 0000000..7d2c333 --- /dev/null +++ b/src/bunny/mod.rs @@ -0,0 +1,218 @@ +//! Contains structs, enums and implementations for the main bunny.net API + +use serde::Deserialize; +use url::Url; + +use crate::{Client, error::Error}; + +/// Country struct returned by get_countries() function +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Country { + /// Country name + pub name: String, + /// Country ISO code + pub iso_code: String, + /// Country is part of the EU + #[serde(rename = "IsEU")] + pub is_eu: bool, + /// Tax rate in percentage + pub tax_rate: f32, + /// Tax prefix + pub tax_prefix: String, + /// URL to country flag + pub flag_url: Url, + /// ?? + pub pop_list: Vec, +} + +/// API Key struct returned by list_api_keys() +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct ApiKey { + /// API Key ID + pub id: i32, + /// API Key + pub key: String, + /// ?? + pub roles: Vec, +} + +/// Pagination struct used by Bunny.net API +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Pagination { + /// Vector of type T + pub items: Vec, + /// Current page number + pub current_page: i32, + /// Total amount of type T + pub total_items: i32, + /// Has more items + pub has_more_items: bool, +} + +/// Region struct returned by region_list() +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Region { + /// Region ID + pub id: i32, + /// Name of the region + pub name: String, + /// Price per gigabyte in region + pub price_per_gigabyte: f32, + /// Region 2 letter code + pub region_code: String, + /// Continent 2 letter code + pub continent_code: String, + /// Country 2 letter code + pub country_code: String, + /// Region latitude + pub latitude: f32, + /// Region longitude + pub longitude: f32, + /// ?? + pub allow_latency_routing: bool, +} + +impl Client { + // TODO: Following functions could probably use better naming, the names are currently derived from the titles on the API reference + + /// Returns a list of countries and tax rates + /// + /// ``` + /// use bunny_api_tokio::{Client, error::Error}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Error> { + /// // Bunny.net api key + /// let mut client = Client::new("api_key").await?; + /// + /// let countries = client.get_country_list().await?; + /// + /// println!("{:#?}", countries); + /// Ok(()) + /// } + /// ``` + pub async fn get_country_list(&self) -> Result, Error> { + let response = self + .reqwest + .get("https://api.bunny.net/country") + .header("accept", "application/json") + .send() + .await?; + + if response.status().as_u16() == 401 { + return Err(Error::Authentication(response.text().await?)); + } else if response.status().as_u16() == 500 { + return Err(Error::InternalServerError(response.text().await?)); + } + + Ok(response.json().await?) + } + + /// Returns a list of API Keys + /// + /// ``` + /// use bunny_api_tokio::{Client, error::Error}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Error> { + /// // Bunny.net api key + /// let mut client = Client::new("api_key").await?; + /// + /// let api_keys = client.list_api_keys(1, 1000).await?; + /// + /// println!("{:#?}", api_keys); + /// Ok(()) + /// } + /// ``` + pub async fn list_api_keys( + &self, + page: i32, + per_page: i32, + ) -> Result, Error> { + let response = self + .reqwest + .get("https://api.bunny.net/apikey") + .query(&[("page", page), ("perPage", per_page)]) + .send() + .await?; + + if response.status().as_u16() == 401 { + return Err(Error::Authentication(response.text().await?)); + } else if response.status().as_u16() == 500 { + return Err(Error::InternalServerError(response.text().await?)); + } + + Ok(response.json().await?) + } + + /// Returns a list of Regions + /// + /// ``` + /// use bunny_api_tokio::{Client, error::Error}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Error> { + /// // Bunny.net api key + /// let mut client = Client::new("api_key").await?; + /// + /// let regions = client.region_list().await?; + /// + /// println!("{:#?}", regions); + /// Ok(()) + /// } + /// ``` + pub async fn region_list(&self) -> Result, Error> { + let response = self + .reqwest + .get("https://api.bunny.net/region") + .send() + .await?; + + if response.status().as_u16() == 401 { + return Err(Error::Authentication(response.text().await?)); + } else if response.status().as_u16() == 500 { + return Err(Error::InternalServerError(response.text().await?)); + } + + Ok(response.json().await?) + } + + /// Purges a URL from the cache + /// + /// ``` + /// use bunny_api_tokio::{Client, error::Error}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Error> { + /// // Bunny.net api key + /// let mut client = Client::new("api_key").await?; + /// + /// client.purge_url("https://url_to_purge.com".parse()?, false).await?; + /// + /// Ok(()) + /// } + /// ``` + pub async fn purge_url(&self, url: Url, asynchronous: bool) -> Result<(), Error> { + let response = self + .reqwest + .post("https://api.bunny.net/purge") + .query(&[ + ("url", url.to_string()), + ("async", asynchronous.to_string()), + ]) + .send() + .await?; + + if response.status().as_u16() == 401 { + return Err(Error::Authentication(response.text().await?)); + } else if response.status().as_u16() == 500 { + return Err(Error::InternalServerError(response.text().await?)); + } + + Ok(response.json().await?) + } +} diff --git a/src/edge_storage.rs b/src/edge_storage.rs index 6dac926..93a70a9 100644 --- a/src/edge_storage.rs +++ b/src/edge_storage.rs @@ -2,15 +2,14 @@ //! //! Contains enums, structs and functions for the Bunny Edge Storage API -use std::sync::Arc; - use crate::Error; use bytes::Bytes; -use reqwest::Client; +use reqwest::{header::{HeaderMap, HeaderValue}, Client}; use serde::Deserialize; use url::Url; /// Endpoints for Edge Storage API +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Endpoint { /// Uses https://storage.bunnycdn.com as endpoint Frankfurt, @@ -54,7 +53,7 @@ impl TryInto for Endpoint { } /// File information returned by list -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "PascalCase")] pub struct ListFile { /// ?? @@ -90,9 +89,10 @@ pub struct ListFile { } /// Edge Storage API for bunny +#[derive(Debug, Clone)] pub struct Storage { pub(crate) url: Url, - pub(crate) reqwest: Arc, + pub(crate) reqwest: Client, } impl<'a> Storage { @@ -103,18 +103,25 @@ impl<'a> Storage { /// /// #[tokio::main] /// async fn main() -> Result<(), Error> { + /// // API key here can be left as "" if you never plan on using anything from the bunny.net api /// let mut client = Client::new("api_key").await?; /// - /// client.storage.init(Endpoint::Frankfurt, "MyStorageZone"); + /// // Requires own API key to use + /// client.storage.init("storage_zone_api_key", Endpoint::Frankfurt, "MyStorageZone").await?; /// /// Ok(()) /// } /// ``` - pub fn init>( + pub async fn init, T1: AsRef>( &mut self, + api_key: T, endpoint: Endpoint, - storage_zone: T, + storage_zone: T1, ) -> Result<(), Error> { + let mut headers = HeaderMap::new(); + headers.append("AccessKey", HeaderValue::from_str(api_key.as_ref())?); + + self.reqwest = Client::builder().default_headers(headers).build()?; let endpoint: Url = endpoint.try_into()?; let storage_zone = String::from("/") + storage_zone.as_ref() + "/"; @@ -132,12 +139,12 @@ impl<'a> Storage { /// async fn main() -> Result<(), Error> { /// let mut client = Client::new("api_key").await?; /// - /// client.storage.init(Endpoint::Frankfurt, "MyStorageZone"); + /// client.storage.init("storage_zone_api_key", Endpoint::Frankfurt, "MyStorageZone").await?; /// - /// let file_bytes = fs::read("path/to/file.png").await?; + /// let file_bytes = fs::read("path/to/file.png").await.unwrap(); /// /// // Will put a file in STORAGE_ZONE/images/file.png - /// client.storage.upload("/images/file.png", file_bytes).await?; + /// client.storage.upload("/images/file.png", file_bytes.into()).await?; /// /// Ok(()) /// } @@ -171,13 +178,13 @@ impl<'a> Storage { /// async fn main() -> Result<(), Error> { /// let mut client = Client::new("api_key").await?; /// - /// client.storage.init(Endpoint::Frankfurt, "MyStorageZone"); + /// client.storage.init("storage_zone_api_key", Endpoint::Frankfurt, "MyStorageZone").await?; /// /// // Will download the file STORAGE_ZONE/images/file.png /// let contents = client.storage.download("/images/file.png").await?; /// - /// let mut file = fs::File::create("file.png").await?; - /// file.write_all(contents).await?; + /// let mut file = fs::File::create("file.png").await.unwrap(); + /// file.write_all(&contents).await.unwrap(); /// /// Ok(()) /// } @@ -208,7 +215,7 @@ impl<'a> Storage { /// async fn main() -> Result<(), Error> { /// let mut client = Client::new("api_key").await?; /// - /// client.storage.init(Endpoint::Frankfurt, "MyStorageZone"); + /// client.storage.init("storage_zone_api_key", Endpoint::Frankfurt, "MyStorageZone").await?; /// /// // Will delete the file STORAGE_ZONE/images/file.png /// client.storage.delete("/images/file.png").await?; @@ -241,12 +248,12 @@ impl<'a> Storage { /// async fn main() -> Result<(), Error> { /// let mut client = Client::new("api_key").await?; /// - /// client.storage.init(Endpoint::Frankfurt, "MyStorageZone"); + /// client.storage.init("storage_zone_api_key", Endpoint::Frankfurt, "MyStorageZone").await?; /// /// // Will list the files in STORAGE_ZONE/images/ /// let files = client.storage.list("/images/").await?; /// - /// println!("{:#?}", files) + /// println!("{:#?}", files); /// /// Ok(()) /// } diff --git a/src/error.rs b/src/error.rs index 4b012bd..d5f7d57 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,4 +26,8 @@ pub enum Error { /// Not found error #[error("not found: {0}")] NotFound(String), + + /// Internal server error + #[error("internal server error: {0}")] + InternalServerError(String), } diff --git a/src/lib.rs b/src/lib.rs index e545ba9..7f2242f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,14 +24,16 @@ use reqwest::{ Client as RClient, header::{HeaderMap, HeaderValue}, }; -use std::sync::Arc; use url::Url; +pub mod bunny; pub mod edge_storage; pub mod error; /// API Client for bunny +#[derive(Debug, Clone)] pub struct Client { + reqwest: RClient, /// Used to interact with the Edge Storage API pub storage: edge_storage::Storage, } @@ -44,6 +46,7 @@ impl Client { /// /// #[tokio::main] /// async fn main() -> Result<(), Error> { + /// // Bunny.net api key /// let mut client = Client::new("api_key").await?; /// /// Ok(()) @@ -52,13 +55,16 @@ impl Client { pub async fn new>(api_key: T) -> Result { let mut headers = HeaderMap::new(); headers.append("AccessKey", HeaderValue::from_str(api_key.as_ref())?); + headers.append("accept", HeaderValue::from_str("application/json")?); - let reqwest = Arc::new(RClient::builder().default_headers(headers).build()?); + let reqwest = RClient::builder().default_headers(headers).build()?; + let storage_reqwest = RClient::new(); Ok(Self { + reqwest, storage: edge_storage::Storage { url: Url::parse("https://storage.bunnycdn.com").unwrap(), - reqwest, + reqwest: storage_reqwest, }, }) }