Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/client/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
},
quotes::{create_quote, get_quote},
token::{revoke_access_token, rotate_access_token},
wallet_address::{get_keys, get_wallet_address},
wallet_address::{get_did_document, get_keys, get_wallet_address},
Result,
};
pub mod authenticated {
Expand Down Expand Up @@ -216,8 +216,11 @@ pub mod unauthenticated {
get_keys(self.client.http_client(), wallet).await
}

pub async fn get_did_document(&self, _wallet: &WalletAddress) -> Result<()> {
unimplemented!()
pub async fn get_did_document(
&self,
wallet: &WalletAddress,
) -> Result<serde_json::Value> {
get_did_document(self.client.http_client(), wallet).await
}
}

Expand Down
41 changes: 31 additions & 10 deletions src/client/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,16 +343,37 @@ async fn execute_request<T: DeserializeOwned + 'static>(
let resp = client.execute(req).await.map_err(OpClientError::from)?;

if !resp.status().is_success() {
return Err(Box::new(OpClientError::http(
"HTTP request failed".to_string(),
Some(
resp.status()
.canonical_reason()
.unwrap_or("Unknown")
.to_string(),
),
Some(resp.status().as_u16()),
)));
let status_code = resp.status().as_u16();
let status_text = resp
.status()
.canonical_reason()
.unwrap_or("Unknown")
.to_string();

// Attempt to read the response body for error details.
// The Open Payments spec defines structured error responses
// that contain useful debugging information.
let body = resp.text().await.unwrap_or_default();
let description = if body.is_empty() {
"HTTP request failed".to_string()
} else {
format!("HTTP request failed: {}", body)
};

let mut error = OpClientError::http(description, Some(status_text), Some(status_code));

// Try to parse the error body as JSON for structured details
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(obj) = parsed.as_object() {
let mut details = std::collections::HashMap::new();
for (key, value) in obj {
details.insert(key.clone(), value.clone());
}
error = error.with_details(details);
}
}

return Err(Box::new(error));
}

if resp.status() == reqwest::StatusCode::NO_CONTENT
Expand Down
11 changes: 11 additions & 0 deletions src/client/wallet_address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,14 @@ pub(crate) async fn get_keys(client: &Client, wallet: &WalletAddress) -> Result<
.build_and_execute()
.await
}

pub(crate) async fn get_did_document(
client: &Client,
wallet: &WalletAddress,
) -> Result<serde_json::Value> {
let url = format!("{}/did.json", wallet.id.trim_end_matches('/'));

UnauthenticatedRequest::new(client, Method::GET, url)
.build_and_execute()
.await
}
3 changes: 1 addition & 2 deletions src/types/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ pub struct AccessToken {
pub manage: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access: Option<Vec<AccessItem>>,
pub access: Vec<AccessItem>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
24 changes: 20 additions & 4 deletions src/types/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@ pub struct IncomingPayment {
pub methods: Option<Vec<PaymentMethod>>,
}

/// An incoming payment with payment methods always present.
/// Used for responses from `POST /incoming-payments` where methods
/// are guaranteed to be included.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IncomingPaymentWithMethods {
#[serde(flatten)]
pub payment: IncomingPayment,
pub id: String,
pub wallet_address: String,
pub completed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub incoming_amount: Option<Amount>,
pub received_amount: Amount,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
pub methods: Vec<PaymentMethod>,
}

Expand Down Expand Up @@ -74,8 +88,10 @@ pub struct OutgoingPayment {
pub receive_amount: Amount,
pub debit_amount: Amount,
pub sent_amount: Amount,
pub grant_spent_debit_amount: Amount,
pub grant_spent_receive_amount: Amount,
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_spent_debit_amount: Option<Amount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_spent_receive_amount: Option<Amount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub created_at: DateTime<Utc>,
Expand Down
77 changes: 65 additions & 12 deletions tests/types_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,80 @@ fn outgoing_payment_roundtrip_minimal() {
asset_code: "USD".into(),
asset_scale: 2,
},
grant_spent_debit_amount: Amount {
grant_spent_debit_amount: Some(Amount {
value: "0".into(),
asset_code: "USD".into(),
asset_scale: 2,
},
grant_spent_receive_amount: Amount {
}),
grant_spent_receive_amount: Some(Amount {
value: "0".into(),
asset_code: "USD".into(),
asset_scale: 2,
},
}),
metadata: None,
created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
updated_at: None,
};
serde_roundtrip(&v);
}

