Hello. If you’re reading this page, it’s likely you want to learn more about Hanami. This is great, and we’re excited to have you here!

If you’re looking for new ways to build maintainable, secure, faster and testable Ruby applications, you’re in for a treat. Hanami is built for people like you.

Whether you’re a total beginner or an experienced developer, this learning process may still be hard. Over time, we become used to certain things, and it can be painful to change. But without change, there is no challenge and without challenge, there is no growth.

In this guide we’ll set up our first Hanami project and build a simple web app. We’ll touch on all the major components of the Hanami framework, guided by tests at each stage.

If you feel alone or frustrated, don’t give up, jump into our forum and ask for help. We and the rest of our community are putting in our best efforts to make Hanami better every day.

Enjoy,
Luca, Peter and Tim
Hanami core team


Getting started

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

These guides aim to introduce you to the Hanami framework and demonstrate how its components fit together to produce a coherent application.

Ideally, you already have some familiarity with web applications and the Ruby language.

Creating a Hanami application

Prerequisites

To create a Hanami application, you will need Ruby 3.0 or greater. Check your ruby version:

  1. ruby --version

If you need to install or upgrade Ruby, follow the instructions on ruby-lang.org.

Installing the gem

In order to create a Hanami application, first install the hanami gem:

  1. gem install hanami

Using the application generator

Hanami provides a hanami new command for generating a new application.

Let’s use it to create a new application for managing books called bookshelf:

  1. hanami new bookshelf

Running this command has created a new bookshelf directory in our current location. Here’s what it contains:

  1. cd bookshelf
  2. tree .
  3. .
  4. ├── Gemfile
  5. ├── Gemfile.lock
  6. ├── Guardfile
  7. ├── README.md
  8. ├── Rakefile
  9. ├── app
  10. ├── action.rb
  11. └── actions
  12. ├── config
  13. ├── app.rb
  14. ├── puma.rb
  15. ├── routes.rb
  16. └── settings.rb
  17. ├── config.ru
  18. ├── lib
  19. ├── bookshelf
  20. └── types.rb
  21. └── tasks
  22. └── spec
  23. ├── requests
  24. └── root_spec.rb
  25. ├── spec_helper.rb
  26. └── support
  27. ├── requests.rb
  28. └── rspec.rb
  29. 9 directories, 16 files

As you can see, a new Hanami application has just 16 files in total.

Here’s how these files and directories are used:

LocationPurpose
GemfileThe application’s gem dependencies, installed using bundler.
GuardfileSupports code reloading in development.
README.mdThe application’s readme document.
RakefileSupport for running rake tasks.
app/This is the directory where you’ll put the majority of your application’s code.
config/A directory for your application’s configuration, including things like routes, settings and Puma configuration.
config.ruA Rack config file.
lib/A directory for supporting code.
spec/The application’s RSpec test suite.

We’ll see this structure in more detail as this guide progresses.

For now let’s get our new application running. In the bookshelf directory, run:

  1. hanami server

If all has gone well, you should see output similar to:

  1. Puma starting in cluster mode...
  2. * Puma version: 6.0.0 (ruby 3.1.0-p0) ("Sunflower")
  3. * Min threads: 5
  4. * Max threads: 5
  5. * Environment: development
  6. * Master PID: 69708
  7. * Workers: 2
  8. * Restarts: (✔) hot (✖) phased
  9. * Preloading application
  10. * Listening on http://0.0.0.0:2300
  11. Use Ctrl-C to stop
  12. * Starting control server on http://127.0.0.1:9293
  13. * Starting control server on http://[::1]:9293
  14. - Worker 0 (PID: 69721) booted in 0.0s, phase: 0
  15. - Worker 1 (PID: 69722) booted in 0.0s, phase: 0

Visit your application in the browser at http://localhost:2300

  1. open http://localhost:2300

You should see “Hello from Hanami”.

Hello from Hanami

Adding our first functionality

Let’s take a look at Hanami by creating the beginnings of a bookshelf application.

In the file spec/requests/root_spec.rb, Hanami provides a request spec for the “Hello from Hanami” message we’ve seen in the browser.

  1. # spec/requests/root_spec.rb
  2. RSpec.describe "Root", type: :request do
  3. it "is successful" do
  4. get "/"
  5. # Find me in `config/routes.rb`
  6. expect(last_response).to be_successful
  7. expect(last_response.body).to eq("Hello from Hanami")
  8. end
  9. end

