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:
2026-03-06 12:36:22 +01:00
co-authored by Claude Opus 4.6
parent 11a1fc6b8f
commit e61f38d4f3
3 changed files with 84 additions and 10 deletions
+5 -1
View File
@@ -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
+29 -9
View File
@@ -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