Skip to main content

pardarsh_core/
extension.rs

1//! Namespaced, typed extensions.
2//!
3//! Applications add their own fields to records through extensions instead of
4//! inventing a competing record model. Every extension namespace is described
5//! by an [`ExtensionSchema`] and every value is typed ([`ExtValue`]); there is
6//! no untyped JSON blob.
7
8use std::collections::{BTreeMap, HashMap};
9
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12
13use crate::id::{RecordId, is_namespace, is_simple_name};
14use crate::model::{Record, RecordKind, Timestamp};
15use crate::schema::SchemaVersion;
16use crate::validation::ValidationIssue;
17
18/// A typed extension value.
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ExtValue {
22    Text(String),
23    Integer(i64),
24    Boolean(bool),
25    Timestamp(Timestamp),
26    Date(NaiveDate),
27    /// One of the values listed in the field's [`FieldType::Enum`].
28    Enum(String),
29    Reference(RecordId),
30    List(Vec<ExtValue>),
31    /// The value was redacted; see ADR 0006.
32    Redacted,
33}
34
35impl ExtValue {
36    pub fn as_text(&self) -> Option<&str> {
37        match self {
38            Self::Text(s) | Self::Enum(s) => Some(s),
39            _ => None,
40        }
41    }
42
43    pub fn as_reference(&self) -> Option<&RecordId> {
44        match self {
45            Self::Reference(r) => Some(r),
46            _ => None,
47        }
48    }
49
50    pub fn as_date(&self) -> Option<NaiveDate> {
51        match self {
52            Self::Date(d) => Some(*d),
53            _ => None,
54        }
55    }
56
57    /// A short human-readable rendering, used in diffs and timelines.
58    pub fn display(&self) -> String {
59        match self {
60            Self::Text(s) | Self::Enum(s) => s.clone(),
61            Self::Integer(i) => i.to_string(),
62            Self::Boolean(b) => b.to_string(),
63            Self::Timestamp(t) => t.to_rfc3339(),
64            Self::Date(d) => d.to_string(),
65            Self::Reference(r) => r.to_string(),
66            Self::List(items) => {
67                let parts: Vec<_> = items.iter().map(ExtValue::display).collect();
68                format!("[{}]", parts.join(", "))
69            }
70            Self::Redacted => crate::event::REDACTED.to_string(),
71        }
72    }
73}
74
75/// The declared type of an extension field.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum FieldType {
79    Text {
80        max_len: usize,
81    },
82    Integer {
83        min: Option<i64>,
84        max: Option<i64>,
85    },
86    Boolean,
87    Timestamp,
88    Date,
89    Enum {
90        values: Vec<String>,
91    },
92    /// A reference to another record, optionally restricted to some kinds.
93    Reference {
94        kinds: Vec<RecordKind>,
95    },
96    List {
97        item: Box<FieldType>,
98        max_items: usize,
99    },
100}
101
102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
103pub struct FieldSpec {
104    #[serde(rename = "type")]
105    pub ty: FieldType,
106    #[serde(default)]
107    pub required: bool,
108    #[serde(default)]
109    pub description: String,
110}
111
112impl FieldSpec {
113    pub fn required(ty: FieldType, description: &str) -> Self {
114        Self { ty, required: true, description: description.to_string() }
115    }
116
117    pub fn optional(ty: FieldType, description: &str) -> Self {
118        Self { ty, required: false, description: description.to_string() }
119    }
120}
121
122/// Describes one extension namespace.
123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
124pub struct ExtensionSchema {
125    pub namespace: String,
126    pub version: SchemaVersion,
127    /// Record kinds the extension may be attached to (empty = any kind).
128    #[serde(default)]
129    pub applies_to: Vec<RecordKind>,
130    pub fields: BTreeMap<String, FieldSpec>,
131    #[serde(default)]
132    pub description: String,
133}
134
135impl ExtensionSchema {
136    pub fn new(namespace: &str, version: SchemaVersion) -> Self {
137        Self {
138            namespace: namespace.to_string(),
139            version,
140            applies_to: Vec::new(),
141            fields: BTreeMap::new(),
142            description: String::new(),
143        }
144    }
145
146    pub fn applies_to(mut self, kinds: impl IntoIterator<Item = RecordKind>) -> Self {
147        self.applies_to = kinds.into_iter().collect();
148        self
149    }
150
151    pub fn field(mut self, name: &str, spec: FieldSpec) -> Self {
152        self.fields.insert(name.to_string(), spec);
153        self
154    }
155
156    pub fn describe(mut self, d: &str) -> Self {
157        self.description = d.to_string();
158        self
159    }
160}
161
162/// The set of extension schemas a validator knows about.
163#[derive(Clone, Debug, Default)]
164pub struct ExtensionRegistry {
165    schemas: HashMap<String, ExtensionSchema>,
166}
167
168impl ExtensionRegistry {
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    pub fn register(&mut self, schema: ExtensionSchema) -> Result<(), String> {
174        if !is_namespace(&schema.namespace) {
175            return Err(format!("invalid extension namespace {:?}", schema.namespace));
176        }
177        if let Some(bad) = schema.fields.keys().find(|k| !is_simple_name(k)) {
178            return Err(format!("invalid field name {bad:?} in {}", schema.namespace));
179        }
180        if self.schemas.contains_key(&schema.namespace) {
181            return Err(format!("extension namespace {} is already registered", schema.namespace));
182        }
183        self.schemas.insert(schema.namespace.clone(), schema);
184        Ok(())
185    }
186
187    pub fn get(&self, namespace: &str) -> Option<&ExtensionSchema> {
188        self.schemas.get(namespace)
189    }
190
191    pub fn schemas(&self) -> impl Iterator<Item = &ExtensionSchema> {
192        self.schemas.values()
193    }
194
195    /// Validate the extension data on a record. Reference targets are only
196    /// checked for syntax here; existence is checked by the repository.
197    pub(crate) fn validate(&self, record: &Record, issues: &mut Vec<ValidationIssue>) {
198        for (namespace, data) in &record.extensions {
199            let path = |f: &str| format!("extensions.{namespace}.{f}");
200            let Some(schema) = self.schemas.get(namespace) else {
201                issues.push(ValidationIssue::new(format!("extensions.{namespace}"), "unknown extension namespace"));
202                continue;
203            };
204            if !schema.version.can_read(&data.schema_version) {
205                issues.push(ValidationIssue::new(
206                    format!("extensions.{namespace}.schema_version"),
207                    format!("version {} is not readable by schema {}", data.schema_version, schema.version),
208                ));
209            }
210            if !schema.applies_to.is_empty() && !schema.applies_to.contains(&record.kind) {
211                issues.push(ValidationIssue::new(
212                    format!("extensions.{namespace}"),
213                    format!("extension does not apply to kind {}", record.kind),
214                ));
215            }
216            for (field, value) in &data.fields {
217                match schema.fields.get(field) {
218                    None => issues.push(ValidationIssue::new(path(field), "unknown field")),
219                    Some(spec) => check_value(&spec.ty, value, &path(field), issues),
220                }
221            }
222            for (field, spec) in &schema.fields {
223                if spec.required && !data.fields.contains_key(field) {
224                    issues.push(ValidationIssue::new(path(field), "required field is missing"));
225                }
226            }
227        }
228    }
229}
230
231fn check_value(ty: &FieldType, value: &ExtValue, path: &str, issues: &mut Vec<ValidationIssue>) {
232    if matches!(value, ExtValue::Redacted) {
233        return;
234    }
235    let mismatch = |issues: &mut Vec<ValidationIssue>| {
236        issues.push(ValidationIssue::new(path, format!("value does not match declared type {ty:?}")))
237    };
238    match (ty, value) {
239        (FieldType::Text { max_len }, ExtValue::Text(s)) => {
240            if s.chars().count() > *max_len {
241                issues.push(ValidationIssue::new(path, format!("text longer than {max_len} characters")));
242            }
243            if s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
244                issues.push(ValidationIssue::new(path, "text contains control characters"));
245            }
246        }
247        (FieldType::Integer { min, max }, ExtValue::Integer(i)) => {
248            if min.is_some_and(|m| *i < m) || max.is_some_and(|m| *i > m) {
249                issues.push(ValidationIssue::new(path, "integer out of range"));
250            }
251        }
252        (FieldType::Boolean, ExtValue::Boolean(_))
253        | (FieldType::Timestamp, ExtValue::Timestamp(_))
254        | (FieldType::Date, ExtValue::Date(_))
255        | (FieldType::Reference { .. }, ExtValue::Reference(_)) => {}
256        (FieldType::Enum { values }, ExtValue::Enum(v)) => {
257            if !values.contains(v) {
258                issues.push(ValidationIssue::new(path, format!("{v:?} is not one of {values:?}")));
259            }
260        }
261        (FieldType::List { item, max_items }, ExtValue::List(items)) => {
262            if items.len() > *max_items {
263                issues.push(ValidationIssue::new(path, format!("more than {max_items} items")));
264            }
265            for (i, v) in items.iter().enumerate() {
266                check_value(item, v, &format!("{path}[{i}]"), issues);
267            }
268        }
269        _ => mismatch(issues),
270    }
271}
272
273/// Collect every `(path, reference, allowed kinds)` in a record's extensions
274/// so the repository can check targets exist and have the right kind.
275pub(crate) fn extension_references<'a>(
276    registry: &'a ExtensionRegistry,
277    record: &'a Record,
278) -> Vec<(String, &'a RecordId, &'a [RecordKind])> {
279    let mut out = Vec::new();
280    for (namespace, data) in &record.extensions {
281        let Some(schema) = registry.get(namespace) else { continue };
282        for (field, value) in &data.fields {
283            let Some(spec) = schema.fields.get(field) else { continue };
284            collect_refs(&spec.ty, value, format!("extensions.{namespace}.{field}"), &mut out);
285        }
286    }
287    out
288}
289
290fn collect_refs<'a>(
291    ty: &'a FieldType,
292    value: &'a ExtValue,
293    path: String,
294    out: &mut Vec<(String, &'a RecordId, &'a [RecordKind])>,
295) {
296    match (ty, value) {
297        (FieldType::Reference { kinds }, ExtValue::Reference(id)) => out.push((path, id, kinds.as_slice())),
298        (FieldType::List { item, .. }, ExtValue::List(items)) => {
299            for (i, v) in items.iter().enumerate() {
300                collect_refs(item, v, format!("{path}[{i}]"), out);
301            }
302        }
303        _ => {}
304    }
305}