diff --git a/.changeset/dark-feet-heal.md b/.changeset/dark-feet-heal.md new file mode 100644 index 000000000..3e0a7e11d --- /dev/null +++ b/.changeset/dark-feet-heal.md @@ -0,0 +1,6 @@ +--- +'hive-console-sdk-rs': patch +--- + +Use the JSON Schema specification of the usage reports directly to generate Rust structs as a source +of truth instead of manually written types diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index bb4c39215..2b4fc8840 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -2619,6 +2619,7 @@ dependencies = [ "md5", "mockito", "moka", + "regress", "reqwest", "reqwest-middleware", "reqwest-retry 0.8.0", @@ -2629,6 +2630,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "typify", ] [[package]] @@ -4438,7 +4440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck 0.5.0", - "itertools 0.11.0", + "itertools 0.14.0", "log", "multimap 0.10.1", "once_cell", @@ -4458,7 +4460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.108", @@ -4760,6 +4762,16 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.0", + "memchr", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -5124,6 +5136,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -5144,12 +5168,24 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.0.4", "serde", "serde_json", "url", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.108", +] + [[package]] name = "schemars_derive" version = "1.0.4" @@ -5196,6 +5232,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5300,6 +5340,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6266,6 +6318,53 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "typify" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5bcc6f62eb1fa8aa4098f39b29f93dcb914e17158b76c50360911257aa629" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1eb359f7ffa4f9ebe947fa11a1b2da054564502968db5f317b7e37693cb2240" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.108", + "thiserror 2.0.17", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.108", + "typify-impl", +] + [[package]] name = "ucd-trie" version = "0.1.7" diff --git a/docker/docker.hcl b/docker/docker.hcl index cf6f2cf36..c8d443c7e 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -363,6 +363,7 @@ target "apollo-router" { contexts = { router_pkg = "${PWD}/packages/libraries/router" sdk_rs_pkg = "${PWD}/packages/libraries/sdk-rs" + usage_service = "${PWD}/packages/services/usage" config = "${PWD}/configs/cargo" } args = { diff --git a/docker/router.dockerfile b/docker/router.dockerfile index 75f8f0ad7..c268cdecf 100644 --- a/docker/router.dockerfile +++ b/docker/router.dockerfile @@ -22,6 +22,11 @@ COPY --from=router_pkg Cargo.toml /usr/src/router/ COPY --from=sdk_rs_pkg Cargo.toml /usr/src/sdk-rs/ COPY --from=config Cargo.lock /usr/src/router/ +# Copy usage report schema +# `usage.rs` uses it with the path `../../services/usage/usage-report-v2.schema.json` +# So we need to place it accordingly +COPY --from=usage_service usage-report-v2.schema.json /usr/services/usage/ + WORKDIR /usr/src/sdk-rs # Get the dependencies cached, so we can use dummy input files so Cargo wont fail RUN echo 'fn main() { println!(""); }' > ./src/main.rs diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml index 34521f158..431efea17 100644 --- a/packages/libraries/sdk-rs/Cargo.toml +++ b/packages/libraries/sdk-rs/Cargo.toml @@ -32,6 +32,8 @@ serde_json = "1" moka = { version = "0.12.10", features = ["future", "sync"] } sha2 = { version = "0.10.8", features = ["std"] } tokio-util = "0.7.16" +typify = "0.5.0" +regress = "0.10.5" [dev-dependencies] mockito = "1.7.0" diff --git a/packages/libraries/sdk-rs/src/agent.rs b/packages/libraries/sdk-rs/src/agent.rs index 20c1d013a..a991f5768 100644 --- a/packages/libraries/sdk-rs/src/agent.rs +++ b/packages/libraries/sdk-rs/src/agent.rs @@ -3,65 +3,14 @@ use graphql_parser::schema::Document; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; -use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, VecDeque}, + collections::{hash_map::Entry, HashMap, VecDeque}, sync::{Arc, Mutex}, time::Duration, }; use thiserror::Error; use tokio_util::sync::CancellationToken; -#[derive(Serialize, Deserialize, Debug)] -pub struct Report { - size: usize, - map: HashMap, - operations: Vec, -} - -#[allow(non_snake_case)] -#[derive(Serialize, Deserialize, Debug)] -struct OperationMapRecord { - operation: String, - #[serde(skip_serializing_if = "Option::is_none")] - operationName: Option, - fields: Vec, -} - -#[allow(non_snake_case)] -#[derive(Serialize, Deserialize, Debug)] -struct Operation { - operationMapKey: String, - timestamp: u64, - execution: Execution, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, - #[serde(skip_serializing_if = "Option::is_none")] - persistedDocumentHash: Option, -} - -#[allow(non_snake_case)] -#[derive(Serialize, Deserialize, Debug)] -struct Execution { - ok: bool, - duration: u128, - errorsTotal: usize, -} - -#[derive(Serialize, Deserialize, Debug)] -struct Metadata { - #[serde(skip_serializing_if = "Option::is_none")] - client: Option, -} - -#[derive(Serialize, Deserialize, Debug)] -struct ClientInfo { - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - version: Option, -} - #[derive(Debug, Clone)] pub struct ExecutionReport { pub schema: Arc>, @@ -76,6 +25,8 @@ pub struct ExecutionReport { pub persisted_document_hash: Option, } +typify::import_types!(schema = "../../services/usage/usage-report-v2.schema.json"); + #[derive(Debug, Default)] pub struct Buffer(Mutex>); @@ -201,6 +152,7 @@ impl UsageAgent { size: 0, map: HashMap::new(), operations: Vec::new(), + subscription_operations: Vec::new(), }; // iterate over reports and check if they are valid @@ -228,30 +180,42 @@ impl UsageAgent { let metadata: Option = if client_name.is_some() || client_version.is_some() { Some(Metadata { - client: Some(ClientInfo { - name: client_name, - version: client_version, + client: Some(Client { + name: client_name.unwrap_or_default(), + version: client_version.unwrap_or_default(), }), }) } else { None }; - report.operations.push(Operation { - operationMapKey: hash.clone(), + report.operations.push(RequestOperation { + operation_map_key: hash.clone(), timestamp: op.timestamp, execution: Execution { ok: op.ok, - duration: op.duration.as_nanos(), - errorsTotal: op.errors, + /* + The conversion from u128 (from op.duration.as_nanos()) to u64 using try_into().unwrap() can panic if the duration is longer than u64::MAX nanoseconds (over 584 years). + While highly unlikely, it's safer to handle this potential overflow gracefully in library code to prevent panics. + A safe alternative is to convert the Result to an Option and provide a fallback value on failure, + effectively saturating at u64::MAX. + */ + duration: op + .duration + .as_nanos() + .try_into() + .ok() + .unwrap_or(u64::MAX), + errors_total: op.errors.try_into().unwrap(), }, - persistedDocumentHash: op.persisted_document_hash, + persisted_document_hash: op + .persisted_document_hash + .map(PersistedDocumentHash), metadata, }); - if let std::collections::hash_map::Entry::Vacant(e) = report.map.entry(hash) - { + if let Entry::Vacant(e) = report.map.entry(ReportMapKey(hash)) { e.insert(OperationMapRecord { operation: operation.operation, - operationName: non_empty_string(op.operation_name), + operation_name: non_empty_string(op.operation_name), fields: operation.coordinates, }); } @@ -401,7 +365,7 @@ mod tests { let record = report.map.values().next().expect("No operation record"); // operation assert!(record.operation.contains("mutation deleteProject")); - assert_eq!(record.operationName.as_deref(), Some("deleteProject")); + assert_eq!(record.operation_name.as_deref(), Some("deleteProject")); // fields let expected_fields = vec![ "Mutation.deleteProject", @@ -434,11 +398,11 @@ mod tests { let operation = &operations[0]; let key = report.map.keys().next().expect("No operation key"); - assert_eq!(&operation.operationMapKey, key); + assert_eq!(operation.operation_map_key, key.0); assert_eq!(operation.timestamp, timestamp); - assert_eq!(operation.execution.duration, duration.as_nanos()); + assert_eq!(operation.execution.duration, duration.as_nanos() as u64); assert_eq!(operation.execution.ok, true); - assert_eq!(operation.execution.errorsTotal, 0); + assert_eq!(operation.execution.errors_total, 0); true }) .expect(1) @@ -545,7 +509,7 @@ mod tests { operation_name: Some("deleteProject".to_string()), client_name: Some(GRAPHQL_CLIENT_NAME.to_string()), client_version: Some(GRAPHQL_CLIENT_VERSION.to_string()), - timestamp, + timestamp: timestamp.try_into().unwrap(), duration, ok: true, errors: 0, diff --git a/packages/services/usage/usage-report-v2.schema.json b/packages/services/usage/usage-report-v2.schema.json index 37fcf5ea0..0cffa024c 100644 --- a/packages/services/usage/usage-report-v2.schema.json +++ b/packages/services/usage/usage-report-v2.schema.json @@ -5,7 +5,8 @@ "type": "object", "properties": { "size": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "map": { "type": "object", @@ -41,7 +42,8 @@ "type": "object", "properties": { "timestamp": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "operationMapKey": { "type": "string" @@ -55,10 +57,12 @@ "type": "boolean" }, "duration": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "errorsTotal": { - "type": "integer" + "type": "integer", + "minimum": 0 } }, "required": ["ok", "duration", "errorsTotal"] @@ -101,7 +105,8 @@ "type": "object", "properties": { "timestamp": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "operationMapKey": { "type": "string"