Skip to content

Commit 99ea238

Browse files
committed
Expose JSON reply accessors
Add typed accessors for receiver JsonReply and sender WellKnownError across the core and FFI layers. This lets bindings inspect structured error data without reparsing raw JSON strings. Fix the receiver-side version-unsupported reply shape at the same time so supported versions are emitted as a JSON array, while still accepting the legacy string form for backward compatibility. Add focused Rust and Python tests to lock in both the new accessors and the wire-format compatibility.
1 parent 332755b commit 99ea238

7 files changed

Lines changed: 239 additions & 17 deletions

File tree

payjoin-ffi/python/test/test_payjoin_unit_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import unittest
23
import payjoin
34

@@ -230,5 +231,41 @@ def test_sender_builder_rejects_bad_psbt(self):
230231
payjoin.SenderBuilder("not-a-psbt", uri)
231232

232233

234+
class TestJsonReplyAccessors(unittest.TestCase):
235+
def test_json_reply_round_trips_supported_versions(self):
236+
reply = payjoin.JsonReply.from_json(
237+
json.dumps(
238+
{
239+
"errorCode": "version-unsupported",
240+
"message": "custom message here",
241+
"supported": [1, 2],
242+
"debug": "keep-me",
243+
}
244+
)
245+
)
246+
247+
self.assertEqual(reply.error_code(), "version-unsupported")
248+
self.assertEqual(reply.message(), "custom message here")
249+
self.assertEqual(reply.status_code(), 400)
250+
self.assertEqual(reply.supported_versions(), [1, 2])
251+
self.assertEqual(
252+
json.loads(reply.to_json()),
253+
{
254+
"errorCode": "version-unsupported",
255+
"message": "custom message here",
256+
"supported": [1, 2],
257+
"debug": "keep-me",
258+
},
259+
)
260+
261+
def test_json_reply_accepts_legacy_supported_string(self):
262+
reply = payjoin.JsonReply.from_json(
263+
'{"errorCode":"version-unsupported","message":"custom message here","supported":"[1,2]"}'
264+
)
265+
266+
self.assertEqual(reply.supported_versions(), [1, 2])
267+
self.assertEqual(json.loads(reply.to_json())["supported"], [1, 2])
268+
269+
233270
if __name__ == "__main__":
234271
unittest.main()

payjoin-ffi/src/receive/error.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::sync::Arc;
22

33
use payjoin::receive;
44

5-
use crate::error::{FfiValidationError, ImplementationError};
5+
use crate::error::{FfiValidationError, ImplementationError, SerdeJsonError};
66
use crate::uri::error::IntoUrlError;
77

88
/// The top-level error type for the payjoin receiver
@@ -163,6 +163,27 @@ impl From<ProtocolError> for JsonReply {
163163
fn from(value: ProtocolError) -> Self { Self((&value.0).into()) }
164164
}
165165

166+
#[uniffi::export]
167+
impl JsonReply {
168+
#[uniffi::constructor]
169+
pub fn from_json(json: String) -> Result<Self, SerdeJsonError> {
170+
let value: serde_json::Value = serde_json::from_str(&json)?;
171+
receive::JsonReply::from_json(value).map(Self).map_err(Into::into)
172+
}
173+
174+
pub fn to_json(&self) -> Result<String, SerdeJsonError> {
175+
serde_json::to_string(&self.0.to_json()).map_err(Into::into)
176+
}
177+
178+
pub fn error_code(&self) -> String { self.0.error_code() }
179+
180+
pub fn message(&self) -> String { self.0.message().to_string() }
181+
182+
pub fn supported_versions(&self) -> Option<Vec<u64>> { self.0.supported_versions() }
183+
184+
pub fn status_code(&self) -> u16 { self.0.status_code() }
185+
}
186+
166187
/// Error that may occur during a v2 session typestate change
167188
#[derive(Debug, thiserror::Error, uniffi::Object)]
168189
#[error(transparent)]

