diff --git a/src/client.rs b/src/client.rs index 28005d0b..96fbf863 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,26 +9,6 @@ use reqwest_middleware::{Middleware, Next}; use crate::config::Config; -/// HTTP error with the status code preserved for programmatic matching. -#[derive(Debug)] -pub struct HttpError { - pub status: u16, - pub method: String, - pub url: String, - pub body: String, -} - -impl std::fmt::Display for HttpError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{} {} failed (HTTP {}): {}", - self.method, self.url, self.status, self.body - ) - } -} - -impl std::error::Error for HttpError {} #[cfg(not(target_arch = "wasm32"))] struct BearerAuthMiddleware { token: String, @@ -140,8 +120,8 @@ pub fn make_dd_config(cfg: &Config) -> datadog_api_client::datadog::Configuratio /// instead of the SDK's `datadog-api-client-rust/...` default. When /// `send_bearer` is true and the config has an access token, also installs /// `BearerAuthMiddleware`. OAuth-incompatible endpoints (see -/// `OAUTH_EXCLUDED_ENDPOINTS`) pass `false` so the SDK falls back to API key -/// headers from the `Configuration`. +/// `raw_client::OAUTH_EXCLUDED_ENDPOINTS`) pass `false` so the SDK falls back +/// to API key headers from the `Configuration`. /// /// Returns `None` on WASM targets; callers use the SDK default client there. pub fn make_dd_client(cfg: &Config, send_bearer: bool) -> Option { @@ -416,711 +396,6 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.unstar_model_lab_project", ]; -// --------------------------------------------------------------------------- -// Auth type detection -// --------------------------------------------------------------------------- - -use crate::useragent; - -// Parse a reqwest response body as JSON without serde_json's default 128-level -// recursion cap. Some Datadog endpoints (e.g. /profiling/api/v1/aggregate) -// return deeply-nested flame-graph trees that exceed it. serde_stacker grows -// the thread stack on demand so disabling the limit can't blow it. -async fn parse_response_json(resp: reqwest::Response) -> anyhow::Result { - use serde::Deserialize; - let bytes = resp.bytes().await?; - let mut de = serde_json::Deserializer::from_slice(&bytes); - de.disable_recursion_limit(); - let de = serde_stacker::Deserializer::new(&mut de); - Ok(serde_json::Value::deserialize(de)?) -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthType { - None, - OAuth, - ApiKeys, -} - -impl std::fmt::Display for AuthType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AuthType::None => write!(f, "None"), - AuthType::OAuth => write!(f, "OAuth2 Bearer Token"), - AuthType::ApiKeys => write!(f, "API Keys (DD_API_KEY + DD_APP_KEY)"), - } - } -} - -#[allow(dead_code)] -pub fn get_auth_type(cfg: &Config) -> AuthType { - if cfg.has_bearer_token() { - AuthType::OAuth - } else if cfg.has_api_keys() { - AuthType::ApiKeys - } else { - AuthType::None - } -} - -// --------------------------------------------------------------------------- -// OAuth-excluded endpoint validation -// --------------------------------------------------------------------------- - -struct EndpointRequirement { - path: &'static str, - method: &'static str, -} - -/// Returns true if the endpoint doesn't support OAuth and requires API key fallback. -#[allow(dead_code)] -pub fn requires_api_key_fallback(method: &str, path: &str) -> bool { - find_endpoint_requirement(method, path).is_some() -} - -fn find_endpoint_requirement(method: &str, path: &str) -> Option<&'static EndpointRequirement> { - OAUTH_EXCLUDED_ENDPOINTS.iter().find(|req| { - if req.method != method { - return false; - } - // Trailing "/" means prefix match (for ID-parameterized paths) - if req.path.ends_with('/') { - path.starts_with(&req.path[..req.path.len() - 1]) - } else { - req.path == path - } - }) -} - -// --------------------------------------------------------------------------- -// Static tables -// --------------------------------------------------------------------------- - -/// Endpoints that don't support OAuth. -/// Trailing "/" means prefix match for ID-parameterized paths. -static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ - // API/App Keys (8) - EndpointRequirement { - path: "/api/v2/api_keys", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/api_keys/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/api_keys", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/api_keys/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/application_keys", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "PATCH", - }, - // DDSQL editor tools (3) - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/ddsql-docs", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/table-names", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/ddsql-editor/tools/table-data", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/application_keys/", - method: "DELETE", - }, - // Fleet Automation (15) - EndpointRequirement { - path: "/api/v2/fleet/agents", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/agents/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/agents/versions", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/configure", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/upgrade", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/deployments/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "PATCH", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/fleet/schedules/", - method: "POST", - }, - // Observability Pipelines (6) — API key only, no OAuth support - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "PUT", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/obs-pipelines/pipelines/validate", - method: "POST", - }, - // Cost / Billing (11) — API key only, no OAuth support - EndpointRequirement { - path: "/api/v2/usage/projected_cost", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/usage/cost_by_org", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost_by_tag/monthly_cost_attribution", - method: "GET", - }, - // Cloud Cost Management config (12) - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/aws_cur_config/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/azure_uc_config/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config", - method: "POST", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config/", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/gcp_uc_config/", - method: "DELETE", - }, - EndpointRequirement { - path: "/api/v2/cost/oci_config", - method: "GET", - }, - EndpointRequirement { - path: "/api/v2/cost/anomalies", - method: "GET", - }, - // Profiling (4) - // No OAuth scope is declared for Continuous Profiler endpoints; force API-key auth. - EndpointRequirement { - path: "/profiling/api/v1/", - method: "POST", - }, - EndpointRequirement { - path: "/profiling/api/v1/", - method: "GET", - }, - EndpointRequirement { - path: "/api/unstable/profiles/", - method: "POST", - }, - EndpointRequirement { - path: "/api/ui/profiling/", - method: "GET", - }, - // Events intake (1) - // Posting an event uses the V1 intake endpoint, which authenticates with the - // API key and does not accept OAuth2 bearer tokens. Listing/getting events - // (GET) is fine over OAuth, so only POST is excluded. - EndpointRequirement { - path: "/api/v1/events", - method: "POST", - }, -]; - -// --------------------------------------------------------------------------- -// Raw HTTP helpers -// --------------------------------------------------------------------------- - -/// Raw HTTP response returned by [`raw_request`]. -#[derive(Debug)] -pub struct HttpResponse { - /// The `Content-Type` header value from the response, or an empty string if absent. - pub content_type: String, - /// The raw response body bytes. - pub bytes: Vec, -} - -/// Makes an authenticated request with any HTTP method via reqwest. -/// -/// - `query` — key/value pairs appended as URL query parameters (reqwest handles percent-encoding). -/// Pass `&[]` when no query parameters are needed. -/// - `body` — raw bytes to send; `content_type` sets the `Content-Type` header when present. -/// - `accept` — value for the `Accept` header (e.g. `"application/json"`, `"*/*"`). -/// - `extra_headers` — additional headers applied after auth and before the body. -/// - Returns an [`HttpResponse`] with the raw bytes and response `Content-Type`. -/// Callers are responsible for decoding the bytes. -#[allow(clippy::too_many_arguments)] -pub async fn raw_request( - cfg: &Config, - method: &str, - path: &str, - query: &[(&str, &str)], - body: Option>, - content_type: Option<&str>, - accept: &str, - extra_headers: &[(&str, &str)], -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let method_name = method.to_uppercase(); - let method = reqwest::Method::from_bytes(method_name.as_bytes()) - .map_err(|_| anyhow::anyhow!("unsupported HTTP method: {method_name}"))?; - let mut req = client.request(method, &url); - if !query.is_empty() { - req = req.query(query); - } - - req = apply_auth(req, cfg, &method_name, path)?; - - req = req - .header("Accept", accept) - .header("User-Agent", useragent::get()); - - for (k, v) in extra_headers { - req = req.header(*k, *v); - } - - if let Some(b) = body { - if let Some(ct) = content_type { - req = req.header("Content-Type", ct); - } - req = req.body(b); - } - - let resp = req.send().await?; - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: method_name, - url, - body: text, - } - .into()); - } - - let resp_ct = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - - if resp.status() == reqwest::StatusCode::NO_CONTENT { - return Ok(HttpResponse { - content_type: resp_ct, - bytes: vec![], - }); - } - - let bytes = resp.bytes().await?.to_vec(); - Ok(HttpResponse { - content_type: resp_ct, - bytes, - }) -} - -/// Makes an authenticated GET request directly via reqwest. -/// Used for endpoints not covered by the typed DD API client. -/// Pass an empty slice for `query` when no query parameters are needed. -pub async fn raw_get( - cfg: &Config, - path: &str, - query: &[(&str, &str)], -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let mut req = client.get(&url); - - req = apply_auth(req, cfg, "GET", path)?; - - if !query.is_empty() { - req = req.query(query); - } - - let resp = req - .header("Accept", "application/json") - .header("User-Agent", useragent::get()) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "GET".into(), - url, - body, - } - .into()); - } - parse_response_json(resp).await -} - -/// Makes an authenticated PATCH request directly via reqwest. -/// Used for endpoints not covered by the typed DD API client. -#[allow(dead_code)] -pub async fn raw_patch( - cfg: &Config, - path: &str, - body: serde_json::Value, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let mut req = client.patch(&url); - - req = apply_auth(req, cfg, "PATCH", path)?; - - let resp = req - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("User-Agent", useragent::get()) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "PATCH".into(), - url, - body, - } - .into()); - } - parse_response_json(resp).await -} - -/// Makes an authenticated POST request directly via reqwest. -/// Used for endpoints not covered by the typed DD API client. -pub async fn raw_post( - cfg: &Config, - path: &str, - body: serde_json::Value, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - raw_post_impl(cfg, path, &url, body, useragent::get()).await -} - -/// Like `raw_post`, but with a custom User-Agent string for audit log differentiation. -pub async fn raw_post_with_ua( - cfg: &Config, - path: &str, - body: serde_json::Value, - ua: String, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - raw_post_impl(cfg, path, &url, body, ua).await -} - -async fn raw_post_impl( - cfg: &Config, - path: &str, - url: &str, - body: serde_json::Value, - ua: String, -) -> anyhow::Result { - let client = reqwest::Client::new(); - let mut req = client.post(url); - - req = apply_auth(req, cfg, "POST", path)?; - - let resp = req - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("User-Agent", ua) - .json(&body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "POST".into(), - url: url.to_string(), - body, - } - .into()); - } - parse_response_json(resp).await -} - -/// Apply Datadog authentication headers to a request builder. -/// -/// Chooses between OAuth bearer and API-key/App-key auth based on `cfg` and the -/// per-endpoint requirements in [`requires_api_key_fallback`]: endpoints that do -/// not accept OAuth (see `OAUTH_EXCLUDED_ENDPOINTS`) force API-key auth even when -/// a bearer token is present. Exposed so the generic `pup api` passthrough reuses -/// the same auth routing as the typed clients. -pub fn apply_auth( - mut req: reqwest::RequestBuilder, - cfg: &Config, - method: &str, - path: &str, -) -> anyhow::Result { - if requires_api_key_fallback(method, path) { - if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - return Ok(req); - } - - anyhow::bail!( - "{method} {path} requires DD_API_KEY and DD_APP_KEY; OAuth2 bearer tokens are not supported" - ); - } - - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - return Ok(req); - } - - if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - return Ok(req); - } - - anyhow::bail!("no authentication configured") -} - -/// POST a JSON:API document. Wraps `attributes` in `{data:{type,attributes}}` -/// and sends with `Content-Type: application/vnd.api+json`. Use for routes -/// whose decoder is configured for JSON:API. -pub async fn raw_post_jsonapi( - cfg: &Config, - path: &str, - resource_type: &str, - attributes: serde_json::Value, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let envelope = serde_json::json!({ - "data": { "type": resource_type, "attributes": attributes }, - }); - let client = reqwest::Client::new(); - let mut req = client.post(&url); - req = apply_auth(req, cfg, "POST", path)?; - let resp = req - .header("Content-Type", "application/vnd.api+json") - .header("Accept", "application/vnd.api+json") - .header("User-Agent", useragent::get()) - .json(&envelope) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("POST {url} failed (HTTP {status}): {body}"); - } - parse_response_json(resp).await -} - -pub async fn raw_put( - cfg: &Config, - path: &str, - body: serde_json::Value, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let req = client.put(&url); - let req = apply_auth(req, cfg, "PUT", path)?; - let resp = req - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("User-Agent", useragent::get()) - .json(&body) - .send() - .await?; - if resp.status() == reqwest::StatusCode::NO_CONTENT { - return Ok(serde_json::Value::Null); - } - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("PUT {url} failed (HTTP {status}): {body}"); - } - parse_response_json(resp).await -} - -/// Like `raw_post`, but returns the parsed JSON body even on non-2xx responses. -/// Callers are responsible for inspecting the body for errors. -pub async fn raw_post_lenient( - cfg: &Config, - path: &str, - body: serde_json::Value, -) -> anyhow::Result { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let mut req = client.post(&url); - - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - } else { - anyhow::bail!("no authentication configured"); - } - - let resp = req - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .header("User-Agent", useragent::get()) - .json(&body) - .send() - .await?; - parse_response_json(resp).await -} - -/// Makes an authenticated DELETE request directly via reqwest. -/// Used for endpoints not covered by the typed DD API client. -pub async fn raw_delete(cfg: &Config, path: &str) -> anyhow::Result<()> { - let url = format!("{}{}", cfg.api_base_url(), path); - let client = reqwest::Client::new(); - let mut req = client.delete(&url); - - if let Some(token) = &cfg.access_token { - req = req.header("Authorization", format!("Bearer {token}")); - } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { - req = req - .header("DD-API-KEY", api_key.as_str()) - .header("DD-APPLICATION-KEY", app_key.as_str()); - } else { - anyhow::bail!("no authentication configured"); - } - - let resp = req - .header("Accept", "application/json") - .header("User-Agent", useragent::get()) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(HttpError { - status: status.as_u16(), - method: "DELETE".into(), - url, - body, - } - .into()); - } - Ok(()) -} - #[cfg(test)] mod tests { use crate::test_support::*; @@ -1145,27 +420,6 @@ mod tests { } } - #[test] - fn test_auth_type_api_keys() { - let cfg = test_cfg(); - assert_eq!(get_auth_type(&cfg), AuthType::ApiKeys); - } - - #[test] - fn test_auth_type_bearer() { - let mut cfg = test_cfg(); - cfg.access_token = Some("token".into()); - assert_eq!(get_auth_type(&cfg), AuthType::OAuth); - } - - #[test] - fn test_auth_type_none() { - let mut cfg = test_cfg(); - cfg.api_key = None; - cfg.app_key = None; - assert_eq!(get_auth_type(&cfg), AuthType::None); - } - /// Asserts the SDK is routed at the host produced by `name`/`protocol`, /// the single `{protocol}://{name}` template at server index 1. fn assert_dd_host( @@ -1260,88 +514,11 @@ mod tests { assert_dd_host(&dd_cfg, "https", "api.datad0g.com"); } - #[test] - fn test_auth_type_display() { - assert_eq!(AuthType::OAuth.to_string(), "OAuth2 Bearer Token"); - assert_eq!( - AuthType::ApiKeys.to_string(), - "API Keys (DD_API_KEY + DD_APP_KEY)" - ); - assert_eq!(AuthType::None.to_string(), "None"); - } - - #[test] - fn test_no_fallback_for_logs() { - assert!(!requires_api_key_fallback("POST", "/api/v2/logs/events")); - assert!(!requires_api_key_fallback( - "POST", - "/api/v2/logs/events/search" - )); - } - - #[test] - fn test_no_fallback_for_rum() { - assert!(!requires_api_key_fallback( - "GET", - "/api/v2/rum/applications" - )); - assert!(!requires_api_key_fallback( - "GET", - "/api/v2/rum/applications/abc-123" - )); - } - - #[test] - fn test_no_fallback_for_events_search() { - assert!(!requires_api_key_fallback("POST", "/api/v2/events/search")); - } - - #[test] - fn test_fallback_for_events_post() { - // Posting an event (V1 intake) requires API keys; reading events does not. - assert!(requires_api_key_fallback("POST", "/api/v1/events")); - assert!(!requires_api_key_fallback("GET", "/api/v1/events")); - } - - #[test] - fn test_no_fallback_for_standard_endpoints() { - assert!(!requires_api_key_fallback("GET", "/api/v1/monitor")); - assert!(!requires_api_key_fallback("GET", "/api/v1/dashboard")); - assert!(!requires_api_key_fallback("GET", "/api/v2/incidents")); - } - - #[test] - fn test_prefix_matching_with_id() { - // Trailing "/" in the pattern should match paths with IDs - assert!(requires_api_key_fallback( - "DELETE", - "/api/v2/api_keys/key-123" - )); - assert!(requires_api_key_fallback( - "GET", - "/api/v2/fleet/agents/agent-123" - )); - } - - #[test] - fn test_method_must_match() { - // RUM events/search is POST-excluded, but GET should not match - assert!(!requires_api_key_fallback( - "GET", - "/api/v2/rum/events/search" - )); - } - #[test] fn test_unstable_ops_count() { assert_eq!(UNSTABLE_OPS.len(), 186); } - #[test] - fn test_oauth_excluded_count() { - assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 55); - } - #[test] fn test_make_dd_client_some_without_token() { // UA middleware is always installed, so the client is always Some on native. @@ -1446,162 +623,6 @@ mod tests { std::env::remove_var("DD_APP_KEY"); } - #[test] - fn test_no_fallback_for_notebooks() { - assert!(!requires_api_key_fallback("GET", "/api/v1/notebooks")); - assert!(!requires_api_key_fallback("GET", "/api/v1/notebooks/12345")); - assert!(!requires_api_key_fallback("POST", "/api/v1/notebooks")); - } - - #[test] - fn test_requires_api_key_fallback_fleet() { - assert!(requires_api_key_fallback("GET", "/api/v2/fleet/agents")); - assert!(requires_api_key_fallback( - "GET", - "/api/v2/fleet/agents/agent-123" - )); - } - - #[test] - fn test_requires_api_key_fallback_api_keys() { - assert!(requires_api_key_fallback("GET", "/api/v2/api_keys")); - assert!(requires_api_key_fallback("POST", "/api/v2/api_keys")); - assert!(requires_api_key_fallback( - "DELETE", - "/api/v2/api_keys/key-123" - )); - } - - #[test] - fn test_requires_api_key_fallback_ddsql_editor_tools() { - assert!(requires_api_key_fallback( - "GET", - "/api/unstable/ddsql-editor/tools/ddsql-docs" - )); - assert!(requires_api_key_fallback( - "GET", - "/api/unstable/ddsql-editor/tools/table-names" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/ddsql-editor/tools/table-data" - )); - } - - #[test] - fn test_no_fallback_for_error_tracking() { - assert!(!requires_api_key_fallback( - "POST", - "/api/v2/error_tracking/issues/search" - )); - } - - // Verify raw_request reaches the auth check (and fails there) for both the - // empty-query and non-empty-query paths. This ensures the `if !query.is_empty()` - // branch compiles and runs without panic. - #[test] - fn test_raw_request_no_auth_empty_query() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut cfg = test_cfg(); - cfg.api_key = None; - cfg.app_key = None; - let err = rt - .block_on(raw_request( - &cfg, - "GET", - "/api/v2/monitors", - &[], - None, - None, - "application/json", - &[], - )) - .unwrap_err(); - assert!( - err.to_string().contains("no authentication configured"), - "expected auth error, got: {err}" - ); - } - - #[test] - fn test_raw_request_no_auth_with_query() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut cfg = test_cfg(); - cfg.api_key = None; - cfg.app_key = None; - let err = rt - .block_on(raw_request( - &cfg, - "GET", - "/api/v2/monitors", - &[("page", "1"), ("page_size", "10")], - None, - None, - "application/json", - &[], - )) - .unwrap_err(); - assert!( - err.to_string().contains("no authentication configured"), - "expected auth error, got: {err}" - ); - } - - #[test] - fn test_requires_api_key_fallback_profiling() { - // /profiling/api/v1/* - assert!(requires_api_key_fallback( - "POST", - "/profiling/api/v1/aggregate" - )); - assert!(requires_api_key_fallback( - "GET", - "/profiling/api/v1/profiles/abc/info" - )); - assert!(requires_api_key_fallback( - "GET", - "/profiling/api/v1/profiles/abc/analysis" - )); - assert!(requires_api_key_fallback( - "POST", - "/profiling/api/v1/profiles/abc/breakdown" - )); - assert!(requires_api_key_fallback( - "POST", - "/profiling/api/v1/profiles/abc/timeline" - )); - // /api/unstable/profiles/* - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/list" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/analytics" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/insights" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/callgraph" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/interactive-analytics/field" - )); - assert!(requires_api_key_fallback( - "POST", - "/api/unstable/profiles/save-favorite" - )); - // /api/ui/profiling/* - assert!(requires_api_key_fallback( - "GET", - "/api/ui/profiling/profiles/abc/download" - )); - } - /// Verifies that requests built via `make_api!` carry pup's branded /// `User-Agent` rather than the SDK's default `datadog-api-client-rust/...`. /// The mock only matches when the header starts with `pup/`; if the @@ -1710,38 +731,4 @@ mod tests { mock.assert_async().await; cleanup_env(); } - - /// Verifies that raw_request attaches query parameters and returns Ok when the - /// server responds 200. Exercises the `!query.is_empty()` branch added to the function. - #[tokio::test] - async fn test_raw_request_with_query_params_ok() { - let _lock = lock_env().await; - let mut server = mockito::Server::new_async().await; - let cfg = test_config(&server.url()); - let _mock = server - .mock("GET", "/api/v2/monitors") - .match_query(mockito::Matcher::Any) - .with_status(200) - .with_header("content-type", "application/json") - .with_body("[]") - .create_async() - .await; - let resp = super::raw_request( - &cfg, - "GET", - "/api/v2/monitors", - &[("page", "1"), ("page_size", "10")], - None, - None, - "application/json", - &[], - ) - .await; - assert!( - resp.is_ok(), - "raw_request with query failed: {:?}", - resp.err() - ); - cleanup_env(); - } } diff --git a/src/commands/api.rs b/src/commands/api.rs index 20b37618..a90b423c 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -120,7 +120,7 @@ pub async fn run( // Path used for per-endpoint auth routing. For relative endpoints this is the // normalized API path; for absolute URLs on the Datadog host we use the URL's - // path component so the OAuth-exclusion table (client::requires_api_key_fallback) + // path component so the OAuth-exclusion table (raw_client::requires_api_key_fallback) // still applies. let auth_path = if is_absolute { reqwest::Url::parse(&url) @@ -179,7 +179,7 @@ pub async fn run( // Datadog credentials are never sent to an arbitrary host (see above); the // request is sent unauthenticated and the caller may add headers via -H. if credentials_allowed { - req = crate::client::apply_auth(req, cfg, &method_upper, &auth_path)?; + req = crate::raw_client::apply_auth(req, cfg, &method_upper, &auth_path)?; } else if cfg.access_token.is_some() || cfg.api_key.is_some() { eprintln!( "warning: not sending Datadog credentials to non-Datadog host {:?}; \ @@ -614,7 +614,7 @@ mod tests { /// OAuth-excluded endpoints (e.g. GET /api/v2/api_keys) must use API-key auth /// even when a bearer token is present. This exercises the reuse of - /// client::apply_auth's per-endpoint fallback table. + /// raw_client::apply_auth's per-endpoint fallback table. #[tokio::test] async fn test_api_oauth_excluded_uses_api_keys() { let _lock = lock_env().await; diff --git a/src/commands/apm.rs b/src/commands/apm.rs index e7e244f8..7b1e8c84 100644 --- a/src/commands/apm.rs +++ b/src/commands/apm.rs @@ -1,39 +1,39 @@ use anyhow::Result; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; pub async fn services_list(cfg: &Config, env: String, from: String, to: String) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/v2/apm/services?start={from_ts}&end={to_ts}&filter[env]={env}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } pub async fn services_stats(cfg: &Config, env: String, from: String, to: String) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/v2/apm/services/stats?start={from_ts}&end={to_ts}&filter[env]={env}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } pub async fn entities_list(cfg: &Config, from: String, to: String) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/unstable/apm/entities?start={from_ts}&end={to_ts}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } pub async fn dependencies_list(cfg: &Config, env: String, from: String, to: String) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/v1/service_dependencies?start={from_ts}&end={to_ts}&env={env}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -44,11 +44,11 @@ pub async fn services_operations( from: String, to: String, ) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/v1/trace/operation_names/{service}?env={env}&start={from_ts}&end={to_ts}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -60,12 +60,12 @@ pub async fn services_resources( from: String, to: String, ) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!( "/api/ui/apm/resources?service={service}&name={name}&env={env}&from={from_ts}&to={to_ts}" ); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -76,11 +76,11 @@ pub async fn flow_map( from: String, to: String, ) -> Result<()> { - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let path = format!("/api/ui/apm/flow-map?query={query}&limit={limit}&start={from_ts}&end={to_ts}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -105,12 +105,12 @@ pub async fn troubleshooting_list( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let data = client::raw_get(cfg, path, &query).await?; + let data = raw_client::raw_get(cfg, path, &query).await?; formatter::output(cfg, &data) } pub async fn service_remapping_list(cfg: &Config) -> Result<()> { - let data = client::raw_get(cfg, "/api/v2/service-naming-rules", &[]).await?; + let data = raw_client::raw_get(cfg, "/api/v2/service-naming-rules", &[]).await?; formatter::output(cfg, &data) } @@ -132,12 +132,12 @@ pub async fn service_remapping_create( } } }); - let data = client::raw_post(cfg, "/api/v2/service-naming-rules", body).await?; + let data = raw_client::raw_post(cfg, "/api/v2/service-naming-rules", body).await?; formatter::output(cfg, &data) } pub async fn service_remapping_get(cfg: &Config, id: String) -> Result<()> { - let data = client::raw_get(cfg, &format!("/api/v2/service-naming-rules/{id}"), &[]).await?; + let data = raw_client::raw_get(cfg, &format!("/api/v2/service-naming-rules/{id}"), &[]).await?; formatter::output(cfg, &data) } @@ -162,12 +162,13 @@ pub async fn service_remapping_update( } } }); - let data = client::raw_put(cfg, &format!("/api/v2/service-naming-rules/{id}"), body).await?; + let data = + raw_client::raw_put(cfg, &format!("/api/v2/service-naming-rules/{id}"), body).await?; formatter::output(cfg, &data) } pub async fn service_remapping_delete(cfg: &Config, id: String, version: i64) -> Result<()> { - client::raw_delete(cfg, &format!("/api/v2/service-naming-rules/{id}/{version}")).await + raw_client::raw_delete(cfg, &format!("/api/v2/service-naming-rules/{id}/{version}")).await } // ============================================================================= @@ -186,15 +187,15 @@ pub async fn sampling_rules_list( // If service + env are both given, prefer the narrowed by_target endpoint. if let (Some(svc), Some(e)) = (service.as_deref(), env.as_deref()) { let path = format!("{SAMPLING_RULES_BASE}/by_target"); - let data = client::raw_get(cfg, &path, &[("service", svc), ("env", e)]).await?; + let data = raw_client::raw_get(cfg, &path, &[("service", svc), ("env", e)]).await?; return formatter::output(cfg, &data); } - let data = client::raw_get(cfg, SAMPLING_RULES_BASE, &[]).await?; + let data = raw_client::raw_get(cfg, SAMPLING_RULES_BASE, &[]).await?; formatter::output(cfg, &data) } pub async fn sampling_rules_get(cfg: &Config, id: String) -> Result<()> { - let data = client::raw_get(cfg, &format!("{SAMPLING_RULES_BASE}/{id}"), &[]).await?; + let data = raw_client::raw_get(cfg, &format!("{SAMPLING_RULES_BASE}/{id}"), &[]).await?; formatter::output(cfg, &data) } @@ -229,7 +230,7 @@ pub async fn sampling_rules_create( } } }); - let data = client::raw_post(cfg, SAMPLING_RULES_BASE, body).await?; + let data = raw_client::raw_post(cfg, SAMPLING_RULES_BASE, body).await?; formatter::output(cfg, &data) } @@ -266,12 +267,12 @@ pub async fn sampling_rules_update( } } }); - let data = client::raw_put(cfg, &format!("{SAMPLING_RULES_BASE}/{id}"), body).await?; + let data = raw_client::raw_put(cfg, &format!("{SAMPLING_RULES_BASE}/{id}"), body).await?; formatter::output(cfg, &data) } pub async fn sampling_rules_delete(cfg: &Config, id: String) -> Result<()> { - client::raw_delete(cfg, &format!("{SAMPLING_RULES_BASE}/{id}")).await + raw_client::raw_delete(cfg, &format!("{SAMPLING_RULES_BASE}/{id}")).await } // ============================================================================= @@ -315,7 +316,7 @@ pub async fn adaptive_sampling_onboarding_status( if let Some(e) = env.as_deref() { params.push(("env", e)); } - let data = client::raw_get(cfg, &path, ¶ms).await?; + let data = raw_client::raw_get(cfg, &path, ¶ms).await?; formatter::output(cfg, &data) } @@ -336,7 +337,7 @@ async fn post_onboarding( } } }); - let data = client::raw_post( + let data = raw_client::raw_post( cfg, &format!("{ADAPTIVE_SAMPLING_BASE}/onboarding_status"), body, @@ -355,7 +356,7 @@ pub async fn adaptive_sampling_offboard(cfg: &Config, service: String, env: Stri pub async fn adaptive_sampling_get_allotment(cfg: &Config) -> Result<()> { let path = format!("{ADAPTIVE_SAMPLING_BASE}/allotment_config"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -372,7 +373,7 @@ pub async fn adaptive_sampling_set_allotment( "attributes": attrs, } }); - let data = client::raw_post( + let data = raw_client::raw_post( cfg, &format!("{ADAPTIVE_SAMPLING_BASE}/allotment_config"), body, @@ -383,7 +384,7 @@ pub async fn adaptive_sampling_set_allotment( pub async fn adaptive_sampling_check(cfg: &Config) -> Result<()> { let path = format!("{ADAPTIVE_SAMPLING_BASE}/allotment_check"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } @@ -400,7 +401,8 @@ pub async fn adaptive_sampling_preview( "attributes": attrs, } }); - let data = client::raw_post(cfg, &format!("{ADAPTIVE_SAMPLING_BASE}/preview"), body).await?; + let data = + raw_client::raw_post(cfg, &format!("{ADAPTIVE_SAMPLING_BASE}/preview"), body).await?; formatter::output(cfg, &data) } @@ -421,7 +423,7 @@ pub async fn service_config_get( ids_owned = ids.clone(); query.push(("service_instance_ids", ids_owned.as_str())); } - let data = client::raw_get(cfg, "/api/unstable/apm/service-config", &query).await?; + let data = raw_client::raw_get(cfg, "/api/unstable/apm/service-config", &query).await?; formatter::output(cfg, &data) } @@ -446,7 +448,7 @@ pub async fn service_library_config_get( if mixed { query.push(("is_mixed", "true")); } - let data = client::raw_get(cfg, "/api/unstable/apm/service-library-config", &query).await?; + let data = raw_client::raw_get(cfg, "/api/unstable/apm/service-library-config", &query).await?; formatter::output(cfg, &data) } diff --git a/src/commands/app_builder.rs b/src/commands/app_builder.rs index 437bcf80..b487e12d 100644 --- a/src/commands/app_builder.rs +++ b/src/commands/app_builder.rs @@ -8,6 +8,7 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; pub async fn list(cfg: &Config, query: Option<&str>) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); @@ -24,7 +25,7 @@ pub async fn list(cfg: &Config, query: Option<&str>) -> Result<()> { pub async fn get(cfg: &Config, app_id: &str) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); - let uuid = util::parse_uuid(app_id, "app")?; + let uuid = util_ext::parse_uuid(app_id, "app")?; let resp = api .get_app(uuid, Default::default()) .await @@ -44,7 +45,7 @@ pub async fn create(cfg: &Config, file: &str) -> Result<()> { pub async fn update(cfg: &Config, app_id: &str, file: &str) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); - let uuid = util::parse_uuid(app_id, "app")?; + let uuid = util_ext::parse_uuid(app_id, "app")?; let body: UpdateAppRequest = util::read_json_file(file)?; let resp = api .update_app(uuid, body) @@ -55,7 +56,7 @@ pub async fn update(cfg: &Config, app_id: &str, file: &str) -> Result<()> { pub async fn delete(cfg: &Config, app_id: &str) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); - let uuid = util::parse_uuid(app_id, "app")?; + let uuid = util_ext::parse_uuid(app_id, "app")?; api.delete_app(uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete app: {e:?}"))?; @@ -68,7 +69,7 @@ pub async fn delete_batch(cfg: &Config, app_ids: &[String]) -> Result<()> { let items: Result> = app_ids .iter() .map(|id| { - let uuid = util::parse_uuid(id, "app")?; + let uuid = util_ext::parse_uuid(id, "app")?; Ok(DeleteAppsRequestDataItems::new( uuid, AppDefinitionType::APPDEFINITIONS, @@ -85,7 +86,7 @@ pub async fn delete_batch(cfg: &Config, app_ids: &[String]) -> Result<()> { pub async fn publish(cfg: &Config, app_id: &str) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); - let uuid = util::parse_uuid(app_id, "app")?; + let uuid = util_ext::parse_uuid(app_id, "app")?; let resp = api .publish_app(uuid) .await @@ -95,7 +96,7 @@ pub async fn publish(cfg: &Config, app_id: &str) -> Result<()> { pub async fn unpublish(cfg: &Config, app_id: &str) -> Result<()> { let api = crate::make_api!(AppBuilderAPI, cfg); - let uuid = util::parse_uuid(app_id, "app")?; + let uuid = util_ext::parse_uuid(app_id, "app")?; api.unpublish_app(uuid) .await .map_err(|e| anyhow::anyhow!("failed to unpublish app: {e:?}"))?; diff --git a/src/commands/audit_logs.rs b/src/commands/audit_logs.rs index 4b333063..ae1e08b5 100644 --- a/src/commands/audit_logs.rs +++ b/src/commands/audit_logs.rs @@ -8,13 +8,13 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; pub async fn list(cfg: &Config, from: String, to: String, limit: i32) -> Result<()> { let api = crate::make_api!(AuditAPI, cfg); - let from_dt = util::parse_time_to_datetime(&from)?; - let to_dt = util::parse_time_to_datetime(&to)?; + let from_dt = util_ext::parse_time_to_datetime(&from)?; + let to_dt = util_ext::parse_time_to_datetime(&to)?; let params = ListAuditLogsOptionalParams::default() .filter_from(from_dt) @@ -37,8 +37,8 @@ pub async fn search( ) -> Result<()> { let api = crate::make_api!(AuditAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let from_str = chrono::DateTime::from_timestamp_millis(from_ms) .unwrap() diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 11dd75e0..8bb35d2b 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -475,10 +475,10 @@ pub fn status(cfg: &Config) -> Result<()> { /// OAuth2 tokens are stored. API-key and bearer-token credentials are /// surfaced as authenticated so agents that wrap pup don't conclude auth is /// broken when API keys are working fine. Auth-type precedence is delegated -/// to `client::get_auth_type` so this command can never disagree with the +/// to `raw_raw_client::get_auth_type` so this command can never disagree with the /// auth headers the client actually sends. fn build_non_oauth_status(cfg: &Config) -> (String, serde_json::Value) { - use crate::client::{get_auth_type, AuthType}; + use crate::raw_client::{get_auth_type, AuthType}; let site = &cfg.site; let org = cfg.org.as_deref(); @@ -870,7 +870,7 @@ mod tests { #[test] fn test_build_non_oauth_status_bearer_takes_precedence_over_api_keys() { // When DD_ACCESS_TOKEN and DD_API_KEY/DD_APP_KEY are both set, the - // client uses the bearer token (see client::get_auth_type). Status + // client uses the bearer token (see raw_client::get_auth_type). Status // should reflect the same precedence so the reported auth method // matches what's actually being sent on the wire. let mut cfg = base_config(); diff --git a/src/commands/cases.rs b/src/commands/cases.rs index 65adb5a0..30ca5e7d 100644 --- a/src/commands/cases.rs +++ b/src/commands/cases.rs @@ -16,9 +16,9 @@ use datadog_api_client::datadogV2::model::{ ServiceNowTicketCreateRequest, }; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; // --------------------------------------------------------------------------- // Helpers @@ -164,7 +164,7 @@ pub async fn comments_update( } }); let payload_bytes = serde_json::to_vec(&payload)?; - client::raw_request( + raw_client::raw_request( cfg, "PUT", &path, @@ -214,7 +214,7 @@ pub async fn timeline(cfg: &Config, case_id: &str) -> Result<()> { async fn fetch_timeline(cfg: &Config, case_id: &str) -> Result { let path = format!("/api/v2/cases/{case_id}/timelines"); - client::raw_get(cfg, &path, &[]) + raw_client::raw_get(cfg, &path, &[]) .await .map_err(|e| anyhow::anyhow!("failed to get case timeline: {e:?}")) } diff --git a/src/commands/change_stories.rs b/src/commands/change_stories.rs index 021f045c..0b6eb041 100644 --- a/src/commands/change_stories.rs +++ b/src/commands/change_stories.rs @@ -1,9 +1,9 @@ use anyhow::Result; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; #[allow(clippy::too_many_arguments)] pub async fn list( @@ -16,10 +16,10 @@ pub async fn list( filter_tags: Option, token_limit: Option, ) -> Result<()> { - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from: {e}"))?; - let to_ms = - util::parse_time_to_unix_millis(&to).map_err(|e| anyhow::anyhow!("invalid --to: {e}"))?; + let to_ms = util_ext::parse_time_to_unix_millis(&to) + .map_err(|e| anyhow::anyhow!("invalid --to: {e}"))?; let from_ms_str = from_ms.to_string(); let to_ms_str = to_ms.to_string(); @@ -46,7 +46,7 @@ pub async fn list( } let q_refs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (*k, v.as_str())).collect(); - let data = client::raw_get(cfg, "/api/unstable/change-stories/cli", &q_refs).await?; + let data = raw_client::raw_get(cfg, "/api/unstable/change-stories/cli", &q_refs).await?; let count = data .get("stories") diff --git a/src/commands/cicd.rs b/src/commands/cicd.rs index 4d937225..a91fcb22 100644 --- a/src/commands/cicd.rs +++ b/src/commands/cicd.rs @@ -19,7 +19,7 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; pub async fn pipelines_list( cfg: &Config, @@ -32,8 +32,8 @@ pub async fn pipelines_list( ) -> Result<()> { let api = crate::make_api!(CIVisibilityPipelinesAPI, cfg); - let from_str = util::parse_time_to_datetime(&from)?.to_rfc3339(); - let to_str = util::parse_time_to_datetime(&to)?.to_rfc3339(); + let from_str = util_ext::parse_time_to_datetime(&from)?.to_rfc3339(); + let to_str = util_ext::parse_time_to_datetime(&to)?.to_rfc3339(); let mut query_parts: Vec = Vec::new(); if let Some(q) = query { @@ -73,8 +73,8 @@ pub async fn tests_list( ) -> Result<()> { let api = crate::make_api!(CIVisibilityTestsAPI, cfg); - let from_dt = util::parse_time_to_datetime(&from)?; - let to_dt = util::parse_time_to_datetime(&to)?; + let from_dt = util_ext::parse_time_to_datetime(&from)?; + let to_dt = util_ext::parse_time_to_datetime(&to)?; let mut params = ListCIAppTestEventsOptionalParams::default() .filter_from(from_dt) @@ -102,8 +102,8 @@ pub async fn events_search( ) -> Result<()> { let api = crate::make_api!(CIVisibilityPipelinesAPI, cfg); - let from_str = util::parse_time_to_datetime(&from)?.to_rfc3339(); - let to_str = util::parse_time_to_datetime(&to)?.to_rfc3339(); + let from_str = util_ext::parse_time_to_datetime(&from)?.to_rfc3339(); + let to_str = util_ext::parse_time_to_datetime(&to)?.to_rfc3339(); let sort_val = match sort.as_str() { "asc" | "timestamp" => CIAppSort::TIMESTAMP_ASCENDING, @@ -154,8 +154,8 @@ pub async fn events_aggregate( ) -> Result<()> { let api = crate::make_api!(CIVisibilityPipelinesAPI, cfg); - let from_str = util::parse_time_to_datetime(&from)?.to_rfc3339(); - let to_str = util::parse_time_to_datetime(&to)?.to_rfc3339(); + let from_str = util_ext::parse_time_to_datetime(&from)?.to_rfc3339(); + let to_str = util_ext::parse_time_to_datetime(&to)?.to_rfc3339(); let compute_spec = build_ci_compute_spec(&compute)?; let filter = CIAppPipelinesQueryFilter::new() @@ -187,8 +187,8 @@ pub async fn tests_search( ) -> Result<()> { let api = crate::make_api!(CIVisibilityTestsAPI, cfg); - let from_str = util::parse_time_to_datetime(&from)?.to_rfc3339(); - let to_str = util::parse_time_to_datetime(&to)?.to_rfc3339(); + let from_str = util_ext::parse_time_to_datetime(&from)?.to_rfc3339(); + let to_str = util_ext::parse_time_to_datetime(&to)?.to_rfc3339(); let filter = CIAppTestsQueryFilter::new() .from(from_str) @@ -219,8 +219,8 @@ pub async fn tests_aggregate( ) -> Result<()> { let api = crate::make_api!(CIVisibilityTestsAPI, cfg); - let from_str = util::parse_time_to_datetime(&from)?.to_rfc3339(); - let to_str = util::parse_time_to_datetime(&to)?.to_rfc3339(); + let from_str = util_ext::parse_time_to_datetime(&from)?.to_rfc3339(); + let to_str = util_ext::parse_time_to_datetime(&to)?.to_rfc3339(); let compute_spec = build_ci_compute_spec(&compute)?; let filter = CIAppTestsQueryFilter::new() @@ -339,7 +339,7 @@ pub async fn flaky_tests_update(cfg: &Config, file: &str) -> Result<()> { } fn parse_ci_agg(compute: &str) -> Result<(CIAppAggregationFunction, Option)> { - let (func, metric) = util::parse_compute_raw(compute)?; + let (func, metric) = util_ext::parse_compute_raw(compute)?; let agg = match func.as_str() { "count" => CIAppAggregationFunction::COUNT, "avg" => CIAppAggregationFunction::AVG, diff --git a/src/commands/cost.rs b/src/commands/cost.rs index a0fdbeae..18eafb30 100644 --- a/src/commands/cost.rs +++ b/src/commands/cost.rs @@ -10,6 +10,7 @@ use datadog_api_client::datadogV2::api_usage_metering::{ use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; fn make_usage_api(cfg: &Config) -> UsageMeteringV2API { crate::make_api!(UsageMeteringV2API, cfg) @@ -27,11 +28,11 @@ pub async fn projected(cfg: &Config) -> Result<()> { pub async fn by_org(cfg: &Config, start_month: String, end_month: Option) -> Result<()> { let api = make_usage_api(cfg); - let start_dt = util::parse_time_to_datetime(&start_month)?; + let start_dt = util_ext::parse_time_to_datetime(&start_month)?; let mut params = GetCostByOrgOptionalParams::default(); if let Some(e) = end_month { - let end_dt = util::parse_time_to_datetime(&e)?; + let end_dt = util_ext::parse_time_to_datetime(&e)?; params = params.end_month(end_dt); } @@ -45,7 +46,7 @@ pub async fn by_org(cfg: &Config, start_month: String, end_month: Option pub async fn attribution(cfg: &Config, start: String, fields: Option) -> Result<()> { let api = make_usage_api(cfg); - let start_dt = util::parse_time_to_datetime(&start)?; + let start_dt = util_ext::parse_time_to_datetime(&start)?; let fields_str = fields.unwrap_or_else(|| "*".to_string()); let params = GetMonthlyCostAttributionOptionalParams::default(); diff --git a/src/commands/cost_ccm.rs b/src/commands/cost_ccm.rs index 87aa0574..9dc4cdbd 100644 --- a/src/commands/cost_ccm.rs +++ b/src/commands/cost_ccm.rs @@ -1,9 +1,10 @@ use anyhow::Result; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; use crate::util; +use crate::util_ext; // ---- Custom Costs ---- @@ -27,16 +28,16 @@ pub async fn custom_costs_list( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, "/api/v2/cost/custom_costs", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/custom_costs", &q).await?; formatter::output(cfg, &value) } pub async fn custom_costs_get(cfg: &Config, file_id: &str) -> Result<()> { let path = format!( "/api/v2/cost/custom_costs/{}", - util::percent_encode(file_id) + util_ext::percent_encode(file_id) ); - let value = client::raw_get(cfg, &path, &[]).await?; + let value = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &value) } @@ -72,7 +73,7 @@ pub async fn custom_costs_upload(cfg: &Config, file: &str, version: Option Result<()> { let path = format!( "/api/v2/cost/custom_costs/{}", - util::percent_encode(file_id) + util_ext::percent_encode(file_id) ); - client::raw_delete(cfg, &path).await?; + raw_client::raw_delete(cfg, &path).await?; eprintln!("Custom cost file '{file_id}' deleted."); Ok(()) } @@ -114,7 +115,7 @@ pub async fn tag_desc_list(cfg: &Config, cloud: Option) -> Result<()> { .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, "/api/v2/cost/tag_descriptions", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/tag_descriptions", &q).await?; formatter::output(cfg, &value) } @@ -127,13 +128,13 @@ pub async fn tag_desc_get(cfg: &Config, tag_key: &str, cloud: Option) -> .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, "/api/v2/cost/tag_description", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/tag_description", &q).await?; formatter::output(cfg, &value) } pub async fn tag_desc_generate(cfg: &Config, tag_key: &str) -> Result<()> { let q = [("tag_key", tag_key)]; - let value = client::raw_get(cfg, "/api/v2/cost/tag_description/generate", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/tag_description/generate", &q).await?; formatter::output(cfg, &value) } @@ -154,7 +155,7 @@ pub async fn tag_desc_upsert( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let resp = client::raw_request( + let resp = raw_client::raw_request( cfg, "PUT", "/api/v2/cost/tag_descriptions", @@ -183,7 +184,7 @@ pub async fn tag_desc_delete(cfg: &Config, tag_key: &str, cloud: Option) .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let resp = client::raw_request( + let resp = raw_client::raw_request( cfg, "DELETE", "/api/v2/cost/tag_descriptions", @@ -219,7 +220,7 @@ async fn tag_meta_get( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, sub_path, &q).await?; + let value = raw_client::raw_get(cfg, sub_path, &q).await?; formatter::output(cfg, &value) } @@ -248,7 +249,7 @@ pub async fn tag_meta_list( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, "/api/v2/cost/tag_metadata", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/tag_metadata", &q).await?; formatter::output(cfg, &value) } @@ -306,7 +307,7 @@ pub async fn tags_list( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, "/api/v2/cost/tags", &q).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/tags", &q).await?; formatter::output(cfg, &value) } @@ -324,12 +325,12 @@ pub async fn tag_keys_list(cfg: &Config, metric: Option, tags: Vec) -> Result<()> { - let path = format!("/api/v2/cost/tag_keys/{}", util::percent_encode(key)); + let path = format!("/api/v2/cost/tag_keys/{}", util_ext::percent_encode(key)); let mut params: Vec<(String, String)> = Vec::new(); if let Some(m) = metric { params.push(("filter[metric]".into(), m)); @@ -338,14 +339,14 @@ pub async fn tag_keys_get(cfg: &Config, key: &str, metric: Option) -> Re .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, &path, &q).await?; + let value = raw_client::raw_get(cfg, &path, &q).await?; formatter::output(cfg, &value) } // ---- Budgets ---- pub async fn budgets_list(cfg: &Config) -> Result<()> { - let value = client::raw_get(cfg, "/api/v2/cost/budgets", &[]).await?; + let value = raw_client::raw_get(cfg, "/api/v2/cost/budgets", &[]).await?; formatter::output(cfg, &value) } @@ -357,17 +358,20 @@ pub async fn budgets_get( actual: bool, forecast: bool, ) -> Result<()> { - let path = format!("/api/v2/cost/budget/{}", util::percent_encode(budget_id)); + let path = format!( + "/api/v2/cost/budget/{}", + util_ext::percent_encode(budget_id) + ); let mut params: Vec<(String, String)> = Vec::new(); match (start, end) { (Some(s), Some(e)) => { params.push(( "start".into(), - util::parse_time_to_unix_millis(&s)?.to_string(), + util_ext::parse_time_to_unix_millis(&s)?.to_string(), )); params.push(( "end".into(), - util::parse_time_to_unix_millis(&e)?.to_string(), + util_ext::parse_time_to_unix_millis(&e)?.to_string(), )); } (None, None) => {} @@ -384,7 +388,7 @@ pub async fn budgets_get( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, &path, &q).await?; + let value = raw_client::raw_get(cfg, &path, &q).await?; formatter::output(cfg, &value) } @@ -392,7 +396,7 @@ pub async fn budgets_upsert(cfg: &Config, file: &str) -> Result<()> { let body: serde_json::Value = util::read_json_file(file)?; let body_bytes = serde_json::to_vec(&body).map_err(|e| anyhow::anyhow!("failed to serialize: {e}"))?; - let resp = client::raw_request( + let resp = raw_client::raw_request( cfg, "PUT", "/api/v2/cost/budget", @@ -413,15 +417,18 @@ pub async fn budgets_upsert(cfg: &Config, file: &str) -> Result<()> { } pub async fn budgets_delete(cfg: &Config, budget_id: &str) -> Result<()> { - let path = format!("/api/v2/cost/budget/{}", util::percent_encode(budget_id)); - client::raw_delete(cfg, &path).await?; + let path = format!( + "/api/v2/cost/budget/{}", + util_ext::percent_encode(budget_id) + ); + raw_client::raw_delete(cfg, &path).await?; eprintln!("Budget '{budget_id}' deleted."); Ok(()) } pub async fn budgets_validate(cfg: &Config, file: &str) -> Result<()> { let body: serde_json::Value = util::read_json_file(file)?; - let value = client::raw_post(cfg, "/api/v2/cost/budget/validate", body).await?; + let value = raw_client::raw_post(cfg, "/api/v2/cost/budget/validate", body).await?; formatter::output(cfg, &value) } @@ -459,7 +466,7 @@ async fn commitment_call(cfg: &Config, path: &str, q: &CommitmentQuery<'_>) -> R .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let value = client::raw_get(cfg, path, &refs).await?; + let value = raw_client::raw_get(cfg, path, &refs).await?; formatter::output(cfg, &value) } @@ -472,8 +479,8 @@ fn parse_commitment_query<'a>( commitment_type: &'a Option, filter_by: &'a Option, ) -> anyhow::Result> { - let from_ms = util::parse_time_to_unix_millis(from)?; - let to_ms = util::parse_time_to_unix_millis(to)?; + let from_ms = util_ext::parse_time_to_unix_millis(from)?; + let to_ms = util_ext::parse_time_to_unix_millis(to)?; Ok(CommitmentQuery { provider, product, diff --git a/src/commands/dbm.rs b/src/commands/dbm.rs index 62e6e05d..a54ff587 100644 --- a/src/commands/dbm.rs +++ b/src/commands/dbm.rs @@ -1,9 +1,9 @@ use anyhow::{bail, Result}; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; fn parse_sort(sort: &str) -> Result<&'static str> { match sort { @@ -50,11 +50,11 @@ pub async fn samples_search( ) -> Result<()> { cfg.validate_auth()?; - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let body = build_search_body(query, from_ms, to_ms, limit, &sort)?; - let resp = client::raw_post(cfg, "/api/v1/logs-analytics/list?type=databasequery", body) + let resp = raw_client::raw_post(cfg, "/api/v1/logs-analytics/list?type=databasequery", body) .await .map_err(|e| anyhow::anyhow!("failed to search DBM query samples: {e:?}"))?; diff --git a/src/commands/ddsql.rs b/src/commands/ddsql.rs index 4c686144..0f8212bd 100644 --- a/src/commands/ddsql.rs +++ b/src/commands/ddsql.rs @@ -4,11 +4,11 @@ use serde_json::{json, Value}; use std::io::{self, Read}; use std::time::Duration; -use crate::client; use crate::config::{Config, OutputFormat}; use crate::formatter; +use crate::raw_client; use crate::useragent; -use crate::util; +use crate::util_ext; use crate::version; fn client_id() -> String { @@ -348,12 +348,12 @@ fn build_public_table_items(table_names: &[String], query: Option<&str>) -> Vec< } async fn get_ddsql_docs(cfg: &Config) -> Result { - let resp = client::raw_get(cfg, DDSQL_DOCS_PATH, &[]).await?; + let resp = raw_client::raw_get(cfg, DDSQL_DOCS_PATH, &[]).await?; parse_ddsql_docs(resp) } async fn get_public_table_names(cfg: &Config) -> Result> { - let resp = client::raw_get(cfg, DDSQL_TABLE_NAMES_PATH, &[]).await?; + let resp = raw_client::raw_get(cfg, DDSQL_TABLE_NAMES_PATH, &[]).await?; parse_table_names(resp) } @@ -380,7 +380,7 @@ async fn search_reference_tables( .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let resp = client::raw_get(cfg, REFERENCE_TABLES_PATH, &refs).await?; + let resp = raw_client::raw_get(cfg, REFERENCE_TABLES_PATH, &refs).await?; let (mut items, next_offset) = parse_reference_table_items(resp)?; items.retain(|item| table_matches_query(&item.name, query)); results.append(&mut items); @@ -412,7 +412,7 @@ async fn get_reference_table_columns( ("page[offset]", offset_param.as_str()), ("filter[table_name_contains]", table_name), ]; - let resp = client::raw_get(cfg, REFERENCE_TABLES_PATH, ¶ms).await?; + let resp = raw_client::raw_get(cfg, REFERENCE_TABLES_PATH, ¶ms).await?; let (tables, next_offset) = parse_reference_tables(resp)?; if let Some(table) = tables @@ -496,7 +496,7 @@ pub async fn schema_columns( let columns = if kind == "reference_table" { get_reference_table_columns(cfg, &table_name).await? } else { - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, DDSQL_TABLE_DATA_PATH, json!({ "tables": [table_name] }), @@ -528,8 +528,9 @@ fn build_advanced_table_request( limit: Option, ) -> Result { let from_ms = - util::parse_time_to_unix_millis(from).map_err(|e| anyhow!("invalid --from: {e}"))?; - let to_ms = util::parse_time_to_unix_millis(to).map_err(|e| anyhow!("invalid --to: {e}"))?; + util_ext::parse_time_to_unix_millis(from).map_err(|e| anyhow!("invalid --from: {e}"))?; + let to_ms = + util_ext::parse_time_to_unix_millis(to).map_err(|e| anyhow!("invalid --to: {e}"))?; let mut query_body = json!({ "dataset": "user_query", @@ -612,7 +613,7 @@ fn build_fetch_request(base_body: &Value, query_id: &str) -> Value { /// to the User-Agent header for audit log differentiation. async fn execute_async_query(cfg: &Config, body: Value, command: Option<&str>) -> Result { let ua = useragent::get_with_command(command); - let resp = client::raw_post_with_ua( + let resp = raw_client::raw_post_with_ua( cfg, "/api/unstable/advanced/query/tabular", body.clone(), @@ -629,7 +630,7 @@ async fn execute_async_query(cfg: &Config, body: Value, command: Option<&str>) - tokio::time::sleep(Duration::from_secs(1)).await; let fetch_body = build_fetch_request(&body, &query_id); - let poll_resp = client::raw_post_with_ua( + let poll_resp = raw_client::raw_post_with_ua( cfg, "/api/unstable/advanced/query/tabular/fetch", fetch_body, diff --git a/src/commands/debugger.rs b/src/commands/debugger.rs index 2a043137..c661cbec 100644 --- a/src/commands/debugger.rs +++ b/src/commands/debugger.rs @@ -6,11 +6,11 @@ use datadog_api_client::datadogV2::model::{ LogsListRequest, LogsListRequestPage, LogsQueryFilter, LogsSort, }; -use crate::client; +use crate::raw_client; use crate::{config::Config, formatter}; async fn post(cfg: &Config, path: &str, body: serde_json::Value) -> Result { - client::raw_post(cfg, path, body).await + raw_client::raw_post(cfg, path, body).await } async fn post_lenient( @@ -18,15 +18,15 @@ async fn post_lenient( path: &str, body: serde_json::Value, ) -> Result { - client::raw_post_lenient(cfg, path, body).await + raw_client::raw_post_lenient(cfg, path, body).await } async fn delete(cfg: &Config, path: &str) -> Result<()> { - client::raw_delete(cfg, path).await + raw_client::raw_delete(cfg, path).await } async fn get(cfg: &Config, path: &str, query: &[(&str, &str)]) -> Result { - client::raw_get(cfg, path, query).await + raw_client::raw_get(cfg, path, query).await } fn extract_api_errors(resp: &serde_json::Value) -> Option { @@ -286,7 +286,7 @@ pub async fn probes_create(cfg: &Config, params: ProbeCreateParams<'_>) -> Resul fields, } = params; let expires_ms = if let Some(ttl_str) = ttl { - Some(crate::util::now_millis() + crate::util::parse_duration_to_millis(ttl_str)?) + Some(crate::util_ext::now_millis() + crate::util_ext::parse_duration_to_millis(ttl_str)?) } else { None }; @@ -455,7 +455,7 @@ pub async fn probes_watch( let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait); let mut found = false; loop { - if let Ok(data) = client::raw_get(cfg, &status_path, &[]).await { + if let Ok(data) = raw_client::raw_get(cfg, &status_path, &[]).await { if data["data"].as_array().and_then(|a| a.first()).is_some() { found = true; break; @@ -477,7 +477,7 @@ pub async fn probes_watch( } let from_ms_init = if let Some(f) = from { - crate::util::parse_time_to_unix_millis(f)? + crate::util_ext::parse_time_to_unix_millis(f)? } else { chrono::Utc::now().timestamp_millis() }; @@ -510,7 +510,7 @@ pub async fn probes_watch( return Ok(()); } _ = status_interval.tick() => { - match client::raw_get(cfg, &status_path, &[]).await { + match raw_client::raw_get(cfg, &status_path, &[]).await { Ok(data) => { consecutive_errors = 0; if let Some(entry) = data["data"].as_array().and_then(|a| a.first()) { diff --git a/src/commands/error_tracking.rs b/src/commands/error_tracking.rs index 75ca9bf7..7844b779 100644 --- a/src/commands/error_tracking.rs +++ b/src/commands/error_tracking.rs @@ -10,7 +10,7 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; #[allow(clippy::too_many_arguments)] pub async fn issues_search( @@ -28,8 +28,8 @@ pub async fn issues_search( ) -> Result<()> { let api = crate::make_api!(ErrorTrackingAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let order_by_val = match order_by.to_uppercase().as_str() { "TOTAL_COUNT" => IssuesSearchRequestDataAttributesOrderBy::TOTAL_COUNT, diff --git a/src/commands/events.rs b/src/commands/events.rs index 02a0466d..5670824a 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -16,7 +16,7 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; #[derive(Clone, Debug, clap::ValueEnum)] pub(crate) enum EventAlertTypeArg { @@ -153,7 +153,7 @@ fn resolve_message( "no event message provided: pass it as an argument or pipe it via stdin" ); } - let buf = util::read_to_string(reader, "failed to read event message from stdin")?; + let buf = util_ext::read_to_string(reader, "failed to read event message from stdin")?; // Drop the trailing newline shells add to piped input so stdin and // argument messages produce the same event text. buf.trim_end().to_owned() @@ -236,8 +236,8 @@ pub async fn search( ) -> Result<()> { let api = crate::make_api!(EventsV2API, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let from_str = chrono::DateTime::from_timestamp_millis(from_ms) .unwrap() diff --git a/src/commands/format.rs b/src/commands/format.rs index 2eef7f4f..592c8e62 100644 --- a/src/commands/format.rs +++ b/src/commands/format.rs @@ -32,7 +32,7 @@ fn read_input(input: Option<&str>, reader: impl Read) -> Result { match input { Some(path) if path != "-" => std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("failed to read --input {path:?}: {e}")), - _ => crate::util::read_to_string(reader, "failed to read JSON from stdin"), + _ => crate::util_ext::read_to_string(reader, "failed to read JSON from stdin"), } } diff --git a/src/commands/idp/mod.rs b/src/commands/idp/mod.rs index e25ff6ff..0b453d34 100644 --- a/src/commands/idp/mod.rs +++ b/src/commands/idp/mod.rs @@ -1,10 +1,10 @@ use anyhow::Result; use serde::Serialize; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; mod migrate; pub use migrate::migrate_schema; @@ -206,7 +206,7 @@ async fn fetch_on_call(cfg: &Config, team_id: &str) -> Option { let path = format!( "/api/v2/on-call/teams/{team_id}/on-call?include=responders,escalations.responders" ); - let data = client::raw_get(cfg, &path, &[]).await.ok()?; + let data = raw_client::raw_get(cfg, &path, &[]).await.ok()?; let included = data.get("included")?.as_array()?; // Primary responders come from data.relationships.responders @@ -432,7 +432,7 @@ fn parse_dependencies(deps_data: &serde_json::Value, entity: &str) -> (Vec String { - let query = util::percent_encode(&format!("kind:service AND name:{entity}")); + let query = util_ext::percent_encode(&format!("kind:service AND name:{entity}")); let mut url = format!("/api/v2/idp/entity_graph/entities?query={query}&page%5Blimit%5D=1"); if !include.is_empty() { url.push_str(&format!("&include={include}")); @@ -451,8 +451,8 @@ pub async fn assist(cfg: &Config, entity: &str) -> Result<()> { let deps_path = "/api/v1/service_dependencies?env=prod"; let (entity_res, deps_res) = tokio::join!( - client::raw_get(cfg, &entity_path, &[]), - client::raw_get(cfg, deps_path, &[]), + raw_client::raw_get(cfg, &entity_path, &[]), + raw_client::raw_get(cfg, deps_path, &[]), ); let entity_data = entity_res?; @@ -534,9 +534,9 @@ pub async fn find(cfg: &Config, query: &str) -> Result<()> { } else { format!("kind:service AND name:*{query}*") }; - let encoded = util::percent_encode(&full_query); + let encoded = util_ext::percent_encode(&full_query); let path = format!("/api/v2/idp/entity_graph/entities?query={encoded}&page%5Blimit%5D=10"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; let meta = formatter::Metadata { count: data.get("data").and_then(|d| d.as_array()).map(|a| a.len()), @@ -559,7 +559,7 @@ pub async fn find(cfg: &Config, query: &str) -> Result<()> { /// Resolve owner, team, and on-call context for an entity. pub async fn owner(cfg: &Config, entity: &str) -> Result<()> { let path = entity_query_url(entity, "owner_teams"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; let entities = data .get("data") @@ -611,7 +611,7 @@ pub async fn owner(cfg: &Config, entity: &str) -> Result<()> { /// Show dependency and relationship context for an entity. pub async fn deps(cfg: &Config, entity: &str) -> Result<()> { let deps_path = "/api/v1/service_dependencies?env=prod"; - let deps_data = client::raw_get(cfg, deps_path, &[]).await?; + let deps_data = raw_client::raw_get(cfg, deps_path, &[]).await?; let (upstream, downstream) = parse_dependencies(&deps_data, entity); let response = serde_json::json!({ @@ -647,7 +647,7 @@ pub async fn register(cfg: &Config, file: &str) -> Result<()> { let yaml_value: serde_json::Value = serde_norway::from_str(&content) .map_err(|e| anyhow::anyhow!("failed to parse YAML in {file}: {e}"))?; - let data = client::raw_post(cfg, "/api/v2/services/definitions", yaml_value).await?; + let data = raw_client::raw_post(cfg, "/api/v2/services/definitions", yaml_value).await?; let service_name = content .lines() diff --git a/src/commands/integrations.rs b/src/commands/integrations.rs index cc28d319..52d33361 100644 --- a/src/commands/integrations.rs +++ b/src/commands/integrations.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; use anyhow::Result; use datadog_api_client::datadogV1::api_slack_integration::SlackIntegrationAPI; use datadog_api_client::datadogV1::api_webhooks_integration::WebhooksIntegrationAPI; @@ -34,7 +34,7 @@ pub async fn jira_templates_list(cfg: &Config) -> Result<()> { pub async fn jira_templates_get(cfg: &Config, template_id: &str) -> Result<()> { let api = crate::make_api!(JiraIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; let resp = api .get_jira_issue_template(uuid) .await @@ -44,7 +44,7 @@ pub async fn jira_templates_get(cfg: &Config, template_id: &str) -> Result<()> { pub async fn jira_accounts_delete(cfg: &Config, account_id: &str) -> Result<()> { let api = crate::make_api!(JiraIntegrationAPI, cfg); - let uuid = util::parse_uuid(account_id, "account")?; + let uuid = util_ext::parse_uuid(account_id, "account")?; api.delete_jira_account(uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete Jira account: {e:?}"))?; @@ -64,7 +64,7 @@ pub async fn jira_templates_create(cfg: &Config, file: &str) -> Result<()> { pub async fn jira_templates_update(cfg: &Config, template_id: &str, file: &str) -> Result<()> { let api = crate::make_api!(JiraIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; let body: JiraIssueTemplateUpdateRequest = crate::util::read_json_file(file)?; let resp = api .update_jira_issue_template(uuid, body) @@ -75,7 +75,7 @@ pub async fn jira_templates_update(cfg: &Config, template_id: &str, file: &str) pub async fn jira_templates_delete(cfg: &Config, template_id: &str) -> Result<()> { let api = crate::make_api!(JiraIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; api.delete_jira_issue_template(uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete Jira template: {e:?}"))?; @@ -105,7 +105,7 @@ pub async fn servicenow_templates_list(cfg: &Config) -> Result<()> { pub async fn servicenow_templates_get(cfg: &Config, template_id: &str) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; let resp = api .get_service_now_template(uuid) .await @@ -129,7 +129,7 @@ pub async fn servicenow_templates_update( file: &str, ) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; let body: ServiceNowTemplateUpdateRequest = crate::util::read_json_file(file)?; let resp = api .update_service_now_template(uuid, body) @@ -140,7 +140,7 @@ pub async fn servicenow_templates_update( pub async fn servicenow_templates_delete(cfg: &Config, template_id: &str) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); - let uuid = util::parse_uuid(template_id, "template")?; + let uuid = util_ext::parse_uuid(template_id, "template")?; api.delete_service_now_template(uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete ServiceNow template: {e:?}"))?; @@ -151,7 +151,7 @@ pub async fn servicenow_templates_delete(cfg: &Config, template_id: &str) -> Res pub async fn servicenow_users_list(cfg: &Config, instance_name: &str) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); let resp = api - .list_service_now_users(util::parse_uuid(instance_name, "instance")?) + .list_service_now_users(util_ext::parse_uuid(instance_name, "instance")?) .await .map_err(|e| anyhow::anyhow!("failed to list ServiceNow users: {e:?}"))?; formatter::output(cfg, &resp) @@ -160,7 +160,7 @@ pub async fn servicenow_users_list(cfg: &Config, instance_name: &str) -> Result< pub async fn servicenow_assignment_groups_list(cfg: &Config, instance_name: &str) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); let resp = api - .list_service_now_assignment_groups(util::parse_uuid(instance_name, "instance")?) + .list_service_now_assignment_groups(util_ext::parse_uuid(instance_name, "instance")?) .await .map_err(|e| anyhow::anyhow!("failed to list ServiceNow assignment groups: {e:?}"))?; formatter::output(cfg, &resp) @@ -169,7 +169,7 @@ pub async fn servicenow_assignment_groups_list(cfg: &Config, instance_name: &str pub async fn servicenow_business_services_list(cfg: &Config, instance_name: &str) -> Result<()> { let api = crate::make_api!(ServiceNowIntegrationAPI, cfg); let resp = api - .list_service_now_business_services(util::parse_uuid(instance_name, "instance")?) + .list_service_now_business_services(util_ext::parse_uuid(instance_name, "instance")?) .await .map_err(|e| anyhow::anyhow!("failed to list ServiceNow business services: {e:?}"))?; formatter::output(cfg, &resp) diff --git a/src/commands/kafka.rs b/src/commands/kafka.rs index f1609611..80db386c 100644 --- a/src/commands/kafka.rs +++ b/src/commands/kafka.rs @@ -10,9 +10,9 @@ use anyhow::Result; use serde_json::{json, Value}; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; const TOPIC_CONFIGS_PATH: &str = "/api/ui/data_streams/kafka_topic_configs"; const BROKER_CONFIGS_PATH: &str = "/api/ui/data_streams/kafka_broker_configs"; @@ -22,7 +22,7 @@ const SUBJECT_SCHEMAS_PATH: &str = "/api/ui/data_streams/subject_kafka_schemas"; pub async fn topic_configs(cfg: &Config, kafka_cluster_id: &str, topic: &str) -> Result<()> { let query = [("kafka_cluster_id", kafka_cluster_id), ("topic", topic)]; - let resp = client::raw_get(cfg, TOPIC_CONFIGS_PATH, &query) + let resp = raw_client::raw_get(cfg, TOPIC_CONFIGS_PATH, &query) .await .map_err(|e| anyhow::anyhow!("failed to get kafka topic configs: {e:?}"))?; formatter::output(cfg, &resp) @@ -33,7 +33,7 @@ pub async fn broker_configs(cfg: &Config, kafka_cluster_id: &str, broker_id: &st ("kafka_cluster_id", kafka_cluster_id), ("broker_id", broker_id), ]; - let resp = client::raw_get(cfg, BROKER_CONFIGS_PATH, &query) + let resp = raw_client::raw_get(cfg, BROKER_CONFIGS_PATH, &query) .await .map_err(|e| anyhow::anyhow!("failed to get kafka broker configs: {e:?}"))?; formatter::output(cfg, &resp) @@ -57,7 +57,7 @@ pub async fn client_configs( "kafka_cluster_id": kafka_cluster_id, "services": services_json, }); - let resp = client::raw_post(cfg, CLIENT_CONFIGS_PATH, body) + let resp = raw_client::raw_post(cfg, CLIENT_CONFIGS_PATH, body) .await .map_err(|e| anyhow::anyhow!("failed to get kafka client configs: {e:?}"))?; formatter::output(cfg, &resp) @@ -101,7 +101,7 @@ pub async fn read_messages( } let resp = - client::raw_post_jsonapi(cfg, READ_MESSAGES_PATH, "kafka_action_read_messages", attrs) + raw_client::raw_post_jsonapi(cfg, READ_MESSAGES_PATH, "kafka_action_read_messages", attrs) .await .map_err(|e| anyhow::anyhow!("failed to read kafka messages: {e:?}"))?; formatter::output(cfg, &resp) @@ -110,7 +110,7 @@ pub async fn read_messages( /// All version history of a single Schema Registry subject on a Kafka cluster. pub async fn subject_schemas(cfg: &Config, kafka_cluster_id: &str, subject: &str) -> Result<()> { let query = [("kafka_cluster_id", kafka_cluster_id), ("subject", subject)]; - let resp = client::raw_get(cfg, SUBJECT_SCHEMAS_PATH, &query) + let resp = raw_client::raw_get(cfg, SUBJECT_SCHEMAS_PATH, &query) .await .map_err(|e| anyhow::anyhow!("failed to get subject kafka schemas: {e:?}"))?; formatter::output(cfg, &resp) diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index d3d87383..cef56ad2 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -11,10 +11,11 @@ use datadog_api_client::datadogV2::model::{ LLMObsProjectRequest, }; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; use crate::util; +use crate::util_ext; fn make_api(cfg: &Config) -> LLMObservabilityAPI { crate::make_api!(LLMObservabilityAPI, cfg) @@ -33,7 +34,7 @@ pub async fn projects_create(cfg: &Config, file: &str) -> Result<()> { } pub async fn projects_list(cfg: &Config) -> Result<()> { - let resp = client::raw_get(cfg, "/api/v2/llm-obs/v1/projects", &[]) + let resp = raw_client::raw_get(cfg, "/api/v2/llm-obs/v1/projects", &[]) .await .map_err(|e| anyhow::anyhow!("failed to list LLM obs projects: {e:?}"))?; formatter::output(cfg, &resp) @@ -64,7 +65,7 @@ pub async fn experiments_list( query.push(("filter[dataset_id]", did.clone())); } let query_refs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (*k, v.as_str())).collect(); - let resp = client::raw_get(cfg, "/api/v2/llm-obs/v1/experiments", &query_refs) + let resp = raw_client::raw_get(cfg, "/api/v2/llm-obs/v1/experiments", &query_refs) .await .map_err(|e| anyhow::anyhow!("failed to list LLM obs experiments: {e:?}"))?; formatter::output(cfg, &resp) @@ -104,7 +105,7 @@ pub async fn datasets_create(cfg: &Config, project_id: &str, file: &str) -> Resu pub async fn datasets_list(cfg: &Config, project_id: &str) -> Result<()> { let path = format!("/api/v2/llm-obs/v1/{project_id}/datasets"); - let resp = client::raw_get(cfg, &path, &[]) + let resp = raw_client::raw_get(cfg, &path, &[]) .await .map_err(|e| anyhow::anyhow!("failed to list LLM obs datasets: {e:?}"))?; formatter::output(cfg, &resp) @@ -159,7 +160,7 @@ pub async fn datasets_restore( pub async fn experiments_summary(cfg: &Config, experiment_id: &str) -> Result<()> { let body = serde_json::json!({ "experiment_id": experiment_id }); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/summary", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/summary", body) .await .map_err(|e| anyhow::anyhow!("failed to get experiment summary: {e:?}"))?; formatter::output(cfg, &resp) @@ -195,7 +196,7 @@ pub async fn experiments_events_list( if let Some(m) = sort_by_metric { body["sort_by_metric_label"] = serde_json::json!(m); } - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/events", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/events", body) .await .map_err(|e| anyhow::anyhow!("failed to list experiment events: {e:?}"))?; formatter::output(cfg, &resp) @@ -207,7 +208,7 @@ pub async fn experiments_events_get( event_id: &str, ) -> Result<()> { let body = serde_json::json!({ "experiment_id": experiment_id, "event_id": event_id }); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/event", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/experiment/event", body) .await .map_err(|e| anyhow::anyhow!("failed to get experiment event: {e:?}"))?; formatter::output(cfg, &resp) @@ -228,7 +229,7 @@ pub async fn experiments_metric_values( if let Some(v) = segment_dimension_value { body["segment_dimension_value"] = serde_json::json!(v); } - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/experiment/metric-values", body, @@ -245,7 +246,7 @@ pub async fn experiments_dimension_values( ) -> Result<()> { let body = serde_json::json!({ "experiment_id": experiment_id, "dimension_key": dimension_key }); - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/experiment/dimension-values", body, @@ -378,7 +379,7 @@ pub async fn eval_config_delete(cfg: &Config, eval_name: &str) -> Result<()> { // ---- Evals (no typed equivalent — unstable MCP endpoint) ---- pub async fn evals_list(cfg: &Config) -> Result<()> { - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/eval/list-for-org", serde_json::json!({}), @@ -389,7 +390,7 @@ pub async fn evals_list(cfg: &Config) -> Result<()> { } pub async fn evals_list_by_ml_app(cfg: &Config, ml_app: &str) -> Result<()> { - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/eval/list", serde_json::json!({ "ml_app": ml_app }), @@ -400,7 +401,7 @@ pub async fn evals_list_by_ml_app(cfg: &Config, ml_app: &str) -> Result<()> { } pub async fn evals_get_evaluator(cfg: &Config, eval_name: &str) -> Result<()> { - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/custom-evaluator/get", serde_json::json!({ "eval_name": eval_name }), @@ -421,13 +422,13 @@ pub async fn evals_get_aggregate_stats( if let Some(a) = ml_app { body["ml_app"] = serde_json::json!(a); } - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/eval/aggregate-stats", body, @@ -440,7 +441,7 @@ pub async fn evals_get_aggregate_stats( pub async fn evals_create_or_update(cfg: &Config, eval_name: &str, file: &str) -> Result<()> { let mut body: serde_json::Value = util::read_json_file(file)?; body["eval_name"] = serde_json::json!(eval_name); - client::raw_post( + raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/custom-evaluator/create-or-update", body, @@ -452,7 +453,7 @@ pub async fn evals_create_or_update(cfg: &Config, eval_name: &str, file: &str) - } pub async fn evals_delete(cfg: &Config, eval_name: &str) -> Result<()> { - client::raw_post( + raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/custom-evaluator/delete", serde_json::json!({ "eval_name": eval_name }), @@ -503,17 +504,17 @@ pub async fn spans_search( if let Some(a) = ml_app { body["ml_app"] = serde_json::json!(a); } - let from_ms = crate::util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = crate::util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); if let Some(c) = cursor { body["cursor"] = serde_json::json!(c); } - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/search-spans", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/search-spans", body) .await .map_err(|e| anyhow::anyhow!("failed to search spans: {e:?}"))?; if summary { @@ -556,13 +557,13 @@ pub async fn spans_get_trace( if include_tree { body["include_tree"] = serde_json::json!(true); } - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/get-trace", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/get-trace", body) .await .map_err(|e| anyhow::anyhow!("failed to get trace: {e:?}"))?; formatter::output(cfg, &resp) @@ -576,14 +577,14 @@ pub async fn spans_get_span_details( to: String, ) -> Result<()> { let mut body = serde_json::json!({ "trace_id": trace_id, "span_ids": span_ids }); - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); let requested = span_ids.len(); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/span-details", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/span-details", body) .await .map_err(|e| anyhow::anyhow!("failed to get span details: {e:?}"))?; let returned = resp["spans"].as_array().map(|a| a.len()).unwrap_or(0); @@ -616,13 +617,13 @@ pub async fn spans_get_span_content( if let Some(m) = max_tokens { body["max_tokens"] = serde_json::json!(m); } - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/span-content", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/span-content", body) .await .map_err(|e| anyhow::anyhow!("failed to get span content: {e:?}"))?; formatter::output(cfg, &resp) @@ -635,13 +636,13 @@ pub async fn spans_find_error_spans( to: String, ) -> Result<()> { let mut body = serde_json::json!({ "trace_id": trace_id }); - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/trace/find-error-spans", body, @@ -668,13 +669,13 @@ pub async fn spans_expand_spans( if let Some(k) = filter_kind { body["filter_kind"] = serde_json::json!(k); } - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/expand-spans", body) + let resp = raw_client::raw_post(cfg, "/api/unstable/llm-obs-mcp/v1/trace/expand-spans", body) .await .map_err(|e| anyhow::anyhow!("failed to expand spans: {e:?}"))?; formatter::output(cfg, &resp) @@ -695,13 +696,13 @@ pub async fn spans_get_agent_loop( if let Some(m) = max_content_length { body["max_content_length"] = serde_json::json!(m); } - let from_ms = util::parse_time_to_unix_millis(&from) + let from_ms = util_ext::parse_time_to_unix_millis(&from) .map_err(|e| anyhow::anyhow!("invalid --from value: {e}"))?; body["from"] = serde_json::json!(from_ms.to_string()); - let to_ms = util::parse_time_to_unix_millis(&to) + let to_ms = util_ext::parse_time_to_unix_millis(&to) .map_err(|e| anyhow::anyhow!("invalid --to value: {e}"))?; body["to"] = serde_json::json!(to_ms.to_string()); - let resp = client::raw_post( + let resp = raw_client::raw_post( cfg, "/api/unstable/llm-obs-mcp/v1/trace/get-agent-loop", body, diff --git a/src/commands/logs.rs b/src/commands/logs.rs index 28c77803..c23c87c7 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -7,10 +7,10 @@ use datadog_api_client::datadogV2::model::{ LogsListRequest, LogsListRequestPage, LogsQueryFilter, LogsSort, LogsStorageTier, }; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; pub struct AggregateArgs { pub query: String, @@ -152,7 +152,7 @@ fn build_aggregate_body( let compute_arr: Vec = computes .iter() .map(|c| { - let (aggregation, metric) = util::parse_compute_raw(c)?; + let (aggregation, metric) = util_ext::parse_compute_raw(c)?; let mut obj = serde_json::json!({ "aggregation": aggregation }); if let Some(m) = metric { obj["metric"] = serde_json::Value::String(m); @@ -203,8 +203,8 @@ pub async fn search(cfg: &Config, args: SearchArgs) -> Result<()> { } = args; let api = crate::make_api!(LogsAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let storage_tier = parse_storage_tier(storage)?; @@ -285,12 +285,12 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> { if compute.is_empty() { compute.push("count".into()); } - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let body = build_aggregate_body( query, from_ms, to_ms, compute, group_by, limit, index, storage, &sort, )?; - let data = client::raw_post(cfg, "/api/v2/logs/analytics/aggregate", body).await?; + let data = raw_client::raw_post(cfg, "/api/v2/logs/analytics/aggregate", body).await?; formatter::output(cfg, &data)?; Ok(()) } @@ -394,13 +394,13 @@ pub async fn metrics_delete(cfg: &Config, metric_id: &str) -> Result<()> { // --------------------------------------------------------------------------- pub async fn restriction_queries_list(cfg: &Config) -> Result<()> { - let data = client::raw_get(cfg, "/api/v2/logs/config/restriction_queries", &[]).await?; + let data = raw_client::raw_get(cfg, "/api/v2/logs/config/restriction_queries", &[]).await?; formatter::output(cfg, &data) } pub async fn restriction_queries_get(cfg: &Config, query_id: &str) -> Result<()> { let path = format!("/api/v2/logs/config/restriction_queries/{query_id}"); - let data = client::raw_get(cfg, &path, &[]).await?; + let data = raw_client::raw_get(cfg, &path, &[]).await?; formatter::output(cfg, &data) } diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index 1ba836c3..f37db394 100644 --- a/src/commands/metrics.rs +++ b/src/commands/metrics.rs @@ -39,6 +39,7 @@ use datadog_api_client::datadogV2::model::MetricPayload; use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; pub async fn list( cfg: &Config, @@ -48,7 +49,7 @@ pub async fn list( ) -> Result<()> { let api = crate::make_api!(MetricsV1API, cfg); - let from_ts = util::parse_time_to_unix(&from)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; let mut params = ListActiveMetricsOptionalParams::default(); if let Some(tf) = tag_filter { params = params.tag_filter(tf); @@ -81,8 +82,8 @@ pub async fn list( pub async fn search(cfg: &Config, query: String, from: String, to: String) -> Result<()> { let api = crate::make_api!(MetricsV1API, cfg); - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let resp = api .query_metrics(from_ts, to_ts, query) @@ -103,8 +104,8 @@ pub async fn metadata_get(cfg: &Config, metric_name: &str) -> Result<()> { pub async fn query(cfg: &Config, query: String, from: String, to: String) -> Result<()> { let api = crate::make_api!(MetricsV1API, cfg); - let from_ts = util::parse_time_to_unix(&from)?; - let to_ts = util::parse_time_to_unix(&to)?; + let from_ts = util_ext::parse_time_to_unix(&from)?; + let to_ts = util_ext::parse_time_to_unix(&to)?; let resp = api .query_metrics(from_ts, to_ts, query) diff --git a/src/commands/monitors.rs b/src/commands/monitors.rs index f56a90df..0055f4d5 100644 --- a/src/commands/monitors.rs +++ b/src/commands/monitors.rs @@ -8,6 +8,7 @@ use datadog_api_client::datadogV1::model::Monitor; use crate::config::Config; use crate::formatter::{self, Metadata}; use crate::util; +use crate::util_ext; pub async fn list( cfg: &Config, @@ -130,10 +131,10 @@ pub async fn diff( // notify_no_data) in the live response that the candidate omits. Those appear // as "removed" because the candidate is treated as the complete desired state. // Use --ignore to suppress specific option fields if the noise is unwanted. - util::normalize_for_diff(&mut live, util::READONLY_MONITOR_FIELDS); - util::normalize_for_diff(&mut candidate, util::READONLY_MONITOR_FIELDS); + util_ext::normalize_for_diff(&mut live, util_ext::READONLY_MONITOR_FIELDS); + util_ext::normalize_for_diff(&mut candidate, util_ext::READONLY_MONITOR_FIELDS); - let entries = util::scope_diff(util::diff_json(&live, &candidate), only, ignore); + let entries = util_ext::scope_diff(util_ext::diff_json(&live, &candidate), only, ignore); // `update` (PUT) is a partial/merge update: fields absent from the candidate // file are left unchanged on the live monitor, not deleted. "removed" entries @@ -143,7 +144,7 @@ pub async fn diff( // fields to the candidate explicitly. let has_removed = entries .iter() - .any(|e| e.change == util::ChangeKind::Removed); + .any(|e| e.change == util_ext::ChangeKind::Removed); let next_action = if entries.is_empty() { None } else if has_removed { diff --git a/src/commands/on_call.rs b/src/commands/on_call.rs index 17a4ae24..e26cc05f 100644 --- a/src/commands/on_call.rs +++ b/src/commands/on_call.rs @@ -20,10 +20,11 @@ use datadog_api_client::datadogV2::model::{ UserTeamType, UserTeamUpdate, UserTeamUpdateRequest, UserTeamUserType, }; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; use crate::util; +use crate::util_ext; fn is_uuid(s: &str) -> bool { uuid::Uuid::parse_str(s).is_ok() @@ -455,11 +456,14 @@ pub async fn pages_create(cfg: &Config, file: &str) -> Result<()> { /// Fetches a single on-call page by ID. /// -/// Uses `client::raw_get` because `datadog-api-client` does not yet +/// Uses `raw_client::raw_get` because `datadog-api-client` does not yet /// expose a `get_on_call_page` binding. pub async fn pages_get(cfg: &Config, page_id: &str) -> Result<()> { - let path = format!("/api/v2/on-call/pages/{}", util::percent_encode(page_id)); - let resp = client::raw_get(cfg, &path, &[]) + let path = format!( + "/api/v2/on-call/pages/{}", + util_ext::percent_encode(page_id) + ); + let resp = raw_client::raw_get(cfg, &path, &[]) .await .map_err(|e| anyhow::anyhow!("failed to get page: {e:?}"))?; formatter::output(cfg, &resp) @@ -843,7 +847,7 @@ mod tests { } // Uses an ID with reserved URL characters ('/' and '?') so the mock's - // path-exact matcher only succeeds if `util::percent_encode` is actually + // path-exact matcher only succeeds if `util_ext::percent_encode` is actually // applied. A refactor that drops the encoder would fail this test. #[tokio::test] async fn test_on_call_pages_get_percent_encodes_id() { diff --git a/src/commands/rum.rs b/src/commands/rum.rs index 69f956cc..1bb3ccc7 100644 --- a/src/commands/rum.rs +++ b/src/commands/rum.rs @@ -15,10 +15,10 @@ use datadog_api_client::datadogV2::model::{ RumRetentionFilterCreateRequest, RumRetentionFilterUpdateRequest, }; -use crate::client; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::raw_client; +use crate::util_ext; pub async fn apps_list(cfg: &Config) -> Result<()> { let api = crate::make_api!(RUMAPI, cfg); @@ -71,8 +71,8 @@ pub async fn events_list( ) -> Result<()> { let api = crate::make_api!(RUMAPI, cfg); - let from_dt = util::parse_time_to_datetime(&from)?; - let to_dt = util::parse_time_to_datetime(&to)?; + let from_dt = util_ext::parse_time_to_datetime(&from)?; + let to_dt = util_ext::parse_time_to_datetime(&to)?; let mut params = ListRUMEventsOptionalParams::default() .filter_from(from_dt) @@ -98,8 +98,8 @@ pub async fn sessions_search( ) -> Result<()> { let api = crate::make_api!(RUMAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let from_str = chrono::DateTime::from_timestamp_millis(from_ms) .ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))? .to_rfc3339(); @@ -242,8 +242,8 @@ pub async fn retention_filters_delete(cfg: &Config, app_id: &str, filter_id: &st pub async fn sessions_list(cfg: &Config, from: String, to: String, limit: i32) -> Result<()> { let api = crate::make_api!(RUMAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let from_str = chrono::DateTime::from_timestamp_millis(from_ms) .ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))? .to_rfc3339(); @@ -370,8 +370,8 @@ pub async fn aggregate(cfg: &Config, args: RumAggregateArgs) -> Result<()> { if compute.is_empty() { compute.push("count".into()); } - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let compute_arr: Vec = compute .iter() @@ -410,7 +410,7 @@ pub async fn aggregate(cfg: &Config, args: RumAggregateArgs) -> Result<()> { body["group_by"] = serde_json::json!(group_by_arr); } - let data = client::raw_post(cfg, "/api/v2/rum/analytics/aggregate", body).await?; + let data = raw_client::raw_post(cfg, "/api/v2/rum/analytics/aggregate", body).await?; formatter::output(cfg, &data)?; Ok(()) } diff --git a/src/commands/security.rs b/src/commands/security.rs index 18d70817..298c6dfd 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -2,6 +2,7 @@ use crate::commands::ddsql; use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; use anyhow::Result; use datadog_api_client::datadogV2::api_application_security::ApplicationSecurityAPI; use datadog_api_client::datadogV2::api_entity_risk_scores::{ @@ -224,8 +225,8 @@ pub async fn signals_search( ) -> Result<()> { let api = crate::make_api!(SecurityMonitoringAPI, cfg); - let from_dt = util::parse_time_to_datetime(&from)?; - let to_dt = util::parse_time_to_datetime(&to)?; + let from_dt = util_ext::parse_time_to_datetime(&from)?; + let to_dt = util_ext::parse_time_to_datetime(&to)?; let sort_val = match sort.as_deref().unwrap_or("-timestamp") { "timestamp" | "asc" => SecurityMonitoringSignalsSort::TIMESTAMP_ASCENDING, diff --git a/src/commands/status_pages.rs b/src/commands/status_pages.rs index dbb9979d..c9a2de6d 100644 --- a/src/commands/status_pages.rs +++ b/src/commands/status_pages.rs @@ -1,6 +1,7 @@ use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; use anyhow::{bail, Result}; use datadog_api_client::datadogV2::api_status_pages::{ CreateComponentOptionalParams, CreateDegradationOptionalParams, @@ -36,7 +37,7 @@ pub async fn pages_list(cfg: &Config) -> Result<()> { pub async fn pages_get(cfg: &Config, page_id: &str) -> Result<()> { let api = make_api(cfg); - let uuid = util::parse_uuid(page_id, "page")?; + let uuid = util_ext::parse_uuid(page_id, "page")?; let resp = api .get_status_page(uuid, GetStatusPageOptionalParams::default()) .await @@ -46,7 +47,7 @@ pub async fn pages_get(cfg: &Config, page_id: &str) -> Result<()> { pub async fn pages_delete(cfg: &Config, page_id: &str) -> Result<()> { let api = make_api(cfg); - let uuid = util::parse_uuid(page_id, "page")?; + let uuid = util_ext::parse_uuid(page_id, "page")?; api.delete_status_page(uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete status page: {e:?}"))?; @@ -55,7 +56,7 @@ pub async fn pages_delete(cfg: &Config, page_id: &str) -> Result<()> { } pub async fn pages_update(cfg: &Config, page_id: &str, file: &str) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; let body: PatchStatusPageRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -67,7 +68,7 @@ pub async fn pages_update(cfg: &Config, page_id: &str, file: &str) -> Result<()> pub async fn components_list(cfg: &Config, page_id: &str) -> Result<()> { let api = make_api(cfg); - let uuid = util::parse_uuid(page_id, "page")?; + let uuid = util_ext::parse_uuid(page_id, "page")?; let resp = api .list_components(uuid, ListComponentsOptionalParams::default()) .await @@ -77,8 +78,8 @@ pub async fn components_list(cfg: &Config, page_id: &str) -> Result<()> { pub async fn components_get(cfg: &Config, page_id: &str, component_id: &str) -> Result<()> { let api = make_api(cfg); - let page_uuid = util::parse_uuid(page_id, "page")?; - let component_uuid = util::parse_uuid(component_id, "component")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let component_uuid = util_ext::parse_uuid(component_id, "component")?; let resp = api .get_component( page_uuid, @@ -96,8 +97,8 @@ pub async fn components_update( component_id: &str, file: &str, ) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; - let component_uuid = util::parse_uuid(component_id, "component")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let component_uuid = util_ext::parse_uuid(component_id, "component")?; let body: PatchComponentRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -123,8 +124,8 @@ pub async fn degradations_list(cfg: &Config) -> Result<()> { pub async fn degradations_get(cfg: &Config, page_id: &str, degradation_id: &str) -> Result<()> { let api = make_api(cfg); - let page_uuid = util::parse_uuid(page_id, "page")?; - let degradation_uuid = util::parse_uuid(degradation_id, "degradation")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let degradation_uuid = util_ext::parse_uuid(degradation_id, "degradation")?; let resp = api .get_degradation( page_uuid, @@ -137,7 +138,7 @@ pub async fn degradations_get(cfg: &Config, page_id: &str, degradation_id: &str) } pub async fn degradations_create(cfg: &Config, page_id: &str, file: &str) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; let body: CreateDegradationRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -153,8 +154,8 @@ pub async fn degradations_update( degradation_id: &str, file: &str, ) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; - let degradation_uuid = util::parse_uuid(degradation_id, "degradation")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let degradation_uuid = util_ext::parse_uuid(degradation_id, "degradation")?; let body: PatchDegradationRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -171,8 +172,8 @@ pub async fn degradations_update( pub async fn components_delete(cfg: &Config, page_id: &str, component_id: &str) -> Result<()> { let api = make_api(cfg); - let page_uuid = util::parse_uuid(page_id, "page")?; - let component_uuid = util::parse_uuid(component_id, "component")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let component_uuid = util_ext::parse_uuid(component_id, "component")?; api.delete_component(page_uuid, component_uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete component: {e:?}"))?; @@ -182,8 +183,8 @@ pub async fn components_delete(cfg: &Config, page_id: &str, component_id: &str) pub async fn degradations_delete(cfg: &Config, page_id: &str, degradation_id: &str) -> Result<()> { let api = make_api(cfg); - let page_uuid = util::parse_uuid(page_id, "page")?; - let degradation_uuid = util::parse_uuid(degradation_id, "degradation")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let degradation_uuid = util_ext::parse_uuid(degradation_id, "degradation")?; api.delete_degradation(page_uuid, degradation_uuid) .await .map_err(|e| anyhow::anyhow!("failed to delete degradation: {e:?}"))?; @@ -210,7 +211,7 @@ pub async fn pages_create(cfg: &Config, file: &str) -> Result<()> { // --------------------------------------------------------------------------- pub async fn components_create(cfg: &Config, page_id: &str, file: &str) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; let body: CreateComponentRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -235,8 +236,8 @@ pub async fn maintenances_list(cfg: &Config) -> Result<()> { pub async fn maintenances_get(cfg: &Config, page_id: &str, maintenance_id: &str) -> Result<()> { let api = make_api(cfg); - let page_uuid = util::parse_uuid(page_id, "page")?; - let maintenance_uuid = util::parse_uuid(maintenance_id, "maintenance")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let maintenance_uuid = util_ext::parse_uuid(maintenance_id, "maintenance")?; let resp = api .get_maintenance( page_uuid, @@ -249,7 +250,7 @@ pub async fn maintenances_get(cfg: &Config, page_id: &str, maintenance_id: &str) } pub async fn maintenances_create(cfg: &Config, page_id: &str, file: &str) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; let body: CreateMaintenanceRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api @@ -265,8 +266,8 @@ pub async fn maintenances_update( maintenance_id: &str, file: &str, ) -> Result<()> { - let page_uuid = util::parse_uuid(page_id, "page")?; - let maintenance_uuid = util::parse_uuid(maintenance_id, "maintenance")?; + let page_uuid = util_ext::parse_uuid(page_id, "page")?; + let maintenance_uuid = util_ext::parse_uuid(maintenance_id, "maintenance")?; let body: PatchMaintenanceRequest = util::read_json_file(file)?; let api = make_api(cfg); let resp = api diff --git a/src/commands/symdb.rs b/src/commands/symdb.rs index 9442b03e..ac17901e 100644 --- a/src/commands/symdb.rs +++ b/src/commands/symdb.rs @@ -2,9 +2,9 @@ use std::collections::HashSet; use anyhow::Result; -use crate::client; use crate::config::Config; use crate::formatter; +use crate::raw_client; #[derive(Clone, clap::ValueEnum)] pub enum SymdbView { @@ -30,11 +30,11 @@ const RETRY_BASE_MS: u64 = 1000; /// Fetch with retries for transient server errors (502, 503, 504). async fn fetch(cfg: &Config, path: &str, query: &[(&str, &str)]) -> Result { for attempt in 0..=MAX_RETRIES { - match client::raw_get(cfg, path, query).await { + match raw_client::raw_get(cfg, path, query).await { Ok(v) => return Ok(v), Err(e) => { let retryable = e - .downcast_ref::() + .downcast_ref::() .is_some_and(|h| matches!(h.status, 502..=504)); if !retryable || attempt == MAX_RETRIES { return Err(e); diff --git a/src/commands/traces.rs b/src/commands/traces.rs index a7140cc4..489db683 100644 --- a/src/commands/traces.rs +++ b/src/commands/traces.rs @@ -11,6 +11,7 @@ use datadog_api_client::datadogV2::model::{ use crate::config::Config; use crate::formatter; use crate::util; +use crate::util_ext; // --------------------------------------------------------------------------- // Spans Metrics @@ -76,7 +77,7 @@ fn validate_sort(sort: &str) -> Result<()> { /// Parse a compute string into (SpansAggregationFunction, Option). fn parse_compute(input: &str) -> Result<(SpansAggregationFunction, Option)> { - let (func, metric) = util::parse_compute_raw(input)?; + let (func, metric) = util_ext::parse_compute_raw(input)?; let agg = match func.as_str() { "count" => SpansAggregationFunction::COUNT, "avg" => SpansAggregationFunction::AVG, @@ -107,8 +108,8 @@ pub async fn search( let api = crate::make_api!(SpansAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; if !(1..=1000).contains(&limit) { anyhow::bail!("--limit must be between 1 and 1000, got {limit}"); @@ -181,8 +182,8 @@ pub async fn aggregate( let api = crate::make_api!(SpansAPI, cfg); - let from_ms = util::parse_time_to_unix_millis(&from)?; - let to_ms = util::parse_time_to_unix_millis(&to)?; + let from_ms = util_ext::parse_time_to_unix_millis(&from)?; + let to_ms = util_ext::parse_time_to_unix_millis(&to)?; let mut spans_compute = SpansCompute::new(agg_fn); if let Some(m) = metric { diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 91b31373..3144ee7c 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -6,16 +6,16 @@ use datadog_api_client::datadogV1::model::HourlyUsageAttributionUsageType; use crate::config::Config; use crate::formatter; -use crate::util; +use crate::util_ext; pub async fn summary(cfg: &Config, start: String, end: Option) -> Result<()> { let api = crate::make_api!(UsageMeteringAPI, cfg); - let start_dt = util::parse_time_to_datetime(&start)?; + let start_dt = util_ext::parse_time_to_datetime(&start)?; let mut params = GetUsageSummaryOptionalParams::default(); if let Some(e) = end { - let end_dt = util::parse_time_to_datetime(&e)?; + let end_dt = util_ext::parse_time_to_datetime(&e)?; params = params.end_month(end_dt); } @@ -29,11 +29,11 @@ pub async fn summary(cfg: &Config, start: String, end: Option) -> Result pub async fn hourly(cfg: &Config, start: String, end: Option) -> Result<()> { let api = crate::make_api!(UsageMeteringAPI, cfg); - let start_dt = util::parse_time_to_datetime(&start)?; + let start_dt = util_ext::parse_time_to_datetime(&start)?; let mut params = GetHourlyUsageAttributionOptionalParams::default(); if let Some(e) = end { - let end_dt = util::parse_time_to_datetime(&e)?; + let end_dt = util_ext::parse_time_to_datetime(&e)?; params = params.end_hr(end_dt); } diff --git a/src/commands/workflows.rs b/src/commands/workflows.rs index e55ca789..5faf8aa8 100644 --- a/src/commands/workflows.rs +++ b/src/commands/workflows.rs @@ -9,6 +9,7 @@ use datadog_api_client::datadogV2::api_workflow_automation::{ use crate::config::Config; use crate::formatter::{self, Metadata}; use crate::util; +use crate::util_ext; // --------------------------------------------------------------------------- // Helper: build a WorkflowAutomationAPI @@ -119,7 +120,7 @@ pub async fn run( eprintln!("Instance {instance_id} started, waiting for completion..."); let timeout_duration = - std::time::Duration::from_millis(crate::util::parse_duration_to_millis(timeout)? as u64); + std::time::Duration::from_millis(util_ext::parse_duration_to_millis(timeout)? as u64); let start = std::time::Instant::now(); loop { diff --git a/src/main.rs b/src/main.rs index 09b6c923..caf921de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod extensions; mod filter; mod formatter; mod generated; +mod raw_client; #[cfg(not(target_arch = "wasm32"))] mod runbooks; #[cfg(not(target_arch = "wasm32"))] @@ -18,6 +19,7 @@ mod skills; mod tunnel; mod useragent; mod util; +mod util_ext; mod version; #[cfg(test)] @@ -12246,8 +12248,8 @@ async fn main_inner() -> anyhow::Result<()> { } SloActions::Delete { id } => commands::slos::delete(&cfg, &id).await?, SloActions::Status { id, from, to } => { - let from_ts = util::parse_time_to_unix_millis(&from)? / 1000; - let to_ts = util::parse_time_to_unix_millis(&to)? / 1000; + let from_ts = util_ext::parse_time_to_unix_millis(&from)? / 1000; + let to_ts = util_ext::parse_time_to_unix_millis(&to)? / 1000; commands::slos::status(&cfg, &id, from_ts, to_ts).await?; } } @@ -12523,8 +12525,8 @@ async fn main_inner() -> anyhow::Result<()> { .await?; } EventActions::List { from, to, tags, .. } => { - let start = util::parse_time_to_unix_millis(&from)? / 1000; - let end = util::parse_time_to_unix_millis(&to)? / 1000; + let start = util_ext::parse_time_to_unix_millis(&from)? / 1000; + let end = util_ext::parse_time_to_unix_millis(&to)? / 1000; commands::events::list(&cfg, start, end, tags).await?; } EventActions::Search { diff --git a/src/raw_client.rs b/src/raw_client.rs new file mode 100644 index 00000000..ce94cb10 --- /dev/null +++ b/src/raw_client.rs @@ -0,0 +1,1041 @@ +//! Raw/generic Datadog HTTP client support: request routing for endpoints not +//! covered by the typed SDK (the `pup api` passthrough and several hand-written +//! commands), plus the OAuth-exclusion fallback table shared with the typed path. + +use crate::config::Config; +use crate::useragent; + +/// HTTP error with the status code preserved for programmatic matching. +#[derive(Debug)] +pub struct HttpError { + pub status: u16, + pub method: String, + pub url: String, + pub body: String, +} + +impl std::fmt::Display for HttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {} failed (HTTP {}): {}", + self.method, self.url, self.status, self.body + ) + } +} + +impl std::error::Error for HttpError {} + +// --------------------------------------------------------------------------- +// Auth type detection +// --------------------------------------------------------------------------- + +// Parse a reqwest response body as JSON without serde_json's default 128-level +// recursion cap. Some Datadog endpoints (e.g. /profiling/api/v1/aggregate) +// return deeply-nested flame-graph trees that exceed it. serde_stacker grows +// the thread stack on demand so disabling the limit can't blow it. +async fn parse_response_json(resp: reqwest::Response) -> anyhow::Result { + use serde::Deserialize; + let bytes = resp.bytes().await?; + let mut de = serde_json::Deserializer::from_slice(&bytes); + de.disable_recursion_limit(); + let de = serde_stacker::Deserializer::new(&mut de); + Ok(serde_json::Value::deserialize(de)?) +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthType { + None, + OAuth, + ApiKeys, +} + +impl std::fmt::Display for AuthType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthType::None => write!(f, "None"), + AuthType::OAuth => write!(f, "OAuth2 Bearer Token"), + AuthType::ApiKeys => write!(f, "API Keys (DD_API_KEY + DD_APP_KEY)"), + } + } +} + +#[allow(dead_code)] +pub fn get_auth_type(cfg: &Config) -> AuthType { + if cfg.has_bearer_token() { + AuthType::OAuth + } else if cfg.has_api_keys() { + AuthType::ApiKeys + } else { + AuthType::None + } +} + +// --------------------------------------------------------------------------- +// OAuth-excluded endpoint validation +// --------------------------------------------------------------------------- + +struct EndpointRequirement { + path: &'static str, + method: &'static str, +} + +/// Returns true if the endpoint doesn't support OAuth and requires API key fallback. +#[allow(dead_code)] +pub fn requires_api_key_fallback(method: &str, path: &str) -> bool { + find_endpoint_requirement(method, path).is_some() +} + +fn find_endpoint_requirement(method: &str, path: &str) -> Option<&'static EndpointRequirement> { + OAUTH_EXCLUDED_ENDPOINTS.iter().find(|req| { + if req.method != method { + return false; + } + // Trailing "/" means prefix match (for ID-parameterized paths) + if req.path.ends_with('/') { + path.starts_with(&req.path[..req.path.len() - 1]) + } else { + req.path == path + } + }) +} + +// --------------------------------------------------------------------------- +// Static tables +// --------------------------------------------------------------------------- + +/// Endpoints that don't support OAuth. +/// Trailing "/" means prefix match for ID-parameterized paths. +static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ + // API/App Keys (8) + EndpointRequirement { + path: "/api/v2/api_keys", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/api_keys/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/api_keys", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/api_keys/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/application_keys", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "PATCH", + }, + // DDSQL editor tools (3) + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/ddsql-docs", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/table-names", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/ddsql-editor/tools/table-data", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/application_keys/", + method: "DELETE", + }, + // Fleet Automation (15) + EndpointRequirement { + path: "/api/v2/fleet/agents", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/agents/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/agents/versions", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/configure", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/upgrade", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/deployments/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "PATCH", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/fleet/schedules/", + method: "POST", + }, + // Observability Pipelines (6) — API key only, no OAuth support + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "PUT", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/obs-pipelines/pipelines/validate", + method: "POST", + }, + // Cost / Billing (11) — API key only, no OAuth support + EndpointRequirement { + path: "/api/v2/usage/projected_cost", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/usage/cost_by_org", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost_by_tag/monthly_cost_attribution", + method: "GET", + }, + // Cloud Cost Management config (12) + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/aws_cur_config/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/azure_uc_config/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config", + method: "POST", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config/", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/gcp_uc_config/", + method: "DELETE", + }, + EndpointRequirement { + path: "/api/v2/cost/oci_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/anomalies", + method: "GET", + }, + // Profiling (4) + // No OAuth scope is declared for Continuous Profiler endpoints; force API-key auth. + EndpointRequirement { + path: "/profiling/api/v1/", + method: "POST", + }, + EndpointRequirement { + path: "/profiling/api/v1/", + method: "GET", + }, + EndpointRequirement { + path: "/api/unstable/profiles/", + method: "POST", + }, + EndpointRequirement { + path: "/api/ui/profiling/", + method: "GET", + }, + // Events intake (1) + // Posting an event uses the V1 intake endpoint, which authenticates with the + // API key and does not accept OAuth2 bearer tokens. Listing/getting events + // (GET) is fine over OAuth, so only POST is excluded. + EndpointRequirement { + path: "/api/v1/events", + method: "POST", + }, +]; + +// --------------------------------------------------------------------------- +// Raw HTTP helpers +// --------------------------------------------------------------------------- + +/// Raw HTTP response returned by [`raw_request`]. +#[derive(Debug)] +pub struct HttpResponse { + /// The `Content-Type` header value from the response, or an empty string if absent. + pub content_type: String, + /// The raw response body bytes. + pub bytes: Vec, +} + +/// Makes an authenticated request with any HTTP method via reqwest. +/// +/// - `query` — key/value pairs appended as URL query parameters (reqwest handles percent-encoding). +/// Pass `&[]` when no query parameters are needed. +/// - `body` — raw bytes to send; `content_type` sets the `Content-Type` header when present. +/// - `accept` — value for the `Accept` header (e.g. `"application/json"`, `"*/*"`). +/// - `extra_headers` — additional headers applied after auth and before the body. +/// - Returns an [`HttpResponse`] with the raw bytes and response `Content-Type`. +/// Callers are responsible for decoding the bytes. +#[allow(clippy::too_many_arguments)] +pub async fn raw_request( + cfg: &Config, + method: &str, + path: &str, + query: &[(&str, &str)], + body: Option>, + content_type: Option<&str>, + accept: &str, + extra_headers: &[(&str, &str)], +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let method_name = method.to_uppercase(); + let method = reqwest::Method::from_bytes(method_name.as_bytes()) + .map_err(|_| anyhow::anyhow!("unsupported HTTP method: {method_name}"))?; + let mut req = client.request(method, &url); + if !query.is_empty() { + req = req.query(query); + } + + req = apply_auth(req, cfg, &method_name, path)?; + + req = req + .header("Accept", accept) + .header("User-Agent", useragent::get()); + + for (k, v) in extra_headers { + req = req.header(*k, *v); + } + + if let Some(b) = body { + if let Some(ct) = content_type { + req = req.header("Content-Type", ct); + } + req = req.body(b); + } + + let resp = req.send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: method_name, + url, + body: text, + } + .into()); + } + + let resp_ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(HttpResponse { + content_type: resp_ct, + bytes: vec![], + }); + } + + let bytes = resp.bytes().await?.to_vec(); + Ok(HttpResponse { + content_type: resp_ct, + bytes, + }) +} + +/// Makes an authenticated GET request directly via reqwest. +/// Used for endpoints not covered by the typed DD API client. +/// Pass an empty slice for `query` when no query parameters are needed. +pub async fn raw_get( + cfg: &Config, + path: &str, + query: &[(&str, &str)], +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.get(&url); + + req = apply_auth(req, cfg, "GET", path)?; + + if !query.is_empty() { + req = req.query(query); + } + + let resp = req + .header("Accept", "application/json") + .header("User-Agent", useragent::get()) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: "GET".into(), + url, + body, + } + .into()); + } + parse_response_json(resp).await +} + +/// Makes an authenticated PATCH request directly via reqwest. +/// Used for endpoints not covered by the typed DD API client. +#[allow(dead_code)] +pub async fn raw_patch( + cfg: &Config, + path: &str, + body: serde_json::Value, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.patch(&url); + + req = apply_auth(req, cfg, "PATCH", path)?; + + let resp = req + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", useragent::get()) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: "PATCH".into(), + url, + body, + } + .into()); + } + parse_response_json(resp).await +} + +/// Makes an authenticated POST request directly via reqwest. +/// Used for endpoints not covered by the typed DD API client. +pub async fn raw_post( + cfg: &Config, + path: &str, + body: serde_json::Value, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + raw_post_impl(cfg, path, &url, body, useragent::get()).await +} + +/// Like `raw_post`, but with a custom User-Agent string for audit log differentiation. +pub async fn raw_post_with_ua( + cfg: &Config, + path: &str, + body: serde_json::Value, + ua: String, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + raw_post_impl(cfg, path, &url, body, ua).await +} + +async fn raw_post_impl( + cfg: &Config, + path: &str, + url: &str, + body: serde_json::Value, + ua: String, +) -> anyhow::Result { + let client = reqwest::Client::new(); + let mut req = client.post(url); + + req = apply_auth(req, cfg, "POST", path)?; + + let resp = req + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", ua) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: "POST".into(), + url: url.to_string(), + body, + } + .into()); + } + parse_response_json(resp).await +} + +/// Apply Datadog authentication headers to a request builder. +/// +/// Chooses between OAuth bearer and API-key/App-key auth based on `cfg` and the +/// per-endpoint requirements in [`requires_api_key_fallback`]: endpoints that do +/// not accept OAuth (see `OAUTH_EXCLUDED_ENDPOINTS`) force API-key auth even when +/// a bearer token is present. Exposed so the generic `pup api` passthrough reuses +/// the same auth routing as the typed clients. +pub fn apply_auth( + mut req: reqwest::RequestBuilder, + cfg: &Config, + method: &str, + path: &str, +) -> anyhow::Result { + if requires_api_key_fallback(method, path) { + if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + req = req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str()); + return Ok(req); + } + + anyhow::bail!( + "{method} {path} requires DD_API_KEY and DD_APP_KEY; OAuth2 bearer tokens are not supported" + ); + } + + if let Some(token) = &cfg.access_token { + req = req.header("Authorization", format!("Bearer {token}")); + return Ok(req); + } + + if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + req = req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str()); + return Ok(req); + } + + anyhow::bail!("no authentication configured") +} + +/// POST a JSON:API document. Wraps `attributes` in `{data:{type,attributes}}` +/// and sends with `Content-Type: application/vnd.api+json`. Use for routes +/// whose decoder is configured for JSON:API. +pub async fn raw_post_jsonapi( + cfg: &Config, + path: &str, + resource_type: &str, + attributes: serde_json::Value, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let envelope = serde_json::json!({ + "data": { "type": resource_type, "attributes": attributes }, + }); + let client = reqwest::Client::new(); + let mut req = client.post(&url); + req = apply_auth(req, cfg, "POST", path)?; + let resp = req + .header("Content-Type", "application/vnd.api+json") + .header("Accept", "application/vnd.api+json") + .header("User-Agent", useragent::get()) + .json(&envelope) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("POST {url} failed (HTTP {status}): {body}"); + } + parse_response_json(resp).await +} + +pub async fn raw_put( + cfg: &Config, + path: &str, + body: serde_json::Value, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let req = client.put(&url); + let req = apply_auth(req, cfg, "PUT", path)?; + let resp = req + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", useragent::get()) + .json(&body) + .send() + .await?; + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(serde_json::Value::Null); + } + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("PUT {url} failed (HTTP {status}): {body}"); + } + parse_response_json(resp).await +} + +/// Like `raw_post`, but returns the parsed JSON body even on non-2xx responses. +/// Callers are responsible for inspecting the body for errors. +pub async fn raw_post_lenient( + cfg: &Config, + path: &str, + body: serde_json::Value, +) -> anyhow::Result { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.post(&url); + + if let Some(token) = &cfg.access_token { + req = req.header("Authorization", format!("Bearer {token}")); + } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + req = req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str()); + } else { + anyhow::bail!("no authentication configured"); + } + + let resp = req + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("User-Agent", useragent::get()) + .json(&body) + .send() + .await?; + parse_response_json(resp).await +} + +/// Makes an authenticated DELETE request directly via reqwest. +/// Used for endpoints not covered by the typed DD API client. +pub async fn raw_delete(cfg: &Config, path: &str) -> anyhow::Result<()> { + let url = format!("{}{}", cfg.api_base_url(), path); + let client = reqwest::Client::new(); + let mut req = client.delete(&url); + + if let Some(token) = &cfg.access_token { + req = req.header("Authorization", format!("Bearer {token}")); + } else if let (Some(api_key), Some(app_key)) = (&cfg.api_key, &cfg.app_key) { + req = req + .header("DD-API-KEY", api_key.as_str()) + .header("DD-APPLICATION-KEY", app_key.as_str()); + } else { + anyhow::bail!("no authentication configured"); + } + + let resp = req + .header("Accept", "application/json") + .header("User-Agent", useragent::get()) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(HttpError { + status: status.as_u16(), + method: "DELETE".into(), + url, + body, + } + .into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::test_support::*; + + fn test_cfg() -> Config { + Config { + api_key: Some("test".into()), + app_key: Some("test".into()), + access_token: None, + site: "datadoghq.com".into(), + site_explicit: false, + org: None, + output_format: crate::config::OutputFormat::Json, + auto_approve: false, + agent_mode: false, + read_only: false, + jq: None, + } + } + + #[test] + fn test_auth_type_api_keys() { + let cfg = test_cfg(); + assert_eq!(get_auth_type(&cfg), AuthType::ApiKeys); + } + + #[test] + fn test_auth_type_bearer() { + let mut cfg = test_cfg(); + cfg.access_token = Some("token".into()); + assert_eq!(get_auth_type(&cfg), AuthType::OAuth); + } + + #[test] + fn test_auth_type_none() { + let mut cfg = test_cfg(); + cfg.api_key = None; + cfg.app_key = None; + assert_eq!(get_auth_type(&cfg), AuthType::None); + } + + #[test] + fn test_auth_type_display() { + assert_eq!(AuthType::OAuth.to_string(), "OAuth2 Bearer Token"); + assert_eq!( + AuthType::ApiKeys.to_string(), + "API Keys (DD_API_KEY + DD_APP_KEY)" + ); + assert_eq!(AuthType::None.to_string(), "None"); + } + + #[test] + fn test_no_fallback_for_logs() { + assert!(!requires_api_key_fallback("POST", "/api/v2/logs/events")); + assert!(!requires_api_key_fallback( + "POST", + "/api/v2/logs/events/search" + )); + } + + #[test] + fn test_no_fallback_for_rum() { + assert!(!requires_api_key_fallback( + "GET", + "/api/v2/rum/applications" + )); + assert!(!requires_api_key_fallback( + "GET", + "/api/v2/rum/applications/abc-123" + )); + } + + #[test] + fn test_no_fallback_for_events_search() { + assert!(!requires_api_key_fallback("POST", "/api/v2/events/search")); + } + + #[test] + fn test_fallback_for_events_post() { + // Posting an event (V1 intake) requires API keys; reading events does not. + assert!(requires_api_key_fallback("POST", "/api/v1/events")); + assert!(!requires_api_key_fallback("GET", "/api/v1/events")); + } + + #[test] + fn test_no_fallback_for_standard_endpoints() { + assert!(!requires_api_key_fallback("GET", "/api/v1/monitor")); + assert!(!requires_api_key_fallback("GET", "/api/v1/dashboard")); + assert!(!requires_api_key_fallback("GET", "/api/v2/incidents")); + } + + #[test] + fn test_prefix_matching_with_id() { + // Trailing "/" in the pattern should match paths with IDs + assert!(requires_api_key_fallback( + "DELETE", + "/api/v2/api_keys/key-123" + )); + assert!(requires_api_key_fallback( + "GET", + "/api/v2/fleet/agents/agent-123" + )); + } + + #[test] + fn test_method_must_match() { + // RUM events/search is POST-excluded, but GET should not match + assert!(!requires_api_key_fallback( + "GET", + "/api/v2/rum/events/search" + )); + } + + #[test] + fn test_oauth_excluded_count() { + assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 55); + } + + #[test] + fn test_no_fallback_for_notebooks() { + assert!(!requires_api_key_fallback("GET", "/api/v1/notebooks")); + assert!(!requires_api_key_fallback("GET", "/api/v1/notebooks/12345")); + assert!(!requires_api_key_fallback("POST", "/api/v1/notebooks")); + } + + #[test] + fn test_requires_api_key_fallback_fleet() { + assert!(requires_api_key_fallback("GET", "/api/v2/fleet/agents")); + assert!(requires_api_key_fallback( + "GET", + "/api/v2/fleet/agents/agent-123" + )); + } + + #[test] + fn test_requires_api_key_fallback_api_keys() { + assert!(requires_api_key_fallback("GET", "/api/v2/api_keys")); + assert!(requires_api_key_fallback("POST", "/api/v2/api_keys")); + assert!(requires_api_key_fallback( + "DELETE", + "/api/v2/api_keys/key-123" + )); + } + + #[test] + fn test_requires_api_key_fallback_ddsql_editor_tools() { + assert!(requires_api_key_fallback( + "GET", + "/api/unstable/ddsql-editor/tools/ddsql-docs" + )); + assert!(requires_api_key_fallback( + "GET", + "/api/unstable/ddsql-editor/tools/table-names" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/ddsql-editor/tools/table-data" + )); + } + + #[test] + fn test_no_fallback_for_error_tracking() { + assert!(!requires_api_key_fallback( + "POST", + "/api/v2/error_tracking/issues/search" + )); + } + + // Verify raw_request reaches the auth check (and fails there) for both the + // empty-query and non-empty-query paths. This ensures the `if !query.is_empty()` + // branch compiles and runs without panic. + #[test] + fn test_raw_request_no_auth_empty_query() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut cfg = test_cfg(); + cfg.api_key = None; + cfg.app_key = None; + let err = rt + .block_on(raw_request( + &cfg, + "GET", + "/api/v2/monitors", + &[], + None, + None, + "application/json", + &[], + )) + .unwrap_err(); + assert!( + err.to_string().contains("no authentication configured"), + "expected auth error, got: {err}" + ); + } + + #[test] + fn test_raw_request_no_auth_with_query() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut cfg = test_cfg(); + cfg.api_key = None; + cfg.app_key = None; + let err = rt + .block_on(raw_request( + &cfg, + "GET", + "/api/v2/monitors", + &[("page", "1"), ("page_size", "10")], + None, + None, + "application/json", + &[], + )) + .unwrap_err(); + assert!( + err.to_string().contains("no authentication configured"), + "expected auth error, got: {err}" + ); + } + + #[test] + fn test_requires_api_key_fallback_profiling() { + // /profiling/api/v1/* + assert!(requires_api_key_fallback( + "POST", + "/profiling/api/v1/aggregate" + )); + assert!(requires_api_key_fallback( + "GET", + "/profiling/api/v1/profiles/abc/info" + )); + assert!(requires_api_key_fallback( + "GET", + "/profiling/api/v1/profiles/abc/analysis" + )); + assert!(requires_api_key_fallback( + "POST", + "/profiling/api/v1/profiles/abc/breakdown" + )); + assert!(requires_api_key_fallback( + "POST", + "/profiling/api/v1/profiles/abc/timeline" + )); + // /api/unstable/profiles/* + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/list" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/analytics" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/insights" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/callgraph" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/interactive-analytics/field" + )); + assert!(requires_api_key_fallback( + "POST", + "/api/unstable/profiles/save-favorite" + )); + // /api/ui/profiling/* + assert!(requires_api_key_fallback( + "GET", + "/api/ui/profiling/profiles/abc/download" + )); + } + + /// Verifies that raw_request attaches query parameters and returns Ok when the + /// server responds 200. Exercises the `!query.is_empty()` branch added to the function. + #[tokio::test] + async fn test_raw_request_with_query_params_ok() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", "/api/v2/monitors") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("[]") + .create_async() + .await; + let resp = super::raw_request( + &cfg, + "GET", + "/api/v2/monitors", + &[("page", "1"), ("page_size", "10")], + None, + None, + "application/json", + &[], + ) + .await; + assert!( + resp.is_ok(), + "raw_request with query failed: {:?}", + resp.err() + ); + cleanup_env(); + } +} diff --git a/src/runbooks/engine.rs b/src/runbooks/engine.rs index 7b98765b..3f9441e7 100644 --- a/src/runbooks/engine.rs +++ b/src/runbooks/engine.rs @@ -349,7 +349,7 @@ async fn execute_datadog_workflow( // Trigger the workflow let path = format!("/api/v2/workflows/{workflow_id}/instances"); - let trigger_resp = crate::client::raw_post(cfg, &path, body) + let trigger_resp = crate::raw_client::raw_post(cfg, &path, body) .await .map_err(|e| anyhow::anyhow!("failed to trigger workflow: {e}"))?; @@ -399,7 +399,7 @@ async fn execute_datadog_workflow( tokio::time::sleep(Duration::from_secs(15)).await; let status_path = format!("/api/v2/workflows/{workflow_id}/instances/{instance_id}"); - let status_resp = crate::client::raw_get(cfg, &status_path, &[]) + let status_resp = crate::raw_client::raw_get(cfg, &status_path, &[]) .await .map_err(|e| anyhow::anyhow!("failed to poll workflow: {e}"))?; @@ -504,7 +504,7 @@ async fn execute_http(cfg: &Config, step: &Step, vars: &HashMap) let http_resp = if rendered_url.starts_with('/') { // Datadog API path — use authenticated client helper. - crate::client::raw_request( + crate::raw_client::raw_request( cfg, &method, &rendered_url, @@ -551,7 +551,7 @@ async fn execute_http(cfg: &Config, step: &Step, vars: &HashMap) } else { resp.bytes().await?.to_vec() }; - crate::client::HttpResponse { + crate::raw_client::HttpResponse { content_type: resp_ct, bytes, } @@ -569,7 +569,7 @@ async fn execute_http(cfg: &Config, step: &Step, vars: &HashMap) /// - Unrecognised binary responses that cannot be decoded as UTF-8 require /// `output_file` to be set; otherwise an error is returned. fn decode_http_response( - resp: crate::client::HttpResponse, + resp: crate::raw_client::HttpResponse, step: &Step, vars: &HashMap, ) -> Result { diff --git a/src/runbooks/template.rs b/src/runbooks/template.rs index 86843630..ab13c8d4 100644 --- a/src/runbooks/template.rs +++ b/src/runbooks/template.rs @@ -52,10 +52,10 @@ fn resolve_token(inner: &str, vars: &HashMap) -> String { /// Parse a duration string into a `std::time::Duration`. /// -/// Delegates to `crate::util::parse_duration_to_millis` — accepts the same +/// Delegates to `crate::util_ext::parse_duration_to_millis` — accepts the same /// formats (30s, 5m, 1h, 7d, 1w, "5 minutes", etc.). pub fn parse_duration(s: &str) -> anyhow::Result { - let ms = crate::util::parse_duration_to_millis(s)?; + let ms = crate::util_ext::parse_duration_to_millis(s)?; Ok(std::time::Duration::from_millis(ms as u64)) } diff --git a/src/util.rs b/src/util.rs index 260c1c19..785be68c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,148 +1,4 @@ -use std::io::Read; - -use anyhow::{bail, Result}; -use chrono::Utc; -use regex::Regex; -use serde::Serialize; -use serde_json::Value; - -/// Parses a relative duration string like "1h", "30m", "7d" into milliseconds. -/// -/// Supported formats: -/// - Short: "30s", "10m", "1h", "7d", "1w" -/// - Long: "5min", "5minutes", "2hours", "3days", "1week" -/// - With spaces: "5 minutes", "2 hours" -/// - Leading minus is stripped: "-5m" → same as "5m" -fn parse_relative_duration_millis(input: &str) -> Result { - let stripped = input.trim_start_matches('-').trim(); - - let re = Regex::new( - r"(?i)^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$", - ) - .unwrap(); - - if let Some(caps) = re.captures(stripped) { - let num: i64 = caps[1].parse()?; - let unit = caps[2].to_lowercase(); - let seconds = match unit.as_str() { - "s" | "sec" | "secs" | "second" | "seconds" => num, - "m" | "min" | "mins" | "minute" | "minutes" => num * 60, - "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600, - "d" | "day" | "days" => num * 86400, - "w" | "week" | "weeks" => num * 7 * 86400, - _ => bail!("unknown time unit: {}", unit), - }; - return Ok(seconds * 1000); - } - - bail!("unable to parse duration: {input:?}") -} - -/// Parses a time string into Unix milliseconds. -/// -/// Supported formats: -/// - "now" (case-insensitive) -/// - Relative short: "1h", "30m", "7d", "5s", "1w" -/// - Relative long: "5min", "5mins", "5minute", "5minutes", "2hr", "2hours", "3days", "1week" -/// - With spaces: "5 minutes", "2 hours" -/// - With leading minus: "-5m", "-2h" -/// - Unix timestamp in seconds (10 digits or fewer) or milliseconds -/// - RFC3339: "2024-01-01T00:00:00Z" -/// -/// All relative times are interpreted as "ago from now". -/// Returns second-aligned milliseconds (Unix seconds * 1000) to match Go behavior. -pub fn parse_time_to_unix_millis(input: &str) -> Result { - let input = input.trim(); - - if input.eq_ignore_ascii_case("now") { - return Ok(now_millis()); - } - - // Unix timestamp (all digits). Treat 10-digit values as seconds to match - // CLI examples and common shell usage like `date +%s`; preserve longer - // values as milliseconds. - if !input.is_empty() && input.chars().all(|c| c.is_ascii_digit()) { - let ts: i64 = input.parse()?; - return if input.len() <= 10 { - Ok(ts * 1000) - } else { - Ok(ts) - }; - } - - // RFC3339 timestamp - if input.contains('T') { - let dt = chrono::DateTime::parse_from_rfc3339(input)?; - return Ok(dt.timestamp() * 1000); - } - - // Relative time - let millis = parse_relative_duration_millis(input).map_err(|_| { - anyhow::anyhow!( - "unable to parse time: {input:?}\n\ - Expected: now, 1h, 30m, 7d, 5minutes, RFC3339, or Unix timestamp" - ) - })?; - Ok(now_millis() - millis) -} - -/// Convenience: parse to Unix seconds. -pub fn parse_time_to_unix(input: &str) -> Result { - Ok(parse_time_to_unix_millis(input)? / 1000) -} - -/// Parses a time string into a `chrono::DateTime`. -/// -/// Returns an `anyhow` error if the input cannot be parsed or if the resulting -/// timestamp falls outside chrono's representable range. -pub fn parse_time_to_datetime(input: &str) -> Result> { - let ms = parse_time_to_unix_millis(input)?; - chrono::DateTime::from_timestamp_millis(ms) - .ok_or_else(|| anyhow::anyhow!("timestamp out of valid range: {input:?}")) -} - -/// Parses a human-readable duration string into milliseconds. -/// -/// Unlike `parse_time_to_unix_millis`, this does **not** subtract from the -/// current time — it returns the raw duration value in milliseconds. -/// -/// Supported formats: -/// - "now" → 0 -/// - Short: "30s", "10m", "1h", "7d", "1w" -/// - Long: "5min", "5minutes", "2hours", "3days", "1week" -/// - With spaces: "5 minutes", "2 hours" -/// - With leading minus (stripped): "-5m" → same as "5m" -pub fn parse_duration_to_millis(input: &str) -> Result { - let input = input.trim(); - - if input.eq_ignore_ascii_case("now") { - return Ok(0); - } - - parse_relative_duration_millis(input).map_err(|_| { - anyhow::anyhow!( - "unable to parse duration: {input:?}\n\ - Expected: now, 1h, 30m, 7d, 5minutes, etc." - ) - }) -} - -pub fn now_millis() -> i64 { - Utc::now().timestamp() * 1000 -} - -/// Read an entire reader into a `String`, attaching `err_context` on I/O failure. -/// -/// Shared by commands that accept stdin input (e.g. `format::read_input` and -/// `events::resolve_message`) so the read-and-contextualize pattern lives in one -/// place instead of being duplicated per command. -pub fn read_to_string(mut reader: impl Read, err_context: &str) -> Result { - let mut buf = String::new(); - reader - .read_to_string(&mut buf) - .map_err(|e| anyhow::anyhow!("{err_context}: {e}"))?; - Ok(buf) -} +use anyhow::Result; /// Read a JSON file and deserialize into the specified type. /// Used by create/update commands that accept `--file` input. @@ -153,438 +9,10 @@ pub fn read_json_file(path: &str) -> Result { .map_err(|e| anyhow::anyhow!("failed to parse JSON from {path:?}: {e}")) } -// ---- JSON diff helpers ---- - -/// Read-only server-managed fields that are stripped before diffing a monitor. -pub const READONLY_MONITOR_FIELDS: &[&str] = &[ - "id", - "created", - "created_at", - "modified", - "deleted", - "overall_state", - "overall_state_modified", - "creator", - "org_id", - "matching_downtimes", -]; - -/// The type of change in a [`DiffEntry`]. -#[derive(Debug, Serialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum ChangeKind { - /// Field is present in the candidate but not in the live monitor. - Added, - /// Field is present in the live monitor but not in the candidate. - Removed, - /// Field is present in both but with different values. - Modified, -} - -/// A single change record produced by [`diff_json`]. -#[derive(Debug, Serialize, PartialEq)] -pub struct DiffEntry { - /// Dot-notation path to the changed field (e.g. `"options.thresholds.critical"`). - pub path: String, - /// Type of change. - pub change: ChangeKind, - /// Value in `before` (live). `None` for [`ChangeKind::Added`] entries. - #[serde(skip_serializing_if = "Option::is_none")] - pub before: Option, - /// Value in `after` (candidate). `None` for [`ChangeKind::Removed`] entries. - #[serde(skip_serializing_if = "Option::is_none")] - pub after: Option, -} - -/// Remove `readonly` keys at the top level and recursively drop `null`-valued -/// keys so that absent fields and explicit `null`s compare equal. -pub fn normalize_for_diff(v: &mut Value, readonly: &[&str]) { - if let Value::Object(map) = v { - for key in readonly { - map.remove(*key); - } - let keys: Vec = map.keys().cloned().collect(); - for key in keys { - if map[&key].is_null() { - map.remove(&key); - } else { - normalize_for_diff(map.get_mut(&key).unwrap(), &[]); - } - } - } -} - -/// Recursively compare `before` and `after` as `serde_json::Value`s, building -/// dot-notation change records. Objects recurse; scalars and arrays compare as -/// whole values. Returns entries sorted by path for deterministic output. -pub fn diff_json(before: &Value, after: &Value) -> Vec { - let mut entries = Vec::new(); - diff_json_inner(before, after, String::new(), &mut entries); - entries.sort_by(|a, b| a.path.cmp(&b.path)); - entries -} - -fn diff_json_inner(before: &Value, after: &Value, prefix: String, out: &mut Vec) { - match (before, after) { - (Value::Object(b_map), Value::Object(a_map)) => { - // Union of all keys across both objects - let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for k in b_map.keys() { - keys.insert(k.as_str()); - } - for k in a_map.keys() { - keys.insert(k.as_str()); - } - for key in keys { - let child_path = if prefix.is_empty() { - key.to_string() - } else { - format!("{prefix}.{key}") - }; - match (b_map.get(key), a_map.get(key)) { - (Some(b_val), Some(a_val)) => { - diff_json_inner(b_val, a_val, child_path, out); - } - (None, Some(a_val)) => out.push(DiffEntry { - path: child_path, - change: ChangeKind::Added, - before: None, - after: Some(a_val.clone()), - }), - (Some(b_val), None) => out.push(DiffEntry { - path: child_path, - change: ChangeKind::Removed, - before: Some(b_val.clone()), - after: None, - }), - (None, None) => unreachable!(), - } - } - } - _ => { - if before != after { - out.push(DiffEntry { - path: prefix, - change: ChangeKind::Modified, - before: Some(before.clone()), - after: Some(after.clone()), - }); - } - } - } -} - -/// Filter a list of [`DiffEntry`]s by path prefix. -/// -/// - If `only` is non-empty, keep only entries whose path equals or starts -/// with one of the `only` values (prefix match: `"options.thresholds"` matches -/// `"options.thresholds"` and `"options.thresholds.critical"`). -/// - Drop entries whose path equals or starts with any value in `ignore`. -/// - Empty slices are no-ops. -pub fn scope_diff(entries: Vec, only: &[String], ignore: &[String]) -> Vec { - // Returns true when `path` equals `filter` exactly or is a sub-path of it - // (i.e. starts with `filter.`). The dot-suffix check prevents "option" from - // accidentally matching "options.thresholds". - fn path_matches(path: &str, filter: &str) -> bool { - path == filter - || (path.starts_with(filter) && path.as_bytes().get(filter.len()) == Some(&b'.')) - } - - entries - .into_iter() - .filter(|e| { - if !only.is_empty() && !only.iter().any(|f| path_matches(&e.path, f)) { - return false; - } - if !ignore.is_empty() && ignore.iter().any(|f| path_matches(&e.path, f)) { - return false; - } - true - }) - .collect() -} - -/// Parses a UUID string, returning a descriptive error if invalid. -pub fn parse_uuid(id: &str, label: &str) -> anyhow::Result { - uuid::Uuid::parse_str(id).map_err(|e| anyhow::anyhow!("invalid {label} UUID '{id}': {e}")) -} - -/// Parse a compute string like "count", "avg(@duration)", "percentile(@duration, 99)" -/// into a (function_name, Option) pair as raw strings. -pub(crate) fn parse_compute_raw(input: &str) -> Result<(String, Option)> { - let input = input.trim(); - if input.is_empty() { - bail!("--compute is required"); - } - - // Simple aggregations without a metric - if input == "count" { - return Ok(("count".into(), None)); - } - - // func(@field) pattern - if let Some(paren) = input.find('(') { - let func = &input[..paren]; - let rest = input[paren + 1..].trim_end_matches(')').trim(); - - // Handle percentile(@field, N) - if func == "percentile" { - let parts: Vec<&str> = rest.splitn(2, ',').collect(); - if parts.len() != 2 { - bail!("percentile requires field and value: percentile(@duration, 99)"); - } - let metric = parts[0].trim().to_string(); - let pct: u32 = parts[1] - .trim() - .parse() - .map_err(|_| anyhow::anyhow!("invalid percentile value: {}", parts[1].trim()))?; - let agg_name = match pct { - 75 => "pc75", - 90 => "pc90", - 95 => "pc95", - 98 => "pc98", - 99 => "pc99", - _ => bail!("unsupported percentile: {pct} (supported: 75, 90, 95, 98, 99)"), - }; - return Ok((agg_name.into(), Some(metric))); - } - - let metric = rest.to_string(); - let agg_name = match func { - "avg" | "sum" | "min" | "max" | "median" | "cardinality" => func.to_string(), - "count" => bail!("count does not accept a field argument; use just 'count'"), - _ => bail!("unknown aggregation function: {func}"), - }; - return Ok((agg_name, Some(metric))); - } - - bail!( - "invalid --compute format: {input:?}\n\ - Expected: count, avg(@duration), sum(@duration), percentile(@duration, 99), etc." - ) -} - -/// Percent-encode a string for safe use in URL paths and query values. -/// -/// Unreserved characters (RFC 3986 §2.3) — `A-Z a-z 0-9 - _ . ~` — are -/// passed through; every other byte is encoded as `%XX`. Spaces become -/// `%20`, not `+`. -/// -/// The implementation is intentionally hand-rolled rather than pulling in the -/// `percent-encoding` crate: that crate is only a transitive dependency (via -/// `url`/`reqwest`) and its `AsciiSet` API requires more boilerplate than this -/// 8-line loop for the single RFC 3986 unreserved-character set we need. -pub fn percent_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for &b in s.as_bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{b:02X}")), - } - } - out -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_now() { - let ms = parse_time_to_unix_millis("now").unwrap(); - let diff = (Utc::now().timestamp() * 1000 - ms).abs(); - assert!(diff < 2000, "now should be within 2s: diff={diff}ms"); - } - - #[test] - fn test_now_case_insensitive() { - assert!(parse_time_to_unix_millis("NOW").is_ok()); - assert!(parse_time_to_unix_millis("Now").is_ok()); - } - - #[test] - fn test_relative_short() { - let ms = parse_time_to_unix_millis("1h").unwrap(); - let expected = (Utc::now().timestamp() - 3600) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_long() { - let ms = parse_time_to_unix_millis("5minutes").unwrap(); - let expected = (Utc::now().timestamp() - 300) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_with_spaces() { - let ms = parse_time_to_unix_millis("5 minutes").unwrap(); - let expected = (Utc::now().timestamp() - 300) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_with_minus() { - let ms = parse_time_to_unix_millis("-30m").unwrap(); - let expected = (Utc::now().timestamp() - 1800) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_unix_timestamp() { - let ms = parse_time_to_unix_millis("1700000000000").unwrap(); - assert_eq!(ms, 1700000000000); - } - - #[test] - fn test_unix_timestamp_seconds_are_expanded_to_millis() { - let ms = parse_time_to_unix_millis("1707048000").unwrap(); - assert_eq!(ms, 1707048000000); - } - - #[test] - fn test_unix_timestamp_milliseconds_are_preserved() { - let ms = parse_time_to_unix_millis("1707048000000").unwrap(); - assert_eq!(ms, 1707048000000); - } - - #[test] - fn test_rfc3339() { - let ms = parse_time_to_unix_millis("2024-01-01T00:00:00Z").unwrap(); - assert_eq!(ms, 1704067200000); - } - - #[test] - fn test_invalid() { - assert!(parse_time_to_unix_millis("invalid").is_err()); - assert!(parse_time_to_unix_millis("").is_err()); - } - - #[test] - fn test_parse_time_to_datetime_relative() { - let dt = parse_time_to_datetime("1h").unwrap(); - let expected = Utc::now().timestamp() - 3600; - assert!((dt.timestamp() - expected).abs() < 2); - } - - #[test] - fn test_parse_time_to_datetime_long_form() { - let dt = parse_time_to_datetime("2hours").unwrap(); - let expected = Utc::now().timestamp() - 7200; - assert!((dt.timestamp() - expected).abs() < 2); - } - - #[test] - fn test_parse_time_to_datetime_unix_millis() { - let dt = parse_time_to_datetime("1700000000000").unwrap(); - assert_eq!(dt.timestamp_millis(), 1700000000000); - } - - #[test] - fn test_parse_time_to_datetime_rfc3339() { - let dt = parse_time_to_datetime("2024-01-01T00:00:00Z").unwrap(); - assert_eq!(dt.timestamp_millis(), 1704067200000); - } - - #[test] - fn test_parse_time_to_datetime_invalid_input() { - let err = parse_time_to_datetime("not-a-time").unwrap_err(); - assert!(err.to_string().contains("unable to parse time")); - } - - #[test] - fn test_parse_time_to_datetime_empty() { - assert!(parse_time_to_datetime("").is_err()); - } - - #[test] - fn test_parse_time_to_unix_returns_seconds() { - let secs = parse_time_to_unix("1700000000000").unwrap(); - assert_eq!(secs, 1700000000); - } - - #[test] - fn test_parse_time_to_unix_now() { - let secs = parse_time_to_unix("now").unwrap(); - let expected = Utc::now().timestamp(); - assert!((secs - expected).abs() < 2); - } - - #[test] - fn test_relative_days() { - let ms = parse_time_to_unix_millis("7d").unwrap(); - let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_weeks() { - let ms = parse_time_to_unix_millis("1w").unwrap(); - let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_seconds() { - let ms = parse_time_to_unix_millis("30s").unwrap(); - let expected = (Utc::now().timestamp() - 30) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_long_hours() { - let ms = parse_time_to_unix_millis("2hours").unwrap(); - let expected = (Utc::now().timestamp() - 7200) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_long_days() { - let ms = parse_time_to_unix_millis("3days").unwrap(); - let expected = (Utc::now().timestamp() - 3 * 86400) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_relative_long_weeks() { - let ms = parse_time_to_unix_millis("1week").unwrap(); - let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; - assert!((ms - expected).abs() < 2000); - } - - #[test] - fn test_percent_encode_unreserved_passthrough() { - // RFC 3986 §2.3 unreserved characters must not be encoded. - assert_eq!(percent_encode("abc"), "abc"); - assert_eq!(percent_encode("foo.bar"), "foo.bar"); - assert_eq!(percent_encode("foo-bar_baz"), "foo-bar_baz"); - assert_eq!(percent_encode("UPPER"), "UPPER"); - assert_eq!(percent_encode("123"), "123"); - assert_eq!(percent_encode("foo~bar"), "foo~bar"); // tilde is unreserved - } - - #[test] - fn test_percent_encode_special_chars() { - assert_eq!(percent_encode("env:prod"), "env%3Aprod"); - assert_eq!(percent_encode("k8s cluster"), "k8s%20cluster"); - assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc"); - assert_eq!(percent_encode("path/value"), "path%2Fvalue"); - } - - #[test] - fn test_percent_encode_empty() { - assert_eq!(percent_encode(""), ""); - } - - #[test] - fn test_percent_encode_multibyte_utf8() { - // Multi-byte UTF-8 characters must be encoded byte-by-byte per RFC 3986. - // "é" is 0xC3 0xA9 in UTF-8. - assert_eq!(percent_encode("café"), "caf%C3%A9"); - } - #[test] fn test_read_json_file_missing() { let result: Result = read_json_file("/tmp/__pup_nonexistent__.json"); @@ -611,322 +39,4 @@ mod tests { assert_eq!(result.unwrap()["name"], "test"); std::fs::remove_file(&path).ok(); } - - #[test] - fn test_duration_10m() { - assert_eq!(parse_duration_to_millis("10m").unwrap(), 600_000); - } - - #[test] - fn test_duration_1h() { - assert_eq!(parse_duration_to_millis("1h").unwrap(), 3_600_000); - } - - #[test] - fn test_duration_30s() { - assert_eq!(parse_duration_to_millis("30s").unwrap(), 30_000); - } - - #[test] - fn test_duration_7d() { - assert_eq!(parse_duration_to_millis("7d").unwrap(), 604_800_000); - } - - #[test] - fn test_duration_5minutes() { - assert_eq!(parse_duration_to_millis("5minutes").unwrap(), 300_000); - } - - #[test] - fn test_duration_now() { - assert_eq!(parse_duration_to_millis("now").unwrap(), 0); - } - - #[test] - fn test_parse_compute_count() { - let (aggregation, metric) = parse_compute_raw("count").unwrap(); - assert_eq!(aggregation, "count"); - assert!(metric.is_none()); - } - - #[test] - fn test_parse_compute_avg() { - let (aggregation, metric) = parse_compute_raw("avg(@duration)").unwrap(); - assert_eq!(aggregation, "avg"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_sum() { - let (aggregation, metric) = parse_compute_raw("sum(@duration)").unwrap(); - assert_eq!(aggregation, "sum"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_min() { - let (aggregation, metric) = parse_compute_raw("min(@duration)").unwrap(); - assert_eq!(aggregation, "min"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_max() { - let (aggregation, metric) = parse_compute_raw("max(@duration)").unwrap(); - assert_eq!(aggregation, "max"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_median() { - let (aggregation, metric) = parse_compute_raw("median(@duration)").unwrap(); - assert_eq!(aggregation, "median"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_cardinality() { - let (aggregation, metric) = parse_compute_raw("cardinality(@usr.id)").unwrap(); - assert_eq!(aggregation, "cardinality"); - assert_eq!(metric.unwrap(), "@usr.id"); - } - - #[test] - fn test_parse_compute_percentile_99() { - let (aggregation, metric) = parse_compute_raw("percentile(@duration, 99)").unwrap(); - assert_eq!(aggregation, "pc99"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_percentile_95() { - let (aggregation, metric) = parse_compute_raw("percentile(@duration, 95)").unwrap(); - assert_eq!(aggregation, "pc95"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_percentile_90() { - let (aggregation, metric) = parse_compute_raw("percentile(@duration, 90)").unwrap(); - assert_eq!(aggregation, "pc90"); - assert_eq!(metric.unwrap(), "@duration"); - } - - #[test] - fn test_parse_compute_empty() { - assert!(parse_compute_raw("").is_err()); - } - - #[test] - fn test_parse_compute_invalid() { - assert!(parse_compute_raw("invalid").is_err()); - } - - #[test] - fn test_parse_compute_unknown_function() { - assert!(parse_compute_raw("foo(@bar)").is_err()); - } - - #[test] - fn test_parse_compute_unsupported_percentile() { - assert!(parse_compute_raw("percentile(@duration, 42)").is_err()); - } - - #[test] - fn test_parse_compute_percentile_missing_value() { - assert!(parse_compute_raw("percentile(@duration)").is_err()); - } - - #[test] - fn test_parse_compute_rejects_invalid_count_metric() { - let err = parse_compute_raw("count(@duration)").unwrap_err(); - assert!(err.to_string().contains("does not accept a field")); - } - - // ---- diff_json ---- - - #[test] - fn test_diff_json_identical() { - let v: Value = serde_json::json!({"name": "cpu", "query": "avg:system.cpu.user{*} > 90"}); - assert!(diff_json(&v, &v).is_empty()); - } - - #[test] - fn test_diff_json_modified_scalar() { - let before = serde_json::json!({"options": {"thresholds": {"critical": 90}}}); - let after = serde_json::json!({"options": {"thresholds": {"critical": 95}}}); - let entries = diff_json(&before, &after); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path, "options.thresholds.critical"); - assert_eq!(entries[0].change, ChangeKind::Modified); - assert_eq!(entries[0].before, Some(serde_json::json!(90))); - assert_eq!(entries[0].after, Some(serde_json::json!(95))); - } - - #[test] - fn test_diff_json_added_field() { - let before = serde_json::json!({"name": "cpu"}); - let after = serde_json::json!({"name": "cpu", "message": "alert!"}); - let entries = diff_json(&before, &after); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path, "message"); - assert_eq!(entries[0].change, ChangeKind::Added); - assert_eq!(entries[0].before, None); - assert_eq!(entries[0].after, Some(serde_json::json!("alert!"))); - } - - #[test] - fn test_diff_json_removed_field() { - let before = serde_json::json!({"name": "cpu", "message": "alert!"}); - let after = serde_json::json!({"name": "cpu"}); - let entries = diff_json(&before, &after); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path, "message"); - assert_eq!(entries[0].change, ChangeKind::Removed); - assert_eq!(entries[0].before, Some(serde_json::json!("alert!"))); - assert_eq!(entries[0].after, None); - } - - #[test] - fn test_diff_json_sorted_paths() { - let before = serde_json::json!({"z": 1, "a": 1, "m": 1}); - let after = serde_json::json!({"z": 2, "a": 2, "m": 2}); - let entries = diff_json(&before, &after); - let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); - assert_eq!(paths, vec!["a", "m", "z"]); - } - - #[test] - fn test_diff_json_array_compared_whole() { - let before = serde_json::json!({"tags": ["env:prod", "team:backend"]}); - let after = serde_json::json!({"tags": ["team:backend", "env:prod"]}); - let entries = diff_json(&before, &after); - // Arrays are compared as whole values; reordered tags → modified - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].path, "tags"); - assert_eq!(entries[0].change, ChangeKind::Modified); - } - - // ---- normalize_for_diff ---- - - #[test] - fn test_normalize_strips_readonly_fields() { - let mut v = serde_json::json!({ - "id": 12345, - "name": "cpu", - "created": "2024-01-01", - "overall_state": "OK", - "org_id": 999, - }); - normalize_for_diff(&mut v, READONLY_MONITOR_FIELDS); - let obj = v.as_object().unwrap(); - assert!(!obj.contains_key("id")); - assert!(!obj.contains_key("created")); - assert!(!obj.contains_key("overall_state")); - assert!(!obj.contains_key("org_id")); - assert!(obj.contains_key("name")); - } - - #[test] - fn test_normalize_drops_null_values() { - let mut v = serde_json::json!({"name": "cpu", "message": null, "priority": null}); - normalize_for_diff(&mut v, &[]); - let obj = v.as_object().unwrap(); - assert!(!obj.contains_key("message")); - assert!(!obj.contains_key("priority")); - assert!(obj.contains_key("name")); - } - - #[test] - fn test_normalize_absent_and_null_compare_equal() { - // After normalizing, absent and null fields are both gone → no diff entry - let mut live = serde_json::json!({"name": "cpu", "priority": null}); - let mut candidate = serde_json::json!({"name": "cpu"}); - normalize_for_diff(&mut live, &[]); - normalize_for_diff(&mut candidate, &[]); - assert!(diff_json(&live, &candidate).is_empty()); - } - - // ---- scope_diff ---- - - // Shorthand for building a Modified DiffEntry in scope_diff tests. - fn modified(path: &str) -> DiffEntry { - DiffEntry { - path: path.into(), - change: ChangeKind::Modified, - before: Some(serde_json::json!("a")), - after: Some(serde_json::json!("b")), - } - } - - #[test] - fn test_scope_diff_empty_filters_noop() { - let entries = vec![modified("name"), modified("options.thresholds.critical")]; - assert_eq!(scope_diff(entries, &[], &[]).len(), 2); - } - - #[test] - fn test_scope_diff_only_prefix() { - let entries = vec![ - modified("name"), - modified("options.thresholds.critical"), - modified("options.thresholds.warning"), - ]; - let only = vec!["options.thresholds".to_string()]; - let result = scope_diff(entries, &only, &[]); - assert_eq!(result.len(), 2); - assert!(result - .iter() - .all(|e| e.path.starts_with("options.thresholds"))); - } - - #[test] - fn test_scope_diff_only_exact_match() { - let entries = vec![modified("options"), modified("name")]; - let only = vec!["options".to_string()]; - let result = scope_diff(entries, &only, &[]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].path, "options"); - } - - #[test] - fn test_scope_diff_only_partial_prefix_does_not_match() { - // "option" (no trailing dot) must NOT match "options.thresholds.critical". - // The path_matches helper uses a dot-boundary check to prevent this. - let entries = vec![modified("options.thresholds.critical")]; - let only = vec!["option".to_string()]; - assert!(scope_diff(entries, &only, &[]).is_empty()); - } - - #[test] - fn test_scope_diff_ignore() { - let entries = vec![modified("message"), modified("name")]; - let ignore = vec!["message".to_string()]; - let result = scope_diff(entries, &[], &ignore); - assert_eq!(result.len(), 1); - assert_eq!(result[0].path, "name"); - } - - #[test] - fn test_scope_diff_ignore_prefix() { - let entries = vec![modified("options.thresholds.critical"), modified("name")]; - let ignore = vec!["options".to_string()]; - let result = scope_diff(entries, &[], &ignore); - assert_eq!(result.len(), 1); - assert_eq!(result[0].path, "name"); - } - - #[test] - fn test_scope_diff_only_and_ignore_combined() { - let entries = vec![ - modified("options.thresholds.critical"), - modified("options.thresholds.warning"), - modified("name"), - ]; - let only = vec!["options.thresholds".to_string()]; - let ignore = vec!["options.thresholds.warning".to_string()]; - let result = scope_diff(entries, &only, &ignore); - assert_eq!(result.len(), 1); - assert_eq!(result[0].path, "options.thresholds.critical"); - } } diff --git a/src/util_ext.rs b/src/util_ext.rs new file mode 100644 index 00000000..c5ef162c --- /dev/null +++ b/src/util_ext.rs @@ -0,0 +1,894 @@ +//! Hand-written pup command utilities: time/duration parsing, the monitor-diff +//! helpers, compute-string parsing, and percent-encoding. None of this is used by +//! generated command modules (only `util::read_json_file` is) -- kept here as pup- +//! owned code the generator never touches. + +use std::io::Read; + +use anyhow::{bail, Result}; +use chrono::Utc; +use regex::Regex; +use serde::Serialize; +use serde_json::Value; + +fn parse_relative_duration_millis(input: &str) -> Result { + let stripped = input.trim_start_matches('-').trim(); + + let re = Regex::new( + r"(?i)^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$", + ) + .unwrap(); + + if let Some(caps) = re.captures(stripped) { + let num: i64 = caps[1].parse()?; + let unit = caps[2].to_lowercase(); + let seconds = match unit.as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => num, + "m" | "min" | "mins" | "minute" | "minutes" => num * 60, + "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600, + "d" | "day" | "days" => num * 86400, + "w" | "week" | "weeks" => num * 7 * 86400, + _ => bail!("unknown time unit: {}", unit), + }; + return Ok(seconds * 1000); + } + + bail!("unable to parse duration: {input:?}") +} + +/// Parses a time string into Unix milliseconds. +/// +/// Supported formats: +/// - "now" (case-insensitive) +/// - Relative short: "1h", "30m", "7d", "5s", "1w" +/// - Relative long: "5min", "5mins", "5minute", "5minutes", "2hr", "2hours", "3days", "1week" +/// - With spaces: "5 minutes", "2 hours" +/// - With leading minus: "-5m", "-2h" +/// - Unix timestamp in seconds (10 digits or fewer) or milliseconds +/// - RFC3339: "2024-01-01T00:00:00Z" +/// +/// All relative times are interpreted as "ago from now". +/// Returns second-aligned milliseconds (Unix seconds * 1000) to match Go behavior. +pub fn parse_time_to_unix_millis(input: &str) -> Result { + let input = input.trim(); + + if input.eq_ignore_ascii_case("now") { + return Ok(now_millis()); + } + + // Unix timestamp (all digits). Treat 10-digit values as seconds to match + // CLI examples and common shell usage like `date +%s`; preserve longer + // values as milliseconds. + if !input.is_empty() && input.chars().all(|c| c.is_ascii_digit()) { + let ts: i64 = input.parse()?; + return if input.len() <= 10 { + Ok(ts * 1000) + } else { + Ok(ts) + }; + } + + // RFC3339 timestamp + if input.contains('T') { + let dt = chrono::DateTime::parse_from_rfc3339(input)?; + return Ok(dt.timestamp() * 1000); + } + + // Relative time + let millis = parse_relative_duration_millis(input).map_err(|_| { + anyhow::anyhow!( + "unable to parse time: {input:?}\n\ + Expected: now, 1h, 30m, 7d, 5minutes, RFC3339, or Unix timestamp" + ) + })?; + Ok(now_millis() - millis) +} + +/// Convenience: parse to Unix seconds. +pub fn parse_time_to_unix(input: &str) -> Result { + Ok(parse_time_to_unix_millis(input)? / 1000) +} + +/// Parses a time string into a `chrono::DateTime`. +/// +/// Returns an `anyhow` error if the input cannot be parsed or if the resulting +/// timestamp falls outside chrono's representable range. +pub fn parse_time_to_datetime(input: &str) -> Result> { + let ms = parse_time_to_unix_millis(input)?; + chrono::DateTime::from_timestamp_millis(ms) + .ok_or_else(|| anyhow::anyhow!("timestamp out of valid range: {input:?}")) +} + +/// Parses a human-readable duration string into milliseconds. +/// +/// Unlike `parse_time_to_unix_millis`, this does **not** subtract from the +/// current time — it returns the raw duration value in milliseconds. +/// +/// Supported formats: +/// - "now" → 0 +/// - Short: "30s", "10m", "1h", "7d", "1w" +/// - Long: "5min", "5minutes", "2hours", "3days", "1week" +/// - With spaces: "5 minutes", "2 hours" +/// - With leading minus (stripped): "-5m" → same as "5m" +pub fn parse_duration_to_millis(input: &str) -> Result { + let input = input.trim(); + + if input.eq_ignore_ascii_case("now") { + return Ok(0); + } + + parse_relative_duration_millis(input).map_err(|_| { + anyhow::anyhow!( + "unable to parse duration: {input:?}\n\ + Expected: now, 1h, 30m, 7d, 5minutes, etc." + ) + }) +} + +pub fn now_millis() -> i64 { + Utc::now().timestamp() * 1000 +} + +/// Read an entire reader into a `String`, attaching `err_context` on I/O failure. +/// +/// Shared by commands that accept stdin input (e.g. `format::read_input` and +/// `events::resolve_message`) so the read-and-contextualize pattern lives in one +/// place instead of being duplicated per command. +pub fn read_to_string(mut reader: impl Read, err_context: &str) -> Result { + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .map_err(|e| anyhow::anyhow!("{err_context}: {e}"))?; + Ok(buf) +} + +// ---- JSON diff helpers ---- + +/// Read-only server-managed fields that are stripped before diffing a monitor. +pub const READONLY_MONITOR_FIELDS: &[&str] = &[ + "id", + "created", + "created_at", + "modified", + "deleted", + "overall_state", + "overall_state_modified", + "creator", + "org_id", + "matching_downtimes", +]; + +/// The type of change in a [`DiffEntry`]. +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ChangeKind { + /// Field is present in the candidate but not in the live monitor. + Added, + /// Field is present in the live monitor but not in the candidate. + Removed, + /// Field is present in both but with different values. + Modified, +} + +/// A single change record produced by [`diff_json`]. +#[derive(Debug, Serialize, PartialEq)] +pub struct DiffEntry { + /// Dot-notation path to the changed field (e.g. `"options.thresholds.critical"`). + pub path: String, + /// Type of change. + pub change: ChangeKind, + /// Value in `before` (live). `None` for [`ChangeKind::Added`] entries. + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + /// Value in `after` (candidate). `None` for [`ChangeKind::Removed`] entries. + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +/// Remove `readonly` keys at the top level and recursively drop `null`-valued +/// keys so that absent fields and explicit `null`s compare equal. +pub fn normalize_for_diff(v: &mut Value, readonly: &[&str]) { + if let Value::Object(map) = v { + for key in readonly { + map.remove(*key); + } + let keys: Vec = map.keys().cloned().collect(); + for key in keys { + if map[&key].is_null() { + map.remove(&key); + } else { + normalize_for_diff(map.get_mut(&key).unwrap(), &[]); + } + } + } +} + +/// Recursively compare `before` and `after` as `serde_json::Value`s, building +/// dot-notation change records. Objects recurse; scalars and arrays compare as +/// whole values. Returns entries sorted by path for deterministic output. +pub fn diff_json(before: &Value, after: &Value) -> Vec { + let mut entries = Vec::new(); + diff_json_inner(before, after, String::new(), &mut entries); + entries.sort_by(|a, b| a.path.cmp(&b.path)); + entries +} + +fn diff_json_inner(before: &Value, after: &Value, prefix: String, out: &mut Vec) { + match (before, after) { + (Value::Object(b_map), Value::Object(a_map)) => { + // Union of all keys across both objects + let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for k in b_map.keys() { + keys.insert(k.as_str()); + } + for k in a_map.keys() { + keys.insert(k.as_str()); + } + for key in keys { + let child_path = if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + }; + match (b_map.get(key), a_map.get(key)) { + (Some(b_val), Some(a_val)) => { + diff_json_inner(b_val, a_val, child_path, out); + } + (None, Some(a_val)) => out.push(DiffEntry { + path: child_path, + change: ChangeKind::Added, + before: None, + after: Some(a_val.clone()), + }), + (Some(b_val), None) => out.push(DiffEntry { + path: child_path, + change: ChangeKind::Removed, + before: Some(b_val.clone()), + after: None, + }), + (None, None) => unreachable!(), + } + } + } + _ => { + if before != after { + out.push(DiffEntry { + path: prefix, + change: ChangeKind::Modified, + before: Some(before.clone()), + after: Some(after.clone()), + }); + } + } + } +} + +/// Filter a list of [`DiffEntry`]s by path prefix. +/// +/// - If `only` is non-empty, keep only entries whose path equals or starts +/// with one of the `only` values (prefix match: `"options.thresholds"` matches +/// `"options.thresholds"` and `"options.thresholds.critical"`). +/// - Drop entries whose path equals or starts with any value in `ignore`. +/// - Empty slices are no-ops. +pub fn scope_diff(entries: Vec, only: &[String], ignore: &[String]) -> Vec { + // Returns true when `path` equals `filter` exactly or is a sub-path of it + // (i.e. starts with `filter.`). The dot-suffix check prevents "option" from + // accidentally matching "options.thresholds". + fn path_matches(path: &str, filter: &str) -> bool { + path == filter + || (path.starts_with(filter) && path.as_bytes().get(filter.len()) == Some(&b'.')) + } + + entries + .into_iter() + .filter(|e| { + if !only.is_empty() && !only.iter().any(|f| path_matches(&e.path, f)) { + return false; + } + if !ignore.is_empty() && ignore.iter().any(|f| path_matches(&e.path, f)) { + return false; + } + true + }) + .collect() +} + +/// Parses a UUID string, returning a descriptive error if invalid. +pub fn parse_uuid(id: &str, label: &str) -> anyhow::Result { + uuid::Uuid::parse_str(id).map_err(|e| anyhow::anyhow!("invalid {label} UUID '{id}': {e}")) +} + +/// Parse a compute string like "count", "avg(@duration)", "percentile(@duration, 99)" +/// into a (function_name, Option) pair as raw strings. +pub(crate) fn parse_compute_raw(input: &str) -> Result<(String, Option)> { + let input = input.trim(); + if input.is_empty() { + bail!("--compute is required"); + } + + // Simple aggregations without a metric + if input == "count" { + return Ok(("count".into(), None)); + } + + // func(@field) pattern + if let Some(paren) = input.find('(') { + let func = &input[..paren]; + let rest = input[paren + 1..].trim_end_matches(')').trim(); + + // Handle percentile(@field, N) + if func == "percentile" { + let parts: Vec<&str> = rest.splitn(2, ',').collect(); + if parts.len() != 2 { + bail!("percentile requires field and value: percentile(@duration, 99)"); + } + let metric = parts[0].trim().to_string(); + let pct: u32 = parts[1] + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("invalid percentile value: {}", parts[1].trim()))?; + let agg_name = match pct { + 75 => "pc75", + 90 => "pc90", + 95 => "pc95", + 98 => "pc98", + 99 => "pc99", + _ => bail!("unsupported percentile: {pct} (supported: 75, 90, 95, 98, 99)"), + }; + return Ok((agg_name.into(), Some(metric))); + } + + let metric = rest.to_string(); + let agg_name = match func { + "avg" | "sum" | "min" | "max" | "median" | "cardinality" => func.to_string(), + "count" => bail!("count does not accept a field argument; use just 'count'"), + _ => bail!("unknown aggregation function: {func}"), + }; + return Ok((agg_name, Some(metric))); + } + + bail!( + "invalid --compute format: {input:?}\n\ + Expected: count, avg(@duration), sum(@duration), percentile(@duration, 99), etc." + ) +} + +/// Percent-encode a string for safe use in URL paths and query values. +/// +/// Unreserved characters (RFC 3986 §2.3) — `A-Z a-z 0-9 - _ . ~` — are +/// passed through; every other byte is encoded as `%XX`. Spaces become +/// `%20`, not `+`. +/// +/// The implementation is intentionally hand-rolled rather than pulling in the +/// `percent-encoding` crate: that crate is only a transitive dependency (via +/// `url`/`reqwest`) and its `AsciiSet` API requires more boilerplate than this +/// 8-line loop for the single RFC 3986 unreserved-character set we need. +pub fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_now() { + let ms = parse_time_to_unix_millis("now").unwrap(); + let diff = (Utc::now().timestamp() * 1000 - ms).abs(); + assert!(diff < 2000, "now should be within 2s: diff={diff}ms"); + } + + #[test] + fn test_now_case_insensitive() { + assert!(parse_time_to_unix_millis("NOW").is_ok()); + assert!(parse_time_to_unix_millis("Now").is_ok()); + } + + #[test] + fn test_relative_short() { + let ms = parse_time_to_unix_millis("1h").unwrap(); + let expected = (Utc::now().timestamp() - 3600) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_long() { + let ms = parse_time_to_unix_millis("5minutes").unwrap(); + let expected = (Utc::now().timestamp() - 300) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_with_spaces() { + let ms = parse_time_to_unix_millis("5 minutes").unwrap(); + let expected = (Utc::now().timestamp() - 300) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_with_minus() { + let ms = parse_time_to_unix_millis("-30m").unwrap(); + let expected = (Utc::now().timestamp() - 1800) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_unix_timestamp() { + let ms = parse_time_to_unix_millis("1700000000000").unwrap(); + assert_eq!(ms, 1700000000000); + } + + #[test] + fn test_unix_timestamp_seconds_are_expanded_to_millis() { + let ms = parse_time_to_unix_millis("1707048000").unwrap(); + assert_eq!(ms, 1707048000000); + } + + #[test] + fn test_unix_timestamp_milliseconds_are_preserved() { + let ms = parse_time_to_unix_millis("1707048000000").unwrap(); + assert_eq!(ms, 1707048000000); + } + + #[test] + fn test_rfc3339() { + let ms = parse_time_to_unix_millis("2024-01-01T00:00:00Z").unwrap(); + assert_eq!(ms, 1704067200000); + } + + #[test] + fn test_invalid() { + assert!(parse_time_to_unix_millis("invalid").is_err()); + assert!(parse_time_to_unix_millis("").is_err()); + } + + #[test] + fn test_parse_time_to_datetime_relative() { + let dt = parse_time_to_datetime("1h").unwrap(); + let expected = Utc::now().timestamp() - 3600; + assert!((dt.timestamp() - expected).abs() < 2); + } + + #[test] + fn test_parse_time_to_datetime_long_form() { + let dt = parse_time_to_datetime("2hours").unwrap(); + let expected = Utc::now().timestamp() - 7200; + assert!((dt.timestamp() - expected).abs() < 2); + } + + #[test] + fn test_parse_time_to_datetime_unix_millis() { + let dt = parse_time_to_datetime("1700000000000").unwrap(); + assert_eq!(dt.timestamp_millis(), 1700000000000); + } + + #[test] + fn test_parse_time_to_datetime_rfc3339() { + let dt = parse_time_to_datetime("2024-01-01T00:00:00Z").unwrap(); + assert_eq!(dt.timestamp_millis(), 1704067200000); + } + + #[test] + fn test_parse_time_to_datetime_invalid_input() { + let err = parse_time_to_datetime("not-a-time").unwrap_err(); + assert!(err.to_string().contains("unable to parse time")); + } + + #[test] + fn test_parse_time_to_datetime_empty() { + assert!(parse_time_to_datetime("").is_err()); + } + + #[test] + fn test_parse_time_to_unix_returns_seconds() { + let secs = parse_time_to_unix("1700000000000").unwrap(); + assert_eq!(secs, 1700000000); + } + + #[test] + fn test_parse_time_to_unix_now() { + let secs = parse_time_to_unix("now").unwrap(); + let expected = Utc::now().timestamp(); + assert!((secs - expected).abs() < 2); + } + + #[test] + fn test_relative_days() { + let ms = parse_time_to_unix_millis("7d").unwrap(); + let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_weeks() { + let ms = parse_time_to_unix_millis("1w").unwrap(); + let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_seconds() { + let ms = parse_time_to_unix_millis("30s").unwrap(); + let expected = (Utc::now().timestamp() - 30) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_long_hours() { + let ms = parse_time_to_unix_millis("2hours").unwrap(); + let expected = (Utc::now().timestamp() - 7200) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_long_days() { + let ms = parse_time_to_unix_millis("3days").unwrap(); + let expected = (Utc::now().timestamp() - 3 * 86400) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_relative_long_weeks() { + let ms = parse_time_to_unix_millis("1week").unwrap(); + let expected = (Utc::now().timestamp() - 7 * 86400) * 1000; + assert!((ms - expected).abs() < 2000); + } + + #[test] + fn test_percent_encode_unreserved_passthrough() { + // RFC 3986 §2.3 unreserved characters must not be encoded. + assert_eq!(percent_encode("abc"), "abc"); + assert_eq!(percent_encode("foo.bar"), "foo.bar"); + assert_eq!(percent_encode("foo-bar_baz"), "foo-bar_baz"); + assert_eq!(percent_encode("UPPER"), "UPPER"); + assert_eq!(percent_encode("123"), "123"); + assert_eq!(percent_encode("foo~bar"), "foo~bar"); // tilde is unreserved + } + + #[test] + fn test_percent_encode_special_chars() { + assert_eq!(percent_encode("env:prod"), "env%3Aprod"); + assert_eq!(percent_encode("k8s cluster"), "k8s%20cluster"); + assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc"); + assert_eq!(percent_encode("path/value"), "path%2Fvalue"); + } + + #[test] + fn test_percent_encode_empty() { + assert_eq!(percent_encode(""), ""); + } + + #[test] + fn test_percent_encode_multibyte_utf8() { + // Multi-byte UTF-8 characters must be encoded byte-by-byte per RFC 3986. + // "é" is 0xC3 0xA9 in UTF-8. + assert_eq!(percent_encode("café"), "caf%C3%A9"); + } + + #[test] + fn test_duration_10m() { + assert_eq!(parse_duration_to_millis("10m").unwrap(), 600_000); + } + + #[test] + fn test_duration_1h() { + assert_eq!(parse_duration_to_millis("1h").unwrap(), 3_600_000); + } + + #[test] + fn test_duration_30s() { + assert_eq!(parse_duration_to_millis("30s").unwrap(), 30_000); + } + + #[test] + fn test_duration_7d() { + assert_eq!(parse_duration_to_millis("7d").unwrap(), 604_800_000); + } + + #[test] + fn test_duration_5minutes() { + assert_eq!(parse_duration_to_millis("5minutes").unwrap(), 300_000); + } + + #[test] + fn test_duration_now() { + assert_eq!(parse_duration_to_millis("now").unwrap(), 0); + } + + #[test] + fn test_parse_compute_count() { + let (aggregation, metric) = parse_compute_raw("count").unwrap(); + assert_eq!(aggregation, "count"); + assert!(metric.is_none()); + } + + #[test] + fn test_parse_compute_avg() { + let (aggregation, metric) = parse_compute_raw("avg(@duration)").unwrap(); + assert_eq!(aggregation, "avg"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_sum() { + let (aggregation, metric) = parse_compute_raw("sum(@duration)").unwrap(); + assert_eq!(aggregation, "sum"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_min() { + let (aggregation, metric) = parse_compute_raw("min(@duration)").unwrap(); + assert_eq!(aggregation, "min"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_max() { + let (aggregation, metric) = parse_compute_raw("max(@duration)").unwrap(); + assert_eq!(aggregation, "max"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_median() { + let (aggregation, metric) = parse_compute_raw("median(@duration)").unwrap(); + assert_eq!(aggregation, "median"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_cardinality() { + let (aggregation, metric) = parse_compute_raw("cardinality(@usr.id)").unwrap(); + assert_eq!(aggregation, "cardinality"); + assert_eq!(metric.unwrap(), "@usr.id"); + } + + #[test] + fn test_parse_compute_percentile_99() { + let (aggregation, metric) = parse_compute_raw("percentile(@duration, 99)").unwrap(); + assert_eq!(aggregation, "pc99"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_percentile_95() { + let (aggregation, metric) = parse_compute_raw("percentile(@duration, 95)").unwrap(); + assert_eq!(aggregation, "pc95"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_percentile_90() { + let (aggregation, metric) = parse_compute_raw("percentile(@duration, 90)").unwrap(); + assert_eq!(aggregation, "pc90"); + assert_eq!(metric.unwrap(), "@duration"); + } + + #[test] + fn test_parse_compute_empty() { + assert!(parse_compute_raw("").is_err()); + } + + #[test] + fn test_parse_compute_invalid() { + assert!(parse_compute_raw("invalid").is_err()); + } + + #[test] + fn test_parse_compute_unknown_function() { + assert!(parse_compute_raw("foo(@bar)").is_err()); + } + + #[test] + fn test_parse_compute_unsupported_percentile() { + assert!(parse_compute_raw("percentile(@duration, 42)").is_err()); + } + + #[test] + fn test_parse_compute_percentile_missing_value() { + assert!(parse_compute_raw("percentile(@duration)").is_err()); + } + + #[test] + fn test_parse_compute_rejects_invalid_count_metric() { + let err = parse_compute_raw("count(@duration)").unwrap_err(); + assert!(err.to_string().contains("does not accept a field")); + } + + // ---- diff_json ---- + + #[test] + fn test_diff_json_identical() { + let v: Value = serde_json::json!({"name": "cpu", "query": "avg:system.cpu.user{*} > 90"}); + assert!(diff_json(&v, &v).is_empty()); + } + + #[test] + fn test_diff_json_modified_scalar() { + let before = serde_json::json!({"options": {"thresholds": {"critical": 90}}}); + let after = serde_json::json!({"options": {"thresholds": {"critical": 95}}}); + let entries = diff_json(&before, &after); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "options.thresholds.critical"); + assert_eq!(entries[0].change, ChangeKind::Modified); + assert_eq!(entries[0].before, Some(serde_json::json!(90))); + assert_eq!(entries[0].after, Some(serde_json::json!(95))); + } + + #[test] + fn test_diff_json_added_field() { + let before = serde_json::json!({"name": "cpu"}); + let after = serde_json::json!({"name": "cpu", "message": "alert!"}); + let entries = diff_json(&before, &after); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "message"); + assert_eq!(entries[0].change, ChangeKind::Added); + assert_eq!(entries[0].before, None); + assert_eq!(entries[0].after, Some(serde_json::json!("alert!"))); + } + + #[test] + fn test_diff_json_removed_field() { + let before = serde_json::json!({"name": "cpu", "message": "alert!"}); + let after = serde_json::json!({"name": "cpu"}); + let entries = diff_json(&before, &after); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "message"); + assert_eq!(entries[0].change, ChangeKind::Removed); + assert_eq!(entries[0].before, Some(serde_json::json!("alert!"))); + assert_eq!(entries[0].after, None); + } + + #[test] + fn test_diff_json_sorted_paths() { + let before = serde_json::json!({"z": 1, "a": 1, "m": 1}); + let after = serde_json::json!({"z": 2, "a": 2, "m": 2}); + let entries = diff_json(&before, &after); + let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, vec!["a", "m", "z"]); + } + + #[test] + fn test_diff_json_array_compared_whole() { + let before = serde_json::json!({"tags": ["env:prod", "team:backend"]}); + let after = serde_json::json!({"tags": ["team:backend", "env:prod"]}); + let entries = diff_json(&before, &after); + // Arrays are compared as whole values; reordered tags → modified + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, "tags"); + assert_eq!(entries[0].change, ChangeKind::Modified); + } + + // ---- normalize_for_diff ---- + + #[test] + fn test_normalize_strips_readonly_fields() { + let mut v = serde_json::json!({ + "id": 12345, + "name": "cpu", + "created": "2024-01-01", + "overall_state": "OK", + "org_id": 999, + }); + normalize_for_diff(&mut v, READONLY_MONITOR_FIELDS); + let obj = v.as_object().unwrap(); + assert!(!obj.contains_key("id")); + assert!(!obj.contains_key("created")); + assert!(!obj.contains_key("overall_state")); + assert!(!obj.contains_key("org_id")); + assert!(obj.contains_key("name")); + } + + #[test] + fn test_normalize_drops_null_values() { + let mut v = serde_json::json!({"name": "cpu", "message": null, "priority": null}); + normalize_for_diff(&mut v, &[]); + let obj = v.as_object().unwrap(); + assert!(!obj.contains_key("message")); + assert!(!obj.contains_key("priority")); + assert!(obj.contains_key("name")); + } + + #[test] + fn test_normalize_absent_and_null_compare_equal() { + // After normalizing, absent and null fields are both gone → no diff entry + let mut live = serde_json::json!({"name": "cpu", "priority": null}); + let mut candidate = serde_json::json!({"name": "cpu"}); + normalize_for_diff(&mut live, &[]); + normalize_for_diff(&mut candidate, &[]); + assert!(diff_json(&live, &candidate).is_empty()); + } + + // ---- scope_diff ---- + + // Shorthand for building a Modified DiffEntry in scope_diff tests. + fn modified(path: &str) -> DiffEntry { + DiffEntry { + path: path.into(), + change: ChangeKind::Modified, + before: Some(serde_json::json!("a")), + after: Some(serde_json::json!("b")), + } + } + + #[test] + fn test_scope_diff_empty_filters_noop() { + let entries = vec![modified("name"), modified("options.thresholds.critical")]; + assert_eq!(scope_diff(entries, &[], &[]).len(), 2); + } + + #[test] + fn test_scope_diff_only_prefix() { + let entries = vec![ + modified("name"), + modified("options.thresholds.critical"), + modified("options.thresholds.warning"), + ]; + let only = vec!["options.thresholds".to_string()]; + let result = scope_diff(entries, &only, &[]); + assert_eq!(result.len(), 2); + assert!(result + .iter() + .all(|e| e.path.starts_with("options.thresholds"))); + } + + #[test] + fn test_scope_diff_only_exact_match() { + let entries = vec![modified("options"), modified("name")]; + let only = vec!["options".to_string()]; + let result = scope_diff(entries, &only, &[]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "options"); + } + + #[test] + fn test_scope_diff_only_partial_prefix_does_not_match() { + // "option" (no trailing dot) must NOT match "options.thresholds.critical". + // The path_matches helper uses a dot-boundary check to prevent this. + let entries = vec![modified("options.thresholds.critical")]; + let only = vec!["option".to_string()]; + assert!(scope_diff(entries, &only, &[]).is_empty()); + } + + #[test] + fn test_scope_diff_ignore() { + let entries = vec![modified("message"), modified("name")]; + let ignore = vec!["message".to_string()]; + let result = scope_diff(entries, &[], &ignore); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "name"); + } + + #[test] + fn test_scope_diff_ignore_prefix() { + let entries = vec![modified("options.thresholds.critical"), modified("name")]; + let ignore = vec!["options".to_string()]; + let result = scope_diff(entries, &[], &ignore); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "name"); + } + + #[test] + fn test_scope_diff_only_and_ignore_combined() { + let entries = vec![ + modified("options.thresholds.critical"), + modified("options.thresholds.warning"), + modified("name"), + ]; + let only = vec!["options.thresholds".to_string()]; + let ignore = vec!["options.thresholds.warning".to_string()]; + let result = scope_diff(entries, &only, &ignore); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "options.thresholds.critical"); + } +}