How to build a URL shortener in Amber

Mitch

Warning - Old content ahead!

This page has been marked as a legacy post. This means it's quite old (from 2018) and probably out of date. Take it with a grain of salt!

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:

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:

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.

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:

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.

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.

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

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.

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;
}
A preview of the create page
![Preview of creation page](/images/amber-shortly-create.png)

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

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.

Spread the word

Share this article

Like this content?

Check out some of the apps that I've built!

Snipline

Command-line snippet manager for power users

View

Pinshard

Third party Pinboard.in app for iOS.

View

Rsyncinator

GUI for the rsync command

View

Comments