struct

Kemal::Controller

Inherits Struct < Value < Object

Abstract controller class that provides a structured way to define HTTP endpoints.

Controllers are structs, so the overhead is minimal and you can still use all Kemal features. Method parameters automatically map to GET/POST/URL parameters with type-safe conversion.

Example

struct UsersController < Kemal::Controller
  @[Get("/users")]
  def index
    "Listing all users"
  end

  @[Get("/users/:id")]
  def show(id : Int32)
    "Showing user with ID: #{id}"
  end

  @[Post("/users")]
  def create(name : String, age : Int32, description : String?)
    "Creating user with name: #{name}, age: #{age}, description: #{description}"
  end
end

Supported Parameter Types

  • String
  • Int32, Int64
  • Bool
  • Array (with nested support)
  • NamedTuple (with nested support)
  • Hash with String keys (with nested support)
  • Nilable versions of the above

Parameter Mapping

  • name=John becomes name : String
  • item[foo]=bar becomes item : NamedTuple(foo: String)
  • items[]=1&items[]=2 becomes items : Array(Int32)
  • items[][id]=1&items[][quantity]=2 becomes items : Array(NamedTuple(id: Int32, quantity: Int32))
  • opts[width]=800&opts[height]=600 becomes opts : Hash(String, Int32), for keys only known at runtime

A parameter's external name (the one looked up in the request) can differ from the name used in the method body by giving it an internal name, same as any other Crystal method. This is required when the request field name is a reserved word, e.g. def sign_in(next url : String) maps the next request parameter to the local variable url.

Route annotation parameters

  • path : String - The URL path for the route (can include path parameters like :id)
  • auth : Bool - If true, requires authentication via authenticate! method (default: false). Must be set explicitly when compiled with the kemal_controller_require_auth flag.
  • strip : Bool | Array(Symbol) - If true, strips all parameters; if array, strips only specified parameters (default: false)
  • status : Int32 - The HTTP status code to set before the action runs (default: 200). The action can still override it, e.g. by calling error.
  • as : Symbol - The name of the route's URL helper in Kemal::Routes (default: {controller}_{action})

Example

@[Get("/users/:id")]
def show(id : Int32)
  "User #{id}"
end

Example with a custom status code

@[Post("/users", status: 201)]
def create(name : String)
  "Creating user with name: #{name}"
end

Example with Authentication

@[Get("/admin/dashboard", auth: true)]
def dashboard
  "Admin Dashboard"
end

def authenticate! : Bool
  # Return false to halt with 401 status
  request.headers["Authorization"]? == "SecretToken"
end

authenticate! can also take over the response itself, e.g. to redirect instead of replying with 401:

def authenticate! : Bool
  return true if session.string?("user")
  redirect("/login")
  false
end

kemal-controller only sets the 401 status when authenticate! returns false and hasn't already changed the response's status code or added a response header (e.g. via redirect, response.status_code =, or setting a new response header directly). This still works when authenticate! responds with the same status the response already had (e.g. it wants to reply 200 with an HX-Redirect header for an htmx request), since a header was added. Overwriting the value of a header that was already present, without changing the status or adding a new header, isn't detected as taking over the response.

Example with before_all Filters

before_all registers methods to run before every route declared in the same controller struct, in declaration order. Filters run after authenticate! and after the route's status: has been applied, but before any parameter is parsed. Call halt from a filter (or from an action) to abort the request.

struct AdminController < Kemal::Controller
  before_all :load_current_user
  before_all :require_admin

  @[Get("/admin/dashboard")]
  def dashboard
    "Welcome, #{@user}"
  end

  private def load_current_user
    @user = session.string?("user")
  end

  private def require_admin
    halt(403, "Forbidden") unless @user == "admin"
  end
end

Example with Parameter Stripping

@[Post("/users", strip: true)]
def create(name : String, description : String?)
  # name and description will have leading/trailing whitespace removed
end

@[Post("/login", strip: [:email])]
def login(email : String, password : String)
  # Only email will be stripped, password remains unchanged
end

Example with a Cast-Error Hook

By default, if a required parameter is missing, or is present but fails to cast to its declared type (e.g. age=foo for age : Int32), Kemal::ParamError propagates uncaught. Define an opt-in {action}_on_cast_error method, with the same parameters as the action but with no type restrictions, to render a response instead:

@[Post("/users")]
def create(name : String, age : Int32)
  "Creating user with name: #{name}, age: #{age}"
end

def create_on_cast_error(name, age)
  # Both `name` and `age` are unions with `Kemal::ParamError`, since
  # either can be missing, and `age` can also fail to cast.
  if age.is_a?(Kemal::ParamError)
    age.reason.missing? ? "age is required" : "age: #{age.value.inspect} is not a number"
  else
    "age was fine: #{age}"
  end
end

Constructors

new(context : HTTP::Server::Context, socket : HTTP::WebSocket | Nil = nil)

Initializes a new controller instance.

This is called automatically by the framework when processing a request. You typically don't need to call this directly.

Parameters

  • context : HTTP::Server::Context - The HTTP server context for the request
  • socket : HTTP::WebSocket? - The WebSocket connection, only set for @[WebSocket] methods
Source

Instance methods

_run_before_all_filters

Runs this controller's before_all filters.

No-op by default; controllers that call before_all get an override generated for them.

:nodoc:

Source
close(*args, **options)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
close(*args, **options, &)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
context

The HTTP server context for the current request.

Provides access to the underlying HTTP::Server::Context which contains the request and response objects.

Source
error(field, message, status : HTTP::Status | Nil = nil)

Adds a field-specific error message.

