Skip to content

Commit

Permalink
feat: ilp-cli tool for interacting with nodes
Browse files Browse the repository at this point in the history
feat: reqwest for lili

refactor: rename to ilp-cli

refactor: revise API

feat(ilp-cli): CLI MVP skeleton

feat: optional arguments to add-account

feat: enough API surface for the simple example

refactor: error handling, output, optional fields

docs: build ilp-cli

fix: update ilp-cli for api field renaming

docs: port simple example
  • Loading branch information
Ben Striegel committed Sep 25, 2019
1 parent 9d5f01f commit 92d8068
Show file tree
Hide file tree
Showing 7 changed files with 371 additions and 128 deletions.
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ members = [
"./crates/interledger-store-redis",
"./crates/interledger-stream",
"./crates/interledger-settlement-engines",
"./crates/ilp-cli",
]

[profile.dev]
Expand Down
11 changes: 11 additions & 0 deletions crates/ilp-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "ilp-cli"
version = "0.0.1"
description = "Interledger.rs Command-Line Interface"
license = "Apache-2.0"
edition = "2018"
repository = "https://github.com/interledger-rs/interledger-rs"

[dependencies]
clap = { version = "2.33.0", default-features = false }
reqwest = { version = "0.9.20", default-features = false }
187 changes: 187 additions & 0 deletions crates/ilp-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
use clap::{crate_version, App, Arg, ArgMatches, SubCommand};
use reqwest;
use std::{collections::HashMap, process::exit};

