Skip to main content

cloacina_workflow/
input_interface.rs

1/*
2 *  Copyright 2025-2026 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! Injectable input interface — JSON Schema generation (CLOACI-I-0128).
18//!
19//! Canonical home for turning a Rust type into a JSON Schema descriptor and for
20//! the [`InputSlot`] contract. Lives in `cloacina-workflow` (not core `cloacina`)
21//! because the `#[workflow(params(...))]` macro emits calls to these helpers into
22//! **packaged cdylibs**, which depend on `cloacina-workflow`, not core. Core
23//! `cloacina` re-exports this module.
24//!
25//! Spec: [CLOACI-S-0013]; descriptor decision: [CLOACI-A-0007] (JSON Schema via
26//! `schemars`).
27
28pub use cloacina_api_types::InputSlot;
29
30/// Generate a JSON Schema for `T` as a `serde_json::Value`.
31///
32/// The macro-generated `#[workflow(params(...))]` descriptor calls this per
33/// declared param type; accumulator/reactor boundary derivation (Task D) calls
34/// it per boundary type. Returns `Value::Null` only if the generated schema
35/// fails to serialize (not expected for a well-formed `JsonSchema` impl).
36pub fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
37    let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::<T>();
38    serde_json::to_value(root).unwrap_or(serde_json::Value::Null)
39}
40
41/// Serialize a default value to `serde_json::Value` for an [`InputSlot::default`].
42/// Returns `None` if the value can't serialize.
43pub fn default_json<T: serde::Serialize>(value: T) -> Option<serde_json::Value> {
44    serde_json::to_value(value).ok()
45}
46
47/// Opt-in schema derivation for accumulator/reactor boundary types (CLOACI-I-0128
48/// Task D).
49///
50/// Unlike workflow params (whose types we control via `#[workflow(params(...))]`),
51/// computation-graph **boundary types** are defined in the author's crate and may
52/// or may not derive [`schemars::JsonSchema`]. We do NOT want to force the derive
53/// — so the `#[computation_graph]` macro can't unconditionally call
54/// [`schema_for`] (that needs the bound at compile time).
55///
56/// Instead the macro emits a probe over [`SchemaProbe<T>`] that, via **autoref
57/// specialization** (the stable-Rust dtolnay pattern), resolves to the real
58/// schema when `T: JsonSchema` and to a permissive `{}` ("any") schema otherwise.
59/// Authors opt a boundary type into rich typing simply by adding
60/// `#[derive(schemars::JsonSchema)]`; types without it degrade to name-only slots
61/// rather than failing to compile.
62///
63/// ## Usage (what the macro emits)
64/// ```ignore
65/// {
66///     use ::cloacina_workflow::input_interface::{ProbeTyped as _, ProbeFallback as _};
67///     (&::cloacina_workflow::input_interface::SchemaProbe::<MyType>::new())
68///         .probe_input_schema()
69/// }
70/// ```
71pub struct SchemaProbe<T>(core::marker::PhantomData<T>);
72
73impl<T> SchemaProbe<T> {
74    #[allow(clippy::new_without_default)]
75    pub fn new() -> Self {
76        SchemaProbe(core::marker::PhantomData)
77    }
78}
79
80/// Specific arm of the probe: selected when `T: JsonSchema` (zero autorefs on a
81/// `&SchemaProbe<T>` receiver), yielding the real derived schema.
82pub trait ProbeTyped {
83    fn probe_input_schema(&self) -> serde_json::Value;
84}
85impl<T: schemars::JsonSchema> ProbeTyped for SchemaProbe<T> {
86    fn probe_input_schema(&self) -> serde_json::Value {
87        schema_for::<T>()
88    }
89}
90
91/// Fallback arm of the probe: selected for any `T` (one autoref on a
92/// `&SchemaProbe<T>` receiver), yielding a permissive "any" schema. Method
93/// resolution prefers [`ProbeTyped`] whenever its `T: JsonSchema` bound holds.
94pub trait ProbeFallback {
95    fn probe_input_schema(&self) -> serde_json::Value;
96}
97impl<T> ProbeFallback for &SchemaProbe<T> {
98    fn probe_input_schema(&self) -> serde_json::Value {
99        // Empty object = "accept anything"; the slot is name-only (untyped).
100        serde_json::json!({})
101    }
102}
103
104/// Serialize a slot list to the JSON array string carried across the FFI
105/// descriptor entrypoint (`InputInterfaceEntry::slots_json`). Falls back to an
106/// empty array on serialization failure.
107pub fn slots_to_json(slots: &[InputSlot]) -> String {
108    serde_json::to_string(slots).unwrap_or_else(|_| "[]".to_string())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use schemars::JsonSchema;
115    use serde::{Deserialize, Serialize};
116
117    #[derive(JsonSchema, Serialize, Deserialize)]
118    #[allow(dead_code)]
119    struct SampleBoundary {
120        order_id: String,
121        limit: u32,
122        enabled: bool,
123    }
124
125    #[test]
126    fn schema_for_scalar_type() {
127        let schema = schema_for::<String>();
128        assert_eq!(
129            schema.get("type").and_then(|t| t.as_str()),
130            Some("string"),
131            "String should produce a string-typed JSON Schema, got: {schema}"
132        );
133    }
134
135    #[test]
136    fn schema_for_struct_has_properties() {
137        let schema = schema_for::<SampleBoundary>();
138        let props = schema
139            .get("properties")
140            .and_then(|p| p.as_object())
141            .expect("struct schema has properties");
142        assert!(props.contains_key("order_id"));
143        assert!(props.contains_key("limit"));
144        assert!(props.contains_key("enabled"));
145    }
146
147    // A type that deliberately does NOT derive JsonSchema (only serde).
148    #[derive(Serialize, Deserialize)]
149    #[allow(dead_code)]
150    struct UntypedBoundary {
151        raw: Vec<u8>,
152    }
153
154    #[test]
155    fn probe_typed_boundary_yields_real_schema() {
156        use super::{ProbeFallback as _, ProbeTyped as _};
157        // SampleBoundary derives JsonSchema → the typed arm wins.
158        let schema = (&SchemaProbe::<SampleBoundary>::new()).probe_input_schema();
159        assert!(
160            schema.get("properties").is_some(),
161            "JsonSchema boundary should yield a real object schema, got: {schema}"
162        );
163    }
164
165    #[test]
166    fn probe_untyped_boundary_falls_back_to_any() {
167        use super::{ProbeFallback as _, ProbeTyped as _};
168        // UntypedBoundary does NOT derive JsonSchema → fallback arm → permissive {}.
169        let schema = (&SchemaProbe::<UntypedBoundary>::new()).probe_input_schema();
170        assert_eq!(
171            schema,
172            serde_json::json!({}),
173            "non-JsonSchema boundary should fall back to a permissive schema, got: {schema}"
174        );
175    }
176
177    #[test]
178    fn probe_scalar_typed_yields_schema() {
179        use super::{ProbeFallback as _, ProbeTyped as _};
180        let schema = (&SchemaProbe::<String>::new()).probe_input_schema();
181        assert_eq!(schema.get("type").and_then(|t| t.as_str()), Some("string"));
182    }
183
184    #[test]
185    fn slots_round_trip_through_json() {
186        let slots = vec![
187            InputSlot::required("order_id", schema_for::<String>()),
188            InputSlot::optional("limit", schema_for::<u32>(), default_json(100u32)),
189        ];
190        let json = slots_to_json(&slots);
191        let back: Vec<InputSlot> = serde_json::from_str(&json).expect("parse slots_json");
192        assert_eq!(back.len(), 2);
193        assert_eq!(back[0].name, "order_id");
194        assert!(back[0].required);
195        assert_eq!(back[1].name, "limit");
196        assert!(!back[1].required);
197        assert_eq!(back[1].default, Some(serde_json::json!(100)));
198    }
199}