Skip to main content

app_facade_api/
interaction_flow.rs

1//! Public Interaction Flow session protocol exposed to MeowLink clients.
2//!
3//! This is deliberately a current-step projection. It contains no complete
4//! definition graph, handler operations, variable references, or Node routes.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10pub const START_TARGET: &str = "/app/interaction-flow/start";
11pub const PROTOCOL_SCHEMA: &str = include_str!("../schema/interaction-flow-app.v1.schema.json");
12pub const RESOLVE_TARGET: &str = "/app/interaction-flow/resolve";
13pub const NEXT_TARGET: &str = "/app/interaction-flow/next";
14pub const BACK_TARGET: &str = "/app/interaction-flow/back";
15pub const COMPLETE_TARGET: &str = "/app/interaction-flow/complete";
16pub const CANCEL_TARGET: &str = "/app/interaction-flow/cancel";
17
18pub const REQUEST_TARGETS: &[&str] = &[
19    START_TARGET,
20    RESOLVE_TARGET,
21    NEXT_TARGET,
22    BACK_TARGET,
23    COMPLETE_TARGET,
24    CANCEL_TARGET,
25];
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(
29    tag = "kind",
30    rename_all = "camelCase",
31    rename_all_fields = "camelCase",
32    deny_unknown_fields
33)]
34pub enum InteractionFlowSource {
35    Plugin {
36        node_type: String,
37        node_id: String,
38        flow_id: String,
39        #[serde(default, skip_serializing_if = "Option::is_none")]
40        subject_id: Option<String>,
41    },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct InteractionFlowStartRequest {
47    pub source: InteractionFlowSource,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct InteractionFlowResolveRequest {
53    pub session_id: String,
54    pub command_id: String,
55    pub expected_revision: u64,
56    #[serde(default)]
57    pub input: HashMap<String, Value>,
58    pub vars: Vec<String>,
59    #[serde(default)]
60    pub force: bool,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct InteractionFlowTransitionRequest {
66    pub session_id: String,
67    pub command_id: String,
68    pub expected_revision: u64,
69    #[serde(default)]
70    pub input: HashMap<String, Value>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct InteractionFlowCompleteRequest {
76    pub session_id: String,
77    pub command_id: String,
78    pub expected_revision: u64,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase", deny_unknown_fields)]
83pub struct InteractionFlowCancelRequest {
84    pub session_id: String,
85    pub command_id: String,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub enum FlowRendererView {
91    TypedForm,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct FlowPresentationView {
97    pub renderer: FlowRendererView,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub title: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub content: Option<String>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub enum FlowFieldValueTypeView {
109    String,
110    Boolean,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub enum FlowFieldControlView {
116    Text,
117    Password,
118    LongText,
119    Select,
120    Boolean,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct FlowFormFieldView {
126    pub var: String,
127    pub value_type: FlowFieldValueTypeView,
128    pub control: FlowFieldControlView,
129    pub required: bool,
130    pub repeat: bool,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub label: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub options: Option<Vec<Value>>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub option_unavailable_text: Option<String>,
137    pub read_only: bool,
138    pub has_value: bool,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub min_rows: Option<u32>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct FlowResolutionView {
146    pub var: String,
147    pub available: bool,
148    #[serde(default)]
149    pub depends_on: Vec<String>,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase", deny_unknown_fields)]
154pub struct FlowFormView {
155    #[serde(default)]
156    pub fields: Vec<FlowFormFieldView>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub enum ExternalAuthCompletionModeView {
162    Callback,
163    Poll,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(
168    tag = "type",
169    rename_all = "camelCase",
170    rename_all_fields = "camelCase",
171    deny_unknown_fields
172)]
173pub enum FlowStepViewContent {
174    Form {
175        presentation: FlowPresentationView,
176        form: FlowFormView,
177    },
178    ExternalAuth {
179        presentation: FlowPresentationView,
180        authorization_url: String,
181        completion_mode: ExternalAuthCompletionModeView,
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        poll_after_ms: Option<u64>,
184    },
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct InteractionFlowView {
190    pub session_id: String,
191    pub revision: u64,
192    pub step_id: String,
193    pub content: FlowStepViewContent,
194    #[serde(default)]
195    pub values: HashMap<String, Value>,
196    pub resolutions: Vec<FlowResolutionView>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub back_step_id: Option<String>,
199    pub ready_to_complete: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase", deny_unknown_fields)]
204pub struct InteractionFlowCompleteResponse {
205    pub completed: bool,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub result: Option<Value>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct InteractionFlowCancelResponse {
213    pub cancelled: bool,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    #[test]
222    fn public_source_cannot_choose_a_handler_or_route() {
223        let request = serde_json::from_value::<InteractionFlowStartRequest>(json!({
224            "source": {
225                "kind": "plugin",
226                "nodeType": "camera",
227                "nodeId": "camera-1",
228                "flowId": "provider.connect",
229                "subjectId": "onvif",
230                "handler": "steal",
231                "target": "/camera/arbitrary"
232            }
233        }));
234        assert!(request.is_err());
235    }
236
237    #[test]
238    fn view_is_a_current_step_projection_without_definition_or_handlers() {
239        let view = InteractionFlowView {
240            session_id: "session-1".into(),
241            revision: 1,
242            step_id: "credentials".into(),
243            content: FlowStepViewContent::Form {
244                presentation: FlowPresentationView {
245                    renderer: FlowRendererView::TypedForm,
246                    title: Some("Connect".into()),
247                    description: None,
248                    content: None,
249                },
250                form: FlowFormView::default(),
251            },
252            values: HashMap::new(),
253            resolutions: Vec::new(),
254            back_step_id: None,
255            ready_to_complete: true,
256        };
257        let value = serde_json::to_value(view).unwrap();
258        assert!(value.get("steps").is_none());
259        assert!(value.get("resolvers").is_none());
260        assert!(value.to_string().find("handler").is_none());
261    }
262
263    #[test]
264    fn conformance_fixture_matches_the_published_app_schema() {
265        let schema: Value = serde_json::from_str(PROTOCOL_SCHEMA).unwrap();
266        let fixture: Value = serde_json::from_str(include_str!(
267            "../fixtures/interaction-flow.conformance.json"
268        ))
269        .unwrap();
270        let validator = jsonschema::validator_for(&schema).unwrap();
271        let errors = validator
272            .iter_errors(&fixture)
273            .map(|error| error.to_string())
274            .collect::<Vec<_>>();
275        assert!(errors.is_empty(), "fixture failed: {errors:?}");
276    }
277}