diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70b2735..a1855d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-22.04 + runs-on: ubuntu-26.04 env: MIX_ENV: test @@ -18,12 +18,12 @@ jobs: matrix: include: - pair: - elixir: 1.14.2 - otp: 25.0 + elixir: 1.20 + otp: 29.0 lint: lint steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: @@ -53,10 +53,10 @@ jobs: matrix: include: - pair: - elixir: 1.9.0 - otp: 21.3.8 + elixir: 1.15 + otp: 24.3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: diff --git a/README.md b/README.md index 4cc6bf5..f4384ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ Tzdata ====== -[![Build -Status](https://travis-ci.org/lau/tzdata.svg?branch=master)](https://travis-ci.org/lau/tzdata) +[![Build Status](https://github.com/lau/tzdata/actions/workflows/ci.yml/badge.svg)](https://github.com/lau/tzdata/actions/workflows/ci.yml) [![Hex.pm version](https://img.shields.io/hexpm/v/tzdata.svg)](http://hex.pm/packages/tzdata) [![Hex.pm downloads](https://img.shields.io/hexpm/dt/tzdata.svg)](https://hex.pm/packages/tzdata) @@ -23,14 +22,24 @@ iex> Tzdata.tzdata_version ## Getting started -To use the Tzdata library with Elixir 1.8+, add it to the dependencies in your mix file: +To use the Tzdata library with Elixir 1.15+, add it to the dependencies in your mix file. +Tzdata also needs an HTTP client to download timezone data updates: add either +[Req](https://hex.pm/packages/req) or [Hackney](https://hex.pm/packages/hackney) +(see the "HTTP client" section below): ```elixir defp deps do - [ {:tzdata, "~> 1.1"}, ] + [ + {:tzdata, "~> 1.2"}, + {:req, "~> 0.7"}, + ] end ``` +Instead of Req, you can use `{:hackney, "~> 1.17 or ~> 4.0"}`. If automatic data +updates are disabled (see the "Automatic data updates" section below), no HTTP +client is needed. + In your application you can choose to globally configure Elixir to use Tzdata. This can be done by putting the following line in the config file of your application: @@ -97,9 +106,38 @@ For use with [Calendar](https://github.com/lau/calendar) you can still specify tzdata ~> 0.1.7 in your mix.exs file in case you experience problems using version ~> 0.5.20 -## Hackney dependency and security +## HTTP client + +Tzdata uses an HTTP client to poll IANA for new timezone data releases and download them. +Both [Req](https://hex.pm/packages/req) and [Hackney](https://hex.pm/packages/hackney) are +supported out of the box as optional dependencies — add one of them to your mix file: + +```elixir +{:req, "~> 0.7"} +``` + +or + +```elixir +{:hackney, "~> 1.17 or ~> 4.0"} +``` + +If both are present, Req is preferred. If neither is available and automatic data updates +are enabled, an error is raised when Tzdata tries to fetch updates. + +The client can also be selected explicitly with the `http_client` config option: + +```elixir +config :tzdata, :http_client, Tzdata.HTTPClient.Hackney +``` + +The same option can be used to supply a custom client: any module implementing the +`Tzdata.HTTPClient` behaviour (its `get/3` and `head/3` callbacks). -Tzdata depends on Hackney in order to do HTTPS requests to get new updates. This is done because Erlang's built in HTTP client `httpc` does not verify SSL certificates when doing HTTPS requests. Hackney verifies the certificate of IANA when getting new tzdata releases from IANA. +An HTTP client that verifies SSL certificates is used instead of Erlang's built in +HTTP client `httpc`, because `httpc` does not verify SSL certificates when doing +HTTPS requests. Req and Hackney verify the certificate of IANA when getting new +tzdata releases from IANA. ## Documentation diff --git a/lib/tzdata/data_loader.ex b/lib/tzdata/data_loader.ex index 192dd54..7ba0ed5 100644 --- a/lib/tzdata/data_loader.ex +++ b/lib/tzdata/data_loader.ex @@ -81,10 +81,10 @@ defmodule Tzdata.DataLoader do defp do_latest_file_size_by_head({:error, error}), do: {:error, error} - defp do_latest_file_size_by_head({_tag, resp_code, _headers}) when resp_code != 200, + defp do_latest_file_size_by_head({:ok, {resp_code, _headers}}) when resp_code != 200, do: {:error, :did_not_get_ok_response} - defp do_latest_file_size_by_head({_tag, _resp_code, headers}) do + defp do_latest_file_size_by_head({:ok, {_resp_code, headers}}) do headers |> content_length_from_headers end @@ -170,7 +170,5 @@ defmodule Tzdata.DataLoader do defp data_dir, do: Tzdata.Util.data_dir() - defp http_client() do - Application.get_env(:tzdata, :http_client, Tzdata.HTTPClient.Hackney) - end + defp http_client, do: Tzdata.Util.http_client!() end diff --git a/lib/tzdata/http_client/hackney.ex b/lib/tzdata/http_client/hackney.ex index c6b75e0..c212b19 100644 --- a/lib/tzdata/http_client/hackney.ex +++ b/lib/tzdata/http_client/hackney.ex @@ -1,9 +1,9 @@ -defmodule Tzdata.HTTPClient.Hackney do - @moduledoc false +if Code.ensure_loaded?(:hackney) do + defmodule Tzdata.HTTPClient.Hackney do + @moduledoc false - @behaviour Tzdata.HTTPClient + @behaviour Tzdata.HTTPClient - if Code.ensure_loaded?(:hackney) do @impl true def get(url, headers, options) do with {:ok, status, headers, result} <- :hackney.get(url, headers, "", options), @@ -27,29 +27,5 @@ defmodule Tzdata.HTTPClient.Hackney do {:ok, {status, headers}} end end - else - @message """ - missing :hackney dependency - - Tzdata requires a HTTP client in order to automatically update timezone - database. - - In order to use the built-in adapter based on Hackney HTTP client, add the - following to your mix.exs dependencies list: - - {:hackney, "~> 4.0"} - - See README for more information. - """ - - @impl true - def get(_url, _headers, _options) do - raise @message - end - - @impl true - def head(_url, _headers, _options) do - raise @message - end end end diff --git a/lib/tzdata/http_client/req.ex b/lib/tzdata/http_client/req.ex new file mode 100644 index 0000000..f631aa7 --- /dev/null +++ b/lib/tzdata/http_client/req.ex @@ -0,0 +1,52 @@ +if Code.ensure_loaded?(Req) do + defmodule Tzdata.HTTPClient.Req do + @moduledoc false + + @behaviour Tzdata.HTTPClient + + @impl true + def get(url, headers, options) do + case Req.get(url, req_options(headers, options)) do + {:ok, %Req.Response{status: status, headers: resp_headers, body: body}} -> + {:ok, {status, normalize_headers(resp_headers), body}} + + {:error, reason} -> + {:error, reason} + end + end + + @impl true + def head(url, headers, options) do + case Req.head(url, req_options(headers, options)) do + {:ok, %Req.Response{status: status, headers: resp_headers}} -> + {:ok, {status, normalize_headers(resp_headers)}} + + {:error, reason} -> + {:error, reason} + end + end + + defp req_options(headers, options) do + [ + headers: headers, + redirect: Keyword.get(options, :follow_redirect, false), + # The downloaded tar.gz must be returned as-is, so don't send + # accept-encoding (on by default in Req < 0.7) and disable Req's + # automatic decompression and body decoding. + compressed: false, + raw: true + ] + end + + # Req (v0.4+) returns headers as a map of lists. + defp normalize_headers(headers) when is_map(headers) do + for {name, values} <- headers, value <- values, do: {name, value} + end + + # Req has a `legacy_headers_as_lists: true` option, for + # backwards-compatibility. + defp normalize_headers(headers) when is_list(headers) do + headers + end + end +end diff --git a/lib/tzdata/tzdata_app.ex b/lib/tzdata/tzdata_app.ex index d060e95..6067585 100644 --- a/lib/tzdata/tzdata_app.ex +++ b/lib/tzdata/tzdata_app.ex @@ -5,10 +5,17 @@ defmodule Tzdata.App do def start(_type, _args) do children = [Tzdata.EtsHolder] - children = case Application.fetch_env(:tzdata, :autoupdate) do - {:ok, :enabled} -> children ++ [Tzdata.ReleaseUpdater] - {:ok, :disabled} -> children - end + + children = + case Application.fetch_env!(:tzdata, :autoupdate) do + :enabled -> + # Getting the HTTP client also ensures that it exists and raises if not. + Tzdata.Util.http_client!() + children ++ [Tzdata.ReleaseUpdater] + + :disabled -> + children + end {:ok, pid} = Supervisor.start_link(children, strategy: :one_for_one) diff --git a/lib/tzdata/util.ex b/lib/tzdata/util.ex index 76d5bdd..790454d 100644 --- a/lib/tzdata/util.ex +++ b/lib/tzdata/util.ex @@ -581,6 +581,47 @@ defmodule Tzdata.Util do end end + def http_client! do + case Application.get_env(:tzdata, :http_client) do + nil -> + cond do + Code.ensure_loaded?(Tzdata.HTTPClient.Req) -> Tzdata.HTTPClient.Req + Code.ensure_loaded?(Tzdata.HTTPClient.Hackney) -> Tzdata.HTTPClient.Hackney + true -> nil + end + + module -> + if Code.ensure_loaded?(module) do + module + end + end + |> check_http_client!() + end + + defp check_http_client!(module) do + if !(module && Code.ensure_loaded?(module)) do + raise """ + missing dependency (or custom HTTP client) + + Tzdata requires a HTTP client in order to automatically update timezone + database. + + In order to use a built-in HTTP Client adapter, add the + following to your mix.exs dependencies list: + + {:req, "~> 0.7"} + + or + + {:hackney, "~> 4.0"} + + See README for more information, including how to use a custom HTTP Client. + """ + end + + module + end + if @elixir_newer_1_12 do # See PR #154. # Elixir 1.17 and 1.18 deprecated using decreasing Ranges without explicit steps. diff --git a/mix.exs b/mix.exs index 0cc8ba1..0b20f26 100644 --- a/mix.exs +++ b/mix.exs @@ -1,14 +1,14 @@ defmodule Tzdata.Mixfile do use Mix.Project - @version "1.1.4" + @version "1.2.0" def project do [ app: :tzdata, name: "tzdata", version: @version, - elixir: "~> 1.9", + elixir: "~> 1.15", package: package(), description: description(), deps: deps(), @@ -27,7 +27,8 @@ defmodule Tzdata.Mixfile do defp deps do [ - {:hackney, "~> 1.17 or ~> 4.0"}, + {:req, "~> 0.5", optional: true}, + {:hackney, "~> 1.17 or ~> 4.0", optional: true}, {:ex_doc, "~> 0.21", only: :dev, runtime: false} ] end @@ -43,8 +44,7 @@ defmodule Tzdata.Mixfile do defp env do [ autoupdate: :enabled, - data_dir: nil, - http_client: Tzdata.HTTPClient.Hackney + data_dir: nil ] end diff --git a/mix.lock b/mix.lock index c278e8a..d54f934 100644 --- a/mix.lock +++ b/mix.lock @@ -2,15 +2,24 @@ "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "req": {:hex, :req, "0.7.1", "86271f9e29dca83d382222428ced09ba03bad21e24888c50dd4527553d85f642", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "254638b15ceb9a2624d15aff13bf7903ea2e95cd4b9c1aa18da1fb06e1086b50"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, }