kemal-controller
Kemal is awesome, but sometimes you need (or just want) a bit more structure in your web applications, kemal-controller is here to help you with that by providing a simple way to declare all your endpoints into controller classes where the method parameters will map to GET/PATCH/.../POST/URL parameters automatically.
Online documentation can be found at: https://hugopl.github.io/kemal-controller/.
Controllers are structs, so the overhead is minimal and you can still use all Kemal features as you would normally do.
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} and description: #{description}"
end
end
Kemal-controller also supports arrays and named tuples in arguments, so you get a type safe way to handle the endpoint parameters.
struct ProductsController < Kemal::Controller
@[Get("/products")]
def filter(categories : Array(String), price_range : NamedTuple(min : Float64, max : Float64))
"Filtering products in categories: #{categories.join(", ")} with price between #{price_range[:min]} and #{price_range[:max]}"
end
end
It supports nested named tuples/arrays in any combination as well.
struct OrdersController < Kemal::Controller
@[Post("/orders")]
def create(items : Array(NamedTuple(id : Int32, quantity : Int32)),
shipping_address : NamedTuple(street : String, city : String, zip : String))
"Creating order with items: #{items.inspect} to be shipped to #{shipping_address[:street]}, #{shipping_address[:city]}, #{shipping_address[:zip]}"
end
end
Default values are supported — the parameter must have an explicit type annotation:
struct UsersController < Kemal::Controller
@[Get("/greet")]
def greet(name : String = "World", times : Int32 = 1)
"Hello, #{name}! " * times
end
end
If the parameter is absent from the request the default value is used. Explicit type annotations are required; omitting the type is a compile-time error.
How the parameters are mapped?
Kemal-controller interprets the form keys almost like Rails does:
item[foo]=barbecomesitem : NamedTuple(foo : String)items[]=1&items[]=2becomesitems : Array(Int32)items[][id]=1&items[][quantity]=2&items[][id]=3&items[][quantity]=4becomesitems : Array(NamedTuple(id : Int32, quantity : Int32))name=Johnbecomesname : String
A NamedTuple fixes the accepted keys at compile time; use a Hash when the keys are only known at
runtime, e.g. opts[width]=800&opts[height]=600 becomes opts : Hash(String, Int32). Only String
keys are supported, any other key type is a compile-time error.
Supported types
- String
- Int32
- Int64
- Enums
- Bool
- NamedTuple (with nested support)
- Array (with nested support)
- Hash with String keys (with nested support)
- Nilable versions of the above types
More types may be added in the future, feel free to open an issue or a PR if you need something specific.
Error handling
Kemal::ParamError is raised for bad request parameters — either a
required (non-nilable, no default) parameter that was not present in the
request, or one that was present but couldn't be coerced to the declared type
(e.g. "foo" for an Int32, an unrecognised enum member, or an invalid
boolean literal). Its reason getter (a Kemal::ParamError::Reason enum)
tells you which: Missing or CastError. param_name is always set;
expected_type and value are only set when reason is CastError.
It inherits from Exception, so you can handle it with Kemal's
exception-specific error handler:
error Kemal::ParamError do |env, ex|
env.response.status_code = ex.reason.missing? ? 400 : 422
ex.message
end
See "Handling cast errors per-action" below for opting a single action into recovering from these instead of letting them propagate.
Handling cast errors per-action
If you'd rather recover from a bad parameter inside a specific action — to
re-render a form with a per-field error, for instance — define a sibling
{action}_on_cast_error method with the same parameter names, in the same
order, but with no type restrictions:
struct UsersController < Kemal::Controller
@[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
end
When create runs, every parameter is cast independently — a bad age
doesn't stop name from being cast too. If any parameter fails, create is
skipped entirely and create_on_cast_error is called instead, receiving each
parameter as either its successfully cast value or the Kemal::ParamError
for that specific parameter. If none fail, create runs as usual with fully
typed, narrowed parameters.
This is entirely opt-in: a controller that never defines _on_cast_error
methods keeps today's behaviour of letting Kemal::ParamError propagate.
Enums
Enums are supported as method parameters as well, anything accepted by Enum.new or Enum.parse is recognized.
Stripping parameters
If you need to strip all parameters (like leading/trailing spaces) before they
reach your controller methods, you can use the strip flag on method annotation.
To strip specific parameters use an array of symbols instead of true.
struct UsersController < Kemal::Controller
@[Post("/users", strip: true)]
def create(name : String, description : String?)
"Creating user with name: '#{name}', description: '#{description}'"
end
@[Get("/users/edit", strip: [:email])]
def login(email : String, password : String)
"Logging in user with email: '#{email}'"
end
end
Authenticated/protected routes
To protect a route, set auth: true on its annotation and implement
authenticate! : Bool in the controller.
If it returns false, the request halts with a 401 — unless authenticate!
already set its own status (e.g. via redirect or response.status_code =),
which is kept instead.
struct AdminController < Kemal::Controller
@[Get("/admin/dashboard", auth: true)]
def dashboard
"Welcome to the admin dashboard!"
end
def authenticate! : Bool
return true if session.string?("role") == "admin"
redirect("/login")
false
end
end
Running filters before every route
before_all registers one or more methods to run before every route declared in
the same controller struct — and only that struct. It accepts symbols, bare names
or strings, can be called more than once, and may appear anywhere in the struct
body, before or after the routes it applies to. Filters run in declaration order.
A filter runs after authenticate! (for auth: true routes) and after the
route's status: has been applied, but before any parameter is parsed or cast.
Its return value is ignored — to abort the request, call halt.
Since a controller is a struct instantiated once per request, a filter can assign instance variables the action then reads.
struct PostsController < Kemal::Controller
before_all :load_current_user
before_all :require_admin
@user : String? = nil
@[Get("/posts")]
def index
"Welcome, #{@user}"
end
@[Get("/posts/:id")]
def show(id : Int32)
"Post #{id}"
end
private def load_current_user
@user = session.string?("user")
end
private def require_admin
halt(403, "Forbidden") unless @user == "admin"
end
end
halt(status_code = 200, response = "") sets the response status and body and
skips everything that follows. It works from filters, from actions and from any
other method of the controller. On a @[WebSocket] route the handshake has
already been answered by the time filters run, so a halt closes the socket with
HTTP::WebSocket::CloseCode::PolicyViolation using response as the close
reason.
[!NOTE] Inside a controller,
haltis kemal-controller's own macro and takes noenv/context argument. Kemal's top-levelhaltexpands tonext, so it was never usable from a controller method anyway. Where the macro isn't in scope — a helper defined in an included module, say — raise it directly withraise Kemal::Controller::Halt.new(403, "Forbidden").
Filters compose through inheritance: an abstract struct controller declaring
before_all passes its filters on to its subclasses, which run before any the
subclass declares itself.
Requiring explicit auth: on every route
By default a route annotation that omits auth entirely is public, same as
auth: false. If you'd rather have that be a compile error, so a route
can never become accidentally public just because someone forgot the
auth: key, build (or run specs) with the kemal_controller_require_auth
flag:
crystal build src/app.cr -Dkemal_controller_require_auth
With the flag enabled, every route annotation must set auth: to either
true or false; omitting it fails the build with an error naming the
controller, method and verb. auth: false remains the way to mark a route
intentionally public; it's just no longer implied by silence.
The flag only affects compilation; it's not a shard.yml setting, since
-D flags are supplied by whoever builds the final application, not by the
shard itself.
WebSocket routes
WebSocket endpoints are declared with @[WebSocket], taking advantage of Kemal's
own WebSocket support. The method is called once, right after the handshake
completes; use the socket getter to register on_message/on_close/etc.
handlers. Parameters are extracted from the handshake request the same way Get does.
struct ChatController < Kemal::Controller
@[WebSocket("/chat/:room")]
def chat(room : String)
socket.send("Welcome to #{room}!")
socket.on_message do |message|
socket.send("#{room}: #{message}")
end
end
end
strip works the same as with HTTP routes. auth works too, but with one
difference: by the time the method runs the handshake response has already
been sent, so a failed authenticate! can't reply with a 401 — the socket is
closed instead with HTTP::WebSocket::CloseCode::PolicyViolation.
Printing routes
You can print all registered routes by calling the Kemal.print_routes method,
useful for debugging purposes.
Kemal.config.extra_options do |parser|
parser.on("--routes", "Show all routes") do
Kemal.print_routes
exit(0)
end
end
On --routes your app will print something like:
GET /area51 TestController#area51()
POST /array_of_named_tuples TestController#array_of_named_tuples(items : Array(NamedTuple(name: String, age: Int32)))
GET /hello TestController#hello(name : String)
POST /hello TestController#post_hello(name : String)
GET /regular_kemal_route ?
WS /chat/:room ChatController#chat(room : String)
5 routes
URL helpers
Every route also gets a method in Kemal::Routes that builds its URL, so paths
are written once — in the annotation — instead of being repeated wherever you
link to them.
Helpers are named {controller}_{action}: the trailing Controller is dropped,
:: becomes _, and the rest is underscored. Pass as to pick a nicer name.
struct UsersController < Kemal::Controller
@[Get("/users/:username")]
def show(username : String)
"User #{username}"
end
@[Get("/users/new", as: new_user)]
def new
"New user form"
end
end
Kemal::Routes.users_show("john doe") # => "/users/john%20doe"
Kemal::Routes.new_user # => "/users/new"
Path parameters become positional arguments, in the order they appear in the
path, typed after the action's own parameter of the same name. They're escaped
with URI.encode_path_segment, except for glob parameters (*path), which keep
their slashes.
Any extra keyword argument is appended as a query parameter, and nil values
are skipped:
Kemal::Routes.users_show("john", tab: "profile", q: nil)
# => "/users/john?tab=profile"
Kemal::Routes is an extend self module, so you can either call the helpers
on it or include Kemal::Routes into your views and models.
Two routes with different paths generating the same helper name is a
compile-time error; give at least one of them an as name.
Installation
-
Add the dependency to your
shard.yml:dependencies: kemal-controller: github: hugopl/kemal-controller -
Run
shards install
Contributing
- Fork it (https://github.com/hugopl/kemal-controller/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Hugo Parente Lima - creator and maintainer