Initial commit

This commit is contained in:
Mateusz Słodkowicz 2024-03-14 19:41:40 +01:00
commit 4efafdd85b
Signed by: materus
GPG Key ID: 28D140BCA60B4FD1
9 changed files with 2444 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1770
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "currency-exchange"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.5.2", features = [ "derive" ] }
reqwest = "0.11.25"
rusqlite = { version = "0.31.0", features = ["bundled", "array"] }
rusty-money = "0.4.1"
serde = { version ="1.0.197", features = ["derive"] }
serde_json = "1.0.114"
tokio = { version = "1.36.0", features = ["full"] }
rust_decimal = "1.34"
rust_decimal_macros = "1.34"

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
brace_style="AlwaysNextLine"

240
src/cache.rs Normal file
View File

@ -0,0 +1,240 @@
use std::{
collections::HashMap,
fs::{metadata, remove_file},
};
use rusqlite::{Connection, Result};
use crate::config::{get_cache_path, println_and_exit};
const CANNOT_CLOSE_MSG: &str = "Couldn't close sqlite connection";
pub fn check_code(code: &String) -> Result<bool>
{
let conn = Connection::open(get_cache_path())?;
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT code FROM currencies WHERE currencies.code = UPPER($1))",
[code],
|row| row.get(0),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(exists)
}
pub fn list_currencies() -> Result<Vec<[String;2]>>
{
let conn = Connection::open(get_cache_path())?;
let mut stmt = conn.prepare("SELECT code, text FROM currencies ORDER BY code")?;
let ret = stmt
.query_map([], |row| {let v:Result<[String;2]> = Ok([row.get(0)?,row.get(1)?]); v})
.expect("Error while listing currencies");
let mut result: Vec<[String;2]> = Vec::new();
for code in ret {
let i = code.unwrap();
let z = [i[0].clone(),i[1].clone()];
result.push(z);
}
stmt.finalize()?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(result)
}
pub fn list_rates(code_from: &String ) -> Result<Vec<[String;2]>>
{
let conn = Connection::open(get_cache_path())?;
let mut stmt = conn.prepare("SELECT code_to, rate FROM exchange_rates WHERE code_from = $1 ORDER BY code_to")?;
let ret = stmt
.query_map([code_from], |row| {let v:Result<[String;2]> = Ok([row.get(0)?,row.get(1)?]); v})
.expect("Error while listing rates");
let mut result: Vec<[String;2]> = Vec::new();
for code in ret {
let i = code.unwrap();
let z = [i[0].clone(),i[1].clone()];
result.push(z);
}
stmt.finalize()?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(result)
}
pub fn check_exchange(code_from: &String, code_to: &String) -> Result<bool>
{
let conn = Connection::open(get_cache_path())?;
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT code_from, code_to
FROM exchange_rates
WHERE exchange_rates.code_from = UPPER($1) AND exchange_rates.code_to = UPPER($2))",
[code_from, code_to],
|row| row.get(0),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(exists)
}
pub fn get_rate(code_from: &String, code_to: &String) -> Result<String>
{
let conn = Connection::open(get_cache_path())?;
let rate: String = conn.query_row(
"SELECT rate
FROM exchange_rates
WHERE exchange_rates.code_from = UPPER($1) AND exchange_rates.code_to = UPPER($2)",
[code_from, code_to],
|row| row.get(0),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(rate)
}
pub fn get_next_update(code: &String) -> Result<u64>
{
let conn = Connection::open(get_cache_path())?;
let next_update: u64 = conn.query_row(
"SELECT next_update FROM currencies WHERE currencies.code = UPPER($1)",
[code],
|row| row.get(0),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(next_update)
}
pub fn add_rates(
next_update: u64,
code_from: &String,
rates: &HashMap<String, serde_json::Value>,
) -> Result<()>
{
let conn = Connection::open(get_cache_path())?;
for (code_to, rate) in rates {
conn.execute(
"
INSERT OR REPLACE INTO exchange_rates(code_from,code_to,rate)
VALUES(UPPER($1),UPPER($2),$3)
",
[code_from, code_to, &rate.to_string()],
)?;
}
conn.execute(
"
UPDATE currencies
SET next_update = $1
WHERE currencies.code = UPPER($2)
",
[&next_update.to_string(), code_from],
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(())
}
pub fn add_code(code: [String; 2]) -> Result<()>
{
let conn = Connection::open(get_cache_path())?;
conn.execute(
"
INSERT OR IGNORE INTO currencies(code,text,next_update)
VALUES(UPPER($1),$2,0)
",
[code.get(0), code.get(1)],
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(())
}
pub fn get_api_key() -> Result<String>
{
let conn = Connection::open(get_cache_path())?;
let api_key: String = conn.query_row(
"SELECT value FROM config WHERE config.name = 'API_KEY'",
[],
|row| row.get(0),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(api_key)
}
pub fn set_api_key(key: String) -> Result<()>
{
let conn = Connection::open(get_cache_path())?;
conn.execute(
"
UPDATE config
SET value = $1
WHERE config.name = 'API_KEY'
",
[key],
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(())
}
pub fn create_cache() -> Result<()>
{
let path = &get_cache_path();
if path.is_dir() {
println_and_exit("Specified path cache path is dir, not file")
}
if path.exists() {
match remove_file(path) {
Ok(()) => (),
Err(_e) => match metadata(path) {
Ok(md) => {
if md.permissions().readonly() {
println_and_exit("Can't modify file");
}
}
Err(_e) => println_and_exit("Unknown error while trying to remove old database"),
},
}
}
let conn = Connection::open(path)?;
conn.execute(
"
CREATE TABLE config (
name TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
(),
)?;
conn.execute(
"
CREATE TABLE currencies (
code TEXT PRIMARY KEY,
text TEXT NOT NULL,
next_update TIME NOT NULL
)",
(),
)?;
conn.execute(
"
CREATE TABLE exchange_rates (
code_from TEXT NOT NULL,
code_to TEXT NOT NULL,
rate TEXT NOT NULL,
PRIMARY KEY (code_from, code_to)
)",
(),
)?;
conn.execute(
"
INSERT INTO config (name, value) VALUES (
'API_KEY',
''
)
",
(),
)?;
conn.close().expect(CANNOT_CLOSE_MSG);
Ok(())
}

45
src/config.rs Normal file
View File

@ -0,0 +1,45 @@
use std::{
env::{temp_dir, var_os},
path::PathBuf,
process::exit,
};
pub const CACHE_LOCATION_ENV_NAME: &str = "CURRENCY_CACHE";
pub const REST_ENDPOINT: &str = "https://v6.exchangerate-api.com/v6/";
pub const REST_ENDPOINT_ENV_NAME: &str = "CURRENCY_ENDPOINT";
pub fn get_endpoint() -> String
{
let ret: String;
match var_os(REST_ENDPOINT_ENV_NAME) {
Some(val) => ret = val.to_str().unwrap().to_string(),
None => ret = REST_ENDPOINT.to_string(),
}
ret
}
pub fn get_cache_path() -> PathBuf
{
let mut path: PathBuf = PathBuf::new();
match var_os(CACHE_LOCATION_ENV_NAME) {
Some(val) => path.push(val),
None => {
path.push(temp_dir());
path.push("currencyCache.db");
}
}
path
}
pub fn get_current_time() -> u64
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn println_and_exit(msg: &str)
{
println!("{}", msg);
exit(1)
}

69
src/exchange.rs Normal file
View File

@ -0,0 +1,69 @@
use crate::*;
use rust_decimal::prelude::*;
use rusty_money::{iso::find, ExchangeRate, Money};
pub async fn update_rate(code: &String)
{
if cache::get_next_update(code).expect("Error getting next update time from cache")
<= config::get_current_time()
{
let status = requests::get_rates(code)
.await
.expect("Error while fetching rates");
if status == requests::Status::INVALID {
config::println_and_exit("Invalid api key when getting rates")
} else if status == requests::Status::LIMIT {
config::println_and_exit("Exceeded API limit when getting rates")
} else if status == requests::Status::ERROR {
config::println_and_exit("Unknown error when getting rates")
}
}
}
pub async fn get_rate(code_from: &String, code_to: &String) -> String
{
if !cache::check_code(code_from).expect("Error on getting code status") {
config::println_and_exit(
format!("Code {} doesn't exists, use correct code!", code_from).as_str(),
)
}
if !cache::check_code(code_to).expect("Error on getting code status") {
config::println_and_exit(
format!("Code {} doesn't exists, use correct code!", code_to).as_str(),
)
}
if (!cache::check_exchange(code_from, code_to).expect("Error on getting exchange status"))
|| (cache::get_next_update(code_from).expect("Error getting next update time from cache")
<= config::get_current_time())
{
update_rate(code_from).await;
}
cache::get_rate(code_from, code_to).expect("Error when getting cached rate")
}
pub async fn convert_value(code_from: &String, code_to: &String, value: &String)
{
if value.parse::<f64>().is_err() {
config::println_and_exit(format!("{} is not a number!", value).as_str())
}
let text_rate = get_rate(code_from, code_to).await;
let from_currency = find(code_from);
if from_currency.is_none() {
config::println_and_exit(format!("{} not found in ISO formats", code_from).as_str())
}
let to_currency = find(code_to);
if to_currency.is_none() {
config::println_and_exit(format!("{} not found in ISO formats", code_to).as_str())
}
let rate = Decimal::from_str(&text_rate).unwrap();
let dec_amount = Decimal::from_str(&value).unwrap();
let from_money = Money::from_decimal(dec_amount, from_currency.unwrap());
println!("Input: {}", from_money.to_string());
if code_from != code_to {
let ex = ExchangeRate::new(from_currency.unwrap(), to_currency.unwrap(), rate).unwrap();
let result = ex.convert(from_money).expect("Error while conversion");
println!("Equals: {}", result.to_string())
} else {
println!("{}", from_money.to_string())
}
println!("Exchange rate: {}", text_rate);
}

206
src/main.rs Normal file
View File

@ -0,0 +1,206 @@
use std::io::Write;
use crate::{
cache::{create_cache, set_api_key},
requests::get_currencies,
};
use cache::check_code;
use clap::Parser;
use exchange::convert_value;
mod cache;
mod config;
mod exchange;
mod requests;
#[derive(Parser)]
#[command(about, long_about = None, arg_required_else_help = true)]
struct Cli
{
/// Currency code to exchange from
currency_from: Option<String>,
/// Currency code to exchange to
currency_to: Option<String>,
/// Currency amount to exchange
value: Option<String>,
/// Set api key
#[arg(short = 'k', long = "set-api-key")]
api_key: Option<String>,
/// Recrate cache
#[arg(short = 'r', long = "recreate-cache")]
recreate_cache: bool,
/// Interactive mode
#[arg(short, long)]
interactive: bool,
/// List currencies
#[arg(short, long)]
list: bool,
/// List currencies
#[arg(short = 'L', long = "list-rates")]
list_rates: Option<String>,
}
async fn setup_key(key: String) -> Result<bool, Box<dyn std::error::Error>>
{
set_api_key(key)?;
let status = get_currencies().await?;
if status == requests::Status::INVALID {
set_api_key("".to_string())?;
println!("Api Key is invalid");
return Ok(false);
} else if status == requests::Status::LIMIT {
set_api_key("".to_string())?;
println!("Can't set up API key due to exceeded API limit");
return Ok(false);
} else if status == requests::Status::ERROR {
set_api_key("".to_string())?;
println!("Can't set up API key due to unknown error");
return Ok(false);
}
Ok(true)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>>
{
let args = Cli::parse();
let all_args =
args.currency_from.is_some() && args.currency_to.is_some() && args.value.is_some();
let wrong_args =
args.currency_from.is_some() && (args.currency_to.is_none() || args.value.is_none());
if args.interactive && (all_args || wrong_args) {
config::println_and_exit("Do not provide codes and value with --interactive")
}
if args.recreate_cache || !config::get_cache_path().exists() {
create_cache()?;
}
match args.api_key {
None => {}
Some(key) => {
let res = setup_key(key)
.await
.expect("Unknown error while setting up key");
if !res {
return Ok(());
}
}
}
if !args.interactive {
if !(cache::get_api_key()
.expect("Error while getting api key")
.len()
> 0)
{
config::println_and_exit("API Key is not set up!");
}
if args.list {
let currencies = cache::list_currencies()?;
for currency in currencies {
println!("{} - {}", currency[0], currency[1]);
}
} else if args.list_rates.is_some() {
let code = args.list_rates.unwrap().clone();
let check = check_code(&code)?;
if !check {
config::println_and_exit(format!("Code {} not found", code).as_str());
}
exchange::update_rate(&code).await;
let rates = cache::list_rates(&code)?;
for rate in rates {
println!("{} to {} rate: {}", code ,rate[0], rate[1]);
}
} else if wrong_args {
config::println_and_exit(
"Not all args specified, provide 'currency from', 'currency to' and 'amount'",
);
} else if all_args {
convert_value(
&args.currency_from.unwrap().to_uppercase(),
&args.currency_to.unwrap().to_uppercase(),
&args.value.unwrap(),
)
.await
}
} else {
interactive().await?;
}
Ok(())
}
async fn interactive() -> Result<(), Box<dyn std::error::Error>>
{
let mut key_setup = cache::get_api_key()
.expect("Error while getting api key")
.len()
> 0;
while !key_setup {
let mut key_string = String::new();
print!("Please enter API Key: ");
std::io::stdout().flush()?;
std::io::stdin()
.read_line(&mut key_string)
.expect("Did not enter a correct string");
setup_key(key_string.trim().to_string())
.await
.expect("Unknown error while setting up key");
key_setup = cache::get_api_key()
.expect("Error while getting api key")
.len()
> 0;
}
let mut code_from: String = String::new();
let mut code_to: String = String::new();
let mut amount: String = String::new();
let mut code_from_check = false;
let mut code_to_check = false;
let mut amount_check = false;
while !code_from_check {
code_from = String::new();
print!("Please enter code of input currency: ");
std::io::stdout().flush()?;
std::io::stdin()
.read_line(&mut code_from)
.expect("Did not enter a correct string");
code_from = code_from.trim().to_uppercase().to_string();
code_from_check = cache::check_code(&code_from)?;
if !code_from_check {
println!("Code {} is unknown", code_from);
}
}
while !code_to_check {
code_to = String::new();
print!("Please enter code of output currency: ");
std::io::stdout().flush()?;
std::io::stdin()
.read_line(&mut code_to)
.expect("Did not enter a correct string");
code_to = code_to.trim().to_uppercase().to_string();
code_to_check = cache::check_code(&code_to)?;
if !code_to_check {
println!("Code {} is unknown", code_to);
}
}
while !amount_check {
amount = String::new();
print!("Please enter amount of input currency: ");
std::io::stdout().flush()?;
std::io::stdin()
.read_line(&mut amount)
.expect("Did not enter a correct string");
amount = amount.trim().to_string();
if amount.parse::<f64>().is_err() {
println!("{} is not a number!", amount)
} else {
amount_check = true
}
}
convert_value(&code_from, &code_to, &amount).await;
Ok(())
}

95
src/requests.rs Normal file
View File

@ -0,0 +1,95 @@
use std::collections::HashMap;
use crate::cache::{self, get_api_key};
use crate::config::get_endpoint;
use serde::Deserialize;
#[derive(PartialEq)]
pub enum Status
{
OK,
INVALID,
LIMIT,
ERROR,
}
#[derive(Deserialize)]
struct CurrencyCodes
{
supported_codes: Vec<[String; 2]>,
}
#[derive(Deserialize)]
struct ConversionRates
{
base_code: String,
time_next_update_unix: u64,
conversion_rates: HashMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct Err
{
#[serde(rename = "error-type")]
error_type: String,
}
pub async fn get_rates(code: &String) -> Result<Status, reqwest::Error>
{
let response = reqwest::get(format!(
"{}{}{}{}",
get_endpoint(),
get_api_key().expect("Error when getting api key from cache"),
"/latest/",
code.to_uppercase()
))
.await?;
if response.status().is_success() {
let response: ConversionRates =
serde_json::from_str(&response.text().await?).expect("Error when deserializng");
cache::add_rates(
response.time_next_update_unix,
&response.base_code,
&response.conversion_rates,
)
.expect("Error while caching response");
return Ok(Status::OK);
} else {
let err: Err =
serde_json::from_str(&response.text().await?).expect("Error when deserializng");
if err.error_type == "invalid-key" {
return Ok(Status::INVALID);
} else if err.error_type == "quota-reached" {
return Ok(Status::LIMIT);
}
}
Ok(Status::ERROR)
}
pub async fn get_currencies() -> Result<Status, reqwest::Error>
{
let response = reqwest::get(format!(
"{}{}{}",
get_endpoint(),
get_api_key().expect("Error when getting api key from cache"),
"/codes"
))
.await?;
if response.status().is_success() {
let codes: CurrencyCodes =
serde_json::from_str(&response.text().await?).expect("Error when deserializng");
for code in codes.supported_codes {
cache::add_code(code).expect("Error when adding code to cache");
}
return Ok(Status::OK);
} else {
let err: Err =
serde_json::from_str(&response.text().await?).expect("Error when deserializng");
if err.error_type == "invalid-key" {
return Ok(Status::INVALID);
} else if err.error_type == "quota-reached" {
return Ok(Status::LIMIT);
}
}
Ok(Status::ERROR)
}