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
44pub 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}