How to Build a Server with Hanami and CockroachDB | Ruby
Carlos Armando Marcano Vargas
Posted on June 20, 2023
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:
Hanami::Router - Rack-compatible HTTP router for Ruby
Hanami::Controller - Full-featured, fast and testable actions for Rack
Hanami::View - Presentation with a separation between views and templates
Hanami::Helpers - View helpers for Ruby applications
Hanami::Mailer - Mail for Ruby applications
Hanami::Assets - Assets management for Ruby
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 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
ortrace
).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 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
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 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>
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, 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
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, 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
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
Posted on June 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.