pub fn main() {
// Define the arguments to the CLI
let mut app = App::new("ilp-cli")
.about("Interledger.rs Command-Line Interface")
.version(crate_version!())
// TODO remove this line once this issue is solved:
// https://github.com/clap-rs/clap/issues/1536
.after_help("")
.args(&[
Arg::with_name("authorization_key")
.long("auth")
.env("ILP_CLI_AUTH")
.required(true)
.help("An authorization key granting access to the designated operation"),
Arg::with_name("node_address")
.long("node")
.env("ILP_CLI_NODE")
.default_value("localhost:7770")
.help("The URL of the node to which to connect"),
Arg::with_name("print_response")
.short("p")
.long("print-response")
.help("Upon a successful HTTP response, response body will be printed to stdout"),
])
.subcommands(vec![
// Example: ilp-cli add-account alice ABC 9
SubCommand::with_name("add-account")
.about("Creates a new account on this node")
.args(&[
// Required, positional arguments
Arg::with_name("username")
.index(1)
.takes_value(true)
.required(true)
.help("The username of the new account"),
Arg::with_name("asset_code")
.index(2)
.takes_value(true)
.required(true)
.help("The code of the asset associated with this account"),
Arg::with_name("asset_scale")
.index(3)
.takes_value(true)
.required(true)
.help("The scale of the asset associated with this account"),
// Optional, named arguments
Arg::with_name("ilp_address").long("ilp-address").takes_value(true),
Arg::with_name("max_packet_amount").long("max-packet-amount").takes_value(true),
Arg::with_name("min_balance").long("min-balance").takes_value(true).allow_hyphen_values(true),
Arg::with_name("ilp_over_http_url").long("ilp-over-http-url").takes_value(true),
Arg::with_name("ilp_over_http_incoming_token").long("ilp-over-http-incoming-token").takes_value(true),
Arg::with_name("ilp_over_http_outgoing_token").long("ilp-over-http-outgoing-token").takes_value(true),
Arg::with_name("ilp_over_btp_url").long("ilp-over-btp-url").takes_value(true),
Arg::with_name("ilp_over_btp_outgoing_token").long("ilp-over-btp-outgoing-token").takes_value(true),
Arg::with_name("ilp_over_btp_incoming_token").long("ilp-over-btp-incoming-token").takes_value(true),
Arg::with_name("settle_threshold").long("settle-threshold").takes_value(true).allow_hyphen_values(true),
Arg::with_name("settle_to").long("settle-to").takes_value(true).allow_hyphen_values(true),
Arg::with_name("routing_relation").long("routing-relation").takes_value(true),
Arg::with_name("round_trip_time").long("round-trip-time").takes_value(true),
Arg::with_name("amount_per_minute_limit").long("amount-per-minute-limit").takes_value(true),
Arg::with_name("packets_per_minute_limit").long("packets-per-minute-limit").takes_value(true),
Arg::with_name("settlement_engine_url").long("settlement-engine-url").takes_value(true),
]),
// Example: ilp-cli get-balance alice
SubCommand::with_name("get-balance")
.about("Returns the balance of an account")
.arg(Arg::with_name("account_username")
.index(1)
.takes_value(true)
.required(true)
.help("The username of the account whose balance to return")),
// Example: ilp-cli post-payment alice 500 "http://localhost:8770/accounts/bob/spsp"
SubCommand::with_name("post-payment")
.about("Issue a payment from an account on this node")
.args(&[
Arg::with_name("sender_username")
.index(1)
.takes_value(true)
.required(true)
.help("The username of the account on this node issuing the payment"),
Arg::with_name("source_amount")
.index(2)
.takes_value(true)
.required(true)
.help("The amount to transfer from the sender to the receiver, denominated in units of the sender's assets"),
Arg::with_name("receiver")
.index(3)
.takes_value(true)
.required(true)
// TODO: better way of describing this parameter
.help("The SPSP address of the account receiving the payment"),
]),
]);

// Parse the CLI input using the defined arguments
let matches = app.clone().get_matches();

// `--auth` is a required argument, so will never be None
let auth = matches.value_of("authorization_key").unwrap();
// `--node` has a a default valiue, so will never be None
let node = matches.value_of("node_address").unwrap();

// Dispatch based on parsed input
match matches.subcommand() {
// Execute the specified subcommand
(subcommand_name, Some(subcommand_matches)) => {
// Send HTTP request
let client = reqwest::Client::new();
let response = match subcommand_name {
"add-account" => {
let args = extract_args(subcommand_matches);
client
// TODO: tacking on the protocol like this doesn't feel ideal,
// should we require it to be specified as part of the argument?
// We could also find a way to legitimately build a URL from parts
// rather than merely interpolating a string.
.post(&format!("http://{}/accounts", node))
.bearer_auth(auth)
.json(&args)
.send()
}
"get-balance" => {
let user = subcommand_matches.value_of("account_username").unwrap();
client
.get(&format!("http://{}/accounts/{}/balance", node, user))
.bearer_auth(auth)
.send()
}
"post-payment" => {
let mut args = extract_args(subcommand_matches);
let user = args.remove("sender_username").unwrap();
client
.post(&format!("http://{}/accounts/{}/payments", node, user))
.bearer_auth(&format!("{}:{}", user, auth))
.json(&args)
.send()
}
name => panic!("Unhandled subcommand: {}", name),
};

// Handle HTTP response
match response {
Err(e) => {
eprintln!("ILP CLI error: failed to send request: {}", e);
exit(1);
}
Ok(mut res) => match res.text() {
Err(e) => {
eprintln!("ILP CLI error: failed to parse response: {}", e);
exit(1);
}
// Final output
Ok(val) => {
if res.status().is_success() {
if matches.is_present("print_response") {
println!("{}", val)
}
} else {
eprintln!(
"ILP CLI error: unsuccessful response from node: {}: {}",
res.status(),
val
);
exit(1);
}
}
},
}
}
// No subcommand identified within parsed input
_ => app.print_help().unwrap(),
}
}

