From 7611a304011c01a0ec3586c8fc73fb54ce43e8e6 Mon Sep 17 00:00:00 2001 From: blaipr Date: Sat, 24 Oct 2026 01:53:17 +0200 Subject: [PATCH] feat: groups=matching, dotted paths and selectors on /hosts --- CHANGELOG.md | 16 ++ docs/api.md | 54 ++++++- src/adapters/in/http/openapi.rs | 1 + src/adapters/in/http/sources.rs | 186 +++++++++++++++++----- src/adapters/in/http/views.rs | 137 ++++++++++++----- src/application/output.rs | 50 +++++- src/domain/group_trim.rs | 154 +++++++++++++++++++ src/domain/host_filter.rs | 9 +- src/domain/mod.rs | 2 + src/domain/var_path.rs | 263 ++++++++++++++++++++++++++++++++ tests/api_integration_test.rs | 153 +++++++++++++++++++ 11 files changed, 946 insertions(+), 79 deletions(-) create mode 100644 src/domain/group_trim.rs create mode 100644 src/domain/var_path.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 071194e..09aa5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `groups=all|matching|none` on `GET /sources/{id}/dataset` (sources and + views). `matching` returns only the groups of the returned hosts, member + lists cut to them, plus the parents of kept groups; `none` drops the + `groups` key. A one-host read of a source of about 1,000 hosts and 40 groups goes + from 444,420 B to 3,516 B (`matching`) — the full group lists were most of the response. + `all` stays the default, so nothing changes for existing consumers. +- Dotted paths in `fields`, `exclude_fields` and `filter` keys — on the + dataset route, views and the builtin endpoints (csv columns too): + `fields=monitor.name`, `filter=monitor.status=DOWN`. A top-level key of + the exact name still wins; paths walk objects, never lists. +- `host=`, `group=` and `filter=` on `GET /sources/{id}/hosts` (sources and + views), selecting exactly as `/dataset` does: names of the matching hosts + without their hostvars. A malformed filter is a `400`. + ## [0.32.0] - 2026-10-23 ### Added diff --git a/docs/api.md b/docs/api.md index d194aac..c007959 100644 --- a/docs/api.md +++ b/docs/api.md @@ -107,9 +107,9 @@ Reading cached inventory: what is configured, how fresh it is, and the datasets | Route | Meaning | |---|---| | `GET /api/v1/sources` | Cached sources with freshness, host counts and sync health, then every configured [view](views.md) (`kind: "view"`) | -| `GET /api/v1/sources/{id}/dataset` | The cached dataset (hostvars + groups); supports `ETag`/`If-None-Match`; paginate/filter with `?limit=&offset=&host=&group=&filter=&fields=&exclude_fields=` — the same parameters as the [builtin endpoints](endpoints.md#builtin-transformers); `?host=x&refresh=true` brings those hosts up to date first — see [on-demand refresh](on-demand-refresh.md) | +| `GET /api/v1/sources/{id}/dataset` | The cached dataset (hostvars + groups); supports `ETag`/`If-None-Match`; paginate/filter with `?limit=&offset=&host=&group=&filter=&fields=&exclude_fields=&groups=` — the same selectors as the [builtin endpoints](endpoints.md#builtin-transformers); `?host=x&refresh=true` brings those hosts up to date first — see [on-demand refresh](on-demand-refresh.md) | | `GET /api/v1/sources/{id}/groups` | Group names with host counts and children — no facts | -| `GET /api/v1/sources/{id}/hosts` | Hostnames only, sorted | +| `GET /api/v1/sources/{id}/hosts` | Hostnames only, sorted; `?host=&group=&filter=` select them exactly as on `/dataset` | | `GET /api/v1/sources/{id}/status` | Per-host age/TTL/freshness; `?host=` names hosts, `?group=` any of several groups, ANDed. `total_hosts` counts the whole source, `returned` this response | | `GET /api/v1/sources/{id}/scope` | The ownership scope this source advertises — config-derived (an explicit `advertise_scope`, an SSH source's `hosts_from_source` pattern, or a view's member-ownership union), so it answers before anything has synced. `declared: false` = no claim | | `POST /api/v1/sources/{id}/sync` | Run the connector now. `?host=x` (comma-separated list) or `?group=y` scope the sync; `&refresh_origin=true` makes a federated source's origin re-gather first — see [on-demand refresh](on-demand-refresh.md) | @@ -271,6 +271,56 @@ Combine them with `fields=` to fetch a few facts of the matching hosts only: GET /api/v1/sources/src-vmware/dataset?filter=vmware_power_state=poweredOff&fields=vmware_vcenter,vmware_num_cpu ``` +#### Dotted paths + +`fields`, `exclude_fields` and the keys in `filter` may be **paths into +objects**: `monitor.name` is the `name` key of the `monitor` object. The +same holds on the [builtin endpoints](endpoints.md#builtin-transformers), +where a csv column may be a path too. + +``` +GET /api/v1/sources/src-monitoring/dataset?filter=monitor.status=DOWN&fields=monitor.name,monitor.poller +GET /api/v1/sources/src-monitoring/dataset?exclude_fields=monitor.services +``` + +- A name that is a top-level key — dots and all — is that key, so a request + written before paths existed selects what it always did. +- Paths walk into objects only, never into lists: `monitor.services.name` + finds nothing (a filter sees a missing var). +- Several paths under one root come back as one object: + `fields=monitor.name,monitor.status` → `{"monitor": {"name": …, "status": …}}`. + +#### How much group membership: `groups=` + +A dataset read carries the source's groups. By default (`groups=all`) that is +every group with its full member list, whatever hosts were selected — and on +a large source the lists are most of a narrow read: + +| ~1,000 hosts, 40 groups, `host=` | Raw | gzip | +|---|---|---| +| `groups=all` (default) | 444,420 B | 24,892 B | +| `groups=matching` | 3,516 B | 709 B | +| `groups=none` | 2,489 B | 613 B | + +- `matching`: only the groups a returned host is in, each cut to the returned + hosts, plus every group that has a kept group among its children (walked + up, so grandparents stay), with `children` cut to kept groups. Group vars are + kept as they are (minus `exclude_fields`). +- `none`: no `groups` key at all. +- With `group=`, only those groups are candidates, as before. + +Measured on a dataset of that size, 2026-10-24. + +#### Names only: `/hosts` with selectors + +`GET /sources/{id}/hosts` takes `host=`, `group=` and `filter=`, chosen exactly +as on `/dataset` (patterns, dotted keys, ANDed), and answers names only — the +cheap answer to "which hosts are X": + +``` +GET /api/v1/sources/src-monitoring/hosts?filter=monitor.poller=poller-1 +``` + A malformed `filter` answers `400` with the condition named. An unmatched filter is an **empty result**, not a `404`: a filter that diff --git a/src/adapters/in/http/openapi.rs b/src/adapters/in/http/openapi.rs index 4d96ff0..9a17942 100644 --- a/src/adapters/in/http/openapi.rs +++ b/src/adapters/in/http/openapi.rs @@ -65,6 +65,7 @@ impl Modify for SecurityAddon { http::detail::Gathering, http::sources::GroupInfo, http::sources::HostList, + http::sources::GroupsMode, http::sources::HostStatus, http::sources::SourceStatus, http::sources::SyncHealthInfo, diff --git a/src/adapters/in/http/sources.rs b/src/adapters/in/http/sources.rs index f7709ba..0b7ea63 100644 --- a/src/adapters/in/http/sources.rs +++ b/src/adapters/in/http/sources.rs @@ -19,8 +19,10 @@ use crate::adapters::r#in::http::views; use crate::application::refresh::{RefreshOutcome, refresh_hosts}; use crate::domain::cache_entry::CacheEntry; use crate::domain::dataset::{Group, HostVars}; +use crate::domain::group_trim; use crate::domain::host_filter::HostFilter; use crate::domain::sync_health::SyncHealth; +use crate::domain::var_path::VarSelect; // Read from the sources cache: list, full dataset, and per-host status. // Write operations live in sync.rs, enrichers.rs, and hosts.rs. @@ -60,42 +62,38 @@ pub(super) fn comma_names(raw: &str) -> impl Iterator { // the same rule the builtin endpoints follow. Built once per request, and // borrowed through when neither is set, so the common read clones nothing. pub(super) struct VarShape<'p> { - keep: Option>, - drop: HashSet<&'p str>, + select: VarSelect<'p>, } impl<'p> VarShape<'p> { pub(super) fn new(params: &'p DatasetParams) -> Self { VarShape { - keep: params.fields.as_deref().map(|f| comma_names(f).collect()), - drop: params - .exclude_fields - .as_deref() - .map(|f| comma_names(f).collect()) - .unwrap_or_default(), + select: VarSelect::new( + params.fields.as_deref().map(comma_names), + params + .exclude_fields + .as_deref() + .into_iter() + .flat_map(comma_names), + ), } } + // Names may be dotted paths (`monitor.name`): see domain::var_path. pub(super) fn host<'a>(&self, vars: &'a HostVars) -> Cow<'a, HostVars> { - if self.keep.is_none() && self.drop.is_empty() { + if self.select.is_identity() { return Cow::Borrowed(vars); } - Cow::Owned( - vars.iter() - .filter(|(key, _)| self.keep.as_ref().is_none_or(|k| k.contains(key.as_str()))) - .filter(|(key, _)| !self.drop.contains(key.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - ) + Cow::Owned(self.select.host(vars)) } pub(super) fn group<'a>(&self, group: &'a Group) -> Cow<'a, Group> { - if self.drop.is_empty() { + if self.select.drops_nothing() { return Cow::Borrowed(group); } let mut owned = group.clone(); if let Some(vars) = owned.vars.as_mut() { - vars.retain(|key, _| !self.drop.contains(key.as_str())); + self.select.group_vars(vars); } Cow::Owned(owned) } @@ -403,15 +401,23 @@ pub struct DatasetParams { pub limit: Option, /// How many hosts to skip (use with limit to page) pub offset: Option, - /// Return only these top-level hostvars keys (comma-separated) + /// Return only these hostvars (comma-separated). A dotted name is a path + /// into objects (`monitor.name`); a top-level key of that exact name wins pub fields: Option, - /// Drop these top-level hostvars keys (comma-separated), from hosts and groups + /// Drop these hostvars (comma-separated, dotted paths too), from hosts and + /// from group vars pub exclude_fields: Option, /// Keep only hosts whose hostvars meet every condition (`;`-separated): /// `keyvalue` with op one of `=` `!=` `~` (contains) `!~` `>` `>=` /// `<` `<=`. Orderings are numeric when both sides are numbers, text /// otherwise (ISO dates compare). `key=` matches a missing or empty var. + /// A dotted key walks into objects (`monitor.status=UP`) pub filter: Option, + /// Which groups come back: `all` (default) every group with its full + /// member list; `matching` only the groups of the returned hosts, members + /// cut to those hosts, plus the parents of kept groups; `none` no `groups` + /// key. On a large source the group lists dominate a narrow read + pub groups: Option, /// Bring the requested hosts up to date before answering, if the source's /// TTL says they are stale. Requires `host` and a source configured with /// `allow_on_demand_refresh`. A refresh that fails does not fail the read: @@ -420,6 +426,18 @@ pub struct DatasetParams { pub refresh: Option, } +// `groups=`: how much group membership a dataset read carries. +#[derive(Deserialize, ToSchema, Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum GroupsMode { + #[default] + All, + Matching, + // `none` on the wire; not `None`, which would read as Option's + #[serde(rename = "none")] + Omit, +} + impl DatasetParams { // `refresh` is deliberately not part of this: it changes how current the // data is, not the shape of the response. A consumer that adds @@ -432,6 +450,7 @@ impl DatasetParams { && self.fields.is_none() && self.exclude_fields.is_none() && self.filter.is_none() + && self.groups.is_none() } // `host` is either a list of exact names or, with a wildcard anywhere in @@ -590,6 +609,7 @@ pub async fn get_source_dataset( params.fields.hash(&mut hasher); params.exclude_fields.hash(&mut hasher); params.filter.hash(&mut hasher); + params.groups.hash(&mut hasher); // A refresh request and a plain one get distinct validators: a client // polling with refresh=true must not be handed a 304 that was minted // for a request that never went to the origin. @@ -643,16 +663,51 @@ pub async fn get_source_dataset( }) .collect(); - // With a group filter only those groups are returned; otherwise all groups - // (membership lists are tiny next to hostvars, which carry the facts) + // With a group filter only those groups are returned. Which of them, and + // with how much of their membership, is `groups=`: by default all of them + // in full, which on a large source is most of a narrow read (441 KB of a + // 444 KB one-host read on a source of about 1,000 hosts) — `matching` keeps the groups + // of the returned page only, `none` drops the key. let wanted: Option> = params.group.as_deref().map(|g| comma_names(g).collect()); - let groups: HashMap<&String, Cow> = entry - .dataset - .groups - .iter() - .filter(|(name, _)| wanted.as_ref().is_none_or(|w| w.contains(name.as_str()))) - .map(|(name, group)| (name, shape.group(group))) - .collect(); + let in_scope = |name: &String| wanted.as_ref().is_none_or(|w| w.contains(name.as_str())); + let groups: Option>> = match params.groups.unwrap_or_default() { + GroupsMode::Omit => None, + GroupsMode::All => Some( + entry + .dataset + .groups + .iter() + .filter(|(name, _)| in_scope(name)) + .map(|(name, group)| (name, shape.group(group))) + .collect(), + ), + GroupsMode::Matching => { + let on_page: HashSet<&str> = page.iter().map(|h| h.as_str()).collect(); + let trimmed = group_trim::matching( + entry + .dataset + .groups + .iter() + .filter(|(name, _)| in_scope(name)) + .map(|(name, g)| (name.as_str(), g.hosts.as_slice(), g.children.as_slice())), + &on_page, + ); + Some( + entry + .dataset + .groups + .iter() + .filter_map(|(name, group)| { + let kept = trimmed.get(name.as_str())?; + let mut owned = shape.group(group).into_owned(); + owned.hosts = kept.hosts.iter().map(|h| h.to_string()).collect(); + owned.children = kept.children.iter().map(|c| c.to_string()).collect(); + Some((name, Cow::Owned(owned))) + }) + .collect(), + ) + } + }; // A borrowing struct serialized straight to bytes: no intermediate Value // tree (same reasoning as the plain path — a group filter can select most @@ -665,7 +720,8 @@ pub async fn get_source_dataset( limit: Option, returned: usize, hostvars: HashMap<&'a String, Cow<'a, HostVars>>, - groups: HashMap<&'a String, Cow<'a, Group>>, + #[serde(skip_serializing_if = "Option::is_none")] + groups: Option>>, } let body = serde_json::to_vec(&DatasetPage { @@ -762,6 +818,49 @@ pub struct HostList { pub hosts: Vec, } +// The selectors of /dataset that pick hosts, for a reader that wants only +// their names: the cheap answer to "which hosts are X" — a few KB of names +// instead of the hostvars that decided it. +#[derive(Deserialize, IntoParams)] +pub struct HostsParams { + /// Only these hosts (comma-separated names); with a `*` or `?` anywhere, + /// every entry is a case-insensitive pattern, as on /dataset + pub host: Option, + /// Only the hosts of these groups (comma-separated, a host in any of them) + pub group: Option, + /// Only hosts whose hostvars meet every condition, as on /dataset + /// (`;`-separated, dotted keys walk into objects: `monitor.status=UP`) + pub filter: Option, +} + +impl HostsParams { + pub fn is_plain(&self) -> bool { + self.host.is_none() && self.group.is_none() && self.filter.is_none() + } + + fn host_is_pattern(&self) -> bool { + self.host.as_deref().is_some_and(|h| h.contains(['*', '?'])) + } + + pub fn exact_hosts(&self) -> Option<&str> { + if self.host_is_pattern() { + None + } else { + self.host.as_deref() + } + } + + pub fn host_filter(&self) -> Result { + let patterns = if self.host_is_pattern() { + self.host.as_deref().unwrap_or("") + } else { + "" + }; + HostFilter::parse(patterns, self.filter.as_deref().unwrap_or("")) + .map_err(ApiError::bad_request) + } +} + // The hostnames only — the cheap answer to "what is in this source", for a UI // picker or an operator. Without it, the alternatives were the full dataset or // passing a deliberately non-existent ?fields= value to empty out the vars. @@ -769,9 +868,9 @@ pub struct HostList { get, path = "/api/v1/sources/{id}/hosts", tag = "Sources", - params(("id" = String, Path, description = "Source identifier")), + params(("id" = String, Path, description = "Source identifier"), HostsParams), responses( - (status = 200, description = "Hostnames in the cached dataset, sorted", body = HostList), + (status = 200, description = "Hostnames in the cached dataset, sorted; with host/group/filter only the hosts every selector matched, chosen exactly as on /dataset. A malformed filter is a 400", body = HostList), (status = 403, description = "API key not allowed to read this source", body = ErrorBody), (status = 404, description = "Source not in cache", body = ErrorBody) ) @@ -780,20 +879,39 @@ pub async fn list_source_hosts( State(state): State>, Extension(auth): Extension, Path(id): Path, + Query(params): Query, ) -> Result, ApiError> { if !auth.permissions.allows_source(&id) { return Err(ApiError::source_forbidden(&id)); } + let host_filter = params.host_filter()?; let config = state.config(); if let Some(view) = config.views.get(&id) { - return Ok(Json(views::hosts(&state, &id, view))); + return views::hosts(&state, &id, view, ¶ms, &host_filter).map(Json); } let entry = state .cache .get(&id) .ok_or_else(|| ApiError::source_not_cached(&id))?; - let mut hosts: Vec = entry.dataset.hostvars.keys().cloned().collect(); + let mut hosts: Vec = if params.is_plain() { + entry.dataset.hostvars.keys().cloned().collect() + } else { + // Selected exactly as /dataset selects, so the two never disagree + // about which hosts a query names. + resolve_hostnames(&entry, params.exact_hosts(), params.group.as_deref()) + .into_iter() + .filter(|host| { + host_filter.is_empty() + || entry + .dataset + .hostvars + .get(*host) + .is_some_and(|vars| host_filter.matches(host, vars)) + }) + .cloned() + .collect() + }; hosts.sort(); Ok(Json(HostList { diff --git a/src/adapters/in/http/views.rs b/src/adapters/in/http/views.rs index cd26dfe..45086a0 100644 --- a/src/adapters/in/http/views.rs +++ b/src/adapters/in/http/views.rs @@ -10,12 +10,15 @@ use utoipa::ToSchema; use crate::AppState; use crate::adapters::r#in::http::error::ApiError; use crate::adapters::r#in::http::sources::{ - CachedSourceInfo, DatasetParams, GroupInfo, HostList, HostStatus, SourceStatus, StatusParams, - SyncHealthInfo, VarShape, comma_names, sanitize_header_value, + CachedSourceInfo, DatasetParams, GroupInfo, GroupsMode, HostList, HostStatus, HostsParams, + SourceStatus, StatusParams, SyncHealthInfo, VarShape, comma_names, sanitize_header_value, }; use crate::application::refresh::{RefreshOutcome, refresh_hosts}; use crate::application::views::{UnclaimedHosts, ViewSnapshot, snapshot}; use crate::domain::dataset::HostVars; +use crate::domain::group_trim; +use crate::domain::host_filter::HostFilter; +use crate::domain::var_path::VarSelect; use crate::domain::view::View; // Serving a view on the source read routes. @@ -117,6 +120,7 @@ pub async fn dataset( params.fields.hash(&mut hasher); params.exclude_fields.hash(&mut hasher); params.filter.hash(&mut hasher); + params.groups.hash(&mut hasher); params.refresh.hash(&mut hasher); format!("\"{:x}-{}\"", hasher.finish(), state.cache.generation()) }; @@ -153,38 +157,65 @@ pub async fn dataset( let wanted: Option> = params.group.as_deref().map(|g| comma_names(g).collect()); - let exclude: HashSet<&str> = params - .exclude_fields - .as_deref() - .map(|f| comma_names(f).collect()) - .unwrap_or_default(); - let groups: BTreeMap<&str, PageGroup> = snap + // exclude_fields on group vars, dotted paths included, as on a source + let exclude = VarSelect::new( + None::>, + params + .exclude_fields + .as_deref() + .into_iter() + .flat_map(comma_names), + ); + let merged: BTreeMap<&str, _> = snap .groups() .into_iter() .filter(|(name, _)| wanted.as_ref().is_none_or(|w| w.contains(name))) - .map(|(name, group)| { - let vars = group.vars.map(|vars| { - if exclude.is_empty() { - Cow::Borrowed(vars) - } else { - Cow::Owned( - vars.iter() - .filter(|(key, _)| !exclude.contains(key.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), + .collect(); + // `groups=` as on a source: all (default), only the page's (matching), + // or no key at all (none). + let groups: Option> = match params.groups.unwrap_or_default() { + GroupsMode::Omit => None, + GroupsMode::All => Some( + merged + .into_iter() + .map(|(name, group)| { + ( + name, + PageGroup { + hosts: group.hosts, + children: group.children, + vars: shape_group_vars(&exclude, group.vars), + }, ) - } - }); - ( - name, - PageGroup { - hosts: group.hosts, - children: group.children, - vars, - }, + }) + .collect(), + ), + GroupsMode::Matching => { + let on_page: HashSet<&str> = page.iter().copied().collect(); + let trimmed = group_trim::matching( + merged + .iter() + .map(|(name, g)| (*name, g.hosts.as_slice(), g.children.as_slice())), + &on_page, + ); + Some( + merged + .iter() + .filter_map(|(name, group)| { + let kept = trimmed.get(name)?; + Some(( + *name, + PageGroup { + hosts: kept.hosts.clone(), + children: kept.children.clone(), + vars: shape_group_vars(&exclude, group.vars), + }, + )) + }) + .collect(), ) - }) - .collect(); + } + }; serde_json::to_vec(&ViewDatasetPage { source_id: id, @@ -208,6 +239,23 @@ pub async fn dataset( .unwrap()) } +// A group's vars with `exclude_fields` applied, borrowed through when it +// excludes nothing. +fn shape_group_vars<'a>( + exclude: &VarSelect, + vars: Option<&'a HostVars>, +) -> Option> { + vars.map(|vars| { + if exclude.drops_nothing() { + Cow::Borrowed(vars) + } else { + let mut owned = vars.clone(); + exclude.group_vars(&mut owned); + Cow::Owned(owned) + } + }) +} + // The per-member report, shared by /status and the view's detail page. One // implementation because two would be two things that can disagree about the // same members. @@ -360,7 +408,13 @@ pub fn groups(state: &Arc, id: &str, view: &View) -> Vec { } // GET /api/v1/sources/{id}/hosts for a view. -pub fn hosts(state: &Arc, id: &str, view: &View) -> HostList { +pub fn hosts( + state: &Arc, + id: &str, + view: &View, + params: &HostsParams, + host_filter: &HostFilter, +) -> Result { let config = state.config(); let snap = snapshot( &*state.cache, @@ -369,13 +423,27 @@ pub fn hosts(state: &Arc, id: &str, view: &View) -> HostList { id, view, ); - let hosts: Vec = snap.hosts().into_iter().map(str::to_string).collect(); + let hosts: Vec = if params.is_plain() { + snap.hosts().into_iter().map(str::to_string).collect() + } else { + // Selected as the view's /dataset selects: a named host no member + // claims is a 404 here too. + let mut selection = select(&snap, params.exact_hosts(), params.group.as_deref()) + .map_err(|unclaimed| unclaimed_error(id, view, unclaimed))?; + if !host_filter.is_empty() { + let vars = snap.hostvars(&selection); + selection.retain(|host| vars.get(host).is_some_and(|v| host_filter.matches(host, v))); + } + selection.sort_unstable(); + selection.dedup(); + selection.into_iter().map(str::to_string).collect() + }; - HostList { + Ok(HostList { source_id: id.to_string(), total_hosts: hosts.len(), hosts, - } + }) } // One line of GET /api/v1/sources. @@ -642,7 +710,8 @@ struct ViewDatasetPage<'a> { limit: Option, returned: usize, hostvars: HashMap<&'a str, Cow<'a, HostVars>>, - groups: BTreeMap<&'a str, PageGroup<'a>>, + #[serde(skip_serializing_if = "Option::is_none")] + groups: Option>>, } // A view's group on a filtered page: MergedGroup's three keys, with diff --git a/src/application/output.rs b/src/application/output.rs index 23fb751..69964d0 100644 --- a/src/application/output.rs +++ b/src/application/output.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::domain::dataset::{Dataset, Group, HostVars}; use crate::domain::endpoint::{BUILTIN_SETTINGS, EndpointLimit, renamed_setting}; use crate::domain::host_filter::HostFilter; +use crate::domain::var_path::{self, VarSelect}; // Apply an endpoint's limit to the datasets it is about to render: a // constructed inventory. Everything the sources carry is still merged — the @@ -233,15 +234,20 @@ fn merge_and_filter( // resolves the inventory, so stripping only hostvars would let an excluded // name through the group door. exclude_fields is how something is kept out of // an endpoint, and a filter with a way around it is not one. - if !fields.is_empty() || !exclude_fields.is_empty() { + // + // Names may be dotted paths into objects (`monitor.name`), as on the + // dataset route: see domain::var_path. + let select = VarSelect::new( + (!fields.is_empty()).then(|| fields.iter().map(String::as_str)), + exclude_fields.iter().map(String::as_str), + ); + if !select.is_identity() { for vars in hostvars.values_mut() { - vars.retain(|name, _| { - (fields.is_empty() || fields.contains(name)) && !exclude_fields.contains(name) - }); + *vars = select.host(vars); } for group in groups.values_mut() { if let Some(vars) = group.vars.as_mut() { - vars.retain(|name, _| !exclude_fields.contains(name)); + select.group_vars(vars); } } } @@ -407,7 +413,8 @@ pub fn render_csv( out.push_str(&csv_field(host)); for column in &columns { out.push(','); - out.push_str(&csv_field(&csv_value(vars.get(column)))); + // a column may be a dotted path, like `fields` itself + out.push_str(&csv_field(&csv_value(var_path::lookup(vars, column)))); } out.push('\n'); } @@ -761,6 +768,37 @@ mod tests { assert_eq!(lines.len(), 3); } + #[test] + fn dotted_fields_shape_endpoints_and_name_csv_columns() { + let mut datasets = HashMap::new(); + datasets.insert( + "src".to_string(), + dataset( + serde_json::json!({"h1": {"os": "linux", "mon": {"name": "h1", "status": "UP", "services": [1, 2]}}}), + serde_json::json!({"g": {"hosts": ["h1"], "vars": {"mon": {"poller": "p", "secret": "s"}}}}), + ), + ); + let config: HashMap = HashMap::new(); + let params = + serde_json::json!({"fields": "mon.name,mon.status", "exclude_fields": "mon.secret"}); + let (hostvars, groups) = merge_and_filter(&datasets, &config, ¶ms); + assert_eq!( + serde_json::to_value(&hostvars["h1"]).unwrap(), + serde_json::json!({"mon": {"name": "h1", "status": "UP"}}) + ); + assert_eq!( + serde_json::to_value(groups["g"].vars.as_ref().unwrap()).unwrap(), + serde_json::json!({"mon": {"poller": "p"}}) + ); + // a csv column may be a path, and reads the value at it + let out = render_csv( + &datasets, + &config, + &serde_json::json!({"fields": "mon.status,os"}), + ); + assert_eq!(out, "host,mon.status,os\nh1,UP,linux\n"); + } + #[test] fn csv_columns_setting_picks_and_orders_and_params_override() { let mut datasets = HashMap::new(); diff --git a/src/domain/group_trim.rs b/src/domain/group_trim.rs new file mode 100644 index 0000000..b2a3713 --- /dev/null +++ b/src/domain/group_trim.rs @@ -0,0 +1,154 @@ +use std::collections::{HashMap, HashSet}; + +// `groups=matching`: the groups a page of hosts actually belongs to. +// +// A dataset read that selects a few hosts used to carry every group of the +// source with its full member list — on a 1000-host source that is most of the +// response, whatever the selection was. Matching keeps: +// +// - every group with at least one host on the page, its `hosts` cut to those; +// - every group with a kept group among its children, so a page still says +// which parents its groups sit under (walked to a fixpoint: a grandparent is +// kept through its kept child); +// +// and cuts each kept group's `children` to groups that were kept. Group vars +// are the caller's business: this only decides membership. +// +// Generic over how the caller holds names, so a source (owned `String`s) and +// a view (borrowed `&str`s) share one implementation. +pub fn matching<'a, H, C>( + groups: impl IntoIterator, + page: &HashSet<&str>, +) -> HashMap<&'a str, Trimmed<'a>> +where + H: AsRef + 'a, + C: AsRef + 'a, +{ + let all: Vec<(&'a str, &'a [H], &'a [C])> = groups.into_iter().collect(); + + let mut kept: HashMap<&'a str, Trimmed<'a>> = HashMap::new(); + for (name, hosts, _) in &all { + let on_page: Vec<&'a str> = hosts + .iter() + .map(AsRef::as_ref) + .filter(|h| page.contains(h)) + .collect(); + if !on_page.is_empty() { + kept.insert( + name, + Trimmed { + hosts: on_page, + children: Vec::new(), + }, + ); + } + } + + loop { + let mut grew = false; + for (name, _, children) in &all { + if !kept.contains_key(name) && children.iter().any(|c| kept.contains_key(c.as_ref())) { + kept.insert( + name, + Trimmed { + hosts: Vec::new(), + children: Vec::new(), + }, + ); + grew = true; + } + } + if !grew { + break; + } + } + + let names: HashSet<&'a str> = kept.keys().copied().collect(); + for (name, _, children) in &all { + if let Some(trimmed) = kept.get_mut(name) { + trimmed.children = children + .iter() + .map(AsRef::as_ref) + .filter(|c| names.contains(c)) + .collect(); + } + } + kept +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trimmed<'a> { + pub hosts: Vec<&'a str>, + pub children: Vec<&'a str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn owned(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn keeps_groups_of_the_page_and_their_parents_and_trims_both_lists() { + let web_hosts = owned(&["web1", "web2", "web3"]); + let db_hosts = owned(&["db1"]); + let none: Vec = Vec::new(); + let dc_children = owned(&["web", "db"]); + let all_children = owned(&["site_a"]); + let other_hosts = owned(&["x"]); + let groups = vec![ + ("web", web_hosts.as_slice(), none.as_slice()), + ("db", db_hosts.as_slice(), none.as_slice()), + ("site_a", none.as_slice(), dc_children.as_slice()), + ("everything", none.as_slice(), all_children.as_slice()), + ("unrelated", other_hosts.as_slice(), none.as_slice()), + ]; + let page: HashSet<&str> = ["web2"].into_iter().collect(); + let out = matching(groups, &page); + + assert_eq!( + out["web"], + Trimmed { + hosts: vec!["web2"], + children: vec![] + } + ); + // the parent stays, naming only the child that stayed + assert_eq!( + out["site_a"], + Trimmed { + hosts: vec![], + children: vec!["web"] + } + ); + // and so does the grandparent, through it + assert_eq!( + out["everything"], + Trimmed { + hosts: vec![], + children: vec!["site_a"] + } + ); + assert!(!out.contains_key("db")); + assert!(!out.contains_key("unrelated")); + } + + #[test] + fn an_empty_page_keeps_no_groups() { + let hosts = owned(&["a"]); + let none: Vec = Vec::new(); + let out = matching([("g", hosts.as_slice(), none.as_slice())], &HashSet::new()); + assert!(out.is_empty()); + } + + #[test] + fn works_on_borrowed_names_too() { + let hosts: Vec<&str> = vec!["a", "b"]; + let none: Vec<&str> = vec![]; + let page: HashSet<&str> = ["b"].into_iter().collect(); + let out = matching([("g", hosts.as_slice(), none.as_slice())], &page); + assert_eq!(out["g"].hosts, vec!["b"]); + } +} diff --git a/src/domain/host_filter.rs b/src/domain/host_filter.rs index e5dcad6..f4e2134 100644 --- a/src/domain/host_filter.rs +++ b/src/domain/host_filter.rs @@ -1,6 +1,7 @@ use std::cmp::Ordering; use crate::domain::dataset::HostVars; +use crate::domain::var_path; // A host filter: which hosts of an inventory a reader wants, decided by the // host's NAME and by its hostvars. Shared by `GET /sources/{id}/dataset` (and @@ -10,8 +11,8 @@ use crate::domain::dataset::HostVars; // Two parts, both optional, ANDed together: // host — comma-separated name patterns, `*` and `?` wildcards, // case-insensitive; a host matching ANY pattern is kept -// filter — `;`-separated conditions on top-level hostvars, ALL must -// hold: `keyvalue` with op one of +// filter — `;`-separated conditions on hostvars, ALL must hold; a +// dotted key walks into objects (`monitor.status`): `keyvalue` with op one of // = != exact (a var's text form, see `text`) // ~ !~ contains, case-insensitive // > >= < <= numeric when both sides are numbers, @@ -162,7 +163,9 @@ fn is_empty(value: Option<&serde_json::Value>) -> bool { impl Condition { fn holds(&self, vars: &HostVars) -> bool { - let var = vars.get(&self.key); + // A dotted key walks into objects (`monitor.status`); a top-level key + // of that exact name still wins. See domain::var_path. + let var = var_path::lookup(vars, &self.key); if self.value.is_empty() { return (self.op == Op::Eq) == is_empty(var); } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index a1067cc..daf58a1 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -4,12 +4,14 @@ pub mod credential; pub mod dataset; pub mod endpoint; pub mod enricher; +pub mod group_trim; pub mod host_filter; pub mod project; pub mod source; pub mod static_inventory; pub mod sync_health; pub mod sync_mode; +pub mod var_path; pub mod view; pub mod yaml; diff --git a/src/domain/var_path.rs b/src/domain/var_path.rs new file mode 100644 index 0000000..5311272 --- /dev/null +++ b/src/domain/var_path.rs @@ -0,0 +1,263 @@ +use serde_json::{Map, Value}; + +use crate::domain::dataset::HostVars; + +// Dotted paths into hostvars: `monitor.name` is the `name` key of the +// `monitor` object. Shared by `filter` (which reads one value) and by +// `fields` / `exclude_fields` (which shape what a response carries), on the +// dataset route, on views and on the builtin endpoints, so all three read a +// path the same way. +// +// Two rules keep this compatible with every request written before paths +// existed: +// +// - A name that is a top-level key, dots and all, is that key. Hostvar names +// are identifiers in practice, but nothing forbids `a.b` as one, and a +// request that worked yesterday must keep selecting what it selected. +// - A path walks into objects only, never into lists. `monitor.services.name` +// would have to mean "the name of every service", which is a projection over +// a list rather than a path, so it simply finds nothing: a filter on it sees +// a missing var, and `fields` leaves nothing behind for it. + +// The value at `path`, or None when any step is missing or not an object. +pub fn lookup<'a>(vars: &'a HostVars, path: &str) -> Option<&'a Value> { + if let Some(value) = vars.get(path) { + return Some(value); + } + let mut segments = path.split('.'); + let mut current = vars.get(segments.next()?)?; + for segment in segments { + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +// What `fields` and `exclude_fields` ask for, parsed once per request. +// +// `keep` is `fields`: when present, a host carries only those paths. `drop` is +// `exclude_fields`, applied after `keep`, and the only half that applies to +// group vars: a var left on a group reaches its members once the consumer +// resolves the inventory, so excluding it from hosts alone would let it back in +// through the group (the rule the builtin endpoints already follow). +#[derive(Debug, Clone, Default)] +pub struct VarSelect<'p> { + keep: Option>, + drop: Vec<&'p str>, +} + +impl<'p> VarSelect<'p> { + pub fn new( + keep: Option>, + drop: impl IntoIterator, + ) -> Self { + VarSelect { + keep: keep.map(|k| k.into_iter().collect()), + drop: drop.into_iter().collect(), + } + } + + // Nothing to shape: callers borrow the vars through instead of copying. + pub fn is_identity(&self) -> bool { + self.keep.is_none() && self.drop.is_empty() + } + + pub fn drops_nothing(&self) -> bool { + self.drop.is_empty() + } + + // A host's vars, shaped by both halves. + pub fn host(&self, vars: &HostVars) -> HostVars { + let mut out = match &self.keep { + None => vars.clone(), + Some(paths) => keep_paths(vars, paths), + }; + drop_paths(&mut out, &self.drop); + out + } + + // A group's vars, shaped by `exclude_fields` only. + pub fn group_vars(&self, vars: &mut HostVars) { + drop_paths(vars, &self.drop); + } +} + +// Build a fresh map holding only `paths`. A whole top-level key is copied as +// it is; a deeper path is copied into objects of the same names, created as +// needed, so `monitor.name,monitor.status` comes back as one `monitor` +// object with those two keys. +fn keep_paths(vars: &HostVars, paths: &[&str]) -> HostVars { + let mut out = HostVars::new(); + for path in paths { + if let Some(value) = vars.get(*path) { + out.insert((*path).to_string(), value.clone()); + continue; + } + let Some(value) = lookup(vars, path) else { + continue; + }; + let segments: Vec<&str> = path.split('.').collect(); + let (root, rest) = segments + .split_first() + .expect("lookup found a non-empty path"); + // The whole root is already here (kept by an earlier entry): it holds + // this path too, so there is nothing to add. + if out.get(*root).is_some_and(|v| !v.is_object()) { + continue; + } + let root_obj = out + .entry((*root).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + insert_path(root_obj, rest, value.clone()); + } + out +} + +fn insert_path(target: &mut Value, segments: &[&str], value: Value) { + let Some((last, parents)) = segments.split_last() else { + return; + }; + let mut current = target; + for segment in parents { + let Some(object) = current.as_object_mut() else { + return; + }; + current = object + .entry((*segment).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + if let Some(object) = current.as_object_mut() { + object.insert((*last).to_string(), value); + } +} + +// Remove `paths` in place. A top-level key goes whole; a deeper path removes +// the last key from its parent object and leaves the rest of that object. +fn drop_paths(vars: &mut HostVars, paths: &[&str]) { + for path in paths { + if vars.remove(*path).is_some() { + continue; + } + let segments: Vec<&str> = path.split('.').collect(); + let Some((root, rest)) = segments.split_first() else { + continue; + }; + if rest.is_empty() { + continue; + } + if let Some(value) = vars.get_mut(*root) { + remove_path(value, rest); + } + } +} + +// Remove the key at `segments` below `value`, if every step is an object. +fn remove_path(value: &mut Value, segments: &[&str]) { + let Some(object) = value.as_object_mut() else { + return; + }; + match segments { + [] => {} + [last] => { + object.remove(*last); + } + [next, rest @ ..] => { + if let Some(child) = object.get_mut(*next) { + remove_path(child, rest); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn vars(value: Value) -> HostVars { + serde_json::from_value(value).unwrap() + } + + fn sample() -> HostVars { + vars(json!({ + "os": "linux", + "a.b": "literal", + "monitor": { + "name": "motoko", + "status": "UP", + "services": [{"name": "Ping"}], + "deep": {"x": 1, "y": 2} + } + })) + } + + #[test] + fn lookup_walks_objects_and_prefers_a_literal_key() { + let v = sample(); + assert_eq!(lookup(&v, "os"), Some(&json!("linux"))); + assert_eq!(lookup(&v, "monitor.name"), Some(&json!("motoko"))); + assert_eq!(lookup(&v, "monitor.deep.y"), Some(&json!(2))); + // a top-level key named with a dot is that key, not a path + assert_eq!(lookup(&v, "a.b"), Some(&json!("literal"))); + assert_eq!(lookup(&v, "monitor.missing"), None); + // never into lists + assert_eq!(lookup(&v, "monitor.services.name"), None); + // nor through a scalar + assert_eq!(lookup(&v, "os.anything"), None); + } + + #[test] + fn keep_builds_only_the_named_paths_under_their_roots() { + let v = sample(); + let sel = VarSelect::new(Some(["monitor.name", "monitor.deep.x", "os"]), []); + assert_eq!( + serde_json::to_value(sel.host(&v)).unwrap(), + json!({"os": "linux", "monitor": {"name": "motoko", "deep": {"x": 1}}}) + ); + // a path that finds nothing adds nothing, not an empty object + let sel = VarSelect::new(Some(["monitor.nope", "missing.path"]), []); + assert_eq!(serde_json::to_value(sel.host(&v)).unwrap(), json!({})); + } + + #[test] + fn keeping_a_whole_root_and_a_path_under_it_keeps_the_whole_root() { + let v = sample(); + for order in [["monitor", "monitor.name"], ["monitor.name", "monitor"]] { + let sel = VarSelect::new(Some(order), []); + assert_eq!(sel.host(&v)["monitor"], v["monitor"], "order {order:?}"); + } + } + + #[test] + fn drop_removes_a_nested_key_and_leaves_its_siblings() { + let v = sample(); + let sel = VarSelect::new( + None::<[&str; 0]>, + ["monitor.services", "monitor.deep.x", "os"], + ); + assert_eq!( + serde_json::to_value(sel.host(&v)).unwrap(), + json!({"a.b": "literal", "monitor": {"name": "motoko", "status": "UP", "deep": {"y": 2}}}) + ); + } + + #[test] + fn drop_applies_after_keep() { + let v = sample(); + let sel = VarSelect::new(Some(["monitor"]), ["monitor.services", "monitor.deep"]); + assert_eq!( + serde_json::to_value(sel.host(&v)).unwrap(), + json!({"monitor": {"name": "motoko", "status": "UP"}}) + ); + } + + #[test] + fn top_level_names_behave_exactly_as_before_paths() { + let v = sample(); + let sel = VarSelect::new(Some(["os", "a.b"]), ["a.b"]); + assert_eq!( + serde_json::to_value(sel.host(&v)).unwrap(), + json!({"os": "linux"}) + ); + assert!(VarSelect::new(None::<[&str; 0]>, []).is_identity()); + } +} diff --git a/tests/api_integration_test.rs b/tests/api_integration_test.rs index 52f90e7..91efb06 100644 --- a/tests/api_integration_test.rs +++ b/tests/api_integration_test.rs @@ -2405,3 +2405,156 @@ async fn builtin_endpoint_names_a_renamed_setting_in_a_request() { ); assert!(body.contains("os="), "body was: {}", body); } + +// A source with nested hostvars and nested groups, for dotted paths and +// `groups=`: three hosts in two groups, both under `dcs`, itself under +// `everything`, and a group var that is an object. +fn app_with_nested_data() -> axum::Router { + let (app, state) = unified_api::AppBuilder::new().build_with_state(); + let dataset: unified_api::domain::dataset::Dataset = serde_json::from_value(serde_json::json!({ + "hostvars": { + "a1.dc": {"os": "linux", "mon": {"name": "a1", "status": "UP", "services": [{"name": "Ping"}]}}, + "a2.dc": {"os": "linux", "mon": {"name": "a2", "status": "UP", "services": []}}, + "b1.dc": {"os": "linux", "mon": {"name": "b1", "status": "DOWN", "services": []}} + }, + "groups": { + "grp_a": {"hosts": ["a1.dc", "a2.dc"], "vars": {"mon": {"poller": "p1", "secret": "s"}}}, + "grp_b": {"hosts": ["b1.dc"]}, + "dcs": {"children": ["grp_a", "grp_b"]}, + "everything": {"children": ["dcs"]} + } + })) + .unwrap(); + state.cache.set( + "src-nested", + unified_api::domain::cache_entry::CacheEntry::new(dataset, 3600), + ); + app +} + +async fn get_json(path: &str) -> (StatusCode, serde_json::Value) { + let (status, body) = get(app_with_nested_data(), path).await; + let json = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); + (status, json) +} + +#[tokio::test] +async fn dataset_groups_matching_keeps_the_pages_groups_and_their_parents() { + let (status, json) = + get_json("/api/v1/sources/src-nested/dataset?host=a1.dc&groups=matching").await; + assert_eq!(status, StatusCode::OK); + let groups = json["groups"].as_object().unwrap(); + assert_eq!( + groups["grp_a"]["hosts"], + serde_json::json!(["a1.dc"]), + "members cut to the page" + ); + assert_eq!( + groups["grp_a"]["vars"]["mon"]["poller"], "p1", + "group vars untouched" + ); + assert_eq!( + groups["dcs"]["children"], + serde_json::json!(["grp_a"]), + "children cut to kept groups" + ); + assert_eq!( + groups["everything"]["children"], + serde_json::json!(["dcs"]), + "kept through its child" + ); + assert!( + !groups.contains_key("grp_b"), + "a group with no host on the page goes" + ); +} + +#[tokio::test] +async fn dataset_groups_default_is_all_and_none_drops_the_key() { + let (_, json) = get_json("/api/v1/sources/src-nested/dataset?host=a1.dc").await; + assert_eq!( + json["groups"]["grp_b"]["hosts"], + serde_json::json!(["b1.dc"]), + "default unchanged: every group in full" + ); + assert_eq!( + json["groups"]["grp_a"]["hosts"].as_array().unwrap().len(), + 2 + ); + + let (status, json) = + get_json("/api/v1/sources/src-nested/dataset?host=a1.dc&groups=none").await; + assert_eq!(status, StatusCode::OK); + assert!(json.get("groups").is_none(), "groups=none: no groups key"); + assert!(json["hostvars"]["a1.dc"].is_object()); + + let (status, _) = get_json("/api/v1/sources/src-nested/dataset?groups=some").await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "an unknown mode is refused" + ); +} + +#[tokio::test] +async fn dataset_fields_and_exclude_fields_take_dotted_paths() { + let (_, json) = + get_json("/api/v1/sources/src-nested/dataset?host=a1.dc&fields=mon.name,mon.status").await; + assert_eq!( + json["hostvars"]["a1.dc"], + serde_json::json!({"mon": {"name": "a1", "status": "UP"}}) + ); + + // exclude_fields reaches group vars too, path by path + let (_, json) = get_json( + "/api/v1/sources/src-nested/dataset?host=a1.dc&exclude_fields=mon.services,mon.secret", + ) + .await; + assert_eq!( + json["hostvars"]["a1.dc"]["mon"], + serde_json::json!({"name": "a1", "status": "UP"}) + ); + assert_eq!( + json["groups"]["grp_a"]["vars"], + serde_json::json!({"mon": {"poller": "p1"}}) + ); +} + +#[tokio::test] +async fn dataset_filter_takes_dotted_keys() { + let (_, json) = + get_json("/api/v1/sources/src-nested/dataset?filter=mon.status=DOWN&fields=mon.name").await; + assert_eq!(json["total_hosts"], 1); + assert_eq!(json["hostvars"]["b1.dc"]["mon"]["name"], "b1"); + // a path into a list finds nothing, so `key=` (missing) matches every host + let (_, json) = get_json("/api/v1/sources/src-nested/dataset?filter=mon.services.name=").await; + assert_eq!(json["total_hosts"], 3); +} + +#[tokio::test] +async fn hosts_route_takes_the_dataset_selectors() { + let names = |json: &serde_json::Value| json["hosts"].clone(); + let (_, json) = get_json("/api/v1/sources/src-nested/hosts?filter=mon.status=UP").await; + assert_eq!(names(&json), serde_json::json!(["a1.dc", "a2.dc"])); + assert_eq!(json["total_hosts"], 2); + let (_, json) = get_json("/api/v1/sources/src-nested/hosts?group=grp_b").await; + assert_eq!(names(&json), serde_json::json!(["b1.dc"])); + let (_, json) = get_json("/api/v1/sources/src-nested/hosts?host=A*").await; + assert_eq!( + names(&json), + serde_json::json!(["a1.dc", "a2.dc"]), + "patterns, case-insensitive" + ); + let (_, json) = + get_json("/api/v1/sources/src-nested/hosts?host=a2.dc,b1.dc&filter=mon.status=UP").await; + assert_eq!( + names(&json), + serde_json::json!(["a2.dc"]), + "selectors are ANDed" + ); + let (_, json) = get_json("/api/v1/sources/src-nested/hosts").await; + assert_eq!(json["total_hosts"], 3, "no selector: every host, as before"); + let (status, json) = get_json("/api/v1/sources/src-nested/hosts?filter=mon.status").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(json["error"].as_str().unwrap().contains("no operator")); +}