Skip to main content

ucp_schema/
types.rs

1//! Core types for UCP schema resolution.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Schema transition: from/to are visibility values (omit, optional, required).
7/// During the transition period the field is always the `from` visibility.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SchemaTransitionInfo {
10    pub from: String,
11    pub to: String,
12    pub description: String,
13}
14
15/// Valid UCP operations for annotation object form.
16pub const VALID_OPERATIONS: &[&str] = &["create", "update", "complete", "read"];
17
18/// UCP annotation keys.
19pub const UCP_ANNOTATIONS: &[&str] = &["ucp_request", "ucp_response"];
20
21/// Returns the JSON type name for error messages.
22pub fn json_type_name(value: &Value) -> &'static str {
23    match value {
24        Value::Null => "null",
25        Value::Bool(_) => "boolean",
26        Value::Number(_) => "number",
27        Value::String(_) => "string",
28        Value::Array(_) => "array",
29        Value::Object(_) => "object",
30    }
31}
32
33/// Direction of the schema transformation.
34///
35/// Determines whether to use `ucp_request` or `ucp_response` annotations.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Direction {
39    Request,
40    Response,
41}
42
43impl Direction {
44    /// Returns the annotation key for this direction.
45    pub fn annotation_key(&self) -> &'static str {
46        match self {
47            Direction::Request => "ucp_request",
48            Direction::Response => "ucp_response",
49        }
50    }
51
52    /// Returns the bare direction string ("request" / "response").
53    ///
54    /// Used to build container operation-shape keys (`{op}_{direction}`,
55    /// e.g. `search_response`) when selecting the validation target for
56    /// container-shaped capabilities.
57    pub fn dir_str(&self) -> &'static str {
58        match self {
59            Direction::Request => "request",
60            Direction::Response => "response",
61        }
62    }
63
64    /// Create direction from a request flag (true = Request, false = Response).
65    pub fn from_request_flag(is_request: bool) -> Self {
66        if is_request {
67            Direction::Request
68        } else {
69            Direction::Response
70        }
71    }
72}
73
74/// Visibility of a field after resolution.
75///
76/// Determines how a field is transformed in the output schema.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
78pub enum Visibility {
79    /// No transformation - keep field as-is with original required status.
80    #[default]
81    Include,
82    /// Remove field from properties and required array.
83    Omit,
84    /// Keep field and ensure it's in the required array.
85    Required,
86    /// Keep field but remove from required array.
87    Optional,
88}
89
90impl Visibility {
91    /// Parse a visibility value from a string.
92    ///
93    /// Returns `None` for unknown values (caller should error).
94    pub fn parse(s: &str) -> Option<Self> {
95        match s {
96            "omit" => Some(Visibility::Omit),
97            "required" => Some(Visibility::Required),
98            "optional" => Some(Visibility::Optional),
99            _ => None,
100        }
101    }
102}
103
104/// Returns true if (from, to) is a valid schema transition: both are visibility
105/// values (omit, optional, required) and from != to.
106pub fn is_valid_schema_transition(from: &str, to: &str) -> bool {
107    from != to && Visibility::parse(from).is_some() && Visibility::parse(to).is_some()
108}
109
110// ---------------------------------------------------------------------------
111// Version constraints (`requires` on extension schemas)
112// ---------------------------------------------------------------------------
113
114/// Check if a string is a valid UCP version (YYYY-MM-DD with valid month/day).
115pub fn is_valid_version(s: &str) -> bool {
116    if s.len() != 10 || s.as_bytes()[4] != b'-' || s.as_bytes()[7] != b'-' {
117        return false;
118    }
119    if !s.bytes().enumerate().all(|(i, b)| {
120        if i == 4 || i == 7 {
121            b == b'-'
122        } else {
123            b.is_ascii_digit()
124        }
125    }) {
126        return false;
127    }
128    let month: u8 = s[5..7].parse().unwrap_or(0);
129    let day: u8 = s[8..10].parse().unwrap_or(0);
130    (1..=12).contains(&month) && (1..=31).contains(&day)
131}
132
133/// Version range: minimum (required) and optional maximum, both inclusive.
134///
135/// Date-based versions (YYYY-MM-DD) are lexicographically orderable,
136/// so constraint checking is simple string comparison.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct VersionConstraint {
139    pub min: String,
140    pub max: Option<String>,
141}
142
143impl VersionConstraint {
144    /// Check if a version satisfies this constraint.
145    pub fn satisfied_by(&self, version: &str) -> bool {
146        if version < self.min.as_str() {
147            return false;
148        }
149        if let Some(ref max) = self.max {
150            if version > max.as_str() {
151                return false;
152            }
153        }
154        true
155    }
156
157    /// Parse from a JSON value: `{ "min": "...", "max": "..." }`.
158    pub fn parse(value: &Value) -> Result<Self, String> {
159        let obj = value.as_object().ok_or("expected object")?;
160
161        let min = obj
162            .get("min")
163            .and_then(|v| v.as_str())
164            .ok_or("missing required field \"min\"")?;
165
166        if !is_valid_version(min) {
167            return Err(format!(
168                "invalid version format for \"min\": \"{}\" (expected YYYY-MM-DD)",
169                min
170            ));
171        }
172
173        let max = match obj.get("max") {
174            Some(v) => {
175                let s = v.as_str().ok_or("\"max\" must be a string")?;
176                if !is_valid_version(s) {
177                    return Err(format!(
178                        "invalid version format for \"max\": \"{}\" (expected YYYY-MM-DD)",
179                        s
180                    ));
181                }
182                Some(s.to_string())
183            }
184            None => None,
185        };
186
187        Ok(Self {
188            min: min.to_string(),
189            max,
190        })
191    }
192}
193
194/// Extension schema version requirements (`requires` field).
195///
196/// Declares minimum (and optionally maximum) protocol and capability
197/// versions needed for correct operation.
198#[derive(Debug, Clone, PartialEq, Eq, Default)]
199pub struct Requires {
200    pub protocol: Option<VersionConstraint>,
201    pub capabilities: Vec<(String, VersionConstraint)>,
202}
203
204impl Requires {
205    /// Parse from a JSON value: the top-level `requires` object.
206    pub fn parse(value: &Value) -> Result<Self, Vec<String>> {
207        let obj = value
208            .as_object()
209            .ok_or_else(|| vec!["\"requires\" must be an object".to_string()])?;
210        let mut errors = Vec::new();
211
212        let protocol = match obj.get("protocol") {
213            Some(v) => match VersionConstraint::parse(v) {
214                Ok(vc) => Some(vc),
215                Err(e) => {
216                    errors.push(format!("requires.protocol: {}", e));
217                    None
218                }
219            },
220            None => None,
221        };
222
223        let mut capabilities = Vec::new();
224        if let Some(caps_val) = obj.get("capabilities") {
225            match caps_val.as_object() {
226                Some(caps) => {
227                    for (key, val) in caps {
228                        match VersionConstraint::parse(val) {
229                            Ok(vc) => capabilities.push((key.clone(), vc)),
230                            Err(e) => errors.push(format!("requires.capabilities.{}: {}", key, e)),
231                        }
232                    }
233                }
234                None => errors.push("requires.capabilities must be an object".to_string()),
235            }
236        }
237
238        if errors.is_empty() {
239            Ok(Self {
240                protocol,
241                capabilities,
242            })
243        } else {
244            Err(errors)
245        }
246    }
247}
248
249/// Options for schema resolution.
250#[derive(Debug, Clone)]
251pub struct ResolveOptions {
252    /// Whether resolving for request or response.
253    pub direction: Direction,
254    /// The operation to resolve for (e.g., "create", "update").
255    /// Will be normalized to lowercase.
256    pub operation: String,
257    /// When true, sets `additionalProperties: false` on all object schemas
258    /// to reject unknown fields. Defaults to false to respect schema extensibility.
259    pub strict: bool,
260    /// When true, includes fields with `omit` visibility that have a transition
261    /// targeting a non-omit value (i.e., planned additions). These fields appear
262    /// in the resolved output with `x-ucp-schema-transition` metadata but are NOT
263    /// added to `required`. Completes the lifecycle symmetry: deprecations (to=omit)
264    /// are always surfaced; this flag surfaces planned additions (from=omit) too.
265    pub include_future: bool,
266    /// Explicit `$defs` entry to select as the validation/output target,
267    /// overriding the `{op}_{direction}` derivation used for container
268    /// capabilities. Names non-derivable shapes that aren't an operation +
269    /// direction — transport message types (`error_response`), host views
270    /// (`business_schema`), and sub-types of single-object schemas
271    /// (`cart` → `checkout`). When set, selection ignores the container check
272    /// so it works on schemas that also have a root body.
273    pub def_name: Option<String>,
274}
275
276impl ResolveOptions {
277    /// Create new resolve options with strict mode disabled (default).
278    ///
279    /// Operation is normalized to lowercase for case-insensitive matching.
280    /// Strict mode is off by default to respect UCP's extensibility model:
281    /// schemas validate known fields but allow additional properties.
282    pub fn new(direction: Direction, operation: impl Into<String>) -> Self {
283        Self {
284            direction,
285            operation: operation.into().to_lowercase(),
286            strict: false,
287            include_future: false,
288            def_name: None,
289        }
290    }
291
292    /// Set strict mode (additionalProperties: false on all objects).
293    pub fn strict(mut self, strict: bool) -> Self {
294        self.strict = strict;
295        self
296    }
297
298    /// Include future fields (omit-visibility with non-omit transition target).
299    pub fn include_future(mut self, include_future: bool) -> Self {
300        self.include_future = include_future;
301        self
302    }
303
304    /// Select an explicit `$defs` entry, overriding `{op}_{direction}`
305    /// derivation (see [`Self::def_name`]).
306    pub fn def_name(mut self, def_name: Option<String>) -> Self {
307        self.def_name = def_name;
308        self
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn direction_annotation_key() {
318        assert_eq!(Direction::Request.annotation_key(), "ucp_request");
319        assert_eq!(Direction::Response.annotation_key(), "ucp_response");
320    }
321
322    #[test]
323    fn visibility_parse_valid() {
324        assert_eq!(Visibility::parse("omit"), Some(Visibility::Omit));
325        assert_eq!(Visibility::parse("required"), Some(Visibility::Required));
326        assert_eq!(Visibility::parse("optional"), Some(Visibility::Optional));
327    }
328
329    #[test]
330    fn visibility_parse_invalid() {
331        assert_eq!(Visibility::parse("include"), None);
332        assert_eq!(Visibility::parse("readonly"), None);
333        assert_eq!(Visibility::parse(""), None);
334    }
335
336    #[test]
337    fn valid_schema_transitions() {
338        // Any distinct pair of visibility values is valid
339        for (from, to) in [
340            ("required", "optional"),
341            ("required", "omit"),
342            ("optional", "omit"),
343            ("optional", "required"),
344            ("omit", "required"),
345            ("omit", "optional"),
346        ] {
347            assert!(super::is_valid_schema_transition(from, to));
348        }
349        // Disbarred: same value for both
350        assert!(!super::is_valid_schema_transition("required", "required"));
351        assert!(!super::is_valid_schema_transition("omit", "omit"));
352        assert!(!super::is_valid_schema_transition("optional", "optional"));
353        // Disbarred: invalid visibility value
354        assert!(!super::is_valid_schema_transition("readonly", "omit"));
355        assert!(!super::is_valid_schema_transition("required", "invalid"));
356    }
357
358    #[test]
359    fn is_valid_version_format() {
360        assert!(is_valid_version("2026-01-23"));
361        assert!(is_valid_version("2025-12-31"));
362        assert!(!is_valid_version("2026-1-23"));
363        assert!(!is_valid_version("not-a-date"));
364        assert!(!is_valid_version("20260123"));
365        assert!(!is_valid_version(""));
366        // Reject nonsense dates
367        assert!(!is_valid_version("2026-13-32"));
368        assert!(!is_valid_version("2026-00-15"));
369        assert!(!is_valid_version("2026-06-00"));
370        assert!(!is_valid_version("9999-99-99"));
371    }
372
373    #[test]
374    fn version_constraint_satisfied_by() {
375        let min_only = VersionConstraint {
376            min: "2026-01-23".into(),
377            max: None,
378        };
379        assert!(!min_only.satisfied_by("2026-01-22"));
380        assert!(min_only.satisfied_by("2026-01-23")); // inclusive
381        assert!(min_only.satisfied_by("2026-06-01"));
382        assert!(min_only.satisfied_by("2099-12-31"));
383
384        let range = VersionConstraint {
385            min: "2026-01-23".into(),
386            max: Some("2026-09-01".into()),
387        };
388        assert!(!range.satisfied_by("2026-01-22"));
389        assert!(range.satisfied_by("2026-01-23")); // min inclusive
390        assert!(range.satisfied_by("2026-06-01"));
391        assert!(range.satisfied_by("2026-09-01")); // max inclusive
392        assert!(!range.satisfied_by("2026-09-02"));
393
394        // Exact pin: min == max
395        let exact = VersionConstraint {
396            min: "2026-06-01".into(),
397            max: Some("2026-06-01".into()),
398        };
399        assert!(!exact.satisfied_by("2026-05-31"));
400        assert!(exact.satisfied_by("2026-06-01"));
401        assert!(!exact.satisfied_by("2026-06-02"));
402    }
403
404    #[test]
405    fn version_constraint_parse_valid() {
406        use serde_json::json;
407        let vc = VersionConstraint::parse(&json!({"min": "2026-01-23"})).unwrap();
408        assert_eq!(vc.min, "2026-01-23");
409        assert_eq!(vc.max, None);
410
411        let vc =
412            VersionConstraint::parse(&json!({"min": "2026-01-23", "max": "2026-09-01"})).unwrap();
413        assert_eq!(vc.min, "2026-01-23");
414        assert_eq!(vc.max, Some("2026-09-01".into()));
415    }
416
417    #[test]
418    fn version_constraint_parse_invalid() {
419        use serde_json::json;
420        assert!(VersionConstraint::parse(&json!({"max": "2026-01-23"})).is_err()); // missing min
421        assert!(VersionConstraint::parse(&json!({"min": "bad"})).is_err()); // bad format
422        assert!(VersionConstraint::parse(&json!("string")).is_err()); // not object
423    }
424
425    #[test]
426    fn requires_parse_valid() {
427        use serde_json::json;
428        let req = Requires::parse(&json!({
429            "protocol": { "min": "2026-01-23" },
430            "capabilities": {
431                "dev.ucp.shopping.checkout": { "min": "2026-06-01" }
432            }
433        }))
434        .unwrap();
435        assert!(req.protocol.is_some());
436        assert_eq!(req.capabilities.len(), 1);
437        assert_eq!(req.capabilities[0].0, "dev.ucp.shopping.checkout");
438    }
439
440    #[test]
441    fn requires_parse_protocol_only() {
442        use serde_json::json;
443        let req = Requires::parse(&json!({
444            "protocol": { "min": "2026-01-23" }
445        }))
446        .unwrap();
447        assert!(req.protocol.is_some());
448        assert!(req.capabilities.is_empty());
449    }
450
451    #[test]
452    fn requires_parse_empty_object() {
453        use serde_json::json;
454        let req = Requires::parse(&json!({})).unwrap();
455        assert!(req.protocol.is_none());
456        assert!(req.capabilities.is_empty());
457    }
458
459    #[test]
460    fn requires_parse_invalid() {
461        use serde_json::json;
462        // Not an object
463        assert!(Requires::parse(&json!("string")).is_err());
464        // Bad protocol constraint
465        assert!(Requires::parse(&json!({"protocol": {"min": "bad"}})).is_err());
466        // Bad capability constraint
467        assert!(Requires::parse(&json!({
468            "capabilities": { "x.y.z": "not-object" }
469        }))
470        .is_err());
471    }
472
473    #[test]
474    fn resolve_options_normalizes_operation() {
475        let opts = ResolveOptions::new(Direction::Request, "Create");
476        assert_eq!(opts.operation, "create");
477
478        let opts = ResolveOptions::new(Direction::Request, "UPDATE");
479        assert_eq!(opts.operation, "update");
480    }
481}