We can run that spec now to prove that it works:

  1. bundle exec rspec spec/requests/root_spec.rb

You should see:

  1. Root
  2. is successful
  3. Finished in 0.01986 seconds (files took 0.70103 seconds to load)
  4. 1 example, 0 failures

Let’s change the “Hello from Hanami” message to “Welcome to Bookshelf”. First, we’ll adjust our spec:

  1. # spec/requests/root_spec.rb
  2. RSpec.describe "Root", type: :request do
  3. it "is successful" do
  4. get "/"
  5. # Find me in `config/routes.rb`
  6. expect(last_response).to be_successful
  7. expect(last_response.body).to eq("Welcome to Bookshelf")
  8. end
  9. end

As we expect, when we run the spec again, it fails:

  1. Root
  2. is successful (FAILED - 1)
  3. Failures:
  4. 1) Root is successful
  5. Failure/Error: expect(last_response.body).to eq("Welcome to Bookshelf")
  6. expected: "Welcome to Bookshelf"
  7. got: "Hello from Hanami"
  8. (compared using ==)
  9. # ./spec/requests/root_spec.rb:9:in `block (2 levels) in <top (required)>'
  10. Finished in 0.04572 seconds (files took 0.72148 seconds to load)
  11. 1 example, 1 failure

To fix this, let’s open our application’s routes file at config/routes.rb:

  1. # config/routes.rb
  2. module Bookshelf
  3. class Routes < Hanami::Routes
  4. root { "Hello from Hanami" }
  5. end
  6. end

This Bookshelf::Routes class contains the configuration for our application’s router. Routes in Hanami are comprised of a HTTP method, a path, and an endpoint to be invoked, which is usually a Hanami action. (See the Routing guide for more information).

We’ll take a look at adding more routes in a moment, but for now let’s get our spec to pass. The above Bookshelf::Routes class contains only one route, the root route, which handles GET requests for "/".

Rather than invoking an action, this route is configured to invoke a block, which returns “Hello from Hanami”.

Blocks are convenient, but let’s adjust our route to invoke an action instead:

  1. # config/routes.rb
  2. module Bookshelf
  3. class Routes < Hanami::Routes
  4. root to: "home.show"
  5. end
  6. end

If we run our test again, we’ll see a Hanami::Routes::MissingActionError:

  1. Failures:
  2. 1) Root is successful
  3. Failure/Error: get "/"
  4. Hanami::Routes::MissingActionError:
  5. Could not find action with key "actions.home.show" in Bookshelf::App
  6. To fix this, define the action class Bookshelf::Actions::Home::Show in app/actions/home/show.rb
  7. # ./spec/requests/root_spec.rb:5:in `block (2 levels) in <top (required)>'
  8. Finished in 0.01871 seconds (files took 0.62516 seconds to load)
  9. 1 example, 1 failure

As this error suggests, we need to create the home show action the route is expecting to be able to call.

Hanami provides an action generator we can use to create this action. Running this command will create the home show action:

  1. bundle exec hanami generate action home.show

We can find this action in our app directory at app/actions/home/show.rb:

  1. # app/actions/home/show.rb
  2. module Bookshelf
  3. module Actions
  4. module Home
  5. class Show < Bookshelf::Action
  6. def handle(*, response)
  7. response.body = self.class.name
  8. end
  9. end
  10. end
  11. end
  12. end

In a Hanami application, every action is an individual class. Actions decide what HTTP response (body, headers and status code) to return for a given request.

Actions define a #handle method which accepts a request object, representing the incoming request, and a response object, representing the outgoing response.

  1. def handle(request, response)
  2. # ...
  3. end

In the automatically generated home show action above, * is used for the request argument because the action does not currently use the request.

For more details on actions, see the Actions guide.

For now, let’s adjust our home action to return our desired “Welcome to Bookshelf” message.

  1. # app/actions/home/show.rb
  2. module Bookshelf
  3. module Actions
  4. module Home
  5. class Show < Bookshelf::Action
  6. def handle(*, response)
  7. response.body = "Welcome to Bookshelf"
  8. end
  9. end
  10. end
  11. end
  12. end

