Numista API

This commit is contained in:
2022-07-20 19:59:33 +02:00
parent 15a0f5c13c
commit 179fe5318f
17 changed files with 411 additions and 8 deletions
Generated
+1
View File
@@ -1275,6 +1275,7 @@ dependencies = [
"dotenv",
"log",
"once_cell",
"regex",
"reqwest",
"sea-orm",
"serde",
+1
View File
@@ -19,6 +19,7 @@ chrono = { version = "^0.4", features = ["serde"] }
dotenv = "^0.15"
log = "^0"
once_cell = "^1"
regex = "^1"
reqwest = { version = "^0.11", features = ["json"] }
sea-orm = { version = "^0", features = [
"sqlx-postgres",
+5
View File
@@ -9,6 +9,11 @@ CREATE TABLE coins
max_year integer,
obverse_thumbnail text,
reverse_thumbnail text,
weight real NOT NULL,
diameter real NOT NULL,
thickness real NOT NULL,
material character(3) NOT NULL,
purity real NOT NULL,
PRIMARY KEY (id)
);
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1235" height="650" viewBox="0 0 36.1 19" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path id="a" transform="scale(.58515)" d="m0-1 0.58779 1.809-1.5388-1.118h1.9021l-1.5388 1.118z" fill="#fff"/>
<g id="b">
<use x=".9025" y="0.8501" xlink:href="#a"/>
<use x="2.7075" y="0.8501" xlink:href="#a"/>
<use x="4.5125" y="0.8501" xlink:href="#a"/>
<use x="6.3175" y="0.8501" xlink:href="#a"/>
<use x="8.1225" y="0.8501" xlink:href="#a"/>
<use x="9.9275" y="0.8501" xlink:href="#a"/>
<use x="11.7325" y="0.8501" xlink:href="#a"/>
<use x="13.5375" y="0.8501" xlink:href="#a"/>
</g>
<g id="c">
<use xlink:href="#b"/>
<use y="1.705" xlink:href="#b"/>
<use y="3.41" xlink:href="#b"/>
<use y="5.115" xlink:href="#b"/>
<use y="6.82" xlink:href="#b"/>
<use y="8.525" xlink:href="#b"/>
</g>
</defs>
<rect width="36.1" height="19" fill="#BB133E"/>
<path d="m0 2.1922h36.1m-36.1 2.923h36.1m-36.1 2.923h36.1m-36.1 2.923h36.1m-36.1 2.923h36.1m-36.1 2.923h36.1" stroke="#FFF" stroke-width="1.4615"/>
<rect width="14.44" height="10.23" fill="#002664"/>
<use xlink:href="#c"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1235" height="650" viewBox="0 0 7410 3900" xmlns:v="https://vecta.io/nano"><path fill="#b22234" d="M0 0h7410v3900H0z"/><path d="M0,450H7410m0,600H0m0,600H7410m0,600H0m0,600H7410m0,600H0" stroke="#fff" stroke-width="300"/><path fill="#3c3b6e" d="M0 0h2964v2100H0z"/><g fill="#fff"><g id="A"><g id="B"><g id="C"><g id="D"><path id="E" d="M247 90l70.534 217.082-184.661-134.164h228.254L176.466 307.082z"/><use xlink:href="#E" y="420"/><use xlink:href="#E" y="840"/><use xlink:href="#E" y="1260"/></g><use xlink:href="#E" y="1680"/></g><use xlink:href="#D" x="247" y="210"/></g><use xlink:href="#B" x="494"/></g><use xlink:href="#A" x="988"/><use xlink:href="#B" x="1976"/><use xlink:href="#C" x="2470"/></g></svg>

After

Width:  |  Height:  |  Size: 800 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 601 601" xmlns="http://www.w3.org/2000/svg">
<circle cx="300.5" cy="300.5" r="300" fill="#1a1e3b"/>
<path d="m300.5 0.5 67.36 207.29 217.96 8e-3 -176.33 128.12 67.345 207.29-176.34-128.1-176.34 128.1 67.345-207.29-176.33-128.12 217.96-8e-3z" fill="#fff" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

+23
View File
@@ -0,0 +1,23 @@
.flag {
height: 1.4em;
vertical-align: bottom;
border-radius: 4px;
}
.coins {
width: 100%;
}
td.thumbnail {
width: 90px;
}
img.thumbnail {
height: 90px;
width: 90px;
}
caption {
caption-side: bottom;
text-align: left;
}
+2 -1
View File
@@ -1,3 +1,4 @@
@use 'generic';
@use 'layout';
@use 'nav';
@use 'nav';
@use 'guide';
+155
View File
@@ -1,3 +1,10 @@
use crate::db::get_db_pool;
use crate::orm::coins;
use regex::Regex;
use sea_orm::{prelude::*, query::*, ActiveValue, DatabaseConnection, EntityTrait};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
// https://en.numista.com/api/doc/index.php
//{
// "id": 38581,
@@ -12,3 +19,151 @@
// "obverse_thumbnail": "https://en.numista.com/catalogue/photos/abkhazia/98-180.jpg",
// "reverse_thumbnail": "https://en.numista.com/catalogue/photos/abkhazia/99-180.jpg"
//},
#[derive(Debug, Deserialize, Serialize)]
pub struct Coin {
pub id: i32,
pub title: String,
pub issuer: Issuer,
pub min_year: i32,
pub max_year: i32,
pub obverse: Engraving,
pub reverse: Engraving,
pub weight: f32,
pub size: f32,
pub thickness: f32,
pub composition: Composition,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Issuer {
pub code: String,
pub name: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Engraving {
pub thumbnail: String,
}
#[derive(Debug, Serialize)]
pub struct Composition {
pub material: String,
pub purity: Option<f32>,
}
impl Composition {
pub fn to_ticker(&self) -> String {
match self.material.as_str() {
"Gold" => "XAU",
"Silver" => "XAG",
_ => unreachable!(),
}
.to_owned()
}
}
impl<'de> Deserialize<'de> for Composition {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Debug, Deserialize)]
struct CompositionWrapper {
text: String,
}
let wrapper: CompositionWrapper = Deserialize::deserialize(deserializer)?;
let regex = Regex::new(r"(Gold|Silver)(?:\s\(\.(\d{3})\))").unwrap();
let caps = regex.captures(&wrapper.text).unwrap();
Ok(Composition {
material: caps.get(1).unwrap().as_str().to_owned(),
purity: caps
.get(2)
.map(|v| v.as_str().parse::<u32>().unwrap() as f32 / 1000.0),
})
}
}
/// Numista IDs for relevant US coins.
pub const USA_COINS: [u32; 15] = [
1492, 10085, 5830, 10591, 30785, 51, 4455, 3573, 5580, 54, 45, 52, 2835, 943, 10865,
];
pub async fn init_coins(db: &DatabaseConnection) {
let api_key = std::env::var("NUMISTA_API_KEY").expect("NUMISTA_API_KEY must be set.");
let new_coins: Vec<coins::Model> = Vec::with_capacity(USA_COINS.len());
let usa_coins = coins::Entity::find()
.filter(coins::Column::NumistaId.is_in(USA_COINS))
.all(get_db_pool())
.await
.expect("Couldn't query coins from database")
.into_iter()
.map(|coin| (coin.numista_id, coin))
.collect::<HashMap<_, _>>();
for coin in USA_COINS {
if !usa_coins.contains_key(&(coin as i32)) {
let coin = reqwest::Client::new()
.get(format!(
"https://api.numista.com/api/v3/types/{}?lang=en",
coin
))
.header(reqwest::header::EXPECT, "application/json")
.header("Numista-API-Key", api_key.to_owned())
.send()
.await
.expect("Failed to query Numista API.")
.json::<Coin>()
.await
.expect("Failed to deserialize Numista API result.");
log::debug!("Pulled new Numista coin: {:?}", coin);
let new_coin = coins::ActiveModel {
id: ActiveValue::NotSet,
numista_id: ActiveValue::set(coin.id),
issuer: ActiveValue::set(coin.issuer.code),
title: ActiveValue::set(coin.title),
min_year: ActiveValue::set(Some(coin.min_year)),
max_year: ActiveValue::set(Some(coin.max_year)),
obverse_thumbnail: ActiveValue::set(Some(coin.obverse.thumbnail)),
reverse_thumbnail: ActiveValue::set(Some(coin.reverse.thumbnail)),
weight: ActiveValue::set(coin.weight),
diameter: ActiveValue::set(coin.size),
thickness: ActiveValue::set(coin.thickness),
material: ActiveValue::set(coin.composition.to_ticker()),
purity: ActiveValue::set(coin.composition.purity.unwrap_or(1.0)),
}
.insert(db)
.await
.expect("Failed to insert a new coin.");
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_composition_deserialize() {
let silver = r#"{ "text": "Silver (.400)" }"#;
let silver_comp: super::Composition =
serde_json::from_str(silver).expect("Failed to parse silver");
assert_eq!(silver_comp.material, "Silver");
assert_eq!(silver_comp.purity, Some(0.4));
let gold = r#"{ "text": "Gold (.900)" }"#;
let gold_comp: super::Composition =
serde_json::from_str(gold).expect("Failed to parse gold");
assert_eq!(gold_comp.material, "Gold");
assert_eq!(gold_comp.purity, Some(0.9));
}
}
+1
View File
@@ -0,0 +1 @@
pub mod usa;
+50
View File
@@ -0,0 +1,50 @@
use crate::coin::USA_COINS;
use crate::db::get_db_pool;
use crate::middleware::ClientCtx;
use crate::orm::coins;
use actix_web::{error, get, Error, Responder};
use askama_actix::Template;
use sea_orm::{prelude::*, EntityTrait};
use std::collections::HashMap;
#[derive(Template)]
#[template(path = "part/coin.html")]
pub struct CoinTemplate<'a> {
pub client: &'a ClientCtx,
pub coin: &'a coins::Model,
}
#[derive(Template)]
#[template(path = "guides/usa.html")]
pub struct GuideTemplate {
pub client: ClientCtx,
pub coins: HashMap<i32, coins::Model>,
}
impl GuideTemplate {
pub fn show_coin(&self, id: i32) -> String {
match self.coins.get(&id) {
Some(coin) => CoinTemplate {
client: &self.client,
coin: &coin,
}
.render()
.unwrap_or("<tr>Failed to render coin row.</tr>".to_string()),
None => format!("<tr>Requested coin `N{}` not in database.</tr>", id),
}
}
}
#[get("/guide/usa")]
pub async fn view_index(client: ClientCtx) -> Result<impl Responder, Error> {
let coins = coins::Entity::find()
.filter(coins::Column::NumistaId.is_in(USA_COINS))
.all(get_db_pool())
.await
.map_err(error::ErrorInternalServerError)?
.into_iter()
.map(|coin| (coin.numista_id, coin))
.collect::<HashMap<_, _>>();
Ok(GuideTemplate { client, coins })
}
+7 -1
View File
@@ -1,8 +1,11 @@
use crate::coin::init_coins;
use crate::db::init_db_pool;
use crate::price::init_spot_prices;
use actix_web::{web::Data, App, HttpServer};
mod coin;
mod db;
mod guide;
mod middleware;
mod orm;
mod price;
@@ -13,7 +16,8 @@ async fn main() -> std::io::Result<()> {
dotenv::dotenv().expect("Could not open .env file");
let db = init_db_pool().await;
let prices = init_spot_prices(db).await;
init_spot_prices(db).await;
init_coins(db).await;
// Recast db in a Data wrapper.
let db = Data::new(db);
@@ -23,6 +27,8 @@ async fn main() -> std::io::Result<()> {
.app_data(Data::clone(&db))
.service(crate::web::view_css)
.service(crate::web::view_index)
.service(crate::web::view_flag)
.service(crate::guide::usa::view_index)
})
.bind(("127.0.0.1", 8080))?
.run()
+7
View File
@@ -18,6 +18,13 @@ pub struct Model {
pub obverse_thumbnail: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub reverse_thumbnail: Option<String>,
weight: f32,
diameter: f32,
thickness: f32,
material: String,
purity: f32,
}
#[derive(Copy, Clone, Debug, EnumIter)]
+2 -5
View File
@@ -1,12 +1,9 @@
use crate::{db::get_db_pool, orm::spots};
use actix_web::web::Data;
use crate::orm::spots;
use chrono::NaiveDateTime;
use sea_orm::{prelude::*, query::*, ActiveValue, DatabaseConnection, EntityTrait};
use serde::Deserialize;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Const for parsing atomic values to real currency.
pub const ATOMIC_TO_CURRENCY: f32 = 0.001;
/// Mathematical constant for convering 1 troy ounce to grams.
pub const TROY_OUNCE_TO_GRAM: f32 = 31.1035;
@@ -229,7 +226,7 @@ pub async fn store_spot_prices(db: &DatabaseConnection, api: ApiData) -> Result<
};
spots::Entity::insert_many(vec![copper, silver, gold])
.exec(get_db_pool())
.exec(db)
.await?;
Ok(())
+11 -1
View File
@@ -1,7 +1,8 @@
use crate::middleware::ClientCtx;
use actix_files::NamedFile;
use actix_web::{get, Error, Responder};
use actix_web::{get, Error, HttpRequest, Responder, Result};
use askama_actix::Template;
use std::path::PathBuf;
#[derive(Template)]
#[template(path = "index.html")]
@@ -19,3 +20,12 @@ pub async fn view_css() -> Result<NamedFile, Error> {
let path = "public/assets/style.css";
Ok(NamedFile::open(path)?)
}
#[get("/images/flags/{filename:.*}")]
pub async fn view_flag(req: HttpRequest) -> Result<NamedFile> {
let filename: PathBuf = req.match_info().query("filename").parse().unwrap();
Ok(NamedFile::open(format!(
"public/images/flags/{}",
filename.to_str().expect("No filename.")
))?)
}
+97
View File
@@ -0,0 +1,97 @@
{% extends "container/public.html" %}
{% block content %}
<section>
<h1>United States Constitutional Coins</h1>
<p>
On April 2nd, 1972, the 2nd United States Congress passed the Coinage Act of 1972.
This officially defined the currency of the United States as gold, silver, and copper.
The Gold Eagle equaled 247 ½ grains (17.5g), the Silver Dollar equaled 371 ¼ grains (27.0g), and the Copper Cent
was 11 pennyweights (17.1g).
</p>
<p>This would be the definition of money in the country until February 12th, 1873.</p>
</section>
<section>
<h2>
Post-Civil War
<img class="flag" src="/images/flags/usa-48.svg" />
</h2>
<table class="coins">
<caption>
Though silver had been officially demonetized, it remained in currency for almost a century after.
</caption>
<thead>
<tr>
<th colspan="2">
<!-- Portrait -->
</th>
<th></th>
</tr>
</thead>
<tbody>
{{ self.show_coin(1492)|safe }}
{{ self.show_coin(10085)|safe }}
{{ self.show_coin(5830)|safe }}
{{ self.show_coin(10591)|safe }}
{{ self.show_coin(30785)|safe }}
{{ self.show_coin(51)|safe }}
{{ self.show_coin(4455)|safe }}
{{ self.show_coin(3573)|safe }}
{{ self.show_coin(5580)|safe }}
{{ self.show_coin(54)|safe }}
</tbody>
</table>
</section>
<section>
<h2>
World War 2 Coins
<img class="flag" src="/images/flags/usa-roundel.svg" />
</h2>
<table class="coins">
<caption>
During the Second World War, nickel became a strategic material used as an alloy in materiel and
industrial equipment. The U.S. would produce Nickels with 35% silver content between 1942 and 1945.
</caption>
<thead>
<tr>
<th colspan="2">
<!-- Portrait -->
</th>
<th></th>
</tr>
</thead>
<tbody>
{{ self.show_coin(45)|safe }}
</tbody>
</table>
</section>
<section>
<h2>
Post-War Coins
<img class="flag" src="/images/flags/usa-50.svg" />
</h2>
<table class="coins">
<caption>
The U.S. kept silver in its currency until the year before President Nixon ended Bretton-Woods and
officially removed the U.S. Dollar from the gold standard.
</caption>
<thead>
<tr>
<th colspan="2">
<!-- Portrait -->
</th>
<th></th>
</tr>
</thead>
<tbody>
{{ self.show_coin(52)|safe }}
{{ self.show_coin(2835)|safe }}
{{ self.show_coin(943)|safe }}
{{ self.show_coin(10865)|safe }}
</tbody>
</table>
</section>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
<tr class="coin">
<td class="thumbnail">
{% if let Some(thumbnail) = coin.obverse_thumbnail %}
<img class="thumbnail observe" src="{{ thumbnail }}" />
{% endif %}
</td>
<td class="thumbnail">
{% if let Some(thumbnail) = coin.reverse_thumbnail %}
<img class="thumbnail reverse" src="{{ thumbnail }}" />
{% endif %}
</td>
<td>
<h3>{{ coin.title }}</h3>
</td>
</tr>