Check out “Designing Lucky: Rock Solid Actions & Routing” to see how Lucky can make writing your applications reliable and productive with its unique approach to HTTP and routing.
Instead of having separate definition files for routes and controllers, Lucky combines them in action classes. This allows for solid error detection, as well as method and helper creation.
Most Lucky actions use a “REST” style convention. If you’re unfamiliar with REST, you can read our RESTful action guide.
To see what a simple action looks like, let’s generate an index action for showing users with
lucky gen.action.browser Users::Index
.
# src/actions/users/index.cr
class Users::Index < BrowserAction
get "/users" do
# `plain_text` sends plain/text to the client
plain_text "Rendering something in Users::Index"
end
end
Routes can be defined for specific request types by using the get
, put
, post
, patch
, trace
, and delete
macros.
Each Action class must only implement one route.
If you still need access to different methods like options
, you can use the match
macro.
# src/actions/profile/show.cr
class Profile::Show < BrowserAction
# Respond to an `HTTP OPTIONS` request
match :options, "/profile" do
# action code here
end
end
Note that
lucky gen.action.browser
is used to create actions that should be shown in a browser. Whereaslucky gen.action.api
is used for actions meant to be used for an API (e.g. JSON).
By default Lucky generates a Home::Index
action that handles the root path "/"
.
This is the action that renders the Lucky welcome page when you first run lucky dev
.
Change Home::Index
to redirect to whatever action you want:
# src/actions/home/index.cr
class Home::Index < BrowserAction
include Auth::AllowGuests
get "/" do
if current_user?
redirect Me::Show
else
# When you're ready change this line to:
#
# redirect SignIns::New
#
# Or maybe show signed out users a marketing page:
#
# html Marketing::IndexPage
html Lucky::WelcomePage
end
end
end
It may seem strange to redirect as soon as the users visits “/”, but it comes in handy later on. It makes it easy to redirect to different places depending on who the user is. For example, if a user is an admin you may want to redirect them to the
Admin::Dashboard::Show
action, and if they’re a regular user you may want to take them to the regular dashboard atDashboard::Show
.
When defining an explicit path, you may mark parts of the path with a :
,
to have a method generated that returns that param in the action.
# src/actions/users/show.cr
class Users::Show < BrowserAction
get "/users/:some_user_id" do
plain_text "Requested user id: #{some_user_id}"
end
end
Here, the string from the request path will be returned by the some_user_id
method.
So in this example if /users/123-foo
is requested some_user_id
would return 123-foo
,
and the action would return a text response of Requested user id: 123-foo
.
Every named parameter will have a method generated for it so that you can access the value. You can have as many as you want.
For example, delete "/projects/:project_id/tasks/:task_id"
would have a
project_id
and task_id
method generated on the class for accessing the named
parameters.
Sometimes it can be helpful to allow optional parameters in a route’s path. We can accomplish
this by prefixing a path parameter with a ?
, like this:
# src/actions/posts/index.cr
class Posts::Index < BrowserAction
get "/posts/:year/:month/?:day" do
if day
plain_text "I'll show all posts on a specific day!"
else
plain_text "I'll show all posts in a given month!"
end
end
end
In the above example, we require that the Posts::Index
route has both a :year
and :month
provided,
but allow users to optionally route to a specific :day
as well.
REST is a way to make access to resources more uniform. It consists of the following actions:
Index
- show a list of resourcesShow
- show one instance of a resourceNew
- typically used to render a form to create a resourceCreate
- create a resource. Usually means saving data to the databaseEdit
- typically used to render a form to edit an existing resourceUpdate
- update an existing resourceDelete
- delete the resourceThe word “resource” generally just refers to some model (i.e. User, or BlogPost, etc…)
For standard resources:
Action Class | Route |
---|---|
Users::Index |
get "/users" |
Users::Show |
get "/users/:user_id" |
Users::New |
get "/users/new" |
Users::Create |
post "/users" |
Users::Edit |
get "/users/:user_id/edit" |
Users::Update |
put "/users/:user_id" |
Users::Delete |
delete "/users/:user_id" |
Api::V1::Users::Show |
get "/api/v1/users/:user_id" |
MyAdminSection::Users::Show |
get "/my_admin_section/users/:user_id" |
For nested resources:
Action Class | Route |
---|---|
Projects::Users::Index |
get "/projects/:project_id/users" |
Projects::Users::Show |
get "/projects/:project_id/users/:user_id" |
Projects::Users::New |
get "/projects/:project_id/users/new" |
Projects::Users::Create |
post "/projects/:project_id/users" |
Projects::Users::Edit |
get "/projects/:project_id/users/:user_id/edit" |
Projects::Users::Update |
put "/projects/:project_id/users/:user_id" |
Projects::Users::Delete |
delete "/projects/:project_id/users/:user_id" |
Api::V1::Projects::Users::Show |
get "/api/v1/projects/:project_id/users/:user_id" |
MyAdminSection::Projects::Users::Show |
get "/my_admin_section/projects/:project_id/users/:user_id" |
For some apps you may want a wildcard/catch-all behavior instead of rendering some HTML when Lucky can’t find a route. For example, this type of behavior can be useful for Single Page Applications (SPAs) so that you can handle routing client-side.
To do this, use the fallback
macro.
# in src/actions/frontend/index.cr
class Frontend::Index < BrowserAction
fallback do
if html?
html Home::IndexPage
else
raise Lucky::RouteNotFoundError.new(context)
end
end
end
The
fallback
should always contain aLucky::RouteNotFoundError
error. This is to throw a 404 when an asset, or some other file is not found.
Sometimes you need a group of routes to be prefixed with some path.
For example, starting all of your routes with /api/v1/
. For this, you can use the route_prefix
macro.
# src/actions/api_action.cr
abstract class ApiAction < Lucky::Action
accepted_formats [:json], default: :json
route_prefix "/api/v1"
end
Now all of your actions that inherit from ApiAction
will start with /api/v1
.
class Api::Posts::Index < ApiAction
# GET /api/v1/posts
get "/posts" do
json(PostQuery.new)
end
end
class Posts::Index < BrowserAction
# This is NOT prefixed because it inherits from
# BrowserAction.
# GET /posts
get "/posts" do
html IndexPage
end
end
You may have a path that has an optional path segment. For example:
# MenuItems::Index
/menu-items
# also MenuItems::Index
/menu-items/appetizers
In this example, the /appetizers
path segment might be optional, but if it
does exist, then you are able to change what is displayed on your page. Both
routes go to the same Action class.
The optional path param is specified the same as a normal path param, but with a
?
prefix. (e.g. /?:path_param
)
class MenuItems::Index < BrowserAction
get "/menu-items/?:section" do
if section
# we have a value for section
else
# no value for section
end
end
end
Glob routing is a way to define a path with an unknown number of path segments. This allows you to do deep nested linking, or use different paths for loading specific data. For example:
# Posts::Index
/posts
# also Posts::Index
/posts/2022/02/14
Similar to the optional path segments, but with an unknown number. This can also be used
as a “catch-all” route similar to the fallback
routing.
To use the glob route, your trailing path segment must be a *
. This will give you a method
glob
that returns a String of the entire trailing path.
class Posts::Index < BrowserAction
get "/posts/*" do
if glob
# glob == "2022/02/14"
else
# the path was just "/posts"
end
end
end
You can also name your glob by giving it a path param.
class Posts::Index < BrowserAction
get "/posts/*:date" do
if date
# date == "2022/02/14"
else
# the path was just "/posts"
end
end
end
By default Lucky ensures that all routes adhere to the same style. All
route paths are expected to use underscores unless you opt-out or change
the style check. You can opt-out from style checking by including
Lucky::SkipRouteStyleCheck
in your action.
# src/actions/users/show.cr
class Guides::GettingStarted < BrowserAction
include Lucky::SkipRouteStyleCheck
get "/guides/getting-started" do
plain_text "Get started"
end
end
Or, skipping checking altogether by removing
Lucky::EnforceUnderscoredRoute
from src/actions/browser_action.cr
.
Similarly, a custom style check can be added by adding the
enforce_route_style
macro in your action. Or, for all actions by adding
it to src/actions/browser_action.cr
. See Lucky::EnforceUnderscoredRoute
for an example.
As your application gets larger, you may need to write helper methods that run expensive
calculations, or queries. Calling these methods multiple times can lead to performance issues.
To mitigate these, you can use the memoize
macro.
class Reports::Show < BrowserAction
get "/report" do
small_number = calculate_numbers
big_number = calculate_numbers + 1000
html ShowPage, small_number: small_number, big_number: big_number
end
memoize def calculate_numbers : Int64
# This is ran only the first time it's called
ReportQuery.new.fetch_numbers_for_today(Time.utc)
end
end
Memoized methods can return any value, includingnil
or false
.
You can also pass arguments to your memoized method.
class Users::Show < BrowserAction
get "/users/:id" do
user = fetch_user(id)
if user
html ShowPage, user: user
else
redirect to: Home::IndexPage
end
end
# This is only called once per `id` passed in.
memoize def fetch_user(id : String) : User
make_api_call && UserQuery.new.find(id)
end
end
If you need access to memoize
from outside of your action, just include Lucky::Memoizable
.
By default Lucky will respond with a 404 when neither a route nor a static
file in public is found. You can change what is rendered in Errors::Show
which
is found in src/actions/errors/show.cr
.
You’ll see a method like this that handles when a route is not found:
# in src/actions/errors/show.cr
#
# Customize this however you want!
def render(error : Lucky::RouteNotFoundError)
if json?
error_json "Not found", status: 404
else
error_html "Sorry, we couldn't find that page", status: 404
end
end
Learn more about error handling.
Parameters, or params
, are data that is sent from client back to the server. There are a few different ways this can happen:
/users/:id
.?
in key/value pairs. e.g. ?page=1
You may want to accept parameters in the query string, e.g. /users?page=2
. Lucky gives you access
to these in a type-safe way through the param
macro.
# src/actions/users/index.cr
class Users::Index < BrowserAction
param page : Int32 = 1
get "/users" do
plain_text "All users starting on page #{page}"
end
end
When you add a query parameter with the param
macro, it will generate a method for you to access the value.
The parameter definition will inspect the given type declaration, so you can easily define
required or optional parameters by using non- or nilable types (Int32
vs. Int32?
).
Parameter defaults are set by assigning a value in the parameter definition. Query parameters
are type-safe as well, so when /users?page=unlucky
is accessed with the above definition, an exception
is raised.
Just like path parameters, you can define as many query parameters as you want. Every query parameter will have a method generated for it to access the value.
To pass these params from a page in to the action, you will use the with
method.
def content
link "View more users", to: Users::Index.with(page: 2)
end
You also have access to these with the params
method.
Here’s an example of using parameters when visiting the /users?page=1&filter=active
path:
# src/actions/users/index.cr
class Users::Index < BrowserAction
get "/users" do
filter = params.get(:filter) # type String
page = params.get(:page) # type String
per = params.get(:per) # Error! there is no parameter :per
plain_text "All users starting on page #{page}"
end
end
The params.get?(:key)
method will return nil
if the key doesn’t exist instead of raising an error.
get "/users" do
per = params.get?(:per) # returns nil
plain_text "..."
end
By default, all param values are trimmed of blankspace. If you need the raw value, use
params.get_raw(:key)
orparams.get_raw?(:key)
.
The from_query
method returns HTTP::Params
from query params. You can access the values
similar to a Hash(String, String)
.
# /path?q=Lucky
params.from_query["q"] #=> "Lucky"
params.from_query["search"] #=> Error!
params.from_query["search"]? #=> nil
This is the same as using
params.get_raw(:key)
andparams.get_raw?(:key)
.
Parses the request body as JSON::Any
or raises Lucky::ParamParsingError
if JSON is invalid.
# {"users": [{"name": "Skyler"}]}
params.from_json["users"][0]["name"].as_s #=> "Skyler"
The from_form_data
method returns HTTP::Params
from x-www-form-urlencoded body params.
params.from_form_data["name"]
Returns multipart params and files in a Tuple(Hash(String, String), Hash(String, Lucky::UploadedFile))
.
form_params = params.from_multipart.first # Hash(String, String)
form_params["name"] # "Kyle"
files = params.from_multipart.last # Hash(String, Lucky::UploadedFile)
files["avatar"] # Lucky::UploadedFile
params.get_file(:avatar) # Lucky::UploadedFile
params.get_file?("missing") # nil
When it comes to a collection of values in params, there’s no official standard for how this should be formatted. For this reason,
each framework must choose on their own how to handle it. For Lucky, we’ve gone with the square bracket notation key[]=value1&key[]=value2
.
To access these values as an Array(String)
, you can use the get_all(:key)
, or get_all?(:key)
methods.
# key[]=value1&key[]=value2
params.get_all(:key) # ["value1", "value2"]
params.get_all?("missing") # nil
When data is sent through HTML forms, Lucky will namespace or “nest” the
parameter names according to the object used in the form. For example,
if we’re saving a User
object, all of the param names will be prefixed with
user:
. (i.e. user:name
, user:email
).
To access these values, we can use the params.nested(:key)
and params.nested?(:key)
methods.
class Users::Create < BrowserAction
# user:name=Alesia&user:age=35&page=1
post "/users" do
data = params.nested(:user)
name = data["name"] #=> "Alesia"
email = data["email"]? #=> nil
plain_text "The name is #{name}"
end
end
Lucky also gives you the ability to send more than 1 set of param values
at the same time. We call the many_nested
.
In this example, we want to create 2 notes at the same time.
# notes[0]:title=Buying¬es[1]:title=Selling
class Notes::Create < BrowserAction
post "/notes" do
notes = params.many_nested(:notes)
plain_text "The first note title is #{notes[0]["title"]}"
end
end
The
many_nested
method will raise an error if the key does not exist. Usemany_nested?(:key)
to returnnil
in that case.
For files associated with models, the param will be nested. (e.g. post:banned_photo
)
To access the file from the nested params, use the nested_file
or nested_file?
methods.
params.nested_file(:post) # Lucky::UploadedFile
params.nested_file?("missing") # nil
If you need to get the raw params, you can call params.body
which will take the request, and give you a String
of what params were sent.
Lucky::Log.dexter.info { {raw_params: params.body} }
# Convert params to a Hash(String, String | Hash(String, String))
params.to_h
Sometimes you want to require that an endpoint is called with a subdomain. For example, you might have endpoints
you only expose in the test environment which is called with a subdomain.
That would allow test.example.com/some-route
to work but example.com/some-route
would raise a Lucky::InvalidSubdomainError
.
To specify and require subdomains, you need to include Lucky::Subdomain
in your action or in the class your action inherits. For example, in src/actions/browser_action.cr
Once included, you use require_subdomain
# subdomain required but can be anything
require_subdomain
# subdomain required and must equal "admin"
require_subdomain "admin"
# subdomain required and must match regex
require_subdomain /(dev|qa|prod)/
# subdomain required and must match one of the items in the array
require_subdomain ["tenant1", "tenant2", /tenantd/]
When that is used, you can then access the subdomain by calling subdomain
in your route handler.
Even if you don’t require a subdomain, you can still call subdomain?
to check if one was provided, but
keep in mind that it will be nilable.
get "/admin" do
subdomain #=> compile time error if `require_subdomain` not specified, guaranteed to return a String otherwise
subdomain? #=> can be called without `require_subdomain`, but will return String | Nil
end
Actions go in src/actions
and follow the structure of the class.
For example Users::Show
would go in src/actions/users/show.cr
and Api::V1::Users::Delete
would go in src/actions/api/v1/users/delete.cr
.