From a795bd1a2907b9bb3d6568380f6f926c4b4f5da0 Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 10:30:55 +0200 Subject: [PATCH 1/8] Obey clippy Signed-off-by: Heinz Gies --- Cargo.toml | 19 +- README.md | 20 +-- examples/cli.rs | 14 +- src/client.rs | 26 ++- src/datasets/mod.rs | 6 +- src/datasets/model.rs | 402 +++++++++++++++--------------------------- src/error.rs | 32 +++- src/http.rs | 2 +- src/lib.rs | 16 +- src/limits.rs | 1 + src/users/model.rs | 3 + tests/cursor.rs | 21 ++- tests/datasets.rs | 24 +-- 13 files changed, 262 insertions(+), 324 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f233c72..f9d02c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,15 +15,20 @@ include = ["src/**/*.rs", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] resolver = "2" [dependencies] -reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "gzip", "blocking"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "gzip", + "blocking", +] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } -serde_qs = "0.8" +serde_qs = "0.13" thiserror = "1" bytes = "1" flate2 = "1" -http = "0.2" +http = "1" backoff = { version = "0.4", features = ["futures"] } futures = "0.3" tokio = { version = "1", optional = true, features = ["rt", "sync"] } @@ -37,11 +42,9 @@ bitflags_serde_shim = "0.2.4" [dev-dependencies] tokio = { version = "1", features = ["full"] } async-std = { version = "1", features = ["attributes"] } -serde_test = "1" -test-context = "0.1" -async-trait = "0.1" +test-context = "0.3" futures-util = "0.3" -httpmock = "0.6" +httpmock = "0.7" structopt = "0.3" tracing-subscriber = { version = "0.3", features = ["ansi", "env-filter"] } @@ -52,3 +55,5 @@ async-std = ["backoff/async-std", "dep:async-std"] default-tls = ["reqwest/default-tls"] native-tls = ["reqwest/native-tls"] rustls-tls = ["reqwest/rustls-tls"] +# require a set uo environment variable to run the integration tests +integration-tests = [] diff --git a/README.md b/README.md index 67e5706..1590ff3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ For more information check out the [official documentation](https://axiom.co/doc ## Quickstart -Add the following to your Cargo.toml: +Add the following to your `Cargo.toml`: ```toml [dependencies] @@ -30,14 +30,14 @@ axiom-rs = "0.9" If you use the [Axiom CLI](https://github.com/axiomhq/cli), run `eval $(axiom config export -f)` to configure your environment variables. -Otherwise create a personal token in +Otherwise, create a personal token in [the Axiom settings](https://cloud.axiom.co/profile) and make note of the organization ID from the settings page of the organization you want to access. Create and use a client like this: -```rust +```rust,no_run use axiom_rs::Client; use serde_json::json; @@ -53,7 +53,7 @@ async fn main() -> Result<(), Box> { // AXIOM_TOKEN and AXIOM_ORG_ID: let client = Client::new()?; - client.datasets.create("my-dataset", "").await?; + client.datasets().create("my-dataset", "").await?; client .ingest( @@ -69,7 +69,7 @@ async fn main() -> Result<(), Box> { .await?; println!("{:?}", res); - client.datasets.delete("my-dataset").await?; + client.datasets().delete("my-dataset").await?; Ok(()) } ``` @@ -82,12 +82,12 @@ The following are a list of [Cargo features](https://doc.rust-lang.org/stable/cargo/reference/features.html#the-features-section) that can be enabled or disabled: -- **default-tls** _(enabled by default)_: Provides TLS support to connect +- **`default-tls`** _(enabled by default)_: Provides TLS support to connect over HTTPS. -- **native-tls**: Enables TLS functionality provided by `native-tls`. -- **rustls-tls**: Enables TLS functionality provided by `rustls`. -- **tokio** _(enabled by default)_: Enables the usage with the `tokio` runtime. -- **async-std** : Enables the usage with the `async-std` runtime. +- **`native-tls`**: Enables TLS functionality provided by `native-tls`. +- **`rustls-tls`**: Enables TLS functionality provided by `rustls`. +- **`tokio`** _(enabled by default)_: Enables the usage with the `tokio` runtime. +- **`async-std`**: Enables the usage with the `async-std` runtime. ## License diff --git a/examples/cli.rs b/examples/cli.rs index e5ae691..2e5c813 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -65,24 +65,24 @@ async fn main() -> Result<(), Box> { match opt { Opt::Datasets(datasets) => match datasets { Datasets::List => client - .datasets + .datasets() .list() .await? .into_iter() .for_each(|dataset| { println!("{:?}", dataset); }), - Datasets::Get { name } => println!("{:?}", client.datasets.get(&name).await?), - Datasets::Info { name } => println!("{:?}", client.datasets.info(&name).await?), + Datasets::Get { name } => println!("{:?}", client.datasets().get(&name).await?), + Datasets::Info { name } => println!("{:?}", client.datasets().info(&name).await?), Datasets::Update { name, description } => { - let dataset = client.datasets.update(&name, description).await?; + let dataset = client.datasets().update(&name, description).await?; println!("{:?}", dataset); } - Datasets::Delete { name } => client.datasets.delete(&name).await?, + Datasets::Delete { name } => client.datasets().delete(&name).await?, Datasets::Trim { name, seconds } => println!( "{:?}", client - .datasets + .datasets() .trim(&name, Duration::from_secs(seconds)) .await? ), @@ -105,7 +105,7 @@ async fn main() -> Result<(), Box> { }, Opt::Users(users) => match users { Users::Current => { - let user = client.users.current().await?; + let user = client.users().current().await?; println!("{:?}", user); } }, diff --git a/src/client.rs b/src/client.rs index adb41a8..66652f9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,7 +33,7 @@ static API_URL: &str = "https://api.axiom.co"; /// You can create it using [`Client::builder`] or [`Client::new`]. /// /// # Examples -/// ``` +/// ```no_run /// use axiom_rs::{Client, Error}; /// /// fn main() -> Result<(), Error> { @@ -56,8 +56,8 @@ pub struct Client { http_client: http::Client, url: String, - pub datasets: datasets::Client, - pub users: users::Client, + datasets: datasets::Client, + users: users::Client, } impl Client { @@ -71,15 +71,25 @@ impl Client { Builder::new() } - /// Get the API url (cloned). + /// Get the dataset + pub fn datasets(&self) -> &datasets::Client { + &self.datasets + } + + /// Get the users + pub fn users(&self) -> &users::Client { + &self.users + } + + /// Get the API url #[doc(hidden)] - pub fn url(&self) -> String { - self.url.clone() + pub fn url(&self) -> &str { + &self.url } /// Get client version. - pub async fn version(&self) -> String { - env!("CARGO_PKG_VERSION").to_string() + pub async fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") } /// Executes the given query specified using the Axiom Processing Language (APL). diff --git a/src/datasets/mod.rs b/src/datasets/mod.rs index 58d0f0c..79df748 100644 --- a/src/datasets/mod.rs +++ b/src/datasets/mod.rs @@ -3,7 +3,7 @@ //! You're probably looking for the [`Client`]. //! //! # Examples -//! ``` +//! ```no_run //! use axiom_rs::{Client, Error}; //! use serde_json::json; //! @@ -11,7 +11,7 @@ //! async fn main() -> Result<(), Error> { //! let client = Client::new()?; //! -//! client.datasets.create("my-dataset", "").await?; +//! client.datasets().create("my-dataset", "").await?; //! //! client.ingest("my-dataset", vec![ //! json!({ @@ -23,7 +23,7 @@ //! let res = client.query("['my-dataset'] | count", None).await?; //! assert_eq!(1, res.status.rows_matched); //! -//! client.datasets.delete("my-dataset").await?; +//! client.datasets().delete("my-dataset").await?; //! //! Ok(()) //! } diff --git a/src/datasets/model.rs b/src/datasets/model.rs index e79ed24..04b93b6 100644 --- a/src/datasets/model.rs +++ b/src/datasets/model.rs @@ -1,20 +1,19 @@ +#![allow(deprecated)] // we need this to be allowed to declare depricated code use bitflags::bitflags; use bitflags_serde_shim::impl_serde_for_bitflags; use chrono::{DateTime, Duration, Utc}; use http::header::HeaderValue; use serde::{ - de::{self, Error as SerdeError, Unexpected, Visitor}, + de::{self, Visitor}, Deserialize, Deserializer, Serialize, Serializer, }; use serde_json::value::Value as JsonValue; use std::{ collections::HashMap, - convert::TryFrom, fmt::{self, Display}, ops::Add, str::FromStr, }; -use thiserror::Error; use crate::serde::{deserialize_null_default, empty_string_as_none}; @@ -37,6 +36,7 @@ pub enum ContentType { } impl ContentType { + /// Returns the content type as a string. pub fn as_str(&self) -> &'static str { match self { ContentType::Json => "application/json", @@ -84,6 +84,7 @@ pub enum ContentEncoding { } impl ContentEncoding { + /// Returns the content encoding as a string. pub fn as_str(&self) -> &'static str { match self { ContentEncoding::Identity => "", @@ -238,6 +239,7 @@ pub struct TrimResult { note = "This field is deprecated and will be removed in a future version." )] #[serde(rename = "numDeleted")] + #[allow(deprecated, warnings)] pub blocks_deleted: u64, } @@ -318,10 +320,15 @@ pub(crate) struct DatasetUpdateRequest { #[derive(Serialize, Deserialize, Debug, Default, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Query { + /// The APL of the query to execute pub apl: String, + /// Start time of the query. pub start_time: Option>, + /// End time of the query. pub end_time: Option>, + /// cursor for the query pub cursor: Option, + /// Specifies whether the event that matches the cursor should be included or not pub include_cursor: bool, } @@ -340,15 +347,15 @@ pub(crate) struct QueryParams { pub struct QueryOptions { /// The start time of the query. pub start_time: Option>, - // The end time of the query. + /// The end time of the query. pub end_time: Option>, - // The cursor for use in pagination. + /// The cursor for use in pagination. pub cursor: Option, - // Specifies whether the event that matches the cursor should be - // included in the result. + /// Specifies whether the event that matches the cursor should be + /// included in the result. pub include_cursor: bool, - // Omits the query cache. + /// Omits the query cache. pub no_cache: bool, /// Save the query on the server, if set to `true`. The ID of the saved query /// is returned with the query result as part of the response. @@ -357,7 +364,7 @@ pub struct QueryOptions { // for the // `saveAsKind` query param. For user experience, we use a bool // here instead of forcing the user to set the value to `query.APL`. pub save: bool, - // Format specifies the format of the APL query. Defaults to Legacy. + /// Format specifies the format of the APL query. Defaults to Legacy. pub format: AplResultFormat, } @@ -376,23 +383,14 @@ impl Default for QueryOptions { } /// The result format of an APL query. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[non_exhaustive] +#[serde(rename_all = "lowercase")] pub enum AplResultFormat { + /// Legacy result format Legacy, } -impl Serialize for AplResultFormat { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - AplResultFormat::Legacy => serializer.serialize_str("legacy"), - } - } -} - impl Default for AplResultFormat { fn default() -> Self { AplResultFormat::Legacy @@ -400,39 +398,16 @@ impl Default for AplResultFormat { } /// The kind of a query. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] +#[serde(rename_all = "lowercase")] pub enum QueryKind { + /// Analytics query Analytics, + /// Streaming query Stream, - Apl, // Read-only, don't use this for requests. -} - -impl Serialize for QueryKind { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - QueryKind::Analytics => serializer.serialize_str("analytics"), - QueryKind::Stream => serializer.serialize_str("stream"), - QueryKind::Apl => serializer.serialize_str("apl"), - } - } -} - -impl<'de> Deserialize<'de> for QueryKind { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - match String::deserialize(deserializer)?.as_str() { - "analytics" => Ok(QueryKind::Analytics), - "stream" => Ok(QueryKind::Stream), - "apl" => Ok(QueryKind::Apl), - _ => Err(D::Error::custom("unknown query kind")), - } - } + /// APL query, Read-only, don't use this for requests. + Apl, } impl Default for QueryKind { @@ -443,7 +418,7 @@ impl Default for QueryKind { /// A query that gets executed on a dataset. /// If you're looking for the APL query, check out [`Query`]. -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct LegacyQuery { /// Start time of the query. @@ -475,6 +450,7 @@ pub struct LegacyQuery { /// orders. #[serde(default, deserialize_with = "deserialize_null_default")] pub virtual_fields: Vec, + /// Pricections for the query result. #[serde(default, deserialize_with = "deserialize_null_default")] pub projections: Vec, /// The query cursor. Should be set to the cursor returned with a previous @@ -490,26 +466,6 @@ pub struct LegacyQuery { pub continuation_token: String, } -impl Default for LegacyQuery { - fn default() -> Self { - LegacyQuery { - start_time: None, - end_time: None, - resolution: "".to_string(), - aggregations: vec![], - filter: None, - group_by: vec![], - order: vec![], - limit: 0, - virtual_fields: vec![], - projections: vec![], - cursor: "".to_string(), - include_cursor: false, - continuation_token: "".to_string(), - } - } -} - /// A field that is projected to the query result. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Projection { @@ -523,29 +479,46 @@ pub struct Projection { #[derive(Debug, PartialEq, Eq)] #[non_exhaustive] pub enum AggregationOp { + /// [count](https://axiom.co/docs/apl/aggregation-function/statistical-functions#count()) Count, + /// [dcount](https://axiom.co/docs/apl/aggregation-function/statistical-functions#dcount()) CountDistinct, + /// [make_set](https://axiom.co/docs/apl/aggregation-function/statistical-functions#make-set()) MakeSet, + /// [make_set_if](https://axiom.co/docs/apl/aggregation-function/statistical-functions#make-set-if()) MakeSetIf, - // Only works for numbers. + /// [sum](https://axiom.co/docs/apl/aggregation-function/statistical-functions#sum()) Sum, + /// [avg](https://axiom.co/docs/apl/aggregation-function/statistical-functions#avg()) Avg, + /// [min](https://axiom.co/docs/apl/aggregation-function/statistical-functions#min()) Min, + /// [max](https://axiom.co/docs/apl/aggregation-function/statistical-functions#max()) Max, + /// [topk](https://axiom.co/docs/apl/aggregation-function/statistical-functions#topk()) Topk, + /// [percentile](https://axiom.co/docs/apl/aggregation-function/statistical-functions#percentile(),-percentiles-array()) Percentiles, + /// [histogram](https://axiom.co/docs/apl/aggregation-function/statistical-functions#histogram()) Histogram, + /// [stdev](https://axiom.co/docs/apl/aggregation-function/statistical-functions#stdev()) StandardDeviation, + /// [variance](https://axiom.co/docs/apl/aggregation-function/statistical-functions#variance()) Variance, + /// [argmin](https://axiom.co/docs/apl/aggregation-function/statistical-functions#argmin()) ArgMin, + /// [argmax](https://axiom.co/docs/apl/aggregation-function/statistical-functions#argmax()) ArgMax, - // Read-only. Not to be used for query requests. Only in place to support - // the APL query result. + /// Read-only. Not to be used for query requests. Only in place to support the APL query result. + /// [countif](https://axiom.co/docs/apl/aggregation-function/statistical-functions#countif()) CountIf, + /// Read-only. Not to be used for query requests. Only in place to support the APL query result. + /// [dcountif](https://axiom.co/docs/apl/aggregation-function/statistical-functions#dcountif()) DistinctIf, + /// Unknown aggregation operation. Unknown(String), } @@ -638,124 +611,79 @@ pub struct Aggregation { pub argument: Option, } -/// Supported filter operations. -#[derive(Debug, PartialEq, Eq)] +/// Supported filter operations. Supported types listed behind each operation. +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] +#[serde(rename_all = "lowercase")] pub enum FilterOp { + /// Logical AND And, + /// Logical OR Or, + /// Logical NOT Not, // Works for strings and numbers. + /// equality (string, number) + #[serde(rename = "==")] Equal, + /// negated equality (string, number) + #[serde(rename = "!=")] NotEqual, + /// existance (string, number) Exists, + /// negated existance (string, number) NotExists, // Only works for numbers. + /// greater than (number) + #[serde(rename = ">")] GreaterThan, + /// greater than or equal (number) + #[serde(rename = ">=")] GreaterThanEqual, + /// less than (number) + #[serde(rename = "<")] LessThan, + /// less than or equal (number) + #[serde(rename = "<=")] LessThanEqual, // Only works for strings. + /// starts with (string) StartsWith, + /// negated starts with (string) NotStartsWith, + /// ends with (string) EndsWith, + /// negated ends with (string) NotEndsWith, + /// regular expression (string) Regexp, + /// negated regular expression (string) NotRegexp, // Works for strings and arrays. + /// contains (string, array) Contains, + /// negated contains (string, array) NotContains, } -impl Serialize for FilterOp { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(match self { - FilterOp::And => "and", - FilterOp::Or => "or", - FilterOp::Not => "not", - FilterOp::Equal => "==", - FilterOp::NotEqual => "!=", - FilterOp::Exists => "exists", - FilterOp::NotExists => "not-exists", - FilterOp::GreaterThan => ">", - FilterOp::GreaterThanEqual => ">=", - FilterOp::LessThan => "<", - FilterOp::LessThanEqual => "<=", - FilterOp::StartsWith => "starts-with", - FilterOp::NotStartsWith => "not-starts-with", - FilterOp::EndsWith => "ends-with", - FilterOp::NotEndsWith => "not-ends-with", - FilterOp::Regexp => "regexp", - FilterOp::NotRegexp => "not-regexp", - FilterOp::Contains => "contains", - FilterOp::NotContains => "not-contains", - }) - } -} - -struct FilterOpVisitor; - -impl<'de> Visitor<'de> for FilterOpVisitor { - type Value = FilterOp; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "a valid filter op string") - } - - fn visit_str(self, s: &str) -> Result - where - E: de::Error, - { - match s { - "and" => Ok(FilterOp::And), - "or" => Ok(FilterOp::Or), - "not" => Ok(FilterOp::Not), - "==" => Ok(FilterOp::Equal), - "!=" => Ok(FilterOp::NotEqual), - "exists" => Ok(FilterOp::Exists), - "not-exists" => Ok(FilterOp::NotExists), - ">" => Ok(FilterOp::GreaterThan), - ">=" => Ok(FilterOp::GreaterThanEqual), - "<" => Ok(FilterOp::LessThan), - "<=" => Ok(FilterOp::LessThanEqual), - "starts-with" => Ok(FilterOp::StartsWith), - "not-starts-with" => Ok(FilterOp::NotStartsWith), - "ends-with" => Ok(FilterOp::EndsWith), - "not-ends-with" => Ok(FilterOp::NotEndsWith), - "regexp" => Ok(FilterOp::Regexp), - "not-regexp" => Ok(FilterOp::NotRegexp), - "contains" => Ok(FilterOp::Contains), - "not-contains" => Ok(FilterOp::NotContains), - _ => Err(de::Error::invalid_value(Unexpected::Str(s), &self)), - } - } -} - -impl<'de> Deserialize<'de> for FilterOp { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_str(FilterOpVisitor {}) - } -} - /// A filter is applied to a query. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Filter { + /// The operation of the filter. pub op: FilterOp, + /// The field to filter on. pub field: String, + /// The value to filter against. pub value: JsonValue, + /// If the filter should be case insensitive. #[serde(default)] pub case_insensitive: bool, + /// Child filters that are applied to the filter. #[serde(default, deserialize_with = "deserialize_null_default")] pub children: Vec, } @@ -795,10 +723,13 @@ pub struct VirtualField { /// The parameters for a query. #[derive(Serialize, Deserialize, Debug, Default)] pub struct LegacyQueryOptions { + /// Duration of the stream #[serde(rename = "streaming-duration")] pub streaming_duration: Option, // TODO: Implement custom type to {de,}serialize to/from go string + /// If the query should not be cached. #[serde(rename = "no-cache")] pub no_cache: bool, + /// The kind to save the query wit. #[serde(rename = "saveAsKind")] pub save_as_kind: QueryKind, } @@ -812,6 +743,7 @@ pub struct QueryResult { // NOTE: The following is copied from QueryResult. Maybe we should have a macro? /// The status of the query result. pub status: QueryStatus, + /// The datasets that were queried. #[serde(default, deserialize_with = "deserialize_null_default")] pub dataset_names: Vec, /// The events that matched the query. @@ -883,10 +815,14 @@ bitflags! { /// The cache status of the query. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct CacheStatus: u32 { + /// cache miss const Miss = 1; - const Materialized = 2; // Filtered rows - const Results = 4; // Aggregated and grouped records - const WalCached = 8; // WAL is cached + /// Filtered rows + const Materialized = 2; + /// Aggregated and grouped records + const Results = 4; + /// WAL is cached + const WalCached = 8; } } impl_serde_for_bitflags!(CacheStatus); @@ -901,125 +837,40 @@ pub struct QueryMessage { } /// The priority of a query message. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Copy)] #[non_exhaustive] +#[serde(rename_all = "lowercase")] pub enum QueryMessagePriority { + /// Trace message priority. Trace, + /// Debug message priority. Debug, + /// Info message priority. Info, + /// Warn message priority. Warn, + /// Error message priority. Error, + /// Fatal message priority. Fatal, } -impl std::fmt::Display for QueryMessagePriority { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - QueryMessagePriority::Trace => "trace", - QueryMessagePriority::Debug => "debug", - QueryMessagePriority::Info => "info", - QueryMessagePriority::Warn => "warn", - QueryMessagePriority::Error => "error", - QueryMessagePriority::Fatal => "fatal", - }) - } -} - -#[derive(Error, Debug)] -pub enum ParseQueryMessagePriorityError { - #[error("Unknown item: {0}")] - UnknownItem(String), -} - -impl TryFrom<&str> for QueryMessagePriority { - type Error = ParseQueryMessagePriorityError; - - fn try_from(s: &str) -> Result>::Error> { - match s { - "trace" => Ok(QueryMessagePriority::Trace), - "debug" => Ok(QueryMessagePriority::Debug), - "info" => Ok(QueryMessagePriority::Info), - "warn" => Ok(QueryMessagePriority::Warn), - "error" => Ok(QueryMessagePriority::Error), - "fatal" => Ok(QueryMessagePriority::Fatal), - item => Err(ParseQueryMessagePriorityError::UnknownItem( - item.to_string(), - )), - } - } -} - -impl Serialize for QueryMessagePriority { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(self.to_string().as_str()) - } -} - -impl<'de> Deserialize<'de> for QueryMessagePriority { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value: &str = Deserialize::deserialize(deserializer)?; - Self::try_from(value).map_err(serde::de::Error::custom) - } -} - /// The code of a message that is returned in the status of a query. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Copy)] #[non_exhaustive] +#[serde(rename_all = "snake_case")] pub enum QueryMessageCode { - Unknown, + /// Failed to finalize a virtual field. VirtualFieldFinalizeError, + /// Missing column in the dataset. MissingColumn, + /// Default limit warning. DefaultLimitWarning, + /// License limit for query warning. LicenseLimitForQueryWarning, -} - -impl std::fmt::Display for QueryMessageCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - QueryMessageCode::Unknown => "unknown", - QueryMessageCode::VirtualFieldFinalizeError => "virtual_field_finalize_error", - QueryMessageCode::MissingColumn => "missing_column", - QueryMessageCode::DefaultLimitWarning => "default_limit_warning", - QueryMessageCode::LicenseLimitForQueryWarning => "license_limit_for_query_warning", - }) - } -} - -impl From<&str> for QueryMessageCode { - fn from(s: &str) -> Self { - match s { - "virtual_field_finalize_error" => QueryMessageCode::VirtualFieldFinalizeError, - "missing_column" => QueryMessageCode::MissingColumn, - "default_limit_warning" => QueryMessageCode::DefaultLimitWarning, - "license_limit_for_query_warning" => QueryMessageCode::LicenseLimitForQueryWarning, - _ => QueryMessageCode::Unknown, - } - } -} - -impl Serialize for QueryMessageCode { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(self.to_string().as_str()) - } -} - -impl<'de> Deserialize<'de> for QueryMessageCode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value: &str = Deserialize::deserialize(deserializer)?; - Ok(Self::from(value)) - } + /// Other unknown error + #[serde(other)] + Unknown, } /// An event that matched a query and is thus part of the result set. @@ -1053,8 +904,11 @@ pub struct Timeseries { #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Interval { + /// The start time of the interval. pub start_time: DateTime, + /// The end time of the interval. pub end_time: DateTime, + /// The groups of the interval. #[serde(default, deserialize_with = "deserialize_null_default")] pub groups: Vec, } @@ -1062,35 +916,55 @@ pub struct Interval { /// A group of queried event. #[derive(Serialize, Deserialize, Debug)] pub struct EntryGroup { + /// The unique ID of the group. pub id: u64, + /// The data of the group. pub group: HashMap, + /// The aggregations of the group. pub aggregations: Vec, } /// An aggregation which is part of a group of queried events. #[derive(Serialize, Deserialize, Debug)] pub struct EntryGroupAgg { + /// The alias of the aggregation. #[serde(rename = "op")] pub alias: String, + /// The value of the aggregation. pub value: JsonValue, } #[cfg(test)] mod test { use super::*; - use serde_test::{assert_de_tokens, assert_tokens, Token}; #[test] fn test_aggregation_op() { - let count = AggregationOp::Count; - assert_tokens(&count, &[Token::Str("count")]); - assert_de_tokens(&count, &[Token::Str("count")]); + let enum_repr = AggregationOp::Count; + let json_repr = r#""count""#; + assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); + assert_eq!( + serde_json::from_str::(json_repr).unwrap(), + enum_repr + ); } #[test] fn test_filter_op() { - let and = FilterOp::And; - assert_tokens(&and, &[Token::Str("and")]); - assert_de_tokens(&and, &[Token::Str("and")]); + let enum_repr = FilterOp::And; + let json_repr = r#""and""#; + assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); + assert_eq!( + serde_json::from_str::(json_repr).unwrap(), + enum_repr + ); + + let enum_repr = FilterOp::Equal; + let json_repr = r#""==""#; + assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); + assert_eq!( + serde_json::from_str::(json_repr).unwrap(), + enum_repr + ); } } diff --git a/src/error.rs b/src/error.rs index 8f7e3a2..5c73548 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,47 +14,73 @@ pub type Result = std::result::Result; #[non_exhaustive] pub enum Error { #[error("Missing token")] + /// Missing token. MissingToken, #[error("Missing Org ID for Personal Access Token")] + /// Missing Org ID for Personal Access Token. MissingOrgId, #[error("Invalid token (make sure there are no invalid characters)")] + /// Invalid token. InvalidToken, #[error("Invalid Org ID (make sure there are no invalid characters)")] + /// Invalid Org ID. InvalidOrgId, #[error("Failed to setup HTTP client: {0}")] + /// Failed to setup HTTP client. HttpClientSetup(reqwest::Error), #[error("Failed to deserialize response: {0}")] + /// Failed to deserialize response. Deserialize(reqwest::Error), #[error("Http error: {0}")] + /// HTTP error. Http(reqwest::Error), #[error(transparent)] + /// Axion API error. Axiom(AxiomError), #[error("Query ID contains invisible characters (this is a server error)")] + /// Query ID contains invisible characters (this is a server error). InvalidQueryId, #[error(transparent)] + /// Invalid Query Parameters. InvalidParams(#[from] serde_qs::Error), #[error(transparent)] + /// Invalid JSON. Serialize(#[from] serde_json::Error), #[error("Failed to encode payload: {0}")] + /// Failed to encode payload. Encoding(std::io::Error), #[error("Duration is out of range (can't be larger than i64::MAX milliseconds)")] + /// Duration is out of range (can't be larger than i64::MAX milliseconds). DurationOutOfRange, #[cfg(feature = "tokio")] #[error("Failed to join thread: {0}")] + /// Failed to join thread. JoinError(tokio::task::JoinError), #[error("Rate limit exceeded for the {scope} scope: {limits}")] - RateLimitExceeded { scope: String, limits: Limits }, + /// Rate limit exceeded. + RateLimitExceeded { + /// The scope of the rate limit. + scope: String, + /// The rate limit. + limits: Limits, + }, #[error("Query limit exceeded: {0}")] + /// Query limit exceeded. QueryLimitExceeded(Limits), #[error("Ingest limit exceeded: {0}")] + /// Ingest limit exceeded. IngestLimitExceeded(Limits), #[error("Invalid URL: {0}")] + /// Invalid URL. InvalidUrl(url::ParseError), #[error("Error in ingest stream: {0}")] + /// Error in ingest stream. IngestStreamError(Box), #[error("Invalid content type: {0}")] + /// Invalid content type. InvalidContentType(String), #[error("Invalid content encoding: {0}")] + /// Invalid content encoding. InvalidContentEncoding(String), } @@ -76,11 +102,15 @@ impl From> for Error { #[derive(Deserialize, Debug)] pub struct AxiomError { #[serde(skip)] + /// The HTTP status code. pub status: u16, #[serde(skip)] + /// The HTTP method. pub method: http::Method, #[serde(skip)] + /// The path that was requested. pub path: String, + /// The error message. pub message: Option, } diff --git a/src/http.rs b/src/http.rs index 6404732..f7c7606 100644 --- a/src/http.rs +++ b/src/http.rs @@ -365,7 +365,7 @@ mod test { .with_token("xapt-nope") .build()?; - match client.datasets.list().await { + match client.datasets().list().await { Err(Error::RateLimitExceeded { scope, limits }) => { assert_eq!(scope, "user"); assert_eq!(limits.limit, 42); diff --git a/src/lib.rs b/src/lib.rs index 83b724c..bf48304 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ //! It contains all methods you'll need to interact with the API. //! //! # Examples -//! ``` +//! ```no_run //! use axiom_rs::{Client, Error}; //! use serde_json::json; //! @@ -13,7 +13,7 @@ //! let client = Client::new()?; //! //! // Create a dataset called my-dataset -//! let dataset = client.datasets.create("my-dataset", "a description").await?; +//! let dataset = client.datasets().create("my-dataset", "a description").await?; //! //! // Ingest one event //! client.ingest(&dataset.name, vec![ @@ -25,11 +25,21 @@ //! dbg!(query_res.matches); //! //! // Delete the dataset -//! client.datasets.delete(dataset.name).await?; +//! client.datasets().delete(dataset.name).await?; //! //! Ok(()) //! } //! ``` + +#![deny(warnings)] +#![deny(missing_docs)] +#![deny( + clippy::all, + clippy::unwrap_used, + clippy::unnecessary_unwrap, + clippy::pedantic, + clippy::mod_module_files +)] pub mod client; pub mod error; mod http; diff --git a/src/limits.rs b/src/limits.rs index 6c46261..5c18234 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -106,6 +106,7 @@ impl Display for Limits { } impl Limits { + /// Returns `true` if the rate limit has been exceeded. pub fn is_exceeded(&self) -> bool { self.remaining == 0 && self.reset > Utc::now() } diff --git a/src/users/model.rs b/src/users/model.rs index cc667bd..7058722 100644 --- a/src/users/model.rs +++ b/src/users/model.rs @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize}; /// An authenticated Axiom user. #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] pub struct User { + /// The user's unique identifier. pub id: String, + /// The user's name. pub name: String, + /// The user's email address. pub emails: Vec, } diff --git a/tests/cursor.rs b/tests/cursor.rs index 3d494de..94656b2 100644 --- a/tests/cursor.rs +++ b/tests/cursor.rs @@ -1,4 +1,4 @@ -use async_trait::async_trait; +#![cfg(feature = "integration-tests")] use axiom_rs::{datasets::*, Client}; use chrono::{Duration, Utc}; use serde_json::json; @@ -10,9 +10,8 @@ struct Context { dataset: Dataset, } -#[async_trait] impl AsyncTestContext for Context { - async fn setup() -> Context { + async fn setup() -> Self { let client = Client::new().unwrap(); let dataset_name = format!( @@ -21,9 +20,13 @@ impl AsyncTestContext for Context { ); // Delete dataset in case we have a zombie - client.datasets.delete(&dataset_name).await.ok(); + client.datasets().delete(&dataset_name).await.ok(); - let dataset = client.datasets.create(&dataset_name, "bar").await.unwrap(); + let dataset = client + .datasets() + .create(&dataset_name, "bar") + .await + .unwrap(); assert_eq!(dataset_name.clone(), dataset.name); assert_eq!("bar".to_string(), dataset.description); @@ -31,7 +34,7 @@ impl AsyncTestContext for Context { } async fn teardown(self) { - self.client.datasets.delete(self.dataset.name).await.ok(); + self.client.datasets().delete(self.dataset.name).await.ok(); } } @@ -53,7 +56,7 @@ async fn test_cursor_impl(ctx: &mut Context) { // Let's update the dataset. let dataset = ctx .client - .datasets + .datasets() .update( &ctx.dataset.name, "This is a soon to be filled test dataset", @@ -63,14 +66,14 @@ async fn test_cursor_impl(ctx: &mut Context) { ctx.dataset = dataset; // Get the dataset and make sure it matches what we have updated it to. - let dataset = ctx.client.datasets.get(&ctx.dataset.name).await.unwrap(); + let dataset = ctx.client.datasets().get(&ctx.dataset.name).await.unwrap(); assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.description, dataset.description); // List all datasets and make sure the created dataset is part of that // list. - let datasets = ctx.client.datasets.list().await.unwrap(); + let datasets = ctx.client.datasets().list().await.unwrap(); datasets .iter() .find(|dataset| dataset.name == ctx.dataset.name) diff --git a/tests/datasets.rs b/tests/datasets.rs index ec20577..5ed6e40 100644 --- a/tests/datasets.rs +++ b/tests/datasets.rs @@ -1,4 +1,4 @@ -use async_trait::async_trait; +#![cfg(feature = "integration-tests")] use axiom_rs::{datasets::*, Client}; use chrono::{Duration, Utc}; use futures::StreamExt; @@ -11,7 +11,6 @@ struct Context { dataset: Dataset, } -#[async_trait] impl AsyncTestContext for Context { async fn setup() -> Context { let client = Client::new().unwrap(); @@ -22,9 +21,13 @@ impl AsyncTestContext for Context { ); // Delete dataset in case we have a zombie - client.datasets.delete(&dataset_name).await.ok(); + client.datasets().delete(&dataset_name).await.ok(); - let dataset = client.datasets.create(&dataset_name, "bar").await.unwrap(); + let dataset = client + .datasets() + .create(&dataset_name, "bar") + .await + .unwrap(); assert_eq!(dataset_name.clone(), dataset.name); assert_eq!("bar".to_string(), dataset.description); @@ -32,7 +35,7 @@ impl AsyncTestContext for Context { } async fn teardown(self) { - self.client.datasets.delete(self.dataset.name).await.ok(); + self.client.datasets().delete(self.dataset.name).await.ok(); } } @@ -42,7 +45,6 @@ impl AsyncTestContext for Context { async fn test_datasets(ctx: &mut Context) { test_datasets_impl(ctx).await; } - #[cfg(feature = "async-std")] #[test_context(Context)] #[async_std::test] @@ -54,7 +56,7 @@ async fn test_datasets_impl(ctx: &mut Context) { // Let's update the dataset. let dataset = ctx .client - .datasets + .datasets() .update( &ctx.dataset.name, "This is a soon to be filled test dataset", @@ -64,14 +66,14 @@ async fn test_datasets_impl(ctx: &mut Context) { ctx.dataset = dataset; // Get the dataset and make sure it matches what we have updated it to. - let dataset = ctx.client.datasets.get(&ctx.dataset.name).await.unwrap(); + let dataset = ctx.client.datasets().get(&ctx.dataset.name).await.unwrap(); assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.description, dataset.description); // List all datasets and make sure the created dataset is part of that // list. - let datasets = ctx.client.datasets.list().await.unwrap(); + let datasets = ctx.client.datasets().list().await.unwrap(); datasets .iter() .find(|dataset| dataset.name == ctx.dataset.name) @@ -169,7 +171,7 @@ async fn test_datasets_impl(ctx: &mut Context) { tokio::time::sleep(StdDuration::from_secs(15)).await; // Get the dataset info and make sure four events have been ingested. - let info = ctx.client.datasets.info(&ctx.dataset.name).await.unwrap(); + let info = ctx.client.datasets().info(&ctx.dataset.name).await.unwrap(); assert_eq!(ctx.dataset.name, info.stat.name); assert_eq!(4327, info.stat.num_events); assert!(info.fields.len() > 0); @@ -282,7 +284,7 @@ async fn test_datasets_impl(ctx: &mut Context) { // Trim the dataset down to a minimum. ctx.client - .datasets + .datasets() .trim(&ctx.dataset.name, Duration::seconds(1)) .await .unwrap(); From 9497f8b3c9e8024bfdd3b1b6b41b4e82d89c0cfa Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 10:45:22 +0200 Subject: [PATCH 2/8] Obey clippy some more Signed-off-by: Heinz Gies --- examples/cli.rs | 6 +++--- src/client.rs | 30 ++++++++++++++++++++------- src/{datasets/mod.rs => datasets.rs} | 0 src/datasets/client.rs | 14 +++++++------ src/datasets/model.rs | 31 +++++++++++++++++++--------- src/error.rs | 21 +++++++++---------- src/http.rs | 10 ++++----- src/limits.rs | 2 +- src/serde.rs | 2 +- src/{users/mod.rs => users.rs} | 0 src/users/client.rs | 2 +- 11 files changed, 72 insertions(+), 46 deletions(-) rename src/{datasets/mod.rs => datasets.rs} (100%) rename src/{users/mod.rs => users.rs} (100%) diff --git a/examples/cli.rs b/examples/cli.rs index 2e5c813..5ac7bfc 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -26,8 +26,8 @@ enum Datasets { List, /// Get a dataset Get { name: String }, - /// Get information for a dataset - Info { name: String }, + // /// Get information for a dataset + // Info { name: String }, /// Update the description of a dataset Update { name: String, @@ -73,7 +73,7 @@ async fn main() -> Result<(), Box> { println!("{:?}", dataset); }), Datasets::Get { name } => println!("{:?}", client.datasets().get(&name).await?), - Datasets::Info { name } => println!("{:?}", client.datasets().info(&name).await?), + // Datasets::Info { name } => println!("{:?}", client.datasets().info(&name).await?), Datasets::Update { name, description } => { let dataset = client.datasets().update(&name, description).await?; println!("{:?}", dataset); diff --git a/src/client.rs b/src/client.rs index 66652f9..2c7b5b6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -62,33 +62,41 @@ pub struct Client { impl Client { /// Creates a new client. If you want to configure it, use [`Client::builder`]. + /// + /// # Errors + /// If the client can not be created pub fn new() -> Result { Self::builder().build() } /// Create a new client using a builder. + #[must_use] pub fn builder() -> Builder { Builder::new() } /// Get the dataset + #[must_use] pub fn datasets(&self) -> &datasets::Client { &self.datasets } /// Get the users + #[must_use] pub fn users(&self) -> &users::Client { &self.users } /// Get the API url #[doc(hidden)] + #[must_use] pub fn url(&self) -> &str { &self.url } /// Get client version. - pub async fn version(&self) -> &str { + #[must_use] + pub fn version(&self) -> &str { env!("CARGO_PKG_VERSION") } @@ -128,7 +136,7 @@ impl Client { }; let query_params = serde_qs::to_string(&query_params)?; - let path = format!("/v1/datasets/_apl?{}", query_params); + let path = format!("/v1/datasets/_apl?{query_params}"); let res = self.http_client.post(path, &req).await?; let saved_query_id = res @@ -137,7 +145,7 @@ impl Client { .map(|s| s.to_str()) .transpose() .map_err(|_e| Error::InvalidQueryId)? - .map(|s| s.to_string()); + .map(std::string::ToString::to_string); let mut result = res.json::().await?; result.saved_query_id = saved_query_id; @@ -166,8 +174,7 @@ impl Client { dataset_name.into(), &opts .into() - .map(|opts| { serde_qs::to_string(&opts) }) - .unwrap_or_else(|| Ok(String::new()))? + .map_or_else(|| Ok(String::new()), |opts| { serde_qs::to_string(&opts) })? ); let res = self.http_client.post(path, &query).await?; @@ -177,7 +184,7 @@ impl Client { .map(|s| s.to_str()) .transpose() .map_err(|_e| Error::InvalidQueryId)? - .map(|s| s.to_string()); + .map(std::string::ToString::to_string); let mut result = res.json::().await?; result.saved_query_id = saved_query_id; @@ -265,7 +272,7 @@ impl Client { let mut ingest_status = IngestStatus::default(); while let Some(events) = chunks.next().await { let new_ingest_status = self.ingest(dataset_name.clone(), events).await?; - ingest_status = ingest_status + new_ingest_status + ingest_status = ingest_status + new_ingest_status; } Ok(ingest_status) } @@ -291,7 +298,7 @@ impl Client { match events { Ok(events) => { let new_ingest_status = self.ingest(dataset_name.clone(), events).await?; - ingest_status = ingest_status + new_ingest_status + ingest_status = ingest_status + new_ingest_status; } Err(e) => return Err(Error::IngestStreamError(Box::new(e))), } @@ -320,6 +327,7 @@ impl Builder { } /// Don't fall back to environment variables. + #[must_use] pub fn no_env(mut self) -> Self { self.env_fallback = false; self @@ -327,6 +335,7 @@ impl Builder { /// Add a token to the client. If this is not set, the token will be read /// from the environment variable `AXIOM_TOKEN`. + #[must_use] pub fn with_token>(mut self, token: S) -> Self { self.token = Some(token.into()); self @@ -335,6 +344,7 @@ impl Builder { /// Add an URL to the client. This is only meant for testing purposes, you /// don't need to set it. #[doc(hidden)] + #[must_use] pub fn with_url>(mut self, url: S) -> Self { self.url = Some(url.into()); self @@ -342,12 +352,16 @@ impl Builder { /// Add an organization ID to the client. If this is not set, the /// organization ID will be read from the environment variable `AXIOM_ORG_ID`. + #[must_use] pub fn with_org_id>(mut self, org_id: S) -> Self { self.org_id = Some(org_id.into()); self } /// Build the client. + /// + /// # Errors + /// If the client can not be built pub fn build(self) -> Result { let env_fallback = self.env_fallback; diff --git a/src/datasets/mod.rs b/src/datasets.rs similarity index 100% rename from src/datasets/mod.rs rename to src/datasets.rs diff --git a/src/datasets/client.rs b/src/datasets/client.rs index b7f9abe..2a01e7c 100644 --- a/src/datasets/client.rs +++ b/src/datasets/client.rs @@ -1,3 +1,11 @@ +#[allow(deprecated)] +use crate::{ + datasets::model::{ + Dataset, DatasetCreateRequest, DatasetUpdateRequest, Info, TrimRequest, TrimResult, + }, + error::{Error, Result}, + http, +}; use std::{ convert::{TryFrom, TryInto}, fmt::Debug as FmtDebug, @@ -6,12 +14,6 @@ use std::{ }; use tracing::instrument; -use crate::{ - datasets::model::*, - error::{Error, Result}, - http, -}; - /// Provides methods to work with Axiom datasets, including ingesting and /// querying. /// If you're looking for the ingest and query methods, those are at the diff --git a/src/datasets/model.rs b/src/datasets/model.rs index 04b93b6..d530cc4 100644 --- a/src/datasets/model.rs +++ b/src/datasets/model.rs @@ -37,6 +37,7 @@ pub enum ContentType { impl ContentType { /// Returns the content type as a string. + #[must_use] pub fn as_str(&self) -> &'static str { match self { ContentType::Json => "application/json", @@ -85,6 +86,7 @@ pub enum ContentEncoding { impl ContentEncoding { /// Returns the content encoding as a string. + #[must_use] pub fn as_str(&self) -> &'static str { match self { ContentEncoding::Identity => "", @@ -692,7 +694,7 @@ impl Default for Filter { fn default() -> Self { Filter { op: FilterOp::Equal, - field: "".to_string(), + field: String::new(), value: JsonValue::Null, case_insensitive: false, children: vec![], @@ -709,7 +711,7 @@ pub struct Order { pub desc: bool, } -/// A VirtualField is not part of a dataset and its value is derived from an +/// A `VirtualField` is not part of a dataset and its value is derived from an /// expression. Aggregations, filters and orders can reference this field like /// any other field. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] @@ -790,7 +792,7 @@ pub struct QueryStatus { pub num_groups: u32, /// True if the query result is a partial result. pub is_partial: bool, - /// Populated when IsPartial is true, must be passed to the next query + /// Populated when `IsPartial` is true, must be passed to the next query /// request to retrieve the next result set. pub continuation_token: Option, /// True if the query result is estimated. @@ -876,7 +878,7 @@ pub enum QueryMessageCode { /// An event that matched a query and is thus part of the result set. #[derive(Serialize, Deserialize, Debug)] pub struct Entry { - /// The time the event occurred. Matches SysTime if not specified during + /// The time the event occurred. Matches `SysTime` if not specified during /// ingestion. #[serde(rename = "_time")] pub time: DateTime, @@ -942,9 +944,12 @@ mod test { fn test_aggregation_op() { let enum_repr = AggregationOp::Count; let json_repr = r#""count""#; - assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); assert_eq!( - serde_json::from_str::(json_repr).unwrap(), + serde_json::to_string(&enum_repr).expect("json error"), + json_repr + ); + assert_eq!( + serde_json::from_str::(json_repr).expect("json error"), enum_repr ); } @@ -953,17 +958,23 @@ mod test { fn test_filter_op() { let enum_repr = FilterOp::And; let json_repr = r#""and""#; - assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); assert_eq!( - serde_json::from_str::(json_repr).unwrap(), + serde_json::to_string(&enum_repr).expect("json error"), + json_repr + ); + assert_eq!( + serde_json::from_str::(json_repr).expect("json error"), enum_repr ); let enum_repr = FilterOp::Equal; let json_repr = r#""==""#; - assert_eq!(serde_json::to_string(&enum_repr).unwrap(), json_repr); assert_eq!( - serde_json::from_str::(json_repr).unwrap(), + serde_json::to_string(&enum_repr).expect("json error"), + json_repr + ); + assert_eq!( + serde_json::from_str::(json_repr).expect("json error"), enum_repr ); } diff --git a/src/error.rs b/src/error.rs index 5c73548..4e2d76e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,6 @@ use serde::Deserialize; use std::fmt; -use thiserror::Error; use crate::limits::Limits; @@ -10,7 +9,7 @@ use crate::limits::Limits; pub type Result = std::result::Result; /// The error type for the Axiom client. -#[derive(Error, Debug)] +#[derive(thiserror::Error, Debug)] #[non_exhaustive] pub enum Error { #[error("Missing token")] @@ -36,7 +35,7 @@ pub enum Error { Http(reqwest::Error), #[error(transparent)] /// Axion API error. - Axiom(AxiomError), + Axiom(Axiom), #[error("Query ID contains invisible characters (this is a server error)")] /// Query ID contains invisible characters (this is a server error). InvalidQueryId, @@ -50,7 +49,7 @@ pub enum Error { /// Failed to encode payload. Encoding(std::io::Error), #[error("Duration is out of range (can't be larger than i64::MAX milliseconds)")] - /// Duration is out of range (can't be larger than i64::MAX milliseconds). + /// Duration is out of range (can't be larger than `i64::MAX` milliseconds). DurationOutOfRange, #[cfg(feature = "tokio")] #[error("Failed to join thread: {0}")] @@ -85,12 +84,12 @@ pub enum Error { } /// This is the manual implementation. We don't really care if the error is -/// permanent or transient at this stage so we just return Error::Http. +/// permanent or transient at this stage so we just return `Error::Http`. impl From> for Error { fn from(err: backoff::Error) -> Self { match err { - backoff::Error::Permanent(err) => Error::Http(err), - backoff::Error::Transient { + backoff::Error::Permanent(err) + | backoff::Error::Transient { err, retry_after: _, } => Error::Http(err), @@ -100,7 +99,7 @@ impl From> for Error { /// An error returned by the Axiom API. #[derive(Deserialize, Debug)] -pub struct AxiomError { +pub struct Axiom { #[serde(skip)] /// The HTTP status code. pub status: u16, @@ -114,7 +113,7 @@ pub struct AxiomError { pub message: Option, } -impl AxiomError { +impl Axiom { pub(crate) fn new( status: u16, method: http::Method, @@ -130,9 +129,9 @@ impl AxiomError { } } -impl std::error::Error for AxiomError {} +impl std::error::Error for Axiom {} -impl fmt::Display for AxiomError { +impl fmt::Display for Axiom { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if let Some(msg) = self.message.as_ref() { write!( diff --git a/src/http.rs b/src/http.rs index f7c7606..4e0a420 100644 --- a/src/http.rs +++ b/src/http.rs @@ -7,13 +7,13 @@ use std::{env, time::Duration}; use url::Url; use crate::{ - error::{AxiomError, Error, Result}, + error::{Axiom, Error, Result}, limits::Limit, }; static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); -/// Client is a wrapper around reqwest::Client which provides automatically +/// Client is a wrapper around `reqwest::Client` which provides automatically /// prepending the base url. #[derive(Debug, Clone)] pub(crate) struct Client { @@ -40,7 +40,7 @@ impl Client { let token = token.into(); let mut default_headers = header::HeaderMap::new(); - let token_header_value = header::HeaderValue::from_str(&format!("Bearer {}", token)) + let token_header_value = header::HeaderValue::from_str(&format!("Bearer {token}")) .map_err(|_e| Error::InvalidToken)?; default_headers.insert(header::AUTHORIZATION, token_header_value); if let Some(org_id) = org_id.into() { @@ -224,7 +224,7 @@ impl Response { } // Try to decode the error - let e = match self.inner.json::().await { + let e = match self.inner.json::().await { Ok(mut e) => { e.status = status.as_u16(); e.method = self.method; @@ -233,7 +233,7 @@ impl Response { } Err(_e) => { // Decoding failed, we still want an AxiomError - Error::Axiom(AxiomError::new( + Error::Axiom(Axiom::new( status.as_u16(), self.method, self.path, diff --git a/src/limits.rs b/src/limits.rs index 5c18234..64e09f6 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -107,7 +107,7 @@ impl Display for Limits { impl Limits { /// Returns `true` if the rate limit has been exceeded. - pub fn is_exceeded(&self) -> bool { + #[must_use] pub fn is_exceeded(&self) -> bool { self.remaining == 0 && self.reset > Utc::now() } diff --git a/src/serde.rs b/src/serde.rs index aeb01f9..c315b26 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -1,7 +1,7 @@ use serde::de::{Deserialize, Deserializer, IntoDeserializer}; /// Set `deserialize_with` to this fn to get the default if null. -/// See https://github.com/serde-rs/serde/issues/1098#issuecomment-760711617 +/// See pub(crate) fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result where T: Default + Deserialize<'de>, diff --git a/src/users/mod.rs b/src/users.rs similarity index 100% rename from src/users/mod.rs rename to src/users.rs diff --git a/src/users/client.rs b/src/users/client.rs index 190784a..fd0effd 100644 --- a/src/users/client.rs +++ b/src/users/client.rs @@ -1,4 +1,4 @@ -use crate::{error::Result, http, users::model::*}; +use crate::{error::Result, http, users::model::User}; use tracing::instrument; /// Provides methods to work with Axiom datasets. From 30d55562a4a7f50d70dd52fa7b7791359aeeb9d4 Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 10:47:33 +0200 Subject: [PATCH 3/8] Obey fmt Signed-off-by: Heinz Gies --- src/http.rs | 7 +------ src/limits.rs | 3 ++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/http.rs b/src/http.rs index 4e0a420..900d587 100644 --- a/src/http.rs +++ b/src/http.rs @@ -233,12 +233,7 @@ impl Response { } Err(_e) => { // Decoding failed, we still want an AxiomError - Error::Axiom(Axiom::new( - status.as_u16(), - self.method, - self.path, - None, - )) + Error::Axiom(Axiom::new(status.as_u16(), self.method, self.path, None)) } }; return Err(e); diff --git a/src/limits.rs b/src/limits.rs index 64e09f6..fe1b776 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -107,7 +107,8 @@ impl Display for Limits { impl Limits { /// Returns `true` if the rate limit has been exceeded. - #[must_use] pub fn is_exceeded(&self) -> bool { + #[must_use] + pub fn is_exceeded(&self) -> bool { self.remaining == 0 && self.reset > Utc::now() } From 11eb0da76a621dc00fcee0737cdbf243366d4c39 Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 12:00:29 +0200 Subject: [PATCH 4/8] Annotations Draft Signed-off-by: Heinz Gies --- Cargo.toml | 2 +- src/annotations.rs | 7 + src/annotations/client.rs | 62 ++++++++ src/annotations/model.rs | 297 ++++++++++++++++++++++++++++++++++++++ src/client.rs | 24 +-- src/datasets/client.rs | 8 +- src/datasets/model.rs | 8 +- src/error.rs | 3 + src/lib.rs | 1 + src/users/client.rs | 8 +- 10 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 src/annotations.rs create mode 100644 src/annotations/client.rs create mode 100644 src/annotations/model.rs diff --git a/Cargo.toml b/Cargo.toml index f9d02c2..e84f27b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ backoff = { version = "0.4", features = ["futures"] } futures = "0.3" tokio = { version = "1", optional = true, features = ["rt", "sync"] } async-std = { version = "1", optional = true, features = ["tokio1"] } -url = "2" +url = { version = "2", features = ["serde"] } tracing = { version = "0.1" } tokio-stream = "0.1" bitflags = "2" diff --git a/src/annotations.rs b/src/annotations.rs new file mode 100644 index 0000000..577e385 --- /dev/null +++ b/src/annotations.rs @@ -0,0 +1,7 @@ +//! Manage annotations. + +mod client; +mod model; + +pub use client::Client; +pub use model::*; diff --git a/src/annotations/client.rs b/src/annotations/client.rs new file mode 100644 index 0000000..6144550 --- /dev/null +++ b/src/annotations/client.rs @@ -0,0 +1,62 @@ +use std::fmt; + +use crate::{ + annotations::model::{Annotation, AnnotationRequest}, + error::Result, + http, +}; +use tracing::instrument; + +use super::ListRequest; + +/// Provides methods to work with Axiom datasets. +#[derive(Debug, Clone)] +pub struct Client<'client> { + http_client: &'client http::Client, +} + +impl<'client> Client<'client> { + pub(crate) fn new(http_client: &'client http::Client) -> Self { + Self { http_client } + } + + /// creates an annotaion + /// + /// # Errors + /// If the API call fails + #[instrument(skip(self))] + pub async fn create(&self, req: AnnotationRequest) -> Result { + self.http_client + .post("/v2/annotations", req) + .await? + .json() + .await + } + + /// gets an annotaion + /// + /// # Errors + /// If the API call fails + #[instrument(skip(self))] + pub async fn get(&self, id: impl fmt::Display + fmt::Debug) -> Result { + self.http_client + .get(format!("/v2/annotations/{id}")) + .await? + .json() + .await + } + + /// lists annotaions + /// + /// # Errors + /// If the API call fails + #[instrument(skip(self))] + pub async fn list(&self, req: ListRequest) -> Result> { + let query_params = serde_qs::to_string(&req)?; + self.http_client + .get(format!("/v2/annotations?{query_params}")) + .await? + .json() + .await + } +} diff --git a/src/annotations/model.rs b/src/annotations/model.rs new file mode 100644 index 0000000..9af061f --- /dev/null +++ b/src/annotations/model.rs @@ -0,0 +1,297 @@ +use std::marker::PhantomData; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::Error; +/// An annotation. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] + +pub struct Annotation { + /// Unique ID of the annotation + pub id: String, + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". + #[serde(rename = "type")] + pub annotation_type: String, + /// Dataset names for which the annotation appears on charts + pub datasets: Vec, + /// Explanation of the event the annotation marks on the charts + pub description: Option, + /// Summary of the annotation that appears on the charts + pub title: Option, + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + pub url: Option, + /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. + pub time: chrono::DateTime, + ///End time of the annotation + pub end_time: Option>, +} +/// An authenticated Axiom user. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct AnnotationRequest { + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". + #[serde(rename = "type")] + annotation_type: String, + /// Dataset names for which the annotation appears on charts + datasets: Vec, + /// Explanation of the event the annotation marks on the charts + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Summary of the annotation that appears on the charts + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option>, + ///End time of the annotation + #[serde(skip_serializing_if = "Option::is_none")] + end_time: Option>, +} + +impl AnnotationRequest { + /// New annotation builder. + pub fn builder() -> AnnotationBuilder { + AnnotationBuilder::default() + } + /// Helper to quickly create a simple annotation request with just a `type` and `datasets`. + pub fn new(annotation_type: &impl ToString, datasets: Vec) -> Self { + AnnotationRequest::builder() + .with_type(annotation_type) + .with_datasets(datasets) + .build() + } +} + +/// The builder needs an annotation type to be set. +pub struct NeedsType; +/// The builder needs datasets to be set. +pub struct NeedsDatasets; +/// The builder is ready to build the request but optional fields can still be set. +pub struct Optionals; + +/// A builder for creating an annotation request. +#[derive(PartialEq, Eq, Debug)] +#[must_use] +pub struct AnnotationBuilder { + request: AnnotationRequest, + _p: PhantomData, +} + +impl Default for AnnotationBuilder { + fn default() -> Self { + Self { + request: AnnotationRequest { + annotation_type: String::new(), + datasets: vec![], + description: None, + title: None, + url: None, + time: None, + end_time: None, + }, + _p: PhantomData, + } + } +} + +impl AnnotationBuilder { + /// Set the type of the annotation. + /// + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. + /// For example, "production-deployment". + pub fn with_type(self, annotation_type: &impl ToString) -> AnnotationBuilder { + AnnotationBuilder { + request: AnnotationRequest { + annotation_type: annotation_type.to_string(), + ..self.request + }, + _p: PhantomData, + } + } +} + +impl AnnotationBuilder { + /// Set the datasets for which the annotation appears on charts. + pub fn with_datasets(self, datasets: Vec) -> AnnotationBuilder { + AnnotationBuilder { + request: AnnotationRequest { + datasets, + ..self.request + }, + _p: PhantomData, + } + } +} + +impl AnnotationBuilder { + /// Builds the request + pub fn build(self) -> AnnotationRequest { + self.request + } + + /// Set the description of the annotation. + /// + /// Explanation of the event the annotation marks on the charts. + pub fn with_description(self, description: &impl ToString) -> Self { + Self { + request: AnnotationRequest { + description: Some(description.to_string()), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the title of the annotation. + /// + /// Summary of the annotation that appears on the charts + pub fn with_title(self, title: &impl ToString) -> Self { + Self { + request: AnnotationRequest { + title: Some(title.to_string()), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the URL of the annotation. + /// + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + pub fn with_url(self, url: Url) -> Self { + Self { + request: AnnotationRequest { + url: Some(url), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the (start) time of the annotation. + /// + /// Time the annotation marks on the charts. If you don't include this field, + /// Axiom assigns the time of the API request to the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_time(self, time: chrono::DateTime) -> Result { + if let Some(end_time) = self.request.end_time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: AnnotationRequest { + time: Some(time), + ..self.request + }, + _p: PhantomData, + }) + } + + /// Set the end time of the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { + if let Some(time) = self.request.time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: AnnotationRequest { + end_time: Some(end_time), + ..self.request + }, + _p: PhantomData, + }) + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Default)] +#[serde(rename_all = "camelCase")] +/// A request to all annotations +#[must_use] +pub struct ListRequest { + #[serde(skip_serializing_if = "Option::is_none")] + datasets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + end: Option>, +} + +impl ListRequest { + /// New list request builder. + pub fn builder() -> ListRequestBuilder { + ListRequestBuilder::default() + } +} + +/// A builder for creating a list request. +#[derive(PartialEq, Eq, Debug, Default)] +#[must_use] +pub struct ListRequestBuilder { + request: ListRequest, +} + +impl ListRequestBuilder { + /// Set the datasets for which the annotations are listed. + pub fn with_datasets(self, datasets: Vec) -> Self { + Self { + request: ListRequest { + datasets: Some(datasets), + ..self.request + }, + } + } + + /// Set the start time of the list. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_time(self, start: chrono::DateTime) -> Result { + if let Some(end) = self.request.end { + if start > end { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: ListRequest { + start: Some(start), + ..self.request + }, + }) + } + + /// Set the end time of list. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end(self, end: chrono::DateTime) -> Result { + if let Some(start) = self.request.start { + if start > end { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: ListRequest { + end: Some(end), + ..self.request + }, + }) + } + /// Builds the request + pub fn build(self) -> ListRequest { + self.request + } +} diff --git a/src/client.rs b/src/client.rs index 2c7b5b6..50ef156 100644 --- a/src/client.rs +++ b/src/client.rs @@ -16,6 +16,7 @@ use tokio_stream::StreamExt; use tracing::instrument; use crate::{ + annotations, datasets::{ self, ContentEncoding, ContentType, IngestStatus, LegacyQuery, LegacyQueryOptions, LegacyQueryResult, Query, QueryOptions, QueryParams, QueryResult, @@ -54,10 +55,7 @@ static API_URL: &str = "https://api.axiom.co"; #[derive(Debug, Clone)] pub struct Client { http_client: http::Client, - url: String, - datasets: datasets::Client, - users: users::Client, } impl Client { @@ -75,16 +73,22 @@ impl Client { Builder::new() } - /// Get the dataset + /// Dataset API + #[must_use] + pub fn datasets(&self) -> datasets::Client { + datasets::Client::new(&self.http_client) + } + + /// Users API #[must_use] - pub fn datasets(&self) -> &datasets::Client { - &self.datasets + pub fn users(&self) -> users::Client { + users::Client::new(&self.http_client) } - /// Get the users + /// Annotations API #[must_use] - pub fn users(&self) -> &users::Client { - &self.users + pub fn annotations(&self) -> annotations::Client { + annotations::Client::new(&self.http_client) } /// Get the API url @@ -396,8 +400,6 @@ impl Builder { Ok(Client { http_client: http_client.clone(), url, - datasets: datasets::Client::new(http_client.clone()), - users: users::Client::new(http_client), }) } } diff --git a/src/datasets/client.rs b/src/datasets/client.rs index 2a01e7c..7fbc2a7 100644 --- a/src/datasets/client.rs +++ b/src/datasets/client.rs @@ -19,12 +19,12 @@ use tracing::instrument; /// If you're looking for the ingest and query methods, those are at the /// [top-level client](crate::Client). #[derive(Debug, Clone)] -pub struct Client { - http_client: http::Client, +pub struct Client<'client> { + http_client: &'client http::Client, } -impl Client { - pub(crate) fn new(http_client: http::Client) -> Self { +impl<'client> Client<'client> { + pub(crate) fn new(http_client: &'client http::Client) -> Self { Self { http_client } } diff --git a/src/datasets/model.rs b/src/datasets/model.rs index d530cc4..f33be37 100644 --- a/src/datasets/model.rs +++ b/src/datasets/model.rs @@ -215,8 +215,8 @@ pub struct Info { } #[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] pub(crate) struct TrimRequest { - #[serde(rename = "maxDuration")] max_duration: String, } @@ -387,7 +387,7 @@ impl Default for QueryOptions { /// The result format of an APL query. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[non_exhaustive] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] pub enum AplResultFormat { /// Legacy result format Legacy, @@ -402,7 +402,7 @@ impl Default for AplResultFormat { /// The kind of a query. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] pub enum QueryKind { /// Analytics query Analytics, @@ -841,7 +841,7 @@ pub struct QueryMessage { /// The priority of a query message. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Copy)] #[non_exhaustive] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "camelCase")] pub enum QueryMessagePriority { /// Trace message priority. Trace, diff --git a/src/error.rs b/src/error.rs index 4e2d76e..bd6dced 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,9 @@ pub type Result = std::result::Result; #[derive(thiserror::Error, Debug)] #[non_exhaustive] pub enum Error { + #[error("Invalid time order")] + /// Invalid time order. + InvalidTimeOrder, #[error("Missing token")] /// Missing token. MissingToken, diff --git a/src/lib.rs b/src/lib.rs index bf48304..520e4a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ mod http; pub mod limits; mod serde; +pub mod annotations; pub mod datasets; pub mod users; diff --git a/src/users/client.rs b/src/users/client.rs index fd0effd..856e0f2 100644 --- a/src/users/client.rs +++ b/src/users/client.rs @@ -3,12 +3,12 @@ use tracing::instrument; /// Provides methods to work with Axiom datasets. #[derive(Debug, Clone)] -pub struct Client { - http_client: http::Client, +pub struct Client<'client> { + http_client: &'client http::Client, } -impl Client { - pub(crate) fn new(http_client: http::Client) -> Self { +impl<'client> Client<'client> { + pub(crate) fn new(http_client: &'client http::Client) -> Self { Self { http_client } } From b056e8c339e09a5578dc06ad5894c36300dfb00a Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 12:48:41 +0200 Subject: [PATCH 5/8] Feedback Signed-off-by: Heinz Gies --- src/annotations/client.rs | 35 ++++++++- src/annotations/model.rs | 157 +++++++++++++++++++++++++++++++++++++- src/error.rs | 5 +- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/src/annotations/client.rs b/src/annotations/client.rs index 6144550..e4b7a0e 100644 --- a/src/annotations/client.rs +++ b/src/annotations/client.rs @@ -9,7 +9,7 @@ use tracing::instrument; use super::ListRequest; -/// Provides methods to work with Axiom datasets. +/// Provides methods to work with Axiom annotations. #[derive(Debug, Clone)] pub struct Client<'client> { http_client: &'client http::Client, @@ -20,7 +20,7 @@ impl<'client> Client<'client> { Self { http_client } } - /// creates an annotaion + /// Creates an annotation /// /// # Errors /// If the API call fails @@ -33,7 +33,7 @@ impl<'client> Client<'client> { .await } - /// gets an annotaion + /// Gets an annotation /// /// # Errors /// If the API call fails @@ -46,7 +46,7 @@ impl<'client> Client<'client> { .await } - /// lists annotaions + /// Lists annotations /// /// # Errors /// If the API call fails @@ -59,4 +59,31 @@ impl<'client> Client<'client> { .json() .await } + + /// Updates an annotation + /// + /// # Errors + /// If the API call fails + #[instrument(skip(self))] + pub async fn update( + &self, + id: impl fmt::Display + fmt::Debug, + req: AnnotationRequest, + ) -> Result { + self.http_client + .put(format!("/v2/annotations/{id}"), req) + .await? + .json() + .await + } + /// Delets an annotation + /// + /// # Errors + /// If the API call fails + #[instrument(skip(self))] + pub async fn delete(&self, id: impl fmt::Display + fmt::Debug) -> Result<()> { + self.http_client + .delete(format!("/v2/annotations/{id}")) + .await + } } diff --git a/src/annotations/model.rs b/src/annotations/model.rs index 9af061f..2e7faa7 100644 --- a/src/annotations/model.rs +++ b/src/annotations/model.rs @@ -89,7 +89,7 @@ impl Default for AnnotationBuilder { Self { request: AnnotationRequest { annotation_type: String::new(), - datasets: vec![], + datasets: Vec::new(), description: None, title: None, url: None, @@ -295,3 +295,158 @@ impl ListRequestBuilder { self.request } } + +/// A request to update an annotation. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct AnnotationUpdateRequest { + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + annotation_type: Option, + /// Dataset names for which the annotation appears on charts + #[serde(skip_serializing_if = "Option::is_none")] + datasets: Option>, + /// Explanation of the event the annotation marks on the charts + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Summary of the annotation that appears on the charts + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option>, + ///End time of the annotation + #[serde(skip_serializing_if = "Option::is_none")] + end_time: Option>, +} + +/// A builder for creating an annotation request. +#[derive(PartialEq, Eq, Debug)] +#[must_use] +pub struct AnnotationUpdateBuilder { + request: AnnotationUpdateRequest, +} + +impl AnnotationUpdateBuilder { + /// Builds the request + /// + /// # Errors + /// If the request is empty. + pub fn build(self) -> Result { + let request = self.request; + if request.annotation_type.is_none() + && request.datasets.is_none() + && request.description.is_none() + && request.title.is_none() + && request.url.is_none() + && request.time.is_none() + && request.end_time.is_none() + { + return Err(Error::EmptyUpdate); + } + Ok(request) + } + + /// Set the type of the annotation. + /// + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. + /// For example, "production-deployment". + pub fn with_type(self, annotation_type: &impl ToString) -> Self { + AnnotationUpdateBuilder { + request: AnnotationUpdateRequest { + annotation_type: Some(annotation_type.to_string()), + ..self.request + }, + } + } + + /// Set the datasets for which the annotation appears on charts. + pub fn with_datasets(self, datasets: Vec) -> Self { + AnnotationUpdateBuilder { + request: AnnotationUpdateRequest { + datasets: Some(datasets), + ..self.request + }, + } + } + + /// Set the description of the annotation. + /// + /// Explanation of the event the annotation marks on the charts. + pub fn with_description(self, description: &impl ToString) -> Self { + Self { + request: AnnotationUpdateRequest { + description: Some(description.to_string()), + ..self.request + }, + } + } + + /// Set the title of the annotation. + /// + /// Summary of the annotation that appears on the charts + pub fn with_title(self, title: &impl ToString) -> Self { + Self { + request: AnnotationUpdateRequest { + title: Some(title.to_string()), + ..self.request + }, + } + } + + /// Set the URL of the annotation. + /// + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + pub fn with_url(self, url: Url) -> Self { + Self { + request: AnnotationUpdateRequest { + url: Some(url), + ..self.request + }, + } + } + + /// Set the (start) time of the annotation. + /// + /// Time the annotation marks on the charts. If you don't include this field, + /// Axiom assigns the time of the API request to the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_time(self, time: chrono::DateTime) -> Result { + if let Some(end_time) = self.request.end_time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: AnnotationUpdateRequest { + time: Some(time), + ..self.request + }, + }) + } + + /// Set the end time of the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { + if let Some(time) = self.request.time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: AnnotationUpdateRequest { + end_time: Some(end_time), + ..self.request + }, + }) + } +} diff --git a/src/error.rs b/src/error.rs index bd6dced..fb053e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,8 +13,11 @@ pub type Result = std::result::Result; #[non_exhaustive] pub enum Error { #[error("Invalid time order")] - /// Invalid time order. + /// Invalid time InvalidTimeOrder, + #[error("Empty update")] + /// Empty update. + EmptyUpdate, #[error("Missing token")] /// Missing token. MissingToken, From ee0bc0a535b5f472403a4e67d1f77005bd6c6dcb Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 13:49:15 +0200 Subject: [PATCH 6/8] Add tests Signed-off-by: Heinz Gies --- .github/workflows/ci.yaml | 3 + src/annotations.rs | 36 ++- src/annotations/client.rs | 14 +- src/annotations/model.rs | 433 +--------------------------- src/annotations/requests.rs | 547 ++++++++++++++++++++++++++++++++++++ src/annotations/tests.rs | 139 +++++++++ src/error.rs | 6 + 7 files changed, 737 insertions(+), 441 deletions(-) create mode 100644 src/annotations/requests.rs create mode 100644 src/annotations/tests.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a7f4e4..2976fd2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,10 +70,13 @@ jobs: url: TESTING_DEV_API_URL token: TESTING_DEV_TOKEN org_id: TESTING_DEV_ORG_ID + flags: --features integration-tests - environment: staging url: TESTING_STAGING_API_URL token: TESTING_STAGING_TOKEN org_id: TESTING_STAGING_ORG_ID + flags: --features integration-tests + steps: - uses: actions/checkout@v3 - uses: actions/cache@v3 diff --git a/src/annotations.rs b/src/annotations.rs index 577e385..919dfc6 100644 --- a/src/annotations.rs +++ b/src/annotations.rs @@ -1,7 +1,37 @@ -//! Manage annotations. - +//! Manage datasets, ingest data and query it. +//! +//! You're probably looking for the [`Client`]. +//! +//! # Examples +//! ```no_run +//! use axiom_rs::{Client, Error, annotations::requests}; +//! use serde_json::json; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Error> { +//! let client = Client::new()?; +//! +//! let req = requests::Create::builder() +//! .with_type("cake")? +//! .with_datasets(vec!["snot".to_string(), "badger".to_string()])? +//! .with_title("cookie") +//! .build(); +//! client.annotations().create(req).await?; +//! +//! let res = client.annotations().list(requests::List::default()).await?; +//! assert_eq!(1, res.len()); +//! +//! client.annotations().delete(&res[1].id).await?; +//! +//! Ok(()) +//! } +//! ``` +//! mod client; mod model; +pub mod requests; +#[cfg(test)] +mod tests; pub use client::Client; -pub use model::*; +pub use model::Annotation; diff --git a/src/annotations/client.rs b/src/annotations/client.rs index e4b7a0e..b3b148a 100644 --- a/src/annotations/client.rs +++ b/src/annotations/client.rs @@ -1,13 +1,9 @@ use std::fmt; -use crate::{ - annotations::model::{Annotation, AnnotationRequest}, - error::Result, - http, -}; +use crate::{annotations::Annotation, error::Result, http}; use tracing::instrument; -use super::ListRequest; +use super::requests; /// Provides methods to work with Axiom annotations. #[derive(Debug, Clone)] @@ -25,7 +21,7 @@ impl<'client> Client<'client> { /// # Errors /// If the API call fails #[instrument(skip(self))] - pub async fn create(&self, req: AnnotationRequest) -> Result { + pub async fn create(&self, req: requests::Create) -> Result { self.http_client .post("/v2/annotations", req) .await? @@ -51,7 +47,7 @@ impl<'client> Client<'client> { /// # Errors /// If the API call fails #[instrument(skip(self))] - pub async fn list(&self, req: ListRequest) -> Result> { + pub async fn list(&self, req: requests::List) -> Result> { let query_params = serde_qs::to_string(&req)?; self.http_client .get(format!("/v2/annotations?{query_params}")) @@ -68,7 +64,7 @@ impl<'client> Client<'client> { pub async fn update( &self, id: impl fmt::Display + fmt::Debug, - req: AnnotationRequest, + req: requests::Create, ) -> Result { self.http_client .put(format!("/v2/annotations/{id}"), req) diff --git a/src/annotations/model.rs b/src/annotations/model.rs index 2e7faa7..37f39df 100644 --- a/src/annotations/model.rs +++ b/src/annotations/model.rs @@ -1,12 +1,9 @@ -use std::marker::PhantomData; - -use chrono::Utc; +use chrono::FixedOffset; use serde::{Deserialize, Serialize}; use url::Url; -use crate::Error; /// An annotation. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Annotation { @@ -24,429 +21,7 @@ pub struct Annotation { /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. pub url: Option, /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. - pub time: chrono::DateTime, - ///End time of the annotation - pub end_time: Option>, -} -/// An authenticated Axiom user. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] -#[serde(rename_all = "camelCase")] -#[must_use] -pub struct AnnotationRequest { - /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". - #[serde(rename = "type")] - annotation_type: String, - /// Dataset names for which the annotation appears on charts - datasets: Vec, - /// Explanation of the event the annotation marks on the charts - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - /// Summary of the annotation that appears on the charts - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. - #[serde(skip_serializing_if = "Option::is_none")] - time: Option>, - ///End time of the annotation - #[serde(skip_serializing_if = "Option::is_none")] - end_time: Option>, -} - -impl AnnotationRequest { - /// New annotation builder. - pub fn builder() -> AnnotationBuilder { - AnnotationBuilder::default() - } - /// Helper to quickly create a simple annotation request with just a `type` and `datasets`. - pub fn new(annotation_type: &impl ToString, datasets: Vec) -> Self { - AnnotationRequest::builder() - .with_type(annotation_type) - .with_datasets(datasets) - .build() - } -} - -/// The builder needs an annotation type to be set. -pub struct NeedsType; -/// The builder needs datasets to be set. -pub struct NeedsDatasets; -/// The builder is ready to build the request but optional fields can still be set. -pub struct Optionals; - -/// A builder for creating an annotation request. -#[derive(PartialEq, Eq, Debug)] -#[must_use] -pub struct AnnotationBuilder { - request: AnnotationRequest, - _p: PhantomData, -} - -impl Default for AnnotationBuilder { - fn default() -> Self { - Self { - request: AnnotationRequest { - annotation_type: String::new(), - datasets: Vec::new(), - description: None, - title: None, - url: None, - time: None, - end_time: None, - }, - _p: PhantomData, - } - } -} - -impl AnnotationBuilder { - /// Set the type of the annotation. - /// - /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. - /// For example, "production-deployment". - pub fn with_type(self, annotation_type: &impl ToString) -> AnnotationBuilder { - AnnotationBuilder { - request: AnnotationRequest { - annotation_type: annotation_type.to_string(), - ..self.request - }, - _p: PhantomData, - } - } -} - -impl AnnotationBuilder { - /// Set the datasets for which the annotation appears on charts. - pub fn with_datasets(self, datasets: Vec) -> AnnotationBuilder { - AnnotationBuilder { - request: AnnotationRequest { - datasets, - ..self.request - }, - _p: PhantomData, - } - } -} - -impl AnnotationBuilder { - /// Builds the request - pub fn build(self) -> AnnotationRequest { - self.request - } - - /// Set the description of the annotation. - /// - /// Explanation of the event the annotation marks on the charts. - pub fn with_description(self, description: &impl ToString) -> Self { - Self { - request: AnnotationRequest { - description: Some(description.to_string()), - ..self.request - }, - _p: PhantomData, - } - } - - /// Set the title of the annotation. - /// - /// Summary of the annotation that appears on the charts - pub fn with_title(self, title: &impl ToString) -> Self { - Self { - request: AnnotationRequest { - title: Some(title.to_string()), - ..self.request - }, - _p: PhantomData, - } - } - - /// Set the URL of the annotation. - /// - /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. - pub fn with_url(self, url: Url) -> Self { - Self { - request: AnnotationRequest { - url: Some(url), - ..self.request - }, - _p: PhantomData, - } - } - - /// Set the (start) time of the annotation. - /// - /// Time the annotation marks on the charts. If you don't include this field, - /// Axiom assigns the time of the API request to the annotation. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_time(self, time: chrono::DateTime) -> Result { - if let Some(end_time) = self.request.end_time { - if time > end_time { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: AnnotationRequest { - time: Some(time), - ..self.request - }, - _p: PhantomData, - }) - } - - /// Set the end time of the annotation. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { - if let Some(time) = self.request.time { - if time > end_time { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: AnnotationRequest { - end_time: Some(end_time), - ..self.request - }, - _p: PhantomData, - }) - } -} - -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Default)] -#[serde(rename_all = "camelCase")] -/// A request to all annotations -#[must_use] -pub struct ListRequest { - #[serde(skip_serializing_if = "Option::is_none")] - datasets: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - start: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - end: Option>, -} - -impl ListRequest { - /// New list request builder. - pub fn builder() -> ListRequestBuilder { - ListRequestBuilder::default() - } -} - -/// A builder for creating a list request. -#[derive(PartialEq, Eq, Debug, Default)] -#[must_use] -pub struct ListRequestBuilder { - request: ListRequest, -} - -impl ListRequestBuilder { - /// Set the datasets for which the annotations are listed. - pub fn with_datasets(self, datasets: Vec) -> Self { - Self { - request: ListRequest { - datasets: Some(datasets), - ..self.request - }, - } - } - - /// Set the start time of the list. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_time(self, start: chrono::DateTime) -> Result { - if let Some(end) = self.request.end { - if start > end { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: ListRequest { - start: Some(start), - ..self.request - }, - }) - } - - /// Set the end time of list. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_end(self, end: chrono::DateTime) -> Result { - if let Some(start) = self.request.start { - if start > end { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: ListRequest { - end: Some(end), - ..self.request - }, - }) - } - /// Builds the request - pub fn build(self) -> ListRequest { - self.request - } -} - -/// A request to update an annotation. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] -#[serde(rename_all = "camelCase")] -#[must_use] -pub struct AnnotationUpdateRequest { - /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". - #[serde(rename = "type")] - #[serde(skip_serializing_if = "Option::is_none")] - annotation_type: Option, - /// Dataset names for which the annotation appears on charts - #[serde(skip_serializing_if = "Option::is_none")] - datasets: Option>, - /// Explanation of the event the annotation marks on the charts - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - /// Summary of the annotation that appears on the charts - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. - #[serde(skip_serializing_if = "Option::is_none")] - time: Option>, + pub time: chrono::DateTime, ///End time of the annotation - #[serde(skip_serializing_if = "Option::is_none")] - end_time: Option>, -} - -/// A builder for creating an annotation request. -#[derive(PartialEq, Eq, Debug)] -#[must_use] -pub struct AnnotationUpdateBuilder { - request: AnnotationUpdateRequest, -} - -impl AnnotationUpdateBuilder { - /// Builds the request - /// - /// # Errors - /// If the request is empty. - pub fn build(self) -> Result { - let request = self.request; - if request.annotation_type.is_none() - && request.datasets.is_none() - && request.description.is_none() - && request.title.is_none() - && request.url.is_none() - && request.time.is_none() - && request.end_time.is_none() - { - return Err(Error::EmptyUpdate); - } - Ok(request) - } - - /// Set the type of the annotation. - /// - /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. - /// For example, "production-deployment". - pub fn with_type(self, annotation_type: &impl ToString) -> Self { - AnnotationUpdateBuilder { - request: AnnotationUpdateRequest { - annotation_type: Some(annotation_type.to_string()), - ..self.request - }, - } - } - - /// Set the datasets for which the annotation appears on charts. - pub fn with_datasets(self, datasets: Vec) -> Self { - AnnotationUpdateBuilder { - request: AnnotationUpdateRequest { - datasets: Some(datasets), - ..self.request - }, - } - } - - /// Set the description of the annotation. - /// - /// Explanation of the event the annotation marks on the charts. - pub fn with_description(self, description: &impl ToString) -> Self { - Self { - request: AnnotationUpdateRequest { - description: Some(description.to_string()), - ..self.request - }, - } - } - - /// Set the title of the annotation. - /// - /// Summary of the annotation that appears on the charts - pub fn with_title(self, title: &impl ToString) -> Self { - Self { - request: AnnotationUpdateRequest { - title: Some(title.to_string()), - ..self.request - }, - } - } - - /// Set the URL of the annotation. - /// - /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. - pub fn with_url(self, url: Url) -> Self { - Self { - request: AnnotationUpdateRequest { - url: Some(url), - ..self.request - }, - } - } - - /// Set the (start) time of the annotation. - /// - /// Time the annotation marks on the charts. If you don't include this field, - /// Axiom assigns the time of the API request to the annotation. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_time(self, time: chrono::DateTime) -> Result { - if let Some(end_time) = self.request.end_time { - if time > end_time { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: AnnotationUpdateRequest { - time: Some(time), - ..self.request - }, - }) - } - - /// Set the end time of the annotation. - /// - /// # Errors - /// If the start time is after the end time. - pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { - if let Some(time) = self.request.time { - if time > end_time { - return Err(Error::InvalidTimeOrder); - } - } - Ok(Self { - request: AnnotationUpdateRequest { - end_time: Some(end_time), - ..self.request - }, - }) - } + pub end_time: Option>, } diff --git a/src/annotations/requests.rs b/src/annotations/requests.rs new file mode 100644 index 0000000..a026703 --- /dev/null +++ b/src/annotations/requests.rs @@ -0,0 +1,547 @@ +//! Request types for the annotations API. + +use crate::Error; +use chrono::FixedOffset; +use serde::{Deserialize, Serialize}; +use std::marker::PhantomData; +use url::Url; + +/// A request to create an annotation. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct Create { + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". + #[serde(rename = "type")] + annotation_type: String, + /// Dataset names for which the annotation appears on charts + datasets: Vec, + /// Explanation of the event the annotation marks on the charts + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Summary of the annotation that appears on the charts + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option>, + ///End time of the annotation + #[serde(skip_serializing_if = "Option::is_none")] + end_time: Option>, +} + +impl Create { + /// New annotation builder. + pub fn builder() -> CreateBuilder { + CreateBuilder { + request: Create { + annotation_type: String::new(), + datasets: Vec::new(), + description: None, + title: None, + url: None, + time: None, + end_time: None, + }, + _p: PhantomData, + } + } + /// Helper to quickly create a simple annotation request with just a `type` and `datasets`. + /// + /// # Errors + /// If the datasets are empty. + /// If the annotation type is empty. + pub fn new( + annotation_type: &(impl ToString + ?Sized), + datasets: Vec, + ) -> Result { + Ok(Create::builder() + .with_type(annotation_type)? + .with_datasets(datasets)? + .build()) + } +} + +/// The builder needs an annotation type to be set. +pub struct NeedsType; +/// The builder needs datasets to be set. +pub struct NeedsDatasets; +/// The builder is ready to build the request but optional fields can still be set. +pub struct Optionals; + +/// A builder for creating an annotation request. +#[derive(PartialEq, Eq, Debug)] +#[must_use] +pub struct CreateBuilder { + request: Create, + _p: PhantomData, +} + +impl CreateBuilder { + /// Set the type of the annotation. + /// + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. + /// For example, "production-deployment". + /// + /// # Errors + /// If the type is empty. + pub fn with_type( + self, + annotation_type: &(impl ToString + ?Sized), + ) -> Result, Error> { + let annotation_type = annotation_type.to_string(); + if annotation_type.is_empty() { + return Err(Error::EmptyType); + } + Ok(CreateBuilder { + request: Create { + annotation_type, + ..self.request + }, + _p: PhantomData, + }) + } +} + +impl CreateBuilder { + /// Set the datasets for which the annotation appears on charts. + /// + /// # Errors + /// If the datasets are empty. + pub fn with_datasets(self, datasets: Vec) -> Result, Error> { + if datasets.is_empty() { + return Err(Error::EmptyDatasets); + } + Ok(CreateBuilder { + request: Create { + datasets, + ..self.request + }, + _p: PhantomData, + }) + } +} + +impl CreateBuilder { + /// Builds the request + pub fn build(self) -> Create { + self.request + } + + /// Set the description of the annotation. + /// + /// Explanation of the event the annotation marks on the charts. + pub fn with_description(self, description: &(impl ToString + ?Sized)) -> Self { + Self { + request: Create { + description: Some(description.to_string()), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the title of the annotation. + /// + /// Summary of the annotation that appears on the charts + pub fn with_title(self, title: &(impl ToString + ?Sized)) -> Self { + Self { + request: Create { + title: Some(title.to_string()), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the URL of the annotation. + /// + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + pub fn with_url(self, url: Url) -> Self { + Self { + request: Create { + url: Some(url), + ..self.request + }, + _p: PhantomData, + } + } + + /// Set the (start) time of the annotation. + /// + /// Time the annotation marks on the charts. If you don't include this field, + /// Axiom assigns the time of the API request to the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_time(self, time: chrono::DateTime) -> Result { + if let Some(end_time) = self.request.end_time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: Create { + time: Some(time), + ..self.request + }, + _p: PhantomData, + }) + } + + /// Set the end time of the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { + if let Some(time) = self.request.time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: Create { + end_time: Some(end_time), + ..self.request + }, + _p: PhantomData, + }) + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Default)] +#[serde(rename_all = "camelCase")] +/// A request to all annotations +#[must_use] +pub struct List { + #[serde(skip_serializing_if = "Option::is_none")] + datasets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + end: Option>, +} + +impl List { + /// New list request builder. + pub fn builder() -> ListBuilder { + ListBuilder::default() + } +} + +/// A builder for creating a list request. +#[derive(PartialEq, Eq, Debug, Default)] +#[must_use] +pub struct ListBuilder { + request: List, +} + +impl ListBuilder { + /// Set the datasets for which the annotations are listed. + pub fn with_datasets(self, datasets: Vec) -> Self { + Self { + request: List { + datasets: Some(datasets), + ..self.request + }, + } + } + + /// Set the start time of the list. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_start(self, start: chrono::DateTime) -> Result { + if let Some(end) = self.request.end { + if start > end { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: List { + start: Some(start), + ..self.request + }, + }) + } + + /// Set the end time of list. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end(self, end: chrono::DateTime) -> Result { + if let Some(start) = self.request.start { + if start > end { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: List { + end: Some(end), + ..self.request + }, + }) + } + /// Builds the request + pub fn build(self) -> List { + self.request + } +} + +/// A request to update an annotation. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct Update { + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. For example, "production-deployment". + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + annotation_type: Option, + /// Dataset names for which the annotation appears on charts + #[serde(skip_serializing_if = "Option::is_none")] + datasets: Option>, + /// Explanation of the event the annotation marks on the charts + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Summary of the annotation that appears on the charts + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Time the annotation marks on the charts. If you don't include this field, Axiom assigns the time of the API request to the annotation. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option>, + ///End time of the annotation + #[serde(skip_serializing_if = "Option::is_none")] + end_time: Option>, +} + +impl Update { + /// New update builder. + pub fn builder() -> UpdateBuilder { + UpdateBuilder { + request: Update { + annotation_type: None, + datasets: None, + description: None, + title: None, + url: None, + time: None, + end_time: None, + }, + } + } +} + +/// A builder for creating an annotation request. +#[derive(PartialEq, Eq, Debug)] +#[must_use] +pub struct UpdateBuilder { + request: Update, +} + +impl UpdateBuilder { + /// Builds the request + /// + /// # Errors + /// If the request is empty. + pub fn build(self) -> Result { + let request = self.request; + if request.annotation_type.is_none() + && request.datasets.is_none() + && request.description.is_none() + && request.title.is_none() + && request.url.is_none() + && request.time.is_none() + && request.end_time.is_none() + { + return Err(Error::EmptyUpdate); + } + Ok(request) + } + + /// Set the type of the annotation. + /// + /// Type of the event marked by the annotation. Use only alphanumeric characters or hyphens. + /// For example, "production-deployment". + /// + /// # Errors + /// If the type is empty. + pub fn with_type(self, annotation_type: &(impl ToString + ?Sized)) -> Result { + let annotation_type = annotation_type.to_string(); + if annotation_type.is_empty() { + return Err(Error::EmptyType); + } + Ok(UpdateBuilder { + request: Update { + annotation_type: Some(annotation_type), + ..self.request + }, + }) + } + + /// Set the datasets for which the annotation appears on charts. + pub fn with_datasets(self, datasets: Vec) -> Self { + UpdateBuilder { + request: Update { + datasets: Some(datasets), + ..self.request + }, + } + } + + /// Set the description of the annotation. + /// + /// Explanation of the event the annotation marks on the charts. + pub fn with_description(self, description: &(impl ToString + ?Sized)) -> Self { + Self { + request: Update { + description: Some(description.to_string()), + ..self.request + }, + } + } + + /// Set the title of the annotation. + /// + /// Summary of the annotation that appears on the charts + pub fn with_title(self, title: &(impl ToString + ?Sized)) -> Self { + Self { + request: Update { + title: Some(title.to_string()), + ..self.request + }, + } + } + + /// Set the URL of the annotation. + /// + /// URL relevant for the event marked by the annotation. For example, link to GitHub pull request. + pub fn with_url(self, url: Url) -> Self { + Self { + request: Update { + url: Some(url), + ..self.request + }, + } + } + + /// Set the (start) time of the annotation. + /// + /// Time the annotation marks on the charts. If you don't include this field, + /// Axiom assigns the time of the API request to the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_time(self, time: chrono::DateTime) -> Result { + if let Some(end_time) = self.request.end_time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: Update { + time: Some(time), + ..self.request + }, + }) + } + + /// Set the end time of the annotation. + /// + /// # Errors + /// If the start time is after the end time. + pub fn with_end_time(self, end_time: chrono::DateTime) -> Result { + if let Some(time) = self.request.time { + if time > end_time { + return Err(Error::InvalidTimeOrder); + } + } + Ok(Self { + request: Update { + end_time: Some(end_time), + ..self.request + }, + }) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn empty_datasets() { + let res = super::Create::new("snot", vec![]); + assert!(matches!(res, Err(super::Error::EmptyDatasets))); + + let res = super::Create::builder() + .with_type("snot") + .expect("we got type") + .with_datasets(vec![]); + assert!(matches!(res, Err(super::Error::EmptyDatasets))); + } + #[test] + fn create_invalid_times() { + let start = chrono::DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("the time is right"); + let end = chrono::DateTime::parse_from_rfc3339("2023-02-06T11:39:28.382Z") + .expect("the time is right"); + let res = super::Create::builder() + .with_type("snot") + .expect("we got type") + .with_datasets(vec!["badger".to_string()]) + .expect("we got datasets") + .with_time(start) + .expect("we got time") + .with_end_time(end); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + let res = super::Create::builder() + .with_type("snot") + .expect("we got type") + .with_datasets(vec!["badger".to_string()]) + .expect("we got datasets") + .with_end_time(end) + .expect("we got time") + .with_time(start); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + } + + #[test] + fn list_invalid_times() { + let start = chrono::DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("the time is right"); + let end = chrono::DateTime::parse_from_rfc3339("2023-02-06T11:39:28.382Z") + .expect("the time is right"); + let res = super::List::builder() + .with_start(start) + .expect("we got start") + .with_end(end); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + let res = super::List::builder() + .with_end(end) + .expect("we got start") + .with_start(start); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + } + + #[test] + fn update_invalid_times() { + let start = chrono::DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("the time is right"); + let end = chrono::DateTime::parse_from_rfc3339("2023-02-06T11:39:28.382Z") + .expect("the time is right"); + let res = super::Update::builder() + .with_time(start) + .expect("we got start") + .with_end_time(end); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + let res = super::Update::builder() + .with_end_time(end) + .expect("we got start") + .with_time(start); + assert!(matches!(res, Err(super::Error::InvalidTimeOrder))); + } +} diff --git a/src/annotations/tests.rs b/src/annotations/tests.rs new file mode 100644 index 0000000..8d21f79 --- /dev/null +++ b/src/annotations/tests.rs @@ -0,0 +1,139 @@ +use super::{requests, Annotation}; +use crate::Client; +use chrono::DateTime; +use httpmock::prelude::*; +use serde_json::json; + +#[tokio::test] +async fn get() -> Result<(), Box> { + let server = MockServer::start(); + let server_reply = Annotation { + id: "42".to_string(), + annotation_type: "cake".to_string(), + datasets: vec!["snot".to_string(), "snot".to_string()], + description: None, + title: Some("cookie".to_string()), + url: None, + time: DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("we know the time is right"), + end_time: None, + }; + let mock = server.mock(|when, then| { + when.method(GET).path("/v2/annotations/42"); + then.status(200).json_body(json!(server_reply.clone())); + }); + let client = Client::builder() + .no_env() + .with_url(server.base_url()) + .with_token("xapt-nope") + .build()?; + + let r = client.annotations().get("42").await?; + assert_eq!(r, server_reply); + mock.assert_hits_async(1).await; + + Ok(()) +} + +#[tokio::test] +async fn lsit() -> Result<(), Box> { + let server = MockServer::start(); + let server_reply = Annotation { + id: "42".to_string(), + annotation_type: "cake".to_string(), + datasets: vec!["snot".to_string(), "snot".to_string()], + description: None, + title: Some("cookie".to_string()), + url: None, + time: DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("we know the time is right"), + end_time: None, + }; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v2/annotations") + .query_param("start", "2024-02-06T11:39:28.382Z"); + then.status(200) + .json_body(json!(vec![server_reply.clone(), server_reply.clone()])); + }); + let client = Client::builder() + .no_env() + .with_url(server.base_url()) + .with_token("xapt-nope") + .build()?; + + let req = requests::List::builder() + .with_start( + DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("we know the time is right"), + )? + .build(); + let r = client.annotations().list(req).await?; + assert_eq!(r, vec![server_reply.clone(), server_reply]); + mock.assert_hits_async(1).await; + Ok(()) +} + +#[tokio::test] +async fn delete() -> Result<(), Box> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(DELETE).path("/v2/annotations/42"); + then.status(204); + }); + let client = Client::builder() + .no_env() + .with_url(server.base_url()) + .with_token("xapt-nope") + .build()?; + + client.annotations().delete("42").await?; + mock.assert_hits_async(1).await; + + Ok(()) +} + +#[tokio::test] +async fn create() -> Result<(), Box> { + let server = MockServer::start(); + let server_reply = Annotation { + id: "42".to_string(), + annotation_type: "cake".to_string(), + datasets: vec!["snot".to_string(), "snot".to_string()], + description: None, + title: Some("cookie".to_string()), + url: None, + time: DateTime::parse_from_rfc3339("2024-02-06T11:39:28.382Z") + .expect("we know the time is right"), + end_time: None, + }; + let mock = server.mock(|when, then| { + when.method(POST).path("/v2/annotations").json_body_obj( + &requests::Create::builder() + .with_type("cake") + .expect("known ok") + .with_datasets(vec!["snot".to_string(), "snot".to_string()]) + .expect("known ok") + .with_title("cookie") + .build(), + ); + then.status(200).json_body(json!(server_reply)); + }); + let client = Client::builder() + .no_env() + .with_url(server.base_url()) + .with_token("xapt-nope") + .build()?; + + let req = requests::Create::builder() + .with_type("cake") + .expect("known ok") + .with_datasets(vec!["snot".to_string(), "snot".to_string()]) + .expect("known ok") + .with_title("cookie") + .build(); + let r = client.annotations().create(req).await?; + assert_eq!(r, server_reply); + mock.assert_hits_async(1).await; + Ok(()) +} diff --git a/src/error.rs b/src/error.rs index fb053e1..67c3511 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,12 @@ pub enum Error { #[error("Empty update")] /// Empty update. EmptyUpdate, + #[error("Empty datasets")] + /// Empty datasets. + EmptyDatasets, + #[error("Empty type")] + /// Empty type. + EmptyType, #[error("Missing token")] /// Missing token. MissingToken, From 7a2da4a8e344fa57cb052c9f638bf9fbbac66be5 Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Fri, 14 Jun 2024 14:27:08 +0200 Subject: [PATCH 7/8] Nicify tests Signed-off-by: Heinz Gies --- tests/datasets.rs | 58 ++++++++++++++++------------------------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/tests/datasets.rs b/tests/datasets.rs index 5ed6e40..32ba79c 100644 --- a/tests/datasets.rs +++ b/tests/datasets.rs @@ -42,17 +42,17 @@ impl AsyncTestContext for Context { #[cfg(feature = "tokio")] #[test_context(Context)] #[tokio::test] -async fn test_datasets(ctx: &mut Context) { - test_datasets_impl(ctx).await; +async fn test_datasets(ctx: &mut Context) -> Result<(), Box> { + Ok(test_datasets_impl(ctx).await?) } #[cfg(feature = "async-std")] #[test_context(Context)] #[async_std::test] -async fn test_datasets(ctx: &mut Context) { - test_datasets_impl(ctx).await; +async fn test_datasets(ctx: &mut Context) -> Result<(), Box> { + Ok(test_datasets_impl(ctx).await?) } -async fn test_datasets_impl(ctx: &mut Context) { +async fn test_datasets_impl(ctx: &mut Context) -> Result<(), Box> { // Let's update the dataset. let dataset = ctx .client @@ -61,19 +61,18 @@ async fn test_datasets_impl(ctx: &mut Context) { &ctx.dataset.name, "This is a soon to be filled test dataset", ) - .await - .unwrap(); + .await?; ctx.dataset = dataset; // Get the dataset and make sure it matches what we have updated it to. - let dataset = ctx.client.datasets().get(&ctx.dataset.name).await.unwrap(); + let dataset = ctx.client.datasets().get(&ctx.dataset.name).await?; assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.name, dataset.name); assert_eq!(ctx.dataset.description, dataset.description); // List all datasets and make sure the created dataset is part of that // list. - let datasets = ctx.client.datasets().list().await.unwrap(); + let datasets = ctx.client.datasets().list().await?; datasets .iter() .find(|dataset| dataset.name == ctx.dataset.name) @@ -110,8 +109,7 @@ async fn test_datasets_impl(ctx: &mut Context) { ContentType::Json, ContentEncoding::Identity, ) - .await - .unwrap(); + .await?; assert_eq!(ingest_status.ingested, 2); assert_eq!(ingest_status.failed, 0); assert_eq!(ingest_status.failures.len(), 0); @@ -140,29 +138,21 @@ async fn test_datasets_impl(ctx: &mut Context) { "agent": "Debian APT-HTTP/1.3 (0.8.16~exp12ubuntu10.21)" }), ]; - let ingest_status = ctx.client.ingest(&ctx.dataset.name, &events).await.unwrap(); + let ingest_status = ctx.client.ingest(&ctx.dataset.name, &events).await?; assert_eq!(ingest_status.ingested, 2); assert_eq!(ingest_status.failed, 0); assert_eq!(ingest_status.failures.len(), 0); // ... a small stream let stream = futures_util::stream::iter(events.clone()); - let ingest_status = ctx - .client - .ingest_stream(&ctx.dataset.name, stream) - .await - .unwrap(); + let ingest_status = ctx.client.ingest_stream(&ctx.dataset.name, stream).await?; assert_eq!(ingest_status.ingested, 2); assert_eq!(ingest_status.failed, 0); assert_eq!(ingest_status.failures.len(), 0); // ... and a big stream (4321 items) let stream = futures_util::stream::iter(events).cycle().take(4321); - let ingest_status = ctx - .client - .ingest_stream(&ctx.dataset.name, stream) - .await - .unwrap(); + let ingest_status = ctx.client.ingest_stream(&ctx.dataset.name, stream).await?; assert_eq!(ingest_status.ingested, 4321); assert_eq!(ingest_status.failed, 0); assert_eq!(ingest_status.failures.len(), 0); @@ -171,7 +161,7 @@ async fn test_datasets_impl(ctx: &mut Context) { tokio::time::sleep(StdDuration::from_secs(15)).await; // Get the dataset info and make sure four events have been ingested. - let info = ctx.client.datasets().info(&ctx.dataset.name).await.unwrap(); + let info = ctx.client.datasets().info(&ctx.dataset.name).await?; assert_eq!(ctx.dataset.name, info.stat.name); assert_eq!(4327, info.stat.num_events); assert!(info.fields.len() > 0); @@ -192,8 +182,7 @@ async fn test_datasets_impl(ctx: &mut Context) { ..Default::default() }), ) - .await - .unwrap(); + .await?; assert!(simple_query_result.saved_query_id.is_some()); // assert_eq!(1, simple_query_result.status.blocks_examined); assert_eq!(4327, simple_query_result.status.rows_examined); @@ -210,8 +199,7 @@ async fn test_datasets_impl(ctx: &mut Context) { ..Default::default() }, ) - .await - .unwrap(); + .await?; assert!(apl_query_result.saved_query_id.is_some()); // assert_eq!(1, apl_query_result.status.blocks_examined); assert_eq!(4327, apl_query_result.status.rows_examined); @@ -266,19 +254,11 @@ async fn test_datasets_impl(ctx: &mut Context) { ..Default::default() }, ) - .await - .unwrap(); + .await?; assert_eq!(4327, query_result.status.rows_examined); assert_eq!(4327, query_result.status.rows_matched); assert!(query_result.buckets.totals.len() == 2); - let agg = query_result - .buckets - .totals - .get(0) - .unwrap() - .aggregations - .get(0) - .unwrap(); + let agg = &query_result.buckets.totals[0].aggregations[0]; assert_eq!("event_count", agg.alias); assert_eq!(2164, agg.value); @@ -286,6 +266,6 @@ async fn test_datasets_impl(ctx: &mut Context) { ctx.client .datasets() .trim(&ctx.dataset.name, Duration::seconds(1)) - .await - .unwrap(); + .await?; + Ok(()) } From fb4f18358b7645fd1741acdad1961948da75bbe5 Mon Sep 17 00:00:00 2001 From: Heinz Gies Date: Mon, 17 Jun 2024 14:33:04 +0200 Subject: [PATCH 8/8] Fix bad param Signed-off-by: Heinz Gies --- src/annotations/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/annotations/client.rs b/src/annotations/client.rs index b3b148a..75b85e5 100644 --- a/src/annotations/client.rs +++ b/src/annotations/client.rs @@ -64,7 +64,7 @@ impl<'client> Client<'client> { pub async fn update( &self, id: impl fmt::Display + fmt::Debug, - req: requests::Create, + req: requests::Update, ) -> Result { self.http_client .put(format!("/v2/annotations/{id}"), req)