Introduction to the Amber Web Framework And its Out-of-the-Box Features
Amber makes building web applications easy, fast, and enjoyable.
Amber is a web application framework written in Crystal. Homepage is at amberframework.org, docs are on Amber Docs, Github repository is at amberframework/amber, and the chat is on Gitter or FreeNode IRC channel #amber.
Amber is inspired by Kemal, Rails, Phoenix and other frameworks. It is simple to get used to, and much more intuitive than frameworks like Rails. (But it does inherit some concepts from Rails that are good.)
This document is here to describe everything that Amber offers out of the box, sorted in a logical order and easy to consult repeatedly over time. The Crystal level is not described; it is expected that the readers coming here have a formed understanding of Crystal and its features.
git clone https://github.com/amberframework/amber cd amber make # The result of 'make' is one file -- command line tool bin/amber # To install the file, or to symlink the system-wide executable to current directory, run one of: make install # default PREFIX is /usr/local make install PREFIX=/usr/local/stow/ make force_link # can also specify PREFIX=...
After installation or linking,
amber is the command you will be using
for creating or managing Amber apps.
Creating New Amber App
amber new <app_name> [-d DATABASE] [-t TEMPLATE_LANG] [-m ORM_MODEL] [--deps]
Slang is extremely elegant, but very different from the traditional perception of HTML.
ECR is HTML-like, very similar to Ruby ERB, and more than mediocre when compared to slang, but it may be the best choice for your application if you intend to use some HTML site template (from e.g. themeforest) whose pages are in HTML + CSS or SCSS. (Or you could also try html2slang which converts HTML pages into slang.)
In any case, you can combine templates in various languages in a project, and regardless of the language, have in mind that the templates are compiled into the application. There is no lookup on disk or choosing between available templates during runtime. This makes templates extremely fast, as well as read-only which is a very welcome side-benefit!
Granite is a very nice and simple, effective ORM model, where you mostly write your own SQL (i.e. all search queries typically look like YourModel.all("WHERE field1 = ? AND field2 = ?", [value1, value2])). But it also has belongs/has relations, and some other little things. (If you have by chance known and loved Class::DBI for Perl, it might remind you of it in some ways.)
Supported migrations engine is micrate. Micrate is very simple and you basically write raw SQL in your migrations. There are just two keywords in the migration file which give instructions whether the SQLs that follow pertain to migrating up or down. These keywords are "-- +micrate Up" and "-- +micrate Down".
If argument --deps is provided, Amber will automatically run
crystal deps in the new directory to install shards.
Running the App
The app can be started as soon as you have created it and ran
crystal deps in the app directory.
(It is not necessary to run deps if you have invoked
amber new with the argument --deps; in that case Amber did it for you.)
To run it, you can use a couple different approaches. Some are of course suitable for development, some for production, etc.:
# For development, clean and simple - compiles and runs your app: crystal src/<app_name>.cr # For development, clean and simple - compiles and runs your app, but # also watches for changes in files and rebuilds/re-runs automatically. amber watch # For production, compiles app with optimizations and places it in bin/app. # Crystal by default compiles using 8 threads (tune if needed with --threads NUM) crystal build --no-debug --release --verbose -t -s -p -o bin/<app_name> src/<app_name>.cr
Please note that Granite currently has problems in edge cases. For example, if you create a new model but do not specify any fields for it, then until you add at least one field, Amber won't start due to a compile error (#112).
Amber by default uses a feature called "port reuse" available in newer Linux kernels. If you get an error "setsockopt: Protocol not available", it means your kernel does not have it. Please edit
config/environments/development.yml and set "port_reuse" to false.
Building the App and Troubleshooting
The application is always built, regardless of whether one is using the Crystal command 'run' (the default) or 'build'. It is just that in run mode, the resulting binary won't be saved to a file, but will be executed and later discarded.
For faster build speed, development versions are compiled without the --release flag. With the --release flag, the compilation takes noticeably longer, but the resulting binary has incredible performance.
Crystal caches partial results of the compilation (*.o files etc.) under
~/.cache/crystal/ for faster subsequent builds. This directory is also where temporary binaries are placed when one runs programs with
crystal [run] rather than
Sometimes building the app will fail on the C level because of missing header files or libraries. If Crystal doesn't print the actual C error, it will at least print the compiler line that caused it.
The best way to see the actual error from there is to copy-paste the command printed and run it manually in the terminal. The error will be shown and from there the cause will be determined easily.
There are some issues with the
libgc library here and there. Crystal comes with built-in
libgc, but it may conflict with the system one. In my case the solution was to install and then remove package
Often times, it is very useful to enter an interactive console (think of IRB shell) with all application classes initialized etc. In Ruby this would be done with IRB or with a command like
Due to its nature, Crystal does not have a free-form REPL, but you can save and execute scripts in the context of the application. One way to do it is via command
amber x [filename]. This command will allow you to type or edit the contents, and then execute the script.
Another, more professional way to do it is via standalone REPL-like script tools cry and icr.
cry began as an experiment and a predecessor to
amber x, but now offers additional functionality such as repeatedly editing and running the script if
cry -r is invoked.
In any case, running a script "in application context" simply means requiring
config/application.cr (and through it,
config/**), Therefore, be sure to list all your requires in
config/application.cr so that everything works as expected.
So, at this point you might be wanting to know what's placed where in an Amber application. The default structure looks like this:
I prefer to have some of these directories accessible directly in the root directory of the application and to have the config directory named
etc, so I run:
ln -sf config etc ln -sf src/assets ln -sf src/controllers ln -sf src/models ln -sf src/views ln -sf src/views/layouts
Amber provides a group of commands under the 'db' group to allow working with the database. The simple commands you will most probably want to run just to see basic things working are:
amber db create amber db status amber db version
Before these commands will work, you will need to configure database
First, create a user to access the database. For PostgreSQL, this is done by invoking something like:
$ sudo su - postgres $ createuser -dElPRS myuser Enter password for new role: Enter it again:
config/environments/development.yml and configure "database_url:" to match your settings. If nothing else, the part that says "postgres:@" should be replaced with "yourusername:yourpassword@".
And then try the database commands from the beginning of this section.
Please note that for the database connection to succeed, all parameters must be correct (hostname, port, username, password, database name), the database server must be accessible, and the database must actually exist (unless you are invoking 'amber db create' to create it). In case of any error in any of the stages of connecting to the database, the error message will be very terse and just say "Connection unsuccessful: <database_url>". The solution is simple, though - simply use the printed database_url to manually attempt a connection to the database with the same parameters, and the problem will most likely quickly reveal itself.
Please note that the environment files for non-production environment are given in plain text. Environment file for the production environment is encrypted for additional security and can be seen or edited by invoking
Routes are very easy to understand. Routes connect HTTP methods (and the paths with which they were invoked) to controllers and methods on the Amber side.
Amber includes a wonderful command
amber routes to display current routes. By default, the routes table looks like the following:
$ amber routes ╔══════╦═══════════════════════════╦════════╦══════════╦═══════╦═════════════╗ ║ Verb | Controller | Action | Pipeline | Scope | URI Pattern ║ ╠──────┼───────────────────────────┼────────┼──────────┼───────┼─────────────╣ ║ get | Amber::Controller::Static | index | static | | /* ║ ╠──────┼───────────────────────────┼────────┼──────────┼───────┼─────────────╣ ║ get | HomeController | index | web | | / ║ ╚══════╩═══════════════════════════╩════════╩══════════╩═══════╩═════════════╝
From this example, we see that a "GET /" request will instantiate
HomeController and then call method index() in it. The return value of
the method will be returned to the client.
Similarly, here's an example of a route that would route HTTP POST requests to "/registration" to the method create() in class RegistrationController:
post "/registration", RegistrationController, :create
Standard HTTP verbs (GET, HEAD, POST, PUT, PATCH, DELETE) by convention go to standard methods on the controllers (show, new, create, edit, update, destroy). However, there is nothing preventing you from routing URLs to any methods you want in the controllers, such as we've done with "index" above.
Websocket routes are supported too.
It gives you the following top-level commands/blocks:
# Define a pipeline pipeline :name do ... end # Group a set of routes routes :name, "path" do ... end
Amber::Server.configure do |app| pipeline :web do # Plug is the method used to connect a pipe (middleware) # A plug accepts an instance of HTTP::Handler plug Amber::Pipe::Logger.new end routes :web do get "/", HomeController, :index # Routes to HomeController::index() get "/test", PageController, :test # Routes to PageController::test() end end
Within 'routes', the following commands are available:
get, post, put, patch, delete, options, head, trace, connect, websocket, resources
resources is a macro defined as:
macro resources(resource, controller, only = nil, except = nil)
And unless it is confined with arguments
except, it will automatically define get, post, put, patch, and delete routes for your resource and route them to the following methods in the controller:
index, new, create, show, edit, update, destroy
Please note that it is not currently possible to define a different behavior for HEAD and GET methods ont he same path, because if a GET is defined it will also automatically add the matching HEAD route. That will result in two HEAD routes existing for the same path and trigger error
Information about views can be summarized in bullet points:
- Views in Amber are located in
- They are rendered using
- The first argument given to
render()is the template name (e.g.
- If we are in the context of a controller,
render("index.slang")will look for view using the path
- If we are not rendering a partial, by default the template will be wrapped in a layout
- If the layout name isn't given, the default layout will be
- There is no unnecessary magic applied to template names — name given is the name that is looked up on disk
- Partials begin with "_" by convention, but that is not required
- To render a partial, use
render( partial: "_name.ext")
Variables in Views
In Amber, templates are compiled in the same scope as controller methods. This means you do not need instance variables for passing the information from controllers to views.
Any variable you define in the controller method is automagically visible in the template. For example, let's add the current date and time display to our /about page:
$ vi src/controllers/page_controller.cr def about time = Time.now render "about.ecr" end $ vi src/views/page/about.ecr Hello, World! The time is now <%= time %>.
Templates are actually executing in the controller class. If you do "<%= self.class %> in the above example, the response will be "PageController". So all the methods and variables you have on the controller are also available in views rendered from it.
Starting the Server
It is important to explain exactly what is happening from when you run the application til Amber starts serving the aplication:
crystal src/<app_name>.cr- you or a script starts Amber
require "../config/*"- as the first thing,
config/*is required. Inclusion is in alphabetical order. Crystal only looks for *.cr files and only files in config/ are loaded (no subdirectories)
require "../config/application.cr"- this is usually the first file in
require "./initializers/**"- loads all initializers. There is only one initializer file by default, named
initializer/database.cr. Here we have a double star ("**") meaning inclusion of all files including in subdirectories. Inclusion is always current-dir first, then depth
require "amber"- Amber itself is loaded
- Loading Amber makes
include Amber::Environment- already in this stage, environment is determined and settings are loaded from yml file (e.g. from
config/environments/development.yml. Settings are later available as
- Loading Amber makes
require "../src/controllers/application_controller"- main controller is required. This is the base class for all other controllers
- It defines
ApplicationController, includes JasperHelpers in it, and sets default layout ("application.slang").
- It defines
require "../src/controllers/**"- all other controllers are loaded
Amber::Server.configureblock is invoked to override any config settings
require "config/routes.cr"- this again invokes
Amber::Server.configureblock, but concerns itself with routes and feeds all the routes in
instance.run- implicitly creates a singleton instance of server, saves it to
@@instance, and calls
- Consults variable
- If process count is 1,
- If process count is > 1, the desired number of processes is forked, while main process enters sleep
- Forks invoke Process.run() and start completely separate, individual processes which go through the same initialization procedure from the beginning. Forked processes have env variable "FORKED" set to "1", and a variable "id" set to their process number. IDs are assigned in reverse order (highest number == first forked).
instance.startis called for every process
- It saves current time and prints startup info
@handler.prepare_pipelinesis called. @handler is Amber::Pipe::Pipeline, a subclass of Crystal's HTTP::Handler.
prepare_pipelinesis called to connect the pipes so the processing can work, and implicitly adds Amber::Pipe::Controller (the pipe in which app's controller is invoked) as the last pipe. This pipe's duty is to call Amber::Router::Context.process_request, which actually dispatches the request to the controller.
server = HTTP::Server.new(host, port, @handler)- Crystal's HTTP server is created
server.tls = Amber::SSL.new(...).generate_tls if ssl_enabled?
- Signal::INT is trapped (calls
loop do server.listen(settings.port_reuse) end- server enters main loop
Similarly as with starting the server, is important to explain exactly what is happening when Amber is serving requests:
Amber's app serving model is based on Crystal's built-in, underlying functionality:
- The server that is running is an instance of Crystal's
- On every incoming request, handler is invoked. As supported by Crystal, handler can be simple Proc or an instance of HTTP::Handler. HTTP::Handlers have a concept of "next" and multiple ones can be connected in a row. In Amber, these individual handlers are called "pipes", and the overall handler (complete chain of pipes) is Amber::Pipe::Pipeline, which, for convenience, is also a subclass of HTTP::Handler. This handler, even though named Pipeline and implying a specific pipeline, is actually aware of all pipelines and can identify and trigger the appropriate one
- In the pipeline, every Pipe (Amber::Pipe:, ultimately subclass of Handler) is invoked, with one argument. That argument is
by convention called "context" and it is an instance of
HTTP::Server::Context, which has two built-in methods —
response, to access the request and response parts respectively. On top of that, Amber adds various other methods and variables, such as
route, and others as seen in src/amber/router/context.cr
- Please note that calling the chain of pipes is not automatic; every pipe needs to call
call_next(context)at the appropriate point in its execution to call the next pipe in a row. It is not necessary to check whether the next pipe exists, because currently
Amber::Pipe::Controlleris always implicitly added as the last pipe, so at least one does exist. State between pipes is not passed via variables but via modifying
contextand the data contained in it
After that, pipelines, pipes, routes, and otherAmber-specific parts come into play.
So, in detail, from the beginning:
loop do server.listen(settings.port_reuse) end- main loop is running
spawn handle_client(server.accept?)- handle_client() is called in a new fiber after connection is accepted
io = OpenSSL::SSL::Socket::Server.new(io, tls, sync_close: true) if @tls
if request.is_a?(HTTP::Request::BadRequest); response.respond_with_error("Bad Request", 400)
response.version = request.version
response.headers["Connection"] = "keep-alive" if request.keep_alive?
context = Context.new(request, response)- this context is already extended with Amber's extensions in src/amber/router/context.cr
raise ...error... if context.invalid_route?- route validity is checked early
if context.websocket?; context.process_websocket_request- if websocket, parse as such
elsif ...; ...pipeline.first...call(context)- if reqular HTTP request, call the first handler in the appropriate pipeline
call_next(context)- each pipe calls call_next(context) somewhere during its execution, and all pipes are executed
context.process_request- the always-last pipe (Amber::Pipe::Controller) calls
process_requestto dispatch the action to controller. After that last pipe, the stack of call_next()s is "unwound" back to the starting position
context.finalize_response- minor final adjustments to response are made (headers are added, and response body is printed unless action was HEAD)
It can be pretty much expected that a website will need a set of simple, "static" pages. Those pages are served by the application, but mostly don't use a database nor any complex code. Such pages might include About and Contact pages, Terms of Conditions, etc. Making this work is trivial.
Let's say that, for simplicity and grouping, we want all "static" pages to be served by PageController. We will group all these pages under a common web-accessible prefix of /page/, and finally we will route page requests to PageController's methods. (Because these pages won't be objects, we won't need a model or anything else other than one controller method and one view per each page.)
Let's start by creating a controller:
amber g controller page
Afterwards, we edit
config/routes.cr to link URL "/about" to method about() in PageController. We do this inside the "routes :web" block:
routes :web do ... get "/about", PageController, :about ... end
Then, we edit the controller and actually add method about(). This method can just directly return some string in response, or it can render a view, and then the expanded view contents will be returned as the response.
$ vi src/controllers/page_controller.cr # Inside the file, we add: def about # "return" can be omitted here. It is included only for clarity. return render "about.ecr" end
Since this is happening in the "page" controller, the view directory for finding the templates defaults to
src/views/page/. We will create the directory and the file "about.ecr" in it:
$ mkdir -p src/views/page/ $ vi src/views/page/about.ecr # Inside the file, we add: Hello, World!
Because we have called render() without additional arguments, the template will default to being rendered within the default application layout,
And that's it! Visiting
/about will go to the router, router will invoke
PageController::about(), that method will render template
src/views/page/about.ecr in the context of layout
views/layouts/application.cr, and the result of rendering will be a full page with content
Hello, World! in the body. That result will be returned to the controller, and from there it will be returned to the client.
In an Amber project, raw assets are in
At build time, all these are processed and placed under
The JS resources are bundled to
main.bundle.js and CSS resources are bundled to
Currently, webpack is being used for asset management. I recommend replacing it with at least Parcel. Finding a non-js/non-node/non-npm application for this purpose would be even better; please let me know if you know one.
This section will be expanded to include a full replacement procedure. (In general it seems it shouldn't be much more complex than replacing the command and development dependencies in project's
By default, Amber project depends on just a few shards:
amberframework/amber - Obviously, must depend on Amber amberframework/granite-orm - Database ORM amberframework/quartz-mailer - Sending and receiving emails amberframework/jasper-helpers - Helpers for working with HTML in Amber/Crystal will/crystal-pg - PostgreSQL connector amberframework/garnet-spec - Extended Crystal specs for testing web applications
In turn, these depend on:
luislavena/radix - Radix Tree implementation jeromegn/kilt - Generic template interface jeromegn/slang - Slang template language stefanwille/crystal-redis - amberframework/cli - Building cmdline apps (based on mosop) mosop/optarg - Parsing cmdline args mosop/callback - Defining and invoking callbacks mosop/string_inflection - Word plurals, counts, etc. amberframework/teeplate - Rendering multiple template files juanedi/micrate - Database migration tool crystal-lang/crystal-db - Common DB API jwaldrip/shell-table.cr - Creates textual tables in shell askn/spinner - Spinner for the shell crystal-lang/crystal-mysql - crystal-lang/crystal-sqlite3 - amberframework/smtp.cr - SMTP client (to be replaced with arcage/crystal-email) ysbaddaden/selenium-webdriver-crystal - Selenium Webdriver client
And basic Crystal's build-in shards:
http logger json colorize random/secure
Only the parts that are used end up in the compiled project.
Now let's take a tour of all the important classes that exist in the Amber application and are useful for understanding the flow.
Amber adds some very convenient extensions to existing String and Number classes. The extensions are in the extensions/ directory, but here's a listing of the current ones:
def str? def email? def domain? def url? def ipv4? def ipv6? def mac_address? def hex_color? def hex? def alpha?(locale = "en-US") def numeric? def alphanum?(locale = "en-US") def md5? def base64? def slug? def lower? def upper? def credit_card? def phone?(locale = "en-US") def excludes?(value) def time_string?
def positive? def negative? def zero? def div?(n) def above?(n) def below?(n) def lt?(num) def self?(num) def lteq?(num) def between?(range) def gteq?(num)
In support/ directory there is a number of various support files that provide additional, ready made routines.
Currently, the following can be found there:
client_reload.cr - Support for reloading developer's browser file_encryptor.cr - Support for storing/reading encrypted versions of files message_encryptor.cr message_verifier.cr locale_formats.cr - Very basic locate data for various, manually-added locales mime_types.cr - List of MIME types and helper methods for working with them: def self.mime_type(format, fallback = DEFAULT_MIME_TYPE) def self.zip_types(path) def self.format(accepts) def self.default def self.get_request_format(request)
This is the base controller from which all other controllers inherit. Source file is in src/amber/controller/base.cr.
On every request, the appropriate controller is instantiated and its initialize() runs. Since this is the base controller, this code runs on every request so you can understand what is available in the context of every controller.
The content of this controller and the methods it gets from including other modules are intuitive enough to be copied here and commented where necessary:
module Amber::Controller class Base include Helpers::CSRF include Helpers::Redirect include Helpers::Render include Helpers::Responders include Helpers::Route include Callbacks protected getter context : HTTP::Server::Context protected getter params : Amber::Validators::Params delegate :cookies, :format, :flash, :port, :requested_url, :session, :valve, :request_handler, :route, :websocket?, :get?, :post?, :patch?, :put?, :delete?, :head?, :client_ip, :request, :response, :halt!, to: context def initialize(@context : HTTP::Server::Context) @params = Amber::Validators::Params.new(context.params) end end end
Helpers::CSRF provides these methods:
def csrf_token def csrf_tag def csrf_metatag
def redirect_to(location : String, **args) def redirect_to(action : Symbol, **args) def redirect_to(controller : Symbol | Class, action : Symbol, **args) def redirect_back(**args)
LAYOUT = "application.slang" macro render(template = nil, layout = true, partial = nil, path = "src/views", folder = __FILE__)
Helpers::Responders helps control what final status code, body, and content-type will be returned to the client.
def action_name def route_resource def route_scope def controller_name
macro before_action macro after_action
We hope you have enjoyed this hands-on introduction to Amber!
Feel free to provide any feedback on content or additional areas you
would like to see covered in this guide. Thanks!