With this change, our root spec will now pass:

  1. bundle exec rspec spec/requests/root_spec.rb
  2. Root
  3. is successful
  4. Finished in 0.03029 seconds (files took 0.72932 seconds to load)
  5. 1 example, 0 failures

Adding a new route and action

As the next step in our bookshelf project, let’s add the ability to display an index of all books in the system, delivered as a JSON API.

First we’ll create a request spec for listing books that expects a successful JSON formatted response, listing two books:

  1. # spec/requests/books/index_spec.rb
  2. RSpec.describe "GET /books", type: :request do
  3. it "returns a list of books" do
  4. get "/books"
  5. expect(last_response).to be_successful
  6. expect(last_response.content_type).to eq("application/json; charset=utf-8")
  7. response_body = JSON.parse(last_response.body)
  8. expect(response_body).to eq([
  9. { "title" => "Test Driven Development" },
  10. { "title" => "Practical Object-Oriented Design in Ruby" }
  11. ])
  12. end
  13. end

If you run this test, you’ll see that it fails because our application currently returns a 404 response for the /books route.

Let’s fix that by generating an action for a books index:

  1. bundle exec hanami generate action books.index

In addition to generating an action at app/actions/books/index.rb, the generator has also added a route in config/routes.rb:

  1. module Bookshelf
  2. class Routes < Hanami::Routes
  3. root to: "home.index"
  4. get "/books", to: "books.index"
  5. end
  6. end

If we run our spec again, our expectation for a successful response is now satisfied, but there’s a different failure:

  1. bundle exec rspec spec/requests/books/index_spec.rb
  2. GET /books
  3. returns a list of books (FAILED - 1)
  4. Failures:
  5. 1) GET /books returns a list of books
  6. Failure/Error: expect(last_response.content_type).to eq("application/json; charset=utf-8")
  7. expected: "application/json; charset=utf-8"
  8. got: "text/html; charset=utf-8"
  9. (compared using ==)
  10. # ./spec/requests/books/index_spec.rb:8:in `block (2 levels) in <top (required)>'

Our response doesn’t have the expected format. Let’s adjust our action to return a JSON formatted response using response.format = :json. We’ll also set the response body to what our test expects:

  1. # app/actions/books/index.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Index < Bookshelf::Action
  6. def handle(*, response)
  7. books = [
  8. {title: "Test Driven Development"},
  9. {title: "Practical Object-Oriented Design in Ruby"}
  10. ]
  11. response.format = :json
  12. response.body = books.to_json
  13. end
  14. end
  15. end
  16. end
  17. end

If we run our spec, it now passes!

  1. bundle exec rspec spec/requests/books/index_spec.rb
  2. GET /books
  3. returns a list of books
  4. Finished in 0.02378 seconds (files took 0.49411 seconds to load)
  5. 1 example, 0 failures

Persisting books

Of course, returning a static list of books is not particularly useful.

Let’s address this by retrieving books from a database.

Integrated support for peristence based on rom-rb is coming in Hanami’s 2.1 release. For now, we can bring our own simple rom-rb configuration to allow us to store books in a database.

Adding persistence using rom-rb

Let’s add just enough rom-rb to get persistence working using Postgres.

First, add these gems to the Gemfile and run bundle install:

  1. # Gemfile
  2. gem "rom", "~> 5.3"
  3. gem "rom-sql", "~> 3.6"
  4. gem "pg"
  5. group :test do
  6. gem "database_cleaner-sequel"
  7. end

If you do not have Postgres installed, you can install it using Homebrew, asdf or by following the installation instruction on the PostgreSQL website.

With Postgres running, create databases for development and test using PostgreSQL’s createdb command:

  1. createdb bookshelf_development
  2. createdb bookshelf_test

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:

  1. Hanami.app.register_provider :persistence, namespace: true do
  2. prepare do
  3. require "rom"
  4. config = ROM::Configuration.new(:sql, target["settings"].database_url)
  5. register "config", config
  6. register "db", config.gateways[:default].connection
  7. end
  8. start do
  9. config = target["persistence.config"]
  10. config.auto_registration(
  11. target.root.join("lib/bookshelf/persistence"),
  12. namespace: "Bookshelf::Persistence"
  13. )
  14. register "rom", ROM.container(config)
  15. end
  16. 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:

  1. # config/settings.rb
  2. module Bookshelf
  3. class Settings < Hanami::Settings
  4. # Define your app settings here, for example:
  5. #
  6. # setting :my_flag, default: false, constructor: Types::Params::Bool
  7. end
  8. 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.

