diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d35c764..25968cd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,10 +1,10 @@ name: Rust -on: +on: push: - branches: [ "main" ] pull_request: - branches: [ "main" ] + branches: + - master env: CARGO_TERM_COLOR: always diff --git a/Cargo.toml b/Cargo.toml index debd5cc..006b551 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,12 @@ publish = false [dependencies] anyhow = "1" ceramic-event = { git = "https://github.com/3box/rust-ceramic", branch = "main" } -#ceramic-event = { path = "/Users/dbrowning/code/3box/rust-ceramic/event" } json-patch = { version = "1.0.0", features = ["diff"] } reqwest = { version = "0.11.14", features = ["json"], optional = true } schemars = "0.8.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -ssi = { version = "0.6", features = ["ed25519"] } +ssi = { version = "0.7", features = ["ed25519"] } url = { version = "2.2.2", optional = true } [features] diff --git a/it/data/daemon.config.json b/it/data/daemon.config.json index 1232dc1..2425923 100644 --- a/it/data/daemon.config.json +++ b/it/data/daemon.config.json @@ -2,7 +2,7 @@ "anchor": {}, "http-api": { "hostname": "0.0.0.0", - "port": 7071, + "port": 7007, "admin-dids": [ "did:key:z6MkeqCTPhHPVg3HaAAtsR7vZ6FXkAHPXEbTJs7Y4CQABV9Z" ] diff --git a/it/docker-compose.yml b/it/docker-compose.yml index 8bce6ec..38fd111 100644 --- a/it/docker-compose.yml +++ b/it/docker-compose.yml @@ -2,8 +2,8 @@ version: '3' services: ceramic-service: - image: ceramicnetwork/js-ceramic + image: ceramicnetwork/js-ceramic:2.35.0-rc.0 volumes: - ./data:/root/.ceramic ports: - - 7071:7071 + - 7007:7007 diff --git a/it/wait_for_ceramic.sh b/it/wait_for_ceramic.sh index 830cf66..5b82b7d 100755 --- a/it/wait_for_ceramic.sh +++ b/it/wait_for_ceramic.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -while [ $(curl -s -o /dev/null -I -w "%{http_code}" "http://localhost:7071/api/v0/node/healthcheck") -ne "200" ]; do +while [ $(curl -s -o /dev/null -I -w "%{http_code}" "http://localhost:7007/api/v0/node/healthcheck") -ne "200" ]; do echo "Ceramic is not yet ready, waiting and trying again" sleep 1 done \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 5bc83f7..ff90376 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,10 +1,14 @@ +use crate::query::FilterQuery; use ceramic_event::{ - Base64String, Jws, MultiBase32String, MultiBase36String, StreamId, StreamIdType, + Base64String, Base64UrlString, Jws, MultiBase32String, MultiBase36String, StreamId, + StreamIdType, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; /// Header for block data #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct BlockHeader { /// Family that block belongs to pub family: String, @@ -62,6 +66,7 @@ pub struct UpdateRequest { /// Log entry for stream #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct StateLog { /// CID for stream pub cid: MultiBase36String, @@ -69,6 +74,7 @@ pub struct StateLog { /// Metadata for stream #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Metadata { /// Controllers for stream pub controllers: Vec, @@ -78,9 +84,10 @@ pub struct Metadata { /// Current state of stream #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct StreamState { /// Content of stream - pub content: serde_json::Value, + pub content: Value, /// Log of stream pub log: Vec, /// Metadata for stream @@ -89,9 +96,9 @@ pub struct StreamState { /// Response from request against streams endpoint #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct StreamsResponse { /// ID of stream requested - #[serde(rename = "streamId")] pub stream_id: StreamId, /// State of stream pub state: Option, @@ -124,6 +131,7 @@ impl StreamsResponseOrError { /// Json wrapper around jws #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct JwsValue { /// Jws for a specific commit pub jws: Jws, @@ -131,6 +139,7 @@ pub struct JwsValue { /// Commit for a specific stream #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Commit { /// Commit id pub cid: MultiBase36String, @@ -140,10 +149,214 @@ pub struct Commit { /// Response from commits endpoint #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CommitsResponse { /// ID of stream for commit - #[serde(rename = "streamId")] pub stream_id: StreamId, /// Commits of stream pub commits: Vec, } + +/// Model data for indexing +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelData { + /// Model id to index + #[serde(rename = "streamID")] + pub model: StreamId, +} + +/// Model data for indexing +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexModelData { + /// Models to index + #[serde(rename = "modelData")] + pub models: Vec, +} + +/// Response from call to admin api /getCode +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminCodeResponse { + /// Generated code + pub code: String, +} + +/// JWS Info for Admin request +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminApiPayload { + /// Admin access code from /getCode request + pub code: String, + /// Admin path request is against + pub request_path: String, + /// Body of request + pub request_body: T, +} + +/// Request against admin api +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminApiRequest { + jws: String, +} + +impl TryFrom for AdminApiRequest { + type Error = anyhow::Error; + fn try_from(value: Jws) -> Result { + let maybe_sig = value + .signatures + .first() + .and_then(|sig| sig.protected.as_ref().map(|p| (&sig.signature, p))); + if let Some((sig, protected)) = &maybe_sig { + let sig = format!("{}.{}.{}", protected, value.payload, sig); + Ok(Self { jws: sig }) + } else { + anyhow::bail!("Invalid jws, no signatures") + } + } +} + +/// Pagination for query +#[derive(Debug, Serialize)] +#[serde(untagged, rename_all = "camelCase")] +pub enum Pagination { + /// Paginate forward + First { + /// Number of results to return + first: u32, + /// Point to start query from + #[serde(skip_serializing_if = "Option::is_none")] + after: Option, + }, + /// Paginate backwards + Last { + /// Number of results to return + last: u32, + /// Point to start query from + #[serde(skip_serializing_if = "Option::is_none")] + before: Option, + }, +} + +impl Default for Pagination { + fn default() -> Self { + Self::First { + first: 100, + after: None, + } + } +} + +/// Request to query +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryRequest { + /// Model to query documents for + pub model: StreamId, + /// Account making query + pub account: String, + /// Filters to use + #[serde(rename = "queryFilters", skip_serializing_if = "Option::is_none")] + pub query: Option, + /// Pagination + #[serde(flatten)] + pub pagination: Pagination, +} + +/// Node returned from query +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryNode { + /// Content of node + pub content: Value, + /// Commits for stream + pub log: Vec, +} + +/// Edge returned from query +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryEdge { + /// Cursor for edge + pub cursor: Base64UrlString, + /// Underlying node + pub node: QueryNode, +} + +/// Info about query pages +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PageInfo { + /// Whether next page exists + pub has_next_page: bool, + /// Whether previous page exists + pub has_previous_page: bool, + /// Cursor for next page + pub end_cursor: Base64UrlString, + /// Cursor for previous page + pub start_cursor: Base64UrlString, +} + +/// Response to query +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResponse { + /// Edges of query + pub edges: Vec, + /// Pagination info + pub page_info: PageInfo, +} + +/// Typed response to query +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TypedQueryDocument { + /// Document extracted from content + pub document: T, + /// All commits for underlying stream + pub commits: Vec, +} + +/// Typed response to query +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TypedQueryResponse { + /// Documents from query + pub documents: Vec>, + /// Pagination info + pub page_info: PageInfo, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::OperationFilter; + use std::collections::HashMap; + use std::str::FromStr; + + #[test] + fn should_serialize_query_request() { + let mut where_filter = HashMap::new(); + where_filter.insert( + "id".to_string(), + OperationFilter::EqualTo("1".to_string().into()), + ); + let filter = FilterQuery::Where(where_filter); + let req = QueryRequest { + model: StreamId::from_str( + "kjzl6hvfrbw6c8apa5yce6ah3fsz9sgrh6upniy0tz8z76gdm169ds3tf8c051t", + ) + .unwrap(), + account: "test".to_string(), + query: Some(filter), + pagination: Pagination::default(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert_eq!( + &json, + r#"{"model":"kjzl6hvfrbw6c8apa5yce6ah3fsz9sgrh6upniy0tz8z76gdm169ds3tf8c051t","account":"test","queryFilters":{"where":{"id":{"equalTo":"1"}}},"first":100}"# + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index cb02a85..7cc9faa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,35 +6,39 @@ /// Structures for working with ceramic http api pub mod api; mod model_definition; +mod query; use ceramic_event::{ - Base64String, Cid, DagCborEncoded, DidDocument, EventArgs, MultiBase36String, StreamId, + Base64String, Cid, DagCborEncoded, EventArgs, Jws, MultiBase36String, Signer, StreamId, StreamIdType, }; use serde::{de::DeserializeOwned, Serialize}; use std::str::FromStr; +use crate::api::ModelData; pub use ceramic_event; pub use model_definition::{ GetRootSchema, ModelAccountRelation, ModelDefinition, ModelRelationDefinition, ModelViewDefinition, }; +pub use query::*; pub use schemars; /// Client for interacting with the Ceramic HTTP API #[derive(Clone, Debug)] -pub struct CeramicHttpClient { - signer: DidDocument, - private_key: String, +pub struct CeramicHttpClient { + signer: S, } -impl CeramicHttpClient { +impl CeramicHttpClient { /// Create a new client, using a signer and private key - pub fn new(signer: DidDocument, private_key: &str) -> Self { - Self { - signer, - private_key: private_key.to_string(), - } + pub fn new(signer: S) -> Self { + Self { signer } + } + + /// Get the signer for this client + pub fn signer(&self) -> &S { + &self.signer } /// Get the streams endpoint @@ -47,13 +51,28 @@ impl CeramicHttpClient { "/api/v0/commits" } + /// Get the collection endpoint + pub fn collection_endpoint(&self) -> &'static str { + "/api/v0/collection" + } + + /// Get the code endpoint + pub fn admin_code_endpoint(&self) -> &'static str { + "/api/v0/admin/getCode" + } + + /// Get the index endpoint + pub fn index_endpoint(&self) -> &'static str { + "/api/v0/admin/modelData" + } + /// Create a serde compatible request for model creation pub async fn create_model_request( &self, model: &ModelDefinition, ) -> anyhow::Result> { let args = EventArgs::new(&self.signer); - let commit = args.init_with_data(&model, &self.private_key).await?; + let commit = args.init_with_data(&model).await?; let controllers: Vec<_> = args.controllers().map(|c| c.id.clone()).collect(); let data = Base64String::from(commit.linked_block.as_ref()); let model = Base64String::from(args.parent().to_vec()?); @@ -74,6 +93,26 @@ impl CeramicHttpClient { }) } + /// Create a serde compatible request for model indexing + pub async fn create_index_model_request( + &self, + model_id: &StreamId, + code: &str, + ) -> anyhow::Result { + let data = api::IndexModelData { + models: vec![ModelData { + model: model_id.clone(), + }], + }; + let req = api::AdminApiPayload { + code: code.to_string(), + request_path: self.index_endpoint().to_string(), + request_body: data, + }; + let jws = Jws::for_data(&self.signer, &req).await?; + api::AdminApiRequest::try_from(jws) + } + /// Create a serde compatible request for a single instance per account creation of a model pub async fn create_single_instance_request( &self, @@ -87,7 +126,7 @@ impl CeramicHttpClient { let controllers: Vec<_> = args.controllers().map(|c| c.id.clone()).collect(); let model = Base64String::from(model_id.to_vec()?); Ok(api::CreateRequest { - r#type: StreamIdType::Document, + r#type: StreamIdType::ModelInstanceDocument, block: api::BlockData { header: api::BlockHeader { family: "test".to_string(), @@ -112,12 +151,12 @@ impl CeramicHttpClient { anyhow::bail!("StreamId was not a model"); } let args = EventArgs::new_with_parent(&self.signer, model_id); - let commit = args.init_with_data(&data, &self.private_key).await?; + let commit = args.init_with_data(&data).await?; let controllers: Vec<_> = args.controllers().map(|c| c.id.clone()).collect(); let data = Base64String::from(commit.linked_block.as_ref()); let model = Base64String::from(model_id.to_vec()?); Ok(api::CreateRequest { - r#type: StreamIdType::Document, + r#type: StreamIdType::ModelInstanceDocument, block: api::BlockData { header: api::BlockHeader { family: "test".to_string(), @@ -145,15 +184,13 @@ impl CeramicHttpClient { if let Some(tip) = get.state.as_ref().and_then(|s| s.log.last()) { let tip = Cid::from_str(tip.cid.as_ref())?; let args = EventArgs::new_with_parent(&self.signer, model); - let commit = args - .update(&get.stream_id.cid, &tip, &self.private_key, &patch) - .await?; + let commit = args.update(&get.stream_id.cid, &tip, &patch).await?; let controllers: Vec<_> = args.controllers().map(|c| c.id.clone()).collect(); let data = Base64String::from(commit.linked_block.as_ref()); let model = Base64String::from(model.to_vec()?); let stream = MultiBase36String::try_from(&get.stream_id)?; Ok(api::UpdateRequest { - r#type: StreamIdType::Document, + r#type: StreamIdType::ModelInstanceDocument, block: api::BlockData { header: api::BlockHeader { family: "test".to_string(), @@ -187,30 +224,54 @@ impl CeramicHttpClient { }; self.create_update_request(model, get, diff).await } + + /// Create a serde compatible request to query model instances + pub async fn create_query_request( + &self, + model: &StreamId, + query: Option, + pagination: api::Pagination, + ) -> anyhow::Result { + Ok(api::QueryRequest { + model: model.clone(), + account: self.signer.id().id.clone(), + query, + pagination, + }) + } } /// Remote HTTP Functionality #[cfg(feature = "remote")] pub mod remote { use super::*; + use crate::api::Pagination; + use crate::query::FilterQuery; + pub use url::{ParseError, Url}; + #[derive(Clone)] /// Ceramic remote http client - pub struct CeramicRemoteHttpClient { - cli: CeramicHttpClient, + pub struct CeramicRemoteHttpClient { + cli: CeramicHttpClient, remote: reqwest::Client, - url: url::Url, + url: Url, } - impl CeramicRemoteHttpClient { + impl CeramicRemoteHttpClient { /// Create a new ceramic remote http client for a signer, private key, and url - pub fn new(signer: DidDocument, private_key: &str, remote: url::Url) -> Self { + pub fn new(signer: S, remote: Url) -> Self { Self { - cli: CeramicHttpClient::new(signer, private_key), + cli: CeramicHttpClient::new(signer), remote: reqwest::Client::new(), url: remote, } } + /// Access the underlying client + pub fn client(&self) -> &CeramicHttpClient { + &self.cli + } + /// Utility function to get a url for this client's base url, given a path pub fn url_for_path(&self, path: &str) -> anyhow::Result { let u = self.url.join(path)?; @@ -231,6 +292,32 @@ pub mod remote { Ok(resp.resolve("create_model")?.stream_id) } + /// Index a model on the remote ceramic + pub async fn index_model(&self, model_id: &StreamId) -> anyhow::Result<()> { + let resp: api::AdminCodeResponse = self + .remote + .get(self.url_for_path(self.cli.admin_code_endpoint())?) + .send() + .await? + .json() + .await?; + let req = self + .cli + .create_index_model_request(model_id, &resp.code) + .await?; + let resp = self + .remote + .post(self.url_for_path(self.cli.index_endpoint())?) + .json(&req) + .send() + .await?; + if resp.status().is_success() { + Ok(()) + } else { + anyhow::bail!("{}", resp.text().await?); + } + } + /// Create an instance of a model that allows a single instance on the remote ceramic pub async fn create_single_instance( &self, @@ -328,6 +415,53 @@ pub mod remote { Err(anyhow::anyhow!("No commits for stream {}", stream_id)) } } + + /// Query for documents, optionally matching a filter + pub async fn query( + &self, + model_id: &StreamId, + query: Option, + pagination: Pagination, + ) -> anyhow::Result { + let req = self + .cli + .create_query_request(model_id, query, pagination) + .await?; + let endpoint = self.url_for_path(self.cli.collection_endpoint())?; + let resp = self + .remote + .post(endpoint) + .json(&req) + .send() + .await? + .json() + .await?; + Ok(resp) + } + + /// Query for documents matching a filter, deserialized to a serde compatible type + pub async fn query_as( + &self, + model_id: &StreamId, + query: Option, + pagination: Pagination, + ) -> anyhow::Result> { + let resp = self.query(model_id, query, pagination).await?; + let try_docs: Result, _> = resp + .edges + .into_iter() + .map(|edge| { + serde_json::from_value(edge.node.content).map(|doc| api::TypedQueryDocument { + document: doc, + commits: edge.node.log, + }) + }) + .collect(); + Ok(api::TypedQueryResponse { + documents: try_docs?, + page_info: resp.page_info, + }) + } } } @@ -335,10 +469,14 @@ pub mod remote { pub mod tests { use super::remote::*; use super::*; + use crate::api::Pagination; use crate::model_definition::{GetRootSchema, ModelAccountRelation, ModelDefinition}; + use crate::query::{FilterQuery, OperationFilter}; + use ceramic_event::{DidDocument, JwkSigner}; use json_patch::ReplaceOperation; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; + use std::collections::HashMap; use std::time::Duration; // See https://github.com/ajv-validator/ajv-formats for information on valid formats @@ -356,29 +494,30 @@ pub mod tests { pub fn ceramic_url() -> url::Url { let u = - std::env::var("CERAMIC_URL").unwrap_or_else(|_| "http://localhost:7071".to_string()); + std::env::var("CERAMIC_URL").unwrap_or_else(|_| "http://localhost:7007".to_string()); url::Url::parse(&u).unwrap() } - pub fn did() -> DidDocument { + pub async fn signer() -> JwkSigner { let s = std::env::var("DID_DOCUMENT").unwrap_or_else(|_| { "did:key:z6MkeqCTPhHPVg3HaAAtsR7vZ6FXkAHPXEbTJs7Y4CQABV9Z".to_string() }); - DidDocument::new(&s) - } - - pub fn did_private_key() -> String { - std::env::var("DID_PRIVATE_KEY").unwrap() + JwkSigner::new( + DidDocument::new(&s), + &std::env::var("DID_PRIVATE_KEY").unwrap(), + ) + .await + .unwrap() } - pub async fn create_model(cli: &CeramicRemoteHttpClient) -> StreamId { + pub async fn create_model(cli: &CeramicRemoteHttpClient) -> StreamId { let model = ModelDefinition::new::("TestBall", ModelAccountRelation::List).unwrap(); cli.create_model(&model).await.unwrap() } #[tokio::test] async fn should_create_model() { - let ceramic = CeramicRemoteHttpClient::new(did(), &did_private_key(), ceramic_url()); + let ceramic = CeramicRemoteHttpClient::new(signer().await, ceramic_url()); let model = ModelDefinition::new::("TestBall", ModelAccountRelation::List).unwrap(); ceramic.create_model(&model).await.unwrap(); } @@ -392,14 +531,13 @@ pub mod tests { #[tokio::test] async fn should_create_and_update_list() { - let d = did(); - let ceramic = CeramicRemoteHttpClient::new(d.clone(), &did_private_key(), ceramic_url()); + let ceramic = CeramicRemoteHttpClient::new(signer().await, ceramic_url()); let model = create_model(&ceramic).await; let stream_id = ceramic .create_list_instance( &model, &Ball { - creator: d.id.to_string(), + creator: ceramic.client().signer().id().id.clone(), radius: 1, red: 2, green: 3, @@ -448,14 +586,13 @@ pub mod tests { #[tokio::test] async fn should_create_and_replace_list() { - let d = did(); - let ceramic = CeramicRemoteHttpClient::new(d.clone(), &did_private_key(), ceramic_url()); + let ceramic = CeramicRemoteHttpClient::new(signer().await, ceramic_url()); let model = create_model(&ceramic).await; let stream_id = ceramic .create_list_instance( &model, &Ball { - creator: d.id.to_string(), + creator: ceramic.client().signer().id().id.clone(), radius: 1, red: 2, green: 3, @@ -469,7 +606,7 @@ pub mod tests { tokio::time::sleep(Duration::from_secs(1)).await; let replace = Ball { - creator: d.id.to_string(), + creator: ceramic.client().signer().id().id.clone(), radius: 1, red: 5, green: 3, @@ -485,7 +622,7 @@ pub mod tests { tokio::time::sleep(Duration::from_secs(1)).await; let replace = Ball { - creator: d.id.to_string(), + creator: ceramic.client().signer().id().id.clone(), radius: 1, red: 0, green: 3, @@ -502,4 +639,47 @@ pub mod tests { let get_resp: Ball = ceramic.get_as(&stream_id).await.unwrap(); assert_eq!(get_resp, post_resp); } + + #[tokio::test] + async fn should_query_models() { + let ceramic = CeramicRemoteHttpClient::new(signer().await, ceramic_url()); + let model = create_model(&ceramic).await; + ceramic.index_model(&model).await.unwrap(); + let _instance1 = ceramic + .create_list_instance( + &model, + &Ball { + creator: ceramic.client().signer().id().id.clone(), + radius: 1, + red: 2, + green: 3, + blue: 4, + }, + ) + .await + .unwrap(); + + let _instance2 = ceramic + .create_list_instance( + &model, + &Ball { + creator: ceramic.client().signer().id().id.clone(), + radius: 2, + red: 3, + green: 4, + blue: 5, + }, + ) + .await + .unwrap(); + + let mut where_filter = HashMap::new(); + where_filter.insert("blue".to_string(), OperationFilter::EqualTo(5.into())); + let filter = FilterQuery::Where(where_filter); + let res = ceramic + .query(&model, Some(filter), Pagination::default()) + .await + .unwrap(); + assert_eq!(res.edges.len(), 1); + } } diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..2fed8a5 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,307 @@ +use serde::Serialize; +use std::collections::HashMap; + +/// Valid values for operation Filter +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum NumberFilter { + /// I64 Value + I64(i64), + /// I32 Value + I32(i32), + /// F32 Value + F32(f32), + /// F64 Value + F64(f64), +} + +impl From for NumberFilter { + fn from(value: i64) -> Self { + Self::I64(value) + } +} + +impl From for NumberFilter { + fn from(value: i32) -> Self { + Self::I32(value) + } +} + +impl From for NumberFilter { + fn from(value: f64) -> Self { + Self::F64(value) + } +} + +impl From for NumberFilter { + fn from(value: f32) -> Self { + Self::F32(value) + } +} + +/// Valid values for operation Filter +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum ValueFilter { + /// String value + String(String), + /// Number value + Number(NumberFilter), +} + +impl From<&str> for ValueFilter { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for ValueFilter { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From for ValueFilter { + fn from(value: i64) -> Self { + Self::Number(value.into()) + } +} + +impl From for ValueFilter { + fn from(value: i32) -> Self { + Self::Number(value.into()) + } +} + +impl From for ValueFilter { + fn from(value: f64) -> Self { + Self::Number(value.into()) + } +} + +impl From for ValueFilter { + fn from(value: f32) -> Self { + Self::Number(value.into()) + } +} + +/// Valid values for operation Filter +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum EqualValueFilter { + /// Boolean value + Boolean(bool), + /// Number value + Value(ValueFilter), +} + +impl From for EqualValueFilter { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From<&str> for EqualValueFilter { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for EqualValueFilter { + fn from(value: String) -> Self { + Self::Value(value.into()) + } +} + +impl From for EqualValueFilter { + fn from(value: i64) -> Self { + Self::Value(value.into()) + } +} + +impl From for EqualValueFilter { + fn from(value: i32) -> Self { + Self::Value(value.into()) + } +} + +impl From for EqualValueFilter { + fn from(value: f64) -> Self { + Self::Value(value.into()) + } +} + +impl From for EqualValueFilter { + fn from(value: f32) -> Self { + Self::Value(value.into()) + } +} + +/// Operation Filter +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperationFilter { + /// Filter by null or not null + IsNull(bool), + /// Filter by equal to + EqualTo(EqualValueFilter), + /// Filter by not equal to + NotEqualTo(EqualValueFilter), + /// Filter against an array of values + In(Vec), + /// Filter against an array of values + NotIn(Vec), + /// Filter by less than + LessThan(NumberFilter), + /// Filter by less than or equal to + LessThanOrEqualTo(NumberFilter), + /// Filter by greater than + GreaterThan(NumberFilter), + /// Filter by greater than or equal to + GreaterThanOrEqualTo(NumberFilter), +} + +/// Combination query +#[derive(Clone, Debug, Serialize)] +pub struct CombinationQuery(Vec); + +impl CombinationQuery { + /// Create a new combination query, consisting of at least 2 filters + pub fn new(a: FilterQuery, b: FilterQuery, rest: Vec) -> Self { + Self(vec![a, b].into_iter().chain(rest).collect()) + } +} + +/// Create an 'and' query +#[macro_export] +macro_rules! and { + ($a:expr, $b:expr, $($x:expr),*) => { + FilterQuery::And(CombinationQuery::new($a, $b, vec![$($x),*])) + }; +} + +/// Create an 'or' query +#[macro_export] +macro_rules! or { + ($a:expr, $b:expr, $($x:expr),*) => { + FilterQuery::Or(CombinationQuery::new($a, $b, vec![$($x),*])) + }; +} + +/// Filter Query +#[derive(Clone, Debug, Serialize)] +pub enum FilterQuery { + /// Filter by where + #[serde(rename = "where")] + Where(HashMap), + /// Filter by and + #[serde(rename = "and")] + And(CombinationQuery), + /// Filter by or + #[serde(rename = "or")] + Or(CombinationQuery), + /// Filter by not + #[serde(rename = "not")] + Not(Box), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_serializer_where() { + let mut where_filter = HashMap::new(); + where_filter.insert( + "id".to_string(), + OperationFilter::EqualTo("1".to_string().into()), + ); + let filter = FilterQuery::Where(where_filter); + let serialized = serde_json::to_string(&filter).unwrap(); + assert_eq!(serialized, r#"{"where":{"id":{"equalTo":"1"}}}"#); + } + + #[test] + fn should_serialize_and() { + let mut where_filter1 = HashMap::new(); + where_filter1.insert("id".to_string(), OperationFilter::LessThan(1i64.into())); + let mut where_filter2 = HashMap::new(); + where_filter2.insert( + "id2".to_string(), + OperationFilter::GreaterThanOrEqualTo(2i32.into()), + ); + let filter = and!( + FilterQuery::Where(where_filter1), + FilterQuery::Where(where_filter2), + ); + let serialized = serde_json::to_string(&filter).unwrap(); + assert_eq!( + serialized, + r#"{"and":[{"where":{"id":{"lessThan":1}}},{"where":{"id2":{"greaterThanOrEqualTo":2}}}]}"# + ); + } + + #[test] + fn should_serialize_or() { + let mut where_filter1 = HashMap::new(); + where_filter1.insert("id".to_string(), OperationFilter::GreaterThan(1i64.into())); + let mut where_filter2 = HashMap::new(); + where_filter2.insert( + "id2".to_string(), + OperationFilter::LessThanOrEqualTo(2i32.into()), + ); + let filter = or!( + FilterQuery::Where(where_filter1), + FilterQuery::Where(where_filter2), + ); + let serialized = serde_json::to_string(&filter).unwrap(); + assert_eq!( + serialized, + r#"{"or":[{"where":{"id":{"greaterThan":1}}},{"where":{"id2":{"lessThanOrEqualTo":2}}}]}"# + ); + } + + #[test] + fn should_serialize_in() { + let mut where_filter = HashMap::new(); + where_filter.insert( + "id".to_string(), + OperationFilter::In(vec!["a".into(), "b".into()]), + ); + let filter = FilterQuery::Not(Box::new(FilterQuery::Where(where_filter))); + let serialized = serde_json::to_string(&filter).unwrap(); + assert_eq!(serialized, r#"{"not":{"where":{"id":{"in":["a","b"]}}}}"#); + } + + #[test] + fn should_serialize_nested() { + let mut where_filter1 = HashMap::new(); + where_filter1.insert("id".to_string(), OperationFilter::IsNull(false)); + let mut where_filter2 = HashMap::new(); + where_filter2.insert("id2".to_string(), OperationFilter::NotEqualTo(2i32.into())); + let mut where_filter3 = HashMap::new(); + where_filter3.insert( + "id3".to_string(), + OperationFilter::NotIn(vec![3f32.into(), 4f32.into()]), + ); + let filter = and!( + or!( + FilterQuery::Not(Box::new(FilterQuery::Where(where_filter1.clone()))), + FilterQuery::Where(where_filter1), + ), + or!( + FilterQuery::Not(Box::new(FilterQuery::Where(where_filter2.clone()))), + FilterQuery::Where(where_filter2), + ), + or!( + FilterQuery::Not(Box::new(FilterQuery::Where(where_filter3.clone()))), + FilterQuery::Where(where_filter3), + ) + ); + let serialized = serde_json::to_string(&filter).unwrap(); + assert_eq!( + serialized, + r#"{"and":[{"or":[{"not":{"where":{"id":{"isNull":false}}}},{"where":{"id":{"isNull":false}}}]},{"or":[{"not":{"where":{"id2":{"notEqualTo":2}}}},{"where":{"id2":{"notEqualTo":2}}}]},{"or":[{"not":{"where":{"id3":{"notIn":[3.0,4.0]}}}},{"where":{"id3":{"notIn":[3.0,4.0]}}}]}]}"# + ); + } +}