Skip to main content

lib/nbp_req.ex

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