How to Build a Server with Hanami and CockroachDB | Ruby

In this article, we are going to create a CRUD application using Hanami and using Cockroach DB as a database.

We will explore how to create controllers and routes to perform CRUD operations in Hanami and how to test the response of the endpoints using Rspec.

Hanami

Hanami is a Ruby framework designed to create software that is well-architected, maintainable and a pleasure to work on.

Hanami is a full-stack Ruby web framework. It's made up of smaller, single-purpose libraries.

This repository is for the full-stack framework, which provides the glue that ties all the parts together:

These components are designed to be used independently or together in a Hanami application.

Requirements

  • Ruby installed

Installing Hanami

Hanami supports Ruby (MRI) 3.0+

PowerShell

gem install hanami

Creating a Hanami project

PowerShell

hanami new hanami-cockroachdb

Project structure

PowerShell

cd hanami-cockroachdb
tree .
.
├── Gemfile
├── Gemfile.lock
├── Guardfile
├── README.md
├── Rakefile
├── app
│   ├── action.rb
│   └── actions
├── config
│   ├── app.rb
│   ├── puma.rb
│   ├── routes.rb
│   └── settings.rb
├── config.ru
├── lib
│   ├── hanami_cockroachdb
│   │   └── types.rb
│   └── tasks
└── spec
    ├── requests
    │   └── root_spec.rb
    ├── spec_helper.rb
    └── support
        ├── requests.rb
        └── rspec.rb

9 directories, 16 files
bundle
bundle exec hanami server

If you are using a Windows machine, is possible to receive the following message in your command line:

To solve this issue, we have to open the project with a code editor and make some changes in the gemfile and config/puma.rb file.

gemfile

In the gemfile we have to add the line gem 'wdm', '>= 0.1.0' if Gem.win_platform?

# frozen_string_literal: true

source "https://rubygems.org"

gem "hanami", "~> 2.0"
gem "hanami-router", "~> 2.0"
gem "hanami-controller", "~> 2.0"
gem "hanami-validations", "~> 2.0"

gem "dry-types", "~> 1.0", ">= 1.6.1"
gem "puma"
gem "rake"
#Here we add the line:
gem 'wdm', '>= 0.1.0' if Gem.win_platform?

group :development, :test do
  gem "dotenv"
end

group :cli, :development do
  gem "hanami-reloader"
end

group :cli, :development, :test do
  gem "hanami-rspec"
end

group :development do
  gem "guard-puma", "~> 0.8"
end

group :test do
  gem "rack-test"
end

Then, we go to config/puma.rb.

puma. rb

In this file, we have to comment on the line workers ENV.fetch("HANAMI_WEB_CONCURRENCY", 2), according to this issue on Puma's GitHub page.

# frozen_string_literal: true

