diff --git a/.gitignore b/.gitignore index 96ef6c0..71fe146 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +/consulrs_derive/target /target Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 6b37649..c0f7961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,22 @@ edition = "2018" [dependencies] async-trait = "0.1.51" -bytes = "1.1.0" +base64 = "0.13.0" +consulrs_derive = { version = "0.1.0", path = "consulrs_derive" } derive_builder = "0.10.2" http = "0.2.5" reqwest = { version = "0.11.4", default-features = false, features = ["rustls-tls"] } -rustify = "0.4.4" +rustify = "0.5.1" +rustify_derive = "0.5.1" +serde = "1.0.130" serde_json = "1.0.66" +serde_with = "1.10.0" thiserror = "1.0.29" tracing = "0.1.28" url = "2.2.2" + +[dev-dependencies] +dockertest-server = { version = "0.1.3", features=["hashi"] } +env_logger = "0.9.0" +test-env-log = { version = "0.2.7", features = ["trace"] } +tracing-subscriber = {version = "0.2.17", default-features = false, features = ["env-filter", "fmt"]} diff --git a/consulrs_derive/Cargo.toml b/consulrs_derive/Cargo.toml new file mode 100644 index 0000000..51024ab --- /dev/null +++ b/consulrs_derive/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "consulrs_derive" +version = "0.1.0" +authors = ["Joshua Gilman "] +description = "A derive macro for implementing query options for Consul endpoints" +license = "MIT" +repository = "https://github.com/jmgilman/consulrs" +edition = "2018" + +[lib] +proc-macro = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +syn = "1.0" +quote = "1.0" +synstructure = "0.12.5" +proc-macro2 = "1.0.28" diff --git a/consulrs_derive/src/error.rs b/consulrs_derive/src/error.rs new file mode 100644 index 0000000..1814329 --- /dev/null +++ b/consulrs_derive/src/error.rs @@ -0,0 +1,31 @@ +use proc_macro2::Span; + +/// The general error object returned by functions in this crate. +/// +/// The error object can be directly converted from a [syn::Error] as well as +/// be converted directly into a [proc_macro2::TokenStream] to be returned to +/// the compiler. +#[derive(Debug)] +pub struct Error(proc_macro2::TokenStream); + +impl Error { + /// Returns a new instance of [Error] using the given [Span] and message. + /// + /// This uses [quote_spanned!] in order to provide more accurate information + /// to the compiler about the exact location of the error. + pub fn new(span: Span, message: &str) -> Error { + Error(quote_spanned! { span => + compile_error!(#message); + }) + } + + pub fn into_tokens(self) -> proc_macro2::TokenStream { + self.0 + } +} + +impl From for Error { + fn from(e: syn::Error) -> Error { + Error(e.to_compile_error()) + } +} diff --git a/consulrs_derive/src/lib.rs b/consulrs_derive/src/lib.rs new file mode 100644 index 0000000..5b78640 --- /dev/null +++ b/consulrs_derive/src/lib.rs @@ -0,0 +1,43 @@ +#[macro_use] +extern crate synstructure; +extern crate proc_macro; + +mod error; + +use error::Error; +use proc_macro2::Span; + +const FIELD_NAME: &str = "features"; + +/// Returns field names of the given struct. +fn fields(data: &syn::DataStruct) -> Vec { + data.fields + .iter() + .map(|f| f.ident.clone().unwrap().to_string()) + .collect() +} + +fn endpoint_derive(s: synstructure::Structure) -> proc_macro2::TokenStream { + // Validate the required field exists + if let syn::Data::Struct(data) = &s.ast().data { + let fields = fields(data); + if !fields.contains(&FIELD_NAME.into()) { + return Error::new(data.struct_token.span, "Missing required `features` field") + .into_tokens(); + } + } else { + return Error::new(Span::call_site(), "May only be used on with structs").into_tokens(); + } + + s.gen_impl(quote! { + use crate::api::features::{FeaturedEndpoint, Features}; + + gen impl FeaturedEndpoint for @Self { + fn features(&self) -> Option { + self.features.clone() + } + } + }) +} + +synstructure::decl_derive!([QueryEndpoint] => endpoint_derive); diff --git a/src/api.rs b/src/api.rs index 5f5bcb4..5bff639 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,23 +1,63 @@ use std::str::FromStr; -use rustify::endpoint::{Endpoint, MiddleWare}; +use crate::api::features::FeaturedEndpoint; +use crate::client::Client; +use crate::error::ClientError; +use derive_builder::Builder; +use rustify::endpoint::{Endpoint, EndpointResult, MiddleWare}; +use rustify::errors::ClientError as RestClientError; +use serde::de::DeserializeOwned; + +pub use crate::api::features::Features; + +pub mod features; +pub mod kv; + +#[derive(Builder, Debug)] +#[builder(pattern = "owned")] +pub struct ApiResponse { + #[builder(setter(into, strip_option), default)] + pub cache: Option, + #[builder(setter(into, strip_option), default)] + pub content_hash: Option, + #[builder(setter(into, strip_option), default)] + pub default_acl_policy: Option, + #[builder(setter(into, strip_option), default)] + pub index: Option, + #[builder(setter(into, strip_option), default)] + pub known_leader: Option, + #[builder(setter(into, strip_option), default)] + pub last_contact: Option, + #[builder(setter(into, strip_option), default)] + pub query_backend: Option, + pub response: T, +} + +impl ApiResponse { + pub fn builder() -> ApiResponseBuilder { + ApiResponseBuilder::default() + } +} /// A [MiddleWare] for adding version and token information to all requests. /// /// Implements [MiddleWare] to provide support for prepending API version /// information to all requests and adding an ACL token to the header of all -/// requests. This is automatically passed by the API functions when an endpoint -/// is executed. +/// requests. Additionally, any API features specified in the endpoint are +/// appended to the request. This is passed by the API functions when an +/// endpoint is executed. #[derive(Debug, Clone)] pub struct EndpointMiddleware { + pub features: Option, pub token: Option, pub version: String, } impl MiddleWare for EndpointMiddleware { + #[instrument(skip(self, req), err)] fn request( &self, _: &E, - req: &mut http::Request, + req: &mut http::Request>, ) -> Result<(), rustify::errors::ClientError> { // Prepend API version to all requests debug!( @@ -41,14 +81,132 @@ impl MiddleWare for EndpointMiddleware { ); } + // Add optional API features + if let Some(f) = &self.features { + f.process(req); + } + Ok(()) } fn response( &self, _: &E, - _: &mut http::Response, + _: &mut http::Response>, ) -> Result<(), rustify::errors::ClientError> { Ok(()) } } + +/// Executes an [Endpoint] and returns the raw response body. +/// +/// Any errors which occur in execution are wrapped in a +/// [ClientError::RestClientError] and propagated. +pub async fn exec_with_raw( + client: &impl Client, + endpoint: E, +) -> Result>, ClientError> +where + E: Endpoint + FeaturedEndpoint, +{ + info!("Executing {} and expecting no response", endpoint.path()); + let features = endpoint.features(); + endpoint + .with_middleware(&client.middle(features)) + .exec(client.http()) + .await + .map_err(parse_err) + .map(parse_raw)? +} + +/// Executes an [Endpoint] and returns the result. +/// +/// The result from the executed endpoint has a few operations performed on it: +/// +/// * Any potential API error responses from the execution are searched for and, +/// if found, converted to a [ClientError::APIError] +/// * All other errors are mapped from [rustify::errors::ClientError] to +/// [ClientError::RestClientError] +pub async fn exec_with_result( + client: &impl Client, + endpoint: E, +) -> Result, ClientError> +where + E: Endpoint + FeaturedEndpoint, +{ + info!("Executing {} and expecting a response", endpoint.path()); + let features = endpoint.features(); + endpoint + .with_middleware(&client.middle(features)) + .exec(client.http()) + .await + .map_err(parse_err) + .map(parse)? +} + +/// Parses an [EndpointResult], turning it into an [ApiResponse]. +fn parse(result: EndpointResult) -> Result, ClientError> +where + T: DeserializeOwned + Send + Sync, +{ + let mut builder = parse_headers(result.response.headers()); + + let response = result.parse().map_err(ClientError::from)?; + builder = builder.response(response); + Ok(builder.build().unwrap()) +} + +/// Parses an [EndpointResult], turning it into an [ApiResponse]. +fn parse_raw(result: EndpointResult) -> Result>, ClientError> +where + T: DeserializeOwned + Send + Sync, +{ + let mut builder = parse_headers(result.response.headers()); + + let response = result.raw(); + builder = builder.response(response); + Ok(builder.build().unwrap()) +} + +/// Parses commonly found header fields out of response headers. +fn parse_headers(headers: &http::HeaderMap) -> ApiResponseBuilder +where + T: DeserializeOwned + Send + Sync, +{ + let mut builder = ApiResponse::builder(); + + if headers.contains_key("X-Cache") { + builder = builder.cache(headers["X-Cache"].to_str().unwrap()); + } + if headers.contains_key("X-Consul-ContentHash") { + builder = builder.content_hash(headers["X-Consul-ContentHash"].to_str().unwrap()) + } + if headers.contains_key("X-Consul-Default-ACL-Policy") { + builder = + builder.default_acl_policy(headers["X-Consul-Default-ACL-Policy"].to_str().unwrap()) + } + if headers.contains_key("X-Consul-Index") { + builder = builder.index(headers["X-Consul-Index"].to_str().unwrap()) + } + if headers.contains_key("X-Consul-KnownLeader") { + builder = builder.known_leader(headers["X-Consul-KnownLeader"].to_str().unwrap()) + } + if headers.contains_key("X-Consul-LastContact") { + builder = builder.last_contact(headers["X-Consul-LastContact"].to_str().unwrap()) + } + if headers.contains_key("X-Consul-Query-Backend") { + builder = builder.query_backend(headers["X-Consul-Query-Backend"].to_str().unwrap()) + } + + builder +} + +/// Extracts any API errors found and converts them to [ClientError::APIError]. +fn parse_err(e: RestClientError) -> ClientError { + if let RestClientError::ServerResponseError { code, content } = &e { + dbg!(content); + ClientError::APIError { code: *code } + } else { + ClientError::from(e) + } +} diff --git a/src/api/features.rs b/src/api/features.rs new file mode 100644 index 0000000..78f0c41 --- /dev/null +++ b/src/api/features.rs @@ -0,0 +1,121 @@ +use std::{collections::HashMap, str::FromStr}; + +use derive_builder::Builder; +use http::{HeaderValue, Request}; + +/// An [Endpoint][rustify::Endpoint] which contains optional [Features] for +/// modifying how its generated request is handled. +/// +/// All Consul endpoints should derive this trait through [consulrs_derive]. +/// While not every endpoint offers all features, each feature by default is +/// optional which allows an end-user to specify the features they would like +/// enabled for the request. This crate does not perform any checks to ensure +/// features are being used correctly - incorrect usage will result in an API +/// error which will eventually make it back to the end-user. +pub trait FeaturedEndpoint { + fn features(&self) -> Option; +} + +/// A set of features which can be applied to an endpoint request. +/// +/// The following features are supported: +/// +/// * [Consistency Modes](https://www.consul.io/api-docs/features/consistency) +/// * [Blocking Queries](https://www.consul.io/api-docs/features/blocking) +/// * [Filtering](https://www.consul.io/api-docs/features/filtering) +/// * [Caching](https://www.consul.io/api-docs/features/caching) +/// +/// By default, all features are optional and must be individually configured +/// in order for them to be applied to a request. Note that not all endpoints +/// support all features or combination of features - this crate performs no +/// checks to ensure correct usage and incorrect usage could result in the API +/// returning an error. Refer to the individual endpoint documentation to verify +/// which features it supports. +#[derive(Builder, Default, Debug, Clone)] +#[builder(setter(strip_option, into), default)] +pub struct Features { + pub blocking: Option, + pub cached: Option, + pub filter: Option, + pub mode: Option, +} + +impl Features { + /// Mutates a [Request] by adding query parameters and header fields as + /// determined by the features configured. + #[instrument(skip(self, request))] + pub fn process(&self, request: &mut Request>) { + let mut query = HashMap::::new(); + let mut keys = Vec::::new(); + info!("Adding features to request"); + + // Blocking Queries + if let Some(b) = &self.blocking { + if let Some(w) = &b.wait { + query.insert("wait".into(), w.clone()); + } + + query.insert("index".into(), b.index.to_string()); + } + + // Caching + if let Some(c) = &self.cached { + if !c.is_empty() { + request + .headers_mut() + .insert("Cache-Control", HeaderValue::from_str(c.as_str()).unwrap()); + } + + keys.push("cached".into()); + } + + // Filtering + if let Some(f) = &self.filter { + query.insert("filter".into(), f.clone()); + } + + // Consistency Modes + if let Some(m) = &self.mode { + let mode = match m { + ConsistencyMode::CONSISTENT => "consistent", + ConsistencyMode::STALE => "stale", + }; + + keys.push(mode.into()) + } + + let mut url = url::Url::parse(request.uri().to_string().as_str()).unwrap(); + + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query); + } + + if !keys.is_empty() { + url.query_pairs_mut() + .extend_keys_only::, String>(keys); + } + + *request.uri_mut() = http::Uri::from_str(url.as_str()).unwrap(); + + info!("Final url with features: {}", request.uri()); + } + + /// Returns a default instance of [FeaturesBuilder] for configuring features. + pub fn builder() -> FeaturesBuilder { + FeaturesBuilder::default() + } +} + +/// Configuration options for the Blocking Queries feature. +#[derive(Debug, Clone)] +pub struct Blocking { + pub index: u64, + pub wait: Option, +} + +/// Configuration options for the Consistency Mode feature. +#[derive(Debug, Clone)] +pub enum ConsistencyMode { + CONSISTENT, + STALE, +} diff --git a/src/api/kv.rs b/src/api/kv.rs new file mode 100644 index 0000000..116da0f --- /dev/null +++ b/src/api/kv.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod responses; diff --git a/src/api/kv/requests.rs b/src/api/kv/requests.rs new file mode 100644 index 0000000..8f4a3ab --- /dev/null +++ b/src/api/kv/requests.rs @@ -0,0 +1,159 @@ +use crate::api::Features; + +use super::responses::ReadKeyResponse; +use consulrs_derive::QueryEndpoint; +use derive_builder::Builder; +use rustify_derive::Endpoint; +use serde::Serialize; +use std::fmt::Debug; + +/// ## Read Key +/// This endpoint returns the specified key. +/// +/// * Path: kv/{self.key} +/// * Method: GET +/// * Response: [ReadKeyResponse] +/// * Reference: https://www.consul.io/api-docs/kv#read-key +#[derive(Builder, Debug, Default, Endpoint, QueryEndpoint)] +#[endpoint( + path = "kv/{self.key}", + response = "Vec", + builder = "true" +)] +#[builder(setter(into, strip_option), default)] +pub struct ReadKeyRequest { + #[endpoint(skip)] + pub features: Option, + #[endpoint(skip)] + pub key: String, + #[endpoint(query)] + pub dc: Option, + #[endpoint(query)] + pub ns: Option, + #[endpoint(query)] + pub recurse: Option, +} + +/// ## Read Key +/// This endpoint returns the raw value of the specified key. +/// +/// * Path: kv/{self.key} +/// * Method: GET +/// * Response: [ReadKeyResponse] +/// * Reference: https://www.consul.io/api-docs/kv#read-key +#[derive(Builder, Debug, Default, Endpoint, QueryEndpoint)] +#[endpoint( + path = "kv/{self.key}", + response = "Vec", + builder = "true" +)] +#[builder(setter(into, strip_option), default)] +pub struct ReadRawKeyRequest { + #[endpoint(skip)] + pub features: Option, + #[endpoint(skip)] + pub key: String, + #[endpoint(query)] + pub dc: Option, + #[endpoint(query)] + pub ns: Option, + #[endpoint(query)] + #[builder(setter(skip), default = "true")] + pub raw: bool, +} + +/// ## Read Keys +/// This endpoint returns a list of key names at the specified key. +/// +/// * Path: kv/{self.key} +/// * Method: GET +/// * Response: [Vec] +/// * Reference: https://www.consul.io/api-docs/kv#read-key +#[derive(Builder, Debug, Default, Endpoint, QueryEndpoint)] +#[endpoint(path = "kv/{self.key}", response = "Vec", builder = "true")] +#[builder(setter(into, strip_option), default)] +pub struct ReadKeysRequest { + #[endpoint(skip)] + pub features: Option, + #[endpoint(skip)] + pub key: String, + #[endpoint(query)] + pub dc: Option, + #[endpoint(query)] + #[builder(setter(skip), default = "true")] + pub keys: bool, + #[endpoint(query)] + pub ns: Option, + #[endpoint(query)] + pub raw: Option, + #[endpoint(query)] + pub recurse: Option, + #[endpoint(query)] + pub separator: Option, // Only valid when `keys` is set +} + +/// ## Create/Update Key +/// This endpoint updates the value of the specified key. +/// +/// * Path: kv/{self.key} +/// * Method: PUT +/// * Response: [bool] +/// * Reference: https://www.consul.io/api-docs/kv#create-update-key +#[derive(Builder, Debug, Default, Endpoint, QueryEndpoint)] +#[endpoint( + path = "kv/{self.key}", + method = "PUT", + response = "bool", + builder = "true" +)] +#[builder(setter(into, strip_option), default)] +pub struct SetKeyRequest { + #[endpoint(skip)] + pub features: Option, + #[endpoint(skip)] + pub key: String, + #[endpoint(raw)] + pub value: Vec, + #[endpoint(query)] + pub acquire: Option, + #[endpoint(query)] + pub cas: Option, + #[endpoint(query)] + pub dc: Option, + #[endpoint(query)] + pub flags: Option, + #[endpoint(query)] + pub ns: Option, + #[endpoint(query)] + pub release: Option, +} + +/// ## Delete Key +/// This endpoint deletes a single key or all keys sharing a prefix. +/// +/// * Path: kv/{self.key} +/// * Method: DELETE +/// * Response: [bool] +/// * Reference: https://www.consul.io/api-docs/kv#delete-key +#[derive(Builder, Debug, Default, Endpoint, QueryEndpoint)] +#[endpoint( + path = "kv/{self.key}", + method = "DELETE", + response = "bool", + builder = "true" +)] +#[builder(setter(into, strip_option), default)] +pub struct DeleteKeyRequest { + #[endpoint(skip)] + pub features: Option, + #[endpoint(skip)] + pub key: String, + #[endpoint(query)] + pub cas: Option, + #[endpoint(query)] + pub dc: Option, + #[endpoint(query)] + pub ns: Option, + #[endpoint(query)] + pub recurse: Option, +} diff --git a/src/api/kv/responses.rs b/src/api/kv/responses.rs new file mode 100644 index 0000000..379d230 --- /dev/null +++ b/src/api/kv/responses.rs @@ -0,0 +1,22 @@ +use crate::error::ClientError; +use serde::{Deserialize, Serialize}; + +/// Response from executing +/// [ReadKeyRequest][crate::api::kv::requests::ReadKeyRequest] +#[derive(Deserialize, Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ReadKeyResponse { + pub create_index: u64, + pub modify_index: u64, + pub lock_index: u64, + pub key: String, + pub flags: u64, + pub session: Option, + pub value: String, +} + +impl ReadKeyResponse { + pub fn value(&self) -> Result, ClientError> { + base64::decode(&self.value).map_err(|e| ClientError::Base64DecodeError { source: e }) + } +} diff --git a/src/client.rs b/src/client.rs index f843f3e..1dc6053 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,7 +3,10 @@ use derive_builder::Builder; use rustify::clients::reqwest::Client as HTTPClient; use std::{env, fs}; -use crate::{api::EndpointMiddleware, error::ClientError}; +use crate::{ + api::{EndpointMiddleware, Features}, + error::ClientError, +}; /// The client interface capabale of interacting with API functions #[async_trait] @@ -11,6 +14,9 @@ pub trait Client: Send + Sync + Sized { /// Returns the underlying HTTP client being used for API calls fn http(&self) -> &HTTPClient; + /// Returns the middleware to be used when executing API calls + fn middle(&self, features: Option) -> EndpointMiddleware; + /// Returns the settings used to configure this client fn settings(&self) -> &ConsulClientSettings; } @@ -22,7 +28,6 @@ pub trait Client: Send + Sync + Sized { /// used for executing [Endpoints][rustify::endpoint::Endpoint]. pub struct ConsulClient { pub http: HTTPClient, - pub middle: EndpointMiddleware, pub settings: ConsulClientSettings, } @@ -32,6 +37,15 @@ impl Client for ConsulClient { &self.http } + fn middle(&self, features: Option) -> EndpointMiddleware { + let version_str = format!("v{}", self.settings.version); + EndpointMiddleware { + features, + token: self.settings.token.clone(), + version: version_str, + } + } + fn settings(&self) -> &ConsulClientSettings { &self.settings } @@ -92,21 +106,12 @@ impl ConsulClient { // Configures middleware for endpoints to append API version and token debug!("Using API version {}", settings.version); - let version_str = format!("v{}", settings.version); - let middle = EndpointMiddleware { - token: settings.token.clone(), - version: version_str, - }; let http_client = http_client .build() .map_err(|e| ClientError::RestClientBuildError { source: e })?; let http = HTTPClient::new(settings.address.as_str(), http_client); - Ok(ConsulClient { - settings, - middle, - http, - }) + Ok(ConsulClient { settings, http }) } } diff --git a/src/error.rs b/src/error.rs index 290365e..bdd49fa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,6 +3,10 @@ use thiserror::Error; /// The common error type returned by this crate #[derive(Error, Debug)] pub enum ClientError { + #[error("The Consul server returned an error (status code {code})")] + APIError { code: u16 }, + #[error("Failed decoding Base64 response")] + Base64DecodeError { source: base64::DecodeError }, #[error("Error reading file: {path}")] FileReadError { source: std::io::Error, @@ -13,6 +17,13 @@ pub enum ClientError { source: reqwest::Error, path: String, }, + #[error("The request returned an empty response")] + ResponseEmptyError, + #[error("An error occurred with the request")] + RestClientError { + #[from] + source: rustify::errors::ClientError, + }, #[error("Error configuring REST client")] RestClientBuildError { source: reqwest::Error }, } diff --git a/src/kv.rs b/src/kv.rs new file mode 100644 index 0000000..cca46b8 --- /dev/null +++ b/src/kv.rs @@ -0,0 +1,92 @@ +use crate::{ + api::{ + self, + kv::{ + requests::{ + DeleteKeyRequest, DeleteKeyRequestBuilder, ReadKeyRequest, ReadKeyRequestBuilder, + ReadKeysRequest, ReadKeysRequestBuilder, ReadRawKeyRequest, + ReadRawKeyRequestBuilder, SetKeyRequest, SetKeyRequestBuilder, + }, + responses::ReadKeyResponse, + }, + ApiResponse, + }, + client::Client, + error::ClientError, +}; + +/// Deletes the given key. +/// +/// See [DeleteKeyRequest] +#[instrument(skip(client, opts), err)] +pub async fn delete( + client: &impl Client, + key: &str, + opts: Option<&mut DeleteKeyRequestBuilder>, +) -> Result, ClientError> { + let mut t = DeleteKeyRequest::builder(); + let endpoint = opts.unwrap_or(&mut t).key(key).build().unwrap(); + api::exec_with_result(client, endpoint).await +} + +/// Reads the value at the given key. +/// +/// See [ReadKeysRequest] +#[instrument(skip(client, opts), err)] +pub async fn keys( + client: &impl Client, + path: &str, + opts: Option<&mut ReadKeysRequestBuilder>, +) -> Result>, ClientError> { + let mut t = ReadKeysRequest::builder(); + let endpoint = opts.unwrap_or(&mut t).key(path).build().unwrap(); + api::exec_with_result(client, endpoint).await +} + +/// Reads the raw value at the given key. +/// +/// See [ReadKeyRequest] +#[instrument(skip(client, opts), err)] +pub async fn raw( + client: &impl Client, + key: &str, + opts: Option<&mut ReadRawKeyRequestBuilder>, +) -> Result>, ClientError> { + let mut t = ReadRawKeyRequest::builder(); + let endpoint = opts.unwrap_or(&mut t).key(key).build().unwrap(); + api::exec_with_raw(client, endpoint).await +} + +/// Reads the value at the given key. +/// +/// See [ReadKeyRequest] +#[instrument(skip(client, opts), err)] +pub async fn read( + client: &impl Client, + key: &str, + opts: Option<&mut ReadKeyRequestBuilder>, +) -> Result>, ClientError> { + let mut t = ReadKeyRequest::builder(); + let endpoint = opts.unwrap_or(&mut t).key(key).build().unwrap(); + api::exec_with_result(client, endpoint).await +} + +/// Sets the value at the given key. +/// +/// See [SetKeyRequest] +#[instrument(skip(client, value, opts), err)] +pub async fn set<'a>( + client: &'a impl Client, + key: &'a str, + value: &'static [u8], + opts: Option<&'a mut SetKeyRequestBuilder>, +) -> Result, ClientError> { + let mut t = SetKeyRequest::builder(); + let endpoint = opts + .unwrap_or(&mut t) + .key(key) + .value(value) + .build() + .unwrap(); + api::exec_with_result(client, endpoint).await +} diff --git a/src/lib.rs b/src/lib.rs index b4b1e1a..52add36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,3 +38,4 @@ extern crate tracing; pub mod api; pub mod client; pub mod error; +pub mod kv; diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..5ee8ac2 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,35 @@ +use consulrs::client::{ConsulClient, ConsulClientSettingsBuilder}; +pub use dockertest_server::servers::hashi::{ConsulServer, ConsulServerConfig}; +use dockertest_server::Test; + +pub const PORT: u32 = 9500; +pub const VERSION: &str = "1.9.9"; + +pub trait ConsulServerHelper { + fn client(&self) -> ConsulClient; +} + +impl ConsulServerHelper for ConsulServer { + fn client(&self) -> ConsulClient { + ConsulClient::new( + ConsulClientSettingsBuilder::default() + .address(self.external_url()) + .build() + .unwrap(), + ) + .unwrap() + } +} + +// Sets up a new test. +#[allow(dead_code)] +pub fn new_test() -> Test { + let mut test = Test::default(); + let config = ConsulServerConfig::builder() + .port(PORT) + .version(VERSION.into()) + .build() + .unwrap(); + test.register(config); + test +} diff --git a/tests/kv.rs b/tests/kv.rs new file mode 100644 index 0000000..ead8429 --- /dev/null +++ b/tests/kv.rs @@ -0,0 +1,46 @@ +mod common; + +use common::{ConsulServer, ConsulServerHelper}; +use consulrs::{client::Client, kv}; +use test_env_log::test; + +#[test] +fn test() { + let test = common::new_test(); + test.run(|instance| async move { + let server: ConsulServer = instance.server(); + let client = server.client(); + let key = "test"; + + test_set(&client, key).await; + test_keys(&client).await; + test_read(&client, key).await; + test_raw(&client, key).await; + test_delete(&client, key).await; + }); +} + +async fn test_delete(client: &impl Client, key: &str) { + let res = kv::delete(client, key, None).await; + assert!(res.is_ok()); +} + +async fn test_keys(client: &impl Client) { + let res = kv::keys(client, "", None).await; + assert!(res.is_ok()); +} + +async fn test_raw(client: &impl Client, key: &str) { + let res = kv::raw(client, key, None).await; + assert!(res.is_ok()); +} + +async fn test_read(client: &impl Client, key: &str) { + let res = kv::read(client, key, None).await; + assert!(res.is_ok()); +} + +async fn test_set(client: &impl Client, key: &str) { + let res = kv::set(client, key, b"test", None).await; + assert!(res.is_ok()); +} diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..11fa850 --- /dev/null +++ b/typos.toml @@ -0,0 +1,5 @@ +[default.extend-words] +hashi = "hashi" + +[files] +extend-exclude = ["target"] \ No newline at end of file