diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b2e32..efe2c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add `mix hammer.install` Igniter task (`mix igniter.install hammer`) that generates a `MyApp.RateLimit` module and adds it to the supervision tree. Supports `--backend ets|atomic|redis`; `redis` also adds the `hammer_backend_redis` dependency. Igniter is an optional dependency. (#157) + ## 7.4.1 - 2026-08-28 - Fix `TokenBucket` ETS backend refill using whole-second resolution, which capped sustained throughput at `capacity` tokens/sec when `refill_rate > capacity`. Refill now uses millisecond resolution. (#192) diff --git a/README.md b/README.md index 20e7a1b..6cf9201 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,23 @@ def deps do end ``` +### Installing with Igniter + +If you use [Igniter](https://hexdocs.pm/igniter), a single command adds the dependency, generates a +`MyApp.RateLimit` module and adds it to your application's supervision tree: + +```sh +mix igniter.install hammer +``` + +Pass `--backend atomic` or `--backend redis` to pick a backend other than the default `:ets`. +The `redis` option also adds the [`hammer_backend_redis`](https://hex.pm/packages/hammer_backend_redis) +dependency. If Hammer is already in your project, run `mix hammer.install` directly. + +Hammer has no global configuration: each limit is defined where it is checked, with +`MyApp.RateLimit.hit(key, scale, limit)`. See the [Tutorial](https://hexdocs.pm/hammer/tutorial.html) +for how to compose keys and use the limiter as a plug. + ## Available Backends Atomic backends are single-node rate limiting but will be the fastest option. diff --git a/lib/mix/tasks/hammer.install.ex b/lib/mix/tasks/hammer.install.ex new file mode 100644 index 0000000..5ea3b0b --- /dev/null +++ b/lib/mix/tasks/hammer.install.ex @@ -0,0 +1,142 @@ +defmodule Mix.Tasks.Hammer.Install.Docs do + @moduledoc false + + @spec short_doc() :: String.t() + def short_doc do + "Installs Hammer: generates a rate limiter module and adds it to the supervision tree" + end + + @spec example() :: String.t() + def example do + "mix hammer.install --backend ets" + end + + @spec long_doc() :: String.t() + def long_doc do + """ + #{short_doc()} + + Generates `MyApp.RateLimit` (`use Hammer, backend: ...`) and adds it as a child of + the application supervisor. Hammer has no global configuration: limits are defined at + each call site with `MyApp.RateLimit.hit(key, scale, limit)`, so after installing, + see the Hammer tutorial for how to compose keys and wire the limiter into a plug. + + ## Example + + ```sh + #{example()} + ``` + + ## Options + + * `--backend` or `-b` - The backend to use. One of `ets` (default), `atomic` or `redis`. + `redis` also adds the `hammer_backend_redis` dependency. + """ + end +end + +if Code.ensure_loaded?(Igniter) do + defmodule Mix.Tasks.Hammer.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()}" + + @moduledoc __MODULE__.Docs.long_doc() + + use Igniter.Mix.Task + + alias Igniter.Project.Deps + + @backends ~w(ets atomic redis) + @redis_dep {:hammer_backend_redis, "~> 7.0"} + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :hammer, + example: __MODULE__.Docs.example(), + only: nil, + schema: [backend: :string], + defaults: [backend: "ets"], + aliases: [b: :backend] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + backend = igniter.args.options[:backend] + + if backend in @backends do + install(igniter, backend) + else + Igniter.add_issue( + igniter, + "Unknown backend #{inspect(backend)} for `mix hammer.install`. " <> + "Expected one of: #{Enum.join(@backends, ", ")}." + ) + end + end + + defp install(igniter, backend) do + module = Igniter.Project.Module.module_name(igniter, "RateLimit") + app_module = Igniter.Project.Module.module_name_prefix(igniter) + + igniter + |> add_backend_dep(backend) + |> Igniter.Project.Module.find_and_update_or_create_module( + module, + module_body(app_module, module, backend), + &{:ok, &1} + ) + |> Igniter.Project.Application.add_new_child({module, child_opts(backend)}) + end + + defp add_backend_dep(igniter, "redis"), + do: Deps.add_dep(igniter, @redis_dep, on_exists: :skip) + + defp add_backend_dep(igniter, _backend), do: igniter + + defp module_body(app_module, module, backend) do + """ + @moduledoc \"\"\" + Rate limiter for #{inspect(app_module)}, backed by `#{backend_option(backend)}`. + + Check and increment a limit with `hit/3`, for example allowing 10 requests per second: + + #{inspect(module)}.hit("some-key", :timer.seconds(1), 10) + + See `Hammer` for the full API and the Hammer tutorial for usage patterns. + \"\"\" + + use Hammer, backend: #{backend_option(backend)} + """ + end + + defp backend_option("ets"), do: ":ets" + defp backend_option("atomic"), do: ":atomic" + defp backend_option("redis"), do: "Hammer.Redis" + + defp child_opts("redis"), do: [url: "redis://localhost:6379"] + + defp child_opts(_backend) do + {:code, quote(do: [clean_period: :timer.minutes(1)])} + end + end +else + defmodule Mix.Tasks.Hammer.Install do + @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" + + @moduledoc __MODULE__.Docs.long_doc() + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + Mix.shell().error(""" + The task 'hammer.install' requires igniter. Please install igniter and try again. + + For more information, see: https://hexdocs.pm/igniter/readme.html#installation + """) + + exit({:shutdown, 1}) + end + end +end diff --git a/mix.exs b/mix.exs index b9b33de..45dc321 100644 --- a/mix.exs +++ b/mix.exs @@ -31,7 +31,8 @@ defmodule Hammer.MixProject do {:benchee, "~> 1.2", only: :bench}, {:credo, "~> 1.7", only: [:dev, :test]}, {:ex_doc, "~> 0.34", only: :dev}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:igniter, "~> 0.8", optional: true} # Keeping to perform benchmark test as needed at times # {:ex_rated, "~> 2.1", only: :bench}, diff --git a/mix.lock b/mix.lock index c624dd7..4cf4813 100644 --- a/mix.lock +++ b/mix.lock @@ -6,12 +6,28 @@ "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "ex_ast": {:hex, :ex_ast, "0.13.1", "b3d80ec163733176f63662ac44d2511445c224f6b5e4e3ce01f5eff83c4a5993", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bd15f68cde5ec945b859bd67416f26cf5499f1aef9b067eb2163ed244c7e703a"}, "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "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"}, + "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "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.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "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"}, + "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, + "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [: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", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, + "spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, } diff --git a/test/mix/tasks/hammer.install_test.exs b/test/mix/tasks/hammer.install_test.exs new file mode 100644 index 0000000..0f642f4 --- /dev/null +++ b/test/mix/tasks/hammer.install_test.exs @@ -0,0 +1,155 @@ +defmodule Mix.Tasks.Hammer.InstallTest do + use ExUnit.Case, async: true + import Igniter.Test + + alias Igniter.Project.Deps + + @mix_exs """ + defmodule Test.MixProject do + use Mix.Project + + def project do + [ + app: :test, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Test.Application, []} + ] + end + + defp deps do + [ + {:hammer, "~> 7.0"} + ] + end + end + """ + + @application """ + defmodule Test.Application do + use Application + + def start(_type, _args) do + children = [ + TestWeb.Endpoint + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: Test.Supervisor) + end + end + """ + + defp project do + test_project(files: %{"mix.exs" => @mix_exs, "lib/test/application.ex" => @application}) + end + + defp rate_limit_module(backend) do + """ + defmodule Test.RateLimit do + @moduledoc \"\"\" + Rate limiter for Test, backed by `#{backend}`. + + Check and increment a limit with `hit/3`, for example allowing 10 requests per second: + + Test.RateLimit.hit("some-key", :timer.seconds(1), 10) + + See `Hammer` for the full API and the Hammer tutorial for usage patterns. + \"\"\" + + use Hammer, backend: #{backend} + end + """ + end + + describe "default (ets) backend" do + test "creates the rate limiter module" do + project() + |> Igniter.compose_task("hammer.install", []) + |> assert_creates("lib/test/rate_limit.ex", rate_limit_module(":ets")) + end + + test "adds the rate limiter to the supervision tree" do + project() + |> Igniter.compose_task("hammer.install", []) + |> assert_has_patch("lib/test/application.ex", """ + + | {Test.RateLimit, [clean_period: :timer.minutes(1)]}, + """) + end + + test "does not add any extra dependency" do + project() + |> Igniter.compose_task("hammer.install", []) + |> assert_unchanged("mix.exs") + end + end + + describe "atomic backend" do + test "generates the module and child spec with the atomic backend" do + project() + |> Igniter.compose_task("hammer.install", ["--backend", "atomic"]) + |> assert_creates("lib/test/rate_limit.ex", rate_limit_module(":atomic")) + |> assert_has_patch("lib/test/application.ex", """ + + | {Test.RateLimit, [clean_period: :timer.minutes(1)]}, + """) + |> assert_unchanged("mix.exs") + end + end + + describe "redis backend" do + test "adds the hammer_backend_redis dependency" do + project() + |> Igniter.compose_task("hammer.install", ["-b", "redis"]) + |> assert_has_patch("mix.exs", """ + + | {:hammer_backend_redis, "~> 7.0"}, + """) + end + + test "does not duplicate an existing hammer_backend_redis dependency" do + project() + |> Deps.add_dep({:hammer_backend_redis, "~> 7.1"}) + |> apply_igniter!() + |> Igniter.compose_task("hammer.install", ["--backend", "redis"]) + |> assert_unchanged("mix.exs") + end + + test "generates the module with the Redis backend" do + project() + |> Igniter.compose_task("hammer.install", ["--backend", "redis"]) + |> assert_creates("lib/test/rate_limit.ex", rate_limit_module("Hammer.Redis")) + end + + test "starts the rate limiter with a Redis url" do + project() + |> Igniter.compose_task("hammer.install", ["--backend", "redis"]) + |> assert_has_patch("lib/test/application.ex", """ + + | {Test.RateLimit, [url: "redis://localhost:6379"]}, + """) + end + end + + test "rejects an unknown backend" do + project() + |> Igniter.compose_task("hammer.install", ["--backend", "mnesia"]) + |> assert_has_issue( + "Unknown backend \"mnesia\" for `mix hammer.install`. Expected one of: ets, atomic, redis." + ) + |> refute_creates("lib/test/rate_limit.ex") + |> assert_unchanged("lib/test/application.ex") + end + + test "is idempotent when the rate limiter already exists" do + project() + |> Igniter.compose_task("hammer.install", []) + |> apply_igniter!() + |> Igniter.compose_task("hammer.install", []) + |> assert_unchanged() + end +end