Now that we've created our app, let's turn it into an API.
Adding our first functionality
Let's take a look at Hanami by creating the beginnings of a bookshelf app.
We'll start by creating a home endpoint that returns "Welcome to Bookshelf".
First, let's look at our app's routes file at config/routes.rb:
# config/routes.rb
# Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details.
end
end
This Bookshelf::Routes class contains the configuration for our app'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).
Let's add a route for our home endpoint that invokes a new action.
# config/routes.rb
root to: "home.index"
end
end
We can use Hanami's action generator to create this action:
$ bundle exec hanami generate action home.index --skip-view --skip-route --skip-tests
We can find this action in our app directory at app/actions/home/index.rb:
# app/actions/home/index.rb
end
end
end
end
end
In a Hanami app, 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.
# ...
end
For more details on actions, see the Actions guide.
Let's adjust our home action to return our "Welcome to Bookshelf" message.
# app/actions/home/show.rb
response.body = "Welcome to Bookshelf"
end
end
end
end
end
Testing your API
Now that we've created our first endpoint, let's start the development server and verify it works.
Run the following command to start the server:
$ bin/hanami dev
This starts Hanami's development server, which watches for file changes and automatically reloads your app as you work.
Once the server is running, open a new terminal and use curl to test your endpoint:
$ curl http://localhost:2300
Welcome to Bookshelf
Keep the dev server running as you continue through this guide - you'll be able to make requests to test your changes as you make them.
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, let's set up a RESTful route for listing books by using the resources helper in config/routes.rb:
root to: "home.index"
resources :books, only: [:index]
end
end
The resources helper can create standard RESTful routes for a resource:
GET /books→"books.index"(list all books)POST /books→"books.create"(create a book)GET /books/:id→"books.show"(show a specific book)
In this guide, we'll implement the index, show, and create actions. (The new and edit actions are typically used for HTML forms, which we don't need in a JSON API.) We use the only: option to specify which routes to create, adding each action as we implement it.
Now let's generate an action for the books index:
$ bundle exec hanami generate action books.index --skip-view --skip-route --skip-tests
Since we've already defined our routes using resources, we use the --skip-route flag to prevent the generator from adding a duplicate route.
Now let's adjust our action to return a JSON formatted response using response.format = :json. We'll also set the response body to a list of books:
# app/actions/books/index.rb
books = [
{title: "Test Driven Development"},
{title: "Practical Object-Oriented Design in Ruby"}
]
response.format = :json
response.body = books.to_json
end
end
end
end
end
Test your books endpoint with curl:
$ curl http://localhost:2300/books
[{"title":"Test Driven Development"},{"title":"Practical Object-Oriented Design in Ruby"}]
You should see the two hardcoded books returned as JSON.
Listing books from a database
Of course, returning a static list of books is not particularly useful. Let's address this by retrieving books from a database.
Preparing a books table
To create a books table, we need to generate a migration:
$ hanami generate migration create_books
Edit the migration file to create a books table with title and author columns and a primary key:
# config/db/migrate/20251112215119_create_books.rb
ROM::SQL.migration do
change do
create_table :books do
primary_key :id
column :title, :text, null: false
column :author, :text, null: false
end
end
end
Migrate the development and test databases:
$ bundle exec hanami db migrate
Next, let's generate a relation to allow our app to interact with our books table. To generate a relation:
$ bundle exec hanami generate relation books
This creates the following file at app/relations/books.rb:
# app/relations/books.rb
schema :books, infer: true
end
end
end
Fetching books from the database
Now we need to update our books index action to retrieve books from our database along with their authors.
For this, we can generate a book repo:
$ bundle exec hanami generate repo book
Repos serve as the interface to our persisted data from our domain layer. Let's edit the repo to add a method that returns all books ordered by title:
# app/repos/book_repo.rb
books
.select(:title, :author)
.order(books[:title].asc)
.to_a
end
end
end
end
To access this book repo from 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 app'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["repos.book_repo"] to make the repo available via a book_repo method within our action.
We can now call this repo to prepare the action's response:
include Deps["repos.book_repo"]
books = book_repo.all_by_title
response.format = :json
response.body = books.map(&:to_h).to_json
end
end
end
end
end
Verifying the database integration
With our books table created and our app configured to read from it, let's add some books to the database and verify everything is working.
Start Hanami's interactive console:
$ bundle exec hanami console
Then create a few books:
bookshelf[development]> books_relation = app["relations.books"]
bookshelf[development]> books_relation.insert(title: "Test Driven Development", author: "Kent Beck")
bookshelf[development]> books_relation.insert(title: "Practical Object-Oriented Design in Ruby", author: "Sandi Metz")
bookshelf[development]> books_relation.insert(title: "The Pragmatic Programmer", author: "Dave Thomas and Andy Hunt")
Now test your endpoint with curl:
$ curl http://localhost:2300/books
[{"title":"Practical Object-Oriented Design in Ruby","author":"Sandi Metz"},{"title":"Test Driven Development","author":"Kent Beck"},{"title":"The Pragmatic Programmer","author":"Dave Thomas and Andy Hunt"}]
You should see your books returned as JSON, ordered alphabetically by title.
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 relations offer pagination support. Let's add pagination with a default page size of 5:
# app/relations/books.rb
schema :books, infer: true
use :pagination
per_page 5
end
end
end
This will enable our books index to accept page and per_page params.
Now we can use the request object in our action to extract the relevant params from the incoming request, and then pass them to our repo method:
# app/actions/books/index.rb
include Deps["repos.book_repo"]
books = book_repo.all_by_title(
page: request.params[:page] || 1,
per_page: request.params[:per_page] || 5
)
response.format = :json
response.body = books.map(&:to_h).to_json
end
end
end
end
end
And in the repo, we can use these to control the pagination:
# app/repos/book_repo.rb
books
.select(:title, :author)
.order(books[:title].asc)
.page(page)
.per_page(per_page)
.to_a
end
end
end
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:
# app/actions/books/index.rb
include Deps["repos.book_repo"]
params do
optional(:page).value(:integer, gt?: 0)
optional(:per_page).value(:integer, gt?: 0, lteq?: 100)
end
halt 422 unless request.params.valid?
books = book_repo.all_by_title(
page: request.params[:page] || 1,
per_page: request.params[:per_page] || 5
)
response.format = :json
response.body = books.map(&:to_h).to_json
end
end
end
end
end
In this instance, the params block specifies the following:
pageandper_pageare optional parameters- if
pageis present, it must be an integer greater than 0 - if
per_pageis 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:
halt 422, {errors: request.params.errors}.to_json unless request.params.valid?
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.
Test pagination with curl:
$ curl http://localhost:2300/books?page=2
$ curl http://localhost:2300/books?per_page=2
$ curl http://localhost:2300/books?page=invalid
{"errors":{"page":[{"text":"must be an integer","code":"int?","path":["page"],"input":"invalid"}]}}
The first request shows the second page of books, the second shows just 2 books per page, and the third demonstrates the validation error for an invalid parameter.
Showing a book
In addition to our books index, we also want to provide an endpoint for viewing the details of a particular book.
First, let's update our routes to add the :show action:
# config/routes.rb
resources :books, only: [:index, :show]
This adds a route for showing individual books at GET /books/:id, which will invoke the "books.show" action.
Now let's generate that action:
$ bundle exec hanami generate action books.show --skip-view --skip-route --skip-tests
To fetch a single book from our database, we can add a new method to our book repo:
# app/repos/book_repo.rb
books.by_pk(id).one
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 via the repo if there's one with a matching primary key, or return a 404 response.
# app/actions/books/show.rb
include Deps["repos.book_repo"]
params do
required(:id).value(:integer)
end
book = book_repo.get(request.params[:id])
response.format = :json
if book
response.body = book.to_h.to_json
else
response.status = 404
response.body = {error: "not_found"}.to_json
end
end
end
end
end
end
Handling missing books
What happens if someone requests a book that doesn't exist? Currently our repo's get method uses #one, which returns nil when no record is found. Relations also provide a #one! method, which instead raises a ROM::TupleCountMismatchError exception when no record is found.
Let's use #one! in our repo:
# app/repos/book_repo.rb
books.by_pk(id).one!
end
We can handle this exception 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.
Let's add this to the base Bookshelf::Action class at app/action.rb, so that any action inheriting from Bookshelf::Action will handle ROM::TupleCountMismatchError by returning a 404 response:
# app/action.rb
# auto_register: false
# Provide `Success` and `Failure` for pattern matching on operation results
include Dry::Monads[:result]
config.handle_exception ROM::TupleCountMismatchError => :handle_not_found
private
response.status = 404
response.format = :json
response.body = {error: "not_found"}.to_json
end
end
end
With this in place, our Books::Show action can remain focused on the happy path, and will automatically return a 404 response when a book isn't found.
Test your show endpoint with curl:
$ curl http://localhost:2300/books/1
{"id":1,"title":"Test Driven Development","author":"Kent Beck"}
$ curl http://localhost:2300/books/999
{"error":"not_found"}
The first request returns the book with ID 1, while the second returns a 404 error for a non-existent book.
Creating a book
Now that our visitors can list and view books, let's allow them to create books too.
First, let's update our routes to add the :create action:
# config/routes.rb
resources :books, only: [:index, :show, :create]
This adds a route for creating books at POST /books, which will invoke the "books.create" action.
Now let's generate that action:
$ bundle exec hanami generate action books.create --skip-view --skip-route --skip-tests
This generates an action at app/actions/books/create.rb:
# app/actions/books/create.rb
end
end
end
end
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:
# config/app.rb
config.middleware.use :body_parser, :json
end
end
With this parser in place, the book key from the JSON body will be available in the action via request.params[:book].
First, let's add a method to our book repo to create new books:
# app/repos/book_repo.rb
books.changeset(:create, attributes).commit
end
We can now complete our create action by creating a book via the repo if the posted params are valid:
include Deps["repos.book_repo"]
params do
required(:book).hash do
required(:title).filled(:string)
required(:author).filled(:string)
end
end
if request.params.valid?
book = book_repo.create(request.params[:book])
response.status = 201
response.body = book.to_json
else
response.status = 422
response.format = :json
response.body = request.params.errors.to_json
end
end
end
end
end
end
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.
Test your create endpoint with curl:
$ curl -X POST http://localhost:2300/books \
-H "Content-Type: application/json" \
-d '{"book":{"title":"Refactoring","author":"Martin Fowler"}}'
{"id":4,"title":"Refactoring","author":"Martin Fowler"}
Try creating a book with missing data to see the validation errors:
$ curl -X POST http://localhost:2300/books \
-H "Content-Type: application/json" \
-d '{"book":{"title":""}}'
{"title":[{"text":"must be filled","code":"filled?","path":["book","title"],"input":""}],"author":[{"text":"is missing","code":"required","path":["book","author"]}]}
What's next
So far we've seen how to create a new Hanami app, explored some of the basics of how an app 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 app architecture, starting with its component management and dependency injection systems.