Add GET /api/currencies/:date endpoint for historical EUR/DKK rates
Returns rates for a given date: today uses latest DB row, past dates check DB first then fetch from Frankfurter and persist for caching. Future dates and invalid formats return 400. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,4 +60,8 @@ SET eur = EXCLUDED.eur, dkk = EXCLUDED.dkk, eur_dkk = EXCLUDED.eur_dkk, updated_
|
|||||||
|
|
||||||
-- :name get-latest-currency-rates :? :1
|
-- :name get-latest-currency-rates :? :1
|
||||||
-- :doc Get the most recent currency rates
|
-- :doc Get the most recent currency rates
|
||||||
SELECT eur, dkk, eur_dkk, updated_at FROM currencies ORDER BY rate_date DESC LIMIT 1
|
SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies ORDER BY rate_date DESC LIMIT 1
|
||||||
|
|
||||||
|
-- :name get-currency-rates-by-date :? :1
|
||||||
|
-- :doc Get currency rates for a specific date
|
||||||
|
SELECT rate_date, eur, dkk, eur_dkk, updated_at FROM currencies WHERE rate_date = :rate-date
|
||||||
|
|||||||
@@ -8,18 +8,35 @@
|
|||||||
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
|
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
|
||||||
[java.time Instant LocalDate]))
|
[java.time Instant LocalDate]))
|
||||||
|
|
||||||
(defn- fetch-rates
|
(defn- fetch-rates*
|
||||||
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}."
|
"GET a Frankfurter endpoint. Returns parsed body."
|
||||||
[^HttpClient client base-url]
|
[^HttpClient client url]
|
||||||
(let [url (str base-url "/v1/latest?from=USD&to=EUR,DKK")
|
(let [request (-> (HttpRequest/newBuilder)
|
||||||
request (-> (HttpRequest/newBuilder)
|
|
||||||
(.uri (URI. url))
|
(.uri (URI. url))
|
||||||
(.header "Accept" "application/json")
|
(.header "Accept" "application/json")
|
||||||
(.GET)
|
(.GET)
|
||||||
(.build))
|
(.build))
|
||||||
resp (.send client request (HttpResponse$BodyHandlers/ofString))
|
resp (.send client request (HttpResponse$BodyHandlers/ofString))]
|
||||||
body (json/read-str (.body resp) :key-fn keyword)]
|
(json/read-str (.body resp) :key-fn keyword)))
|
||||||
(:rates body)))
|
|
||||||
|
(defn- fetch-rates
|
||||||
|
"GET /latest?from=USD&to=EUR,DKK from Frankfurter. Returns {:EUR x :DKK y}."
|
||||||
|
[^HttpClient client base-url]
|
||||||
|
(:rates (fetch-rates* client (str base-url "/v1/latest?from=USD&to=EUR,DKK"))))
|
||||||
|
|
||||||
|
(defn fetch-and-persist-for-date!
|
||||||
|
"Fetch rates for a specific date from Frankfurter, persist, and return the rate map."
|
||||||
|
[^HttpClient client base-url query-fn ^LocalDate date]
|
||||||
|
(let [url (str base-url "/v1/" date "?from=USD&to=EUR,DKK")
|
||||||
|
rates (:rates (fetch-rates* client url))]
|
||||||
|
(when (and (:EUR rates) (:DKK rates))
|
||||||
|
(let [eur (bigdec (str (:EUR rates)))
|
||||||
|
dkk (bigdec (str (:DKK rates)))
|
||||||
|
eur-dkk (.divide dkk eur 6 java.math.RoundingMode/HALF_UP)]
|
||||||
|
(query-fn :upsert-currency-rates!
|
||||||
|
{:rate-date date :eur eur :dkk dkk :eur-dkk eur-dkk})
|
||||||
|
(log/info "Frankfurter historical rates for" (str date) "— EUR:" eur "DKK:" dkk)
|
||||||
|
{:rate_date date :eur eur :dkk dkk :eur_dkk eur-dkk}))))
|
||||||
|
|
||||||
(defn- poll!
|
(defn- poll!
|
||||||
"Fetch rates, update the atom, and persist to DB."
|
"Fetch rates, update the atom, and persist to DB."
|
||||||
@@ -85,7 +102,10 @@
|
|||||||
(let [fut (start-poll-loop! client url rates running? query-fn)]
|
(let [fut (start-poll-loop! client url rates running? query-fn)]
|
||||||
{:rates rates
|
{:rates rates
|
||||||
:running? running?
|
:running? running?
|
||||||
:future fut})))
|
:future fut
|
||||||
|
:client client
|
||||||
|
:url url
|
||||||
|
:query-fn query-fn})))
|
||||||
|
|
||||||
(defmethod ig/halt-key! :frankfurter/rates
|
(defmethod ig/halt-key! :frankfurter/rates
|
||||||
[_ {:keys [running? future]}]
|
[_ {:keys [running? future]}]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[clojure.data.json :as json]
|
[clojure.data.json :as json]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
|
[pmagnus.btcdata.frankfurter.rates :as rates]
|
||||||
[pmagnus.btcdata.web.controllers.health :as health]
|
[pmagnus.btcdata.web.controllers.health :as health]
|
||||||
[pmagnus.btcdata.web.middleware.exception :as exception]
|
[pmagnus.btcdata.web.middleware.exception :as exception]
|
||||||
[pmagnus.btcdata.web.middleware.formats :as formats]
|
[pmagnus.btcdata.web.middleware.formats :as formats]
|
||||||
@@ -12,6 +13,8 @@
|
|||||||
[reitit.swagger :as swagger])
|
[reitit.swagger :as swagger])
|
||||||
(:import
|
(:import
|
||||||
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
[io.undertow.websockets.core WebSockets WebSocketChannel]
|
||||||
|
[java.time LocalDate]
|
||||||
|
[java.time.format DateTimeParseException]
|
||||||
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
|
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
|
||||||
|
|
||||||
(defn- price->json [{:keys [price prev-price recorded-at]} rates]
|
(defn- price->json [{:keys [price prev-price recorded-at]} rates]
|
||||||
@@ -95,6 +98,51 @@
|
|||||||
:headers {"Content-Type" "application/json"}
|
:headers {"Content-Type" "application/json"}
|
||||||
:body (json/write-str {:error "No price data yet"})})))
|
:body (json/write-str {:error "No price data yet"})})))
|
||||||
|
|
||||||
|
(defn- rate-row->json [row]
|
||||||
|
(json/write-str {:rate_date (str (:rate_date row))
|
||||||
|
:eur (str (:eur row))
|
||||||
|
:dkk (str (:dkk row))
|
||||||
|
:eur_dkk (str (:eur_dkk row))}))
|
||||||
|
|
||||||
|
(defn- currencies-handler [{:keys [query-fn frankfurter]} req]
|
||||||
|
(let [date-str (get-in req [:path-params :date])]
|
||||||
|
(try
|
||||||
|
(let [date (LocalDate/parse date-str)
|
||||||
|
today (LocalDate/now)]
|
||||||
|
(cond
|
||||||
|
(.isAfter date today)
|
||||||
|
{:status 400
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/write-str {:error "Future date not allowed"})}
|
||||||
|
|
||||||
|
(.isEqual date today)
|
||||||
|
(if-let [row (query-fn :get-latest-currency-rates {})]
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (rate-row->json row)}
|
||||||
|
{:status 404
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/write-str {:error "No rates available yet"})})
|
||||||
|
|
||||||
|
:else
|
||||||
|
(if-let [row (query-fn :get-currency-rates-by-date {:rate-date date})]
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (rate-row->json row)}
|
||||||
|
(if-let [result (rates/fetch-and-persist-for-date!
|
||||||
|
(:client frankfurter) (:url frankfurter)
|
||||||
|
query-fn date)]
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (rate-row->json result)}
|
||||||
|
{:status 502
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/write-str {:error "Failed to fetch rates from Frankfurter"})}))))
|
||||||
|
(catch DateTimeParseException _
|
||||||
|
{:status 400
|
||||||
|
:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/write-str {:error "Invalid date format, use YYYY-MM-DD"})}))))
|
||||||
|
|
||||||
(defn- api-routes [opts]
|
(defn- api-routes [opts]
|
||||||
[["/swagger.json"
|
[["/swagger.json"
|
||||||
{:get {:no-doc true
|
{:get {:no-doc true
|
||||||
@@ -108,6 +156,8 @@
|
|||||||
:middleware []}]
|
:middleware []}]
|
||||||
["/price/latest"
|
["/price/latest"
|
||||||
{:get (fn [req] (latest-price-handler opts req))}]
|
{:get (fn [req] (latest-price-handler opts req))}]
|
||||||
|
["/currencies/:date"
|
||||||
|
{:get (fn [req] (currencies-handler opts req))}]
|
||||||
["/strike/ws"
|
["/strike/ws"
|
||||||
{:get (fn [req] (ws-strike-handler opts req))
|
{:get (fn [req] (ws-strike-handler opts req))
|
||||||
:no-doc true
|
:no-doc true
|
||||||
|
|||||||
Reference in New Issue
Block a user