From 7e44466c54a330556c6b12773f0e0d391276664b Mon Sep 17 00:00:00 2001 From: daywalker90 <8257956+daywalker90@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:14:35 +0200 Subject: [PATCH] clnrest: add more valid request and response types Changelog-Added: clnrest can now return successful responses as xml, yaml, or form-encoded in addition to json defined in the 'Accept' header. The same goes for request types defined in the 'Content-type' header. --- Cargo.lock | 69 ++++++++ plugins/rest-plugin/Cargo.toml | 4 + plugins/rest-plugin/src/handlers.rs | 254 +++++++++++++++++++++++----- tests/test_clnrest.py | 131 ++++++++++++++ 4 files changed, 419 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a56ec51970e8..af2c65b04597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,9 +482,13 @@ dependencies = [ "hyper 1.5.2", "log", "log-panics", + "quick-xml", "rcgen", + "roxmltree_to_serde", "serde", "serde_json", + "serde_qs", + "serde_yml", "socketioxide", "tokio", "tokio-util", @@ -1240,6 +1244,16 @@ version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1610,6 +1624,16 @@ dependencies = [ "prost", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.38" @@ -1766,6 +1790,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree_to_serde" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eabe602f48dfc72e56d9beefcefe457c2898b3b4853ba4aa836804e49732460" +dependencies = [ + "regex", + "roxmltree", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "rust-embed" version = "8.5.0" @@ -1989,6 +2032,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.11", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2001,6 +2055,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.7.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/plugins/rest-plugin/Cargo.toml b/plugins/rest-plugin/Cargo.toml index aa915d246408..7dab8a46badc 100644 --- a/plugins/rest-plugin/Cargo.toml +++ b/plugins/rest-plugin/Cargo.toml @@ -13,6 +13,10 @@ bytes = "1" log = { version = "0.4", features = ['std'] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" +quick-xml = { version = "0.37", features = ["serialize"] } +roxmltree_to_serde = "0.6" +serde_qs = "0.15" tokio-util = { version = "0.7", features = ["codec"] } tokio = { version="1", features = ['io-std', 'rt-multi-thread', 'sync', 'macros', 'io-util'] } axum = "0.8" diff --git a/plugins/rest-plugin/src/handlers.rs b/plugins/rest-plugin/src/handlers.rs index e9d4f838eaac..cc6b3ec359b9 100644 --- a/plugins/rest-plugin/src/handlers.rs +++ b/plugins/rest-plugin/src/handlers.rs @@ -27,6 +27,7 @@ pub enum AppError { Forbidden(RpcError), NotFound(RpcError), InternalServerError(RpcError), + NotAcceptable(RpcError), } impl IntoResponse for AppError { @@ -36,6 +37,7 @@ impl IntoResponse for AppError { AppError::Forbidden(err) => (StatusCode::FORBIDDEN, err), AppError::NotFound(err) => (StatusCode::NOT_FOUND, err), AppError::InternalServerError(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), + AppError::NotAcceptable(err) => (StatusCode::NOT_ACCEPTABLE, err), }; let body = Json(json!(error_message)); @@ -83,19 +85,38 @@ fn process_help_response(help_response: serde_json::Value) -> String { processed_html_res } +/* Example for swagger ui */ +#[derive(utoipa::ToSchema)] +#[allow(non_camel_case_types)] +struct newaddr { + #[schema(example = "p2tr")] + #[allow(dead_code)] + addresstype: String, +} + +/* Example for swagger ui */ +#[derive(utoipa::ToSchema)] +#[allow(dead_code)] +struct DynamicForm(HashMap); + /* Handler for calling RPC methods */ #[utoipa::path( post, path = "/v1/{rpc_method}", responses( - (status = 201, description = "Call rpc method", body = serde_json::Value), + (status = 201, description = "Call rpc method", body = serde_json::Value, + content(("application/json"),("application/yaml"),("application/xml"), + ("application/x-www-form-urlencoded"))), (status = 401, description = "Unauthorized", body = serde_json::Value), (status = 403, description = "Forbidden", body = serde_json::Value), (status = 404, description = "Not Found", body = serde_json::Value), (status = 500, description = "Server Error", body = serde_json::Value) ), - request_body(content = serde_json::Value, content_type = "application/json", - example = json!({}) ), + request_body(description = "RPC params", + content((newaddr = "application/json"), + (newaddr = "application/yaml"), + (DynamicForm = "application/x-www-form-urlencoded"), + (newaddr = "application/xml"))), security(("api_key" = [])) )] pub async fn call_rpc_method( @@ -109,7 +130,7 @@ pub async fn call_rpc_method( .and_then(|v| v.to_str().ok()) .map(String::from); - let bytes = match to_bytes(body.into_body(), usize::MAX).await { + let request_bytes = match to_bytes(body.into_body(), usize::MAX).await { Ok(o) => o, Err(e) => { return Err(AppError::InternalServerError(RpcError { @@ -120,52 +141,207 @@ pub async fn call_rpc_method( } }; - let mut rpc_params = match serde_json::from_slice(&bytes) { - Ok(o) => o, - Err(e1) => { - /* it's not json but a form instead */ - let form_str = String::from_utf8(bytes.to_vec()).unwrap(); - let mut form_data = HashMap::new(); - for pair in form_str.split('&') { - let mut kv = pair.split('='); - if let (Some(key), Some(value)) = (kv.next(), kv.next()) { - form_data.insert(key.to_string(), value.to_string()); - } - } - match serde_json::to_value(form_data) { - Ok(o) => o, - Err(e2) => { - return Err(AppError::InternalServerError(RpcError { - code: None, - data: None, - message: format!( - "Could not parse json from form data: {}\ - Original serde_json error: {}", - e2, e1 - ), - })) - } - } - } - }; + let mut rpc_params = convert_request_to_json(&headers, &rpc_method, request_bytes)?; filter_json(&mut rpc_params); verify_rune(plugin.clone(), rune, &rpc_method, &rpc_params).await?; - match call_rpc(plugin, &rpc_method, rpc_params).await { - Ok(result) => { - let response_body = Json(result); - let response = (StatusCode::CREATED, response_body).into_response(); - Ok(response) - } + let cln_result = match call_rpc(plugin, &rpc_method, rpc_params).await { + Ok(result) => result, Err(err) => { if let Some(code) = err.code { if code == -32601 { return Err(AppError::NotFound(err)); } } - Err(AppError::InternalServerError(err)) + return Err(AppError::InternalServerError(err)); + } + }; + + convert_json_to_response(headers, &rpc_method, cln_result) +} + +fn convert_request_to_json( + headers: &axum::http::HeaderMap, + rpc_method: &str, + request_bytes: axum::body::Bytes, +) -> Result { + let content_type = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/json"); + + let format = match content_type { + a if a.contains("*/*") => "json", + a if a.contains("application/json") => "json", + a if a.contains("application/yaml") => "yaml", + a if a.contains("application/xml") => "xml", + a if a.contains("application/x-www-form-urlencoded") => "form", + _ => { + return Err(AppError::NotAcceptable(RpcError { + code: None, + data: None, + message: format!("Unsupported content-type header: {}", content_type), + })); + } + }; + + if request_bytes.is_empty() { + return Ok(json!({})); + } + + match format { + "yaml" => serde_yml::from_slice(&request_bytes).map_err(|e| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!( + "Could not parse `{}` as YAML request: {}", + String::from_utf8_lossy(&request_bytes), + e + ), + }) + }), + "xml" => { + let req_str = std::str::from_utf8(&request_bytes).map_err(|e| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!( + "Could not read `{}` as valid utf8: {}", + String::from_utf8_lossy(&request_bytes), + e + ), + }) + })?; + let json_with_root = roxmltree_to_serde::xml_str_to_json( + req_str, + &roxmltree_to_serde::Config::new_with_defaults(), + ) + .map_err(|e| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!( + "Could not parse `{}` as XML request: {}", + String::from_utf8_lossy(&request_bytes), + e + ), + }) + })?; + let json_without_root = json_with_root.get(rpc_method).ok_or_else(|| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!("Use rpc method name as root element: `{}`", rpc_method), + }) + })?; + Ok(json!(json_without_root)) + } + "form" => { + let form_map: HashMap = serde_qs::from_bytes(&request_bytes) + .map_err(|e| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!( + "Could not parse `{}` FORM-URLENCODED request: {}", + String::from_utf8_lossy(&request_bytes), + e + ), + }) + })?; + Ok(json!(form_map)) + } + _ => serde_json::from_slice(&request_bytes).map_err(|e| { + AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!( + "Could not parse `{}` JSON request: {}", + String::from_utf8_lossy(&request_bytes), + e + ), + }) + }), + } +} + +fn convert_json_to_response( + headers: axum::http::HeaderMap, + rpc_method: &str, + cln_result: serde_json::Value, +) -> Result { + let accept = headers + .get("accept") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/json"); + + let format = match accept { + a if a.contains("*/*") => "json", + a if a.contains("application/json") => "json", + a if a.contains("application/yaml") => "yaml", + a if a.contains("application/xml") => "xml", + a if a.contains("application/x-www-form-urlencoded") => "form", + _ => { + return Err(AppError::NotAcceptable(RpcError { + code: None, + data: None, + message: format!("Unsupported accept header: {}", accept), + })); + } + }; + + match format { + "yaml" => match serde_yml::to_string(&cln_result) { + Ok(yaml) => Ok(( + StatusCode::CREATED, + [("Content-Type", "application/yaml")], + yaml, + ) + .into_response()), + Err(e) => Err(AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!("Could not serialize to YAML: {}", e), + })), + }, + "xml" => match quick_xml::se::to_string_with_root(rpc_method, &cln_result) { + Ok(xml) => Ok(( + StatusCode::CREATED, + [("Content-Type", "application/xml")], + xml, + ) + .into_response()), + Err(e) => Err(AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!("Could not serialize to XML: {}", e), + })), + }, + "form" => match serde_qs::to_string(&cln_result) { + Ok(form) => Ok(( + StatusCode::CREATED, + [("Content-Type", "application/x-www-form-urlencoded")], + form, + ) + .into_response()), + Err(e) => Err(AppError::InternalServerError(RpcError { + code: None, + data: None, + message: format!("Could not serialize to FORM-URLENCODED: {}", e), + })), + }, + _ => { + let response_body = Json(cln_result); + let response = ( + StatusCode::CREATED, + [("Content-Type", "application/json")], + response_body, + ) + .into_response(); + Ok(response) } } } diff --git a/tests/test_clnrest.py b/tests/test_clnrest.py index ea2f65ba364a..0740c6ae2774 100644 --- a/tests/test_clnrest.py +++ b/tests/test_clnrest.py @@ -8,6 +8,8 @@ import socketio import time import pytest +import json +import unittest def http_session_with_retry(): @@ -494,3 +496,132 @@ def message(data): sio.disconnect() assert len(notifications) == 0 + + +def test_accept_header_types(node_factory): + l1, base_url, ca_cert = start_node_with_clnrest(node_factory) + http_session = http_session_with_retry() + + rune = l1.rpc.createrune(restrictions=[])['rune'] + + response = http_session.post(base_url + '/v1/getinfo', + headers={'Rune': rune}, + verify=ca_cert) + response.raise_for_status() + assert response.json()['id'] == l1.info['id'] + + response = http_session.post(base_url + '/v1/getinfo', + headers={'Rune': rune, 'Accept': 'application/json'}, + verify=ca_cert) + response.raise_for_status() + assert response.json()['id'] == l1.info['id'] + + response = http_session.post(base_url + '/v1/getinfo', + headers={'Rune': rune, 'Accept': 'application/yaml'}, + verify=ca_cert) + response.raise_for_status() + assert f"id: '{l1.info['id']}'" in response.text + + response = http_session.post(base_url + '/v1/getinfo', + headers={'Rune': rune, 'Accept': 'application/xml'}, + verify=ca_cert) + response.raise_for_status() + assert f"{l1.info['id']}" in response.text + + response = http_session.post(base_url + '/v1/getinfo', + headers={'Rune': rune, 'Accept': 'application/x-www-form-urlencoded'}, + verify=ca_cert) + response.raise_for_status() + assert f"id={l1.info['id']}" in response.text + + +@unittest.skipIf( + TEST_NETWORK == 'liquid-regtest', + 'p2tr addresses are not supported on liquid-regtest') +def test_content_type_header_types(node_factory): + l1, base_url, ca_cert = start_node_with_clnrest(node_factory) + http_session = http_session_with_retry() + + newaddr = l1.rpc.newaddr('p2tr')['p2tr'] + l1.rpc.newaddr('p2tr')['p2tr'] + l1.rpc.newaddr('p2tr')['p2tr'] + + rune = l1.rpc.createrune(restrictions=[])['rune'] + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/json'}, + data=json.dumps({'address': newaddr}), + verify=ca_cert) + response.raise_for_status() + json_response = response.json()["addresses"] + assert len(json_response) == 1 + assert json_response[0]['p2tr'] == newaddr + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/yaml'}, + data=f"address: {newaddr}", + verify=ca_cert) + response.raise_for_status() + json_response = response.json()["addresses"] + assert len(json_response) == 1 + assert json_response[0]['p2tr'] == newaddr + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/x-www-form-urlencoded'}, + data={'address': newaddr}, + verify=ca_cert) + response.raise_for_status() + json_response = response.json()["addresses"] + assert len(json_response) == 1 + assert json_response[0]['p2tr'] == newaddr + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/xml'}, + data=f"
{newaddr}
", + verify=ca_cert) + response.raise_for_status() + json_response = response.json()["addresses"] + assert len(json_response) == 1 + assert json_response[0]['p2tr'] == newaddr + + +@unittest.skipIf( + TEST_NETWORK == 'liquid-regtest', + 'p2tr addresses are not supported on liquid-regtest') +def test_matching_accept_and_content_type(node_factory): + l1, base_url, ca_cert = start_node_with_clnrest(node_factory) + http_session = http_session_with_retry() + + newaddr = l1.rpc.newaddr('p2tr')['p2tr'] + + rune = l1.rpc.createrune(restrictions=[])['rune'] + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/json', 'Accept': 'application/json'}, + data=json.dumps({'address': newaddr}), + verify=ca_cert) + response.raise_for_status() + json_response = response.json()["addresses"] + assert len(json_response) == 1 + assert json_response[0]['p2tr'] == newaddr + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/yaml', 'Accept': 'application/yaml'}, + data=f"address: {newaddr}", + verify=ca_cert) + response.raise_for_status() + assert f"p2tr: {newaddr}" in response.text + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/x-www-form-urlencoded'}, + data={'address': newaddr}, + verify=ca_cert) + response.raise_for_status() + assert f"addresses[0][p2tr]={newaddr}" in response.text + + response = http_session.post(base_url + '/v1/listaddresses', + headers={'Rune': rune, 'Content-Type': 'application/xml', 'Accept': 'application/xml'}, + data=f"
{newaddr}
", + verify=ca_cert) + response.raise_for_status() + assert f"{newaddr}" in response.text