Skip to main content

ignition_core/client/
config_resources.rs

1//! Config resources by module, type and name: path builders, segment
2//! validation and the lenient wire models (20-04, DAPI-01 / DAPI-02).
3//!
4//! Every shape here rests on the live 8.3.6 captures in
5//! `20-LIVE-CAPTURES.md`: the `C-list` blocks (find with and without a
6//! collection, list, names, singleton), the `C3a` blocks (create, update,
7//! the signature refusals) and Decision D7 (what a list can and cannot see).
8//!
9//! `config` is a [`serde_json::Value`], so the key order of a resource's
10//! config is not preserved: the workspace keeps `serde_json`'s order
11//! preservation off as a frozen-contract rule. Callers compare configs
12//! semantically, never as text.
13//!
14//! Path safety (T-20-16): the module and type segments are validated
15//! against `[A-Za-z0-9._-]` and never encoded, so a module id such as
16//! `com.inductiveautomation.eam` keeps its dots. The name segment is free
17//! text, so it is percent-encoded from its UTF-8 bytes through
18//! [`crate::client::projects::encode_segment`].
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::client::projects::encode_segment;
25use crate::client::query::ListQuery;
26use crate::error::{CoreError, SignatureReason};
27
28/// Base of every resource route.
29const RESOURCES_BASE: &str = "/data/api/v1/resources";
30
31/// Longest caller value quoted back in an `invalid_input` reason.
32const QUOTE_CAP: usize = 64;
33
34/// The filter operators the live OpenAPI allows (Decision D7: the
35/// `propertyNames` pattern of the `filter` parameter).
36const FILTER_OPS: [&str; 10] = [
37    "eq", "ne", "cn", "sw", "ew", "gt", "gte", "lt", "lte", "rgx",
38];
39
40fn quoted(value: &str) -> String {
41    let head: String = value.chars().take(QUOTE_CAP).collect();
42    if head.len() < value.len() {
43        format!("{head:?}...")
44    } else {
45        format!("{head:?}")
46    }
47}
48
49/// Check a module or type path segment: non-empty ASCII `[A-Za-z0-9._-]`,
50/// and not `.` or `..`. Refused before any request is built.
51pub fn validate_segment(label: &str, value: &str) -> Result<(), CoreError> {
52    let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
53    if value.is_empty() {
54        return Err(CoreError::InvalidInput {
55            reason: format!("resource {label} must not be empty"),
56        });
57    }
58    if value == "." || value == ".." || !value.chars().all(allowed) {
59        return Err(CoreError::InvalidInput {
60            reason: format!(
61                "resource {label} {} is not allowed: use only letters, digits, '.', '_' and '-' \
62                 (and not '.' or '..' alone)",
63                quoted(value)
64            ),
65        });
66    }
67    Ok(())
68}
69
70fn validated(module: &str, ty: &str) -> Result<(), CoreError> {
71    validate_segment("module", module)?;
72    validate_segment("type", ty)
73}
74
75/// `GET .../resources/list/{module}/{type}` (capture C-list "GET list").
76pub fn resource_list_path(module: &str, ty: &str) -> Result<String, CoreError> {
77    validated(module, ty)?;
78    Ok(format!("{RESOURCES_BASE}/list/{module}/{ty}"))
79}
80
81/// `GET .../resources/names/{module}/{type}` (capture C-list "GET names").
82pub fn resource_names_path(module: &str, ty: &str) -> Result<String, CoreError> {
83    validated(module, ty)?;
84    Ok(format!("{RESOURCES_BASE}/names/{module}/{ty}"))
85}
86
87/// `GET .../resources/find/{module}/{type}/{name}` (captures C3a "GET find"
88/// and C-list). The name is percent-encoded from its UTF-8 bytes. An empty
89/// name, or one that is `.` or `..`, is refused: an encoded dot segment is
90/// ambiguous to the servlet container, so it is never sent.
91pub fn resource_find_path(module: &str, ty: &str, name: &str) -> Result<String, CoreError> {
92    validated(module, ty)?;
93    if name.is_empty() {
94        return Err(CoreError::InvalidInput {
95            reason: "resource name must not be empty".to_string(),
96        });
97    }
98    if name == "." || name == ".." {
99        return Err(CoreError::InvalidInput {
100            reason: format!("resource name {} is not allowed", quoted(name)),
101        });
102    }
103    Ok(format!(
104        "{RESOURCES_BASE}/find/{module}/{ty}/{}",
105        encode_segment(name)
106    ))
107}
108
109/// `GET .../resources/singleton/{module}/{type}` (capture C-list
110/// "GET singleton").
111pub fn resource_singleton_path(module: &str, ty: &str) -> Result<String, CoreError> {
112    validated(module, ty)?;
113    Ok(format!("{RESOURCES_BASE}/singleton/{module}/{ty}"))
114}
115
116/// `POST` (create) and `PUT` (update) `.../resources/{module}/{type}`
117/// (captures C3a).
118pub fn resource_write_path(module: &str, ty: &str) -> Result<String, CoreError> {
119    validated(module, ty)?;
120    Ok(format!("{RESOURCES_BASE}/{module}/{ty}"))
121}
122
123/// The query pairs of a resource list call.
124///
125/// `limit` and `offset` always go out. Per Decision D7 the gateway reads a
126/// filter as its own query parameter `field[op]=value`, NOT as
127/// `filter=field[op]=value` (that form matches nothing, capture C-list
128/// "list with filter"). So a `ListQuery.filter` of `name[eq]=X` is sent as
129/// the pair `name[eq]` = `X`, and one that is not of that form is refused
130/// with `invalid_input` rather than sent to a route that would silently
131/// answer an empty list. The list sees the EFFECTIVE view only (D7).
132pub fn list_query_pairs(q: &ListQuery) -> Result<Vec<(String, String)>, CoreError> {
133    let mut pairs: Vec<(String, String)> = q
134        .to_query_pairs()
135        .into_iter()
136        .filter(|(key, _)| key != "filter")
137        .collect();
138    if let Some(filter) = &q.filter {
139        let bad = || CoreError::InvalidInput {
140            reason: format!(
141                "resource list filter {} must look like field[op]=value with op one of {}",
142                quoted(filter),
143                FILTER_OPS.join(", ")
144            ),
145        };
146        let (key, value) = filter.split_once('=').ok_or_else(bad)?;
147        let (field, op) = key
148            .strip_suffix(']')
149            .and_then(|k| k.split_once('['))
150            .ok_or_else(bad)?;
151        let field_ok =
152            !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
153        if !field_ok || !FILTER_OPS.contains(&op) {
154            return Err(bad());
155        }
156        pairs.push((key.to_string(), value.to_string()));
157    }
158    Ok(pairs)
159}
160
161/// One config resource as the gateway reports it (captures C3a "GET find",
162/// C-list "GET find" / "GET list" / "GET singleton"). Lenient: a key the
163/// captures do not show stays in `extra` (live bodies also carry `data`,
164/// `metrics` and `healthchecks`). A singleton has no `name`.
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct ResourceRecord {
167    /// `type` — the `{module}/{type}` token, e.g. `ignition/database-connection`.
168    #[serde(rename = "type", default)]
169    pub resource_type: String,
170    /// The resource name; absent on a singleton.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub name: Option<String>,
173    /// `description` (`null` on the captured create).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub description: Option<String>,
176    /// `enabled`.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub enabled: Option<bool>,
179    /// `version` (1 on every capture).
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub version: Option<i64>,
182    /// The collection this copy lives in (`core`, `production`, ...).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub collection: Option<String>,
185    /// Every collection that holds a copy (C-list: `["core","production"]`).
186    #[serde(default)]
187    pub collections: Vec<String>,
188    /// The signature of THIS copy. An update must carry the signature of
189    /// the copy it targets (D7: the two copies differ).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub signature: Option<String>,
192    /// The resource config, order not preserved (see the module doc).
193    #[serde(default)]
194    pub config: serde_json::Value,
195    /// `backupConfig`, when present.
196    #[serde(
197        rename = "backupConfig",
198        alias = "backup_config",
199        default,
200        skip_serializing_if = "Option::is_none"
201    )]
202    pub backup_config: Option<serde_json::Value>,
203    /// `attributes` (`uuid`, `enabled` on the captures); shape not modelled.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub attributes: Option<serde_json::Value>,
206    /// Every key above that the captures do not type.
207    #[serde(flatten)]
208    pub extra: BTreeMap<String, serde_json::Value>,
209}
210
211/// One item of the `names` list (capture C-list "GET names").
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct ResourceName {
214    /// The resource name.
215    #[serde(default)]
216    pub name: String,
217    /// `enabled`.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub enabled: Option<bool>,
220    /// The collections that hold the resource, in gateway order.
221    #[serde(default)]
222    pub modes: Vec<String>,
223    /// Any other key.
224    #[serde(flatten)]
225    pub extra: BTreeMap<String, serde_json::Value>,
226}
227
228/// The mutation envelope of a create or update: `{success, changes[],
229/// problem}` (captures C3a "POST create" and "PUT with the right
230/// signature"). `changes[].new_signature` is the POST-write signature: each
231/// write rotates it, so it is what the next update must carry.
232/// `problem.message` is never surfaced verbatim by this crate (8.3.3
233/// messages carry the live signature; the 8.3.6 stacktrace is about 6 KB).
234pub type MutationOutcome = crate::client::eam::ModifyOutcome;
235
236/// One resource to create, `POST .../resources/{module}/{type}` (capture
237/// C3a "POST create"; Decision D10: a create needs only `name` and
238/// `config`, and assumption A9 holds, so there is no `attributes` field).
239/// Absent options are not sent, never sent as `null`.
240#[derive(Debug, Clone, PartialEq, Serialize)]
241pub struct ResourceCreate {
242    /// The resource name.
243    pub name: String,
244    /// Target collection; the gateway picks one when absent (`core` on the
245    /// capture). C-list created a `production` copy by sending it.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub collection: Option<String>,
248    /// `enabled`.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub enabled: Option<bool>,
251    /// `description`.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub description: Option<String>,
254    /// The resource config.
255    pub config: serde_json::Value,
256    /// `backupConfig`.
257    #[serde(rename = "backupConfig", skip_serializing_if = "Option::is_none")]
258    pub backup_config: Option<serde_json::Value>,
259}
260
261impl ResourceCreate {
262    /// A create of `name` with `config` and nothing else.
263    pub fn new(name: impl Into<String>, config: serde_json::Value) -> Self {
264        Self {
265            name: name.into(),
266            collection: None,
267            enabled: None,
268            description: None,
269            config,
270            backup_config: None,
271        }
272    }
273
274    /// Target a collection.
275    #[must_use]
276    pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
277        self.collection = Some(collection.into());
278        self
279    }
280
281    /// Set `enabled`.
282    #[must_use]
283    pub fn with_enabled(mut self, enabled: bool) -> Self {
284        self.enabled = Some(enabled);
285        self
286    }
287
288    /// Set `description`.
289    #[must_use]
290    pub fn with_description(mut self, description: impl Into<String>) -> Self {
291        self.description = Some(description.into());
292        self
293    }
294
295    /// Set `backupConfig`.
296    #[must_use]
297    pub fn with_backup_config(mut self, backup_config: serde_json::Value) -> Self {
298        self.backup_config = Some(backup_config);
299        self
300    }
301}
302
303/// One resource to update, an item of the signature-checked array PUT
304/// (capture C3a, Decision D3). The signature is PRIVATE and always
305/// serialized, so an update without one cannot be built: construction
306/// goes through [`ResourceUpdate::new`] or [`ResourceUpdate::from_record`],
307/// both of which refuse an empty signature as
308/// `resource_signature_rejected` (missing) before any request exists.
309///
310/// The signature must be the one of the copy being updated (D7: copies in
311/// different collections carry different signatures), read just before the
312/// write; every successful write rotates it.
313#[derive(Debug, Clone, PartialEq, Serialize)]
314pub struct ResourceUpdate {
315    /// The resource name; an update of an unknown name is a bare 404 (D10).
316    pub name: String,
317    /// Collection of the copy being updated.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub collection: Option<String>,
320    /// `enabled`.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub enabled: Option<bool>,
323    /// `description`.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub description: Option<String>,
326    /// The new resource config.
327    pub config: serde_json::Value,
328    /// `backupConfig`.
329    #[serde(rename = "backupConfig", skip_serializing_if = "Option::is_none")]
330    pub backup_config: Option<serde_json::Value>,
331    signature: String,
332}
333
334impl ResourceUpdate {
335    /// An update of `name` carrying `signature` and the new `config`.
336    ///
337    /// An empty or whitespace-only signature is
338    /// `resource_signature_rejected` (missing); an empty name is
339    /// `invalid_input`. Both are refused before any request.
340    pub fn new(
341        name: impl Into<String>,
342        signature: impl Into<String>,
343        config: serde_json::Value,
344    ) -> Result<Self, CoreError> {
345        let name = name.into();
346        let signature = signature.into();
347        if name.is_empty() {
348            return Err(CoreError::InvalidInput {
349                reason: "resource name must not be empty".to_string(),
350            });
351        }
352        if signature.trim().is_empty() {
353            return Err(CoreError::ResourceSignatureRejected {
354                name,
355                reason: SignatureReason::Missing,
356                endpoint: None,
357            });
358        }
359        Ok(Self {
360            name,
361            collection: None,
362            enabled: None,
363            description: None,
364            config,
365            backup_config: None,
366            signature,
367        })
368    }
369
370    /// An update seeded from a record just read: its name, signature,
371    /// collection, `enabled`, `description`, `config` and `backupConfig`.
372    /// A record without a signature is `resource_signature_rejected`
373    /// (missing); one without a name (a singleton) is `invalid_input`.
374    /// Every capture of a PUT body (C3a) carries only `name`, `signature`,
375    /// `description` and `config`. Whether the gateway accepts `collection`,
376    /// `enabled` and `backupConfig` on a PUT, and which copy a collection-less
377    /// PUT updates, is NOT settled by any capture: the 20-06 live gate
378    /// updates with [`ResourceUpdate::new`] only. The live section that
379    /// exercises this constructor (two copies present, one update per copy)
380    /// is written but has not been run against a gateway yet (20-REVIEW
381    /// WR-03), so until it has, treat the extra keys as unproven and prefer
382    /// [`ResourceUpdate::new`] with [`ResourceUpdate::with_collection`] only
383    /// when a copy must be targeted.
384    pub fn from_record(record: &ResourceRecord) -> Result<Self, CoreError> {
385        let name = record.name.clone().ok_or_else(|| CoreError::InvalidInput {
386            reason: "the record has no name, so it cannot be updated by name".to_string(),
387        })?;
388        let mut update = Self::new(
389            name,
390            record.signature.clone().unwrap_or_default(),
391            record.config.clone(),
392        )?;
393        update.collection = record.collection.clone();
394        update.enabled = record.enabled;
395        update.description = record.description.clone();
396        update.backup_config = record.backup_config.clone();
397        Ok(update)
398    }
399
400    /// The signature this update carries.
401    pub fn signature(&self) -> &str {
402        &self.signature
403    }
404
405    /// Target a collection.
406    #[must_use]
407    pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
408        self.collection = Some(collection.into());
409        self
410    }
411
412    /// Set `enabled`.
413    #[must_use]
414    pub fn with_enabled(mut self, enabled: bool) -> Self {
415        self.enabled = Some(enabled);
416        self
417    }
418
419    /// Set `description`.
420    #[must_use]
421    pub fn with_description(mut self, description: impl Into<String>) -> Self {
422        self.description = Some(description.into());
423        self
424    }
425
426    /// Set `backupConfig`.
427    #[must_use]
428    pub fn with_backup_config(mut self, backup_config: serde_json::Value) -> Self {
429        self.backup_config = Some(backup_config);
430        self
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn segments_accept_module_ids_and_types() {
440        for ok in [
441            "ignition",
442            "com.inductiveautomation.eam",
443            "eam-tasks",
444            "a_b",
445        ] {
446            assert!(validate_segment("module", ok).is_ok(), "{ok}");
447        }
448        for bad in ["", ".", "..", "../etc", "a/b", "db conn", "é", "a%2Fb"] {
449            let err = validate_segment("module", bad).expect_err(bad);
450            assert_eq!(err.code(), "invalid_input", "{bad}");
451        }
452    }
453
454    #[test]
455    fn quoted_value_is_capped() {
456        let long = "x/".repeat(100);
457        let err = validate_segment("type", &long).expect_err("refused");
458        assert!(err.to_string().len() < 250, "{err}");
459    }
460
461    #[test]
462    fn name_segment_is_encoded_and_dot_names_refused() {
463        assert_eq!(
464            resource_find_path("com.inductiveautomation.eam", "eam-tasks", "a/b c é").unwrap(),
465            "/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/a%2Fb%20c%20%C3%A9"
466        );
467        assert!(resource_find_path("ignition", "t", "").is_err());
468        assert!(resource_find_path("ignition", "t", "..").is_err());
469    }
470
471    #[test]
472    fn filter_goes_out_object_style() {
473        let q = ListQuery {
474            filter: Some("name[eq]=A b".to_string()),
475            ..ListQuery::default()
476        };
477        let pairs = list_query_pairs(&q).unwrap();
478        assert!(pairs.contains(&("name[eq]".to_string(), "A b".to_string())));
479        assert!(!pairs.iter().any(|(k, _)| k == "filter"));
480        for bad in ["name=A", "name[zz]=A", "[eq]=A", "na me[eq]=A", "name[eq]"] {
481            let q = ListQuery {
482                filter: Some(bad.to_string()),
483                ..ListQuery::default()
484            };
485            assert!(list_query_pairs(&q).is_err(), "{bad}");
486        }
487    }
488}