1use 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#[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 Enum(String),
29 Reference(RecordId),
30 List(Vec<ExtValue>),
31 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 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#[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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
124pub struct ExtensionSchema {
125 pub namespace: String,
126 pub version: SchemaVersion,
127 #[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#[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 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
273pub(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}