payjoin-ffi/src/receive/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,8 @@ impl HasReplyableErrorTransition {
12541254

12551255
#[uniffi::export]
12561256
impl HasReplyableError {
1257+
pub fn error_reply(&self) -> JsonReply { self.0.error_reply().clone().into() }
1258+
12571259
pub fn create_error_request(
12581260
&self,
12591261
ohttp_relay: String,

payjoin-ffi/src/send/error.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ impl From<send::ResponseError> for ResponseError {
109109
#[error(transparent)]
110110
pub struct WellKnownError(#[from] send::WellKnownError);
111111

112+
#[uniffi::export]
113+
impl WellKnownError {
114+
pub fn code(&self) -> String { self.0.code() }
115+
116+
pub fn message(&self) -> String { self.0.message().to_string() }
117+
118+
pub fn supported_versions(&self) -> Option<Vec<u64>> { self.0.supported_versions() }
119+
}
120+
112121
/// Error that may occur when the sender session event log is replayed
113122
#[derive(Debug, thiserror::Error, uniffi::Object)]
114123
#[error(transparent)]

payjoin/src/core/receive/error.rs

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
use std::str::FromStr;
12
use std::{error, fmt};
23

4+
use serde::Deserialize;
5+
36
use crate::error_codes::ErrorCode::{
47
self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported,
58
};
@@ -93,6 +96,47 @@ impl JsonReply {
9396
Self { error_code, message: message.to_string(), extra: serde_json::Map::new() }
9497
}
9598

99+
/// Parse a reply from the BIP78 wire JSON shape.
100+
pub fn from_json(json: serde_json::Value) -> Result<Self, serde_json::Error> {
101+
#[derive(serde::Deserialize)]
102+
struct WireJsonReply {
103+
#[serde(rename = "errorCode")]
104+
error_code: String,
105+
message: String,
106+
#[serde(default, deserialize_with = "deserialize_supported_versions")]
107+
supported: Option<Vec<u64>>,
108+
#[serde(flatten)]
109+
extra: serde_json::Map<String, serde_json::Value>,
110+
}
111+
112+
fn deserialize_supported_versions<'de, D>(
113+
deserializer: D,
114+
) -> Result<Option<Vec<u64>>, D::Error>
115+
where
116+
D: serde::Deserializer<'de>,
117+
{
118+
use serde::de::Error as _;
119+
120+
let supported = Option::<serde_json::Value>::deserialize(deserializer)?;
121+
parse_supported_versions_value(supported.as_ref()).map_err(D::Error::custom)
122+
}
123+
124+
let wire: WireJsonReply = serde_json::from_value(json)?;
125+
let error_code = ErrorCode::from_str(&wire.error_code).map_err(|()| {
126+
<serde_json::Error as serde::de::Error>::custom(format!(
127+
"invalid errorCode: {}",
128+
wire.error_code
129+
))
130+
})?;
131+
132+
let mut reply = Self::new(error_code, wire.message);
133+
if let Some(supported) = wire.supported {
134+
reply = reply.with_extra("supported", serde_json::to_value(supported)?);
135+
}
136+
reply.extra.extend(wire.extra);
137+
Ok(reply)
138+
}
139+
96140
/// Add an additional field to the JSON response
97141
pub fn with_extra(mut self, key: &str, value: impl Into<serde_json::Value>) -> Self {
98142
self.extra.insert(key.to_string(), value.into());
@@ -119,6 +163,45 @@ impl JsonReply {
119163
}
120164
.as_u16()
121165
}
166+
167+
/// Return the wire-format error code, such as `version-unsupported`.
168+
pub fn error_code(&self) -> String { self.error_code.to_string() }
169+
170+
/// Return the human-readable message.
171+
pub fn message(&self) -> &str { &self.message }
172+
173+
/// Return the supported versions when present.
174+
pub fn supported_versions(&self) -> Option<Vec<u64>> {
175+
self.extra
176+
.get("supported")
177+
.and_then(|value| parse_supported_versions_value(Some(value)).ok().flatten())
178+
}
179+
}
180+
181+
fn parse_supported_versions_value(
182+
value: Option<&serde_json::Value>,
183+
) -> Result<Option<Vec<u64>>, String> {
184+
use serde_json::Value;
185+
186+
let Some(value) = value else {
187+
return Ok(None);
188+
};
189+
190+
match value {
191+
Value::Array(items) => items
192+
.iter()
193+
.map(|item| {
194+
item.as_u64()
195+
.ok_or_else(|| "supported versions must be an array of u64 values".to_string())
196+
})
197+
.collect::<Result<Vec<_>, _>>()
198+
.map(Some),
199+
// Backward compatibility for the old broken wire shape where `supported`
200+
// was serialized as a JSON string containing the array.
201+
Value::String(json) =>
202+
serde_json::from_str::<Vec<u64>>(json).map(Some).map_err(|err| err.to_string()),
203+
other => Err(format!("unsupported versions must be an array or JSON string, got {other}")),
204+
}
122205
}
123206

124207
impl From<&ProtocolError> for JsonReply {
@@ -235,12 +318,12 @@ impl From<&PayloadError> for JsonReply {
235318
FeeTooHigh(_, _) => JsonReply::new(NotEnoughMoney, e),
236319

237320
SenderParams(e) => match e {
238-
super::optional_parameters::Error::UnknownVersion { supported_versions } => {
239-
let supported_versions_json =
240-
serde_json::to_string(supported_versions).unwrap_or_default();
321+
super::optional_parameters::Error::UnknownVersion { supported_versions } =>
241322
JsonReply::new(VersionUnsupported, "This version of payjoin is not supported.")
242-
.with_extra("supported", supported_versions_json)
243-
}
323+
.with_extra(
324+
"supported",
325+
serde_json::to_value(supported_versions).unwrap_or_default(),
326+
),
244327
super::optional_parameters::Error::FeeRate =>
245328
JsonReply::new(OriginalPsbtRejected, e),
246329
},
@@ -504,4 +587,43 @@ mod tests {
504587
assert_eq!(json["errorCode"], "original-psbt-rejected");
505588
assert_eq!(json["message"], "Missing payment.");
506589
}
590+
591+
#[test]
592+
fn test_json_reply_supported_versions_are_arrays() {
593+
let supported_versions = &[crate::Version::One, crate::Version::Two];
594+
let reply = JsonReply::new(ErrorCode::VersionUnsupported, "unsupported")
595+
.with_extra("supported", serde_json::to_value(supported_versions).unwrap());
596+
597+
assert_eq!(reply.supported_versions(), Some(vec![1, 2]));
598+
assert_eq!(
599+
reply.to_json(),
600+
serde_json::json!({
601+
"errorCode": "version-unsupported",
602+
"message": "unsupported",
603+
"supported": [1, 2],
604+
})
605+
);
606+
}
607+
608+
#[test]
609+
fn test_json_reply_from_json_accepts_legacy_supported_string() {
610+
let reply = JsonReply::from_json(serde_json::json!({
611+
"errorCode": "version-unsupported",
612+
"message": "unsupported",
613+
"supported": "[1,2]",
614+
}))
615+
.expect("legacy supported string should parse");
616+
617+
assert_eq!(reply.error_code(), "version-unsupported");
618+
assert_eq!(reply.message(), "unsupported");
619+
assert_eq!(reply.supported_versions(), Some(vec![1, 2]));
620+
assert_eq!(
621+
reply.to_json(),
622+
serde_json::json!({
623+
"errorCode": "version-unsupported",
624+
"message": "unsupported",
625+
"supported": [1, 2],
626+
})
627+
);
628+
}
507629
}

payjoin/src/core/receive/v2/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,9 @@ pub struct HasReplyableError {
11721172
}
11731173

11741174
impl Receiver<HasReplyableError> {
1175+
/// Return the replyable error payload that will be sent back to the sender.
1176+
pub fn error_reply(&self) -> &JsonReply { &self.state.error_reply }
1177+
11751178
/// Construct an OHTTP Encapsulated HTTP POST request to return
11761179
/// a Receiver Error Response
11771180
pub fn create_error_request(

payjoin/src/core/send/error.rs

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,22 @@ impl fmt::Debug for ResponseError {
330330

331331
impl ResponseError {
332332
pub(crate) fn from_json(json: serde_json::Value) -> Self {
333+
fn supported_versions(json: &serde_json::Value) -> Vec<u64> {
334+
let Some(supported) = json.as_object().and_then(|v| v.get("supported")) else {
335+
return vec![];
336+
};
337+
338+
if let Some(array) = supported.as_array() {
339+
return array.iter().filter_map(|v| v.as_u64()).collect();
340+
}
341+
342+
if let Some(json) = supported.as_str() {
343+
return serde_json::from_str::<Vec<u64>>(json).unwrap_or_default();
344+
}
345+
346+
vec![]
347+
}
348+
333349
let message = json
334350
.as_object()
335351
.and_then(|v| v.get("message"))
@@ -341,15 +357,8 @@ impl ResponseError {
341357

342358
match error_code {
343359
Some(code) => match ErrorCode::from_str(code) {
344-
Ok(ErrorCode::VersionUnsupported) => {
345-
let supported = json
346-
.as_object()
347-
.and_then(|v| v.get("supported"))
348-
.and_then(|v| v.as_array())
349-
.map(|array| array.iter().filter_map(|v| v.as_u64()).collect::<Vec<u64>>())
350-
.unwrap_or_default();
351-
WellKnownError::version_unsupported(message, supported).into()
352-
}
360+
Ok(ErrorCode::VersionUnsupported) =>
361+
WellKnownError::version_unsupported(message, supported_versions(&json)).into(),
353362
Ok(code) => WellKnownError::new(code, message).into(),
354363
Err(_) => Self::Unrecognized { error_code: code.to_string(), message },
355364
},
@@ -399,6 +408,15 @@ impl WellKnownError {
399408
pub(crate) fn version_unsupported(message: String, supported: Vec<u64>) -> Self {
400409
Self { code: ErrorCode::VersionUnsupported, message, supported_versions: Some(supported) }
401410
}
411+
412+
/// Return the wire-format error code, such as `version-unsupported`.
413+
pub fn code(&self) -> String { self.code.to_string() }
414+
415+
/// Return the protocol message associated with the error.
416+
pub fn message(&self) -> &str { &self.message }
417+
418+
/// Return the advertised supported versions when present.
419+
pub fn supported_versions(&self) -> Option<Vec<u64>> { self.supported_versions.clone() }
402420
}
403421

404422
#[cfg(test)]
@@ -410,20 +428,30 @@ mod tests {
410428
#[test]
411429
fn test_parse_json() {
412430
let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#;
413-
match ResponseError::parse(known_str_error) {
431+
match ResponseError::from_json(serde_json::from_str(known_str_error).unwrap()) {
414432
ResponseError::WellKnown(e) => {
415433
assert_eq!(e.code, ErrorCode::VersionUnsupported);
416434
assert_eq!(e.message, "custom message here");
435+
assert_eq!(e.supported_versions(), Some(vec![1, 2]));
417436
assert_eq!(
418437
e.to_string(),
419438
"This version of payjoin is not supported. Use version [1, 2]."
420439
);
421440
}
422441
_ => panic!("Expected WellKnown error"),
423442
};
443+
let legacy_supported_string = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": "[1,2]"}"#;
444+
match ResponseError::from_json(serde_json::from_str(legacy_supported_string).unwrap()) {
445+
ResponseError::WellKnown(e) => {
446+
assert_eq!(e.code(), "version-unsupported");
447+
assert_eq!(e.message(), "custom message here");
448+
assert_eq!(e.supported_versions(), Some(vec![1, 2]));
449+
}
450+
_ => panic!("Expected WellKnown error"),
451+
};
424452
let unrecognized_error = r#"{"errorCode":"random", "message":"random"}"#;
425453
assert!(matches!(
426-
ResponseError::parse(unrecognized_error),
454+
ResponseError::from_json(serde_json::from_str(unrecognized_error).unwrap()),
427455
ResponseError::Unrecognized { .. }
428456
));
429457
let invalid_json_error = json!({

0 commit comments

Comments
 (0)