DEV Community

Carlos Armando Marcano Vargas
Carlos Armando Marcano Vargas

Posted on • Originally published at carlosmv.hashnode.dev on

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 
Enter fullscreen mode Exit fullscreen mode

Creating a Hanami project

PowerShell

hanami new hanami-cockroachdb 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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! 
Enter fullscreen mode Exit fullscreen mode

Now, we try again.

bundle bundle exec hanami server 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

Your applications routes are defined within the Routes class in config/routes.rb

Each route in Hanamis 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"]] } 
Enter fullscreen mode Exit fullscreen mode

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 Hanamis 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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode


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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

You can read more about Hanamis settings in the Application guide.

Lets 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> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

To create a migration run:

$ bundle exec rake db:create_migration[create_tasks] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Now we run the following command to run migrations.

bundle exec rake db:migrate 
Enter fullscreen mode Exit fullscreen mode

Then, lets 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 
Enter fullscreen mode Exit fullscreen mode

Create action

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

$ bundle exec hanami generate action tasks.create 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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, lets 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 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)