#[test]
fn outgoing_payment_without_grant_spent_amounts() {
// GET /outgoing-payments/{id} responses do NOT include grant spent amounts.
// This test ensures deserialization succeeds when those fields are absent.
let json = serde_json::json!({
"id": "https://ilp.interledger-test.dev/outgoing-payments/abc",
"walletAddress": "https://ilp.interledger-test.dev/alice",
"quoteId": "https://ilp.interledger-test.dev/quotes/q1",
"failed": false,
"receiver": "https://ilp.interledger-test.dev/incoming-payments/123",
"receiveAmount": { "value": "10", "assetCode": "USD", "assetScale": 2 },
"debitAmount": { "value": "110", "assetCode": "USD", "assetScale": 2 },
"sentAmount": { "value": "0", "assetCode": "USD", "assetScale": 2 },
"createdAt": "2024-01-01T00:00:00Z"
});

let payment: OutgoingPayment =
serde_json::from_value(json).expect("should deserialize without grant spent amounts");
assert!(payment.grant_spent_debit_amount.is_none());
assert!(payment.grant_spent_receive_amount.is_none());
}

#[test]
fn outgoing_payment_with_grant_spent_amounts() {
// POST /outgoing-payments responses include grant spent amounts.
let json = serde_json::json!({
"id": "https://ilp.interledger-test.dev/outgoing-payments/abc",
"walletAddress": "https://ilp.interledger-test.dev/alice",
"failed": false,
"receiver": "https://ilp.interledger-test.dev/incoming-payments/123",
"receiveAmount": { "value": "10", "assetCode": "USD", "assetScale": 2 },
"debitAmount": { "value": "110", "assetCode": "USD", "assetScale": 2 },
"sentAmount": { "value": "0", "assetCode": "USD", "assetScale": 2 },
"grantSpentDebitAmount": { "value": "50", "assetCode": "USD", "assetScale": 2 },
"grantSpentReceiveAmount": { "value": "5", "assetCode": "USD", "assetScale": 2 },
"createdAt": "2024-01-01T00:00:00Z"
});

let payment: OutgoingPayment =
serde_json::from_value(json).expect("should deserialize with grant spent amounts");
assert!(payment.grant_spent_debit_amount.is_some());
assert_eq!(payment.grant_spent_debit_amount.unwrap().value, "50");
}

#[test]
fn access_token_requires_access_field() {
// The spec requires `access` on access tokens. Verify deserialization
// fails when the field is missing.
let json = serde_json::json!({
"value": "token-value",
"manage": "https://auth.example.com/manage"
});

let result = serde_json::from_value::<AccessToken>(json);
assert!(result.is_err(), "should fail without required access field");
}

#[test]
fn quote_roundtrip() {
let v = Quote {
Expand Down Expand Up @@ -238,7 +295,7 @@ fn grant_and_continue_response_roundtrip_variants() {
value: "av".into(),
manage: "https://auth.interledger-test.dev/manage".into(),
expires_in: Some(3600),
access: None,
access: vec![],
},
continue_: cont.clone(),
};
Expand All @@ -258,7 +315,7 @@ fn grant_and_continue_response_roundtrip_variants() {
value: "av".into(),
manage: "https://auth.interledger-test.dev/manage".into(),
expires_in: Some(3600),
access: None,
access: vec![],
},
continue_: cont.clone(),
};
Expand Down Expand Up @@ -313,7 +370,7 @@ fn access_token_and_response_roundtrip() {
value: "token".into(),
manage: "https://auth.interledger-test.dev/manage".into(),
expires_in: Some(3600),
access: None,
access: vec![],
};
serde_roundtrip(&tok);
let resp = AccessTokenResponse { access_token: tok };
Expand Down Expand Up @@ -411,7 +468,7 @@ fn incoming_payment_with_methods_roundtrip() {
ilp_address: "test.bank".into(),
shared_secret: "s".into(),
};
let base = IncomingPayment {
let wrapped = IncomingPaymentWithMethods {
id: "https://ilp.interledger-test.dev/incoming-payments/123".into(),
wallet_address: "https://ilp.interledger-test.dev/alice".into(),
completed: false,
Expand All @@ -429,10 +486,6 @@ fn incoming_payment_with_methods_roundtrip() {
metadata: None,
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
updated_at: None,
methods: None,
};
let wrapped = IncomingPaymentWithMethods {
payment: base,
methods: vec![ilp],
};
serde_roundtrip(&wrapped);
Expand Down