Skip to main content

rskit_dag/
as_tool.rs

1use std::collections::HashMap;
2use std::marker::PhantomData;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use rskit_errors::AppResult;
7use rskit_provider::{Provider, RequestResponse};
8use tokio_util::sync::CancellationToken;
9
10use crate::Dag;
11
12/// Configuration for wrapping a DAG as a [`RequestResponse`] provider.
13pub struct ToolConfig<I, O> {
14    /// Provider name.
15    pub name: &'static str,
16    /// Maps input to initial inputs for root DAG nodes.
17    pub input_fn: Box<dyn Fn(I) -> HashMap<String, serde_json::Value> + Send + Sync>,
18    /// Extracts output from DAG execution results.
19    pub output_fn: Box<dyn Fn(HashMap<String, serde_json::Value>) -> AppResult<O> + Send + Sync>,
20}
21
22/// Wraps a DAG execution as a [`RequestResponse`] provider.
23pub struct DagTool<I, O> {
24    dag: Arc<Dag>,
25    config: ToolConfig<I, O>,
26    _phantom: PhantomData<fn(I) -> O>,
27}
28
29/// Wrap a DAG execution as a [`RequestResponse`] provider.
30///
31/// The returned [`DagTool`] maps an input of type `I` into initial DAG inputs via `config.input_fn`,
32/// executes the DAG, then extracts an output of type `O` via `config.output_fn`.
33pub fn as_tool<I, O>(dag: Arc<Dag>, config: ToolConfig<I, O>) -> DagTool<I, O>
34where
35    I: Send + 'static,
36    O: Send + 'static,
37{
38    DagTool {
39        dag,
40        config,
41        _phantom: PhantomData,
42    }
43}
44
45impl<I: Send + 'static, O: Send + 'static> Provider for DagTool<I, O> {
46    fn name(&self) -> &'static str {
47        self.config.name
48    }
49}
50
51#[async_trait]
52impl<I, O> RequestResponse<I, O> for DagTool<I, O>
53where
54    I: Send + 'static,
55    O: Send + 'static,
56{
57    async fn execute(&self, input: I) -> AppResult<O> {
58        let initial_inputs = (self.config.input_fn)(input);
59        let cancel = CancellationToken::new();
60        let outputs = self.dag.execute_with_inputs(initial_inputs, cancel).await?;
61        (self.config.output_fn)(outputs)
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::DagNode;
69    use rskit_errors::{AppError, ErrorCode};
70    use std::future::Future;
71    use std::pin::Pin;
72
73    struct EchoNode {
74        id: String,
75    }
76
77    impl DagNode for EchoNode {
78        fn id(&self) -> &str {
79            &self.id
80        }
81
82        fn execute(
83            &self,
84            inputs: HashMap<String, serde_json::Value>,
85            _cancel: CancellationToken,
86        ) -> Pin<Box<dyn Future<Output = AppResult<serde_json::Value>> + Send + '_>> {
87            Box::pin(async move { Ok(serde_json::to_value(&inputs).unwrap()) })
88        }
89    }
90
91    struct AddNode {
92        id: String,
93        value: i64,
94    }
95
96    impl DagNode for AddNode {
97        fn id(&self) -> &str {
98            &self.id
99        }
100
101        fn execute(
102            &self,
103            inputs: HashMap<String, serde_json::Value>,
104            _cancel: CancellationToken,
105        ) -> Pin<Box<dyn Future<Output = AppResult<serde_json::Value>> + Send + '_>> {
106            Box::pin(async move {
107                let sum: i64 = inputs
108                    .values()
109                    .filter_map(serde_json::Value::as_i64)
110                    .sum::<i64>()
111                    + self.value;
112                Ok(serde_json::json!(sum))
113            })
114        }
115    }
116
117    struct FailNode {
118        id: String,
119    }
120
121    impl DagNode for FailNode {
122        fn id(&self) -> &str {
123            &self.id
124        }
125
126        fn execute(
127            &self,
128            _inputs: HashMap<String, serde_json::Value>,
129            _cancel: CancellationToken,
130        ) -> Pin<Box<dyn Future<Output = AppResult<serde_json::Value>> + Send + '_>> {
131            Box::pin(async { Err(AppError::new(ErrorCode::Internal, "node failed")) })
132        }
133    }
134
135    #[tokio::test]
136    async fn execute_with_inputs_root_receives_inputs() {
137        let dag = Dag::new().add_node(EchoNode { id: "root".into() });
138
139        let cancel = CancellationToken::new();
140        let mut initial = HashMap::new();
141        initial.insert("x".to_owned(), serde_json::json!(42));
142
143        let outputs = dag.execute_with_inputs(initial, cancel).await.unwrap();
144        let root_output: HashMap<String, serde_json::Value> =
145            serde_json::from_value(outputs["root"].clone()).unwrap();
146        assert_eq!(root_output["x"], serde_json::json!(42));
147    }
148
149    #[tokio::test]
150    async fn as_tool_basic_execution() {
151        let dag = Arc::new(
152            Dag::new()
153                .add_node(AddNode {
154                    id: "a".into(),
155                    value: 0,
156                })
157                .add_node(AddNode {
158                    id: "b".into(),
159                    value: 5,
160                })
161                .add_edge("a", "b")
162                .unwrap(),
163        );
164
165        let tool = as_tool(
166            dag,
167            ToolConfig {
168                name: "add-pipeline",
169                input_fn: Box::new(|input: i64| {
170                    let mut m = HashMap::new();
171                    m.insert("seed".to_owned(), serde_json::json!(input));
172                    m
173                }),
174                output_fn: Box::new(|outputs| {
175                    outputs["b"]
176                        .as_i64()
177                        .ok_or_else(|| AppError::new(ErrorCode::Internal, "missing b"))
178                }),
179            },
180        );
181
182        let result = RequestResponse::execute(&tool, 10).await.unwrap();
183        // a receives {"seed": 10} → sums to 10, b receives {"a": 10} → 10 + 5 = 15
184        assert_eq!(result, 15);
185    }
186
187    #[tokio::test]
188    async fn as_tool_provider_name() {
189        let dag = Arc::new(Dag::new().add_node(EchoNode { id: "n".into() }));
190
191        let tool: DagTool<(), ()> = as_tool(
192            dag,
193            ToolConfig {
194                name: "my-tool",
195                input_fn: Box::new(|()| HashMap::new()),
196                output_fn: Box::new(|_| Ok(())),
197            },
198        );
199
200        assert_eq!(Provider::name(&tool), "my-tool");
201    }
202
203    #[tokio::test]
204    async fn as_tool_error_propagation() {
205        let dag = Arc::new(Dag::new().add_node(FailNode { id: "fail".into() }));
206
207        let tool = as_tool(
208            dag,
209            ToolConfig {
210                name: "failing",
211                input_fn: Box::new(|()| HashMap::new()),
212                output_fn: Box::new(|_| Ok(())),
213            },
214        );
215
216        let result = RequestResponse::execute(&tool, ()).await;
217        assert!(result.is_err());
218        assert_eq!(result.unwrap_err().code(), ErrorCode::Internal);
219    }
220}