How to build a URL shortener in Amber

Posted on Nov 10, 2018

Crystal Tailwind CSS Amber

In this tutorial, we will create a URL shortening service in Amber - one of the popular Crystal web application frameworks.

Shortener home page preview

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.

1
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

1
2
3
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 Forgery
  • plug 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
  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.

1
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.

1
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.

1
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.

1
2
3
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.

Route list

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.

1
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).

1
2
3
4
5
6
7
8
9
10
11
-- +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 UUIDs 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 and updated_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

1
2
3
4
5
6
7
8
9
10
11
12
13
  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

1
2
3
4
5
  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

1
2
3
4
5
  getter shortened_link : ShortenedLink = ShortenedLink.new

  before_action do
	only [:show] { set_shortened_link }
  end

And another private method to the bottom

1
2
3
  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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

1
2
3
4
5
6
7
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

1
2
3
4
5
6
7
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.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;
}
A preview of the create page

Preview of creation page

Handling the Redirect

Run the following command to create a new controller

1
amber g controller redirect show:get

And add the new route to match the controller in confi/routes.cr in the :web block

1
get "/r/:id", RedirectController, :show

Then update the RedirectController to the following

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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.

1
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.

Like this article? Sign up to updates - no spam!