open-payments 0.2.0

Open Payments Rust SDK library with types, HTTP client and signature utilities
Documentation
use crate::client::AuthenticatedOpenPaymentsClient;
use crate::request::AuthenticatedRequest;
use crate::types::{
    AccessItem, Client, ContinueRequest, ContinueResponse, GrantRequest, GrantResponse, JsonWebKey,
};
use crate::OpClientError;
use crate::Result;
use reqwest::Method;

pub(crate) async fn request_grant(
    client: &AuthenticatedOpenPaymentsClient,
    auth_url: &str,
    grant: &GrantRequest,
    client_override: Option<&JsonWebKey>,
) -> Result<GrantResponse> {
    validate_grant_request(grant)?;

    let client_id = match client_override {
        Some(jwk) => Client::Jwk { jwk: jwk.clone() },
        None => Client::WalletAddressUrl(client.config.wallet_address_url.clone()),
    };

    let grant_with_client = GrantRequest {
        client: client_id,
        ..grant.clone()
    };
    let body = serde_json::to_string(&grant_with_client).map_err(OpClientError::from)?;

    AuthenticatedRequest::new(client, Method::POST, auth_url.to_string())
        .with_body(body)
        .build_and_execute(None)
        .await
}

fn validate_grant_request(grant: &GrantRequest) -> Result<()> {
    if grant.access_token.is_none() && grant.subject.is_none() {
        return Err(Box::new(OpClientError::validation(
            "Invalid Grant Request",
            vec![
                "Grant request must include at least one of \"access_token\" or \"subject\"."
                    .into(),
            ],
        )));
    }

    if let Some(access_token) = &grant.access_token {
        for item in &access_token.access {
            if let AccessItem::OutgoingPayment {
                limits: Some(limits),
                ..
            } = item
            {
                if limits.debit_amount.is_some() && limits.receive_amount.is_some() {
                    return Err(Box::new(OpClientError::validation(
                        "Invalid Grant Request",
                        vec![
                            "Only one of \"debitAmount\" or \"receiveAmount\" may be specified."
                                .into(),
                        ],
                    )));
                }
            }
        }
    }

    Ok(())
}

pub(crate) async fn continue_grant(
    client: &AuthenticatedOpenPaymentsClient,
    continue_uri: &str,
    interact_ref: &str,
    access_token: Option<&str>,
) -> Result<ContinueResponse> {
    let body = serde_json::to_string(&ContinueRequest {
        interact_ref: Some(interact_ref.to_string()),
    })
    .map_err(OpClientError::from)?;

    AuthenticatedRequest::new(client, Method::POST, continue_uri.to_string())
        .with_body(body)
        .build_and_execute(access_token)
        .await
}

pub(crate) async fn cancel_grant(
    client: &AuthenticatedOpenPaymentsClient,
    continue_uri: &str,
    access_token: Option<&str>,
) -> Result<()> {
    AuthenticatedRequest::new(client, Method::DELETE, continue_uri.to_string())
        .build_and_execute(access_token)
        .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{
        AccessTokenRequest, Amount, IncomingPaymentAction, LimitsOutgoing, OutgoingPaymentAction,
    };

    #[test]
    fn rejects_grant_without_access_token_or_subject() {
        let grant = GrantRequest {
            access_token: None,
            client: Client::WalletAddressUrl(String::new()),
            interact: None,
            subject: None,
        };
        let err = validate_grant_request(&grant).unwrap_err();
        assert!(err.description.contains("Invalid Grant Request"));
    }

    #[test]
    fn rejects_outgoing_limits_with_both_amounts() {
        let grant = GrantRequest::new(
            AccessTokenRequest {
                access: vec![AccessItem::OutgoingPayment {
                    actions: vec![OutgoingPaymentAction::Create],
                    identifier: "https://wallet.example/alice".into(),
                    limits: Some(LimitsOutgoing {
                        receiver: None,
                        debit_amount: Some(Amount {
                            value: "1".into(),
                            asset_code: "USD".into(),
                            asset_scale: 2,
                        }),
                        receive_amount: Some(Amount {
                            value: "1".into(),
                            asset_code: "USD".into(),
                            asset_scale: 2,
                        }),
                        interval: None,
                    }),
                }],
            },
            None,
        );
        let err = validate_grant_request(&grant).unwrap_err();
        assert!(err
            .validation_errors
            .as_ref()
            .unwrap()
            .iter()
            .any(|e| e.contains("debitAmount")));
    }

    #[test]
    fn accepts_incoming_payment_grant() {
        let grant = GrantRequest::new(
            AccessTokenRequest {
                access: vec![AccessItem::IncomingPayment {
                    actions: vec![IncomingPaymentAction::Create],
                    identifier: None,
                }],
            },
            None,
        );
        assert!(validate_grant_request(&grant).is_ok());
    }
}