Each setting is sourced from an environment variable matching its name. For example my_flag will be source from ENV["MY_FLAG"].

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:

  1. # config/settings.rb
  2. module Bookshelf
  3. class Settings < Hanami::Settings
  4. # Define your app settings here, for example:
  5. #
  6. # setting :my_flag, default: false, constructor: Types::Params::Bool
  7. setting :database_url, constructor: Types::String
  8. end
  9. end

Our bookshelf application will now raise an invalid settings error when it boots, unless a DATABASE_URL environment variable is present.

In development and test environments, Hanami uses the dotenv gem to load environment variables from .env files.

We can now create .env and .env.test files in order to set database_url appropriately in development and test environments:

  1. # .env
  2. DATABASE_URL=postgres://postgres:postgres@localhost:5432/bookshelf_development
  1. # .env.test
  2. DATABASE_URL=postgres://postgres:postgres@localhost:5432/bookshelf_test

You might need to adjust these connection strings based on your local postgres configuration.

To confirm that the database_url setting is working as expected, you can run bundle exec hanami console to start a console, then call the database_url method on your application’s settings object.

  1. bundle exec hanami console
  1. bookshelf[development]> Hanami.app["settings"].database_url
  2. => "postgres://postgres:postgres@localhost:5432/bookshelf_development"

And in test:

  1. HANAMI_ENV=test bundle exec hanami console
  1. bookshelf[test]> Hanami.app["settings"].database_url
  2. => "postgres://postgres:postgres@localhost:5432/bookshelf_test"

To ensure the database is cleaned between tests, add the following to a spec/support/database_cleaner.rb file:

  1. # spec/support/database_cleaner.rb
  2. require "database_cleaner-sequel"
  3. Hanami.app.prepare(:persistence)
  4. DatabaseCleaner[:sequel, db: Hanami.app["persistence.db"]]
  5. RSpec.configure do |config|
  6. config.before(:suite) do
  7. DatabaseCleaner.strategy = :transaction
  8. DatabaseCleaner.clean_with(:truncation)
  9. end
  10. config.around(:each, type: :database) do |example|
  11. DatabaseCleaner.cleaning do
  12. example.run
  13. end
  14. end
  15. end

And then append the following line to spec/spec_helper.rb:

  1. require_relative "support/database_cleaner"

Finally, enable rom-rb’s rake tasks for database migrations by appending the following to the Rakefile:

  1. # Rakefile
  2. require "rom/sql/rake_task"
  3. task :environment do
  4. require_relative "config/app"
  5. require "hanami/prepare"
  6. end
  7. namespace :db do
  8. task setup: :environment do
  9. Hanami.app.prepare(:persistence)
  10. ROM::SQL::RakeSupport.env = Hanami.app["persistence.config"]
  11. end
  12. end

Hanami’s 2.1 release, slated for early 2023, will bring persistence as a first class feature, after which none of the above set up will be required.

Creating a books table

With persistence ready, we can now create a books table.

To create a migration run:

  1. bundle exec rake db:create_migration[create_books]

Edit the migration file in order to create a books table with title and author columns and a primary key:

  1. # db/migrate/20221113050928_create_books.rb
  2. ROM::SQL.migration do
  3. change do
  4. create_table :books do
  5. primary_key :id
  6. column :title, :text, null: false
  7. column :author, :text, null: false
  8. end
  9. end
  10. end

Migrate both the development and test databases:

  1. bundle exec rake db:migrate
  2. HANAMI_ENV=test bundle exec rake db:migrate

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

  1. # lib/bookshelf/persistence/relations/books.rb
  2. module Bookshelf
  3. module Persistence
  4. module Relations
  5. class Books < ROM::Relation[:sql]
  6. schema(:books, infer: true)
  7. end
  8. end
  9. end
  10. end

Listing books

