Skip to main content

netget/client/restconf/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::restconf::actions::{action, parameter};
8use crate::server::restconf::path;
9use anyhow::{bail, ensure, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct RestconfClientProtocol;
15impl RestconfClientProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21fn path_param() -> Parameter {
22    parameter(
23        "path",
24        "string",
25        "Data resource under data/, e.g. example:car/tire=1 (empty for the whole datastore)",
26        true,
27    )
28}
29fn data_param() -> Parameter {
30    parameter(
31        "data",
32        "object",
33        "YANG JSON body, e.g. {\"example:speed\": 30}",
34        true,
35    )
36}
37
38pub fn all_actions() -> Vec<ActionDefinition> {
39    vec![
40        action(
41            "restconf_get",
42            "Read a data resource",
43            vec![
44                path_param(),
45                parameter(
46                    "depth",
47                    "string",
48                    "Subtree depth, 1-65535 or unbounded",
49                    false,
50                ),
51                parameter("content", "string", "all, config or nonconfig", false),
52                parameter(
53                    "fields",
54                    "string",
55                    "Fields selector, e.g. speed;tire/pos",
56                    false,
57                ),
58            ],
59            json!({"type": "restconf_get", "path": "example:car"}),
60        ),
61        action(
62            "restconf_put",
63            "Create or replace a data resource",
64            vec![path_param(), data_param()],
65            json!({"type": "restconf_put", "path": "example:car/speed", "data": {"example:speed": 30}}),
66        ),
67        action(
68            "restconf_post",
69            "Create a child of a data resource",
70            vec![path_param(), data_param()],
71            json!({"type": "restconf_post", "path": "example:car", "data": {"example:tire": [{"pos": 4}]}}),
72        ),
73        action(
74            "restconf_patch",
75            "Merge into a data resource",
76            vec![path_param(), data_param()],
77            json!({"type": "restconf_patch", "path": "example:car", "data": {"example:car": {"speed": 40}}}),
78        ),
79        action(
80            "restconf_delete",
81            "Delete a data resource",
82            vec![path_param()],
83            json!({"type": "restconf_delete", "path": "example:car/tire=4"}),
84        ),
85        action(
86            "restconf_invoke",
87            "Invoke an RPC under operations/",
88            vec![
89                parameter(
90                    "operation",
91                    "string",
92                    "module:rpc, e.g. example:reset",
93                    true,
94                ),
95                parameter(
96                    "input",
97                    "object",
98                    "The input leaves (Rust wraps them as module:input)",
99                    false,
100                ),
101            ],
102            json!({"type": "restconf_invoke", "operation": "example:reset", "input": {"speed": 0}}),
103        ),
104        action(
105            "disconnect",
106            "Stop the client",
107            vec![],
108            json!({"type": "disconnect"}),
109        ),
110    ]
111}
112
113pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
114    EventType::new(
115        "restconf_connected",
116        "Discovered the RESTCONF root and its YANG library",
117        json!({"type": "restconf_get", "path": ""}),
118    )
119    .with_parameters(vec![
120        parameter(
121            "root",
122            "string",
123            "The RESTCONF root found through host-meta, e.g. /restconf",
124            true,
125        ),
126        parameter(
127            "yang_library_version",
128            "string",
129            "The server's yang-library-version",
130            false,
131        ),
132        parameter(
133            "modules",
134            "array",
135            "The modules the server implements: [{name, revision, namespace}]",
136            true,
137        ),
138    ])
139    .with_actions(all_actions())
140});
141pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
142    EventType::new(
143        "restconf_response",
144        "The server answered a request",
145        json!({"type": "restconf_get", "path": ""}),
146    )
147    .with_parameters(vec![
148        parameter("method", "string", "The HTTP method sent", true),
149        parameter("path", "string", "The resource or operation", true),
150        parameter("status", "number", "The HTTP status", true),
151        parameter(
152            "data",
153            "object",
154            "The YANG JSON body of a successful answer",
155            false,
156        ),
157        parameter(
158            "errors",
159            "array",
160            "The RFC 8040 errors: [{error-type, error-tag, error-message, error-path}]",
161            false,
162        ),
163        parameter(
164            "location",
165            "string",
166            "Location of a created resource",
167            false,
168        ),
169    ])
170    .with_actions(all_actions())
171});
172
173impl Protocol for RestconfClientProtocol {
174    fn protocol_name(&self) -> &'static str {
175        "RESTCONF"
176    }
177    fn stack_name(&self) -> &'static str {
178        "ETH>IP>TCP>HTTP>RESTCONF"
179    }
180    fn keywords(&self) -> Vec<&'static str> {
181        vec!["restconf", "restconf client", "rfc8040", "yang"]
182    }
183    fn description(&self) -> &'static str {
184        "RESTCONF client: discovers a server's root and YANG library, reads and edits data resources and invokes operations"
185    }
186    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
187        all_actions()
188    }
189    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
190        vec![]
191    }
192    fn get_event_types(&self) -> Vec<EventType> {
193        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
194    }
195    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
196        vec![
197            ParameterDefinition {
198                name: "username".into(),
199                type_hint: "string".into(),
200                description: "HTTP Basic user, if the server needs one".into(),
201                required: false,
202                example: json!("admin"),
203                default: None,
204            },
205            ParameterDefinition {
206                name: "password".into(),
207                type_hint: "string".into(),
208                description: "HTTP Basic password".into(),
209                required: false,
210                example: json!("secret"),
211                default: None,
212            },
213        ]
214    }
215    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
216        use crate::protocol::metadata::*;
217        ProtocolMetadataV2::builder()
218            .state(DevelopmentState::Experimental)
219            .privilege_requirement(PrivilegeRequirement::None)
220            .implementation("RFC 8040 over the shared HTTP fetch client: host-meta discovery (XRD or JSON), the API root, yang-library-version and ietf-yang-library modules-state, JSON data requests and operations with RFC 8040 errors decoded")
221            .llm_control("Which resources to read and edit and which operations to invoke")
222            .e2e_testing("tests/client/restconf: a FreeCONF RESTCONF server (independent, Go) serving its car example: reads, edits, a missing resource and an operation")
223            .notes("Plain HTTP only; JSON only; no event streams.")
224            .build()
225    }
226    fn example_prompt(&self) -> &'static str {
227        "Read example:car from the RESTCONF server at 127.0.0.1:8080"
228    }
229    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
230        let llm = json!({"type":"open_client","protocol":"restconf","remote_addr":"127.0.0.1:8080","instruction":"Read the car's speed"});
231        let mut static_example = llm.clone();
232        static_example["event_handlers"] = json!([{"event_pattern":"restconf_connected","handler":{"type":"static","actions":[{"type":"restconf_get","path":"example:car"}]}}]);
233        let mut scripted = llm.clone();
234        scripted["event_handlers"] = json!([{"event_pattern":"restconf_connected","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nm=e['modules'][0]['name'] if e['modules'] else 'example'\nprint(json.dumps({'actions':[{'type':'restconf_get','path':m+':'}]}))"}}]);
235        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
236    }
237    fn group_name(&self) -> &'static str {
238        "Network Management"
239    }
240}
241
242/// A path as the client will put it in a URL: parseable, and free of characters a URL would
243/// need escaped outside key values.
244pub fn check_path(p: &str, allow_empty: bool) -> Result<()> {
245    if p.is_empty() {
246        ensure!(allow_empty, "path names a resource");
247        return Ok(());
248    }
249    path::parse(p)?;
250    ensure!(
251        !p.contains(['?', '#', ' ']) && !crate::utils::sanitize::has_controls(&p),
252        "path has no ?, # or spaces (percent-encode key values)"
253    );
254    Ok(())
255}
256
257impl Client for RestconfClientProtocol {
258    fn connect(
259        &self,
260        ctx: ConnectContext,
261    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
262    {
263        Box::pin(super::connect(ctx))
264    }
265    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
266        match v["type"].as_str() {
267            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
268            Some("restconf_get") => check_path(v["path"].as_str().unwrap_or_default(), true)?,
269            Some("restconf_delete") => check_path(v["path"].as_str().unwrap_or_default(), false)?,
270            Some("restconf_put" | "restconf_post" | "restconf_patch") => {
271                check_path(
272                    v["path"].as_str().unwrap_or_default(),
273                    v["type"] == "restconf_post",
274                )?;
275                ensure!(v["data"].is_object(), "data is a YANG JSON object");
276            }
277            Some("restconf_invoke") => {
278                let op = v["operation"].as_str().unwrap_or_default();
279                ensure!(
280                    path::parse(op).is_ok_and(|s| s.len() == 1 && s[0].keys.is_none()),
281                    "operation is module:rpc"
282                );
283                ensure!(
284                    v.get("input").is_none_or(|i| i.is_null() || i.is_object()),
285                    "input is an object"
286                );
287            }
288            _ => bail!("Unknown RESTCONF client action"),
289        }
290        Ok(ClientActionResult::Custom {
291            name: v["type"].as_str().unwrap_or_default().into(),
292            data: v,
293        })
294    }
295}