In this tutorial, we will create a URL shortening service in Amber - one of the popular Crystal web application frameworks.
Along the way, we’ll do the following:
- Set up a new Amber project
- Use the built-in scaffold generator
- Run through the basics of routing, controllers and templates
- Validate user-entered data
- Use UUID primary keys for generating random URLs
- Redirect and respond to requests
Setting up a new project
If you haven’t already, follow the instructions to install Crystal, Amber and Node as seen here. This tutorial uses Crystal 0.27.0 and Amber 0.11.1 but works with Crystal 0.26.1 and Amber 0.10.0 as well. You’ll also need PostgreSQL installed as well for the database.
First let's create a new Amber application, run the following command in your terminal to get started.
amber new shortly
This will generate a new app named shortly using the Amber defaults, PostgreSQL for the database, and Slang for the templates. If you prefer, you can use amber new shortly -t ecr
to use HTML templates rather than Slang. ECR is less efficient than Slang but more convenient if you have a HTML template to work from.
Most of the code is in the src
directory, this includes Models, Views and Controllers. Similarly to Ruby on Rails, the routes are located in the config
directory.
To get the server up and running use the following commands. The first one downloads Crystal dependencies, the second creates the database, and the third runs the HTTP Server and Webpack with live reloading.
Note: If you want to edit the database configuration, now is the time to do so. Open config/environments/development.yml
and look for the database_url
key
shards install
amber db create
amber watch
Open http://0.0.0.0:3000
to see the default home page.
A brief introduction to routing
Amber uses a similar approach to Elixir Phoenix when it comes to routing by using plugs
and pipelines
.
A plug is a piece of middleware that runs during a request. Lots of these will run one after the other, transforming the request depending on what was sent from the client and which route the request matches against.
Some example plugs include:
Amber::Pipe::Logger.new
- Handles the logging functionality.Amber::Pipe::CSRF.new
- Prevent form requests from Cross-Site Request Forgeryplug Amber::Pipe::Flash.new
- Handles flash messages
Applying plugs to each individual route wouldn’t be an efficient way of doing things. Instead, You can group common sets of plugs together in a pipeline.
Amber comes with three pipelines that provide common functionality - but you can create your own, too.
web
is for standard web requests and includes most things you’ll need.api
is similar to web but takes out the unnecessary plugs for APIs such as CSRF and Flash messages. It also adds in the CORS plug.static
is for requests to static assets.
Other useful pipelines you might want to build include authentication
for requests that sit behind a log-in system. Or admin
for requests that need user-level permissions to view admin functionality.
The routes
function takes a pipeline’s name and a block of routes. When a route resides within the web
routes block, it will run all of the web
pipeline plugs on a matching web request.
pipeline :web do
plug Amber::Pipe::PoweredByAmber.new
plug Citrine::I18n::Handler.new
plug Amber::Pipe::Error.new
plug Amber::Pipe::Logger.new
plug Amber::Pipe::Session.new
plug Amber::Pipe::Flash.new
plug Amber::Pipe::CSRF.new
end
routes :web do
get "/", HomeController, :static
end
If your already familiar with other web frameworks then you’ll probably know the types of requests to expect. These include get
, post
, update
, put
, delete
and resources
.
For all except resources, the first parameter is URL to match against, the second is the controller class to load, and the third is the method to run.
Resources, on the other hand, takes 2 parameters: the base URL which will be expanded into various URLs for CRUD actions, and the controller class to use each request with. These will need class methods matching each URL.
There is also a third, optional, parameter which lets you include or exclude specific CRUD actions.
resources "/user", UserController, only: [:index, :show]
Creating a new controller
Although a full scaffold
command exists in Amber, for this application doing the process manually allows each step to be explained and understood more thoroughly.
Run the following command to create a new controller.
amber g controller Shortener index:get show:get create:post
This controller will generate 3 end points:
index
which will replace the root URL and have a form for the user to enter their desired shortened web address.create
which will handle the post request to generate a shortened URL.show
will be where the user is redirected to after submitting a URL and will show their new address to copy and share.
Delete the create.slang
template file as it won’t be used.
rm src/views/shortener/create.slang
Opening up src/controllers/shortener_controller.cr
we can see that the class has three methods and also inherits from ApplicationController
.
Looking at the src/controllers/application_controller.cr
file, we can see JasperHelpers
which makes a few useful helper methods available including form, input and link helpers. There's also a layout variable which sets the layout template to use. This class then inherits further functionality from Amber::Controller::Base
.
Amber::Controller::Base
adds in several helper modules including functionality related to CSRF, Redirects, and Rendering. It also adds the validation params
which is used for checking request data from the user (More on that later)
Back in the shortener_controller.cr
remove the render("create.slang")
as we won't be needing it.
Adding routes
Re-open the config/routes.cr
file and replace the :web
block with this.
routes :web do
resources "/", ShortenerController, only: [:index, :create, :show]
end
Run amber routes
in the terminal and the three routes we have added should show.
You can also see the fallback wildcard route, /*
. When Amber can't find a matching route it will look for static assets in the public
directory.
Creating the model
Use the amber g model
command to create a new model and migration file for ShortenedUrl
. Links can get quite long so its safer to use text
rather than string
, as VARCHAR
has a limit of 256 characters.
amber g model ShortenedLink original_url:text
Open up the db/migrations/x_create_shortened_link.sql
file. Amber uses Micrate
, a shard which handles migrating the database.
Micrate reads the commented SQL to handle what should be run.
The SQL below -- +micrate Up
will run when amber db migrate
is entered and -- +micrate Down
gets run when you want to rollback with amber db rollback
. There's also amber db status
to see what state your database schema is in.
In this tutorial we'll use id
for the shortened url code. Since integers starting from 1 aren’t an ideal format for this we'll store a shortened UUID
.
Modify the id
to be VARCHAR
, optionally set a max length e.g. VARCHAR(8)
.
-- +micrate Up
CREATE TABLE shortened_links (
id VARCHAR(8) PRIMARY KEY,
original_url TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- +micrate Down
DROP TABLE IF EXISTS shortened_links;
Note that UUID
s are supported with Postgres, however, since we're trimming the length down we'll be using varchar
.
Run amber db migrate
.
We'll need to modify the model class to support the new id format. Open src/models/shortened_link.cr
and update it to the following
require "uuid"
class ShortenedLink < Granite::Base
adapter pg
table_name shortened_links
primary id : String, auto: false
field original_url : String
timestamps
before_create :assign_id
def assign_id
potential_id = generate_id()
while ShortenedLink.find(potential_id)
potential_id = generate_id()
end
@id = potential_id
end
def generate_id
UUID.random.hexstring.to_s[0..7]
end
end
This file has a reasonable amount of code, here’s the breakdown.
- Line 1 includes the
UUID
library which is used to generate nicer IDs and shortened URL codes. - Line 2 opens the class and inherits from Granite, the default ORM for Amber.
- Line 3 specifies that PostgreSQL is the database that’s used.
- Line 4 declares the table name for the model.
- Line 5 is the primary key, notice it’s set to a string and auto increment is turned off
- Line 6 is the
original_url
field included in the schema. Any other fields should go here too. - Line 7 Adds timestamp functionality for
created_at
andupdated_at
columns. - Line 8 adds a before hook. This runs a method of the same name before new models are saved.
- Line 10 is the above mentioned method. Here a UUID is generated, turned into a hex string, checked for duplicates and then updates the
id
instance property ready to be saved.
Finishing the Shortener Controller
The index
will return the home page where users will enter their URL, the create
is where the form is posted to be validated and created, and the show
method will give the link to the user, ready for sharing.
Update the create
method with the following
def create
if shortened_link_params.valid?
shortened_link = ShortenedLink.new shortened_link_params.validate!
shortened_link.save
redirect_to(
location: "/links/#{shortened_link.id}",
status: 302,
flash: {"success" => "Created short link successfully."}
)
else
redirect_to(controller: :shortener, action: :index, flash: {"danger" => shortened_link_params.errors.first.message})
end
end
And add this private method to the controller
private def shortened_link_params
params.validation do
required :original_url {|f| !f.nil? && f.url? }
end
end
The create
method calls the shortened_link_params
method to check if the posted data is valid, if it is then it saves it to the database and redirects the user to the show
page. If it isn’t, the user is redirected back and is sent an error message. Notice how the validation checks if :original_url
is sent, if it is nil and if it is a valid url string. This parameter validation functionality comes from the Amber::Controller::Base
class mentioned earlier.
To finish off the controller add the following to the top of it
getter shortened_link : ShortenedLink = ShortenedLink.new
before_action do
only [:show] { set_shortened_link }
end
And another private method to the bottom
private def set_shortened_link
@shortened_link = ShortenedLink.find! params[:id]
end
Here we’re using a before hook to set relevant model for the show
method, ready to be used in the template. Since there is only one method using this we could put the functionality straight into the controller show
method. However, it's worth demonstrating it for usage with controllers that have edit
, update
, and delete
functionality as well.
Templates
Amber uses Slang
templates by default. If you’re familiar with Slim
/Haml
in Ruby then you should be in familiar terrority. HTML (ecr
templates) can be used as well, however they are not as efficient as Slang
.
Update views/layouts/application.slang
doctype html
html.min-h-screen
head
title Shortly using Amber
meta charset="utf-8"
meta http-equiv="X-UA-Compatible" content="IE=edge"
meta name="viewport" content="width=device-width, initial-scale=1"
link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css"
link rel="stylesheet" href="/dist/main.bundle.css"
link rel="apple-touch-icon" href="/favicon.png"
link rel="icon" href="/favicon.png"
link rel="icon" type="image/x-icon" href="/favicon.ico"
body.flex.items-center.min-h-screen.bg-orange-lightest.text-black.pb-8
.container.mx-auto
- flash.each do |key, value|
div class="alert alert-#{key}"
p = flash[key]
== content
script src="/dist/main.bundle.js"
views/shortener/index.slang
div.container.mx-auto.text-center.mb-16
h1.text-3xl.mb-8 Link Shortener
== form(action: "/links", method: :post, class: "w-full max-w-md mx-auto") do
== csrf_tag
.flex.items-center.border-b.border-b-2.border-black.py-2
== text_field name: "original_url", value: "", placeholder: "http://", class: "appearance-none bg-transparent border-none w-full text-grey-darker mr-3 py-1 px-2 leading-tight"
== submit("Create Link", class: "flex-no-shrink bg-teal bg-black border-black border-black text-sm border-4 text-white py-1 px-2 rounded")
views/shortener/show.slang
div.container.mx-auto.text-center.my-8
h1.text-3xl.mb-8 Link Shortener
h2.text-2xl.mb-4 Your link
p
== link_to "http://#{request.host_with_port}/#{@shortened_link.id}", "http://#{request.host_with_port}/#{@shortened_link.id}"
p.button-link
== link_to "Create a new link", "/"```
src/assets/stylesheets/main.scss
.alert-danger {
background-color: #e3342f;
color: #fff;
}
.alert-success {
background-color: #38c172;
color: #22292f;
}
.button-link a {
text-decoration: none;
display: inline-block;
margin-top: .5rem;
background-color: #22292f;
background-color: #22292f;
border-color: #22292f;
border-color: #22292f;
font-size: .875rem;
border-width: 4px;
color: #fff;
padding-top: .25rem;
padding-bottom: .25rem;
padding-left: .5rem;
padding-right: .5rem;
border-radius: .25rem;
}
Handling the Redirect
Run the following command to create a new controller
amber g controller redirect show:get
And add the new route to match the controller in confi/routes.cr
in the :web
block
get "/r/:id", RedirectController, :show
Then update the RedirectController
to the following
class RedirectController < ApplicationController
getter shortened_link : ShortenedLink = ShortenedLink.new
before_action do
only [:show] { set_shortened_link }
end
def show
redirect_to(location: "#{@shortened_link.original_url}", status: 302)
end
private def set_shortened_link
@shortened_link = ShortenedLink.find! params[:id]
end
end
This code is a bit overkill for how small the controller is, however, it demonstrates using before_action
hooks again for an specific actions, defining a getter
with a default empty model on line 3, and redirecting to a new URL on line 10.
The show
method triggers the before_action
which in turn...
- Attempts to find a Shortened link by the ID URL parameter
- Sets that model to the instance variable
shortened_link
This means the show method then has access to this variable and can use it to redirect the user.
Running the application
If you haven't already tried, then run the watch
command and view it in your browser.
amber watch
Amber watch will both start up the Crystal web server on port 3000 and run Webpack in the background to compile Javascript and CSS. Whenever you make a change the page in the browser will be refreshed.
Summary
This tutorial has covered many aspects of Amber, including basic usage of controllers, routes, and templates. It’s also given a gentle introduction to controller hooks and tables with a UUID primary key.
If this article interested you, please sign up for my newsletter below where I'll announce my latest programming articles (including Crystal!).
You can also checkout my previous Crystal/Amber articles, including using Tailwind CSS with Amber, or look into a deeper look into UUID’s with Amber.