With our books table ready to go, let’s adapt our books index spec to expect an index of persisted books:

  1. RSpec.describe "GET /books", type: [:request, :database] do
  2. let(:books) { app["persistence.rom"].relations[:books] }
  3. before do
  4. books.insert(title: "Practical Object-Oriented Design in Ruby", author: "Sandi Metz")
  5. books.insert(title: "Test Driven Development", author: "Kent Beck")
  6. end
  7. it "returns a list of books" do
  8. get "/books"
  9. expect(last_response).to be_successful
  10. expect(last_response.content_type).to eq("application/json; charset=utf-8")
  11. response_body = JSON.parse(last_response.body)
  12. expect(response_body).to eq([
  13. { "title" => "Practical Object-Oriented Design in Ruby", "author" => "Sandi Metz" },
  14. { "title" => "Test Driven Development", "author" => "Kent Beck" }
  15. ])
  16. end
  17. end

To get this spec to pass, we’ll need to update our books index action to return books from the books relation.

To access the books relation within the action, we can use Hanami’s “Deps mixin”. Covered in detail in the container and components section of the Architecture guide, the Deps mixin gives each of your application’s components easy access to the other components it depends on to achieve its work. We’ll see this in more detail as these guides progress.

For now however, it’s enough to know that we can use include Deps["persistence.rom"] to make rom-rb available via a rom method within our action. The books relation is then available via rom.relations[:books].

To satisfy our spec, we need to meet a few requirements. Firstly, we want to render each book’s title and author, but not its id. Secondly we want to return books alphabetically by title. We can achieve these requirements using the select and order methods offered by the books relation:

  1. module Bookshelf
  2. module Actions
  3. module Books
  4. class Index < Bookshelf::Action
  5. include Deps["persistence.rom"]
  6. def handle(*, response)
  7. books = rom.relations[:books]
  8. .select(:title, :author)
  9. .order(:title)
  10. .to_a
  11. response.format = :json
  12. response.body = books.to_json
  13. end
  14. end
  15. end
  16. end
  17. end

Accessing relations directly from actions is not a commonly recommended pattern. Instead, a rom repository should be used. Here, however, the repository is ommitted for brevity. Hanami’s 2.1 release will offer repositories out of the box.

With this action in place, the spec passes once more:

  1. bundle exec rspec spec/requests/books/index_spec.rb
  2. GET /books
  3. returns a list of books
  4. Finished in 0.05765 seconds (files took 1.36 seconds to load)
  5. 1 example, 0 failures

Parameter validation

Of course, returning every book in the database when a visitor makes a request to /books is not going to be a good strategy for very long. Luckily rom-rb relations offer pagination support. Let’s add pagination with a default page size of 5:

  1. # lib/bookshelf/persistence/relations/books.rb
  2. module Bookshelf
  3. module Persistence
  4. module Relations
  5. class Books < ROM::Relation[:sql]
  6. schema(:books, infer: true)
  7. use :pagination
  8. per_page 5
  9. end
  10. end
  11. end
  12. end

This will enable our books index to accept page and per_page params.

Let’s add a request spec verifying pagination:

  1. # spec/requests/books/index/pagination_spec.rb
  2. RSpec.describe "GET /books pagination", type: [:request, :database] do
  3. let(:books) { app["persistence.rom"].relations[:books] }
  4. before do
  5. 10.times do |n|
  6. books.insert(title: "Book #{n}", author: "Author #{n}")
  7. end
  8. end
  9. context "given valid page and per_page params" do
  10. it "returns the correct page of books" do
  11. get "/books?page=1&per_page=3"
  12. expect(last_response).to be_successful
  13. response_body = JSON.parse(last_response.body)
  14. expect(response_body).to eq([
  15. { "title" => "Book 0", "author" => "Author 0" },
  16. { "title" => "Book 1", "author" => "Author 1" },
  17. { "title" => "Book 2", "author" => "Author 2" }
  18. ])
  19. end
  20. end
  21. end

In our action class, we can use the request object to extract the relevant params from the incoming request, which allows our spec to pass:

  1. # app/actions/books/index.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Index < Bookshelf::Action
  6. include Deps["persistence.rom"]
  7. def handle(request, response)
  8. books = rom.relations[:books]
  9. .select(:title, :author)
  10. .order(:title)
  11. .page(request.params[:page] || 1)
  12. .per_page(request.params[:per_page] || 5)
  13. .to_a
  14. response.format = :json
  15. response.body = books.to_json
  16. end
  17. end
  18. end
  19. end
  20. end

