defmodule NbpReq do
@moduledoc """
HTTP client for the [NBP Web API](https://api.nbp.pl/) built on `Req`.
Provides access to exchange rate tables, single currency rates and gold
prices published by Narodowy Bank Polski.
All functions accept the same query options (at most one of):
* `today: true` - today's data (the API returns 404 if not published yet)
* `date: %Date{}` - data for a specific day
* `last: n` - the last `n` data points
* `from: %Date{}, to: %Date{}` - an inclusive date range (max 367 days)
With no query option, the most recent data is returned.
## Examples
NbpReq.tables(:a)
NbpReq.rates(:a, "USD", last: 10)
NbpReq.gold_prices(from: ~D[2026-01-01], to: ~D[2026-01-31])
## Errors
Functions return `{:error, reason}` where reason is:
* `:not_found` - no data for the requested day/range (HTTP 404)
* `{:bad_request, message}` - invalid query, e.g. range over 367 days (HTTP 400)
* `{:http_error, status, body}` - any other unexpected HTTP status
* an exception struct (e.g. `Req.TransportError`) for connection errors
"""
alias NbpReq.{Client, CurrencyRates, GoldPrice, Table}
@tables [:a, :b, :c]
@typedoc "Exchange rate table identifier."
@type table :: :a | :b | :c
@typedoc "Query options shared by all endpoints."
@type query_opts :: [
today: boolean(),
date: Date.t(),
last: pos_integer(),
from: Date.t(),
to: Date.t(),
req_options: keyword()
]
@doc """
Fetches complete exchange rate tables.
Returns a list of `NbpReq.Table` structs (the API always wraps tables in
a list, even for a single day).
## Examples
{:ok, [table]} = NbpReq.tables(:a)
{:ok, tables} = NbpReq.tables(:a, last: 5)
"""
@spec tables(table(), query_opts()) :: {:ok, [Table.t()]} | {:error, term()}
def tables(table \\ :a, opts \\ []) do
path = "/exchangerates/tables/#{table_segment!(table)}" <> Client.path_suffix(opts)
with {:ok, body} <- Client.get(path, opts) do
{:ok, Enum.map(body, &Table.from_json/1)}
end
end
@doc """
Fetches exchange rates for a single currency.
`code` is a three-letter ISO 4217 currency code (case insensitive).
Returns a `NbpReq.CurrencyRates` struct with one `NbpReq.Rate` per
quoted day.
## Examples
{:ok, currency} = NbpReq.rates(:a, "USD")
{:ok, currency} = NbpReq.rates(:c, "EUR", last: 10)
"""
@spec rates(table(), String.t(), query_opts()) ::
{:ok, CurrencyRates.t()} | {:error, term()}
def rates(table, code, opts \\ []) do
path =
"/exchangerates/rates/#{table_segment!(table)}/#{code_segment!(code)}" <>
Client.path_suffix(opts)
with {:ok, body} <- Client.get(path, opts) do
{:ok, CurrencyRates.from_json(body)}
end
end
@doc """
Fetches gold prices (1g of 1000 fineness gold, in PLN).
Returns a list of `NbpReq.GoldPrice` structs.
## Examples
{:ok, [price]} = NbpReq.gold_prices()
{:ok, prices} = NbpReq.gold_prices(last: 30)
"""
@spec gold_prices(query_opts()) :: {:ok, [GoldPrice.t()]} | {:error, term()}
def gold_prices(opts \\ []) do
path = "/cenyzlota" <> Client.path_suffix(opts)
with {:ok, body} <- Client.get(path, opts) do
{:ok, Enum.map(body, &GoldPrice.from_json/1)}
end
end
defp code_segment!(code) when is_binary(code) do
downcased = String.downcase(code)
unless downcased =~ ~r/^[a-z]{3}$/ do
raise ArgumentError,
"currency code must be a three-letter ISO 4217 code, got: #{inspect(code)}"
end
downcased
end
defp code_segment!(other) do
raise ArgumentError,
"currency code must be a three-letter ISO 4217 code, got: #{inspect(other)}"
end
defp table_segment!(table) when table in @tables, do: Atom.to_string(table)
defp table_segment!(other) do
raise ArgumentError, "table must be one of #{inspect(@tables)}, got: #{inspect(other)}"
end
end