Skip to main content

netget/server/restconf/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct RestconfProtocol;
14impl RestconfProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    ActionDefinition {
36        name: name.into(),
37        description: description.into(),
38        parameters,
39        example,
40        log_template: Some(LogTemplate::new().with_info(format!("RESTCONF {name}"))),
41    }
42}
43
44/// RFC 8040 section 7 error tags and the status each defaults to.
45pub const ERROR_TAGS: &[(&str, u16)] = &[
46    ("in-use", 409),
47    ("invalid-value", 400),
48    ("too-big", 413),
49    ("missing-attribute", 400),
50    ("bad-attribute", 400),
51    ("unknown-attribute", 400),
52    ("bad-element", 400),
53    ("unknown-element", 400),
54    ("unknown-namespace", 400),
55    ("access-denied", 403),
56    ("lock-denied", 409),
57    ("resource-denied", 409),
58    ("rollback-failed", 500),
59    ("data-exists", 409),
60    ("data-missing", 409),
61    ("operation-not-supported", 405),
62    ("operation-failed", 500),
63    ("partial-operation", 500),
64    ("malformed-message", 400),
65];
66
67fn data() -> ActionDefinition {
68    action(
69        "restconf_data",
70        "Answer a GET or HEAD with the resource's data as YANG JSON (RFC 7951: top-level members carry their module, e.g. {\"example:car\": {...}})",
71        vec![parameter("data", "object", "The data, e.g. {\"example:car\": {\"speed\": 100}} for a GET of example:car", true)],
72        json!({"type": "restconf_data", "data": {"example:car": {"speed": 100, "tire": [{"pos": 0, "size": "H15"}]}}}),
73    )
74}
75fn ok() -> ActionDefinition {
76    action(
77        "restconf_ok",
78        "Accept an edit: 201 Created (POST, with the new resource's Location) or 204 No Content (PUT, PATCH, DELETE)",
79        vec![parameter("status", "number", "201 or 204 (default 201 for POST, 204 otherwise)", false), parameter("location", "string", "For 201, the new resource's path under data/, e.g. example:car/tire=4", false)],
80        json!({"type": "restconf_ok"}),
81    )
82}
83fn error() -> ActionDefinition {
84    action(
85        "restconf_error",
86        "Refuse with an RFC 8040 error: the error-tag decides the default status (invalid-value 400, data-missing 409, access-denied 403, data-exists 409, operation-not-supported 405, operation-failed 500)",
87        vec![
88            parameter("error_tag", "string", "RFC 8040 error-tag, e.g. invalid-value, data-missing, data-exists, access-denied", true),
89            parameter("message", "string", "error-message text", false),
90            parameter("status", "number", "HTTP status overriding the tag's default, e.g. 404 for a missing resource", false),
91            parameter("error_type", "string", "transport, rpc, protocol (default) or application", false),
92            parameter("error_path", "string", "The instance identifier the error concerns", false),
93        ],
94        json!({"type": "restconf_error", "error_tag": "invalid-value", "status": 404, "message": "no such tire"}),
95    )
96}
97fn output() -> ActionDefinition {
98    action(
99        "restconf_output",
100        "Answer an operation: 200 with its output (wrapped as module:output), or 204 when there is none",
101        vec![parameter("output", "object", "The operation's output leaves, e.g. {\"oilLevel\": 12.5}", false)],
102        json!({"type": "restconf_output", "output": {"oilLevel": 12.5}}),
103    )
104}
105
106pub static DATA_EVENT: LazyLock<EventType> = LazyLock::new(|| {
107    EventType::new(
108        "restconf_data_request",
109        "A client read or edited a data resource. GET and HEAD want restconf_data; POST, PUT, PATCH and DELETE want restconf_ok; any may get restconf_error.",
110        data().example.clone(),
111    )
112    .with_parameters(vec![
113        parameter("method", "string", "GET, HEAD, POST, PUT, PATCH or DELETE", true),
114        parameter("path", "string", "The resource under data/, e.g. example:car/tire=1 (empty: the whole datastore)", true),
115        parameter("target", "array", "The path parsed: [{module, name, keys}]", true),
116        parameter("query", "object", "RESTCONF query parameters: depth, content, fields, with-defaults, insert, point", true),
117        parameter("body", "object", "The YANG JSON body of a POST, PUT or PATCH", false),
118    ])
119    .with_actions(vec![data(), ok(), error()])
120});
121
122pub static OPERATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
123    EventType::new(
124        "restconf_operation",
125        "A client invoked an RPC under operations/",
126        output().example.clone(),
127    )
128    .with_parameters(vec![
129        parameter("operation", "string", "module:name of the RPC", true),
130        parameter(
131            "input",
132            "object",
133            "The input leaves (the module:input wrapper removed)",
134            false,
135        ),
136    ])
137    .with_actions(vec![output(), error()])
138});
139
140impl Protocol for RestconfProtocol {
141    fn protocol_name(&self) -> &'static str {
142        "RESTCONF"
143    }
144    fn stack_name(&self) -> &'static str {
145        "ETH>IP>TCP>HTTP>RESTCONF"
146    }
147    fn description(&self) -> &'static str {
148        "RESTCONF (RFC 8040) server: YANG-shaped data resources and operations over HTTP with JSON, discovery and RFC 8040 errors; the handler is the datastore"
149    }
150    fn keywords(&self) -> Vec<&'static str> {
151        vec!["restconf", "rfc8040", "yang", "network management api"]
152    }
153    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
154        vec![]
155    }
156    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
157        vec![data(), ok(), error(), output()]
158    }
159    fn get_event_types(&self) -> Vec<EventType> {
160        vec![DATA_EVENT.clone(), OPERATION_EVENT.clone()]
161    }
162    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
163        vec![
164            ParameterDefinition {
165                name: "modules".into(),
166                type_hint: "array".into(),
167                description: "The YANG modules the server implements, listed in ietf-yang-library: [{name, revision, namespace}]".into(),
168                required: false,
169                example: json!([{"name": "example", "revision": "2026-10-01", "namespace": "urn:example"}]),
170                default: None,
171            },
172            ParameterDefinition {
173                name: "operations".into(),
174                type_hint: "array".into(),
175                description: "The RPCs listed under operations/, e.g. [\"example:reset\"]".into(),
176                required: false,
177                example: json!(["example:reset"]),
178                default: None,
179            },
180        ]
181    }
182    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
183        use crate::protocol::metadata::*;
184        ProtocolMetadataV2::builder()
185            .state(DevelopmentState::Experimental)
186            .privilege_requirement(PrivilegeRequirement::None)
187            .implementation("RFC 8040 over hyper HTTP/1.1: host-meta discovery, the API root, yang-library-version, ietf-yang-library modules-state from the declared modules, data-resource paths, query parameters, media types and RFC 8040 error documents are Rust's; data and operation answers are the handler's")
188            .llm_control("Every data resource's content, which edits are accepted, and every operation's output or error")
189            .e2e_testing("tests/server/restconf: the FreeCONF RESTCONF client (independent, Go) discovers the server, loads the module list, reads, edits and invokes an operation")
190            .notes("JSON only (application/yang-data+json); no XML, no event streams, no ETag/Last-Modified, no YANG validation (declared modules are listed, not parsed). No storage: the handler keeps data in memory or SQLite. 1 MiB bodies.")
191            .request_only("RESTCONF answers each HTTP request; event streams are not offered")
192            .answers_on_failure()
193            .max_inbound_bytes(super::MAX_BODY)
194            .build()
195    }
196    fn example_prompt(&self) -> &'static str {
197        "RESTCONF server on port 8080 for an example:car module with a speed leaf"
198    }
199    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
200        let llm = json!({"type":"open_server","base_stack":"restconf","port":8080,"instruction":"Serve example:car with speed 100","startup_params":{"modules":[{"name":"example","revision":"2026-10-01","namespace":"urn:example"}]}});
201        let mut static_example = llm.clone();
202        static_example["event_handlers"] = json!([{"event_pattern":"restconf_data_request","handler":{"type":"static","actions":[{"type":"restconf_data","data":{"speed":100}}]}}]);
203        let mut scripted = llm.clone();
204        scripted["event_handlers"] = json!([{"event_pattern":"restconf_data_request","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\na={'type':'restconf_data','data':{'speed':100}} if e['method'] in ('GET','HEAD') else {'type':'restconf_ok'}\nprint(json.dumps({'actions':[a]}))"}}]);
205        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
206    }
207    fn group_name(&self) -> &'static str {
208        "Network Management"
209    }
210}
211
212impl Server for RestconfProtocol {
213    fn spawn(
214        &self,
215        ctx: SpawnContext,
216    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
217    {
218        Box::pin(super::spawn(ctx))
219    }
220    fn execute_action(&self, v: Value) -> Result<ActionResult> {
221        match v["type"].as_str() {
222            Some("restconf_data") => ensure!(v["data"].is_object(), "data is a JSON object"),
223            Some("restconf_ok") => ensure!(
224                matches!(
225                    v.get("status").and_then(Value::as_u64),
226                    None | Some(201 | 204)
227                ),
228                "status is 201 or 204"
229            ),
230            Some("restconf_output") => ensure!(
231                v.get("output").is_none_or(|o| o.is_null() || o.is_object()),
232                "output is an object"
233            ),
234            Some("restconf_error") => {
235                let tag = v["error_tag"].as_str().unwrap_or_default();
236                ensure!(
237                    ERROR_TAGS.iter().any(|(t, _)| *t == tag),
238                    "error_tag is an RFC 8040 error-tag such as invalid-value or data-missing"
239                );
240                ensure!(
241                    v.get("status")
242                        .and_then(Value::as_u64)
243                        .is_none_or(|s| (400..=599).contains(&s)),
244                    "status is 400-599"
245                );
246                ensure!(
247                    matches!(
248                        v.get("error_type").and_then(Value::as_str),
249                        None | Some("transport" | "rpc" | "protocol" | "application")
250                    ),
251                    "error_type is transport, rpc, protocol or application"
252                );
253            }
254            _ => bail!("Unknown RESTCONF server action"),
255        }
256        Ok(ActionResult::Custom {
257            name: v["type"].as_str().unwrap_or_default().into(),
258            data: v,
259        })
260    }
261}