├── .formatter.exs ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── cache.ex ├── errors │ ├── cache_missing_error.ex │ ├── invalid_max_key_length_error.ex │ └── unfetched_body_error.ex ├── one_and_done.ex ├── parsers │ └── plug_parser.ex ├── plug.ex ├── request.ex ├── response.ex ├── response_parser.ex └── telemetry.ex ├── mix.exs ├── mix.lock └── test ├── parsers └── plug_parser_test.exs ├── plug_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: One and Done CI 2 | 3 | on: 4 | # Triggers the workflow on push or pull request events but only for the main branch 5 | push: 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | env: 13 | MIX_ENV: test 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | strategy: 16 | matrix: 17 | include: 18 | - elixir: "1.18" 19 | erlang: "27.2" 20 | 21 | # Oldest-supported Erlang and Elixir versions. 22 | - elixir: "1.14.5-otp-25" 23 | erlang: "25.3.2" 24 | steps: 25 | - name: Check out this repository 26 | uses: actions/checkout@v4 27 | 28 | - uses: erlef/setup-beam@v1.18 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.erlang }} 32 | 33 | - name: Retrieve Mix cache 34 | uses: actions/cache@v4 35 | id: "mix-cache" 36 | with: 37 | path: | 38 | deps 39 | _build 40 | key: ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.erlang }}-${{ hashFiles('**/mix.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.erlang }}- 43 | 44 | - name: Install Mix dependencies 45 | if: steps.mix-cache.outputs.cache-hit != 'true' 46 | run: mix deps.get 47 | 48 | - name: Check formatting 49 | run: mix format --check-formatted 50 | 51 | - name: Run credo 52 | run: mix credo --strict 53 | 54 | # Compile for the test env before test to catch any compiler errors + warnings 55 | - name: Precompile 56 | run: mix compile --warnings-as-errors 57 | 58 | # Run tests with compiler warnings causing a failure 59 | - name: Run tests 60 | run: mix test --warnings-as-errors 61 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publlish to Hex" 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out this repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Elixir and Erlang 15 | uses: erlef/setup-beam@v1.18 16 | with: 17 | elixir-version: "1.18" 18 | otp-version: "27.2" 19 | 20 | - name: Install Mix dependencies 21 | run: mix deps.get 22 | 23 | # https://hex.pm/docs/publish#publishing-from-ci 24 | - name: Publish to Hex 25 | run: mix hex.publish --yes 26 | env: 27 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | one_and_done-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | /_build 28 | /cover 29 | /deps 30 | /doc 31 | /.fetch 32 | erl_crash.dump 33 | *.ez 34 | *.beam 35 | /config/*.secret.exs 36 | .elixir_ls/ 37 | 38 | .DS_Store 39 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 25.3.2.9 2 | elixir 1.17.3 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.6 4 | 5 | * Fix an issue where the Plug conn wouldn't be halted when responding with an error related to the idempotency key being too long. 6 | 7 | ## 0.1.5 8 | 9 | * Adds a new `build_ttl_fn` option to `OneAndDone.Plug`. Provide a function here to generate a dynamic idempotency TTL per request. If not provided, or if the function returns a non-integer value, OneAndDone falls back to the `ttl` option and then finally the 24 hour default. 10 | 11 | ## 0.1.4 12 | 13 | * Fixes a typo in a Github URL link. 14 | 15 | ## 0.1.3 16 | 17 | * Set content-type response header to application/json when returning a 400 when reusing an idempotency key incorrectly 18 | 19 | ## 0.1.2 20 | 21 | * Limit the max idempotency key length to 255 characters with the option `max_key_length` (disable by setting it to 0) 22 | * Compare the originally stored request with the current request to ensure the request structure matches 23 | * This is to prevent reusing the idempotency key e.g. on a different route or with different parameters 24 | * Matching function is configurable with the option `check_requests_match_fn` 25 | * Add option `request_matching_checks_enabled` to skip checking if requests match 26 | * Ensure idempotency keys are unique across the same calls using the same method 27 | * Calls using the same key but different methods or paths will not be considered duplicates 28 | * Do not cache responses for status codes >= 400 and < 500 29 | * This is to prevent caching errors that may be retryable 30 | * 5xx errors are considered non-retryable to reduce system pressure in a failure mode 31 | 32 | ## 0.1.1 33 | 34 | * Support retaining some response headers (e.g. `x-request-id`) and passing along the original header with the prefix `original-` (e.g. `original-x-request-id`) 35 | 36 | ## 0.1.0 37 | 38 | * Initial release 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 Knock Labs, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # One and Done 2 | 3 | One and Done is the easiest way to make HTTP requests idempotent in Elixir applications. 4 | 5 | One and Done supports the following frameworks: 6 | 7 | * `Plug` (including `Phoenix`) 8 | 9 | ## Usage 10 | 11 | One and Done depends on having a pre-existing cache like [Nebulex](https://hexdocs.pm/nebulex/Nebulex.html). This guide assumes Nebulex is already configured under `MyApp.Cache`. 12 | 13 | 1. Add `one_and_done` to your `mix.exs` dependencies: 14 | 15 | ```elixir 16 | def deps do 17 | [ 18 | {:one_and_done, "~> 0.1.5"} 19 | ] 20 | end 21 | ``` 22 | 23 | 2. Add `OneAndDone` to your `Plug` pipeline: 24 | 25 | ```elixir 26 | defmodule MyAppWeb.Router do 27 | use MyAppWeb, :router 28 | 29 | pipeline :api do 30 | # Configuration options for OneAndDone are in the docs 31 | plug OneAndDone.Plug, cache: MyApp.Cache 32 | end 33 | 34 | # By default, all POST and PUT requests piped through :api 35 | # that have an Idempotency-Key header set will be cached for 24 hours. 36 | scope "/api", MyAppWeb do 37 | pipe_through :api 38 | 39 | resources "/users", UserController 40 | end 41 | end 42 | ``` 43 | 44 | 3. Make your requests idempotent by adding the `Idempotency-Key` header: 45 | 46 | ```shell 47 | 48 | curl -X POST \ 49 | http://localhost:4000/api/users \ 50 | -H 'Content-Type: application/json' \ 51 | -H 'Idempotency-Key: 123' \ 52 | -d '{ 53 | "email": "hello@example.com", 54 | "password": "password" 55 | }' 56 | ``` 57 | 58 | Repeat the request with the same `Idempotency-Key` header and you will get the same response 59 | without the request being processed again. 60 | 61 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :one_and_done, OneAndDone.TestPlug, cache: OneAndDone.TestCache 4 | -------------------------------------------------------------------------------- /lib/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Cache do 2 | @moduledoc """ 3 | Defines the most basic cache interface. 4 | 5 | This module is used as a reference for Cache implementations. Although not used by 6 | OneAndDone, Cache implementations should be compliant with this module. 7 | 8 | This module is compliant with `Nebulex.Cache`. If you use Nebulex, you 9 | are already compliant with this module. 10 | """ 11 | 12 | @doc """ 13 | Retreive a value from the cache. 14 | """ 15 | @callback get(key :: any()) :: any | nil 16 | 17 | @doc """ 18 | Store a value in the cache under the given key. 19 | 20 | Opts must include a TTL, given in milliseconds. 21 | """ 22 | @callback put(key :: any(), value :: any(), opts :: [ttl: pos_integer()]) :: :ok 23 | end 24 | -------------------------------------------------------------------------------- /lib/errors/cache_missing_error.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Errors.CacheMissingError do 2 | @moduledoc """ 3 | Raised when a cache is not configured. Check the docs for the OneAndDone module 4 | you are using (e.g. `OneAndDone.Plug`) for details on how to configure a cache. 5 | """ 6 | 7 | defexception message: """ 8 | A cache was not configured for OneAndDone. Check the docs for 9 | the OneAndDone module you are using (e.g. OneAndDone.Plug) for 10 | details on how to configure a cache. 11 | """ 12 | end 13 | -------------------------------------------------------------------------------- /lib/errors/invalid_max_key_length_error.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Errors.InvalidMaxKeyLengthError do 2 | @moduledoc """ 3 | Raised when the configured cache key is not an integer greater than or equal to 0. 4 | """ 5 | 6 | defexception message: 7 | "`max_key_length` was set to an invalid value. It must be an integer greater than or equal to 0." 8 | end 9 | -------------------------------------------------------------------------------- /lib/errors/unfetched_body_error.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Errors.PlugUnfetchedBodyError do 2 | @moduledoc """ 3 | Raised when a request's body has not been fetched. 4 | 5 | We compare each request body to the original request body to ensure that 6 | the request body has not been modified. If the body has not been fetched, 7 | we cannot compare it to the original request body, so we raise this error. 8 | """ 9 | 10 | defexception message: """ 11 | A request's body has not been fetched. This is likely due to a missing `Plug.Parsers` plug. 12 | 13 | If the body has not been parsed yet, it cannot be compared to the original request body. 14 | This is done to prevent accidental misuse of the idempotency key. 15 | 16 | You have two options: 17 | 18 | 1. Add a `Plug.Parsers` plug before the `OneAndDone.Plug` plug in your router (See `Plug` docs here: https://hexdocs.pm/plug/Plug.Parsers.html). 19 | 2. Set the `OneAndDone.Plug` plug's `:ignore_body` option to `true`: 20 | 21 | plug OneAndDone.Plug, cache: MyApp.Cache, ignore_body: true 22 | """ 23 | end 24 | -------------------------------------------------------------------------------- /lib/one_and_done.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone do 2 | @moduledoc """ 3 | OneAndDone makes it easy to introduce idempotency in any Elixir application. 4 | 5 | Its only dependency is a cache to store requests in (Nebulex works great). 6 | 7 | Usage is framework-dependent. Currently, OneAndDone supports the following frameworks: 8 | 9 | * `OneAndDone.Plug` 10 | 11 | Other frameworks are welcome too! 12 | 13 | For details on integrating OneAndDone with your framework, look at the docs for the 14 | module you would use in your framework. 15 | """ 16 | end 17 | -------------------------------------------------------------------------------- /lib/parsers/plug_parser.ex: -------------------------------------------------------------------------------- 1 | defimpl OneAndDone.Parser, for: Plug.Conn do 2 | @moduledoc """ 3 | Turns a `Plug.Conn` into a `OneAndDone.Request` `OneAndDone.Response`. 4 | """ 5 | 6 | alias OneAndDone.Request 7 | alias OneAndDone.Response 8 | 9 | @doc """ 10 | Builds a OneAndDone.Request from a Plug.Conn. 11 | """ 12 | @spec build_request(Plug.Conn.t()) :: OneAndDone.Request.t() 13 | def build_request(%Plug.Conn{} = conn) do 14 | request = %Request{ 15 | host: conn.host, 16 | port: conn.port, 17 | scheme: conn.scheme, 18 | method: conn.method, 19 | path: conn.request_path, 20 | query_string: conn.query_string, 21 | body: conn.body_params 22 | } 23 | 24 | case request do 25 | %Request{body: %Plug.Conn.Unfetched{}} -> raise OneAndDone.Errors.PlugUnfetchedBodyError 26 | _ -> request 27 | end 28 | end 29 | 30 | @doc """ 31 | Builds a OneAndDone.Response from a Plug.Conn. 32 | """ 33 | @spec build_response(Plug.Conn.t()) :: OneAndDone.Response.t() 34 | def build_response(%Plug.Conn{} = conn) do 35 | %Response{ 36 | request_hash: OneAndDone.Parser.build_request(conn) |> OneAndDone.Request.hash(), 37 | status: conn.status, 38 | body: conn.resp_body, 39 | cookies: conn.resp_cookies, 40 | headers: conn.resp_headers 41 | } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Plug do 2 | @moduledoc """ 3 | Easy to use plug for idempoent requests. 4 | 5 | ## Getting started 6 | 7 | 1. Add `:one_and_done` to your list of dependencies in `mix.exs`: 8 | 9 | ```elixir 10 | def deps do 11 | [ 12 | {:one_and_done, "~> 0.1.5"} 13 | ] 14 | end 15 | ``` 16 | 17 | 2. Add the plug to your router: 18 | 19 | ```elixir 20 | pipeline :api do 21 | plug OneAndDone.Plug, 22 | # Required: must conform to OneAndDone.Cache (Nebulex.Cache works fine) 23 | cache: MyApp.Cache, 24 | 25 | # Optional: How long to keep entries, defaults to 86_400 (24 hours) 26 | ttl: 86_400, 27 | 28 | # Optional: Function reference to generate an idempotence TTL per request. 29 | # Takes the current `Plug.Conn` as the first argument and the current 30 | # `idempotency_key` as the second. 31 | # 32 | # When provided, this function is called before falling back to the 33 | # `ttl` option. 34 | # 35 | # Defaults to `nil`. 36 | build_ttl_fn: &OneAndDone.Plug.build_ttl/2, 37 | 38 | # Optional: Which methods to cache, defaults to ["POST", "PUT"] 39 | # Used by the default idempotency_key_fn to quickly determine if the request 40 | # can be cached. If you override idempotency_key_fn, consider checking the 41 | # request method in your implementation for better performance. 42 | # `supported_methods` is available in the opts passed to the idempotency_key_fn. 43 | supported_methods: ["POST", "PUT"], 44 | 45 | # Optional: Which response headers to ignore when caching, defaults to ["x-request-id"] 46 | # When returning a cached response, some headers should not be modified by the contents of the cache. 47 | # 48 | # Instead, the ignored headers are returned with the prefix `original-`. 49 | # 50 | # By default, the `x-request-id` header is not modified. This means that each request will have a 51 | # unique `x-request-id` header, even if a cached response is returned for a request. The original request 52 | # ID is still available under `original-x-request-id`. 53 | # 54 | # If you are using a framework that sets a different header for request IDs, you can add it to this list. 55 | ignored_response_headers: ["x-request-id"], 56 | 57 | # Optional: Function reference to generate the idempotency key for a given request. 58 | # By default, uses the value of the `Idempotency-Key` header. 59 | # Must return a binary or nil. If nil is returned, the request will not be cached. 60 | # Default function implementation: 61 | # 62 | # fn conn, opts -> # Opts is the same as the opts passed to the plug 63 | # if Enum.any?(opts.supported_methods, &(&1 == conn.method)) do 64 | # conn 65 | # |> Plug.Conn.get_req_header("idempotency-key") # Request headers are always downcased 66 | # |> List.first() 67 | # else 68 | # nil 69 | # end 70 | # end 71 | idempotency_key_fn: &OneAndDone.Plug.idempotency_key_from_conn/2, 72 | 73 | # Optional: Function reference to generate the cache key for a given request. 74 | # Given the conn & idempotency key (returned from idempotency_key_fn), this function 75 | # should return a term that will be used as the cache key. 76 | # By default, it returns a tuple of the module name and the idempotency key. 77 | # Default function implementation: fn _conn, idempotency_key -> {__MODULE__, idempotency_key} 78 | cache_key_fn: &OneAndDone.Plug.build_cache_key/2 79 | 80 | # Optional: Flag to enable request match checking. Defaults to true. 81 | # If true, the function given in check_requests_match_fn will be called to determine if the 82 | # original request matches the current request. 83 | # If false, no such check shall be performed. 84 | request_matching_checks_enabled: true, 85 | 86 | # Optional: Function reference to determine if the original request matches the current request. 87 | # Given the current connection and a hash of the original request, this function should return 88 | # true if the current request matches the original request. 89 | # By default, uses `:erlang.phash2/2` to generate a hash of the current request. If the `hashes` 90 | # do not match, the request is not idempotent and One and Done will return a 400 response. 91 | # To disable this check, use `fn _conn, _original_request_hash -> true end` 92 | # Default function implementation: 93 | # 94 | # fn conn, original_request_hash -> 95 | # request_hash = 96 | # Parser.build_request(conn) 97 | # |> Request.hash() 98 | # 99 | # cached_response.request_hash == request_hash 100 | # end 101 | check_requests_match_fn: &OneAndDone.Plug.matching_request?/2, 102 | 103 | # Optional: Max length of each idempotency key. Defaults to 255 characters. 104 | # If the idempotency key is longer than this, we respond with error 400. 105 | # Set to 0 to disable this check. 106 | max_key_length: 255 107 | end 108 | ``` 109 | 110 | That's it! POST and PUT requests will now be cached by default for 24 hours. 111 | 112 | ## Response headers 113 | By default, the "x-request-id" header is not modified. This means that each request will have a 114 | unique "x-request-id" header, even if a cached response is returned for a request. 115 | By default, the "original-x-request-id" header is set to the value of the "x-request-id" header 116 | from the original request. This is useful for tracing the original request that was cached. 117 | One and Done sets the "idempotent-replayed" header to "true" if a cached response is returned. 118 | 119 | ## Telemetry 120 | 121 | To monitor the performance of the OneAndDone plug, you can hook into `OneAndDone.Telemetry`. 122 | 123 | For a complete list of events, see `OneAndDone.Telemetry.events/0`. 124 | 125 | ### Example 126 | 127 | ```elixir 128 | # In your application.ex 129 | # ... 130 | :telemetry.attach_many( 131 | "one-and-done", 132 | OneAndDone.Telemetry.events(), 133 | &MyApp.Telemetry.handle_event/4, 134 | nil 135 | ) 136 | # ... 137 | 138 | # In your telemetry module: 139 | defmodule MyApp.Telemetry do 140 | require Logger 141 | 142 | def handle_event([:one_and_done, :request, :stop], measurements, _metadata, _config) do 143 | duration = System.convert_time_unit(measurements.duration, :native, :millisecond) 144 | 145 | Logger.info("Running one_and_done took #\{duration\}ms") 146 | 147 | :ok 148 | end 149 | 150 | # Catch-all for unhandled events 151 | def handle_event(_, _, _, _) do 152 | :ok 153 | end 154 | end 155 | ``` 156 | 157 | """ 158 | 159 | @behaviour Plug 160 | 161 | alias OneAndDone.Parser 162 | alias OneAndDone.Request 163 | alias OneAndDone.Telemetry 164 | 165 | @supported_methods ["POST", "PUT"] 166 | @ttl :timer.hours(24) 167 | @default_max_key_length 255 168 | 169 | @impl Plug 170 | @spec init(cache: OneAndDone.Cache.t()) :: %{ 171 | cache: any, 172 | ttl: any, 173 | build_ttl_fn: any, 174 | supported_methods: any, 175 | ignored_response_headers: any, 176 | idempotency_key_fn: any, 177 | cache_key_fn: any, 178 | request_matching_checks_enabled: boolean(), 179 | check_requests_match_fn: any, 180 | max_key_length: non_neg_integer() 181 | } 182 | def init(opts) do 183 | %{ 184 | cache: Keyword.get(opts, :cache) || raise(OneAndDone.Errors.CacheMissingError), 185 | ttl: Keyword.get(opts, :ttl, @ttl), 186 | build_ttl_fn: Keyword.get(opts, :build_ttl_fn), 187 | supported_methods: Keyword.get(opts, :supported_methods, @supported_methods), 188 | ignored_response_headers: Keyword.get(opts, :ignored_response_headers, ["x-request-id"]), 189 | idempotency_key_fn: 190 | Keyword.get(opts, :idempotency_key_fn, &__MODULE__.idempotency_key_from_conn/2), 191 | cache_key_fn: Keyword.get(opts, :cache_key_fn, &__MODULE__.build_cache_key/2), 192 | request_matching_checks_enabled: Keyword.get(opts, :request_matching_checks_enabled, true), 193 | check_requests_match_fn: 194 | Keyword.get(opts, :check_requests_match_fn, &__MODULE__.matching_request?/2), 195 | max_key_length: validate_max_key_length!(opts) 196 | } 197 | end 198 | 199 | defp validate_max_key_length!(opts) do 200 | case Keyword.get(opts, :max_key_length, @default_max_key_length) do 201 | number when is_integer(number) and number >= 0 -> number 202 | _ -> raise(OneAndDone.Errors.InvalidMaxKeyLengthError) 203 | end 204 | end 205 | 206 | @impl Plug 207 | def call(conn, opts) do 208 | Telemetry.span(:request, %{conn: conn, opts: opts}, fn -> 209 | idempotency_key = opts.idempotency_key_fn.(conn, opts) 210 | handle_idempotent_request(conn, idempotency_key, opts) 211 | end) 212 | end 213 | 214 | # If we didn't get an idempotency key, move on 215 | defp handle_idempotent_request(conn, nil, _) do 216 | Telemetry.event([:request, :idempotency_key_not_set], %{}, %{conn: conn}) 217 | 218 | conn 219 | end 220 | 221 | defp handle_idempotent_request(conn, idempotency_key, opts) do 222 | case check_cache(conn, idempotency_key, opts) do 223 | {:ok, cached_response} -> 224 | handle_cache_hit(conn, cached_response, idempotency_key, opts) 225 | 226 | {:error, :idempotency_key_too_long} -> 227 | handle_idempotency_key_too_long(conn, idempotency_key, opts) 228 | 229 | # Cache miss passes through; we cache the response in the response callback 230 | _ -> 231 | handle_cache_miss(conn, idempotency_key, opts) 232 | end 233 | end 234 | 235 | defp check_cache(conn, idempotency_key, opts) do 236 | if opts.max_key_length > 0 and String.length(idempotency_key) > opts.max_key_length do 237 | {:error, :idempotency_key_too_long} 238 | else 239 | Telemetry.span( 240 | [:request, :cache_get], 241 | %{conn: conn, idempotency_key: idempotency_key}, 242 | fn -> 243 | conn 244 | |> opts.cache_key_fn.(idempotency_key) 245 | |> opts.cache.get() 246 | end 247 | ) 248 | end 249 | end 250 | 251 | defp handle_cache_hit(conn, response, idempotency_key, opts) do 252 | if opts.request_matching_checks_enabled and not opts.check_requests_match_fn.(conn, response) do 253 | handle_request_mismatch(conn, response, idempotency_key) 254 | else 255 | send_idempotent_response(conn, response, idempotency_key, opts) 256 | end 257 | end 258 | 259 | defp handle_cache_miss(conn, idempotency_key, opts) do 260 | Telemetry.event([:request, :cache_miss], %{}, %{ 261 | idempotency_key: idempotency_key, 262 | conn: conn 263 | }) 264 | 265 | Plug.Conn.register_before_send(conn, fn conn -> 266 | cache_response(conn, idempotency_key, opts) 267 | end) 268 | end 269 | 270 | defp handle_idempotency_key_too_long(conn, idempotency_key, opts) do 271 | Telemetry.event( 272 | [:request, :idempotency_key_too_long], 273 | %{key_length: String.length(idempotency_key), key_length_limit: opts.max_key_length}, 274 | %{ 275 | idempotency_key: idempotency_key, 276 | conn: conn 277 | } 278 | ) 279 | 280 | send_400_response(conn, "idempotency_key_too_long") 281 | end 282 | 283 | defp cache_response(conn, idempotency_key, opts) do 284 | if conn.status >= 400 and conn.status < 500 do 285 | Telemetry.event([:request, :skip_put_cache], %{}, %{ 286 | idempotency_key: idempotency_key, 287 | conn: conn 288 | }) 289 | 290 | conn 291 | else 292 | Telemetry.span( 293 | [:request, :put_cache], 294 | %{idempotency_key: idempotency_key, conn: conn}, 295 | fn -> 296 | response = Parser.build_response(conn) 297 | ttl = build_ttl(conn, idempotency_key, opts) 298 | 299 | conn 300 | |> opts.cache_key_fn.(idempotency_key) 301 | |> opts.cache.put({:ok, response}, ttl: ttl) 302 | 303 | conn 304 | end 305 | ) 306 | end 307 | end 308 | 309 | defp build_ttl(conn, idempotency_key, %{build_ttl_fn: build_ttl_fn} = opts) 310 | when is_function(build_ttl_fn, 2) do 311 | case build_ttl_fn.(conn, idempotency_key) do 312 | ttl when is_integer(ttl) -> ttl 313 | _ -> opts.ttl 314 | end 315 | end 316 | 317 | defp build_ttl(_, _, opts), do: opts.ttl 318 | 319 | defp handle_request_mismatch(conn, response, idempotency_key) do 320 | Telemetry.event( 321 | [:request, :request_mismatch], 322 | %{}, 323 | %{ 324 | idempotency_key: idempotency_key, 325 | conn: conn, 326 | response: response 327 | } 328 | ) 329 | 330 | send_400_response(conn, """ 331 | This request does not match the first request used with this idempotency key. \ 332 | This could mean you are reusing idempotency keys across requests. Either make sure the \ 333 | request matches across idempotent requests, or change your idempotency key when making \ 334 | new requests.\ 335 | """) 336 | end 337 | 338 | defp send_400_response(conn, message) do 339 | conn 340 | |> Plug.Conn.put_resp_content_type("application/json") 341 | |> Plug.Conn.send_resp(400, ~s({"error": "#{message}"})) 342 | |> Plug.Conn.halt() 343 | end 344 | 345 | defp send_idempotent_response(conn, response, idempotency_key, opts) do 346 | Telemetry.event([:request, :cache_hit], %{}, %{ 347 | idempotency_key: idempotency_key, 348 | conn: conn, 349 | response: response 350 | }) 351 | 352 | conn = 353 | Enum.reduce(response.cookies, conn, fn {key, %{value: value}}, conn -> 354 | Plug.Conn.put_resp_cookie(conn, key, value) 355 | end) 356 | 357 | conn = 358 | Enum.reduce(response.headers, conn, fn 359 | {key, value}, conn -> 360 | if key in opts.ignored_response_headers do 361 | Plug.Conn.put_resp_header(conn, "original-#{key}", value) 362 | else 363 | Plug.Conn.put_resp_header(conn, key, value) 364 | end 365 | end) 366 | |> Plug.Conn.put_resp_header("idempotent-replayed", "true") 367 | 368 | Plug.Conn.send_resp(conn, response.status, response.body) 369 | |> Plug.Conn.halt() 370 | end 371 | 372 | # These functions must be public to avoid an ArgumentError during compilation. 373 | 374 | def matching_request?(conn, cached_response) do 375 | request_hash = 376 | Parser.build_request(conn) 377 | |> Request.hash() 378 | 379 | cached_response.request_hash == request_hash 380 | end 381 | 382 | @doc false 383 | def idempotency_key_from_conn(%Plug.Conn{} = conn, opts) do 384 | if Enum.any?(opts.supported_methods, &(&1 == conn.method)) do 385 | conn 386 | |> Plug.Conn.get_req_header("idempotency-key") 387 | |> List.first() 388 | else 389 | nil 390 | end 391 | end 392 | 393 | @doc false 394 | def build_cache_key(conn, idempotency_key), 395 | do: {__MODULE__, conn.method, conn.request_path, idempotency_key} 396 | end 397 | -------------------------------------------------------------------------------- /lib/request.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Request do 2 | @moduledoc """ 3 | Capture the request information that we want to cache. 4 | 5 | Headers are not included in the cache key because they can change 6 | from request to request and should not influence the substance of 7 | the request being made to a controller. 8 | 9 | Generally we do not cache this Request struct, but we do cache the 10 | hash of the struct so that we can compare subsequent requests to 11 | the original request. If the hashes don't match, we return an error. 12 | If the hashes do match, then we can continue processing. 13 | """ 14 | 15 | @type t :: %__MODULE__{ 16 | host: binary(), 17 | port: non_neg_integer(), 18 | scheme: binary(), 19 | method: binary(), 20 | path: binary(), 21 | query_string: binary(), 22 | body: binary() 23 | } 24 | 25 | @enforce_keys [ 26 | :host, 27 | :port, 28 | :scheme, 29 | :method, 30 | :path, 31 | :query_string, 32 | :body 33 | ] 34 | 35 | defstruct @enforce_keys 36 | 37 | @doc """ 38 | Hashes the request struct. 39 | """ 40 | @spec hash(t()) :: non_neg_integer() 41 | def hash(%__MODULE__{} = request) do 42 | :erlang.phash2(request) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/response.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Response do 2 | @moduledoc """ 3 | A basic module for capturing the essence of a response. 4 | 5 | Also captures a hash of the request that generated the response. This is used 6 | to determine if two requests sharing the same idempotency key are the same 7 | to prevent accidental misuse of the idempotency key. 8 | 9 | Response structs are stored in the cache so that idempotent requests can be 10 | quickly returned. 11 | 12 | See `OneAndDone.Response.Parser` for turning an inbound connection (e.g. a Plug.Conn) 13 | into a `OneAndDone.Response`. 14 | """ 15 | 16 | @type t :: %__MODULE__{ 17 | request_hash: non_neg_integer(), 18 | status: non_neg_integer(), 19 | body: iodata(), 20 | cookies: %{optional(binary) => map()}, 21 | headers: [{binary(), binary()}] 22 | } 23 | 24 | @enforce_keys [:request_hash, :status, :body, :cookies, :headers] 25 | defstruct [ 26 | :request_hash, 27 | :status, 28 | :body, 29 | :cookies, 30 | :headers 31 | ] 32 | end 33 | -------------------------------------------------------------------------------- /lib/response_parser.ex: -------------------------------------------------------------------------------- 1 | defprotocol OneAndDone.Parser do 2 | @moduledoc """ 3 | Protocol for turning an inbound connection (e.g. a Plug.Conn) into a 4 | OneAndDone.Request or a OneAndDone.Response. 5 | """ 6 | 7 | @spec build_request(t) :: OneAndDone.Request.t() 8 | def build_request(value) 9 | 10 | @spec build_response(t) :: OneAndDone.Response.t() 11 | def build_response(value) 12 | end 13 | -------------------------------------------------------------------------------- /lib/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration to track how long it takes to process a request. 4 | 5 | OneAndDone emits the following metrics: 6 | 7 | | Metric | Description | Measurements | Metadata | 8 | | --- | --- | --- | --- | 9 | | `[:one_and_done, :request, :start]` | When we begin processing a request. | | `conn`, `opts` | 10 | | `[:one_and_done, :request, :stop]` | When we finish processing a request, including the duration in native units. | `duration` | `conn`, `opts` | 11 | | `[:one_and_done, :request, :exception]` | When we finish processing a request, if an exception was raised. Includes the duration in native units. | `duration`, `exception` | `conn`, `opts` | 12 | | `[:one_and_done, :request, :cache_hit]` | Given an idempotency key, we found a cached response. | `idempotency_key` | `conn`, `response` | 13 | | `[:one_and_done, :request, :cache_miss]` | Given an idempotency key, we didn't find a cached response. | `idempotency_key` | `conn` | 14 | | `[:one_and_done, :request, :idempotency_key_not_set]` | The request doesn't have an idempotency key and will not be processed further by OneAndDone. | | `conn` | 15 | | `[:one_and_done, :request, :idempotency_key_too_long]` | The idempotency key is too long. A 400 error was returned to the client. | `key_length`, `key_length_limit` | `conn` | 16 | | `[:one_and_done, :request, :cache_get, :start]` | When we begin checking the cache for a request. | | `conn`, `idempotency_key` | 17 | | `[:one_and_done, :request, :cache_get, :stop]` | When we finish checking the cache for a request, including the duration in native units. | `duration` | `conn`, `idempotency_key` | 18 | | `[:one_and_done, :request, :cache_get, :exception]` | When we finish checking the cache for a request, if an exception was raised. Includes the duration in native units. | `duration`, `exception` | `conn`, `idempotency_key` | 19 | | `[:one_and_done, :request, :cache_put, :start]` | When we begin serializing and putting a response into the cache. | | `conn`, `idempotency_key` | 20 | | `[:one_and_done, :request, :cache_put, :stop]` | When we finish serializing and putting a response into the cache, including the duration in native units. | `duration` | `conn`, `idempotency_key` | 21 | | `[:one_and_done, :request, :cache_put, :exception]` | When we finish serializing and putting a response into the cache, if an exception was raised. Includes the duration in native units. | `duration`, `exception` | `conn`, `idempotency_key` | 22 | 23 | 24 | The duration is emitted in native units. To convert to milliseconds, use `System.convert_time_unit(duration, :native, :millisecond)`. 25 | 26 | """ 27 | 28 | require Logger 29 | 30 | @events [ 31 | [:one_and_done, :request, :start], 32 | [:one_and_done, :request, :stop], 33 | [:one_and_done, :request, :exception], 34 | [:one_and_done, :request, :cache_hit], 35 | [:one_and_done, :request, :cache_miss], 36 | [:one_and_done, :request, :idempotency_key_not_set], 37 | [:one_and_done, :request, :idempotency_key_too_long], 38 | [:one_and_done, :request, :cache_get, :start], 39 | [:one_and_done, :request, :cache_get, :stop], 40 | [:one_and_done, :request, :cache_get, :exception], 41 | [:one_and_done, :request, :cache_put, :start], 42 | [:one_and_done, :request, :cache_put, :stop], 43 | [:one_and_done, :request, :cache_put, :exception] 44 | ] 45 | 46 | @doc """ 47 | Return the list of events emitted by this module. 48 | """ 49 | @spec events() :: list() 50 | def events, do: @events 51 | 52 | defmodule SpanResult do 53 | @moduledoc """ 54 | Additional metadata to include at the end of a span. 55 | """ 56 | 57 | defstruct [ 58 | :status, 59 | :result 60 | ] 61 | 62 | @type t :: %__MODULE__{ 63 | status: :success | :error, 64 | result: any() 65 | } 66 | 67 | @doc """ 68 | Create a new SpanResult struct. 69 | """ 70 | @spec new(any()) :: t() 71 | def new(result) do 72 | # Infer a success or error status from the result of the wrapped 73 | # function. We default to calling any response a `success`. 74 | status = 75 | case result do 76 | :error -> :error 77 | {:error, _} -> :error 78 | _ -> :success 79 | end 80 | 81 | %__MODULE__{status: status, result: result} 82 | end 83 | end 84 | 85 | alias OneAndDone.Telemetry 86 | 87 | @namespace :one_and_done 88 | 89 | @doc """ 90 | Measure the duration of a function call. 91 | """ 92 | @spec span(atom() | list(atom()), map(), fun()) :: any() 93 | def span(base_name, meta \\ %{}, fun) do 94 | meta = meta_with_tags(meta) 95 | 96 | base_name 97 | |> build_name() 98 | |> :telemetry.span(meta, fn -> 99 | result = fun.() 100 | 101 | { 102 | result, 103 | Map.put(meta, :result, Telemetry.SpanResult.new(result)) 104 | } 105 | end) 106 | end 107 | 108 | @doc """ 109 | Emit a telemetry event. 110 | """ 111 | @spec event(atom() | list(atom()), map(), map()) :: :ok 112 | def event(base_name, metrics, meta \\ %{}) do 113 | meta = meta_with_tags(meta) 114 | 115 | base_name 116 | |> build_name() 117 | |> :telemetry.execute(metrics, meta) 118 | end 119 | 120 | defp build_name(paths) when is_list(paths), do: [@namespace | paths] 121 | defp build_name(path) when is_atom(path), do: [@namespace, path] 122 | 123 | # Ensure the event metadata always has a `tags` entry 124 | defp meta_with_tags(meta), do: Map.merge(%{tags: []}, meta) 125 | end 126 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | name: "One and Done", 7 | app: :one_and_done, 8 | version: "0.1.6", 9 | elixir: "~> 1.13", 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | package: package(), 12 | description: description(), 13 | source_url: "https://github.com/knocklabs/one_and_done", 14 | docs: [ 15 | # The main page in the docs 16 | main: "readme", 17 | extras: ["README.md"] 18 | ], 19 | start_permanent: Mix.env() == :prod, 20 | deps: deps() 21 | ] 22 | end 23 | 24 | # Run "mix help compile.app" to learn about applications. 25 | def application do 26 | [ 27 | extra_applications: [:logger] 28 | ] 29 | end 30 | 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | # Run "mix help deps" to learn about dependencies. 35 | defp deps do 36 | [ 37 | {:plug, "~> 1.14"}, 38 | {:telemetry, "~> 1.0"}, 39 | {:jason, "~> 1.0", only: [:dev, :test], runtime: false}, 40 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 41 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, 42 | {:ex_doc, "~> 0.29", only: :dev, runtime: false} 43 | ] 44 | end 45 | 46 | defp package do 47 | [ 48 | name: "one_and_done", 49 | licenses: ["MIT"], 50 | links: %{"GitHub" => "https://github.com/knocklabs/one_and_done"} 51 | ] 52 | end 53 | 54 | defp description do 55 | """ 56 | Easy to use plug for idempoent requests. 57 | """ 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 5 | "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"}, 6 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 7 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 8 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 9 | "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"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 11 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 12 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 14 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 15 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 16 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/parsers/plug_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.PlugParserTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | use Plug.Test 5 | 6 | alias OneAndDone.Parser 7 | 8 | describe "build_request/1" do 9 | test "converts a conn into a request struct" do 10 | conn = 11 | conn(:get, "/hello", "some-body") 12 | |> Plug.run([{Plug.Parsers, parsers: [{:json, json_decoder: Jason}], pass: ["*/*"]}]) 13 | |> Plug.Conn.send_resp(200, "Hello World") 14 | 15 | request = Parser.build_request(conn) 16 | 17 | assert request.host == conn.host 18 | assert request.method == conn.method 19 | assert request.path == conn.request_path 20 | assert request.port == conn.port 21 | assert request.scheme == conn.scheme 22 | assert request.query_string == conn.query_string 23 | end 24 | 25 | test "throws an error if the body is not available" do 26 | conn = 27 | conn(:get, "/hello", "some-body") 28 | |> Plug.Conn.send_resp(200, "Hello World") 29 | 30 | assert_raise OneAndDone.Errors.PlugUnfetchedBodyError, fn -> 31 | Parser.build_request(conn) 32 | end 33 | end 34 | end 35 | 36 | describe "build_response/1" do 37 | test "converts a conn into a response struct" do 38 | conn = 39 | conn(:post, "/hello?key=value#something", "{\"some\": \"json\"}") 40 | |> Plug.Conn.put_req_header("content-type", "application/json") 41 | |> Plug.Conn.put_req_header("some-header", "some-value") 42 | |> Plug.run([{Plug.Parsers, parsers: [{:json, json_decoder: Jason}], pass: ["*/*"]}]) 43 | |> Plug.Conn.send_resp(200, "Hello World") 44 | 45 | response = Parser.build_response(conn) 46 | assert response.request_hash == Parser.build_request(conn) |> OneAndDone.Request.hash() 47 | assert response.body == conn.resp_body 48 | assert response.cookies == conn.resp_cookies 49 | assert response.headers == conn.resp_headers 50 | assert response.status == conn.status 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OneAndDone.PlugTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | use Plug.Test 5 | 6 | doctest OneAndDone.Plug 7 | 8 | defmodule TestCache do 9 | @moduledoc """ 10 | Agent for storing cached responses under test. 11 | """ 12 | 13 | use Agent 14 | @default_state %{data: %{}, ttls: %{}} 15 | 16 | def start_link(_) do 17 | Agent.start_link(fn -> @default_state end, name: __MODULE__) 18 | end 19 | 20 | def get(key) do 21 | Agent.get(__MODULE__, fn cache -> 22 | with {:ok, ttl} <- Map.fetch(cache.ttls, key), 23 | :gt <- DateTime.compare(ttl, DateTime.utc_now()) do 24 | Map.get(cache.data, key) 25 | else 26 | _ -> nil 27 | end 28 | end) 29 | end 30 | 31 | def put(key, value, opts) do 32 | ttl = Keyword.fetch!(opts, :ttl) 33 | 34 | Agent.update(__MODULE__, fn cache -> 35 | cache 36 | |> put_in([:ttls, key], DateTime.utc_now() |> DateTime.add(ttl, :millisecond)) 37 | |> put_in([:data, key], value) 38 | end) 39 | end 40 | 41 | def dump do 42 | Agent.get(__MODULE__, fn cache -> cache end) 43 | end 44 | 45 | def delete(key) do 46 | Agent.update(__MODULE__, fn %{data: data, ttls: ttls} -> 47 | %{ 48 | data: Map.delete(data, key), 49 | ttls: Map.delete(ttls, key) 50 | } 51 | end) 52 | end 53 | 54 | def clear do 55 | Agent.update(__MODULE__, fn _ -> @default_state end) 56 | end 57 | end 58 | 59 | setup do 60 | start_supervised!(TestCache) 61 | 62 | :ok 63 | end 64 | 65 | @empty_cache_state %{data: %{}, ttls: %{}} 66 | 67 | describe "init/1" do 68 | test "raises an error if the cache is not set" do 69 | assert_raise OneAndDone.Errors.CacheMissingError, fn -> 70 | OneAndDone.Plug.init([]) 71 | end 72 | end 73 | 74 | test "raises an error if the max_key_length is invalid" do 75 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 76 | OneAndDone.Plug.init(cache: TestCache, max_key_length: "invalid") 77 | end 78 | 79 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 80 | OneAndDone.Plug.init(cache: TestCache, max_key_length: -1) 81 | end 82 | 83 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 84 | OneAndDone.Plug.init(cache: TestCache, max_key_length: 1.5) 85 | end 86 | 87 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 88 | OneAndDone.Plug.init(cache: TestCache, max_key_length: :one) 89 | end 90 | 91 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 92 | OneAndDone.Plug.init(cache: TestCache, max_key_length: :infinity) 93 | end 94 | 95 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 96 | OneAndDone.Plug.init(cache: TestCache, max_key_length: [1, 2, 3]) 97 | end 98 | 99 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 100 | OneAndDone.Plug.init(cache: TestCache, max_key_length: %{"key" => "123"}) 101 | end 102 | 103 | assert_raise OneAndDone.Errors.InvalidMaxKeyLengthError, fn -> 104 | OneAndDone.Plug.init(cache: TestCache, max_key_length: [key: 123]) 105 | end 106 | end 107 | end 108 | 109 | @pre_plugs [{Plug.Parsers, parsers: [{:json, json_decoder: Jason}], pass: ["*/*"]}] 110 | 111 | describe "call/2" do 112 | test "does nothing with non-idempotent requests" do 113 | [:get, :delete, :patch] 114 | |> Enum.each(fn method -> 115 | conn = 116 | conn(method, "/hello") 117 | |> Plug.Conn.put_req_header("idempotency-key", "123") 118 | 119 | assert Plug.run(conn, [{OneAndDone.Plug, cache: TestCache}]) == conn 120 | assert TestCache.dump() == @empty_cache_state 121 | end) 122 | end 123 | 124 | test "does nothing with put/post requests missing the idempotency-key header" do 125 | [:post, :put] 126 | |> Enum.each(fn method -> 127 | conn = conn(method, "/hello") 128 | assert Plug.run(conn, [{OneAndDone.Plug, cache: TestCache}]) == conn 129 | assert TestCache.dump() == @empty_cache_state 130 | end) 131 | end 132 | 133 | test "when the idempotency-key header is set, stores put/post requests in the cache" do 134 | [:post, :put] 135 | |> Enum.each(fn method -> 136 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 137 | 138 | original_conn = 139 | conn(method, "/hello", Jason.encode!("some-body")) 140 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 141 | |> Plug.Conn.put_req_header("content-type", "application/json") 142 | |> Plug.run(@pre_plugs) 143 | 144 | conn = 145 | original_conn 146 | |> Plug.run([{OneAndDone.Plug, cache: TestCache}]) 147 | |> Plug.Conn.put_resp_content_type("text/plain") 148 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 149 | |> Plug.Conn.put_resp_header("some-header", "value") 150 | |> Plug.Conn.send_resp(200, "Okay!") 151 | 152 | method_str = method |> Atom.to_string() |> String.upcase() 153 | 154 | refute Plug.run(original_conn, [{OneAndDone.Plug, cache: TestCache}]) == conn 155 | struct = TestCache.get({OneAndDone.Plug, method_str, "/hello", cache_key}) |> elem(1) 156 | 157 | assert struct == %OneAndDone.Response{ 158 | request_hash: 159 | OneAndDone.Parser.build_request(original_conn) |> OneAndDone.Request.hash(), 160 | body: "Okay!", 161 | cookies: %{"some-cookie" => %{value: "value"}}, 162 | headers: [ 163 | {"cache-control", "max-age=0, private, must-revalidate"}, 164 | {"content-type", "text/plain; charset=utf-8"}, 165 | {"some-header", "value"} 166 | ], 167 | status: 200 168 | } 169 | end) 170 | end 171 | 172 | test "when we've seen a request before, we get the old response back" do 173 | [:post, :put] 174 | |> Enum.each(fn method -> 175 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 176 | 177 | original_conn = 178 | conn(method, "/hello", Jason.encode!("some-body")) 179 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 180 | |> Plug.Conn.put_req_header("content-type", "application/json") 181 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 182 | |> Plug.Conn.put_resp_content_type("text/plain") 183 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 184 | |> Plug.Conn.put_resp_header("some-header", "value") 185 | |> Plug.Conn.send_resp(200, "Okay!") 186 | 187 | new_conn = 188 | conn(method, "/hello", Jason.encode!("some-body")) 189 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 190 | |> Plug.Conn.put_req_header("content-type", "application/json") 191 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 192 | 193 | assert new_conn.resp_body == original_conn.resp_body 194 | assert new_conn.resp_cookies == original_conn.resp_cookies 195 | 196 | assert new_conn.resp_headers -- [{"idempotent-replayed", "true"}] == 197 | original_conn.resp_headers 198 | 199 | assert new_conn.status == original_conn.status 200 | end) 201 | end 202 | 203 | test "when we've seen a request before, it halts the processing pipeline" do 204 | [:post, :put] 205 | |> Enum.each(fn method -> 206 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 207 | 208 | _original_conn = 209 | conn(method, "/hello") 210 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 211 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 212 | |> Plug.Conn.put_resp_content_type("text/plain") 213 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 214 | |> Plug.Conn.put_resp_header("some-header", "value") 215 | |> Plug.Conn.send_resp(200, "Okay!") 216 | 217 | new_conn = 218 | conn(method, "/hello") 219 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 220 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 221 | 222 | assert new_conn.state == :sent 223 | end) 224 | end 225 | 226 | test "when we've seen a request before, the idempotent-replayed header is set" do 227 | [:post, :put] 228 | |> Enum.each(fn method -> 229 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 230 | 231 | original_conn = 232 | conn(method, "/hello") 233 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 234 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 235 | |> Plug.Conn.put_resp_content_type("text/plain") 236 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 237 | |> Plug.Conn.put_resp_header("some-header", "value") 238 | |> Plug.Conn.send_resp(200, "Okay!") 239 | 240 | new_conn = 241 | conn(method, "/hello") 242 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 243 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 244 | 245 | assert Plug.Conn.get_resp_header(new_conn, "idempotent-replayed") == ["true"] 246 | refute Plug.Conn.get_resp_header(original_conn, "idempotent-replayed") == ["true"] 247 | end) 248 | end 249 | 250 | test "requests that return error 4xx are not cached" do 251 | [:post, :put] 252 | |> Enum.each(fn method -> 253 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 254 | 255 | _original_conn = 256 | conn(method, "/hello") 257 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 258 | |> Plug.run([{OneAndDone.Plug, cache: TestCache}]) 259 | |> Plug.Conn.put_resp_content_type("text/plain") 260 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 261 | |> Plug.Conn.put_resp_header("some-header", "value") 262 | |> Plug.Conn.send_resp(400, "Not okay!") 263 | 264 | new_conn = 265 | conn(method, "/hello") 266 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 267 | |> Plug.run([{OneAndDone.Plug, cache: TestCache}]) 268 | 269 | refute Plug.Conn.get_resp_header(new_conn, "idempotent-replayed") == ["true"] 270 | refute TestCache.get({OneAndDone.Plug, cache_key}) 271 | end) 272 | end 273 | 274 | test "by default, ignores x-request-id and returns original-x-request-id for the original request's x-request-id" do 275 | [:post, :put] 276 | |> Enum.each(fn method -> 277 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 278 | 279 | original_conn = 280 | conn(method, "/hello") 281 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 282 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 283 | |> Plug.Conn.put_resp_content_type("text/plain") 284 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 285 | |> Plug.Conn.put_resp_header("some-header", "value") 286 | |> Plug.Conn.put_resp_header("x-request-id", "1234") 287 | |> Plug.Conn.send_resp(200, "Okay!") 288 | 289 | new_conn = 290 | conn(method, "/hello") 291 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 292 | |> Plug.Conn.put_resp_header("x-request-id", "5678") 293 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 294 | 295 | refute Plug.Conn.get_resp_header(new_conn, "x-request-id") == 296 | Plug.Conn.get_resp_header(original_conn, "x-request-id") 297 | 298 | assert Plug.Conn.get_resp_header(new_conn, "original-x-request-id") == 299 | Plug.Conn.get_resp_header(original_conn, "x-request-id") 300 | end) 301 | end 302 | 303 | test "ignored response headers are returned without modification, but the original matching header is still returned" do 304 | [:post, :put] 305 | |> Enum.each(fn method -> 306 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 307 | 308 | _original_conn = 309 | conn(method, "/hello") 310 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 311 | |> Plug.run( 312 | @pre_plugs ++ 313 | [ 314 | {OneAndDone.Plug, cache: TestCache, ignored_response_headers: ["some-header"]} 315 | ] 316 | ) 317 | |> Plug.Conn.put_resp_content_type("text/plain") 318 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 319 | |> Plug.Conn.put_resp_header("some-header", "value") 320 | |> Plug.Conn.send_resp(200, "Okay!") 321 | 322 | new_conn = 323 | conn(method, "/hello") 324 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 325 | |> Plug.Conn.put_resp_header("some-header", "not the same value") 326 | |> Plug.run( 327 | @pre_plugs ++ 328 | [ 329 | {OneAndDone.Plug, cache: TestCache, ignored_response_headers: ["some-header"]} 330 | ] 331 | ) 332 | 333 | assert ["not the same value"] == Plug.Conn.get_resp_header(new_conn, "some-header") 334 | assert ["value"] == Plug.Conn.get_resp_header(new_conn, "original-some-header") 335 | end) 336 | end 337 | 338 | test "respects TTL" do 339 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 340 | 341 | original_conn = 342 | conn(:post, "/hello", Jason.encode!("some-body")) 343 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 344 | |> Plug.Conn.put_req_header("content-type", "application/json") 345 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache, ttl: 0}]) 346 | |> Plug.Conn.put_resp_content_type("text/plain") 347 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 348 | |> Plug.Conn.put_resp_header("some-header", "value") 349 | |> Plug.Conn.send_resp(200, "Okay!") 350 | 351 | Process.sleep(10) 352 | 353 | new_conn = 354 | conn(:post, "/hello") 355 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 356 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 357 | |> Plug.Conn.put_resp_content_type("text/plain") 358 | |> Plug.Conn.put_resp_cookie("some-cookie", "different value") 359 | |> Plug.Conn.put_resp_header("some-header", "different value") 360 | |> Plug.Conn.send_resp(201, "Different response") 361 | 362 | refute new_conn.resp_body == original_conn.resp_body 363 | refute new_conn.resp_cookies == original_conn.resp_cookies 364 | refute new_conn.resp_headers == original_conn.resp_headers 365 | refute new_conn.status == original_conn.status 366 | end 367 | 368 | test "respects a dynamic TTL" do 369 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 370 | 371 | original_conn = 372 | conn(:post, "/hello", Jason.encode!("some-body")) 373 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 374 | |> Plug.Conn.put_req_header("content-type", "application/json") 375 | |> Plug.run( 376 | @pre_plugs ++ 377 | [ 378 | {OneAndDone.Plug, 379 | cache: TestCache, ttl: :timer.seconds(1), build_ttl_fn: fn _, ^cache_key -> 100 end} 380 | ] 381 | ) 382 | |> Plug.Conn.put_resp_content_type("text/plain") 383 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 384 | |> Plug.Conn.put_resp_header("some-header", "value") 385 | |> Plug.Conn.send_resp(200, "Okay!") 386 | 387 | Process.sleep(150) 388 | 389 | new_conn = 390 | conn(:post, "/hello") 391 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 392 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 393 | |> Plug.Conn.put_resp_content_type("text/plain") 394 | |> Plug.Conn.put_resp_cookie("some-cookie", "different value") 395 | |> Plug.Conn.put_resp_header("some-header", "different value") 396 | |> Plug.Conn.send_resp(201, "Different response") 397 | 398 | refute new_conn.resp_body == original_conn.resp_body 399 | refute new_conn.resp_cookies == original_conn.resp_cookies 400 | refute new_conn.resp_headers == original_conn.resp_headers 401 | refute new_conn.status == original_conn.status 402 | end 403 | 404 | test "if two requests share the same key and path but have different methods, it doesn't matter" do 405 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 406 | 407 | original_conn = 408 | conn(:post, "/hello", Jason.encode!("some-body")) 409 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 410 | |> Plug.Conn.put_req_header("content-type", "application/json") 411 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 412 | |> Plug.Conn.put_resp_content_type("text/plain") 413 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 414 | |> Plug.Conn.put_resp_header("some-header", "value") 415 | |> Plug.Conn.send_resp(200, "Okay!") 416 | 417 | failed_conn = 418 | conn(:put, "/hello", Jason.encode!(%{"key" => "different-body"})) 419 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 420 | |> Plug.Conn.put_req_header("content-type", "application/json") 421 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 422 | |> Plug.Conn.put_resp_content_type("text/plain") 423 | |> Plug.Conn.put_resp_cookie("some-cookie", "different-value") 424 | |> Plug.Conn.put_resp_header("some-header", "different-value") 425 | |> Plug.Conn.send_resp(204, "Different Okay!") 426 | 427 | refute failed_conn.resp_body == original_conn.resp_body 428 | refute failed_conn.resp_cookies == original_conn.resp_cookies 429 | refute failed_conn.resp_headers == original_conn.resp_headers 430 | refute failed_conn.status == original_conn.status 431 | end 432 | 433 | test "if two requests share the same key and method but have different paths, it doesn't matter" do 434 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 435 | 436 | original_conn = 437 | conn(:post, "/hello", Jason.encode!("some-body")) 438 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 439 | |> Plug.Conn.put_req_header("content-type", "application/json") 440 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 441 | |> Plug.Conn.put_resp_content_type("text/plain") 442 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 443 | |> Plug.Conn.put_resp_header("some-header", "value") 444 | |> Plug.Conn.send_resp(200, "Okay!") 445 | 446 | failed_conn = 447 | conn(:post, "/hello-again", Jason.encode!(%{"key" => "different-body"})) 448 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 449 | |> Plug.Conn.put_req_header("content-type", "application/json") 450 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 451 | |> Plug.Conn.put_resp_content_type("text/plain") 452 | |> Plug.Conn.put_resp_cookie("some-cookie", "different-value") 453 | |> Plug.Conn.put_resp_header("some-header", "different-value") 454 | |> Plug.Conn.send_resp(204, "Different Okay!") 455 | 456 | refute failed_conn.resp_body == original_conn.resp_body 457 | refute failed_conn.resp_cookies == original_conn.resp_cookies 458 | refute failed_conn.resp_headers == original_conn.resp_headers 459 | refute failed_conn.status == original_conn.status 460 | end 461 | 462 | test "if two requests share the same key but don't match, it fails" do 463 | cache_key = :rand.uniform(1_000_000) |> Integer.to_string() 464 | 465 | original_conn = 466 | conn(:post, "/hello", Jason.encode!(%{"key" => "some-body"})) 467 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 468 | |> Plug.Conn.put_req_header("content-type", "application/json") 469 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 470 | |> Plug.Conn.put_resp_content_type("text/plain") 471 | |> Plug.Conn.put_resp_cookie("some-cookie", "value") 472 | |> Plug.Conn.put_resp_header("some-header", "value") 473 | |> Plug.Conn.send_resp(200, "Okay!") 474 | 475 | failed_conn = 476 | conn(:post, "/hello", Jason.encode!(%{"key" => "different-body"})) 477 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 478 | |> Plug.Conn.put_req_header("content-type", "application/json") 479 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 480 | 481 | refute failed_conn.resp_body == original_conn.resp_body 482 | refute failed_conn.resp_cookies == original_conn.resp_cookies 483 | refute failed_conn.resp_headers == original_conn.resp_headers 484 | refute failed_conn.status == original_conn.status 485 | end 486 | 487 | test "returns 400 for idempotency keys which are too long" do 488 | cache_key = String.duplicate("a", 259) 489 | 490 | conn = 491 | conn(:post, "/hello", Jason.encode!(%{"key" => "some-body"})) 492 | |> Plug.Conn.put_req_header("idempotency-key", cache_key) 493 | |> Plug.Conn.put_req_header("content-type", "application/json") 494 | |> Plug.run(@pre_plugs ++ [{OneAndDone.Plug, cache: TestCache}]) 495 | 496 | assert conn.halted 497 | assert conn.status == 400 498 | assert ["application/json" <> _] = Plug.Conn.get_resp_header(conn, "content-type") 499 | assert conn.resp_body == ~s({"error": "idempotency_key_too_long"}) 500 | end 501 | end 502 | end 503 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------