Stores an error message for a specific field and sets the appropriate HTTP status code. If no custom status is provided, sets 400 (Bad Request) for GET/HEAD/OPTIONS requests or 422 (Unprocessable Entity) for POST/PUT/PATCH/DELETE requests.

Parameters

  • field : String - The name of the field that has an error
  • message : String - The error message for this field
  • status : HTTP::Status? - Optional custom HTTP status code (default: nil)

Example

def create(email : String, password : String)
  if !email.includes?("@")
    error("email", "Invalid email format")
    render("src/views/users/new.ecr")
    return
  end
  if password.size < 8
    error("password", "Password must be at least 8 characters", HTTP::Status::BAD_REQUEST)
    render("src/views/users/new.ecr")
    return
  end
end
Source
error(message : String)

Adds a general error message to the base error field.

This is useful for errors that don't belong to a specific field. Sets the response status to 400 (Bad Request) for GET/HEAD/OPTIONS requests or 422 (Unprocessable Entity) for POST/PUT/PATCH/DELETE requests.

Parameters

  • message : String - The error message to add

Example

def create(name : String)
  if name.empty?
    error("Name cannot be empty")
    render("src/views/users/new.ecr")
    return
  end
end
Source
error_for?(field : String) : String | Nil

Returns the error message for a specific field.

Returns nil if there is no error for the specified field.

Parameters

  • field : String - The name of the field to check for errors

Example

def create(email : String)
  error("email", "Invalid email") unless email.includes?("@")

  if msg = error_for?("email")
    render("src/views/users/new.ecr")
    return
  end
end
Source
error_for_base

Returns the error message for the "base" field.

The "base" field is used for general errors that don't belong to a specific field. Returns nil if there is no base error.

Example

def update
  error("Something went wrong")
  if msg = error_for_base
    render("src/views/error.ecr")
    return
  end
end
Source
errors

Hash of validation errors that occurred during request processing.

Maps field names to error messages. Use error methods to add errors and has_error?, error_for?, error_for_base to check for errors.

Returns nil if no errors have been recorded.

Source
has_error?

Checks if any errors have been recorded.

Returns true if there are one or more validation errors, false otherwise.

Example

def create(name : String, email : String)
  error("name", "Name is required") if name.empty?
  error("email", "Email is required") if email.empty?

  if has_error?
    render("src/views/users/new.ecr")
    return
  end

  # Process the valid data
end
Source
on_binary

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source
on_close

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source
on_message

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source
on_ping

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source
on_pong

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source
ping(*args, **options)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
ping(*args, **options, &)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
pong(*args, **options)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
pong(*args, **options, &)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
redirect(*args, **options)

Delegates to the redirect method from the context.

Redirects the request to another URL.

Example

redirect("/login")
Source
redirect(*args, **options, &)

Delegates to the redirect method from the context.

Redirects the request to another URL.

Example

redirect("/login")
Source
request(*args, **options)

Delegates to the request object from the context.

Provides direct access to the HTTP::Request for the current request.

Source
request(*args, **options, &)

Delegates to the request object from the context.

Provides direct access to the HTTP::Request for the current request.

Source
response(*args, **options)

Delegates to the response object from the context.

Provides direct access to the HTTP::Response for the current request.

Source
response(*args, **options, &)

Delegates to the response object from the context.

Provides direct access to the HTTP::Response for the current request.

Source
send(*args, **options)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
send(*args, **options, &)

Delegates the non-block WebSocket methods to the socket getter.

Lets @[WebSocket] methods call send, close, etc. directly instead of going through socket. Like socket, these raise NilAssertionError if called from a regular HTTP route handler.

Source
session(*args, **options)

Delegates to the session object from the context.

Provides access to the Kemal session for the current request.

Source
session(*args, **options, &)

Delegates to the session object from the context.

Provides access to the Kemal session for the current request.

Source
socket

The WebSocket connection for the current request.

Only available inside methods annotated with @[WebSocket]. Raises NilAssertionError if accessed from a regular HTTP route handler.

Source
socket?

The WebSocket connection for the current request.

Only available inside methods annotated with @[WebSocket]. Raises NilAssertionError if accessed from a regular HTTP route handler.

Source
stream(binary = true, frame_size = 1024, &)

Forwards the block-accepting WebSocket methods to the socket getter.

These can't be handled by delegate because the target methods capture their block (&), and the wrapper delegate generates would yield from inside a captured block, which doesn't compile. Like socket, they raise NilAssertionError when called from a regular HTTP route handler.

Source

Macros

before_all(*names)

Registers methods to run before every route declared in this controller.

Accepts symbols, bare names or strings, and may be called more than once; filters run in declaration order, and only for routes declared in this controller struct. It can appear anywhere in the struct body, before or after the routes it applies to.

Filters run after authenticate! (for auth: true routes) and after the route's status: has been applied, but before any parameter is parsed or cast. Their return value is ignored; call halt to abort the request.

Since a controller is a struct that is instantiated per request, a filter can assign instance variables for the action to read.

Example

struct PostsController < Kemal::Controller
  before_all :load_current_user
  before_all :require_admin

  @[Get("/posts")]
  def index
    "Hello #{@user}"
  end

  private def load_current_user
    @user = session.string?("user")
  end

  private def require_admin
    halt(403, "Forbidden") unless @user == "admin"
  end
end
Source
halt(status_code = 200, response = "")

Aborts the current request by raising Halt.

Usable from before_all filters, from actions, and from any other method of the controller. The action (and any remaining filters) are skipped.

NOTE: This shadows Kemal's own top-level halt macro inside controllers, and takes no env/context argument. Kemal's version expands to next, so it never worked inside a controller method to begin with.

Example

before_all :ensure_setup

def ensure_setup
  halt(403, "Forbidden") unless Config.ready?
end
Source

Nested types