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
Supported types
- String
- Int32
- Int64
- Enums
- Bool
- NamedTuple (with nested support)
- Array (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
If you need to protect some routes with authentication you must set the auth
flag to true in the method annotation and implement the authenticate! : Bool
method in your controller.
If authenticate! returns false the request will be halted and no further
processing will be done, status code is set to 401 (Unauthorized).
struct AdminController < Kemal::Controller
@[Get("/admin/dashboard", auth: true)]
def dashboard
"Welcome to the admin dashboard!"
end
def authenticate! : Bool
if !current_user.try(&.current_user.admin?)
redirect("/login")
return false
end
true
end
end
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