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}