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

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)
}
}
}