diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 00000000..4090692f --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,24 @@ +on: + pull_request: {} + workflow_dispatch: {} + push: + branches: + - main + - master + schedule: + - cron: '0 0 * * *' +name: Semgrep config +jobs: + semgrep: + name: semgrep/ci + runs-on: ubuntu-latest + env: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + SEMGREP_URL: https://cloudflare.semgrep.dev + SEMGREP_APP_URL: https://cloudflare.semgrep.dev + SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version + container: + image: semgrep/semgrep + steps: + - uses: actions/checkout@v4 + - run: semgrep ci diff --git a/Cargo.toml b/Cargo.toml index d79a2375..2cd05df7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] +resolver = "2" members = [ "cloudflare", "cloudflare-examples", "cloudflare-e2e-test", -] \ No newline at end of file +] diff --git a/cloudflare-e2e-test/Cargo.toml b/cloudflare-e2e-test/Cargo.toml index 3e897b4d..61423497 100644 --- a/cloudflare-e2e-test/Cargo.toml +++ b/cloudflare-e2e-test/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cloudflare-e2e-test" version = "0.5.0" -edition = "2018" +edition = "2021" description = "End-to-end tests of the Cloudflare Rust API client" license = "BSD-3-Clause" @@ -14,3 +14,5 @@ anyhow = "1.0.33" clap = { version = "4.1", features = ["env"] } cloudflare = { path = "../cloudflare" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +serde_json = "1.0.138" +rand = "0.8.5" diff --git a/cloudflare-e2e-test/src/main.rs b/cloudflare-e2e-test/src/main.rs index 7551b0d4..a929f871 100644 --- a/cloudflare-e2e-test/src/main.rs +++ b/cloudflare-e2e-test/src/main.rs @@ -1,79 +1,20 @@ #![forbid(unsafe_code)] +mod routing_performance; +mod storage_databases; use clap::{Arg, Command}; -use cloudflare::framework::async_api::Client as AsyncClient; -use cloudflare::framework::{async_api, auth::Credentials, Environment, HttpApiClientConfig}; +use cloudflare::framework::client::async_api::Client as AsyncClient; +use cloudflare::framework::client::ClientConfig; +use cloudflare::framework::{auth::Credentials, client::async_api, Environment}; use std::fmt::Display; -use std::net::{IpAddr, Ipv4Addr}; async fn tests(api_client: &AsyncClient, account_id: &str) -> anyhow::Result<()> { - test_lb_pool(api_client, account_id).await?; + routing_performance::load_balancers::test_lb_pool(api_client, account_id).await?; + storage_databases::kv::test_kv(api_client, account_id).await?; println!("Tests passed"); Ok(()) } -async fn test_lb_pool(api_client: &AsyncClient, account_identifier: &str) -> anyhow::Result<()> { - use cloudflare::endpoints::load_balancing::*; - - // Create a pool - let origins = vec![ - Origin { - name: "test-origin".to_owned(), - address: IpAddr::V4(Ipv4Addr::new(152, 122, 3, 1)), - enabled: true, - weight: 1.0, - }, - Origin { - name: "test-origin-2".to_owned(), - address: IpAddr::V4(Ipv4Addr::new(152, 122, 3, 2)), - enabled: true, - weight: 1.0, - }, - ]; - let pool = api_client - .request(&create_pool::CreatePool { - account_identifier, - params: create_pool::Params { - name: "test-pool", - optional_params: Some(create_pool::OptionalParams { - description: Some("test description"), - enabled: Some(true), - minimum_origins: Some(2), - monitor: Some("9004c07f1c0f33255410e45590251cf4"), - notification_email: Some("test@example.com"), - }), - origins: &origins, - }, - }) - .await - .log_err(|e| println!("Error in CreatePool: {e}"))? - .result; - - // Get the details, but wait until after we delete the pool to validate it. - let pool_details = api_client - .request(&pool_details::PoolDetails { - account_identifier, - identifier: &pool.id, - }) - .await - .log_err(|e| println!("Error in PoolDetails: {e}")); - - // Delete the pool - let _ = api_client - .request(&delete_pool::DeletePool { - account_identifier, - identifier: &pool.id, - }) - .await - .log_err(|e| println!("Error in DeletePool: {e}"))?; - - // Validate the pool we got was the same as the pool we sent - let pool_details = pool_details?.result; - assert_eq!(pool, pool_details); - - Ok(()) -} - #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = @@ -83,33 +24,38 @@ async fn main() -> anyhow::Result<()> { .about("Issues example requests to the Cloudflare API using the cloudflare-rust client library") .arg(Arg::new("email") .long("email") + .env("CF_RS_EMAIL") .help("Email address associated with your account") .requires("auth-key")) .arg(Arg::new("auth-key") .long("auth-key") + .alias("api-key") .env("CF_RS_AUTH_KEY") + .env("CF_RS_API_KEY") .help("API key generated on the \"My Account\" page") .requires("email")) .arg(Arg::new("auth-token") .long("auth-token") + .alias("api-token") .env("CF_RS_AUTH_TOKEN") + .env("CF_RS_API_TOKEN") .help("API token generated on the \"My Account\" page") .conflicts_with_all(["email", "auth-key"])) .arg(Arg::new("account-id") .long("account-id") - .env("CF_RS_ZONE_ID") + .env("CF_RS_ACCOUNT_ID") .help("The ID of the account tests should be run on")) .arg_required_else_help(true); let mut matches = cli.get_matches(); - let email = matches.remove_one("email").unwrap(); + let email = matches.remove_one("email"); let key = matches.remove_one("auth-key"); let token = matches.remove_one("auth-token"); - let account_id = matches + let account_id: String = matches .remove_one("account-id") .expect("account_id is mandatory"); - let credentials: Credentials = if let Some(key) = key { + let credentials: Credentials = if let (Some(email), Some(key)) = (email, key) { Credentials::UserAuthKey { email, key } } else if let Some(token) = token { Credentials::UserAuthToken { token } @@ -119,11 +65,11 @@ async fn main() -> anyhow::Result<()> { let api_client = async_api::Client::new( credentials, - HttpApiClientConfig::default(), + ClientConfig::default(), Environment::Production, )?; - tests(&api_client, account_id).await + tests(&api_client, account_id.as_str()).await } pub trait ResultExt { diff --git a/cloudflare-e2e-test/src/routing_performance/load_balancers.rs b/cloudflare-e2e-test/src/routing_performance/load_balancers.rs new file mode 100644 index 00000000..152e1045 --- /dev/null +++ b/cloudflare-e2e-test/src/routing_performance/load_balancers.rs @@ -0,0 +1,68 @@ +use crate::AsyncClient; + +pub async fn test_lb_pool( + api_client: &AsyncClient, + account_identifier: &str, +) -> anyhow::Result<()> { + use crate::ResultExt; + use cloudflare::endpoints::load_balancing::*; + use std::net::{IpAddr, Ipv4Addr}; + + // Create a pool + let origins = vec![ + Origin { + name: "test-origin".to_owned(), + address: IpAddr::V4(Ipv4Addr::new(152, 122, 3, 1)), + enabled: true, + weight: 1.0, + }, + Origin { + name: "test-origin-2".to_owned(), + address: IpAddr::V4(Ipv4Addr::new(152, 122, 3, 2)), + enabled: true, + weight: 1.0, + }, + ]; + let pool = api_client + .request(&create_pool::CreatePool { + account_identifier, + params: create_pool::Params { + name: "test-pool", + optional_params: Some(create_pool::OptionalParams { + description: Some("test description"), + enabled: Some(true), + minimum_origins: Some(2), + monitor: Some("9004c07f1c0f33255410e45590251cf4"), + notification_email: Some("test@example.com"), + }), + origins: &origins, + }, + }) + .await + .log_err(|e| println!("Error in CreatePool: {e}"))? + .result; + + // Get the details, but wait until after we delete the pool to validate it. + let pool_details = api_client + .request(&pool_details::PoolDetails { + account_identifier, + identifier: &pool.id, + }) + .await + .log_err(|e| println!("Error in PoolDetails: {e}")); + + // Delete the pool + let _ = api_client + .request(&delete_pool::DeletePool { + account_identifier, + identifier: &pool.id, + }) + .await + .log_err(|e| println!("Error in DeletePool: {e}"))?; + + // Validate the pool we got was the same as the pool we sent + let pool_details = pool_details?.result; + assert_eq!(pool, pool_details); + + Ok(()) +} diff --git a/cloudflare-e2e-test/src/routing_performance/mod.rs b/cloudflare-e2e-test/src/routing_performance/mod.rs new file mode 100644 index 00000000..7ed73ae3 --- /dev/null +++ b/cloudflare-e2e-test/src/routing_performance/mod.rs @@ -0,0 +1 @@ +pub mod load_balancers; diff --git a/cloudflare-e2e-test/src/storage_databases/kv.rs b/cloudflare-e2e-test/src/storage_databases/kv.rs new file mode 100644 index 00000000..0685c302 --- /dev/null +++ b/cloudflare-e2e-test/src/storage_databases/kv.rs @@ -0,0 +1,435 @@ +use crate::{AsyncClient, ResultExt}; +use cloudflare::endpoints::workerskv::create_namespace::{CreateNamespace, CreateNamespaceParams}; +use cloudflare::endpoints::workerskv::delete_bulk::DeleteBulk; +use cloudflare::endpoints::workerskv::delete_key::DeleteKey; +use cloudflare::endpoints::workerskv::list_namespace_keys::ListNamespaceKeys; +use cloudflare::endpoints::workerskv::list_namespaces::ListNamespaces; +use cloudflare::endpoints::workerskv::read_key::ReadKey; +use cloudflare::endpoints::workerskv::read_key_metadata::ReadKeyMetadata; +use cloudflare::endpoints::workerskv::remove_namespace::RemoveNamespace; +use cloudflare::endpoints::workerskv::rename_namespace::{RenameNamespace, RenameNamespaceParams}; +use cloudflare::endpoints::workerskv::write_bulk::{KeyValuePair, WriteBulk}; +use cloudflare::endpoints::workerskv::write_key::WriteKey; +use cloudflare::endpoints::workerskv::write_key::{WriteKeyBody, WriteKeyBodyMetadata}; +use cloudflare::endpoints::workerskv::{Key, WorkersKvBulkResult, WorkersKvNamespace}; +use cloudflare::framework::client::async_api::Client; +use cloudflare::framework::response::{ApiFailure, ApiResponse, ApiSuccess}; +use rand; +use rand::Rng; +use serde_json::json; + +async fn read_key( + client: &Client, + account_id: &str, + namespace_id: &str, + key: &str, +) -> ApiResponse> { + let endpoint = ReadKey { + account_identifier: account_id, + namespace_identifier: namespace_id, + key, + }; + + client.request(&endpoint).await +} + +async fn create_namespace( + client: &Client, + account_id: &str, + title: &str, +) -> ApiResponse> { + let endpoint = CreateNamespace { + account_identifier: account_id, + params: CreateNamespaceParams { + title: title.into(), + }, + }; + + client.request(&endpoint).await +} + +async fn delete_bulk( + client: &Client, + account_id: &str, + namespace_id: &str, + keys: Vec<&str>, +) -> ApiResponse> { + let endpoint = DeleteBulk { + account_identifier: account_id, + namespace_identifier: namespace_id, + bulk_keys: keys.into_iter().map(|k| k.into()).collect(), + }; + + client.request(&endpoint).await +} + +async fn delete_key( + client: &Client, + account_id: &str, + namespace_id: &str, + key: &str, +) -> ApiResponse> { + let endpoint = DeleteKey { + account_identifier: account_id, + namespace_identifier: namespace_id, + key, + }; + + client.request(&endpoint).await +} + +async fn list_namespace_keys( + client: &Client, + account_id: &str, + namespace_id: &str, +) -> ApiResponse>> { + let endpoint = ListNamespaceKeys { + account_identifier: account_id, + namespace_identifier: namespace_id, + params: Default::default(), + }; + + client.request(&endpoint).await +} + +async fn list_namespaces( + client: &Client, + account_id: &str, +) -> ApiResponse>> { + let endpoint = ListNamespaces { + account_identifier: account_id, + params: Default::default(), + }; + + client.request(&endpoint).await +} + +async fn read_key_metadata( + client: &Client, + account_id: &str, + namespace_id: &str, + key: &str, +) -> ApiResponse>> { + let endpoint = ReadKeyMetadata { + account_identifier: account_id, + namespace_identifier: namespace_id, + key, + }; + + client.request(&endpoint).await +} + +async fn remove_namespace( + client: &Client, + account_id: &str, + namespace_id: &str, +) -> ApiResponse> { + let endpoint = RemoveNamespace { + account_identifier: account_id, + namespace_identifier: namespace_id, + }; + + client.request(&endpoint).await +} + +async fn rename_namespace( + client: &Client, + account_id: &str, + namespace_id: &str, + title: &str, +) -> ApiResponse> { + let endpoint = RenameNamespace { + account_identifier: account_id, + namespace_identifier: namespace_id, + params: RenameNamespaceParams { + title: title.into(), + }, + }; + + client.request(&endpoint).await +} + +async fn write_bulk( + client: &Client, + account_id: &str, + namespace_id: &str, + key_value_pairs: Vec<(&str, &str)>, +) -> ApiResponse> { + let endpoint = WriteBulk { + account_identifier: account_id, + namespace_identifier: namespace_id, + bulk_key_value_pairs: key_value_pairs + .into_iter() + .map(|(k, v)| KeyValuePair { + key: k.into(), + value: v.into(), + expiration: None, + expiration_ttl: None, + base64: None, + }) + .collect(), + }; + + client.request(&endpoint).await +} + +async fn write_key_metadata( + client: &Client, + account_id: &str, + namespace_id: &str, + key: &str, + value: Vec, + metadata: Option, +) -> ApiResponse> { + let endpoint = WriteKey { + account_identifier: account_id, + namespace_identifier: namespace_id, + key, + params: Default::default(), + body: if let Some(metadata) = metadata { + WriteKeyBody::Metadata(WriteKeyBodyMetadata { value, metadata }) + } else { + WriteKeyBody::Value(value) + }, + }; + + client.request(&endpoint).await +} + +pub async fn test_kv(client: &AsyncClient, account_id: &str) -> anyhow::Result<()> { + //region Create a new namespace + println!("Creating a new namespace..."); + + let title: String = get_random_title(); + let result = create_namespace(client, account_id, &title) + .await + .log_err(|e| println!("Error while creating namespace: {e}"))? + .result; + assert_eq!(result.title, title); + assert_eq!(result.supports_url_encoding, Some(true)); + //endregion + + let namespace_id = result.id; + let namespace_id = namespace_id.as_str(); + let key = "key"; + + //region List all namespaces + println!("Listing all namespaces..."); + + let result = list_namespaces(client, account_id) + .await + .log_err(|e| println!("Error while listing namespaces: {e}"))? + .result; + assert!(result.contains(&WorkersKvNamespace { + id: namespace_id.to_string(), + title: title.clone(), + supports_url_encoding: Some(true), + })); + //endregion + + //region Write a key-value pair + println!("Writing a key-value pair..."); + + write_key_metadata( + client, + account_id, + namespace_id, + key, + b"value".to_vec(), + Some(serde_json::to_value(json!({"metadata": true})).unwrap()), + ) + .await + .log_err(|e| println!("Error while writing key: {e}"))?; + //endregion + + //region Read a key-value pair + println!("Reading a key-value pair..."); + + let result = read_key(client, account_id, namespace_id, key) + .await + .log_err(|e| println!("Error while reading key: {e}"))?; + assert_eq!(result, b"value"); + //endregion + + //region Write multiple key-value pairs + println!("Writing multiple key-value pairs..."); + + let key_value_pairs = vec![("debug", "test"), ("debug2", "test2")]; + + let result = write_bulk(client, account_id, namespace_id, key_value_pairs.clone()) + .await + .log_err(|e| println!("Error while writing bulk: {e}"))? + .result; + assert_eq!(result.successful_key_count.unwrap(), 2); + assert_eq!(result.unsuccessful_keys, Some(vec![])); + let result = read_key( + client, + account_id, + namespace_id, + key_value_pairs.clone()[0].0, + ) + .await + .log_err(|e| println!("Error while reading key: {e}"))?; + assert_eq!(result, b"test"); + let result = read_key(client, account_id, namespace_id, key_value_pairs[1].0) + .await + .log_err(|e| println!("Error while reading key: {e}"))?; + assert_eq!(result, b"test2"); + //endregion + + //region Read a key-value pair's metadata + println!("Reading a key-value pair's metadata..."); + + let result = read_key_metadata(client, account_id, namespace_id, key) + .await + .log_err(|e| println!("Error while reading key metadata: {e}"))? + .result; + assert_eq!(result, Some(json!({"metadata": true}))); + //endregion + + //region List all keys in a namespace + println!("Listing all keys in a namespace..."); + + let result = list_namespace_keys(client, account_id, namespace_id) + .await + .log_err(|e| println!("Error while listing namespace keys: {e}"))? + .result; + assert_eq!(result.len(), 3); + assert!(result.contains(&Key { + name: key.to_string(), + expiration: None, + metadata: Some(json!({"metadata": true})), + })); + assert!(result.contains(&Key { + name: key_value_pairs[0].0.to_string(), + expiration: None, + metadata: None, + })); + assert!(result.contains(&Key { + name: key_value_pairs[1].0.to_string(), + expiration: None, + metadata: None, + })); + //endregion + + //region Delete a key-value pair + println!("Deleting a key-value pair..."); + + delete_key(client, account_id, namespace_id, key) + .await + .log_err(|e| { + println!("Error while deleting key: {e}"); + })?; + let result = list_namespace_keys(client, account_id, namespace_id) + .await + .log_err(|e| println!("Error while listing namespace keys: {e}"))? + .result; + assert_eq!(result.len(), 2); + assert!(result.iter().all(|k| k.name != key)); + assert!(result.contains(&Key { + name: key_value_pairs[0].0.to_string(), + expiration: None, + metadata: None, + })); + assert!(result.contains(&Key { + name: key_value_pairs[1].0.to_string(), + expiration: None, + metadata: None, + })); + //endregion + + //region Delete multiple key-value pairs + println!("Deleting multiple key-value pairs..."); + + let keys = vec!["debug", "debug2"]; + delete_bulk(client, account_id, namespace_id, keys) + .await + .log_err(|e| { + println!("Error while deleting bulk: {e}"); + })?; + let result = list_namespace_keys(client, account_id, namespace_id) + .await + .log_err(|e| println!("Error while listing namespace keys: {e}"))? + .result; + assert_eq!(result.len(), 0); + //endregion + + //region Rename a namespace + println!("Renaming a namespace..."); + + let new_title = get_random_title(); + + rename_namespace(client, account_id, namespace_id, new_title.as_str()) + .await + .log_err(|e| { + println!("Error while renaming namespace: {e}"); + })?; + let result = list_namespaces(client, account_id) + .await + .log_err(|e| println!("Error while listing namespaces: {e}"))? + .result; + assert!(result.iter().all(|n| n.title != title)); + assert!(result.contains(&WorkersKvNamespace { + id: namespace_id.to_string(), + title: new_title, + supports_url_encoding: Some(true), + })); + //endregion + + //region Remove a namespace + println!("Removing a namespace..."); + + remove_namespace(client, account_id, namespace_id) + .await + .log_err(|e| { + println!("Error while removing namespace: {e}"); + })?; + let result = list_namespaces(client, account_id) + .await + .log_err(|e| println!("Error while listing namespaces: {e}"))? + .result; + assert!(result.iter().all(|n| n.title != "test_renamed")); + //endregion + + //region Set a key on a non-existing namespace and check for error handling + println!("Error handling check..."); + + let result = write_key_metadata( + client, + account_id, + namespace_id, + key, + b"value".to_vec(), + None, + ) + .await + .expect_err("Error while checking error handling"); + match result { + ApiFailure::Error(status, errors) => { + assert_eq!(status, 404); + assert_eq!(errors.errors.len(), 1); + assert_eq!(errors.errors[0].code, 10013); + } + ApiFailure::Invalid(e) => { + panic!("Unexpected error: {e}"); + } + } + //endregion + + println!("All KV tests passed!"); + + Ok(()) +} + +fn get_random_title() -> String { + let title: String = rand::thread_rng() + // Generate a random string of length 10 from characters 'A' to 'Z' + .sample_iter(&rand::distributions::Uniform::new( + char::from(65), + char::from(90), + )) + .take(10) + .map(char::from) + .collect(); + title +} diff --git a/cloudflare-e2e-test/src/storage_databases/mod.rs b/cloudflare-e2e-test/src/storage_databases/mod.rs new file mode 100644 index 00000000..05c6d5b9 --- /dev/null +++ b/cloudflare-e2e-test/src/storage_databases/mod.rs @@ -0,0 +1 @@ +pub mod kv; diff --git a/cloudflare-examples/Cargo.toml b/cloudflare-examples/Cargo.toml index 06042299..dbb6afbb 100644 --- a/cloudflare-examples/Cargo.toml +++ b/cloudflare-examples/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cloudflare-examples" version = "0.6.0" -edition = "2018" +edition = "2021" description = "Examples of how to use the Cloudflare Rust API client" license = "BSD-3-Clause" rust-version = "1.56.0" diff --git a/cloudflare-examples/src/main.rs b/cloudflare-examples/src/main.rs index 19d4bac9..d7861c77 100644 --- a/cloudflare-examples/src/main.rs +++ b/cloudflare-examples/src/main.rs @@ -3,13 +3,18 @@ use std::collections::HashMap; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; -use cloudflare::endpoints::{account, dns, workers, zone}; -use cloudflare::framework::endpoint::Endpoint; -use cloudflare::framework::response::{ApiError, ApiErrors}; +use cloudflare::endpoints::dns::dns; +use cloudflare::endpoints::zones::zone; +use cloudflare::endpoints::{account, workers}; +use cloudflare::framework::client::blocking_api::HttpApiClient; +use cloudflare::framework::client::ClientConfig; +use cloudflare::framework::endpoint::spec::EndpointSpec; +use cloudflare::framework::response::{ApiErrors, ApiResult, ApiSuccess}; +use cloudflare::framework::response::{ResponseConverter, ResponseInfo}; use cloudflare::framework::{ auth::Credentials, - response::{ApiFailure, ApiResponse, ApiResult}, - Environment, HttpApiClient, HttpApiClientConfig, OrderDirection, + response::{ApiFailure, ApiResponse}, + Environment, OrderDirection, }; use serde::Serialize; @@ -45,8 +50,8 @@ where } } -/// Sometimes you want to pipe results to jq etc -fn print_response_json(response: ApiResponse) +/// Sometimes you want to pipe results to jq etc. +fn print_response_json(response: ApiResponse>) where T: ApiResult + Serialize, { @@ -71,9 +76,10 @@ where } fn zone(arg_matches: &ArgMatches, api_client: &HttpApiClient) { - let zone_identifier = arg_matches.get_one::("zone_identifier"); + let zone_identifier = arg_matches.get_one::("zone_identifier").unwrap(); + // Create the endpoint using the new trait. let endpoint = zone::ZoneDetails { - identifier: zone_identifier.unwrap(), + identifier: zone_identifier, }; if api_client.is_mock() { add_static_mock(&endpoint); @@ -95,7 +101,6 @@ fn dns(arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response(response); } @@ -131,7 +136,6 @@ fn create_txt_record(arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response(response); } @@ -148,7 +152,6 @@ fn list_routes(arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response_json(response); } @@ -158,7 +161,6 @@ fn list_accounts(_arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response_json(response); } @@ -188,7 +190,6 @@ fn create_route(arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response_json(response); } @@ -213,17 +214,19 @@ fn delete_route(arg_matches: &ArgMatches, api_client: &HttpApiClient) { add_static_mock(&endpoint); } let response = api_client.request(&endpoint); - print_response_json(response); } /// Add and leak a mock (so it runs for 'static) -fn add_static_mock(endpoint: &dyn Endpoint) +fn add_static_mock(endpoint: &E) where - ResultType: ApiResult, + E: EndpointSpec, + // The endpoint's JSON response type isn't used for raw endpoints + // but we require that its final response type is ApiResult. + E::ResponseType: ResponseConverter, { let body = ApiErrors { - errors: vec![ApiError { + errors: vec![ResponseInfo { code: 9999, message: "This is a mocked failure response".to_owned(), other: HashMap::new(), @@ -345,7 +348,6 @@ fn main() -> Result<(), Box> { for (section_name, section) in sections.iter() { let mut subcommand = Command::new(*section_name).about(section.description); - for arg in §ion.args { subcommand = subcommand.arg(arg); } @@ -353,11 +355,11 @@ fn main() -> Result<(), Box> { } let mut matches = cli.get_matches(); - let email = matches.remove_one("email"); - let key = matches.remove_one("auth-key"); - let token = matches.remove_one("auth-token"); + let email = matches.remove_one::("email"); + let key = matches.remove_one::("auth-key"); + let token = matches.remove_one::("auth-token"); let environment = if matches.get_flag("mock") { - Environment::Mockito + Environment::Custom(mockito::server_url()) } else { Environment::Production }; @@ -379,7 +381,7 @@ fn main() -> Result<(), Box> { panic!("Either API token or API key + email pair must be provided") }; - let api_client = HttpApiClient::new(credentials, HttpApiClientConfig::default(), environment)?; + let api_client = HttpApiClient::new(credentials, ClientConfig::default(), environment)?; for (section_name, section) in matched_sections { (section.function)( diff --git a/cloudflare/Cargo.toml b/cloudflare/Cargo.toml index b1a54536..7db610f5 100644 --- a/cloudflare/Cargo.toml +++ b/cloudflare/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "cloudflare" -version = "0.12.0" -authors = ["Noah Kennedy ", "Jeff Hiner "] -edition = "2018" +version = "0.14.1" +authors = ["Noah Kennedy ", "Jeff Hiner ", "Kenneth Eversole "] +repository = "https://github.com/cloudflare/cloudflare-rs" +edition = "2021" description = "Rust library for the Cloudflare v4 API" keywords = ["cloudflare", "api", "client"] categories = ["api-bindings", "web-programming::http-client"] @@ -13,6 +14,7 @@ default = ["default-tls"] blocking = ["reqwest/blocking"] default-tls = ["reqwest/default-tls"] rustls-tls = ["reqwest/rustls-tls"] +ndarray = ["dep:ndarray"] spec = [] [dependencies] @@ -22,14 +24,20 @@ chrono = { version = "0.4", default-features = false, features = [ "std", "wasmbind", ] } -http = "0.2" -mockito = { version = "0.31", optional = true } -percent-encoding = "2.1.0" -reqwest = { version = "0.11.4", default-features = false, features = ["json"] } +http = "1" +mockito = { version = "1.6.1", optional = true } +ndarray = { version = "0.16", optional = true, features = ["serde"] } +reqwest = { version = "0.12.12", default-features = false, features = ["json", "multipart"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_with = { version = "2.0", features = ["base64"] } +serde_with = { version = "3", features = ["base64"] } serde_urlencoded = "0.7.1" -thiserror = "1" +thiserror = "2" url = "2.2" +urlencoding = "2.1.3" uuid = { version = "1.0", features = ["serde"] } + +[dev-dependencies] +mockito = { version = "1.6.1" } +tokio = { version = "1.0", features = ["macros"] } +regex = "1.11.1" diff --git a/cloudflare/src/endpoints/account/list_accounts.rs b/cloudflare/src/endpoints/account/list_accounts.rs index 4d1f128c..cc5e37e2 100644 --- a/cloudflare/src/endpoints/account/list_accounts.rs +++ b/cloudflare/src/endpoints/account/list_accounts.rs @@ -3,6 +3,7 @@ use super::Account; use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; use crate::framework::OrderDirection; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// List Accounts @@ -13,7 +14,10 @@ pub struct ListAccounts { pub params: Option, } -impl EndpointSpec> for ListAccounts { +impl EndpointSpec for ListAccounts { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/account/mod.rs b/cloudflare/src/endpoints/account/mod.rs index 2954079c..3f9470e6 100644 --- a/cloudflare/src/endpoints/account/mod.rs +++ b/cloudflare/src/endpoints/account/mod.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; pub mod list_accounts; +pub mod user; pub use list_accounts::ListAccounts; /// Cloudflare Accounts diff --git a/cloudflare/src/endpoints/user.rs b/cloudflare/src/endpoints/account/user.rs similarity index 89% rename from cloudflare/src/endpoints/user.rs rename to cloudflare/src/endpoints/account/user.rs index 0d4113f1..d40f5617 100644 --- a/cloudflare/src/endpoints/user.rs +++ b/cloudflare/src/endpoints/account/user.rs @@ -1,5 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -68,7 +68,10 @@ fn handles_empty_betas_field() { #[derive(Debug)] pub struct GetUserDetails {} -impl EndpointSpec for GetUserDetails { +impl EndpointSpec for GetUserDetails { + type JsonResponse = UserDetails; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -91,7 +94,10 @@ impl ApiResult for UserTokenStatus {} #[derive(Debug)] pub struct GetUserTokenStatus {} -impl EndpointSpec for GetUserTokenStatus { +impl EndpointSpec for GetUserTokenStatus { + type JsonResponse = UserTokenStatus; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/ai/execute_model.rs b/cloudflare/src/endpoints/ai/execute_model.rs new file mode 100644 index 00000000..67869427 --- /dev/null +++ b/cloudflare/src/endpoints/ai/execute_model.rs @@ -0,0 +1,619 @@ +use serde::{Deserialize, Serialize}; + +use crate::framework::endpoint::RequestBody; +use crate::framework::response::ApiSuccess; +use crate::framework::{ + endpoint::{EndpointSpec, Method}, + response::ApiResult, +}; + +/// Get an inference from a model. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExecuteModel<'a> { + pub account_identifier: &'a str, + pub model_name: &'a str, + pub params: ExecuteModelParams, +} + +impl EndpointSpec for ExecuteModel<'_> { + type JsonResponse = ExecuteModelResult; + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + Method::POST + } + + fn path(&self) -> String { + format!( + "accounts/{}/ai/run/{}", + self.account_identifier, self.model_name + ) + } + + #[inline] + fn body(&self) -> Option { + let body = serde_json::to_string(&self.params).unwrap(); + Some(RequestBody::Json(body)) + } +} + +/// Represents various inference tasks supported by Workers AI. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ExecuteModelParams { + /// Text Classification task. + /// + /// Classifies the input text into predefined categories. + TextClassification { + /// The text that you want to classify. + /// Must be at least 1 character long. + text: String, + }, + + /// Text-to-Image generation task. + /// + /// Generates an image based on the provided text description. + TextToImage(TextToImageParams), + + /// Text-to-Speech generation task. + /// + /// Converts text into speech. + TextToSpeech(TextToSpeechParams), + + /// Text Embedding generation task. + /// + /// Converts text into numerical embeddings. + TextEmbeddings { + /// The array of texts to embed. + text: Vec, + }, + + /// Automatic Speech Recognition task. + /// + /// Converts audio into text, with optional translation. + AutomaticSpeechRecognition(AutomaticSpeechRecognitionParams), + + /// Image Classification task. + /// + /// Classifies an image into predefined categories. + ImageClassification { + /// An array of integers representing the image data (8-bit unsigned integer values). + image: Vec, + }, + + /// Object Detection task. + /// + /// Detects objects in the input image. + ObjectDetection { + /// An array of integers representing the image data (8-bit unsigned integer values). + image: Vec, + }, + + /// General Prompt task. + /// + /// Generates a response based on the provided input text. + Prompt(PromptParams), + + /// Messages task. + /// + /// Handles conversation-based input and output. + Messages(MessagesParams), + + /// Translation task. + /// Translates text into the specified language. + Translation(TranslationParams), + + /// Summarization task. + /// Summarizes the provided input text. + Summarization(SummarizationParams), + + /// Image-to-Text task. + /// Converts an image into text-based descriptions. + ImageToText(ImageToTextParams), +} + +/// Parameters for the `TextToImage` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TextToImageParams { + /// A text description of the image to generate. + /// Must be at least 1 character long. + pub prompt: String, + + /// Controls how closely the generated image should adhere to the prompt. + pub guidance: Option, + + /// The height of the generated image in pixels. Must be between 256 and 2048. + pub height: Option, + + /// An array of integers representing the image data for img2img tasks. + pub image: Option>, + + /// A base64-encoded string of the input image for img2img tasks. + pub image_b64: Option, + + /// An array of integers representing mask image data for inpainting. + pub mask: Option>, + + /// Text describing elements to avoid in the generated image. + pub negative_prompt: Option, + + /// The number of diffusion steps (max 20). + pub num_steps: Option, + + /// Random seed for reproducibility. + pub seed: Option, + + /// Strength of transformation for img2img tasks (0.0 to 1.0). + pub strength: Option, + + /// The width of the generated image in pixels. Must be between 256 and 2048. + pub width: Option, +} + +/// Parameters for the `TextToSpeech` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TextToSpeechParams { + /// The text to generate speech from. + /// Must be at least 1 character long. + pub prompt: String, + + /// The language for the generated speech. Defaults to "en". + pub lang: Option, +} + +/// Parameters for the `AutomaticSpeechRecognition` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct AutomaticSpeechRecognitionParams { + /// An array of integers representing the audio data (8-bit unsigned integer values). + pub audio: Vec, + + /// The language of the recorded audio. + pub source_lang: Option, + + /// The target language for translation (currently only English is supported). + pub target_lang: Option, +} + +/// Parameters for the `Prompt` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct PromptParams { + /// The input text prompt for the model. + /// Must be between `1` and `131072` characters long. + pub prompt: String, + + /// Decreases the likelihood of repeating the same lines verbatim (0 to 2). + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + + /// Name of the LoRA (Low-Rank Adaptation) model to fine-tune the base model. + #[serde(skip_serializing_if = "Option::is_none")] + pub lora: Option, + + /// The maximum number of tokens to generate in the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + + /// Increases the likelihood of introducing new topics (0 to 2). + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + + /// If `true`, bypasses chat templates and uses the model's raw format. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + + /// Penalty for repeated tokens (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub repetition_penalty: Option, + + /// Random seed for reproducibility (`1` to `9999999999`). + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// If `true`, streams the response incrementally using SSE. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + + /// Controls the randomness of the output (`0` to `5`). + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + + /// Limits the AI to top 'k' most probable words (`1` to `50`). + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + + /// Adjusts creativity of responses (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +/// Parameters for the `Messages` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct MessagesParams { + /// The conversation history as an array of message objects. + pub messages: Vec, + + /// Decreases the likelihood of repeating the same lines verbatim (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency_penalty: Option, + + /// An array of functions or tools available for the assistant. + #[serde(skip_serializing_if = "Option::is_none")] + pub functions: Option>, + + /// The maximum number of tokens to generate in the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + + /// Increases the likelihood of introducing new topics (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub presence_penalty: Option, + + /// Penalty for repeated tokens (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub repetition_penalty: Option, + + /// Random seed for reproducibility (`1` to `9999999999`). + #[serde(skip_serializing_if = "Option::is_none")] + pub seed: Option, + + /// If `true`, streams the response incrementally using SSE. + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + + /// Controls the randomness of the output (`0` to `5`). + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + + /// A list of tools available for the assistant. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + + /// Limits the AI to top `k` most probable words (`1` to `50`). + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + + /// Adjusts creativity of responses (`0` to `2`). + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, +} + +/// Represents a single message in a conversation. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Message { + /// The content of the message. + pub content: String, + + /// The role of the message sender (e.g., "user" or "assistant"). + pub role: MessageRole, +} + +impl Message { + pub fn system(content: String) -> Self { + Message { + content, + role: MessageRole::System, + } + } + + pub fn user(content: String) -> Self { + Message { + content, + role: MessageRole::User, + } + } + + pub fn assistant(content: String) -> Self { + Message { + content, + role: MessageRole::Assistant, + } + } +} + +#[derive(Copy, Clone, Debug, Deserialize, Serialize)] +pub enum MessageRole { + #[serde(rename = "system")] + System, + #[serde(rename = "user")] + User, + #[serde(rename = "assistant")] + Assistant, +} + +impl ToString for MessageRole { + fn to_string(&self) -> String { + match self { + MessageRole::System => "System".to_string(), + MessageRole::User => "User".to_string(), + MessageRole::Assistant => "Assistant".to_string(), + } + } +} + +/// Represents a function or tool available for use by the assistant. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AssistantFunction { + /// The function code. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + + /// The function name. + pub name: String, + + /// The function parameters (if applicable). + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +/// Represents a tool with additional details. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AssistantTool { + /// A description of the tool. + pub description: String, + + /// The name of the tool. + pub name: String, + + /// The parameters associated with the tool. + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +/// Parameters for the `Translation` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct TranslationParams { + /// The target language code (e.g., `"es"` for Spanish). + pub target_lang: String, + + /// The text to translate. Must be at least 1 character long. + pub text: String, + + /// The source language code. Defaults to `"en"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_lang: Option, +} + +/// Parameters for the `Summarization` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SummarizationParams { + /// The text to summarize. Must be at least 1 character long. + pub input_text: String, + + /// The maximum length of the generated summary in tokens. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, +} + +/// Parameters for the `ImageToText` task. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ImageToTextParams { + /// An array of integers representing the image data. + pub image: Vec, + + /// The maximum number of tokens to generate in the response. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + + /// The input text prompt for the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// If `true`, bypasses chat templates and uses the model's raw format. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + + /// Controls the randomness of the output; higher values produce more random results. + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, +} + +/// Enum representing various AI processing results, including text classification, +/// text-to-image generation, audio generation, and more. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ExecuteModelResult { + /// Results of text classification, containing an array of classification results. + TextClassification(Vec), + + /// The generated image in PNG format. + TextToImage(String), + + /// The generated audio in MP3 format, base64-encoded. + Audio(AudioResult), + + /// Text embeddings, containing a nested array of embedding values and their shape. + TextEmbeddings(TextEmbeddingsResult), + + /// Results of automatic speech recognition. + AutomaticSpeechRecognition(AutomaticSpeechRecognitionResult), + + /// Results of image classification, containing predicted categories and confidence scores. + ImageClassification(Vec), + + /// Results of object detection within an input image. + ObjectDetection(Vec), + + /// Generated text response and tool calls from the model. + ResponseAndToolCallsResult(ResponseAndToolCallsResult), + + /// Results of text translation into a target language. + Translation(TranslationResult), + + /// Results of text summarization. + Summarization(SummarizationResult), + + /// Generated description for an input image. + ImageToText(ImageToTextResult), +} + +impl ApiResult for ExecuteModelResult {} + +/// Represents a single text classification result. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextClassificationResult { + /// The classification label assigned to the text (e.g., `'POSITIVE'` or `'NEGATIVE'`). + pub label: String, + + /// Confidence score indicating the likelihood of the label. + pub score: f64, +} + +/// Represents the generated audio. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct AudioResult { + /// The generated audio in MP3 format, base64-encoded. + pub audio: String, +} + +/// Represents text embeddings. +/// +/// When the `ndarray` feature is enabled, the embeddings are automatically deserialized into an +/// `ndarray::ArrayD`. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextEmbeddingsResult { + #[cfg(feature = "ndarray")] + /// Embeddings of the requested text values. + pub data: ndarray::ArrayD, + + #[cfg(not(feature = "ndarray"))] + /// Embeddings of the requested text values. + pub data: Vec, + + /// The shape of the embedding array. + pub shape: Vec, +} + +/// Represents automatic speech recognition results. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AutomaticSpeechRecognitionResult { + /// The transcription of the audio. + pub text: String, + + /// The transcription in VTT format. + #[serde(skip_serializing_if = "Option::is_none")] + pub vtt: Option, + + /// The word count of the transcription. + #[serde(skip_serializing_if = "Option::is_none")] + pub word_count: Option, + + /// Array of words with timing information. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub words: Vec, +} + +/// Represents timing information for words in an automatic speech recognition result. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct WordTiming { + /// The start time of the word. + pub start: f64, + + /// The end time of the word. + pub end: f64, + + /// The word itself. + pub word: String, +} + +/// Represents a single image classification result. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ImageClassificationResult { + /// The predicted category or class for the input image. + pub label: String, + + /// Confidence score for the classification. + pub score: f64, +} + +/// Represents a single object detection result. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ObjectDetectionResult { + /// The bounding box around the detected object. + #[serde(rename = "box")] + pub bounding_box: BoundingBox, + + /// The class label or name of the detected object. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + + /// Confidence score for the object detection. + pub score: f64, +} + +/// Represents the bounding box coordinates for an object. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BoundingBox { + /// The minimum x-coordinate. + pub xmin: f64, + + /// The maximum x-coordinate. + pub xmax: f64, + + /// The minimum y-coordinate. + pub ymin: f64, + + /// The maximum y-coordinate. + pub ymax: f64, +} + +/// Represents a generated text response and tool calls from the model. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct ResponseAndToolCallsResult { + /// The generated text response. + pub response: String, + + /// Array of tool call requests made during the response generation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, + // TODO: Missing `usage` field +} + +/// Represents a single tool call request during response generation. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct ToolCall { + /// The name of the tool. + pub name: String, + + /// The arguments passed to the tool. + pub arguments: String, +} + +/// Represents translation results. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct TranslationResult { + /// The translated text in the target language. + pub translated_text: String, +} + +/// Represents summarization results. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct SummarizationResult { + /// The summarized text. + pub summary: String, +} + +/// Represents a generated description for an input image. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct ImageToTextResult { + /// Generated description for an input image. + pub description: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// This tests the use-case showcased on the website's Workers AI beta. + #[test] + fn test_deserialize_response_and_tool_calls_result() { + let json = r#" + {"response":"\"A short story\""} + "#; + + let response: ExecuteModelResult = serde_json::from_str(json).unwrap(); + assert!(matches!( + response, + ExecuteModelResult::ResponseAndToolCallsResult(_) + )); + } +} diff --git a/cloudflare/src/endpoints/ai/mod.rs b/cloudflare/src/endpoints/ai/mod.rs new file mode 100644 index 00000000..b00bdac2 --- /dev/null +++ b/cloudflare/src/endpoints/ai/mod.rs @@ -0,0 +1 @@ +pub mod execute_model; diff --git a/cloudflare/src/endpoints/argo_tunnel/create_tunnel.rs b/cloudflare/src/endpoints/argo_tunnel/create_tunnel.rs index d851d36e..2187353a 100644 --- a/cloudflare/src/endpoints/argo_tunnel/create_tunnel.rs +++ b/cloudflare/src/endpoints/argo_tunnel/create_tunnel.rs @@ -5,9 +5,9 @@ use serde_with::{ serde_as, }; -use crate::framework::endpoint::{EndpointSpec, Method}; - use super::Tunnel; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; /// Create a Named Argo Tunnel /// This creates the Tunnel, which can then be routed and ran. Creating the Tunnel per se is only @@ -19,7 +19,10 @@ pub struct CreateTunnel<'a> { pub params: Params<'a>, } -impl<'a> EndpointSpec for CreateTunnel<'a> { +impl EndpointSpec for CreateTunnel<'_> { + type JsonResponse = Tunnel; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -27,9 +30,9 @@ impl<'a> EndpointSpec for CreateTunnel<'a> { format!("accounts/{}/tunnels", self.account_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/argo_tunnel/delete_tunnel.rs b/cloudflare/src/endpoints/argo_tunnel/delete_tunnel.rs index caa6b2fe..806656f4 100644 --- a/cloudflare/src/endpoints/argo_tunnel/delete_tunnel.rs +++ b/cloudflare/src/endpoints/argo_tunnel/delete_tunnel.rs @@ -1,6 +1,6 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; - use super::Tunnel; +use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Delete a tunnel /// @@ -12,7 +12,10 @@ pub struct DeleteTunnel<'a> { pub cascade: bool, } -impl<'a> EndpointSpec for DeleteTunnel<'a> { +impl EndpointSpec for DeleteTunnel<'_> { + type JsonResponse = Tunnel; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/argo_tunnel/list_tunnels.rs b/cloudflare/src/endpoints/argo_tunnel/list_tunnels.rs index f112e468..2585274e 100644 --- a/cloudflare/src/endpoints/argo_tunnel/list_tunnels.rs +++ b/cloudflare/src/endpoints/argo_tunnel/list_tunnels.rs @@ -1,9 +1,9 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; - use super::Tunnel; +use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List/search tunnels in an account. /// @@ -13,7 +13,10 @@ pub struct ListTunnels<'a> { pub params: Params, } -impl<'a> EndpointSpec> for ListTunnels<'a> { +impl EndpointSpec for ListTunnels<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/argo_tunnel/route_dns.rs b/cloudflare/src/endpoints/argo_tunnel/route_dns.rs index 823101d3..46f4a0c3 100644 --- a/cloudflare/src/endpoints/argo_tunnel/route_dns.rs +++ b/cloudflare/src/endpoints/argo_tunnel/route_dns.rs @@ -1,6 +1,7 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; use super::RouteResult; +use crate::framework::response::ApiSuccess; use serde::Serialize; use uuid::Uuid; @@ -16,7 +17,10 @@ pub struct RouteTunnel<'a> { pub params: Params<'a>, } -impl<'a> EndpointSpec for RouteTunnel<'a> { +impl EndpointSpec for RouteTunnel<'_> { + type JsonResponse = RouteResult; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -24,9 +28,9 @@ impl<'a> EndpointSpec for RouteTunnel<'a> { format!("zones/{}/tunnels/{}/routes", self.zone_tag, self.tunnel_id) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/cfd_tunnel/create_tunnel.rs b/cloudflare/src/endpoints/cfd_tunnel/create_tunnel.rs index 7172218e..ffd74e58 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/create_tunnel.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/create_tunnel.rs @@ -6,7 +6,8 @@ use serde_with::{ serde_as, }; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; /// Create a Cfd Tunnel /// This creates the Tunnel, which can then be routed and ran. Creating the Tunnel per se is only @@ -18,7 +19,10 @@ pub struct CreateTunnel<'a> { pub params: Params<'a>, } -impl<'a> EndpointSpec for CreateTunnel<'a> { +impl EndpointSpec for CreateTunnel<'_> { + type JsonResponse = Tunnel; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -26,9 +30,9 @@ impl<'a> EndpointSpec for CreateTunnel<'a> { format!("accounts/{}/cfd_tunnel", self.account_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/cfd_tunnel/data_structures.rs b/cloudflare/src/endpoints/cfd_tunnel/data_structures.rs index 997e04a1..bb843906 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/data_structures.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/data_structures.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use crate::framework::response::ApiResult; /// A Cfd Tunnel -/// This is an Cfd Tunnel that has been created. It can be used for routing and subsequent running. +/// This is a Cfd Tunnel that has been created. It can be used for routing and subsequent running. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] pub struct Tunnel { pub id: Uuid, diff --git a/cloudflare/src/endpoints/cfd_tunnel/delete_tunnel.rs b/cloudflare/src/endpoints/cfd_tunnel/delete_tunnel.rs index d7348bb4..d8b68d03 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/delete_tunnel.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/delete_tunnel.rs @@ -1,8 +1,8 @@ +use super::Tunnel; use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; use serde::Serialize; -use super::Tunnel; - /// Delete a tunnel /// #[derive(Debug)] @@ -12,7 +12,10 @@ pub struct DeleteTunnel<'a> { pub params: Params, } -impl<'a> EndpointSpec for DeleteTunnel<'a> { +impl EndpointSpec for DeleteTunnel<'_> { + type JsonResponse = Tunnel; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/cfd_tunnel/list_tunnels.rs b/cloudflare/src/endpoints/cfd_tunnel/list_tunnels.rs index 7562cd66..3990c7f6 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/list_tunnels.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/list_tunnels.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List/search tunnels in an account. /// @@ -12,7 +13,10 @@ pub struct ListTunnels<'a> { pub params: Params, } -impl<'a> EndpointSpec> for ListTunnels<'a> { +impl EndpointSpec for ListTunnels<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/cfd_tunnel/route_dns.rs b/cloudflare/src/endpoints/cfd_tunnel/route_dns.rs index 823101d3..ae747661 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/route_dns.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/route_dns.rs @@ -1,6 +1,9 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; +// TODO: Exact same code as in argo_tunnel/route_dns.rs. Consider refactoring? + +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; use super::RouteResult; +use crate::framework::response::ApiSuccess; use serde::Serialize; use uuid::Uuid; @@ -16,7 +19,10 @@ pub struct RouteTunnel<'a> { pub params: Params<'a>, } -impl<'a> EndpointSpec for RouteTunnel<'a> { +impl EndpointSpec for RouteTunnel<'_> { + type JsonResponse = RouteResult; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -24,9 +30,9 @@ impl<'a> EndpointSpec for RouteTunnel<'a> { format!("zones/{}/tunnels/{}/routes", self.zone_tag, self.tunnel_id) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/cfd_tunnel/update_tunnel.rs b/cloudflare/src/endpoints/cfd_tunnel/update_tunnel.rs index b2c5aba8..79e080d4 100644 --- a/cloudflare/src/endpoints/cfd_tunnel/update_tunnel.rs +++ b/cloudflare/src/endpoints/cfd_tunnel/update_tunnel.rs @@ -6,7 +6,8 @@ use serde_with::{ serde_as, }; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; /// Create a Cfd Tunnel /// This creates the Tunnel, which can then be routed and ran. Creating the Tunnel per se is only @@ -19,7 +20,10 @@ pub struct UpdateTunnel<'a> { pub params: Params<'a>, } -impl<'a> EndpointSpec for UpdateTunnel<'a> { +impl EndpointSpec for UpdateTunnel<'_> { + type JsonResponse = Tunnel; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PATCH } @@ -30,9 +34,9 @@ impl<'a> EndpointSpec for UpdateTunnel<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/dns.rs b/cloudflare/src/endpoints/dns/dns.rs similarity index 86% rename from cloudflare/src/endpoints/dns.rs rename to cloudflare/src/endpoints/dns/dns.rs index b0766a95..66afd829 100644 --- a/cloudflare/src/endpoints/dns.rs +++ b/cloudflare/src/endpoints/dns/dns.rs @@ -1,7 +1,5 @@ -use crate::framework::{ - endpoint::{serialize_query, EndpointSpec, Method}, - response::ApiResult, -}; +use crate::framework::endpoint::{serialize_query, EndpointSpec, Method, RequestBody}; +use crate::framework::response::{ApiResult, ApiSuccess}; /// use crate::framework::{OrderDirection, SearchMatch}; use chrono::offset::Utc; @@ -16,7 +14,10 @@ pub struct ListDnsRecords<'a> { pub zone_identifier: &'a str, pub params: ListDnsRecordsParams, } -impl<'a> EndpointSpec> for ListDnsRecords<'a> { +impl EndpointSpec for ListDnsRecords<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -37,7 +38,10 @@ pub struct CreateDnsRecord<'a> { pub params: CreateDnsRecordParams<'a>, } -impl<'a> EndpointSpec for CreateDnsRecord<'a> { +impl EndpointSpec for CreateDnsRecord<'_> { + type JsonResponse = DnsRecord; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -45,9 +49,9 @@ impl<'a> EndpointSpec for CreateDnsRecord<'a> { format!("zones/{}/dns_records", self.zone_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } @@ -75,7 +79,10 @@ pub struct DeleteDnsRecord<'a> { pub zone_identifier: &'a str, pub identifier: &'a str, } -impl<'a> EndpointSpec for DeleteDnsRecord<'a> { +impl EndpointSpec for DeleteDnsRecord<'_> { + type JsonResponse = DeleteDnsRecordResponse; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } @@ -96,7 +103,10 @@ pub struct UpdateDnsRecord<'a> { pub params: UpdateDnsRecordParams<'a>, } -impl<'a> EndpointSpec for UpdateDnsRecord<'a> { +impl EndpointSpec for UpdateDnsRecord<'_> { + type JsonResponse = DnsRecord; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -107,9 +117,9 @@ impl<'a> EndpointSpec for UpdateDnsRecord<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } @@ -153,10 +163,7 @@ pub struct ListDnsRecordsParams { /// Extra Cloudflare-specific information about the record #[derive(Deserialize, Debug)] -pub struct Meta { - /// Will exist if Cloudflare automatically added this DNS record during initial setup. - pub auto_added: bool, -} +pub struct Meta {} /// Type of the DNS record, along with the associated value. /// When we add support for other types (LOC/SRV/...), the `meta` field should also probably be encoded @@ -188,8 +195,6 @@ pub struct DnsRecord { pub name: String, /// Time to live for DNS record. Value of 1 is 'automatic' pub ttl: u32, - /// Zone identifier tag - pub zone_id: String, /// When the record was last modified pub modified_on: DateTime, /// When the record was created @@ -203,8 +208,6 @@ pub struct DnsRecord { pub id: String, /// Whether the record is receiving the performance and security benefits of Cloudflare pub proxied: bool, - /// The domain of the record - pub zone_name: String, } impl ApiResult for DnsRecord {} diff --git a/cloudflare/src/endpoints/dns/mod.rs b/cloudflare/src/endpoints/dns/mod.rs new file mode 100644 index 00000000..4670a6a5 --- /dev/null +++ b/cloudflare/src/endpoints/dns/mod.rs @@ -0,0 +1 @@ +pub mod dns; diff --git a/cloudflare/src/endpoints/load_balancing/create_lb.rs b/cloudflare/src/endpoints/load_balancing/create_lb.rs index c079a781..b3dd1c6f 100644 --- a/cloudflare/src/endpoints/load_balancing/create_lb.rs +++ b/cloudflare/src/endpoints/load_balancing/create_lb.rs @@ -2,8 +2,9 @@ use crate::endpoints::load_balancing::{ LbPoolId, LbPoolMapping, LoadBalancer, SessionAffinity, SessionAffinityAttributes, SteeringPolicy, }; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// Create Load Balancer @@ -55,7 +56,10 @@ pub struct OptionalParams<'a> { pub session_affinity_ttl: Option, } -impl<'a> EndpointSpec for CreateLoadBalancer<'a> { +impl EndpointSpec for CreateLoadBalancer<'_> { + type JsonResponse = LoadBalancer; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -63,8 +67,8 @@ impl<'a> EndpointSpec for CreateLoadBalancer<'a> { format!("zones/{}/load_balancers", self.zone_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/load_balancing/create_pool.rs b/cloudflare/src/endpoints/load_balancing/create_pool.rs index f0f28fd2..9a1aaaa5 100644 --- a/cloudflare/src/endpoints/load_balancing/create_pool.rs +++ b/cloudflare/src/endpoints/load_balancing/create_pool.rs @@ -1,6 +1,7 @@ use crate::endpoints::load_balancing::{Origin, Pool}; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// Create Pool @@ -51,7 +52,10 @@ pub struct OptionalParams<'a> { pub notification_email: Option<&'a str>, } -impl<'a> EndpointSpec for CreatePool<'a> { +impl EndpointSpec for CreatePool<'_> { + type JsonResponse = Pool; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -59,8 +63,8 @@ impl<'a> EndpointSpec for CreatePool<'a> { format!("accounts/{}/load_balancers/pools", self.account_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/load_balancing/delete_lb.rs b/cloudflare/src/endpoints/load_balancing/delete_lb.rs index 6723e0a5..a663cb82 100644 --- a/cloudflare/src/endpoints/load_balancing/delete_lb.rs +++ b/cloudflare/src/endpoints/load_balancing/delete_lb.rs @@ -1,5 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; use serde::Deserialize; @@ -13,7 +13,10 @@ pub struct DeleteLoadBalancer<'a> { pub identifier: &'a str, } -impl<'a> EndpointSpec for DeleteLoadBalancer<'a> { +impl EndpointSpec for DeleteLoadBalancer<'_> { + type JsonResponse = Response; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/load_balancing/delete_pool.rs b/cloudflare/src/endpoints/load_balancing/delete_pool.rs index bae55a63..ad462610 100644 --- a/cloudflare/src/endpoints/load_balancing/delete_pool.rs +++ b/cloudflare/src/endpoints/load_balancing/delete_pool.rs @@ -1,5 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; use serde::Deserialize; @@ -13,7 +13,10 @@ pub struct DeletePool<'a> { pub identifier: &'a str, } -impl<'a> EndpointSpec for DeletePool<'a> { +impl EndpointSpec for DeletePool<'_> { + type JsonResponse = Response; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/load_balancing/list_lb.rs b/cloudflare/src/endpoints/load_balancing/list_lb.rs index 75066c59..7cfdacbb 100644 --- a/cloudflare/src/endpoints/load_balancing/list_lb.rs +++ b/cloudflare/src/endpoints/load_balancing/list_lb.rs @@ -1,6 +1,6 @@ use crate::endpoints::load_balancing::LoadBalancer; use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; /// List Load Balancers /// @@ -10,7 +10,10 @@ pub struct ListLoadBalancers<'a> { pub zone_identifier: &'a str, } -impl<'a> EndpointSpec> for ListLoadBalancers<'a> { +impl EndpointSpec for ListLoadBalancers<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/load_balancing/pool_details.rs b/cloudflare/src/endpoints/load_balancing/pool_details.rs index 8ff0d575..0eb95981 100644 --- a/cloudflare/src/endpoints/load_balancing/pool_details.rs +++ b/cloudflare/src/endpoints/load_balancing/pool_details.rs @@ -1,5 +1,6 @@ use crate::endpoints::load_balancing::Pool; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Pool Details /// @@ -11,7 +12,10 @@ pub struct PoolDetails<'a> { pub identifier: &'a str, } -impl<'a> EndpointSpec for PoolDetails<'a> { +impl EndpointSpec for PoolDetails<'_> { + type JsonResponse = Pool; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/mod.rs b/cloudflare/src/endpoints/mod.rs index a423977c..f590cc90 100644 --- a/cloudflare/src/endpoints/mod.rs +++ b/cloudflare/src/endpoints/mod.rs @@ -4,13 +4,12 @@ If you want to add a new Cloudflare API to this crate, simply add a new submodul module. */ pub mod account; +pub mod ai; pub mod argo_tunnel; pub mod cfd_tunnel; pub mod dns; pub mod load_balancing; -pub mod plan; pub mod r2; -pub mod user; pub mod workers; pub mod workerskv; -pub mod zone; +pub mod zones; diff --git a/cloudflare/src/endpoints/r2/mod.rs b/cloudflare/src/endpoints/r2/mod.rs new file mode 100644 index 00000000..c542d7b0 --- /dev/null +++ b/cloudflare/src/endpoints/r2/mod.rs @@ -0,0 +1 @@ +pub mod r2; diff --git a/cloudflare/src/endpoints/r2.rs b/cloudflare/src/endpoints/r2/r2.rs similarity index 80% rename from cloudflare/src/endpoints/r2.rs rename to cloudflare/src/endpoints/r2/r2.rs index 44626945..dae13daa 100644 --- a/cloudflare/src/endpoints/r2.rs +++ b/cloudflare/src/endpoints/r2/r2.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; /// A Bucket is a collection of Objects stored in R2. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] @@ -31,7 +31,10 @@ pub struct ListBuckets<'a> { pub account_identifier: &'a str, } -impl<'a> EndpointSpec for ListBuckets<'a> { +impl EndpointSpec for ListBuckets<'_> { + type JsonResponse = ListBucketsResult; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -49,7 +52,10 @@ pub struct CreateBucket<'a> { pub bucket_name: &'a str, } -impl<'a> EndpointSpec for CreateBucket<'a> { +impl EndpointSpec for CreateBucket<'_> { + type JsonResponse = EmptyMap; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -68,7 +74,10 @@ pub struct DeleteBucket<'a> { pub bucket_name: &'a str, } -impl<'a> EndpointSpec for DeleteBucket<'a> { +impl EndpointSpec for DeleteBucket<'_> { + type JsonResponse = EmptyMap; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/create_route.rs b/cloudflare/src/endpoints/workers/create_route.rs index 376d191b..ead47d74 100644 --- a/cloudflare/src/endpoints/workers/create_route.rs +++ b/cloudflare/src/endpoints/workers/create_route.rs @@ -1,7 +1,8 @@ use super::WorkersRouteIdOnly; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// Create a Route @@ -13,7 +14,10 @@ pub struct CreateRoute<'a> { pub params: CreateRouteParams, } -impl<'a> EndpointSpec for CreateRoute<'a> { +impl EndpointSpec for CreateRoute<'_> { + type JsonResponse = WorkersRouteIdOnly; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -21,9 +25,9 @@ impl<'a> EndpointSpec for CreateRoute<'a> { format!("zones/{}/workers/routes", self.zone_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/workers/create_secret.rs b/cloudflare/src/endpoints/workers/create_secret.rs index ff97f9a3..eb13c45b 100644 --- a/cloudflare/src/endpoints/workers/create_secret.rs +++ b/cloudflare/src/endpoints/workers/create_secret.rs @@ -1,7 +1,8 @@ use super::WorkersSecret; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// Create Secret @@ -16,7 +17,10 @@ pub struct CreateSecret<'a> { pub params: CreateSecretParams, } -impl<'a> EndpointSpec for CreateSecret<'a> { +impl EndpointSpec for CreateSecret<'_> { + type JsonResponse = WorkersSecret; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -27,9 +31,9 @@ impl<'a> EndpointSpec for CreateSecret<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/workers/create_tail.rs b/cloudflare/src/endpoints/workers/create_tail.rs index 4fe6f43a..be6bb79a 100644 --- a/cloudflare/src/endpoints/workers/create_tail.rs +++ b/cloudflare/src/endpoints/workers/create_tail.rs @@ -1,7 +1,8 @@ use super::WorkersTail; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; /// Create Tail @@ -20,7 +21,10 @@ pub struct CreateTail<'a> { pub params: CreateTailParams, } -impl<'a> EndpointSpec for CreateTail<'a> { +impl EndpointSpec for CreateTail<'_> { + type JsonResponse = WorkersTail; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -31,10 +35,10 @@ impl<'a> EndpointSpec for CreateTail<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { if self.params.url.is_some() { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } else { None } diff --git a/cloudflare/src/endpoints/workers/delete_do.rs b/cloudflare/src/endpoints/workers/delete_do.rs index ad224cb0..caaa131a 100644 --- a/cloudflare/src/endpoints/workers/delete_do.rs +++ b/cloudflare/src/endpoints/workers/delete_do.rs @@ -1,4 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Delete a Durable Object namespace #[derive(Debug)] @@ -9,7 +10,10 @@ pub struct DeleteDurableObject<'a> { pub namespace_id: &'a str, } -impl<'a> EndpointSpec<()> for DeleteDurableObject<'a> { +impl EndpointSpec for DeleteDurableObject<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/delete_route.rs b/cloudflare/src/endpoints/workers/delete_route.rs index ca252032..bb798976 100644 --- a/cloudflare/src/endpoints/workers/delete_route.rs +++ b/cloudflare/src/endpoints/workers/delete_route.rs @@ -1,6 +1,7 @@ use super::WorkersRouteIdOnly; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Delete a Route /// Deletes a route by route id @@ -11,7 +12,10 @@ pub struct DeleteRoute<'a> { pub identifier: &'a str, } -impl<'a> EndpointSpec for DeleteRoute<'a> { +impl EndpointSpec for DeleteRoute<'_> { + type JsonResponse = WorkersRouteIdOnly; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/delete_script.rs b/cloudflare/src/endpoints/workers/delete_script.rs index e249d3a7..be326b01 100644 --- a/cloudflare/src/endpoints/workers/delete_script.rs +++ b/cloudflare/src/endpoints/workers/delete_script.rs @@ -1,5 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; -use crate::framework::response::ApiResult; +use crate::framework::response::{ApiResult, ApiSuccess}; use serde::{Deserialize, Serialize}; @@ -13,7 +13,10 @@ pub struct DeleteScript<'a> { pub script_name: &'a str, } -impl<'a> EndpointSpec for DeleteScript<'a> { +impl EndpointSpec for DeleteScript<'_> { + type JsonResponse = ScriptDeleteID; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/delete_secret.rs b/cloudflare/src/endpoints/workers/delete_secret.rs index 09eee7ba..a25b86f3 100644 --- a/cloudflare/src/endpoints/workers/delete_secret.rs +++ b/cloudflare/src/endpoints/workers/delete_secret.rs @@ -1,4 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Delete Secret /// @@ -12,7 +13,10 @@ pub struct DeleteSecret<'a> { pub secret_name: &'a str, } -impl<'a> EndpointSpec<()> for DeleteSecret<'a> { +impl EndpointSpec for DeleteSecret<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/delete_tail.rs b/cloudflare/src/endpoints/workers/delete_tail.rs index 838d6270..4a96db56 100644 --- a/cloudflare/src/endpoints/workers/delete_tail.rs +++ b/cloudflare/src/endpoints/workers/delete_tail.rs @@ -1,4 +1,5 @@ use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Delete Tail /// @@ -12,7 +13,10 @@ pub struct DeleteTail<'a> { pub tail_id: &'a str, } -impl<'a> EndpointSpec<()> for DeleteTail<'a> { +impl EndpointSpec for DeleteTail<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workers/list_bindings.rs b/cloudflare/src/endpoints/workers/list_bindings.rs index 718b4e02..8a3c34dd 100644 --- a/cloudflare/src/endpoints/workers/list_bindings.rs +++ b/cloudflare/src/endpoints/workers/list_bindings.rs @@ -1,5 +1,6 @@ use super::WorkersBinding; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List Bindings /// Lists all bindings for a given script @@ -11,7 +12,10 @@ pub struct ListBindings<'a> { pub script_name: &'a str, } -impl<'a> EndpointSpec> for ListBindings<'a> { +impl EndpointSpec for ListBindings<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/workers/list_routes.rs b/cloudflare/src/endpoints/workers/list_routes.rs index bde68257..a8f18f5a 100644 --- a/cloudflare/src/endpoints/workers/list_routes.rs +++ b/cloudflare/src/endpoints/workers/list_routes.rs @@ -1,6 +1,7 @@ use super::WorkersRoute; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List Routes /// Lists all route mappings for a given zone @@ -10,7 +11,10 @@ pub struct ListRoutes<'a> { pub zone_identifier: &'a str, } -impl<'a> EndpointSpec> for ListRoutes<'a> { +impl EndpointSpec for ListRoutes<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/workers/list_secrets.rs b/cloudflare/src/endpoints/workers/list_secrets.rs index 773322dc..536f980f 100644 --- a/cloudflare/src/endpoints/workers/list_secrets.rs +++ b/cloudflare/src/endpoints/workers/list_secrets.rs @@ -1,6 +1,7 @@ use super::WorkersSecret; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List Secrets /// Lists all secrets mappings for a given script @@ -11,7 +12,10 @@ pub struct ListSecrets<'a> { pub script_name: &'a str, } -impl<'a> EndpointSpec> for ListSecrets<'a> { +impl EndpointSpec for ListSecrets<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/workers/list_tails.rs b/cloudflare/src/endpoints/workers/list_tails.rs index a7630b28..918b1474 100644 --- a/cloudflare/src/endpoints/workers/list_tails.rs +++ b/cloudflare/src/endpoints/workers/list_tails.rs @@ -1,6 +1,7 @@ use super::WorkersTail; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// List Tails /// Lists all active Tail sessions for a given Worker @@ -11,7 +12,10 @@ pub struct ListTails<'a> { pub script_name: &'a str, } -impl<'a> EndpointSpec> for ListTails<'a> { +impl EndpointSpec for ListTails<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/workers/mod.rs b/cloudflare/src/endpoints/workers/mod.rs index 2b590aae..734a4e85 100644 --- a/cloudflare/src/endpoints/workers/mod.rs +++ b/cloudflare/src/endpoints/workers/mod.rs @@ -84,13 +84,297 @@ impl ApiResult for WorkersTail {} impl ApiResult for Vec {} // Binding for a Workers Script -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] -pub struct WorkersBinding { - pub name: String, - pub r#type: String, - pub namespace_id: String, - pub class_name: Option, +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WorkersBinding { + Ai { + name: String, + }, + AnalyticsEngine { + name: String, + dataset: String, + }, + Assets { + name: String, + }, + BrowserRendering { + name: String, + }, + D1 { + name: String, + id: String, + }, + DurableObjectNamespace { + name: String, + class_name: String, + }, + Hyperdrive { + name: String, + id: String, + }, + KvNamespace { + name: String, + namespace_id: String, + }, + MtlsCertificate { + name: String, + certificate_id: String, + }, + PlainText { + name: String, + text: String, + }, + Queue { + name: String, + queue_name: String, + }, + R2Bucket { + name: String, + bucket_name: String, + }, + SecretText { + name: String, + // When fetching bindings, the text field of a Secret is not returned + text: Option, + }, + Service { + name: String, + service: String, + environment: String, + }, + TailConsumer { + service: String, + }, + Vectorize { + name: String, + index_name: String, + }, + VersionMetadata { + name: String, + }, } impl ApiResult for WorkersBinding {} impl ApiResult for Vec {} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use super::WorkersBinding; + + #[test] + fn test_deserializing_worker_bindings() { + // https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/#bindings + let payload = serde_json::json!( + [ + { + "type": "ai", + "name": "" + }, + { + "type": "analytics_engine", + "name": "", + "dataset": "" + }, + { + "type": "assets", + "name": "" + }, + { + "type": "browser_rendering", + "name": "" + }, + { + "type": "d1", + "name": "", + "id": "" + }, + { + "type": "durable_object_namespace", + "name": "", + "class_name": "" + }, + { + "type": "hyperdrive", + "name": "", + "id": "" + }, + { + "type": "kv_namespace", + "name": "", + "namespace_id": "" + }, + { + "type": "mtls_certificate", + "name": "", + "certificate_id": "" + }, + { + "type": "plain_text", + "name": "", + "text": "" + }, + { + "type": "queue", + "name": "", + "queue_name": "" + }, + { + "type": "r2_bucket", + "name": "", + "bucket_name": "" + }, + { + "type": "secret_text", + "name": "", + "text": "" + }, + { + "type": "service", + "name": "", + "service": "", + "environment": "production" + }, + { + "type": "tail_consumer", + "service": "" + }, + { + "type": "vectorize", + "name": "", + "index_name": "" + }, + { + "type": "version_metadata", + "name": "" + } + ] + ); + + let result: Result, serde_json::Error> = + serde_json::from_value(payload); + assert!(result.is_ok()); + + let mut bindings = VecDeque::from(result.unwrap()); + assert_eq!(17, bindings.len()); + + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Ai { + name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::AnalyticsEngine { + name: "".to_string(), + dataset: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Assets { + name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::BrowserRendering { + name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::D1 { + name: "".to_string(), + id: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::DurableObjectNamespace { + name: "".to_string(), + class_name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Hyperdrive { + name: "".to_string(), + id: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::KvNamespace { + name: "".to_string(), + namespace_id: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::MtlsCertificate { + name: "".to_string(), + certificate_id: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::PlainText { + name: "".to_string(), + text: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Queue { + name: "".to_string(), + queue_name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::R2Bucket { + name: "".to_string(), + bucket_name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::SecretText { + name: "".to_string(), + text: Some("".to_string()) + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Service { + name: "".to_string(), + service: "".to_string(), + environment: "production".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::TailConsumer { + service: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::Vectorize { + name: "".to_string(), + index_name: "".to_string() + } + ); + assert_eq!( + bindings.pop_front().unwrap(), + WorkersBinding::VersionMetadata { + name: "".to_string() + } + ); + + assert!(bindings.is_empty()); + } +} diff --git a/cloudflare/src/endpoints/workers/send_tail_heartbeat.rs b/cloudflare/src/endpoints/workers/send_tail_heartbeat.rs index 9da8deda..7750b54f 100644 --- a/cloudflare/src/endpoints/workers/send_tail_heartbeat.rs +++ b/cloudflare/src/endpoints/workers/send_tail_heartbeat.rs @@ -1,6 +1,7 @@ use super::WorkersTail; use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; /// Send Tail Heartbeat /// @@ -14,7 +15,10 @@ pub struct SendTailHeartbeat<'a> { pub tail_id: &'a str, } -impl<'a> EndpointSpec for SendTailHeartbeat<'a> { +impl EndpointSpec for SendTailHeartbeat<'_> { + type JsonResponse = WorkersTail; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } diff --git a/cloudflare/src/endpoints/workerskv/create_namespace.rs b/cloudflare/src/endpoints/workerskv/create_namespace.rs index 849b9974..55d29412 100644 --- a/cloudflare/src/endpoints/workerskv/create_namespace.rs +++ b/cloudflare/src/endpoints/workerskv/create_namespace.rs @@ -1,21 +1,26 @@ use super::WorkersKvNamespace; -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; -/// Create a Namespace /// Creates a namespace under the given title. -/// A 400 is returned if the account already owns a namespace with this title. +/// +/// A `400` is returned if the account already owns a namespace with this title. /// A namespace must be explicitly deleted to be replaced. -/// +/// +/// #[derive(Debug)] pub struct CreateNamespace<'a> { pub account_identifier: &'a str, pub params: CreateNamespaceParams, } -impl<'a> EndpointSpec for CreateNamespace<'a> { +impl EndpointSpec for CreateNamespace<'_> { + type JsonResponse = WorkersKvNamespace; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -23,9 +28,9 @@ impl<'a> EndpointSpec for CreateNamespace<'a> { format!("accounts/{}/storage/kv/namespaces", self.account_identifier) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/workerskv/delete_bulk.rs b/cloudflare/src/endpoints/workerskv/delete_bulk.rs index f29cc448..de425d25 100644 --- a/cloudflare/src/endpoints/workerskv/delete_bulk.rs +++ b/cloudflare/src/endpoints/workerskv/delete_bulk.rs @@ -1,9 +1,13 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::endpoints::workerskv::WorkersKvBulkResult; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; -/// Delete Key-Value Pairs in Bulk -/// Deletes multiple key-value pairs from Workers KV at once. -/// A 404 is returned if a delete action is for a namespace ID the account doesn't have. -/// +/// Remove multiple KV pairs from the namespace. +/// +/// Body should be an array of up to 10,000 keys to be removed. +/// A `404` is returned if a delete action is for a namespace ID the account doesn't have. +/// +/// #[derive(Debug)] pub struct DeleteBulk<'a> { pub account_identifier: &'a str, @@ -11,7 +15,10 @@ pub struct DeleteBulk<'a> { pub bulk_keys: Vec, } -impl<'a> EndpointSpec<()> for DeleteBulk<'a> { +impl EndpointSpec for DeleteBulk<'_> { + type JsonResponse = WorkersKvBulkResult; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } @@ -22,9 +29,12 @@ impl<'a> EndpointSpec<()> for DeleteBulk<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { + if self.bulk_keys.len() > 10_000 { + panic!("Bulk delete request can only contain up to 10,000 keys."); + } let body = serde_json::to_string(&self.bulk_keys).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } // default content-type is already application/json } diff --git a/cloudflare/src/endpoints/workerskv/delete_key.rs b/cloudflare/src/endpoints/workerskv/delete_key.rs index fe43d875..7b194a1c 100644 --- a/cloudflare/src/endpoints/workerskv/delete_key.rs +++ b/cloudflare/src/endpoints/workerskv/delete_key.rs @@ -1,9 +1,11 @@ use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; -/// Delete a key-value pair from Workers KV -/// Deletes a given key from the given namespace in Workers KV. -/// Returns 404 if the given namespace id is not found for an account. -/// +/// Remove a KV pair from the namespace. +/// +/// Use URL-encoding to use special characters (for example, `:`, `!`, `%`) in the key name. +/// +/// #[derive(Debug)] pub struct DeleteKey<'a> { pub account_identifier: &'a str, @@ -11,7 +13,10 @@ pub struct DeleteKey<'a> { pub key: &'a str, } -impl<'a> EndpointSpec<()> for DeleteKey<'a> { +impl EndpointSpec for DeleteKey<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workerskv/get_namespace.rs b/cloudflare/src/endpoints/workerskv/get_namespace.rs new file mode 100644 index 00000000..ed7e2723 --- /dev/null +++ b/cloudflare/src/endpoints/workerskv/get_namespace.rs @@ -0,0 +1,28 @@ +use crate::endpoints::workerskv::WorkersKvNamespace; +use crate::framework::endpoint::EndpointSpec; +use crate::framework::endpoint::Method; +use crate::framework::response::ApiSuccess; + +/// Get the namespace corresponding to the given ID. +/// +/// +#[derive(Debug)] +pub struct GetNamespace<'a> { + pub account_identifier: &'a str, + pub namespace_identifier: &'a str, +} + +impl EndpointSpec for GetNamespace<'_> { + type JsonResponse = WorkersKvNamespace; + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + Method::GET + } + fn path(&self) -> String { + format!( + "accounts/{}/storage/kv/namespaces/{}", + self.account_identifier, self.namespace_identifier, + ) + } +} diff --git a/cloudflare/src/endpoints/workerskv/list_namespace_keys.rs b/cloudflare/src/endpoints/workerskv/list_namespace_keys.rs index 9d75db67..5f25ccac 100644 --- a/cloudflare/src/endpoints/workerskv/list_namespace_keys.rs +++ b/cloudflare/src/endpoints/workerskv/list_namespace_keys.rs @@ -2,10 +2,12 @@ use super::Key; use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; use serde::Serialize; -/// List a Namespace's Keys -/// +/// Lists a namespace's keys. +/// +/// #[derive(Debug)] pub struct ListNamespaceKeys<'a> { pub account_identifier: &'a str, @@ -13,7 +15,10 @@ pub struct ListNamespaceKeys<'a> { pub params: ListNamespaceKeysParams, } -impl<'a> EndpointSpec> for ListNamespaceKeys<'a> { +impl EndpointSpec for ListNamespaceKeys<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } diff --git a/cloudflare/src/endpoints/workerskv/list_namespaces.rs b/cloudflare/src/endpoints/workerskv/list_namespaces.rs index adc57a9c..301af89a 100644 --- a/cloudflare/src/endpoints/workerskv/list_namespaces.rs +++ b/cloudflare/src/endpoints/workerskv/list_namespaces.rs @@ -2,18 +2,22 @@ use super::WorkersKvNamespace; use crate::framework::endpoint::{serialize_query, EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; use serde::Serialize; -/// List Namespaces -/// Returns the namespaces owned by an account -/// +/// Returns the namespaces owned by an account. +/// +/// #[derive(Debug)] pub struct ListNamespaces<'a> { pub account_identifier: &'a str, pub params: ListNamespacesParams, } -impl<'a> EndpointSpec> for ListNamespaces<'a> { +impl EndpointSpec for ListNamespaces<'_> { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -29,6 +33,20 @@ impl<'a> EndpointSpec> for ListNamespaces<'a> { #[serde_with::skip_serializing_none] #[derive(Serialize, Clone, Debug, Default)] pub struct ListNamespacesParams { + pub direction: Option, + pub order: Option, pub page: Option, pub per_page: Option, } + +#[derive(Serialize, Clone, Debug)] +pub enum Direction { + Asc, + Desc, +} + +#[derive(Serialize, Clone, Debug)] +pub enum Order { + Id, + Title, +} diff --git a/cloudflare/src/endpoints/workerskv/mod.rs b/cloudflare/src/endpoints/workerskv/mod.rs index 378fa2a6..c2b6c204 100644 --- a/cloudflare/src/endpoints/workerskv/mod.rs +++ b/cloudflare/src/endpoints/workerskv/mod.rs @@ -1,65 +1,59 @@ use crate::framework::response::ApiResult; use chrono::DateTime; use chrono::{TimeZone, Utc}; -use percent_encoding::{percent_encode, AsciiSet, CONTROLS}; use serde::{Deserialize, Deserializer, Serialize}; pub mod create_namespace; pub mod delete_bulk; pub mod delete_key; +pub mod get_namespace; pub mod list_namespace_keys; pub mod list_namespaces; +pub mod read_key; +pub mod read_key_metadata; pub mod remove_namespace; pub mod rename_namespace; pub mod write_bulk; - -// Upgrading to percent_encode 2.x unfortunately removed this prebaked const. -// We need to re-assemble it by combining "control" ASCII characters with other characters -// which are invalid or reserved in URIs. Non-ASCII characters are always encoded. - -// https://docs.rs/percent-encoding/1.0.0/src/percent_encoding/lib.rs.html#104 -const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS - // "QUERY_ENCODE_SET" additions: - .add(b' ') - .add(b'"') - .add(b'#') - .add(b'<') - .add(b'>') - // "DEFAULT_ENCODE_SET" additions: - .add(b'`') - .add(b'?') - .add(b'{') - .add(b'}') - // "PATH_SEGMENT_ENCODE_SET" additions - .add(b'%') - .add(b'/') - // The following were NOT in PATH_SEGMENT but are URI reserved characters not covered above. - // ':' and '@' are explicitly permitted in paths, so we don't add them. - .add(b'[') - .add(b']'); +pub mod write_key; /// Workers KV Namespace +/// /// A Namespace is a collection of key-value pairs stored in Workers KV. -/// +/// +/// #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] pub struct WorkersKvNamespace { /// Namespace identifier tag. pub id: String, /// A human-readable string name for a Namespace. pub title: String, + /// True if keys written on the URL will be URL-decoded before storing. + /// For example, if set to "true", a key written on the URL as "%3F" will be stored as "?". + pub supports_url_encoding: Option, } impl ApiResult for WorkersKvNamespace {} impl ApiResult for Vec {} +/// A name for a value. A value stored under a given key may be retrieved via the same key. #[serde_with::skip_serializing_none] #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] pub struct Key { + /// A key's name. The name may be at most 512 bytes. + /// All printable, non-whitespace characters are valid. + /// Use percent-encoding to define key names as part of a URL. pub name: String, + + /// The time, measured in number of seconds since the UNIX epoch, at which the key will expire. + /// This property is omitted for keys that will not expire. #[serde(default)] #[serde(deserialize_with = "deserialize_option_timestamp")] pub expiration: Option>, + + /// Arbitrary JSON that is associated with a key. + #[serde(default)] + pub metadata: Option, } pub fn deserialize_option_timestamp<'de, D>( @@ -77,9 +71,21 @@ where } impl ApiResult for Key {} - impl ApiResult for Vec {} fn url_encode_key(key: &str) -> String { - percent_encode(key.as_bytes(), PATH_SEGMENT_ENCODE_SET).to_string() + urlencoding::encode(key).to_string() } + +#[serde_with::skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct WorkersKvBulkResult { + /// Number of keys successfully updated. + pub successful_key_count: Option, + + /// Name of the keys that failed to be fully updated. They should be retried. + // TODO: Ambiguity with the official docs; it does not seem to be optional. It's an empty array if no keys failed. + pub unsuccessful_keys: Option>, +} + +impl ApiResult for WorkersKvBulkResult {} diff --git a/cloudflare/src/endpoints/workerskv/read_key.rs b/cloudflare/src/endpoints/workerskv/read_key.rs new file mode 100644 index 00000000..e3c57767 --- /dev/null +++ b/cloudflare/src/endpoints/workerskv/read_key.rs @@ -0,0 +1,38 @@ +use crate::framework::endpoint::EndpointSpec; +use crate::framework::endpoint::Method; +use crate::framework::response::ApiResult; + +/// Returns the value associated with the given key in the given namespace. +/// +/// Use URL-encoding to use special characters (for example, `:`, `!`, `%`) in the key name. +/// If the KV-pair is set to expire at some point, the expiration time as measured in seconds since +/// the UNIX epoch will be returned in the expiration response header. +/// +/// +#[derive(Debug)] +pub struct ReadKey<'a> { + pub account_identifier: &'a str, + pub namespace_identifier: &'a str, + pub key: &'a str, +} + +impl ApiResult for Vec {} + +impl EndpointSpec for ReadKey<'_> { + const IS_RAW_BODY: bool = true; + + type JsonResponse = (); + type ResponseType = Vec; + + fn method(&self) -> Method { + Method::GET + } + fn path(&self) -> String { + format!( + "accounts/{}/storage/kv/namespaces/{}/values/{}", + self.account_identifier, + self.namespace_identifier, + super::url_encode_key(self.key) + ) + } +} diff --git a/cloudflare/src/endpoints/workerskv/read_key_metadata.rs b/cloudflare/src/endpoints/workerskv/read_key_metadata.rs new file mode 100644 index 00000000..f225f29e --- /dev/null +++ b/cloudflare/src/endpoints/workerskv/read_key_metadata.rs @@ -0,0 +1,34 @@ +use crate::framework::endpoint::EndpointSpec; +use crate::framework::endpoint::Method; +use crate::framework::response::{ApiResult, ApiSuccess}; + +/// Returns the metadata associated with the given key in the given namespace. +/// +/// Use URL-encoding to use special characters (for example, `:`, `!`, `%`) in the key name. +/// +/// +#[derive(Debug)] +pub struct ReadKeyMetadata<'a> { + pub account_identifier: &'a str, + pub namespace_identifier: &'a str, + pub key: &'a str, +} + +impl ApiResult for Option {} + +impl EndpointSpec for ReadKeyMetadata<'_> { + type JsonResponse = Option; + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + Method::GET + } + fn path(&self) -> String { + format!( + "accounts/{}/storage/kv/namespaces/{}/metadata/{}", + self.account_identifier, + self.namespace_identifier, + super::url_encode_key(self.key) + ) + } +} diff --git a/cloudflare/src/endpoints/workerskv/remove_namespace.rs b/cloudflare/src/endpoints/workerskv/remove_namespace.rs index 3c9c87d0..4cfdf8ff 100644 --- a/cloudflare/src/endpoints/workerskv/remove_namespace.rs +++ b/cloudflare/src/endpoints/workerskv/remove_namespace.rs @@ -1,15 +1,19 @@ use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::ApiSuccess; -/// Remove a Namespace /// Deletes the namespace corresponding to the given ID. -/// +/// +/// #[derive(Debug)] pub struct RemoveNamespace<'a> { pub account_identifier: &'a str, pub namespace_identifier: &'a str, } -impl<'a> EndpointSpec<()> for RemoveNamespace<'a> { +impl EndpointSpec for RemoveNamespace<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::DELETE } diff --git a/cloudflare/src/endpoints/workerskv/rename_namespace.rs b/cloudflare/src/endpoints/workerskv/rename_namespace.rs index 17dc9b7d..9e6d4c37 100644 --- a/cloudflare/src/endpoints/workerskv/rename_namespace.rs +++ b/cloudflare/src/endpoints/workerskv/rename_namespace.rs @@ -1,10 +1,11 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::framework::response::ApiSuccess; use serde::Serialize; -/// Rename a Namespace /// Modifies a namespace's title. -/// +/// +/// #[derive(Debug)] pub struct RenameNamespace<'a> { pub account_identifier: &'a str, @@ -12,7 +13,10 @@ pub struct RenameNamespace<'a> { pub params: RenameNamespaceParams, } -impl<'a> EndpointSpec<()> for RenameNamespace<'a> { +impl EndpointSpec for RenameNamespace<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -23,9 +27,9 @@ impl<'a> EndpointSpec<()> for RenameNamespace<'a> { ) } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } diff --git a/cloudflare/src/endpoints/workerskv/write_bulk.rs b/cloudflare/src/endpoints/workerskv/write_bulk.rs index c59f889a..e0a6ab36 100644 --- a/cloudflare/src/endpoints/workerskv/write_bulk.rs +++ b/cloudflare/src/endpoints/workerskv/write_bulk.rs @@ -1,11 +1,22 @@ -use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::endpoint::{EndpointSpec, Method, RequestBody}; +use crate::endpoints::workerskv::WorkersKvBulkResult; +use crate::framework::response::ApiSuccess; use serde::{Deserialize, Serialize}; -/// Write Key-Value Pairs in Bulk -/// Writes multiple key-value pairs to Workers KV at once. -/// A 404 is returned if a write action is for a namespace ID the account doesn't have. -/// +/// Write multiple keys and values at once. +/// +/// Body should be an array of up to 10,000 key-value pairs to be stored, along with optional expiration information. +/// +/// Existing values and expirations will be overwritten. +/// If neither expiration nor expiration_ttl is specified, the key-value pair will never expire. +/// If both are set, expiration_ttl is used and expiration is ignored. +/// +/// The entire request size must be 100 megabytes or less. +/// +/// A `404` is returned if a write action is for a namespace ID the account doesn't have. +/// +/// #[derive(Debug)] pub struct WriteBulk<'a> { pub account_identifier: &'a str, @@ -13,7 +24,10 @@ pub struct WriteBulk<'a> { pub bulk_key_value_pairs: Vec, } -impl<'a> EndpointSpec<()> for WriteBulk<'a> { +impl EndpointSpec for WriteBulk<'_> { + type JsonResponse = WorkersKvBulkResult; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::PUT } @@ -23,15 +37,19 @@ impl<'a> EndpointSpec<()> for WriteBulk<'a> { self.account_identifier, self.namespace_identifier ) } - #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { + if self.bulk_key_value_pairs.len() > 10_000 { + panic!("Bulk write request must have 10,000 key-value pairs or less."); + } + // TODO: The entire request size must be 100 megabytes or less. let body = serde_json::to_string(&self.bulk_key_value_pairs).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } // default content-type is already application/json } +// TODO: Does not reflect the API documentation, but having everything Optional doesn't make sense either #[serde_with::skip_serializing_none] #[derive(Serialize, Deserialize, Clone, Debug)] pub struct KeyValuePair { diff --git a/cloudflare/src/endpoints/workerskv/write_key.rs b/cloudflare/src/endpoints/workerskv/write_key.rs new file mode 100644 index 00000000..927002e8 --- /dev/null +++ b/cloudflare/src/endpoints/workerskv/write_key.rs @@ -0,0 +1,110 @@ +use crate::framework::endpoint::{serialize_query, EndpointSpec, MultipartBody, MultipartPart}; +use crate::framework::endpoint::{Method, RequestBody}; +use crate::framework::response::ApiSuccess; +use serde::Serialize; +use std::borrow::Cow; + +/// Write a value identified by a key. +/// +/// Use URL-encoding to use special characters (for example, `:`, `!`, `%`) in the key name. +/// +/// Body should be the value to be stored. +/// If JSON metadata to be associated with the key/value pair is needed, use multipart/form-data +/// content type for your PUT request (see dropdown below in REQUEST BODY SCHEMA). +/// +/// Existing values, expirations, and metadata will be overwritten. If neither expiration nor +/// expiration_ttl is specified, the key-value pair will never expire. +/// If both are set, expiration_ttl is used and expiration is ignored. +/// +/// +#[derive(Debug)] +pub struct WriteKey<'a> { + /// Identifier + pub account_identifier: &'a str, + /// Namespace identifier tag. + pub namespace_identifier: &'a str, + /// A key's name. The name may be at most 512 bytes. + /// All printable, non-whitespace characters are valid. + /// Use percent-encoding to define key names as part of a URL. + pub key: &'a str, + /// Parameters + pub params: WriteKeyParams, + /// Body + pub body: WriteKeyBody, +} + +impl EndpointSpec for WriteKey<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + Method::PUT + } + fn path(&self) -> String { + format!( + "accounts/{}/storage/kv/namespaces/{}/values/{}", + self.account_identifier, + self.namespace_identifier, + super::url_encode_key(self.key) + ) + } + #[inline] + fn query(&self) -> Option { + serialize_query(&self.params) + } + #[inline] + fn body(&self) -> Option { + match &self.body { + WriteKeyBody::Value(value) => Some(RequestBody::Raw(value.clone())), + WriteKeyBody::Metadata(metadata) => Some(RequestBody::MultiPart(metadata)), + } + } + fn content_type(&self) -> Option> { + match &self.body { + WriteKeyBody::Value(_) => Some(Cow::Borrowed("application/octet-stream")), + WriteKeyBody::Metadata(_) => Some(Cow::Borrowed("multipart/form-data")), + } + } +} + +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, Default)] +pub struct WriteKeyParams { + /// The time, measured in number of seconds since the UNIX epoch, at which the key should expire. + pub expiration: Option, + /// The number of seconds for which the key should be visible before it expires. At least 60. + pub expiration_ttl: Option, +} + +#[derive(Serialize, Clone, Debug)] +pub struct WriteKeyBodyMetadata { + /// The value to store. + pub value: Vec, + /// Arbitrary JSON that is associated with a key. + pub metadata: serde_json::Value, +} + +impl MultipartBody for WriteKeyBodyMetadata { + fn parts(&self) -> Vec<(String, MultipartPart)> { + vec![ + ( + "metadata".to_string(), + MultipartPart::Text( + serde_json::to_string(&self.metadata).expect("Failed to serialize metadata"), + ), + ), + ( + "value".to_string(), + MultipartPart::Bytes(self.value.clone()), + ), + ] + } +} + +#[derive(Serialize, Clone, Debug)] +pub enum WriteKeyBody { + /// The value to store. + Value(Vec), + /// The value to store with metadata. + Metadata(WriteKeyBodyMetadata), +} diff --git a/cloudflare/src/endpoints/zones/mod.rs b/cloudflare/src/endpoints/zones/mod.rs new file mode 100644 index 00000000..770957a2 --- /dev/null +++ b/cloudflare/src/endpoints/zones/mod.rs @@ -0,0 +1,2 @@ +pub mod plan; +pub mod zone; diff --git a/cloudflare/src/endpoints/plan.rs b/cloudflare/src/endpoints/zones/plan.rs similarity index 100% rename from cloudflare/src/endpoints/plan.rs rename to cloudflare/src/endpoints/zones/plan.rs diff --git a/cloudflare/src/endpoints/zone.rs b/cloudflare/src/endpoints/zones/zone.rs similarity index 83% rename from cloudflare/src/endpoints/zone.rs rename to cloudflare/src/endpoints/zones/zone.rs index 1ab3f125..15726c47 100644 --- a/cloudflare/src/endpoints/zone.rs +++ b/cloudflare/src/endpoints/zones/zone.rs @@ -1,9 +1,8 @@ -use crate::endpoints::{account::AccountDetails, plan::Plan}; -use crate::framework::endpoint::serialize_query; -use crate::framework::{ - endpoint::{EndpointSpec, Method}, - response::ApiResult, -}; +use crate::endpoints::account::AccountDetails; +use crate::endpoints::zones::plan::Plan; +use crate::framework::endpoint::{serialize_query, RequestBody}; +use crate::framework::endpoint::{EndpointSpec, Method}; +use crate::framework::response::{ApiResult, ApiSuccess}; use crate::framework::{OrderDirection, SearchMatch}; use chrono::offset::Utc; use chrono::DateTime; @@ -17,7 +16,10 @@ pub struct ListZones { pub params: ListZonesParams, } -impl EndpointSpec> for ListZones { +impl EndpointSpec for ListZones { + type JsonResponse = Vec; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -36,7 +38,10 @@ impl EndpointSpec> for ListZones { pub struct ZoneDetails<'a> { pub identifier: &'a str, } -impl<'a> EndpointSpec for ZoneDetails<'a> { +impl EndpointSpec for ZoneDetails<'_> { + type JsonResponse = Zone; + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::GET } @@ -50,7 +55,10 @@ impl<'a> EndpointSpec for ZoneDetails<'a> { pub struct CreateZone<'a> { pub params: CreateZoneParams<'a>, } -impl<'a> EndpointSpec<()> for CreateZone<'a> { +impl EndpointSpec for CreateZone<'_> { + type JsonResponse = (); + type ResponseType = ApiSuccess; + fn method(&self) -> Method { Method::POST } @@ -60,9 +68,9 @@ impl<'a> EndpointSpec<()> for CreateZone<'a> { } #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { let body = serde_json::to_string(&self.params).unwrap(); - Some(body) + Some(RequestBody::Json(body)) } } @@ -109,8 +117,14 @@ pub enum Status { #[derive(Deserialize, Debug)] #[serde(rename_all = "lowercase", tag = "type")] pub enum Owner { - User { id: String, email: String }, - Organization { id: String, name: String }, + User { + id: Option, + email: Option, + }, + Organization { + id: Option, + name: Option, + }, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -136,8 +150,6 @@ pub struct Meta { pub page_rule_quota: u32, /// Indicates if URLs on the zone have been identified as hosting phishing content. pub phishing_detected: bool, - /// Indicates whether the zone is allowed to be connected to multiple Railguns at once - pub multiple_railguns_allowed: bool, } /// A Zone is a domain name along with its subdomains and other identities @@ -150,6 +162,8 @@ pub struct Zone { pub name: String, /// Information about the account the zone belongs to pub account: AccountDetails, + /// The last time proof of ownership was detected and the zone was made active + pub activated_on: DateTime, /// A list of beta features in which the zone is participating pub betas: Option>, /// When the zone was created @@ -183,6 +197,7 @@ pub struct Zone { /// Available permissions on the zone for the current user requesting the item pub permissions: Vec, /// A zone plan + // TODO: Correct, but undocumented in the official API docs nor in the official TypeScript library. What should we do? pub plan: Option, /// A zone plan pub plan_pending: Option, diff --git a/cloudflare/src/framework/async_api.rs b/cloudflare/src/framework/async_api.rs deleted file mode 100644 index 006fed7e..00000000 --- a/cloudflare/src/framework/async_api.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::framework::{ - auth, - auth::{AuthClient, Credentials}, - endpoint::Endpoint, - response::{ApiErrors, ApiFailure, ApiSuccess}, - response::{ApiResponse, ApiResult}, - Environment, HttpApiClientConfig, -}; -use std::net::SocketAddr; - -/// A Cloudflare API client that makes requests asynchronously. -pub struct Client { - environment: Environment, - credentials: auth::Credentials, - http_client: reqwest::Client, -} - -impl AuthClient for reqwest::RequestBuilder { - fn auth(mut self, credentials: &Credentials) -> Self { - for (k, v) in credentials.headers() { - self = self.header(k, v); - } - self - } -} - -impl Client { - pub fn new( - credentials: auth::Credentials, - config: HttpApiClientConfig, - environment: Environment, - ) -> Result { - let mut builder = reqwest::Client::builder().default_headers(config.default_headers); - - #[cfg(not(target_arch = "wasm32"))] - { - // There is no resolve method in wasm. - if let Some(address) = config.resolve_ip { - let url = url::Url::from(&environment); - builder = builder.resolve( - url.host_str() - .expect("Environment url should have a hostname"), - SocketAddr::new(address, 443), - ); - } - - // There are no timeouts in wasm. The property is documented as no-op in wasm32. - builder = builder.timeout(config.http_timeout); - } - - let http_client = builder.build()?; - - Ok(Client { - environment, - credentials, - http_client, - }) - } - - /// Issue an API request of the given type. - pub async fn request( - &self, - endpoint: &(dyn Endpoint + Send + Sync), - ) -> ApiResponse - where - ResultType: ApiResult, - { - // Build the request - let mut request = self - .http_client - .request(endpoint.method(), endpoint.url(&self.environment)); - - if let Some(body) = endpoint.body() { - request = request.body(body); - request = request.header( - reqwest::header::CONTENT_TYPE, - endpoint.content_type().as_ref(), - ); - } - - request = request.auth(&self.credentials); - let response = request.send().await?; - map_api_response(response).await - } -} - -// If the response is 2XX and parses, return Success. -// If the response is 2XX and doesn't parse, return Invalid. -// If the response isn't 2XX, return Failure, with API errors if they were included. -async fn map_api_response( - resp: reqwest::Response, -) -> ApiResponse { - let status = resp.status(); - if status.is_success() { - let parsed: Result, reqwest::Error> = resp.json().await; - match parsed { - Ok(api_resp) => Ok(api_resp), - Err(e) => Err(ApiFailure::Invalid(e)), - } - } else { - let parsed: Result = resp.json().await; - let errors = parsed.unwrap_or_default(); - Err(ApiFailure::Error(status, errors)) - } -} diff --git a/cloudflare/src/framework/blocking_api.rs b/cloudflare/src/framework/blocking_api.rs deleted file mode 100644 index c3a24f62..00000000 --- a/cloudflare/src/framework/blocking_api.rs +++ /dev/null @@ -1,75 +0,0 @@ -use reqwest::blocking::RequestBuilder; -use std::net::SocketAddr; - -use crate::framework::auth::Credentials; -use crate::framework::{ - auth, auth::AuthClient, endpoint, response, response::map_api_response, Environment, - HttpApiClient, HttpApiClientConfig, -}; - -impl HttpApiClient { - pub fn new( - credentials: auth::Credentials, - config: HttpApiClientConfig, - environment: Environment, - ) -> Result { - let mut builder = reqwest::blocking::Client::builder() - .timeout(config.http_timeout) - .default_headers(config.default_headers); - - if let Some(address) = config.resolve_ip { - let url = url::Url::from(&environment); - builder = builder.resolve( - url.host_str() - .expect("Environment url should have a hostname"), - SocketAddr::new(address, 443), - ); - } - let http_client = builder.build()?; - - Ok(HttpApiClient { - environment, - credentials, - http_client, - }) - } - - // TODO: This should probably just implement request for the Reqwest client itself :) - // TODO: It should also probably be called `ReqwestApiClient` rather than `HttpApiClient`. - /// Synchronously send a request to the Cloudflare API. - pub fn request( - &self, - endpoint: &dyn endpoint::Endpoint, - ) -> response::ApiResponse - where - ResultType: response::ApiResult, - { - // Build the request - let mut request = self - .http_client - .request(endpoint.method(), endpoint.url(&self.environment)); - - if let Some(body) = endpoint.body() { - request = request.body(body); - request = request.header( - reqwest::header::CONTENT_TYPE, - endpoint.content_type().as_ref(), - ); - } - - request = request.auth(&self.credentials); - - let response = request.send()?; - - map_api_response(response) - } -} - -impl AuthClient for RequestBuilder { - fn auth(mut self, credentials: &Credentials) -> Self { - for (k, v) in credentials.headers() { - self = self.header(k, v); - } - self - } -} diff --git a/cloudflare/src/framework/client/async_api.rs b/cloudflare/src/framework/client/async_api.rs new file mode 100644 index 00000000..319b2242 --- /dev/null +++ b/cloudflare/src/framework/client/async_api.rs @@ -0,0 +1,597 @@ +use crate::framework::client::ClientConfig; +use crate::framework::endpoint::{EndpointSpec, MultipartPart, RequestBody}; +use crate::framework::response::ResponseConverter; +use crate::framework::{ + auth::{AuthClient, Credentials}, + response::ApiResponse, + response::{ApiErrors, ApiFailure, ApiSuccess}, + Environment, +}; +use std::borrow::Cow; +use std::net::SocketAddr; + +/// A Cloudflare API client that makes requests asynchronously. +// TODO: Rename to AsyncClient? +pub struct Client { + environment: Environment, + credentials: Credentials, + http_client: reqwest::Client, +} + +impl AuthClient for reqwest::RequestBuilder { + fn auth(mut self, credentials: &Credentials) -> Self { + for (k, v) in credentials.headers() { + self = self.header(k, v); + } + self + } +} + +impl Client { + pub fn new( + credentials: Credentials, + config: ClientConfig, + environment: Environment, + ) -> Result { + let mut builder = reqwest::Client::builder().default_headers(config.default_headers); + + #[cfg(not(target_arch = "wasm32"))] + { + // There is no resolve method in wasm. + if let Some(address) = config.resolve_ip { + let url = url::Url::from(&environment); + builder = builder.resolve( + url.host_str() + .expect("Environment url should have a hostname"), + SocketAddr::new(address, 443), + ); + } + + // There are no timeouts in wasm. The property is documented as no-op in wasm32. + builder = builder.timeout(config.http_timeout); + } + + let http_client = builder.build()?; + + Ok(Client { + environment, + credentials, + http_client, + }) + } + + //noinspection RsConstantConditionIf + /// Issue an API request of the given type. + pub async fn request( + &self, + endpoint: &Endpoint, + ) -> ApiResponse + where + Endpoint: EndpointSpec + Send + Sync, + Endpoint::ResponseType: ResponseConverter, + { + // Build the request + let mut request = self + .http_client + .request(endpoint.method(), endpoint.url(&self.environment)); + + if let Some(body) = endpoint.body() { + match body { + RequestBody::Json(json) => { + request = request.body(json); + } + RequestBody::Raw(bytes) => { + request = request.body(bytes); + } + RequestBody::MultiPart(multipart) => { + let mut form = reqwest::multipart::Form::new(); + for (name, part) in multipart.parts() { + match part { + MultipartPart::Text(text) => { + form = form.text(name, text); + } + MultipartPart::Bytes(bytes) => { + form = form.part(name, reqwest::multipart::Part::bytes(bytes)); + } + } + } + request = request.multipart(form); + } + } + // Reqwest::RequestBuilder::multipart sets the content type for us. + match endpoint.content_type() { + None | Some(Cow::Borrowed("multipart/form-data")) => {} + Some(content_type) => { + request = request.header(reqwest::header::CONTENT_TYPE, content_type.as_ref()); + } + } + } + + request = request.auth(&self.credentials); + let response = request.send().await?; + + // The condition is necessary, even if a warning is present. + // The constant is overridden in some cases. + if Endpoint::IS_RAW_BODY { + map_api_response_raw::(response).await + } else { + map_api_response_json::(response).await + } + } +} + +// If the response is 2XX and parses, return Success. +// If the response is 2XX and doesn't parse, return Invalid. +// If the response isn't 2XX, return Failure, with API errors if they were included. +async fn map_api_response_raw( + resp: reqwest::Response, +) -> Result +where + Endpoint: EndpointSpec, + Endpoint::ResponseType: ResponseConverter, +{ + let status = resp.status(); + if status.is_success() { + let bytes = resp.bytes().await.map_err(ApiFailure::Invalid)?.to_vec(); + Ok(Endpoint::ResponseType::from_raw(bytes)) + } else { + let parsed: Result = resp.json().await; + let errors = parsed.unwrap_or_default(); + Err(ApiFailure::Error(status, errors)) + } +} + +async fn map_api_response_json( + resp: reqwest::Response, +) -> Result +where + Endpoint: EndpointSpec, + Endpoint::ResponseType: ResponseConverter, +{ + let status = resp.status(); + if status.is_success() { + let parsed: Result, reqwest::Error> = resp.json().await; + match parsed { + Ok(success) => Ok(Endpoint::ResponseType::from_json(success)), + Err(e) => Err(ApiFailure::Invalid(e)), + } + } else { + let parsed: Result = resp.json().await; + let errors = parsed.unwrap_or_default(); + Err(ApiFailure::Error(status, errors)) + } +} + +// TODO: Refactor this to test the blocking_api as well +#[cfg(test)] +mod tests { + use super::*; + use crate::framework::auth::Credentials; + use crate::framework::client::ClientConfig; + use crate::framework::endpoint::RequestBody; + use crate::framework::endpoint::{serialize_query, EndpointSpec}; + use crate::framework::response::{ApiFailure, ApiResult, ApiSuccess}; + use crate::framework::Environment; + use mockito::{Matcher, Server}; + use regex; + use regex::Regex; + use serde::{Deserialize, Serialize}; + use serde_json::json; + use tokio; + + //region Endpoint that returns JSON (ApiSuccess). + #[derive(Debug)] + struct DummyJsonEndpoint; + + #[derive(Debug, Deserialize)] + struct DummyJsonResponse { + message: String, + } + + impl ApiResult for DummyJsonResponse {} + + impl EndpointSpec for DummyJsonEndpoint { + type JsonResponse = DummyJsonResponse; + type ResponseType = ApiSuccess; + + fn method(&self) -> reqwest::Method { + reqwest::Method::GET + } + + fn path(&self) -> String { + "/dummy/json".into() + } + } + //endregion + + //region Endpoint that returns raw bytes. + #[derive(Debug)] + struct DummyRawEndpoint; + + impl EndpointSpec for DummyRawEndpoint { + const IS_RAW_BODY: bool = true; + type JsonResponse = (); + type ResponseType = Vec; + + fn method(&self) -> reqwest::Method { + reqwest::Method::GET + } + + fn path(&self) -> String { + "/dummy/raw".into() + } + } + //endregion + + //region Endpoint that returns nothing. + #[derive(Debug)] + struct DummyNothingEndpoint; + + impl EndpointSpec for DummyNothingEndpoint { + type JsonResponse = (); + type ResponseType = ApiSuccess; + + fn method(&self) -> reqwest::Method { + reqwest::Method::GET + } + + fn path(&self) -> String { + "/dummy/nothing".into() + } + } + //endregion + + //region Endpoint that sends a JSON request. + #[derive(Debug)] + struct DummyJsonRequestEndpoint; + + impl EndpointSpec for DummyJsonRequestEndpoint { + type JsonResponse = (); + type ResponseType = ApiSuccess; + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } + + fn path(&self) -> String { + "/dummy/json".into() + } + + fn body(&self) -> Option { + Some(RequestBody::Json(json!({"key": "value"}).to_string())) + } + } + //endregion + + //region Endpoint that sends raw bytes. + #[derive(Debug)] + struct DummyRawRequestEndpoint; + + impl EndpointSpec for DummyRawRequestEndpoint { + const IS_RAW_BODY: bool = true; + type JsonResponse = (); + type ResponseType = Vec; + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } + + fn path(&self) -> String { + "/dummy/raw".into() + } + + fn body(&self) -> Option { + Some(RequestBody::Raw(b"raw content".to_vec())) + } + } + //endregion + + //region Endpoint that sends a multipart request. + #[derive(Debug)] + struct DummyMultipartEndpoint; + + impl EndpointSpec for DummyMultipartEndpoint { + type JsonResponse = (); + type ResponseType = ApiSuccess; + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } + + fn path(&self) -> String { + "/dummy/multipart".into() + } + + fn body(&self) -> Option { + Some(RequestBody::MultiPart(&DummyMultipart)) + } + } + + struct DummyMultipart; + + impl crate::framework::endpoint::MultipartBody for DummyMultipart { + fn parts(&self) -> Vec<(String, MultipartPart)> { + vec![("key".into(), MultipartPart::Text("value".into()))] + } + } + //endregion + + //region Endpoint that sends a request with query parameters. + #[derive(Debug)] + struct DummyJsonRequestWithQueryEndpoint; + + #[derive(Debug, Serialize)] + struct DummyJsonRequestWithQueryParams { + key: String, + } + + impl EndpointSpec for DummyJsonRequestWithQueryEndpoint { + type JsonResponse = (); + type ResponseType = ApiSuccess; + + fn method(&self) -> reqwest::Method { + reqwest::Method::POST + } + + fn path(&self) -> String { + "/dummy/json".into() + } + + fn query(&self) -> Option { + serialize_query(&DummyJsonRequestWithQueryParams { + key: "value".into(), + }) + } + } + //endregion + + fn create_test_client(url: String) -> Client { + let environment = Environment::Custom(url); + let credentials = Credentials::UserAuthToken { + token: "dummy".into(), + }; + let config = ClientConfig::default(); + Client::new(credentials, config, environment).unwrap() + } + + /// Test that the client can successfully request a JSON endpoint. + #[tokio::test] + async fn test_json_endpoint_success() { + let body = json!({ + "result": {"message": "Hello, World!"}, + "result_info": null, + "messages": [], + "errors": [], + "success": true + }); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/dummy/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header("content-type", Matcher::Missing) + .match_query(Matcher::Missing) + .match_body(Matcher::Missing) + .create(); + + let client = create_test_client(server.url()); + let response = client.request(&DummyJsonEndpoint).await; + + mock.assert(); + let response = response.unwrap(); + assert_eq!(response.result.message, "Hello, World!"); + assert_eq!(response.result_info, None); + assert!(response.messages.is_empty()); + assert!(response.errors.is_empty()); + } + + /// Test that the client can successfully request a raw endpoint. + #[tokio::test] + async fn test_raw_endpoint_success() { + let raw_body = b"raw content".to_vec(); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/dummy/raw") + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(raw_body.clone()) + .match_header("content-type", Matcher::Missing) + .match_query(Matcher::Missing) + .match_body(Matcher::Missing) + .create(); + + let client = create_test_client(server.url()); + let response = client.request(&DummyRawEndpoint).await.unwrap(); + + mock.assert(); + assert_eq!(response, raw_body); + } + + /// Test that the client can handle an endpoint that returns an error. + #[tokio::test] + async fn test_endpoint_failure() { + let body = json!({ + "errors": [{"code": 123, "message": "Something went wrong", "other": {}}], + "other": {} + }); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/dummy/json") + .with_status(400) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header("content-type", Matcher::Missing) + .match_query(Matcher::Missing) + .match_body(Matcher::Missing) + .create(); + + let client = create_test_client(server.url()); + let result = client.request(&DummyJsonEndpoint).await; + + mock.assert(); + assert!(result.is_err()); + if let Err(ApiFailure::Error(status, errors)) = result { + assert_eq!(status.as_u16(), 400); + assert!(!errors.errors.is_empty()); + assert_eq!(errors.errors[0].code, 123); + } else { + panic!("Expected error result"); + } + } + + /// Test that the client can handle an endpoint that returns nothing. + #[tokio::test] + async fn test_nothing_endpoint_success() { + let body = json!({ + "result": null, + "result_info": null, + "messages": [], + "errors": [], + "success": true + }); + + let mut server = Server::new_async().await; + let mock = server + .mock("GET", "/dummy/nothing") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header("content-type", Matcher::Missing) + .match_query(Matcher::Missing) + .match_body(Matcher::Missing) + .create(); + + let client = create_test_client(server.url()); + let response = client.request(&DummyNothingEndpoint).await; + + mock.assert(); + let response = response.unwrap(); + assert!(matches!(response.result, ())); + assert_eq!(response.result_info, None); + assert!(response.messages.is_empty()); + assert!(response.errors.is_empty()); + } + + /// Test that the client can successfully send a JSON request. + #[tokio::test] + async fn test_json_body_success() { + let body = json!({ + "result": null, + "result_info": null, + "messages": [], + "errors": [], + "success": true + }); + + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/dummy/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header("content-type", "application/json") + .match_query(Matcher::Missing) + .match_body(Matcher::Json(json!({"key": "value"}))) + .create(); + + let client = create_test_client(server.url()); + let _ = client.request(&DummyJsonRequestEndpoint).await; + + mock.assert(); + } + + /// Test that the client can successfully send a raw request. + #[tokio::test] + async fn test_raw_body_success() { + let raw_body = b"raw content".to_vec(); + + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/dummy/raw") + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(raw_body.clone()) + .match_header("content-type", "application/octet-stream") + .match_query(Matcher::Missing) + .match_body(raw_body) + .create(); + + let client = create_test_client(server.url()); + let _ = client.request(&DummyRawRequestEndpoint).await; + + mock.assert(); + } + + /// Test that the client can successfully send a multipart request. + #[tokio::test] + async fn test_multipart_body_success() { + let body = json!({ + "result": null, + "result_info": null, + "messages": [], + "errors": [], + "success": true + }); + + let mut server = Server::new_async().await; + + let mock = server + .mock("POST", "/dummy/multipart") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header( + "content-type", + Matcher::Regex("multipart/form-data; boundary=.*".into()), + ) + .match_query(Matcher::Missing) + .match_request(|req| { + let body = req.body().unwrap().to_vec(); + let body = String::from_utf8_lossy(&body); + + let re = Regex::new( + r#"^--.*\s+Content-Disposition: form-data; name="key"\s+\s+value\s+--.*\s*$"#, + ) + .unwrap(); + re.is_match(&body) + }) + .create(); + + let client = create_test_client(server.url()); + let _ = client.request(&DummyMultipartEndpoint).await; + + mock.assert(); + } + + /// Test that the client can successfully send a request with query parameters. + #[tokio::test] + async fn test_query_parameters_success() { + let body = json!({ + "result": null, + "result_info": null, + "messages": [], + "errors": [], + "success": true + }); + + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/dummy/json") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .match_header("content-type", Matcher::Missing) + .match_query(Matcher::UrlEncoded("key".into(), "value".into())) + .match_body(Matcher::Missing) + .create(); + + let client = create_test_client(server.url()); + let _ = client.request(&DummyJsonRequestWithQueryEndpoint).await; + + mock.assert(); + } +} diff --git a/cloudflare/src/framework/client/blocking_api.rs b/cloudflare/src/framework/client/blocking_api.rs new file mode 100644 index 00000000..d6528fc9 --- /dev/null +++ b/cloudflare/src/framework/client/blocking_api.rs @@ -0,0 +1,170 @@ +use crate::framework::auth::Credentials; +use crate::framework::client::ClientConfig; +use crate::framework::endpoint::{EndpointSpec, MultipartPart, RequestBody}; +use crate::framework::response::{ + ApiErrors, ApiFailure, ApiResponse, ApiSuccess, ResponseConverter, +}; +use crate::framework::{auth::AuthClient, Environment}; +use reqwest::blocking::RequestBuilder; +use std::borrow::Cow; +use std::net::SocketAddr; + +/// Synchronous Cloudflare API client. +// TODO: Rename to BlockingClient? +pub struct HttpApiClient { + environment: Environment, + credentials: Credentials, + http_client: reqwest::blocking::Client, +} + +impl HttpApiClient { + // TODO: Rename to is_custom? + #[cfg(feature = "mockito")] + pub fn is_mock(&self) -> bool { + matches!(self.environment, Environment::Custom(_)) + } +} + +impl HttpApiClient { + pub fn new( + credentials: Credentials, + config: ClientConfig, + environment: Environment, + ) -> Result { + let mut builder = reqwest::blocking::Client::builder() + .timeout(config.http_timeout) + .default_headers(config.default_headers); + + if let Some(address) = config.resolve_ip { + let url = url::Url::from(&environment); + builder = builder.resolve( + url.host_str() + .expect("Environment url should have a hostname"), + SocketAddr::new(address, 443), + ); + } + let http_client = builder.build()?; + + Ok(HttpApiClient { + environment, + credentials, + http_client, + }) + } + + //noinspection ALL + // TODO: This should probably just implement request for the Reqwest client itself :) + /// Synchronously send a request to the Cloudflare API. + pub fn request(&self, endpoint: &Endpoint) -> ApiResponse + where + Endpoint: EndpointSpec + Send + Sync, + Endpoint::ResponseType: ResponseConverter, + { + // Build the request + let mut request = self + .http_client + .request(endpoint.method(), endpoint.url(&self.environment)); + + if let Some(body) = endpoint.body() { + match body { + RequestBody::Json(json) => { + request = request.body(json); + } + RequestBody::Raw(bytes) => { + request = request.body(bytes); + } + RequestBody::MultiPart(multipart) => { + let mut form = reqwest::blocking::multipart::Form::new(); + for (name, part) in multipart.parts() { + match part { + MultipartPart::Text(text) => { + form = form.text(name, text); + } + MultipartPart::Bytes(bytes) => { + form = form + .part(name, reqwest::blocking::multipart::Part::bytes(bytes)); + } + } + } + request = request.multipart(form); + } + } + // Reqwest::RequestBuilder::multipart sets the content type for us. + match endpoint.content_type() { + None | Some(Cow::Borrowed("multipart/form-data")) => {} + Some(content_type) => { + request = request.header(reqwest::header::CONTENT_TYPE, content_type.as_ref()); + } + } + } + + request = request.auth(&self.credentials); + let response = request.send()?; + + // The condition is necessary, even if a warning is present. + // The constant is overridden in some cases. + if Endpoint::IS_RAW_BODY { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .unwrap_or(""); + assert_eq!(content_type, "application/octet-stream"); + + map_api_response_raw::(response) + } else { + map_api_response_json::(response) + } + } +} + +impl AuthClient for RequestBuilder { + fn auth(mut self, credentials: &Credentials) -> Self { + for (k, v) in credentials.headers() { + self = self.header(k, v); + } + self + } +} + +// If the response is 2XX and parses, return Success. +// If the response is 2XX and doesn't parse, return Invalid. +// If the response isn't 2XX, return Failure, with API errors if they were included. +fn map_api_response_raw( + resp: reqwest::blocking::Response, +) -> Result +where + Endpoint: EndpointSpec, + Endpoint::ResponseType: ResponseConverter, +{ + let status = resp.status(); + if status.is_success() { + let bytes = resp.bytes().map_err(ApiFailure::Invalid)?.to_vec(); + Ok(Endpoint::ResponseType::from_raw(bytes)) + } else { + let parsed: Result = resp.json(); + let errors = parsed.unwrap_or_default(); + Err(ApiFailure::Error(status, errors)) + } +} + +fn map_api_response_json( + resp: reqwest::blocking::Response, +) -> Result +where + Endpoint: EndpointSpec, + Endpoint::ResponseType: ResponseConverter, +{ + let status = resp.status(); + if status.is_success() { + let parsed: Result, reqwest::Error> = resp.json(); + match parsed { + Ok(success) => Ok(Endpoint::ResponseType::from_json(success)), + Err(e) => Err(ApiFailure::Invalid(e)), + } + } else { + let parsed: Result = resp.json(); + let errors = parsed.unwrap_or_default(); + Err(ApiFailure::Error(status, errors)) + } +} diff --git a/cloudflare/src/framework/client/mod.rs b/cloudflare/src/framework/client/mod.rs new file mode 100644 index 00000000..c5809e4c --- /dev/null +++ b/cloudflare/src/framework/client/mod.rs @@ -0,0 +1,30 @@ +use std::net::IpAddr; +use std::time::Duration; + +pub mod async_api; +// There is no blocking support for wasm. +#[cfg(all(feature = "blocking", not(target_arch = "wasm32")))] +pub mod blocking_api; + +/// Configuration for the API client. Allows users to customize its behaviour. +pub struct ClientConfig { + /// The maximum time limit for an API request. If a request takes longer than this, it will be + /// cancelled. + /// Note: this configuration has no effect when the target is wasm32. + pub http_timeout: Duration, + /// A default set of HTTP headers which will be sent with each API request. + pub default_headers: http::HeaderMap, + /// A specific IP to use when establishing a connection + /// Note: this configuration has no effect when the target is wasm32. + pub resolve_ip: Option, +} + +impl Default for ClientConfig { + fn default() -> Self { + ClientConfig { + http_timeout: Duration::from_secs(30), + default_headers: http::HeaderMap::default(), + resolve_ip: None, + } + } +} diff --git a/cloudflare/src/framework/endpoint.rs b/cloudflare/src/framework/endpoint.rs index b013467d..1d8f4410 100644 --- a/cloudflare/src/framework/endpoint.rs +++ b/cloudflare/src/framework/endpoint.rs @@ -6,11 +6,33 @@ use url::Url; pub use http::Method; -#[cfg(feature = "endpoint-spec")] -pub use spec::EndpointSpec; -#[cfg(not(feature = "endpoint-spec"))] pub(crate) use spec::EndpointSpec; +pub enum RequestBody<'a> { + Json(String), + Raw(Vec), + MultiPart(&'a dyn MultipartBody), +} + +pub enum MultipartPart { + Text(String), + Bytes(Vec), +} + +/// Helper trait for endpoints that require a multipart body. +/// +/// Mainly exists to allow for client-agnostic multipart body implementations, until reqwest has a +/// conversion between blocking::multipart::Form/Part and async_impl::multipart::Form/Part. +pub trait MultipartBody { + /// Returns a list of parts to be included in a multipart request. + /// Each part is a tuple of the part name and the part data. + // + // Client-agnostic implementation, because of the non-interoperability + // between reqwest's blocking::multipart::Form/Part and async_impl::multipart::Form/Part. + // Refactor this when reqwest has some sort of conversion between the two. + fn parts(&self) -> Vec<(String, MultipartPart)>; +} + pub mod spec { use super::*; @@ -18,12 +40,23 @@ pub mod spec { /// New endpoints should implement this trait. /// /// If the request succeeds, the call will resolve to a `ResultType`. - pub trait EndpointSpec - where - ResultType: ApiResult, - { + pub trait EndpointSpec { + /// If the body of the response is raw bytes (Vec), set this to `true`. Defaults to `false`. + const IS_RAW_BODY: bool = false; + + /// The JSON response type for this endpoint, if any. + /// + /// For endpoints that return either raw bytes or nothing, this should be `()`. + type JsonResponse: ApiResult; + /// The final response type for this endpoint. + /// + /// For endpoints that return raw bytes, this should be `Vec`. + /// + /// For endpoints that return JSON, this should be `ApiSuccess`. + type ResponseType; + /// The HTTP Method used for this endpoint (e.g. GET, PATCH, DELETE) - fn method(&self) -> http::Method; + fn method(&self) -> Method; /// The relative URL path for this endpoint fn path(&self) -> String; @@ -40,7 +73,7 @@ pub mod spec { /// /// Implementors should inline this. #[inline] - fn body(&self) -> Option { + fn body(&self) -> Option { None } @@ -53,21 +86,27 @@ pub mod spec { url } + //noinspection RsConstantConditionIf /// If `body` is populated, indicates the body MIME type (defaults to JSON). /// /// Implementors generally do not need to override this. - fn content_type(&self) -> Cow<'static, str> { - Cow::Borrowed("application/json") + fn content_type(&self) -> Option> { + match Self::body(self) { + Some(RequestBody::Json(_)) => Some(Cow::Borrowed("application/json")), + Some(RequestBody::Raw(_)) => Some(Cow::Borrowed("application/octet-stream")), + Some(RequestBody::MultiPart(_)) => Some(Cow::Borrowed("multipart/form-data")), + None => None, + } } } } // Auto-implement the public Endpoint trait for EndpointInternal implementors. -impl> Endpoint for U {} +impl Endpoint for U {} /// An API call that can be built into an HTTP request and sent. /// /// If the request succeeds, the call will resolve to a `ResultType`. -pub trait Endpoint: spec::EndpointSpec {} +pub trait Endpoint: EndpointSpec {} /// A utility function for serializing parameters into a URL query string. #[inline] diff --git a/cloudflare/src/framework/mod.rs b/cloudflare/src/framework/mod.rs index d15a8d87..6740be2d 100644 --- a/cloudflare/src/framework/mod.rs +++ b/cloudflare/src/framework/mod.rs @@ -1,17 +1,12 @@ /*! This module controls how requests are sent to Cloudflare's API, and how responses are parsed from it. */ -pub mod async_api; pub mod auth; -// There is no blocking implementation for wasm. -#[cfg(all(feature = "blocking", not(target_arch = "wasm32")))] -pub mod blocking_api; +pub mod client; pub mod endpoint; pub mod response; use serde::Serialize; -use std::net::IpAddr; -use std::time::Duration; #[derive(thiserror::Error, Debug)] /// Errors encountered while trying to connect to the Cloudflare API @@ -45,11 +40,8 @@ pub enum SearchMatch { pub enum Environment { /// The production endpoint: `https://api.cloudflare.com/client/v4` Production, - /// A custom endpoint - Custom(url::Url), - #[cfg(feature = "mockito")] - /// The local mock endpoint associated with `mockito` - Mockito, + /// A custom endpoint (for example, a `mockito` server) + Custom(String), } impl<'a> From<&'a Environment> for url::Url { @@ -58,49 +50,7 @@ impl<'a> From<&'a Environment> for url::Url { Environment::Production => { url::Url::parse("https://api.cloudflare.com/client/v4/").unwrap() } - Environment::Custom(url) => url.clone(), - #[cfg(feature = "mockito")] - Environment::Mockito => url::Url::parse(&mockito::server_url()).unwrap(), - } - } -} - -// There is no blocking support for wasm. -#[cfg(all(feature = "blocking", not(target_arch = "wasm32")))] -/// Synchronous Cloudflare API client. -pub struct HttpApiClient { - environment: Environment, - credentials: auth::Credentials, - http_client: reqwest::blocking::Client, -} - -#[cfg(all(feature = "blocking", not(target_arch = "wasm32")))] -impl HttpApiClient { - #[cfg(feature = "mockito")] - pub fn is_mock(&self) -> bool { - matches!(self.environment, Environment::Mockito) - } -} - -/// Configuration for the API client. Allows users to customize its behaviour. -pub struct HttpApiClientConfig { - /// The maximum time limit for an API request. If a request takes longer than this, it will be - /// cancelled. - /// Note: this configuration has no effect when the target is wasm32. - pub http_timeout: Duration, - /// A default set of HTTP headers which will be sent with each API request. - pub default_headers: http::HeaderMap, - /// A specific IP to use when establishing a connection - /// Note: this configuration has no effect when the target is wasm32. - pub resolve_ip: Option, -} - -impl Default for HttpApiClientConfig { - fn default() -> Self { - HttpApiClientConfig { - http_timeout: Duration::from_secs(30), - default_headers: http::HeaderMap::default(), - resolve_ip: None, + Environment::Custom(url) => url::Url::parse(url.as_str()).unwrap(), } } } diff --git a/cloudflare/src/framework/response/apifail.rs b/cloudflare/src/framework/response/api_fail.rs similarity index 58% rename from cloudflare/src/framework/response/apifail.rs rename to cloudflare/src/framework/response/api_fail.rs index 0d4cf636..f74365c2 100644 --- a/cloudflare/src/framework/response/apifail.rs +++ b/cloudflare/src/framework/response/api_fail.rs @@ -1,26 +1,17 @@ -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use crate::framework::response::ResponseInfo; +use serde::{Deserialize, Serialize}; use serde_json::value::Value as JValue; use std::collections::HashMap; use std::error::Error; use std::fmt::{self, Debug, Write as _}; -/// Note that APIError's `eq` implementation only compares `code` and `message`. -/// It does NOT compare the `other` values. -#[derive(Deserialize, Serialize, Debug)] -pub struct ApiError { - pub code: u16, - pub message: String, - #[serde(flatten)] - pub other: HashMap, -} - /// Note that APIErrors's `eq` implementation only compares `code` and `message`. /// It does NOT compare the `other` values. #[derive(Deserialize, Serialize, Debug, Default)] pub struct ApiErrors { #[serde(flatten)] pub other: HashMap, - pub errors: Vec, + pub errors: Vec, } impl PartialEq for ApiErrors { @@ -29,23 +20,7 @@ impl PartialEq for ApiErrors { } } -impl PartialEq for ApiError { - fn eq(&self, other: &Self) -> bool { - self.code == other.code && self.message == other.message - } -} - -impl Eq for ApiError {} impl Eq for ApiErrors {} -impl Error for ApiError {} - -impl fmt::Display for ApiError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Error {}: {}", self.code, self.message) - } -} - -pub trait ApiResult: DeserializeOwned + Debug {} #[derive(Debug)] pub enum ApiFailure { @@ -91,3 +66,45 @@ impl From for ApiFailure { ApiFailure::Invalid(error) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::framework::response::ResponseInfo; + use std::collections::HashMap; + + #[test] + fn api_failure_eq() { + let err1 = ApiFailure::Error( + reqwest::StatusCode::NOT_FOUND, + ApiErrors { + errors: vec![ResponseInfo { + code: 1000, + message: "some failed".to_owned(), + other: HashMap::new(), + }], + other: HashMap::new(), + }, + ); + assert_eq!(err1, err1); + + let err2 = ApiFailure::Error( + reqwest::StatusCode::NOT_FOUND, + ApiErrors { + errors: vec![ResponseInfo { + code: 1000, + message: "some different thing failed".to_owned(), + other: HashMap::new(), + }], + other: HashMap::new(), + }, + ); + assert_ne!(err2, err1); + + let not_real_website = "notavalid:url.evena little"; + let fail = ApiFailure::Invalid(reqwest::blocking::get(not_real_website).unwrap_err()); + assert_eq!(fail, fail); + assert_ne!(fail, err1); + assert_ne!(fail, err2); + } +} diff --git a/cloudflare/src/framework/response/mod.rs b/cloudflare/src/framework/response/mod.rs index 189fa48a..64c6f010 100644 --- a/cloudflare/src/framework/response/mod.rs +++ b/cloudflare/src/framework/response/mod.rs @@ -1,83 +1,79 @@ -mod apifail; +mod api_fail; -pub use apifail::*; -use serde::Deserialize; +pub use api_fail::*; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use serde_json::value::Value as JsonValue; +use std::collections::HashMap; +use std::error::Error; +use std::fmt; +use std::fmt::Debug; -#[derive(Deserialize, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct ApiSuccess { pub result: ResultType, pub result_info: Option, #[serde(default)] - pub messages: JsonValue, + pub messages: Vec, #[serde(default)] - pub errors: Vec, + pub errors: Vec, } -pub type ApiResponse = Result, ApiFailure>; +pub type ApiResponse = Result; -// There is no blocking implementation for wasm. -#[cfg(all(feature = "blocking", not(target_arch = "wasm32")))] -// If the response is 200 and parses, return Success. -// If the response is 200 and doesn't parse, return Invalid. -// If the response isn't 200, return Failure, with API errors if they were included. -pub fn map_api_response( - resp: reqwest::blocking::Response, -) -> ApiResponse { - let status = resp.status(); - if status.is_success() { - let parsed: Result, reqwest::Error> = resp.json(); - match parsed { - Ok(api_resp) => Ok(api_resp), - Err(e) => Err(ApiFailure::Invalid(e)), - } - } else { - let parsed: Result = resp.json(); - let errors = parsed.unwrap_or_default(); - Err(ApiFailure::Error(status, errors)) - } -} +pub trait ApiResult: DeserializeOwned + Debug {} + +impl ApiResult for ApiSuccess where T: ApiResult {} /// Some endpoints return nothing. That's OK. impl ApiResult for () {} -#[cfg(all(test, feature = "blocking", not(target_arch = "wasm32")))] -mod tests { - use super::*; - use std::collections::HashMap; +/// A helper trait to avoid trait bounds issues in the clients. +pub trait ResponseConverter: Sized { + fn from_raw(bytes: Vec) -> Self; + fn from_json(api: ApiSuccess) -> Self; +} +// JSON endpoints +impl ResponseConverter for ApiSuccess { + fn from_raw(_bytes: Vec) -> Self { + panic!("This endpoint does not return raw bytes") + } + fn from_json(api: ApiSuccess) -> Self { + api + } +} +// Raw endpoints +impl ResponseConverter<()> for Vec { + fn from_raw(bytes: Vec) -> Self { + bytes + } + fn from_json(_api: ApiSuccess<()>) -> Self { + panic!("This endpoint does not return JSON") + } +} + +/// Note that ResponseInfo's `eq` implementation only compares `code` and `message`. +/// It does NOT compare the `other` values. +#[derive(Deserialize, Serialize, Debug)] +pub struct ResponseInfo { + pub code: u16, + pub message: String, + #[serde(flatten)] + pub other: HashMap, +} + +impl PartialEq for ResponseInfo { + fn eq(&self, other: &Self) -> bool { + self.code == other.code && self.message == other.message + } +} - #[test] - fn api_failure_eq() { - let err1 = ApiFailure::Error( - reqwest::StatusCode::NOT_FOUND, - ApiErrors { - errors: vec![ApiError { - code: 1000, - message: "some failed".to_owned(), - other: HashMap::new(), - }], - other: HashMap::new(), - }, - ); - assert_eq!(err1, err1); +impl Eq for ResponseInfo {} - let err2 = ApiFailure::Error( - reqwest::StatusCode::NOT_FOUND, - ApiErrors { - errors: vec![ApiError { - code: 1000, - message: "some different thing failed".to_owned(), - other: HashMap::new(), - }], - other: HashMap::new(), - }, - ); - assert_ne!(err2, err1); +impl Error for ResponseInfo {} - let not_real_website = "notavalid:url.evena little"; - let fail = ApiFailure::Invalid(reqwest::blocking::get(not_real_website).unwrap_err()); - assert_eq!(fail, fail); - assert_ne!(fail, err1); - assert_ne!(fail, err2); +impl fmt::Display for ResponseInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error {}: {}", self.code, self.message) } }