View Source OneAndDone.Plug (One and Done v0.1.5)

Easy to use plug for idempoent requests.

getting-started

Getting started

  1. Add :one_and_done to your list of dependencies in mix.exs:

     def deps do
       [
         {:one_and_done, "~> 0.1.5"}
       ]
     end
  2. Add the plug to your router:

     pipeline :api do
       plug OneAndDone.Plug,
         # Required: must conform to OneAndDone.Cache (Nebulex.Cache works fine)
         cache: MyApp.Cache,
    
         # Optional: How long to keep entries, defaults to 86_400 (24 hours)
         ttl: 86_400,
    
         # Optional: Function reference to generate an idempotence TTL per request.
         # Takes the current `Plug.Conn` as the first argument and the current
         # `idempotency_key` as the second.
         #
         # When provided, this function is called before falling back to the
         # `ttl` option.
         #
         # Defaults to `nil`.
         build_ttl_fn: &OneAndDone.Plug.build_ttl/2,
    
         # Optional: Which methods to cache, defaults to ["POST", "PUT"]
         # Used by the default idempotency_key_fn to quickly determine if the request
         # can be cached. If you override idempotency_key_fn, consider checking the
         # request method in your implementation for better performance.
         # `supported_methods` is available in the opts passed to the idempotency_key_fn.
         supported_methods: ["POST", "PUT"],
    
         # Optional: Which response headers to ignore when caching, defaults to ["x-request-id"]
         # When returning a cached response, some headers should not be modified by the contents of the cache.
         #
         # Instead, the ignored headers are returned with the prefix `original-`.
         #
         # By default, the `x-request-id` header is not modified. This means that each request will have a
         # unique `x-request-id` header, even if a cached response is returned for a request. The original request
         # ID is still available under `original-x-request-id`.
         #
         # If you are using a framework that sets a different header for request IDs, you can add it to this list.
         ignored_response_headers: ["x-request-id"],
    
         # Optional: Function reference to generate the idempotency key for a given request.
         # By default, uses the value of the `Idempotency-Key` header.
         # Must return a binary or nil. If nil is returned, the request will not be cached.
         # Default function implementation:
         #
         # fn conn, opts -> # Opts is the same as the opts passed to the plug
         #   if Enum.any?(opts.supported_methods, &(&1 == conn.method)) do
         #     conn
         #     |> Plug.Conn.get_req_header("idempotency-key") # Request headers are always downcased
         #     |> List.first()
         #   else
         #     nil
         #   end
         # end
         idempotency_key_fn: &OneAndDone.Plug.idempotency_key_from_conn/2,
    
         # Optional: Function reference to generate the cache key for a given request.
         # Given the conn & idempotency key (returned from idempotency_key_fn), this function
         # should return a term that will be used as the cache key.
         # By default, it returns a tuple of the module name and the idempotency key.
         # Default function implementation: fn _conn, idempotency_key -> {__MODULE__, idempotency_key}
         cache_key_fn: &OneAndDone.Plug.build_cache_key/2
    
         # Optional: Flag to enable request match checking. Defaults to true.
         # If true, the function given in check_requests_match_fn will be called to determine if the
         # original request matches the current request.
         # If false, no such check shall be performed.
         request_matching_checks_enabled: true,
    
         # Optional: Function reference to determine if the original request matches the current request.
         # Given the current connection and a hash of the original request, this function should return
         # true if the current request matches the original request.
         # By default, uses `:erlang.phash2/2` to generate a hash of the current request. If the `hashes`
         # do not match, the request is not idempotent and One and Done will return a 400 response.
         # To disable this check, use `fn _conn, _original_request_hash -> true end`
         # Default function implementation:
         #
         # fn conn, original_request_hash ->
         #   request_hash =
         #     Parser.build_request(conn)
         #     |> Request.hash()
         #
         #   cached_response.request_hash == request_hash
         # end
         check_requests_match_fn: &OneAndDone.Plug.matching_request?/2,
    
         # Optional: Max length of each idempotency key. Defaults to 255 characters.
         # If the idempotency key is longer than this, we respond with error 400.
         # Set to 0 to disable this check.
         max_key_length: 255
     end

That's it! POST and PUT requests will now be cached by default for 24 hours.

response-headers

Response headers

By default, the "x-request-id" header is not modified. This means that each request will have a unique "x-request-id" header, even if a cached response is returned for a request. By default, the "original-x-request-id" header is set to the value of the "x-request-id" header from the original request. This is useful for tracing the original request that was cached. One and Done sets the "idempotent-replayed" header to "true" if a cached response is returned.

telemetry

Telemetry

To monitor the performance of the OneAndDone plug, you can hook into OneAndDone.Telemetry.

For a complete list of events, see OneAndDone.Telemetry.events/0.

example

Example

# In your application.ex
# ...
:telemetry.attach_many(
  "one-and-done",
  OneAndDone.Telemetry.events(),
  &MyApp.Telemetry.handle_event/4,
  nil
)
# ...

# In your telemetry module:
defmodule MyApp.Telemetry do
  require Logger

  def handle_event([:one_and_done, :request, :stop], measurements, _metadata, _config) do
    duration = System.convert_time_unit(measurements.duration, :native, :millisecond)

    Logger.info("Running one_and_done took #{duration}ms")

    :ok
  end

  # Catch-all for unhandled events
  def handle_event(_, _, _, _) do
    :ok
  end
end

Link to this section Summary

Link to this section Functions

Link to this function

matching_request?(conn, cached_response)

View Source