Accepting parameters from the internet without validation is never a good idea however. Hanami actions offer built-in parameter validation, which we can use here to ensure that both page and per_page are positive integers, and that per_page is at most 100:

  1. # app/actions/books/index.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Index < Bookshelf::Action
  6. include Deps["persistence.rom"]
  7. params do
  8. optional(:page).value(:integer, gt?: 0)
  9. optional(:per_page).value(:integer, gt?: 0, lteq?: 100)
  10. end
  11. def handle(request, response)
  12. halt 422 unless request.params.valid?
  13. books = rom.relations[:books]
  14. .select(:title, :author)
  15. .order(:title)
  16. .page(request.params[:page] || 1)
  17. .per_page(request.params[:per_page] || 5)
  18. .to_a
  19. response.format = :json
  20. response.body = books.to_json
  21. end
  22. end
  23. end
  24. end
  25. end

In this instance, the params block specifies the following:

  • page and per_page are optional parameters
  • if page is present, it must be an integer greater than 0
  • if per_page is present, it must be an integer greater than 0 and less than or equal to 100

At the start of the handle method, the line halt 422 unless request.params.valid? ensures that the action halts and returns 422 Unprocessable if an invalid parameter was given.

A helpful response revealing why parameter validation failed can also be rendered by passing a body when calling halt:

  1. halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
  1. # spec/requests/books/index/pagination_spec.rb
  2. context "given invalid page and per_page params" do
  3. it "returns a 422 unprocessable response" do
  4. get "/books?page=-1&per_page=3000"
  5. expect(last_response).to be_unprocessable
  6. response_body = JSON.parse(last_response.body)
  7. expect(response_body).to eq(
  8. "errors" => {
  9. "page" => ["must be greater than 0"],
  10. "per_page" => ["must be less than or equal to 100"]
  11. }
  12. )
  13. end
  14. end

Validating parameters in actions is useful for performing parameter coercion and type validation. More complex domain-specific validations, or validations concerned with things such as uniqueness, however, are usually better performed at layers deeper than your HTTP actions.

You can find more details on actions and parameter validation in the Actions guide.

Showing a book

In addition to our books index, we also want to provide an endpoint for viewing the details of a particular book.

Let’s specify a /books/:id request that renders a book for a given id, or returns 404 if there’s no book with the provided id.

  1. # spec/requests/books/show_spec.rb
  2. RSpec.describe "GET /books/:id", type: [:request, :database] do
  3. let(:books) { app["persistence.rom"].relations[:books] }
  4. context "when a book matches the given id" do
  5. let!(:book_id) do
  6. books.insert(title: "Test Driven Development", author: "Kent Beck")
  7. end
  8. it "renders the book" do
  9. get "/books/#{book_id}"
  10. expect(last_response).to be_successful
  11. expect(last_response.content_type).to eq("application/json; charset=utf-8")
  12. response_body = JSON.parse(last_response.body)
  13. expect(response_body).to eq(
  14. "id" => book_id, "title" => "Test Driven Development", "author" => "Kent Beck"
  15. )
  16. end
  17. end
  18. context "when no book matches the given id" do
  19. it "returns not found" do
  20. get "/books/#{books.max(:id).to_i + 1}"
  21. expect(last_response).to be_not_found
  22. expect(last_response.content_type).to eq("application/json; charset=utf-8")
  23. response_body = JSON.parse(last_response.body)
  24. expect(response_body).to eq(
  25. "error" => "not_found"
  26. )
  27. end
  28. end
  29. end

Because there’s no matching route yet, this spec immediately fails:

  1. GET /books/:id
  2. when a book matches the given id
  3. renders the book (FAILED - 1)
  4. when no book matches the given id
  5. returns not found (FAILED - 2)
  6. Failures:
  7. 1) GET /books/:id when a book matches the given id renders the book
  8. Failure/Error: expect(last_response).to be_successful
  9. expected `#<Rack::MockResponse:0x000000010c9f5788 @original_headers={"Content-Length"=>"9"}, @errors="", @cooki...ms/rack-2.2.4/lib/rack/response.rb:287>, @block=nil, @body=["Not Found"], @buffered=true, @length=9>.successful?` to be truthy, got false
  10. # ./spec/requests/books/show_spec.rb:14:in `block (3 levels) in <top (required)>'
  11. # ./spec/support/database_cleaner.rb:15:in `block (3 levels) in <top (required)>'
  12. # ./spec/support/database_cleaner.rb:14:in `block (2 levels) in <top (required)>'
  13. 2) GET /books/:id when no book matches the given id returns not found
  14. Failure/Error: expect(last_response.content_type).to eq("application/json; charset=utf-8")
  15. expected: "application/json; charset=utf-8"
  16. got: nil
  17. (compared using ==)
  18. # ./spec/requests/books/show_spec.rb:30:in `block (3 levels) in <top (required)>'
  19. # ./spec/support/database_cleaner.rb:15:in `block (3 levels) in <top (required)>'
  20. # ./spec/support/database_cleaner.rb:14:in `block (2 levels) in <top (required)>'
  21. Finished in 0.05427 seconds (files took 0.88631 seconds to load)
  22. 2 examples, 2 failures

