Function middleware for explicitly annotated definitions.
Basic Usage
Patterns.Middleware wraps functions that opt in with @middleware:
defmodule Blog do
use Patterns.Middleware
@middleware [Blog.Middlewares.AuthorizeEditor, Blog.Middlewares.RecordAuditLog]
def create_post(attrs) do
{:ok, attrs}
end
endPublic API
- Entrypoints:
@middlewareannotations andrun/4. - Callback flow:
process/2andyield/2. get_private/3,put_private/3,update_private/4, anddelete_private/2store middleware metadata on the resolution.get_super/1,put_super/2, andupdate_super/2inspect or replace the operation called after the last middleware yields.
Middleware Modules
Middleware modules can use Patterns.Middleware to import yield/2 and the
super helpers, but they should still declare @behaviour Patterns.Middleware
explicitly.
Middleware modules implement process/2. For middleware attached to a function,
the first argument passed to process/2 is a list containing the arguments passed
to the wrapped function. The second argument is a
Patterns.Middleware.Resolution with metadata about the call.
For annotated functions, input is always the wrapped function argument list.
For direct run/4 callers, input is whatever value was passed to run/4 or
the previous yield/2.
process/2 must return {result, resolution}. Calling yield/2 continues to
the next middleware, or to the wrapped function when there is no middleware
left. Returning {result, resolution} without calling yield/2 halts the
stack.
Return Values
Calling an annotated function returns only the wrapped result. Middleware
callbacks, yield/2, and run/4 return {result, resolution}. If the wrapped
function returns {:ok, post}, middleware receives and returns
{{:ok, post}, resolution}.
defmodule Blog.Middlewares.RecordAuditLog do
use Patterns.Middleware
@behaviour Patterns.Middleware
@impl Patterns.Middleware
def process(args, resolution) do
yield(args, resolution)
end
endFunction Arguments
Calling Blog.create_post(%{title: "Hello"}) passes
[%{title: "Hello"}] as args to the first middleware. Calling a wrapped
function with multiple arguments, such as Blog.publish_post(123, force: true),
passes [123, [force: true]].
resolution.args stores the original argument list. If middleware passes a
changed value to yield/2, the changed value is passed to the rest of the
stack, but resolution.args still refers to the original call.
Private Metadata
Middleware can use private metadata to communicate with later or earlier
middleware in the same invocation. Because yield/2 returns the updated
resolution, middleware can inspect private metadata written by later middleware.
def process(args, resolution) do
{result, resolution} = yield(args, resolution)
if get_private(resolution, :paginated?) do
{{:paginated, result}, resolution}
else
{result, resolution}
end
endStacking Middleware
Middleware can be attached to public or private functions. A stack can be
declared as a list, or by repeating @middleware before the function:
@middleware Blog.Middlewares.AuthorizeEditor
@middleware Blog.Middlewares.RecordAuditLog
def publish_post(post_id) do
{:ok, {:published, post_id}}
end
@middleware [Blog.Middlewares.AuthorizeEditor, Blog.Middlewares.RecordAuditLog]
defp persist_post(attrs) do
{:ok, attrs}
end@middleware is captured by the next def or defp. Middleware is tracked
per function name and arity, not per clause, so an annotated clause wraps the
whole function/arity. For functions with multiple clauses or default arguments,
prefer annotating the function head. Different stacks for different clauses of
the same function are rejected at compile time.
@middleware Blog.Middlewares.AuthorizeEditor
def publish_post(post_id, opts \ [])
def publish_post(post_id, opts) do
{:ok, {post_id, opts}}
endYielding
Middleware continues the stack by calling yield/2. Passing a changed
argument list to yield/2 calls the wrapped function with those changed
arguments.
yield/2 returns {result, resolution} so middleware can inspect the return
value and any resolution changes made by later middleware.
For function middleware, pass a list matching the wrapped function arity. If the final input cannot be matched to the wrapped arity, the generated wrapper raises.
Middleware runs in the order it is declared. In a stack like
[AuthorizeEditor, RecordAuditLog], AuthorizeEditor runs first and
RecordAuditLog runs inside it. When the innermost, rightmost middleware calls
yield/2, Patterns.Middleware calls the original wrapped function.
Code before yield/2 runs before the rest of the stack and the wrapped
function. Code after yield/2 runs after the wrapped function returns.
def process([attrs], resolution) do
attrs = Map.update!(attrs, :title, &String.trim/1)
case yield([attrs], resolution) do
{{:ok, post}, resolution} ->
{{:ok, Map.put(post, :audited, true)}, resolution}
{error, resolution} ->
{error, resolution}
end
endSuper
The super function is the operation called when the last middleware yields. For
functions wrapped with @middleware, super calls the original function body.
Code that uses run/4 directly can provide a different super function.
{result, resolution} =
Patterns.Middleware.run(stack, args, resolution, fn args, resolution ->
call_existing_operation(args, resolution)
end)Middleware can replace super before continuing the stack:
def process(args, resolution) do
resolution =
put_super(resolution, fn args, resolution ->
call_remote_operation(args, resolution)
end)
yield(args, resolution)
endMiddleware can also wrap super before continuing the stack. super returns
the wrapped operation's raw result, not {result, resolution}. In this example,
{:ok, result} is the wrapped operation result.
def process(args, resolution) do
resolution =
update_super(resolution, fn super ->
fn args, resolution ->
{:ok, result} = super.(args, resolution)
{:ok, Map.put(result, :audited, true)}
end
end)
{result, resolution} = yield(args, resolution)
{result, resolution}
endIf multiple middleware wrap super before yielding, later middleware in the stack wrap the super function produced by earlier middleware.
Super changes affect only the current invocation because super is stored on the resolution.
Caveats
Clause matching happens later
Middleware currently runs before the original function clauses and guards match. Do not rely on middleware receiving only values accepted by the wrapped clauses. This ordering is an implementation detail and may change in the future.
Middleware return values
Middleware return values are not normalized or validated. Middleware must return
{result, resolution}.
Halting
Middleware can also halt the stack by returning {result, resolution} without
calling yield/2.
def process([user | _] = args, resolution) do
if user.admin? do
yield(args, resolution)
else
{{:error, :unauthorized}, resolution}
end
end
Summary
Callbacks
Handles the current middleware value.
Functions
Sets up @middleware annotations for the using module.
Deletes a value from resolution.private.
Returns a value from resolution.private.
Returns the super function for a middleware invocation.
Stores a value in resolution.private.
Replaces the super function for a middleware invocation.
Runs a middleware stack, then calls super after the last middleware yields.
Updates a value in resolution.private.
Updates the super function for a middleware invocation.
Continues the active middleware stack with the given value.
Callbacks
@callback process(term(), Patterns.Middleware.Resolution.t()) :: {term(), Patterns.Middleware.Resolution.t()}
Handles the current middleware value.
When middleware is attached to a function with @middleware, the first
argument passed to process/2 is the list of arguments passed to the wrapped
function.
For example, a wrapped create_post(attrs) function receives [attrs], while
a wrapped publish_post(post_id, opts) function receives [post_id, opts].
Call Patterns.Middleware.yield/2 with the current value and resolution to
continue the stack. Returning {result, resolution} without yielding halts the
stack and uses result as the wrapped function result.
Work done before yield/2 pre-processes the call. Work done after yield/2
post-processes the result returned by the rest of the stack.
Functions
Sets up @middleware annotations for the using module.
Also imports yield/2, private helpers, and super helpers for middleware
implementations.
@spec delete_private(Patterns.Middleware.Resolution.t(), term()) :: Patterns.Middleware.Resolution.t()
Deletes a value from resolution.private.
Example
resolution = delete_private(resolution, :paginated?)
@spec get_private(Patterns.Middleware.Resolution.t(), term(), term()) :: term()
Returns a value from resolution.private.
Returns default when the key is missing.
Example
get_private(resolution, :paginated?, false)
@spec get_super(Patterns.Middleware.Resolution.t()) :: (term(), Patterns.Middleware.Resolution.t() -> term())
Returns the super function for a middleware invocation.
Raises when no super function is available.
@spec put_private(Patterns.Middleware.Resolution.t(), term(), term()) :: Patterns.Middleware.Resolution.t()
Stores a value in resolution.private.
Example
resolution = put_private(resolution, :paginated?, true)
@spec put_super(Patterns.Middleware.Resolution.t(), (term(), Patterns.Middleware.Resolution.t() -> term())) :: Patterns.Middleware.Resolution.t()
Replaces the super function for a middleware invocation.
Use this when middleware should replace the operation that runs after the last middleware yields.
@spec run([module()] | module(), term(), Patterns.Middleware.Resolution.t(), (term(), Patterns.Middleware.Resolution.t() -> term())) :: {term(), Patterns.Middleware.Resolution.t()}
Runs a middleware stack, then calls super after the last middleware yields.
Returns {result, resolution}.
run/4 is exposed for code that wants to integrate with the middleware
pattern without using @middleware annotations. Use it when the stack, input,
or super operation needs to be selected dynamically, or when a higher-level
wrapper needs more parameterized behavior than function annotations can
express.
The super function receives the current input and resolution, then returns
the wrapped operation's raw result. run/4 wraps that raw result with the final
resolution.
For functions wrapped with @middleware, super calls the original function.
Code that uses Patterns.Middleware directly can pass a different super
function.
For example, another library or wrapper module could run middleware around an existing operation:
resolution = %Patterns.Middleware.Resolution{
module: Blog,
function: :create_post,
arity: 1,
args: [attrs]
}
{result, resolution} =
Patterns.Middleware.run(stack, args, resolution, fn args, resolution ->
call_existing_operation(args, resolution)
end)The super function must be an arity-2 function. If it returns
{result, resolution}, that tuple is treated as the raw result.
@spec update_private(Patterns.Middleware.Resolution.t(), term(), term(), (term() -> term())) :: Patterns.Middleware.Resolution.t()
Updates a value in resolution.private.
Example
resolution = update_private(resolution, :attempts, 0, &(&1 + 1))
@spec update_super( Patterns.Middleware.Resolution.t(), ((term(), Patterns.Middleware.Resolution.t() -> term()) -> (term(), Patterns.Middleware.Resolution.t() -> term())) ) :: Patterns.Middleware.Resolution.t()
Updates the super function for a middleware invocation.
fun receives the current super function and must return a replacement super
function. Raises when no super function is available.
Example
resolution =
update_super(resolution, fn super ->
fn args, resolution ->
case super.(args, resolution) do
{:ok, result} -> {:ok, Map.put(result, :processed?, true)}
error -> error
end
end
end)
@spec yield(term(), Patterns.Middleware.Resolution.t()) :: {term(), Patterns.Middleware.Resolution.t()}
Continues the active middleware stack with the given value.
Returns {result, resolution}. If there is more middleware, yield/2 calls
the next middleware's process/2. If the stack is empty, it calls the current
super function and wraps its raw result with the current resolution.
Example
{result, resolution} = yield(args, resolution)