max_threads_count = ENV.fetch("HANAMI_MAX_THREADS", 5)
min_threads_count = ENV.fetch("HANAMI_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

port        ENV.fetch("HANAMI_PORT", 2300)
environment ENV.fetch("HANAMI_ENV", "development")
# workers     ENV.fetch("HANAMI_WEB_CONCURRENCY", 2)

on_worker_boot do
  Hanami.shutdown
end

preload_app!

Now, we try again.

bundle
bundle exec hanami server

We go to localhost:2300 in our browser.

Building the app

Now, let's go to config/routes.rb .

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
  end
end

As the documentation says, Hanami provides a fast, simple router for handling HTTP requests.

Your application’s routes are defined within the Routes class in config/routes.rb

Each route in Hanami’s router is comprised of:

  • An HTTP method (i.e. get, post, put, patch, delete, options or trace).

  • A path.

  • An endpoint to be invoked.

Endpoints are usually actions within your application, but they can also be a block, a Rack application, or anything that responds to #call.

# Invokes the FeedReader::Actions:Feeds::Index action
get "/tasks", to: "tasks.index" 

# Invokes the FeedReader::Actions:Feeds::Create action 
post "/tasks", to: "tasks.create"
get "/rack-app", to: RackApp.new
get "/my-lambda", to: ->(env) { [200, {}, ["A Rack compatible response"]] }

To add a full set of routes for viewing and managing books, you can either manually add the required routes to your config/routes.rb file, or use Hanami’s action generator, which will generate actions in addition to adding routes for you.

$ bundle exec hanami generate action tasks.index
$ bundle exec hanami generate action tasks.show
$ bundle exec hanami generate action tasks.new
$ bundle exec hanami generate action tasks.create
$ bundle exec hanami generate action tasks.update
$ bundle exec hanami generate action tasks.destroy

A root method allows you to define a root route for handling GET requests to "/". In a newly generated application, the root path calls a block that returns “Hello from Hanami”. You can instead choose to invoke an action by specifying root to: "my_action". For example, with the following configuration, the router will invoke the home action:

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root to: "home"
  end
end

Displaying all the tasks

Let's create a route and a controller to display all the entries in an RSS URL.

bundle exec hanami generate action tasks.index

Now, we go to the config/route.rb file, and we should see that a route for our index action was added.

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
    get "/tasks", to: "tasks.index"
  end
end

The HanamiCockroachdb module contains the Routes class, which inherits from Hanami::Routes. The root method defines the root route of the web application with a welcome message. The get method defines a route for HTTP GET requests to the /tasks URL, which maps to the index action in the Tasks controller.

Now, we go to app/actions/tasks/index.rb to code our first controller.

# frozen_string_literal: true

module HanamiCockroachdb
  module Actions
    module Tasks
      class Index < HanamiCockroachdb::Action

        def my_task 
          task = {
            "task":"Writing a new article",
            "completed": "false",
          }
          return task
        end

        def handle(*, response)
          task = my_task
          response.format = :json
          response.body = task.to_json
        end
      end
    end
  end
end

If use of browser and go to localhost:2300/tasks, we should receive the following response in our window:

Testing endpoints with Rspec

We create a spec/requests/index_spec.rb file to test the index action.

RSpec.describe "GET /tasks", type: :request do
    it "is successful" do
      get "/tasks"

      expect(last_response).to be_successful
      expect(last_response.content_type).to eq("application/json; charset=utf-8")

      response_body = JSON.parse(last_response.body)  
      # Find me in `config/routes.rb`

      expect(response_body).to eq({})
    end
  end
bundle exec rspec spec/requests/index_spec.rb

RSpec.describe "GET /tasks", type: :request do
    it "is successful" do
      get "/tasks"

      expect(last_response).to be_successful
      expect(last_response.content_type).to eq("application/json; charset=utf-8")

      response_body = JSON.parse(last_response.body)  


      expect(response_body).to eq({"task"=>"Writing a new article","completed" =>"false"})
    end
  end

We run again the bundle exec rspec spec/requests/index_spec.rb command in our terminal. And we should see the following response:

Creating a CockroachDB Cluster

We have to have a CockroachDB account and create a cluster. You can sign in here.

After we create a cluster. We press on Connect.

Then, we select Ruby in the language field and Pg in the tool field.

We copy the DATABASE_URL, which is the connection URL. Through this URL we connect our app to the cluster.

When we create a new cluster, we have to download a CA certificate. CockroachDB shows us a URL we have to copy and paste into our command line or PowerShell on Windows, to download the certificate.

The URL has the form:

mkdir -p $env:appdata\postgresql\; Invoke-WebRequest -Uri https://cockroachlabs.cl

We need to add these dependencies to the gemfile and run bundle install:

# Gemfile
gem "rom", "~> 5.3"
gem "rom-sql", "~> 3.6"
gem "pg"

group :test do
  gem "database_cleaner-sequel"
end

We run bundle install in our command line.

According to the documentation, in Hanami, providers offer a mechanism for configuring and using dependencies, like databases, within your application.

Copy and paste the following provider into a new file at config/providers/persistence.rb:

Hanami.app.register_provider :persistence, namespace: true do
    prepare do
      require "rom"

      config = ROM::Configuration.new(:sql, target["settings"].database_url)

      register "config", config
      register "db", config.gateways[:default].connection
    end

    start do
      config = target["persistence.config"]

      config.auto_registration(
        target.root.join("lib/tasks/persistence"),
        namespace: "HanamiCockroachdb::Persistence"
      )

      register "rom", ROM.container(config)
    end
  end

For this persistence provider to function, we need to establish a database_url setting.

Settings in Hanami are defined by a Settings class in config/settings.rb:

# frozen_string_literal: true

module HanamiCockroachdb
  class Settings < Hanami::Settings
    # Define your app settings here, for example:
    #
    # setting :my_flag, default: false, constructor: Types::Params::Bool
    setting :database_url, constructor: Types::String
  end
end

Settings can be strings, booleans, integers and other types. Each setting can be either optional or required (meaning the app won’t boot without them), and each can also have a default.

You can read more about Hanami’s settings in the Application guide.

Let’s add database_url and make it a required setting by using the Types::String constructor:

.env file

In the .env file we paste the DATABASE_URL of the Cockroach DB cluster.

DATABASE_URL=<CockroachDB DATABASE_URL>

Rakefile

Enable rom-rb's rake tasks for database migrations by appending the following code to the Rakefile:

# frozen_string_literal: true

require "hanami/rake_tasks"
require "rom/sql/rake_task"

task :environment do
  require_relative "config/app"
  require "hanami/prepare"
end

namespace :db do
  task setup: :environment do
    Hanami.app.prepare(:persistence)
    ROM::SQL::RakeSupport.env = Hanami.app["persistence.config"]
  end
end

With persistence ready, we can now create the tasks table.

To create a migration run:

$ bundle exec rake db:create_migration[create_tasks]

Now, we go to db/<timestamp>_create_tasks.db which is the migration file, to define our table.

# frozen_string_literal: true

ROM::SQL.migration do
  change do
  end
end

Now, we change this file, to create our table.

Initially, I want to store the tasks in the database, for now.

Here, we define the table, with three fields: Primary key, the name of the task, and its status.

ROM::SQL.migration do
  change do
    create_table :tasks do
      primary_key :id
      column :task, :text, null: false
      column :completed, :text, null: false
    end  
  end
end

Now we run the following command to run migrations.

bundle exec rake db:migrate

Then, let’s add a rom-rb relation to allow our application to interact with our tasks table. Create the following file at lib/hanami_cockroachdb/persistence/relations/tasks.rb:

module HanamiCockroachdb
    module Persistence
      module Relations
        class Tasks < ROM::Relation[:sql]
          schema(:tasks, infer: true)
        end
      end
    end
  end

Create action

Now, we run the following command to create a new action:

$ bundle exec hanami generate action tasks.create

This new action is to perform POST operation and create a new row in the database.

We have to add the body_parser middleware to be able to parse the body of a POST request.

We go to the config/app.rb file and add the line config.middleware.use :body_parser, :json to the App class.

require "hanami/action"

module HanamiCockroachdb
  class App < Hanami::App
    config.middleware.use :body_parser, :json
  end
end

With this parser, the task key will be available in the action via request.params[:task].

If we go to config/routes.rb we can confirm that a route for POST requests was added.

# frozen_string_literal: true

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
    get "/tasks", to: "tasks.index"
    post "/tasks", to: "tasks.create"
  end
end

We go to app/actions/tasks/create.rb to write the handler for POST requests.

# frozen_string_literal: true

module HanamiCockroachdb
  module Actions
    module Tasks
      class Create < HanamiCockroachdb::Action
        include Deps["persistence.rom"]

        params do
          required(:task).hash do
            required(:task).filled(:string)
            required(:completed).filled(:string)
          end
        end 


        def handle(request, response)
          if request.params.valid?
            task = rom.relations[:tasks].changeset(:create, request.params[:task]).commit

            response.status = 201
            response.body = task.to_json
          else
            response.status = 422
            response.format = :json
            response.body = request.params.errors.to_json
          end   
        end
      end
    end
  end
end

This class extends the HanamiCockroachdb::Action class and includes a dependency on persistence.rom. In this class, we define a params block that requires two fields, task and completed, both of which must be of type string.

Then we define the handle method that takes in a request and a response object. Within the handle method, we first check if the parameters in the request are valid by checking request.params.valid?. If they are, we create a new task in the database using a changeset from rom.relations[:tasks], passing in the task parameter value from the request as an argument.

If the task creation is successful, we set the response status to 201 (created) and return the task object as JSON in the response body. If the parameters are not valid, we set the response status to 422 (unprocessable entity) and return the validation errors in JSON format as the response body.

We can write a test for the POST request.

We create spec/requests/create_spec.rb file to test the create controller.

# spec/requests/create_spec.rb

RSpec.describe "POST /tasks", type: [:request, :database] do
    let(:request_headers) do
      {"HTTP_ACCEPT" => "application/json", "CONTENT_TYPE" => "application/json"}
    end

    context "given valid params" do
      let(:params) do
        {task: {task: "Buy the groceries", completed: "true"}}
      end

      it "creates a task" do
        post "/tasks", params.to_json, request_headers

        expect(last_response).to be_created
      end
    end

    context "given invalid params" do
      let(:params) do
        {task: {task: nil}}
      end

      it "returns 422 unprocessable" do
        post "/tasks", params.to_json, request_headers

        expect(last_response).to be_unprocessable
      end
    end
  end

We run the bundle exec rspec spec/requests/create_spec.rb command, to run the test.

If everything is good. We should see the following message:

Index action

There is no need to create another action, we just need to rewrite the code so it will be able to make queries to the database.

# frozen_string_literal: true

module HanamiCockroachdb
  module Actions
    module Tasks
      class Index < HanamiCockroachdb::Action
        include Deps["persistence.rom"]

        def handle(*, response)
          task = rom.relations[:tasks]
            .select(:id, :task, :completed)
            .to_a
          response.format = :json
          response.body = task.to_json
        end
      end
    end
  end
end

Within the handle method, we first access the tasks relation from rom.relations[:tasks]. We then select the id, task, and completed columns from the tasks table. Finally, we convert the returned tasks to an array and set the response format to JSON and body to the array of tasks as JSON.

Show action

To create a show action to retrieve a task by its ID. We run the bundle exec hanami generate action tasks.show command in our console.

A new route will be added to the config/routes.rb file,

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
    get "/tasks", to: "tasks.index"
    post "/tasks", to: "tasks.create"
    get "/tasks/:id", to: "tasks.show"
  end
end

Now, we create a spec/requests/show_spec.rb file, to write a test for the show action.

# spec/requests/show_spec.rb

RSpec.describe "GET /tasks/:id", type: [:request, :database] do
    let(:task) { app["persistence.rom"].relations[:tasks] }

    context "when a task matches the given id" do
      let!(:id) do
        task.insert(task: "Publish a new article", completed: "false")
      end

      it "renders the task" do
        get "/tasks/#{id}"

        expect(last_response).to be_successful
        expect(last_response.content_type).to eq("application/json; charset=utf-8")

        response_body = JSON.parse(last_response.body)

        expect(response_body).to eq(
          "id" => id, "task" => "Publish a new article", "completed" => "false"
        )
      end
    end

    context "when no task matches the given id" do
      it "returns not found" do
        get "/tasks/#{task.max(:id).to_i + 1}"

        expect(last_response).to be_not_found
        expect(last_response.content_type).to eq("application/json; charset=utf-8")

        response_body = JSON.parse(last_response.body)

        expect(response_body).to eq(
          "error" => "not_found"
        )
      end
    end
  end

Now, we run test by running the bundle exec rspec spec/requests/show_spec.rb command.

The test fails because we didn't write our handler yet.

# frozen_string_literal: true
require "rom"
module HanamiCockroachdb
  module Actions
    module Tasks
      class Show < HanamiCockroachdb::Action
        include Deps["persistence.rom"]

        params do
          required(:id).value(:integer)
        end

        def handle(request, response)

          task = rom.relations[:tasks].by_pk(
            request.params[:id]
          ).one
          response.format = :json

          if task
            response.body = task.to_json

          else
            response.status = 404
            response.body = {error:"not_found"}.to_json

          end  
        end
      end
    end
  end
end

This class extends the HanamiCockroachdb::Action class and includes a dependency on persistence.rom. The class also defines a params block for validating the input params and ensuring that only an id of a specific type is provided.

Within the handle method, we first retrieve the task from the tasks relation using the by_pk method of rom.relations[:tasks] and passing in the id parameter from the request. We then format the response as JSON.

If a task is found, we set the response body to the task details in JSON format. If no task is found, we set the response status to 404 (not found) and set the response body to an error message as JSON.

If we run the test again, it should be a success.

Update action

For the update action, we create spec/requests/update_spec.rb file to test the update action.



RSpec.describe "PATCH /tasks/:id", type: [:request, :database] do
    let(:task) { app["persistence.rom"].relations[:tasks] }
    let!(:id) do
      task.insert(task: "Publish a new article", completed: "false")
    end

    context "when a task matches the given id" do


        it "renders the task" do
          patch "/tasks/#{id}", {"task": {"task":"Publish a new article", "completed":"false"}}.to_json, "CONTENT_TYPE" => "application/json"

          expect(last_response).to be_successful
          expect(last_response.content_type).to eq("application/json; charset=utf-8")

          response_body = JSON.parse(last_response.body)

          expect(response_body).to eq(
            "id" => id, "task" => "Publish a new article", "completed" => "false"
          )
        end
      end

    context "given valid params" do

      it "should update the task" do
        patch "/tasks/#{id}", {"task": {"task":"Update task", "completed":"true"}}.to_json, "CONTENT_TYPE" => "application/json"

        expect(last_response).to be_successful
        updated_task = task.by_pk(id).one

        expect(updated_task[:task]).to eq("Update task")
        expect(updated_task[:completed]).to eq("true")
      end
    end

    context "given invalid params" do


      it "returns 422 unprocessable" do
        patch "/tasks/#{id}", {task: {task: nil}}.to_json, "CONTENT_TYPE" => "application/json"

        expect(last_response).to be_unprocessable
      end
    end

    context "when no task matches the given id" do
      it "returns not found" do
        patch "/tasks/#{task.max(:id).to_i + 1}", {"task": {"task":"Update task", "completed":"true"}}.to_json, "CONTENT_TYPE" => "application/json"

        expect(last_response).to be_not_found

        response_body = JSON.parse(last_response.body)

        expect(response_body).to eq(
          "error" => "not_found"
        )
      end
    end
end

Let's create the update action by running this command in our console: bundle exec hanami generate action tasks.update

It should generate a PATCH route inside the config/routes.rb file.

# frozen_string_literal: true

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
    get "/tasks", to: "tasks.index"
    post "/tasks", to: "tasks.create"
    get "/tasks/:id", to: "tasks.show"
    patch "/tasks/:id", to: "tasks.update"
  end
end

We go to app/actions/tasks/update.rb to write the handler for PATCH requests.

# frozen_string_literal: true

module HanamiCockroachdb
  module Actions
    module Tasks
      class Update < HanamiCockroachdb::Action
        include Deps["persistence.rom"]

        params do
          required(:id).value(:integer)
          required(:task).hash do
            required(:task).filled(:string)
            required(:completed).filled(:string)
          end
        end 


        def handle(request, response)
          if request.params.valid?
            task = rom.relations[:tasks].by_pk(
              request.params[:id]
            ).one
            response.format = :json

            if task
              task = rom.relations[:tasks].by_pk(request.params[:id]).changeset(:update, request.params[:task]).commit
              response.body = task.to_json

            else
              response.status = 404
              response.body = {error:"not_found"}.to_json

            end
          else
            response.status = 422
            response.format = :json
            response.body = request.params.errors.to_json
          end       
        end
      end
    end
  end
end

In this class, we define a params block for validating the input params and ensuring that only an id of a specific type is provided.

Like in the Show Action, within the handle method, we first retrieve the task from the tasks relation using the by_pk method of rom.relations[:tasks] and passing in the id parameter from the request. We then format the response as JSON.

If a task is found, we retrieve the changeset by leveraging the method changeset of the relation object rom.relations[:tasks] and committing the changes by using the commit method. This updates the task in the database and returns the updated task.

If no task is found, we set the response status to 404 (not found) and set the response body to an error message as JSON.

If we run our test again with the bundle exec rspec spec/requests/update_spec.rb command, we should receive the following output:

Destroy action

The last action we will create is the destroy action, the action that will handle the DELETE request given a specific ID.

We run the following command to create this action: bundle exec hanami generate action tasks.destroy .

It will add a DELETE route to the config/routes.rb.

module HanamiCockroachdb
  class Routes < Hanami::Routes
    root { "Hello from Hanami" }
    get "/tasks", to: "tasks.index"
    post "/tasks", to: "tasks.create"
    get "/tasks/:id", to: "tasks.show"
    patch "/tasks/:id", to: "tasks.update"
    delete "/tasks/:id", to: "tasks.destroy"
  end
end

Now, we go to app/actions/tasks/destroy.rb.

# frozen_string_literal: true

module HanamiCockroachdb
  module Actions
    module Tasks
      class Destroy < HanamiCockroachdb::Action
        include Deps["persistence.rom"]
        params do
          required(:id).value(:integer)

        end 

        def handle(request, response)
          task = rom.relations[:tasks].by_pk(
            request.params[:id]
          ).one

          if task
            task = rom.relations[:tasks].by_pk(request.params[:id]).command(:delete)
            task.call
            response.body = "Task Deleted"

          else
            response.status = 404
            response.body = {error:"not_found"}.to_json

          end  
        end
      end
    end
  end
end

We define a params block for validating the input params and ensuring that only an id of a specific type is provided.

Within the handle method, we first retrieve the task from the tasks relation using the by_pk method of rom.relations[:tasks] and passing in the id parameter from the request.

If the task is found, we retrieve the command to delete the task by using the command method of the relation object rom.relations[:tasks] and passing in the :delete argument. We then execute the command by calling the call method on the task object, effectively deleting the task from the database.

If no task is found, we set the response status to 404 (not found) and set the response body to an error message as JSON.

Now, let´s test this endpoint. We create a new file, spec/requests/destroy_spec.rb for testing the destroy action.

# spec/requests/delete_spec.rb

RSpec.describe "DELETE /tasks/:id", type: [:request, :database] do
    let(:task) { app["persistence.rom"].relations[:tasks] }


    context "when a task matches the given id" do
      let!(:id) do
        task.insert(task: "Publish a new article", completed: "false")
      end

      it "deletes the task" do
        delete "/tasks/#{id}"

        expect(last_response).to be_successful



        response_body = last_response.body

        expect(response_body).to eq(
          "Task Deleted"
        )
      end
    end

    context "when no task matches the given id" do
      it "returns not found" do
        delete "/tasks/#{task.max(:id).to_i + 1}"

        expect(last_response).to be_not_found


        response_body = JSON.parse(last_response.body)

        expect(response_body).to eq(
          "error" => "not_found"
        )
      end
    end
  end

Conclusion

In conclusion, building a CRUD API with Hanami can be a rewarding experience. Hanami provides a set of tools that allow developers to create web applications quickly and efficiently. Hanami is an excellent choice for building APIs that require high performance and reliability.

During this tutorial, we have seen how to set up a basic CRUD API using Hanami and how to integrate it with a Cockroach database. We started by generating a new Hanami project using the hanami CLI tool and then creating database. We then went on to create RESTful routes for our API endpoints and implemented the basic CRUD operations, including adding, updating, deleting, and reading data records from the database.

In addition to the core features, we also covered Hanami's test features using Rspec and wrote tests for every endpoint. We demonstrated how to use Hanami's validators to ensure that requests and data are validated and sanitized before persisting them in the database.

Resources