use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::client::projects::encode_segment;
use crate::client::query::ListQuery;
use crate::error::{CoreError, SignatureReason};
const RESOURCES_BASE: &str = "/data/api/v1/resources";
const QUOTE_CAP: usize = 64;
const FILTER_OPS: [&str; 10] = [
"eq", "ne", "cn", "sw", "ew", "gt", "gte", "lt", "lte", "rgx",
];
fn quoted(value: &str) -> String {
let head: String = value.chars().take(QUOTE_CAP).collect();
if head.len() < value.len() {
format!("{head:?}...")
} else {
format!("{head:?}")
}
}
pub fn validate_segment(label: &str, value: &str) -> Result<(), CoreError> {
let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
if value.is_empty() {
return Err(CoreError::InvalidInput {
reason: format!("resource {label} must not be empty"),
});
}
if value == "." || value == ".." || !value.chars().all(allowed) {
return Err(CoreError::InvalidInput {
reason: format!(
"resource {label} {} is not allowed: use only letters, digits, '.', '_' and '-' \
(and not '.' or '..' alone)",
quoted(value)
),
});
}
Ok(())
}
fn validated(module: &str, ty: &str) -> Result<(), CoreError> {
validate_segment("module", module)?;
validate_segment("type", ty)
}
pub fn resource_list_path(module: &str, ty: &str) -> Result<String, CoreError> {
validated(module, ty)?;
Ok(format!("{RESOURCES_BASE}/list/{module}/{ty}"))
}
pub fn resource_names_path(module: &str, ty: &str) -> Result<String, CoreError> {
validated(module, ty)?;
Ok(format!("{RESOURCES_BASE}/names/{module}/{ty}"))
}
pub fn resource_find_path(module: &str, ty: &str, name: &str) -> Result<String, CoreError> {
validated(module, ty)?;
if name.is_empty() {
return Err(CoreError::InvalidInput {
reason: "resource name must not be empty".to_string(),
});
}
if name == "." || name == ".." {
return Err(CoreError::InvalidInput {
reason: format!("resource name {} is not allowed", quoted(name)),
});
}
Ok(format!(
"{RESOURCES_BASE}/find/{module}/{ty}/{}",
encode_segment(name)
))
}
pub fn resource_singleton_path(module: &str, ty: &str) -> Result<String, CoreError> {
validated(module, ty)?;
Ok(format!("{RESOURCES_BASE}/singleton/{module}/{ty}"))
}
pub fn resource_write_path(module: &str, ty: &str) -> Result<String, CoreError> {
validated(module, ty)?;
Ok(format!("{RESOURCES_BASE}/{module}/{ty}"))
}
pub fn list_query_pairs(q: &ListQuery) -> Result<Vec<(String, String)>, CoreError> {
let mut pairs: Vec<(String, String)> = q
.to_query_pairs()
.into_iter()
.filter(|(key, _)| key != "filter")
.collect();
if let Some(filter) = &q.filter {
let bad = || CoreError::InvalidInput {
reason: format!(
"resource list filter {} must look like field[op]=value with op one of {}",
quoted(filter),
FILTER_OPS.join(", ")
),
};
let (key, value) = filter.split_once('=').ok_or_else(bad)?;
let (field, op) = key
.strip_suffix(']')
.and_then(|k| k.split_once('['))
.ok_or_else(bad)?;
let field_ok =
!field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !field_ok || !FILTER_OPS.contains(&op) {
return Err(bad());
}
pairs.push((key.to_string(), value.to_string()));
}
Ok(pairs)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceRecord {
#[serde(rename = "type", default)]
pub resource_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collection: Option<String>,
#[serde(default)]
pub collections: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default)]
pub config: serde_json::Value,
#[serde(
rename = "backupConfig",
alias = "backup_config",
default,
skip_serializing_if = "Option::is_none"
)]
pub backup_config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceName {
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default)]
pub modes: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
pub type MutationOutcome = crate::client::eam::ModifyOutcome;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ResourceCreate {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub collection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub config: serde_json::Value,
#[serde(rename = "backupConfig", skip_serializing_if = "Option::is_none")]
pub backup_config: Option<serde_json::Value>,
}
impl ResourceCreate {
pub fn new(name: impl Into<String>, config: serde_json::Value) -> Self {
Self {
name: name.into(),
collection: None,
enabled: None,
description: None,
config,
backup_config: None,
}
}
#[must_use]
pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
self.collection = Some(collection.into());
self
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = Some(enabled);
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_backup_config(mut self, backup_config: serde_json::Value) -> Self {
self.backup_config = Some(backup_config);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ResourceUpdate {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub collection: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub config: serde_json::Value,
#[serde(rename = "backupConfig", skip_serializing_if = "Option::is_none")]
pub backup_config: Option<serde_json::Value>,
signature: String,
}
impl ResourceUpdate {
pub fn new(
name: impl Into<String>,
signature: impl Into<String>,
config: serde_json::Value,
) -> Result<Self, CoreError> {
let name = name.into();
let signature = signature.into();
if name.is_empty() {
return Err(CoreError::InvalidInput {
reason: "resource name must not be empty".to_string(),
});
}
if signature.trim().is_empty() {
return Err(CoreError::ResourceSignatureRejected {
name,
reason: SignatureReason::Missing,
endpoint: None,
});
}
Ok(Self {
name,
collection: None,
enabled: None,
description: None,
config,
backup_config: None,
signature,
})
}
pub fn from_record(record: &ResourceRecord) -> Result<Self, CoreError> {
let name = record.name.clone().ok_or_else(|| CoreError::InvalidInput {
reason: "the record has no name, so it cannot be updated by name".to_string(),
})?;
let mut update = Self::new(
name,
record.signature.clone().unwrap_or_default(),
record.config.clone(),
)?;
update.collection = record.collection.clone();
update.enabled = record.enabled;
update.description = record.description.clone();
update.backup_config = record.backup_config.clone();
Ok(update)
}
pub fn signature(&self) -> &str {
&self.signature
}
#[must_use]
pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
self.collection = Some(collection.into());
self
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = Some(enabled);
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_backup_config(mut self, backup_config: serde_json::Value) -> Self {
self.backup_config = Some(backup_config);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn segments_accept_module_ids_and_types() {
for ok in [
"ignition",
"com.inductiveautomation.eam",
"eam-tasks",
"a_b",
] {
assert!(validate_segment("module", ok).is_ok(), "{ok}");
}
for bad in ["", ".", "..", "../etc", "a/b", "db conn", "é", "a%2Fb"] {
let err = validate_segment("module", bad).expect_err(bad);
assert_eq!(err.code(), "invalid_input", "{bad}");
}
}
#[test]
fn quoted_value_is_capped() {
let long = "x/".repeat(100);
let err = validate_segment("type", &long).expect_err("refused");
assert!(err.to_string().len() < 250, "{err}");
}
#[test]
fn name_segment_is_encoded_and_dot_names_refused() {
assert_eq!(
resource_find_path("com.inductiveautomation.eam", "eam-tasks", "a/b c é").unwrap(),
"/data/api/v1/resources/find/com.inductiveautomation.eam/eam-tasks/a%2Fb%20c%20%C3%A9"
);
assert!(resource_find_path("ignition", "t", "").is_err());
assert!(resource_find_path("ignition", "t", "..").is_err());
}
#[test]
fn filter_goes_out_object_style() {
let q = ListQuery {
filter: Some("name[eq]=A b".to_string()),
..ListQuery::default()
};
let pairs = list_query_pairs(&q).unwrap();
assert!(pairs.contains(&("name[eq]".to_string(), "A b".to_string())));
assert!(!pairs.iter().any(|(k, _)| k == "filter"));
for bad in ["name=A", "name[zz]=A", "[eq]=A", "na me[eq]=A", "name[eq]"] {
let q = ListQuery {
filter: Some(bad.to_string()),
..ListQuery::default()
};
assert!(list_query_pairs(&q).is_err(), "{bad}");
}
}
}