We can use Hanami’s action generator to create both a route and an action. Run:

  1. bundle exec hanami generate action books.show

If you inspect config/routes.rb you will see the generator has automatically added a new get "/books/:id", to: "books.show" route:

  1. # config/routes.rb
  2. module Bookshelf
  3. class Routes < Hanami::Routes
  4. root to: "home.index"
  5. get "/books", to: "books.index"
  6. get "/books/:id", to: "books.show"
  7. end
  8. end

We can now edit the new action at app/actions/books/show.rb to add the required behaviour. Here, we use param validation to coerce params[:id] to an integer, render a book if there’s one with a matching primary key, or return a 404 response. With this, our test passes.

  1. # app/actions/books/show.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Show < Bookshelf::Action
  6. include Deps["persistence.rom"]
  7. params do
  8. required(:id).value(:integer)
  9. end
  10. def handle(request, response)
  11. book = rom.relations[:books].by_pk(
  12. request.params[:id]
  13. ).one
  14. response.format = :json
  15. if book
  16. response.body = book.to_json
  17. else
  18. response.status = 404
  19. response.body = {error: "not_found"}.to_json
  20. end
  21. end
  22. end
  23. end
  24. end
  25. end

In addition to the #one method, which will return nil if there’s no book with the requisite id, rom-rb relations also provide a #one! method, which instead raises a ROM::TupleCountMismatchError exception when no record is found.

We can use this to handle 404s via Hanami’s action exception handling: config.handle_exception. This action configuration takes the name of a method to invoke when a particular exception occurs.

Taking this approach allows our handle method to concern itself only with the happy path:

  1. # app/actions/books/show.rb
  2. require "rom"
  3. module Bookshelf
  4. module Actions
  5. module Books
  6. class Show < Bookshelf::Action
  7. include Deps["persistence.rom"]
  8. config.handle_exception ROM::TupleCountMismatchError => :handle_not_found
  9. params do
  10. required(:id).value(:integer)
  11. end
  12. def handle(request, response)
  13. book = rom.relations[:books].by_pk(
  14. request.params[:id]
  15. ).one!
  16. response.format = :json
  17. response.body = book.to_json
  18. end
  19. private
  20. def handle_not_found(_request, response, _exception)
  21. response.status = 404
  22. response.format = :json
  23. response.body = {error: "not_found"}.to_json
  24. end
  25. end
  26. end
  27. end
  28. end

This exception handling behaviour can also be moved into the base Bookshelf::Action class at app/action.rb, meaning that any action inheriting from Bookshelf::Action will handle ROM::TupleCountMismatchError in the same way.

  1. # app/action.rb
  2. # auto_register: false
  3. require "hanami/action"
  4. module Bookshelf
  5. class Action < Hanami::Action
  6. config.handle_exception ROM::TupleCountMismatchError => :handle_not_found
  7. private
  8. def handle_not_found(_request, response, _exception)
  9. response.status = 404
  10. response.format = :json
  11. response.body = {error: "not_found"}.to_json
  12. end
  13. end
  14. end

With its base action configured to handle ROM::TupleCountMismatchError exceptions, the Books::Show action can now be as follows and our spec continues to pass:

  1. # app/actions/books/show.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Show < Bookshelf::Action
  6. include Deps["persistence.rom"]
  7. params do
  8. required(:id).value(:integer)
  9. end
  10. def handle(request, response)
  11. book = rom.relations[:books].by_pk(
  12. request.params[:id]
  13. ).one!
  14. response.format = :json
  15. response.body = book.to_json
  16. end
  17. end
  18. end
  19. end
  20. end
  1. bundle exec rspec spec/requests/books/show_spec.rb
  2. GET /books/:id
  3. when a book matches the given id
  4. renders the book
  5. when no book matches the given id
  6. returns not found
  7. Finished in 0.07726 seconds (files took 1.29 seconds to load)
  8. 2 examples, 0 failures

