The Ultimate Guide to Plugs: Powering Your Phoenix Applications
Phoenix is a powerful web framework for Elixir that emphasizes high performance and developer productivity. At the heart of Phoenix’s flexibility and extensibility lies Plugs — a fundamental component that handles the request-response cycle in a modular and composable manner. This comprehensive guide explores Plugs in depth, from basic concepts to advanced implementations, ensuring you have the knowledge needed to build robust and scalable Phoenix applications.
1. Introduction
Overview of Phoenix Ecosystem
Before diving into Plugs, it’s essential to understand how they fit into the broader Elixir/Phoenix ecosystem. Phoenix is a robust web framework built on top of Elixir, a functional programming language designed for building scalable and maintainable applications.
The Elixir/Phoenix Stack
- Elixir: A functional programming language built on the Erlang VM (BEAM), known for its concurrency and fault-tolerance.
- Phoenix: A web framework written in Elixir, emphasizing modularity, performance, and developer productivity.
- Plugs: The middleware layer that handles HTTP requests and responses in a modular and composable manner.
- Ecto: The database wrapper and query language, often used alongside Plugs for data management.
Plugs serve as the building blocks of Phoenix’s HTTP middleware layer. They process incoming requests and outgoing responses, enabling functionalities such as authentication, logging, parameter parsing, and more. By composing Plugs, developers can create flexible and maintainable web applications.
2. Core Concepts
What Are Plugs?
Plugs are the middleware components of the Phoenix framework, designed to handle the processing of HTTP requests and responses. They follow a standardized interface, allowing developers to create reusable and composable modules that can be inserted into the request pipeline.
Types of Plugs (Module vs Function)
Plugs come in two primary forms, each offering different levels of flexibility and simplicity:
Module Plugs
Module Plugs are the most common type and provide a structured way to implement Plug behavior. They are defined as Elixir modules that implement the Plug
behavior by defining init/1
and call/2
functions.
defmodule MyAppWeb.Plugs.CustomHeader do
@moduledoc """
Adds a custom header to the response.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts) do
Keyword.get(opts, :header_value, "DefaultValue")
end
@impl true
def call(conn, header_value) do
put_resp_header(conn, "x-custom-header", header_value)
end
end
Function Plugs
Function Plugs are simpler and consist of a single function that takes a connection and options. They are useful for straightforward tasks without the need for maintaining state or complex logic.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
def add_custom_header(conn, opts) do
put_resp_header(conn, "x-custom-header", opts[:header_value] || "DefaultValue")
end
pipeline :custom do
plug :add_custom_header, header_value: "MyValue"
end
scope "/", MyAppWeb do
pipe_through [:browser, :custom]
get ~p"/", PageController, :home
end
end
The Plug Contract
Every Plug must adhere to the following rules, ensuring consistency and reliability within the Phoenix framework:
- Input: Accept a
Plug.Conn
struct as its first argument. - Output: Return a
Plug.Conn
struct. - Idempotency: Be callable multiple times with the same input without causing unintended side effects.
Connection Struct
The Plug.Conn
struct is central to Phoenix applications, carrying all the data about the current HTTP request and response.
%Plug.Conn{
host: "example.com",
method: "GET",
path_info: ["api", "users"],
query_string: "page=1",
req_headers: [{"accept", "application/json"}],
params: %{"page" => "1"},
assigns: %{},
private: %{},
cookies: %{},
halted: false,
status: nil,
resp_body: nil,
resp_headers: []
}
Key Fields:
- host: The requested host.
- method: HTTP method (GET, POST, etc.).
- path_info: The path segments of the URL.
- query_string: The raw query string.
- req_headers: List of request headers.
- params: Parsed parameters from the request.
- assigns: A map for assigning values to be used later in the pipeline.
- halted: Indicates if the connection has been halted.
- status: HTTP response status code.
- resp_body: The body of the HTTP response.
- resp_headers: List of response headers.
Plug Pipeline
The Plug Pipeline is the sequence of Plugs that an HTTP request passes through, from the initial endpoint to the final controller and view. Each Plug in the pipeline can modify the connection, perform side effects, or halt the connection to prevent further processing.
Understanding the Plug Pipeline is crucial for effectively utilizing Plugs in your Phoenix applications.
3. Building Basic Plugs
Creating custom Plugs allows you to inject specific functionality into your Phoenix application’s request pipeline. This section guides you through building both Module and Function Plugs, configuring them, understanding common patterns, and handling basic errors.
Creating Module Plugs
Module Plugs are structured and reusable components that encapsulate specific middleware logic.
Step 1: Define the Plug Module
Start by creating a new module for your Plug. It’s a common convention to place custom Plugs within the lib/my_app_web/plugs/
directory.
defmodule MyAppWeb.Plugs.MyCustomPlug do
@moduledoc """
A custom plug that performs a specific task.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(options) do
# Initialize any options if needed
options
end
@impl true
def call(conn, opts) do
# Implement the plug's functionality
conn
end
end
Step 2: Implement the Plug Behavior
Ensure your Plug module implements the Plug
behavior by defining the init/1
and call/2
functions.
- init/1: Receives options when the Plug is added to the pipeline. Use this to set up any configuration.
- call/2: Contains the logic that manipulates the connection (
conn
). It should return the updatedconn
.
Creating Function Plugs
Function Plugs offer a more straightforward approach for simple middleware tasks.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
def add_custom_header(conn, opts) do
put_resp_header(conn, "x-custom-header", opts[:header_value] || "DefaultValue")
end
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash # Changed from fetch_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root} # New in 1.7
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :custom do
plug :add_custom_header, header_value: "MyValue"
end
scope "/", MyAppWeb do
pipe_through [:browser, :custom]
get "/", PageController, :home # Changed from :index to :home
end
end
Configuration and Options
Plugs can accept configuration options, allowing them to be flexible and reusable across different contexts.
defmodule MyAppWeb.Plugs.SetLocale do
@moduledoc """
Sets the locale for the request based on a header or default.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts) do
Keyword.get(opts, :default_locale, "en")
end
@impl true
def call(conn, default_locale) do
locale = get_req_header(conn, "accept-language") |> List.first() || default_locale
assign(conn, :locale, locale)
end
end
Integrating the Plug:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash # Changed from fetch_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root} # New in 1.7
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.SetLocale, default_locale: "en"
end
Common Patterns
Several common patterns emerge when building Plugs, enhancing their usability and maintainability:
- Assigning Values: Use
assign/3
to add data to the connection for later use.
conn |> assign(:current_user, user)
- Modifying Headers: Use
put_resp_header/3
to modify response headers.
conn |> put_resp_header("x-custom-header", "value")
- Halting the Connection: Use
halt/1
to stop further Plug execution.
conn |> put_status(:unauthorized) |> halt()
Error Handling Basics
Basic error handling within Plugs ensures that unexpected situations are managed gracefully without crashing the application.
defmodule MyAppWeb.Plugs.BasicErrorHandler do
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
try do
# Plug logic that might raise an error
conn
rescue
e in RuntimeError ->
conn
|> put_status(:internal_server_error)
|> Phoenix.Controller.put_view(html: MyAppWeb.ErrorHTML) # Updated for 1.7
|> Phoenix.Controller.render(:"500") # Updated error rendering
|> halt()
end
end
end
4. Advanced Plug Patterns
Beyond basic functionality, Plugs can handle complex tasks such as authentication, rate limiting, request tracking, response compression, and implementing the circuit breaker pattern. This section explores these advanced patterns with practical examples.
Authentication
Authentication Plugs verify the identity of users, ensuring that only authorized individuals can access certain resources.
defmodule MyAppWeb.Plugs.Authentication do
@moduledoc """
Handles user authentication by validating JWT tokens in the Authorization header.
"""
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts) do
# Validate required options
Keyword.validate!(opts, [:secret_key, :issuer])
opts
end
@impl true
def call(conn, opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, claims} <- verify_token(token, opts) do
assign(conn, :current_user, claims["sub"])
else
_ ->
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.put_view(json: MyAppWeb.ErrorJSON) # Updated for 1.7
|> Phoenix.Controller.render(:"401") # Updated error rendering
|> halt()
end
end
defp verify_token(token, opts) do
# Implementation of JWT verification
# This is a placeholder for actual JWT verification logic
case JWT.verify(token, opts[:secret_key]) do
{:ok, claims} -> validate_claims(claims, opts[:issuer])
error -> error
end
end
defp validate_claims(claims, issuer) do
cond do
claims["iss"] != issuer ->
{:error, :invalid_issuer}
claims["exp"] < System.system_time(:second) ->
{:error, :token_expired}
true ->
{:ok, claims}
end
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.Authentication, secret_key: "your_secret_key", issuer: "your_app"
end
Authorization
Authorization Plugs ensure that authenticated users have the necessary permissions to perform specific actions.
defmodule MyAppWeb.Plugs.Authorization do
@moduledoc """
Ensures that the current user has the required permissions.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts) do
Keyword.fetch!(opts, :required_role)
end
@impl true
def call(conn, required_role) do
user = conn.assigns[:current_user]
if user_has_role?(user, required_role) do
conn
else
conn
|> put_status(:forbidden)
|> Phoenix.Controller.put_view(html: MyAppWeb.ErrorHTML) # Updated for 1.7
|> Phoenix.Controller.render(:"403") # Updated error rendering
|> halt()
end
end
defp user_has_role?(user, role) do
# Implement role checking logic
user.role == role
end
end
Integration:
pipeline :admin do
plug MyAppWeb.Plugs.Authorization, required_role: "admin"
end
scope "/admin", MyAppWeb do
pipe_through [:api, :admin]
get "/dashboard", AdminController, :dashboard
end
Rate Limiting
Rate Limiting Plugs control the number of requests a client can make within a specified timeframe, preventing abuse and ensuring fair usage.
defmodule MyAppWeb.Plugs.RateLimiter do
@moduledoc """
Implements request rate limiting using Redis.
"""
import Plug.Conn
@behaviour Plug
@redis_pool MyApp.RedisPool
@impl true
def init(opts) do
Keyword.merge([limit: 100, window: 60], opts)
end
@impl true
def call(conn, opts) do
limit = opts[:limit]
window = opts[:window]
case check_rate(conn.remote_ip, limit, window) do
:ok -> conn
:exceeded ->
conn
|> put_status(:too_many_requests)
|> put_resp_header("retry-after", to_string(window))
|> halt()
end
end
defp check_rate(ip, limit, window) do
key = "rate_limit:#{:inet.ntoa(ip)}"
{:ok, _} = Redix.command(@redis_pool, ["INCR", key])
{:ok, _} = Redix.command(@redis_pool, ["EXPIRE", key, window])
case Redix.command(@redis_pool, ["GET", key]) do
{:ok, count} when String.to_integer(count) <= limit -> :ok
_ -> :exceeded
end
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.RateLimiter, limit: 200, window: 120
end
Request Tracking
Request Tracking Plugs assign a unique identifier to each request, facilitating easier tracing and debugging.
defmodule MyAppWeb.Plugs.RequestId do
@moduledoc """
Generates and tracks a unique request ID for each incoming request.
"""
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
request_id = get_request_id(conn)
Logger.metadata(request_id: request_id)
conn
|> put_resp_header("x-request-id", request_id)
|> assign(:request_id, request_id)
end
defp get_request_id(conn) do
case get_req_header(conn, "x-request-id") do
[request_id | _] -> request_id
[] -> generate_request_id()
end
end
defp generate_request_id do
Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
end
end
Integration:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash # Changed from fetch_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root} # New in 1.7
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.RequestId
end
Response Compression
Response Compression Plugs reduce bandwidth usage and improve load times by compressing HTTP responses.
defmodule MyAppWeb.Plugs.CompressResponse do
@moduledoc """
Compresses HTTP responses using gzip or deflate.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
conn
|> register_before_send(&compress_body/1)
end
defp compress_body(conn) do
case get_resp_header(conn, "content-type") do
["application/json" | _] ->
body = conn.resp_body
compressed_body = :zlib.gzip(body)
conn
|> put_resp_header("content-encoding", "gzip")
|> resp(conn.status, compressed_body)
_ ->
conn
end
end
end
Integration:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash # Changed from fetch_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root} # New in 1.7
plug :protect_from_forgery
plug :put_secure_browser_headers
plug MyAppWeb.Plugs.CompressResponse
end
Circuit Breaker Pattern
The Circuit Breaker Pattern protects downstream services by monitoring their health and preventing calls when they are unresponsive or failing.
defmodule MyAppWeb.Plugs.CircuitBreaker do
@moduledoc """
Implements a circuit breaker to protect downstream services.
"""
import Plug.Conn
@behaviour Plug
@circuit_name :api_circuit
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
threshold = opts[:threshold] || 0.5
window = opts[:window] || 60_000
case check_circuit(@circuit_name, threshold, window) do
:ok -> conn
:open ->
conn
|> put_status(:service_unavailable)
|> put_resp_header("retry-after", "30")
|> halt()
end
end
defp check_circuit(name, threshold, window) do
case :fuse.ask(name, :sync) do
:ok -> :ok
:blown -> :open
end
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.CircuitBreaker, threshold: 0.7, window: 120_000
end
5. Security Considerations
Security is paramount in web applications. Plugs can enforce various security measures to protect your application from common vulnerabilities. This section explores key security practices and how to implement them using Plugs.
Input Validation
Validating and sanitizing all incoming parameters is crucial to prevent SQL injection, Cross-Site Scripting (XSS), and other injection attacks.
defmodule MyAppWeb.Plugs.SecureParams do
@moduledoc """
Validates and sanitizes incoming request parameters.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
case validate_params(conn.params) do
:ok -> conn
{:error, _reason} ->
conn
|> put_status(:bad_request)
|> Phoenix.Controller.json(%{error: "Invalid parameters"})
|> halt()
end
end
defp validate_params(params) do
# Implement strict parameter validation
# Example: Check for SQL injection, XSS, etc.
if valid?(params), do: :ok, else: {:error, :invalid_params}
end
defp valid?(params) do
# Define validation logic
# This could involve checking parameter types, formats, etc.
true
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.SecureParams
end
CORS (Cross-Origin Resource Sharing)
Controlling cross-origin requests prevents unauthorized access and data exposure.
defmodule MyAppWeb.Plugs.SecureCORS do
@moduledoc """
Controls cross-origin requests to the application.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
origin = get_req_header(conn, "origin")
case validate_origin(origin) do
{:ok, valid_origin} ->
conn
|> put_resp_header("access-control-allow-origin", valid_origin)
|> put_resp_header("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|> put_resp_header("access-control-allow-headers", "content-type, authorization")
:error ->
conn
|> put_status(:forbidden)
|> Phoenix.Controller.json(%{error: "CORS policy does not allow access from this origin"})
|> halt()
end
end
defp validate_origin([origin]) do
allowed_origins = Application.get_env(:my_app, :allowed_origins, [])
if origin in allowed_origins, do: {:ok, origin}, else: :error
end
defp validate_origin(_), do: :error
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.SecureCORS
end
Rate Limiting
Implementing rate limiting helps prevent abuse and ensures fair usage of your APIs.
defmodule MyAppWeb.Plugs.RateLimiter do
@moduledoc """
Implements request rate limiting using Redis.
"""
import Plug.Conn
@behaviour Plug
@redis_pool MyApp.RedisPool
@impl true
def init(opts), do: Keyword.merge([limit: 100, window: 60], opts)
@impl true
def call(conn, opts) do
limit = opts[:limit]
window = opts[:window]
case check_rate(conn.remote_ip, limit, window) do
:ok -> conn
:exceeded ->
conn
|> put_status(:too_many_requests)
|> put_resp_header("retry-after", to_string(window))
|> Phoenix.Controller.json(%{error: "Rate limit exceeded", retry_after: window})
|> halt()
end
end
defp check_rate(ip, limit, window) do
key = "rate_limit:#{:inet.ntoa(ip)}"
{:ok, _} = Redix.command(@redis_pool, ["INCR", key])
{:ok, _} = Redix.command(@redis_pool, ["EXPIRE", key, window])
case Redix.command(@redis_pool, ["GET", key]) do
{:ok, count} when String.to_integer(count) <= limit -> :ok
_ -> :exceeded
end
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.RateLimiter, limit: 200, window: 120
end
Authentication Best Practices
Implementing authentication securely ensures that only authorized users can access protected resources.
- Use Secure Tokens: Utilize secure token standards like JWT for authentication.
- Store Secrets Safely: Keep secret keys and tokens out of version control and use environment variables.
- Validate Tokens Thoroughly: Always validate token signatures, expiration, and claims.
- Implement Refresh Mechanisms: Provide mechanisms to refresh tokens securely.
- Limit Token Scope: Restrict tokens to only necessary permissions and scopes.
Example: Enhanced Authentication Plug
defmodule MyAppWeb.Plugs.Authentication do
@moduledoc """
Securely handles user authentication by validating JWT tokens.
"""
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, claims} <- verify_token(token, opts) do
assign(conn, :current_user, claims["sub"])
else
_ ->
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.json(%{error: "Unauthorized"})
|> halt()
end
end
defp verify_token(token, opts) do
# Secure JWT verification logic
case JWT.verify(token, opts[:secret_key]) do
{:ok, claims} -> validate_claims(claims, opts[:issuer])
error -> error
end
end
defp validate_claims(claims, issuer) do
cond do
claims["iss"] != issuer ->
{:error, :invalid_issuer}
claims["exp"] < System.system_time(:second) ->
{:error, :token_expired}
true ->
{:ok, claims}
end
end
end
Common Security Pitfalls
- Exposing Sensitive Data: Avoid sending sensitive information in responses or logs.
- Weak Token Management: Ensure tokens are securely generated, stored, and validated.
- Improper Input Sanitization: Always sanitize and validate user inputs to prevent injection attacks.
- Insecure Configuration: Protect configuration files and environment variables containing secrets.
- Lack of HTTPS: Always use HTTPS to encrypt data in transit.
6. Performance Optimization
Optimizing Plugs is essential for maintaining high-performance Phoenix applications. This section covers key strategies for enhancing Plug performance, including plug ordering, memory management, resource cleanup, caching strategies, and asynchronous operations.
Plug Ordering
The sequence in which Plugs are added to the pipeline affects their execution and overall performance.
- Early Halting Plugs: Place Plugs that can halt the connection early to prevent unnecessary processing.
pipeline :api do
plug MyAppWeb.Plugs.Authentication
plug MyAppWeb.Plugs.RateLimiter
plug :accepts, ["json"]
end
- Resource-Intensive Plugs: Position Plugs that consume significant resources later in the pipeline to ensure they only run when necessary.
pipeline :api do
plug MyAppWeb.Plugs.Authentication
plug :accepts, ["json"]
plug MyAppWeb.Plugs.CompressResponse
end
- Logical Grouping: Group related Plugs together for clarity and efficiency.
pipeline :api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.Authentication
plug MyAppWeb.Plugs.Authorization
plug MyAppWeb.Plugs.RateLimiter
end
Memory Management
Efficient memory usage within Plugs ensures that your application remains responsive and scalable.
defmodule MyAppWeb.Plugs.MemoryOptimized do
@moduledoc """
Optimizes memory usage by streaming request bodies.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts) do
# Add defaults
Keyword.merge([
max_length: 1_048_576 # 1MB default chunk size
], opts)
end
@impl true
def call(conn, opts) do
{:ok, conn} = Plug.Conn.read_body(conn,
length: opts[:max_length],
read_length: opts[:max_length],
read_timeout: 15_000
)
conn
|> process_in_chunks()
|> register_before_send(&cleanup_resources/1)
end
defp process_in_chunks(conn) do
# Process the streamed chunks
conn
end
defp cleanup_resources(conn) do
# Cleanup using register_before_send
conn
end
end
Resource Cleanup
Ensuring that resources are properly cleaned up prevents memory leaks and other performance issues.
defmodule MyAppWeb.Plugs.ResourceCleanup do
@moduledoc """
Ensures that resources are cleaned up after request processing.
"""
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
register_before_send(conn, fn conn ->
cleanup_resources(conn)
conn
end)
end
defp cleanup_resources(conn) do
# Implement resource cleanup logic
:ok
end
end
Caching Strategies
Implementing caching within Plugs can significantly reduce response times and server load.
defmodule MyAppWeb.Plugs.CacheControl do
@moduledoc """
Implements caching strategies for responses.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts) do
Keyword.merge([
cache_control: "public, max-age=3600",
vary: "Accept, Accept-Encoding"
], opts)
end
@impl true
def call(conn, opts) do
conn
|> put_resp_header("cache-control", opts[:cache_control])
|> put_resp_header("vary", opts[:vary])
end
end
Integration:
pipeline :api do
plug MyAppWeb.Plugs.CacheControl,
cache_control: "public, max-age=86400",
vary: "Accept, Accept-Encoding"
end
Async Operations
Offloading long-running tasks to background processes enhances application responsiveness.
defmodule MyAppWeb.Plugs.AsyncLogger do
@moduledoc """
Logs request information asynchronously to avoid blocking the main thread.
"""
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
log_request(conn, opts)
end)
conn
end
defp log_request(conn, _opts) do
Logger.info("Request to #{conn.request_path} by #{:inet.ntoa(conn.remote_ip)}")
end
end
# In application.ex:
children = [
{Task.Supervisor, name: MyApp.TaskSupervisor}
# ... other children
]
Integration:
pipeline :api do
plug MyAppWeb.Plugs.AsyncLogger
end
7. Monitoring and Telemetry
Monitoring the performance and health of your Plugs ensures that your application runs smoothly and efficiently. Telemetry provides a powerful way to collect and analyze metrics from your Plugs.
Setting Up Telemetry
Integrate Telemetry into your Plugs to collect metrics and monitor their performance.
defmodule MyAppWeb.Plugs.Telemetry do
@moduledoc """
Integrates Telemetry for monitoring Plug performance.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
start_time = System.monotonic_time()
try do
# Execute the Plug's main functionality
result = conn
end_time = System.monotonic_time()
:telemetry.execute(
[:my_app, :plug, :request],
%{duration: end_time - start_time},
%{
plug: __MODULE__,
status: result.status,
path: result.request_path
}
)
result
rescue
e ->
:telemetry.execute(
[:my_app, :plug, :error],
%{},
%{
plug: __MODULE__,
error: e,
stacktrace: __STACKTRACE__
}
)
reraise e, __STACKTRACE__
end
end
end
Integration:
pipeline :api do
plug MyAppWeb.Plugs.Telemetry
end
Metrics Collection
Define and collect various metrics to gain insights into your Plugs’ performance and behavior.
defmodule MyAppWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(_arg) do
Supervisor.start_link(__MODULE__, [], name: __MODULE__)
end
@impl true
def init(_) do
children = [
{Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
# Add other reporters like Prometheus, Grafana, etc.
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
summary("my_app.plug.request.duration",
unit: {:native, :millisecond}
),
counter("my_app.plug.error.count")
]
end
end
Performance Monitoring
Use Telemetry to monitor and measure Plug performance, enabling you to identify and address bottlenecks.
defmodule MyAppWeb.Plugs.Instrumented do
@moduledoc """
Wraps Plugs with Telemetry instrumentation.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
start_time = System.monotonic_time()
try do
result = do_call(conn, opts)
end_time = System.monotonic_time()
:telemetry.execute(
[:my_app, :plug, :call],
%{duration: end_time - start_time},
%{plug: __MODULE__}
)
result
rescue
e ->
:telemetry.execute(
[:my_app, :plug, :error],
%{},
%{
plug: __MODULE__,
error: e,
stacktrace: __STACKTRACE__
}
)
reraise e, __STACKTRACE__
end
end
defp do_call(conn, opts) do
# Implement the actual Plug functionality here
conn
end
end
Error Tracking
Capture and report errors within Plugs to Telemetry for comprehensive monitoring.
defmodule MyAppWeb.Plugs.ErrorTracker do
@moduledoc """
Tracks and reports errors occurring within Plugs.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
try do
# Plug logic that might raise an error
conn
rescue
e ->
:telemetry.execute(
[:my_app, :plug, :error],
%{},
%{
plug: __MODULE__,
error: e,
stacktrace: __STACKTRACE__
}
)
reraise e, __STACKTRACE__
end
end
end
Visualization
Use tools like Grafana, Prometheus, or Kibana to visualize the telemetry data collected from your Plugs, enabling you to monitor trends, detect anomalies, and make informed optimization decisions.
8. Testing
Ensuring that your Plugs function correctly is vital for maintaining application reliability. This section covers strategies for unit testing, integration testing, mocking dependencies, common testing patterns, and ensuring comprehensive test coverage.
Unit Testing Plugs
Unit tests focus on testing individual Plugs in isolation, ensuring they perform their intended functionality correctly.
defmodule MyAppWeb.Plugs.AuthenticationTest do
use MyAppWeb.ConnCase, async: true # Add async: true for parallel testing
import Phoenix.ConnTest
alias MyAppWeb.Plugs.Authentication
describe "call/2" do
test "assigns current_user when token is valid", %{conn: conn} do
token = generate_valid_token()
conn =
build_conn() # Use build_conn() instead of conn fixture
|> put_req_header("authorization", "Bearer #{token}")
|> Authentication.call(secret_key: "secret", issuer: "myapp")
assert conn.assigns[:current_user] == "user123" # More specific assertion
refute conn.halted
end
test "halts with 401 when token is invalid", %{conn: conn} do
conn =
build_conn()
|> put_req_header("authorization", "Bearer invalid_token")
|> Authentication.call(secret_key: "secret", issuer: "myapp")
assert conn.halted
assert conn.status == 401
assert json_response(conn, 401) == %{"error" => "unauthorized"} # Test response body
end
end
end
Integration Testing
Integration tests assess how Plugs interact within the entire pipeline and with other components like controllers and views.
defmodule MyAppWeb.Plugs.IntegrationTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.ConnTest
@endpoint MyAppWeb.Endpoint # Explicitly define endpoint
describe "Full Plug Pipeline" do
test "authentication and authorization" do
token = generate_valid_token()
response =
build_conn()
|> put_req_header("authorization", "Bearer #{token}")
|> get("/api/protected/resource") # Updated to use versioned API path
|> json_response(200)
assert response == %{
"data" => "Protected Resource",
"status" => "success"
}
end
test "handles invalid authentication gracefully" do
response =
build_conn()
|> get("/api/protected/resource")
|> json_response(401)
assert response == %{
"error" => "unauthorized",
"status" => "error"
}
end
end
end
Mock Dependencies
Use mocking libraries like Mox to simulate external dependencies such as databases or external APIs during testing.
defmodule MyAppWeb.Plugs.RateLimiterTest do
use MyAppWeb.ConnCase, async: true
import Mox
import Phoenix.ConnTest
# Define test behaviors
@behaviour MyApp.RateLimiter.Behaviour
setup :verify_on_exit!
test "allows request when under limit" do
test_pid = self()
MyApp.RateLimiter.Mock
|> expect(:increment_counter, fn key, ttl ->
send(test_pid, {:increment_counter, key, ttl})
{:ok, 1}
end)
|> expect(:get_counter, fn key ->
send(test_pid, {:get_counter, key})
{:ok, 1}
end)
conn =
build_conn()
|> put_req_header("x-forwarded-for", "127.0.0.1")
|> MyAppWeb.Plugs.RateLimiter.call(limit: 100, window: 60)
refute conn.halted
assert_received {:increment_counter, "rate_limit:127.0.0.1", 60}
assert_received {:get_counter, "rate_limit:127.0.0.1"}
end
end
Common Testing Patterns
- Setup and Teardown: Use setup blocks to prepare the testing environment and ensure isolation between tests.
- Edge Case Testing: Test boundary conditions and unexpected inputs to ensure robust error handling.
- Data-Driven Tests: Utilize data-driven approaches to test multiple scenarios with different inputs.
Test Coverage
Ensure comprehensive test coverage by writing tests that cover all functionalities and edge cases of your Plugs. Utilize tools like ExCoveralls to measure and report test coverage.
9. Real-World Examples
Examining how Plugs are used in real-world applications provides valuable insights into their practical applications and best practices. This section explores examples such as multi-tenant applications, API gateways, authentication systems, and rate-limiting services.
Multi-tenant Applications
In a multi-tenant SaaS application, Plugs can enforce tenant isolation by loading tenant-specific configurations based on request headers.
defmodule MyApp.Plugs.TenantContext do
import Plug.Conn
require Logger
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
with ["tenant-" <> tenant_id] <- get_req_header(conn, "x-tenant-id"),
{:ok, tenant} <- load_tenant(tenant_id) do
Logger.metadata(tenant_id: tenant_id) # Add structured logging
conn
|> assign(:current_tenant, tenant)
|> put_private(:tenant_id, tenant_id)
|> put_resp_header("x-tenant-id", tenant_id) # Echo tenant ID back
else
_ ->
Logger.warning("Tenant context missing or invalid",
tenant_id: get_req_header(conn, "x-tenant-id"))
conn
|> put_status(:unauthorized)
|> put_view(json: MyAppWeb.ErrorJSON) # Use new error view convention
|> render(:unauthorized, %{message: "Unauthorized: Tenant not found"})
|> halt()
end
end
end
Integration:
pipeline :api do
plug :accepts, ["json"]
plug MyApp.Plugs.TenantContext
plug MyAppWeb.Plugs.Authentication,
secret_key: Application.fetch_env!(:my_app, :secret_key), # Use runtime config
issuer: Application.fetch_env!(:my_app, :issuer)
end
API Gateway
An API Gateway can use Plugs to manage and stabilize interactions with downstream services, implementing patterns like circuit breakers and request throttling.
defmodule MyApp.Plugs.APIProxy do
import Plug.Conn
require Logger
alias MyApp.CircuitBreaker
@behaviour Plug
@telemetry_event [:my_app, :api_proxy, :request]
@impl true
def call(conn, opts) do
downstream_url = opts[:downstream_url]
start_time = System.monotonic_time()
:telemetry.span(@telemetry_event, %{path: conn.request_path}, fn ->
case CircuitBreaker.check(:api_circuit) do
:ok ->
result = make_downstream_request(conn, downstream_url)
{result, %{status: result.status_code}}
:blown ->
conn = handle_circuit_blown(conn)
{conn, %{status: :service_unavailable}}
end
end)
end
defp make_downstream_request(conn, downstream_url) do
Finch.build(:get, downstream_url <> conn.request_path)
|> Finch.request!(MyApp.Finch) # Use Finch instead of HTTPoison
|> handle_downstream_response(conn)
rescue
e ->
Logger.error("Downstream request failed: #{inspect(e)}")
handle_downstream_error(conn)
end
defp handle_downstream_response(response, conn) do
conn
|> put_resp_headers(response.headers)
|> send_resp(response.status, response.body)
end
defp handle_circuit_blown(conn) do
conn
|> put_status(:service_unavailable)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:service_unavailable, %{
message: "Service temporarily unavailable",
retry_after: 30
})
|> halt()
end
end
Integration:
pipeline :api_gateway do
plug MyApp.Plugs.CircuitBreaker,
circuit_name: :api_circuit,
failure_threshold: 5,
reset_timeout: 30_000
plug MyApp.Plugs.APIProxy,
downstream_url: {MyApp.Config, :get_downstream_url, []} # Use runtime configuration
end
scope "/proxy", MyAppWeb do
pipe_through :api_gateway
get "/*path", ProxyController, :proxy
end
Authentication System
A comprehensive authentication system using Plugs ensures secure access to protected resources.
defmodule MyAppWeb.Plugs.ComprehensiveAuthentication do
import Plug.Conn
require Logger
alias MyApp.Accounts
alias MyApp.Guardian # Use Guardian for JWT handling
@impl true
def call(conn, opts) do
with {:ok, token} <- get_authentication_token(conn),
{:ok, claims} <- Guardian.decode_and_verify(token),
{:ok, user} <- Accounts.get_user(claims["sub"]),
{:ok, _} <- verify_session(conn, user) do
conn
|> assign(:current_user, user)
|> assign(:user_token, token)
else
error ->
Logger.warning("Authentication failed",
error: inspect(error),
remote_ip: :inet.ntoa(conn.remote_ip) |> to_string())
conn
|> clear_session()
|> delete_resp_cookie("_my_app_key")
|> put_status(:unauthorized)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:unauthorized, %{message: "Invalid authentication"})
|> halt()
end
end
defp get_authentication_token(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] -> {:ok, token}
_ -> get_session_token(conn)
end
end
defp get_session_token(conn) do
case get_session(conn, :user_token) do
nil -> {:error, :token_missing}
token -> {:ok, token}
end
end
defp verify_session(conn, user) do
case get_session(conn, :session_id) do
nil -> {:ok, nil}
session_id ->
Accounts.verify_session(user.id, session_id)
end
end
end
Integration:
pipeline :authenticated do
plug MyAppWeb.Plugs.ComprehensiveAuthentication, secret_key: "your_secret_key", issuer: "your_app"
end
scope "/protected", MyAppWeb do
pipe_through [:api, :authenticated]
get "/dashboard", DashboardController, :index
end
Rate Limiting Service
Implementing a dedicated rate-limiting service ensures consistent enforcement of request quotas across different parts of your application.
defmodule MyAppWeb.Plugs.RateLimitingService do
import Plug.Conn
require Logger
alias MyApp.RateLimit
@behaviour Plug
@telemetry_event [:my_app, :rate_limit, :check]
@impl true
def call(conn, opts) do
client_id = get_client_id(conn)
limit = opts[:limit] || 100
window = opts[:window] || 60
:telemetry.span(@telemetry_event, %{client_id: client_id}, fn ->
case RateLimit.check_rate(client_id, limit, window) do
{:ok, current_count} ->
conn = add_rate_limit_headers(conn, limit, current_count, window)
{conn, %{result: :allowed}}
{:error, :exceeded, current_count} ->
conn =
conn
|> put_status(:too_many_requests)
|> put_view(json: MyAppWeb.ErrorJSON)
|> add_rate_limit_headers(limit, current_count, window)
|> render(:rate_limited, %{
message: "Rate limit exceeded",
retry_after: window
})
|> halt()
{conn, %{result: :blocked}}
end
end)
end
defp add_rate_limit_headers(conn, limit, current_count, window) do
conn
|> put_resp_header("x-ratelimit-limit", to_string(limit))
|> put_resp_header("x-ratelimit-remaining", to_string(max(0, limit - current_count)))
|> put_resp_header("x-ratelimit-reset", to_string(window))
end
defp get_client_id(conn) do
cond do
user_id = conn.assigns[:current_user_id] ->
"user:#{user_id}"
[api_key] = get_req_header(conn, "x-api-key") ->
"api:#{api_key}"
true ->
"ip:#{:inet.ntoa(conn.remote_ip) |> to_string()}"
end
end
end
Integration:
pipeline :api do
plug MyAppWeb.Plugs.RateLimitingService, limit: 200, window: 120
end
10. Best Practices
Adhering to best practices ensures that your Plugs are maintainable, secure, and performant. This section outlines essential best practices for developing Plugs in Phoenix.
Documentation
- Module Documentation: Use
@moduledoc
to describe the purpose and usage of each Plug.
defmodule MyAppWeb.Plugs.CustomHeader do
@moduledoc """
Adds a custom header to the response.
""" # ... end
- Function Documentation: Use
@doc
for documenting functions within your Plug modules.
@doc """
Initializes options for the CustomHeader Plug.
"""
def init(opts), do: opts
Error Handling
- Graceful Failures: Handle errors gracefully within Plugs to prevent application crashes.
defmodule MyAppWeb.Plugs.SafeOperation do
@moduledoc """
Performs a safe operation with error handling.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
try do
# Safe operation
conn
rescue
e ->
conn
|> put_status(:internal_server_error)
|> send_resp(:internal_server_error, "An error occurred")
|> halt()
end
end
end
- Logging Errors: Log errors for debugging and monitoring purposes.
Logger.error("Error in SafeOperation Plug: #{inspect(e)}")
Configuration
- Validate Options: Ensure that all required options are provided and valid in the
init/1
function.
def init(opts) do required = [:secret_key, :issuer] missing = required -- Keyword.keys(opts) if missing == [] do opts else raise ArgumentError, "Missing required options: #{inspect(missing)}" end end
- Default Values: Provide sensible default values for optional configuration parameters.
def init(opts) do Keyword.merge([limit: 100, window: 60], opts) end
Security
- Sanitize Inputs: Always sanitize and validate inputs to prevent injection attacks.
- Use HTTPS: Ensure that all communications are encrypted using HTTPS.
- Manage Secrets Securely: Store sensitive data like API keys and secret tokens in environment variables or secure storage solutions.
- Regularly Update Dependencies: Keep your dependencies up-to-date to patch known vulnerabilities.
Performance
- Minimize Processing: Avoid unnecessary computations within Plugs to reduce latency.
- Leverage Caching: Implement caching strategies to minimize repetitive processing.
- Asynchronous Operations: Offload long-running tasks to background processes to keep the main pipeline responsive.
- Benchmarking: Regularly benchmark your Plugs to identify and address performance bottlenecks.
Testing
- Comprehensive Coverage: Write tests that cover various scenarios, including edge cases.
- Isolate Plugs: Test Plugs in isolation to ensure they perform their specific tasks correctly.
- Mock External Dependencies: Use mocking libraries to simulate interactions with external systems during testing.
Single Responsibility Principle
Design Plugs to handle a single, well-defined task, enhancing reusability and maintainability.
defmodule MyAppWeb.Plugs.Logging do
@moduledoc """
Logs request details.
"""
import Plug.Conn
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, opts) do
Logger.info("Request: #{conn.method} #{conn.request_path}")
conn
end
end
11. Troubleshooting
Even with best practices, issues can arise when working with Plugs. This section addresses common problems, debugging techniques, performance issues, memory leaks, and pipeline-related problems.
Common Issues
- Halted Connections
- Problem: Plugs not executing after a halt.
- Solution: Review the order of Plugs in your pipeline. Ensure that Plugs intended to execute after a halting Plug are placed before it.
2. Memory Leaks
- Problem: Accumulating data in the process dictionary.
- Solution: Use connection assigns or ETS for temporary storage instead of the process dictionary to prevent memory leaks.
3. Performance Bottlenecks
- Problem: Slow response times.
- Solution: Profile Plug execution using Telemetry to identify and optimize slow-performing Plugs. Consider asynchronous processing for long-running tasks.
4. Pipeline Problems
- Problem: Incorrect Plug sequencing leading to unexpected behavior.
- Solution: Ensure that Plugs are ordered logically, respecting dependencies and execution flow.
Debugging Techniques
- Logging: Insert
Logger
statements within Plugs to trace execution and inspect connection data.
Logger.debug("Executing MyCustomPlug with options: #{inspect(opts)}")
- Inspecting Connection: Use
IO.inspect/1
orinspect/1
to view the state of the connection at various points.
def call(conn, opts) do IO.inspect(conn.assigns, label: "Assigns before MyCustomPlug") conn end
- Using Debugger: Utilize Elixir’s debugging tools, such as
:debugger
orIEx.pry
, to step through Plug execution.
def call(conn, opts) do require IEx IEx.pry conn end
Performance Problems
- High Latency: Identify Plugs that introduce significant delays and optimize their logic.
- Excessive Memory Usage: Monitor memory consumption and refactor Plugs to handle data more efficiently.
- Resource Contention: Avoid Plugs that lock resources for extended periods, causing contention and reduced throughput.
Memory Leaks
- Symptoms: Increasing memory usage over time without corresponding drops.
- Causes: Accumulating data in the process dictionary, not releasing resources, or retaining large structures in assigns.
- Solutions:
- Use
assign/3
instead of the process dictionary. - Ensure that any allocated resources are properly cleaned up.
- Avoid retaining large data structures unless necessary.
Pipeline Problems
- Unexpected Behavior: Plugs executing out of order or missing execution.
- Solutions:
- Verify the Plug order in your pipelines.
- Ensure that Plugs are not inadvertently halting the connection.
- Use comprehensive tests to validate pipeline behavior.
Development Tools
- Mix: Elixir’s build tool for managing projects and dependencies.
- ExUnit: Elixir’s built-in testing framework.
- Mox: A mocking library for Elixir, useful for testing Plugs with external dependencies.
- Telemetry: A dynamic dispatching library for metrics and instrumentation.
- Dialyzer: A static analysis tool for Erlang and Elixir, useful for detecting type errors and code discrepancies.
Conclusion and Next Steps
Understanding and effectively using Plugs is crucial for building robust and scalable Phoenix applications. This comprehensive guide has covered everything from basic concepts to advanced implementations, equipping you with the knowledge needed to harness the full power of Plugs.
Learning Path
Start with Basic Plugs
- Understand the Plug specification and contract.
- Implement simple Plugs to manipulate the connection.
Experiment with Provided Examples
- Use the interactive learning resources to try out different Plug implementations.
- Modify and extend example Plugs to deepen your understanding.
Implement Security Measures
- Apply security-focused Plugs like authentication, rate limiting, and CORS handling.
- Ensure your application adheres to best security practices.
Add Monitoring and Telemetry
- Integrate Telemetry to monitor Plug performance and collect metrics.
- Use the collected data to optimize and maintain application health.
Optimize Performance
- Analyze and optimize Plug ordering and resource usage.
- Implement caching and asynchronous processing where appropriate.
Additional Resources
- Books
Programming Phoenix by Chris McCord, Bruce Tate, and José Valim
Functional Web Development with Elixir, OTP, and Phoenix by Lance Halvorsen
- Development Tools
Mix: Elixir’s build tool.
ExUnit: Testing framework.
Mox: Mocking library.
Telemetry: Metrics collection.
Dialyzer: Static analysis tool.
Remember that Plugs are one of Phoenix’s most powerful features, allowing you to build modular, maintainable, and performant web applications. Start small, experiment often, and gradually build up to more complex implementations to master the art of using Plugs in Phoenix.