// This function takes the map of arguments parsed by Clap
// and extracts the values for each argument.
fn extract_args<'a>(matches: &'a ArgMatches) -> HashMap<&'a str, &'a str> {
matches // Contains data and metadata about the parsed command
.args // The hashmap containing each parameter along with its values and metadata
.iter()
.map(|(&key, val)| (key, val.vals[0].to_str().unwrap())) // Extract raw key/value pairs
.collect()
}
72 changes: 70 additions & 2 deletions crates/interledger-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ use interledger_service::{Account, AddressStore, IncomingService, OutgoingServic
use interledger_service_util::{BalanceStore, ExchangeRateStore};
use interledger_settlement::{SettlementAccount, SettlementStore};
use interledger_stream::StreamNotificationsStore;
use serde::{Deserialize, Serialize};
use serde::{de, Deserialize, Serialize};
use std::{
error::Error as StdError,
fmt::{self, Display},
net::SocketAddr,
str::FromStr,
};
use warp::{self, Filter};
mod routes;
Expand All @@ -21,6 +22,48 @@ use secrecy::SecretString;

pub(crate) mod http_retry;

// This enum and the following two functions are used to allow clients to send either
// numbers or strings and have them be properly deserialized into the appropriate
// integer type.
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr<T> {
Num(T),
Str(String),
}

pub fn number_or_string<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: de::Deserializer<'de>,
T: FromStr + Deserialize<'de>,
<T as FromStr>::Err: Display,
{
match NumOrStr::deserialize(deserializer)? {
NumOrStr::Num(n) => Ok(n),
NumOrStr::Str(s) => T::from_str(&s).map_err(de::Error::custom),
}
}

pub fn optional_number_or_string<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: de::Deserializer<'de>,
T: FromStr + Deserialize<'de>,
<T as FromStr>::Err: Display,
{
match NumOrStr::deserialize(deserializer)? {
NumOrStr::Num(n) => Ok(Some(n)),
NumOrStr::Str(s) => T::from_str(&s)
.map_err(de::Error::custom)
.and_then(|n| Ok(Some(n))),
}
}

// Ordinarily providing a serde default for Option types is unnecessary;
// this is only for cases where we also set the `deserialize_with` attribute.
pub fn optional_default<T>() -> Option<T> {
None
}

pub trait NodeStore: AddressStore + Clone + Send + Sync + 'static {
type Account: Account;

Expand Down Expand Up @@ -102,21 +145,46 @@ pub struct AccountDetails {
pub ilp_address: Option<Address>,
pub username: Username,
pub asset_code: String,
#[serde(deserialize_with = "number_or_string")]
pub asset_scale: u8,
#[serde(default = "u64::max_value")]
#[serde(default = "u64::max_value", deserialize_with = "number_or_string")]
pub max_packet_amount: u64,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub min_balance: Option<i64>,
pub ilp_over_http_url: Option<String>,
pub ilp_over_http_incoming_token: Option<SecretString>,
pub ilp_over_http_outgoing_token: Option<SecretString>,
pub ilp_over_btp_url: Option<String>,
pub ilp_over_btp_outgoing_token: Option<SecretString>,
pub ilp_over_btp_incoming_token: Option<SecretString>,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub settle_threshold: Option<i64>,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub settle_to: Option<i64>,
pub routing_relation: Option<String>,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub round_trip_time: Option<u32>,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub amount_per_minute_limit: Option<u64>,
#[serde(
default = "optional_default",
deserialize_with = "optional_number_or_string"
)]
pub packets_per_minute_limit: Option<u32>,
pub settlement_engine_url: Option<String>,
}
Expand Down
5 changes: 4 additions & 1 deletion crates/interledger-api/src/routes/accounts.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::{http_retry::Client, AccountDetails, AccountSettings, ApiError, NodeStore};
use crate::{
http_retry::Client, number_or_string, AccountDetails, AccountSettings, ApiError, NodeStore,
};
use bytes::Bytes;
use futures::{
future::{err, join_all, ok, Either},
Expand Down Expand Up @@ -30,6 +32,7 @@ const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_millis(5000);
#[derive(Deserialize, Debug)]
struct SpspPayRequest {
receiver: String,
#[serde(deserialize_with = "number_or_string")]
source_amount: u64,
}

Expand Down
Loading

0 comments on commit 92d8068

Please sign in to comment.