diff --git a/src/client/api.rs b/src/client/api.rs index 7aa2bf2..8e33623 100644 --- a/src/client/api.rs +++ b/src/client/api.rs @@ -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 { @@ -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 { + get_did_document(self.client.http_client(), wallet).await } } diff --git a/src/client/request.rs b/src/client/request.rs index e19bb76..5d9f18e 100644 --- a/src/client/request.rs +++ b/src/client/request.rs @@ -343,16 +343,37 @@ async fn execute_request( 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::(&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 diff --git a/src/client/wallet_address.rs b/src/client/wallet_address.rs index 12fa210..478ed8e 100644 --- a/src/client/wallet_address.rs +++ b/src/client/wallet_address.rs @@ -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 { + let url = format!("{}/did.json", wallet.id.trim_end_matches('/')); + + UnauthenticatedRequest::new(client, Method::GET, url) + .build_and_execute() + .await +} diff --git a/src/types/auth.rs b/src/types/auth.rs index 51aa32b..3929b9d 100644 --- a/src/types/auth.rs +++ b/src/types/auth.rs @@ -69,8 +69,7 @@ pub struct AccessToken { pub manage: String, #[serde(skip_serializing_if = "Option::is_none")] pub expires_in: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub access: Option>, + pub access: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/types/resource.rs b/src/types/resource.rs index 3c96e8a..a1d562f 100644 --- a/src/types/resource.rs +++ b/src/types/resource.rs @@ -23,11 +23,25 @@ pub struct IncomingPayment { pub methods: Option>, } +/// 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, + pub received_amount: Amount, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, pub methods: Vec, } @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_spent_receive_amount: Option, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, pub created_at: DateTime, diff --git a/tests/types_roundtrip.rs b/tests/types_roundtrip.rs index f1fd65f..562d295 100644 --- a/tests/types_roundtrip.rs +++ b/tests/types_roundtrip.rs @@ -77,16 +77,16 @@ 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, @@ -94,6 +94,63 @@ fn outgoing_payment_roundtrip_minimal() { 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::(json); + assert!(result.is_err(), "should fail without required access field"); +} + #[test] fn quote_roundtrip() { let v = Quote { @@ -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(), }; @@ -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(), }; @@ -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 }; @@ -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, @@ -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);