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.
This commit is contained in:
daywalker90
2025-06-27 17:14:35 +02:00
committed by ShahanaFarooqui
parent 2e7181d04f
commit bf37d41c7e
4 changed files with 432 additions and 39 deletions

69
Cargo.lock generated
View File

@@ -482,9 +482,13 @@ dependencies = [
"hyper 1.6.0",
"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.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[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.9.4"
@@ -1604,6 +1618,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.40"
@@ -1759,6 +1783,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.7.1"
@@ -2008,6 +2051,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.12",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -2020,6 +2074,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.9.0",
"itoa",
"libyml",
"memchr",
"ryu",
"serde",
"version_check",
]
[[package]]
name = "sha1"
version = "0.10.6"

View File

@@ -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"

View File

@@ -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<String, String>);
/* 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<serde_json::Value, AppError> {
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<String, serde_json::Value> = 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<Response, AppError> {
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)
}
}
}

View File

@@ -8,6 +8,7 @@ from urllib3.util.retry import Retry
import socketio
import time
import pytest
import json
def http_session_with_retry():
@@ -494,3 +495,146 @@ def test_websocket_upgrade_header(node_factory):
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"<id>{l1.info['id']}</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
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()
datastore_res = l1.rpc.datastore(key=['project'], string='core lightning', mode='must-create')
rune = l1.rpc.createrune(restrictions=[])['rune']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune},
data=json.dumps({'key': ['project']}),
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
assert datastore_key[0]['string'] == datastore_res['string']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/json'},
data=json.dumps({'key': ['project']}),
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
assert datastore_key[0]['string'] == datastore_res['string']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/yaml'},
data=f"key: {datastore_res['key']}",
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
assert datastore_key[0]['string'] == datastore_res['string']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/xml'},
data=f"<listdatastore><key>{datastore_res['key'][0]}</key></listdatastore>",
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
assert datastore_key[0]['string'] == datastore_res['string']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/x-www-form-urlencoded'},
data={'key': datastore_res['key']},
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
assert datastore_key[0]['string'] == datastore_res['string']
def test_matching_accept_and_content_types(node_factory):
l1, base_url, ca_cert = start_node_with_clnrest(node_factory)
http_session = http_session_with_retry()
datastore_res = l1.rpc.datastore(key=['project'], string='core lightning', mode='must-create')
rune = l1.rpc.createrune(restrictions=[])['rune']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune},
data=json.dumps({'key': datastore_res['key']}),
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/json', 'Accept': 'application/json'},
data=json.dumps({'key': datastore_res['key']}),
verify=ca_cert)
listdatastore_res.raise_for_status()
datastore_key = listdatastore_res.json()["datastore"]
assert len(datastore_key) == 1
assert datastore_key[0]['key'] == datastore_res['key']
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/yaml', 'Accept': 'application/yaml'},
data=f"key: {datastore_res['key']}",
verify=ca_cert)
listdatastore_res.raise_for_status()
assert f"key:\n - {datastore_res['key'][0]}" in listdatastore_res.text
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/xml', 'Accept': 'application/xml'},
data=f"<listdatastore><key>{datastore_res['key'][0]}</key></listdatastore>",
verify=ca_cert)
listdatastore_res.raise_for_status()
assert f"<key>{datastore_res['key'][0]}</key>" in listdatastore_res.text
listdatastore_res = http_session.post(base_url + '/v1/listdatastore',
headers={'Rune': rune, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/x-www-form-urlencoded'},
data={'key': datastore_res['key']},
verify=ca_cert)
listdatastore_res.raise_for_status()
assert f"datastore[0][key][0]={datastore_res['key'][0]}" in listdatastore_res.text