Creating a book

Now that our visitors can list and view books, let’s allow them to create books too.

Here’s a spec for POST requests to the /books path, where it’s expected that only valid requests result in a book being created:

  1. # spec/requests/books/create_spec.rb
  2. RSpec.describe "POST /books", type: [:request, :database] do
  3. let(:request_headers) do
  4. {"HTTP_ACCEPT" => "application/json", "CONTENT_TYPE" => "application/json"}
  5. end
  6. context "given valid params" do
  7. let(:params) do
  8. {book: {title: "Practical Object-Oriented Design in Ruby", author: "Sandi Metz"}}
  9. end
  10. it "creates a book" do
  11. post "/books", params.to_json, request_headers
  12. expect(last_response).to be_created
  13. end
  14. end
  15. context "given invalid params" do
  16. let(:params) do
  17. {book: {title: nil}}
  18. end
  19. it "returns 422 unprocessable" do
  20. post "/books", params.to_json, request_headers
  21. expect(last_response).to be_unprocessable
  22. end
  23. end
  24. end

Executing this spec, we get the message Method Not Allowed, because there’s no route or action for handling this request.

Hanami’s action generator can add these for us:

  1. bundle exec hanami generate action books.create

The application’s routes now include the expected route - invoking the books.create action for POST requests to /books:

  1. module Bookshelf
  2. class Routes < Hanami::Routes
  3. root to: "home.index"
  4. get "/books", to: "books.index"
  5. get "/books/:id", to: "books.show"
  6. post "/books", to: "books.create"
  7. end
  8. end

And Hanami has generated an action at app/actions/books/create.rb:

  1. # app/actions/books/create.rb
  2. module Bookshelf
  3. module Actions
  4. module Books
  5. class Create < Bookshelf::Action
  6. def handle(*, response)
  7. response.body = self.class.name
  8. end
  9. end
  10. end
  11. end
  12. end

To enable convenient parsing of params from JSON request bodies, Hanami includes a body parser middleware that can be enabled through a config option on the app class. Enable it by adding the following to the Bookshelf::App class in config/app.rb:

  1. # config/app.rb
  2. require "hanami"
  3. module Bookshelf
  4. class App < Hanami::App
  5. config.middleware.use :body_parser, :json
  6. end
  7. end

With this parser in place, the book key from the JSON body will be available in the action via request.params[:book].

We can now complete our create action by inserting a record into the books relation if the posted params are valid:

  1. module Bookshelf
  2. module Actions
  3. module Books
  4. class Create < Bookshelf::Action
  5. include Deps["persistence.rom"]
  6. params do
  7. required(:book).hash do
  8. required(:title).filled(:string)
  9. required(:author).filled(:string)
  10. end
  11. end
  12. def handle(request, response)
  13. if request.params.valid?
  14. book = rom.relations[:books].changeset(:create, request.params[:book]).commit
  15. response.status = 201
  16. response.body = book.to_json
  17. else
  18. response.status = 422
  19. response.format = :json
  20. response.body = request.params.errors.to_json
  21. end
  22. end
  23. end
  24. end
  25. end
  26. end

Our request spec now passes!

  1. bundle exec rspec spec/requests/books/create_spec.rb
  2. POST /books
  3. given valid params
  4. creates a book
  5. given invalid params
  6. returns 422 unprocessable
  7. Finished in 0.07143 seconds (files took 1.32 seconds to load)
  8. 2 examples, 0 failures

In addition to validating title and author are present, the params block in the action also serves to prevent mass assignment - params not included in the schema (for example an attempt to inject a price of 0) will be discarded.

What’s next

So far we’ve seen how to create a new Hanami application, explored some of the basics of how an application is structured, and seen how we can list, display and create a simple book entity while validating user input.

Still, we’ve barely touched the surface of what Hanami offers.

From here you might want to look in more detail at routing and actions, or explore Hanami’s application architecture, starting with its component management and dependency injection systems.