Hi, Habr! Some time ago, I became interested in the Hanami framework and, in order to better lay in my head, began to translate the official guide for beginners. This and want to share with the community. I tried to translate closer to the original, but I am more a reader than a writer, and if you have comments on the translation, do not hesitate to express them, I will correct.
In this guide, we will create our first project in Hanami, make a simple web application. We will touch all the main components of the framework and cover everything that is written with tests.
Hanami is a Ruby MVC framework consisting of multiple micro libraries.
It has a simple, stable API, a minimum DSL, and a preference for using simple objects for magic, overcomplicated classes with many responsibilities.
The price of using simple objects with pure duties is more than a template code. Hanami provides ways to reduce this extra work by serving low-level code implementations.
There are three reasons why you should choose Hanami:
The Hanami code is relatively short. He is responsible only for the things that are needed in order not to write the very thing that every web application needs. Hanami comes with several optional modules, and other libraries can also be easily connected. The meaning of Hanami in architecture.
Hanami bases the actions of the controllers on the classes, which simplifies their testing in isolation.
Hanami also encourages you to write business logic in interactors (usage scenarios).
Views are separated from patterns, so their logic can also be tested in isolation.
Using multithreading is a great way to improve application performance. It should not be difficult to write thread-safe code, and Hanami (the whole application or its parts) are thread-safe.
The manuals explain the high-level components and how to prepare them (set up, use and test) in a full-fledged application. The imaginary project in question is the "Bookshelf": an online community in which people read and buy books.
Hey. If you read this page, you probably want to know more about Hanami. This is great, congratulations! If you're looking for new ways to build supported, secure, fast, and testable applications, you're in good hands. Hanami is made for people like you.
It is necessary to warn you, regardless of whether you are completely new, or an experienced developer, the learning process will be difficult . After 10 years, during which you have formed a certain view on the development, it may be difficult to break some of their habits. Change is always a challenge to yourself.
Sometimes it will seem that some features do not look particularly sensible, but it is not always the case in your views. This can be a matter of habit, a mistake in design or even a bug. With the best of intentions, we try to improve Hanami every day.
In this guide, we will create our first project in Hanami, make a simple web application 'bookshelf'. We will touch all the main components of the framework and cover everything that is written with tests.
If you encounter any difficulties or just get confused, do not give up, go to our chat and ask questions. There will always be someone who will be happy to help.
Have fun
Luca guidi
Creator hanami
Before we begin, we will clarify some things. First, suppose you need some basic knowledge of web application development.
You should be familiar with some things like Bundler , Rake , be able to work in the terminal and build applications using the Model, View, Controller patterns.
Later in the tutorial, we will use the SQLite Postgresql database.
To go further, you need a working version of Ruby 2.3 or higher and SQLite 3+.
To create a project in Hanami, we first need to install gem Hanami through Rubygems.
Then we can use the console command hanami new
to generate a new project:
% gem install hanami % hanami new bookshelf
By default, the project will be configured to use SQLite. For real projects, you can specify another database engine, for example, Postgres:
% hanami new bookshelf --database=postgres
This will create a new bookshelf
folder in the one from which the command was run. See what it will contain:
% cd bookshelf % tree -L 1 . βββ Gemfile βββ Rakefile βββ apps βββ config βββ config.ru βββ db βββ lib βββ public βββ spec 6 directories, 3 files
Here, at least, is the cost to know about it:
Gemfile
defines our Rubygems dependencies (using Bundler).Rakefile
describes our Rake tasks.apps
contains one or more Rack compatible applications.Web
.config
contains (all of a sudden!) configuration files.config.ru
(rack up) for Rack servers.db
contains our database and migration schema.lib
contains our business logic and domain model, including entities and repositories.public
will contain compiled assets and static files.spec
contains our tests.Move on and install the dependencies specified in the Gemfile using Bundler; then run the server in development mode:
% bundle install % bundle exec hanami server
And ... we meet your first Hanami project at http: // localhost: 2300 ! We should see in the browser something like this:
The Hanami architecture allows you to contain multiple Hanami (and Rack) applications in a single Ruby process .
These applications are in the apps/
directory. Each of them can be a component of your product, such as a user interface, control panel, dashboard or, for example, HTTP API ..
These are all parts of the business logic delivery mechanism living in the lib/
folder. This is the place where our domain models are described, their interaction with each other, constituting the functionality provided by our product.
Hanamiβs architecture was strongly influenced by Uncle Bobβs Pure Architecture ideas.
The start screen of the application, which we saw in the browser, is the page that is shown by default until we have defined a single route.
Hanami encourages Behavior Driven Development (BDD) as a way to develop web applications. Before creating our first page, we first write a high-level test describing its work:
# spec/web/features/visit_home_spec.rb require 'features_helper' describe 'Visit home' do it 'is successful' do visit '/' page.body.must_include('Bookshelf') end end
Please note that despite the fact that Hanami out of the box supports the Development Through Behavior (BDD), you are not forced to use some special framework for testing - there is also no need for any special integration or libraries.
We start with Minitest (which is the default), but we can also use RSpec if we create a project with the option --test=rspec.
Hanami will in this case generate helpers and file templates for it.
We already have a test and we can see how it falls:
% rake test Run options: --seed 44759 # Running: F Finished in 0.018611s, 53.7305 runs/s, 53.7305 assertions/s. 1) Failure: Homepage#test_0001_is successful [/Users/hanami/bookshelf/spec/web/features/visit_home_spec.rb:6]: Expected "<!DOCTYPE html>\n<html>\n <head>\n <title>Not Found</title>\n </head>\n <body>\n <h1>Not Found</h1>\n </body>\n</html>\n" to include "Bookshelf". 1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
It's time to make it go. We will write a few lines of code required to pass the test, step by step.
The first thing we need to add is the route:
# apps/web/config/routes.rb root to: 'home#index'
We redirect the root (root) URL of our application to the action index
home
controller (see the routing guide for a more detailed explanation). Now we can create an action index
.
# apps/web/controllers/home/index.rb module Web::Controllers::Home class Index include Web::Action def call(params) end end end
This is an empty action and does not contain any logic. Each action has a corresponding view that represents the Ruby object to be given in response to a request that arrives at the action.
# apps/web/views/home/index.rb module Web::Views::Home class Index include Web::View end end
... which, in turn, is also empty and does nothing except render the template. By default, this is an erb
file, but no one will forbid you to use slim
or haml
. We will have to fix it to pass the test. All we have to do is add the title Bookshelf.
# apps/web/templates/home/index.html.erb <h1>Bookshelf</h1>
Save the changes, run the tests again and this time have to pass. Sumptuously!
Run options: --seed 19286 # Running: . Finished in 0.011854s, 84.3600 runs/s, 168.7200 assertions/s. 1 runs, 2 assertions, 0 failures, 0 errors, 0 skips
Let's take advantage of learning about the main components of Hanami to add a new action. The project Bookshelf is designed to account for books.
We will store information about books in the database and give the user the ability to manage it. The first step is to display a list of all books stored in the system.
We describe the functionality to which we aspire, using a feature test:
# spec/web/features/list_books_spec.rb require 'features_helper' describe 'List books' do it 'displays each book on the page' do visit '/books' within '#books' do assert page.has_css?('.book', count: 2), 'Expected to find 2 books' end end end
The test is quite simple and falls because the address /books
is not yet recognized by the application. Create a controller action to fix this.
Hanami comes with some generators to write less template code framing the new functionality.
Type in the terminal:
% bundle exec hanami generate action web books#index
This command should generate a new action index in the books controller of the web application.
This will give us an empty action, a view and a template, and also add a route to apps/web/config/routes.rb
:
get '/books', to: 'books#index'
In ZSH, you can get the error zsh: no matches found: books#index
. In this case, try another syntax:
% hanami generate action web books/index
Now, in order to pass the test, we just need to make the template in the file apps/web/templates/books/index.html.erb
look like:
<h1>Bookshelf</h1> <h2>All books</h2> <div id="books"> <div class="book"> <h3>Patterns of Enterprise Application Architecture</h3> <p>by <strong>Martin Fowler</strong></p> </div> <div class="book"> <h3>Test Driven Development</h3> <p>by <strong>Kent Beck</strong></p> </div> </div>
Save the changes and see that the tests pass!
The terminology of controllers and actions can be confusing, so it is worth clarifying: actions constitute the basis of the Hanami application; controllers are simply modules that combine several actions.
In general, despite the conceptual presence of "controllers" in the application, in practice we will work only with actions.
We used a generator to create a new entry point into the application. But you should pay attention to the fact that our new template contains the same <h1>
as in home/index.html.erb
. Let's fix it.
To avoid repeating the same lines from the template to the template, we can use the layout. Open the apps/web/templates/application.html.erb
file and make it look like this:
<!DOCTYPE HTML> <html> <head> <title>Bookshelf</title> </head> <body> <h1>Bookshelf</h1> <%= yield %> </body> </html>
Now you can remove duplicate lines from other templates.
The layout is similar to any other pattern, but it is used to wrap standard patterns. The yield
keyword is replaced with the contents of the regular template. This is a great place for repeating items like caps, footers or menus.
Hard-wired in the pattern of the book is cheating, to be honest. It's time to add live data to the app.
First, we need a table in the database for storing data about books. We can use migration to create it. Let's use the generator to create an empty migration:
% bundle exec hanami generate migration create_books
This will give us a file with a name like db/migrations/20161115110038_create_books.rb
, edit it:
Hanami::Model.migration do change do create_table :books do primary_key :id column :title, String, null: false column :author, String, null: false column :created_at, DateTime, null: false column :updated_at, DateTime, null: false end end end
Hanami provides a special language to describe changes to the database schema. You can learn how to use it from the migration guide .
In this case, we define a new table with columns for each attribute of our entity.
Let's prepare the database:
% bundle exec hanami db prepare
We store books in our database and display them on the page. For this, we need a way to read and write in the database. Introducing entities and repositories:
Book
) that is uniquely identified by its identifier.Entities are completely database independent. This makes them light and easy to test .
For this reason, we need a repository for storing data on which Book
depends.
Learn more about entities and repositories in the model manual .
Hanami provides a generator for the models, so let's create the Book
entity and the corresponding repository:
% bundle exec hanami generate model book create lib/bookshelf/entities/book.rb create lib/bookshelf/repositories/book_repository.rb create spec/bookshelf/entities/book_spec.rb create spec/bookshelf/repositories/book_repository_spec.rb
The generator gives us the entity, the repository and the accompanying test files.
Entities are something inherently close to simple Ruby objects. We need to focus on the behavior that we want from them and only later on how to save them.
Right now we need a simple entity class:
# lib/bookshelf/entities/book.rb class Book < Hanami::Entity end
This class will generate getters and setters for each attribute passed as a parameter during initialization. We can verify this by writing a unit test:
# spec/bookshelf/entities/book_spec.rb require 'spec_helper' describe Book do it 'can be initialised with attributes' do book = Book.new(title: 'Refactoring') book.title.must_equal 'Refactoring' end end
Now we are ready to play with the repository. Using the Hanami console
command, we will launch IRb in the context of our application, which will allow us to use existing objects:
% bundle exec hanami console >> repository = BookRepository.new => => #<BookRepository:0x007f9ab61fbb40 ...> >> repository.all => [] >> book = repository.create(title: 'TDD', author: 'Kent Beck') => #<Book:0x007f9ab61c23b8 @attributes={:id=>1, :title=>"TDD", :author=>"Kent Beck", :created_at=>2016-11-15 11:11:38 UTC, :updated_at=>2016-11-15 11:11:38 UTC}> >> repository.find(book.id) => #<Book:0x007f9ab6181610 @attributes={:id=>1, :title=>"TDD", :author=>"Kent Beck", :created_at=>2016-11-15 11:11:38 UTC, :updated_at=>2016-11-15 11:11:38 UTC}>
Hanami repositories have methods for loading both one and several entities from the database; as well as to create and update existing ones. You can also define new methods in the repository for your own queries.
In summary, we have seen how Hanami uses entities and repositories to model our data. Entities reflect behavior, and repositories are used to link entities and a data warehouse. We can use migrations to apply changes to the database schema.
With our new data modeling experience, we can make a book listing page show changing data. Let's adapt the test written earlier for this:
# spec/web/features/list_books_spec.rb require 'features_helper' describe 'List books' do let(:repository) { BookRepository.new } before do repository.clear repository.create(title: 'PoEAA', author: 'Martin Fowler') repository.create(title: 'TDD', author: 'Kent Beck') end it 'displays each book on the page' do visit '/books' within '#books' do assert page.has_css?('.book', count: 2), 'Expected to find 2 books' end end end
We created the required entries in the test and then stated that the number of classes of books on the page corresponds to them. When we run the tests again, we will most likely see an error related to the database - remember that we have already migrated the development base, but have not yet migrated the test database.
% HANAMI_ENV=test bundle exec hanami db prepare
Now we can change the template and remove static HTML. Our views must go through all the available records and render them. We write a test that requires such a change for the form:
# spec/web/views/books/index_spec.rb require 'spec_helper' require_relative '../../../../apps/web/views/books/index' describe Web::Views::Books::Index do let(:exposures) { Hash[books: []] } let(:template) { Hanami::View::Template.new('apps/web/templates/books/index.html.erb') } let(:view) { Web::Views::Books::Index.new(template, exposures) } let(:rendered) { view.render } it 'exposes #books' do view.books.must_equal exposures.fetch(:books) end describe 'when there are no books' do it 'shows a placeholder message' do rendered.must_include('<p class="placeholder">There are no books yet.</p>') end end describe 'when there are books' do let(:book1) { Book.new(title: 'Refactoring', author: 'Martin Fowler') } let(:book2) { Book.new(title: 'Domain Driven Design', author: 'Eric Evans') } let(:exposures) { Hash[books: [book1, book2]] } it 'lists them all' do rendered.scan(/class="book"/).count.must_equal 2 rendered.must_include('Refactoring') rendered.must_include('Domain Driven Design') end it 'hides the placeholder message' do rendered.wont_include('<p class="placeholder">There are no books yet.</p>') end end end
We have indicated that the index page will show a message when there are no books and nothing to show; when there are books, it will show their list. Note that rendering a view with data is relatively straightforward. Hanami is designed from simple objects with minimal interfaces, which makes it easy to test them separately, and they work well together.
Let's rewrite our template to make it happen:
# apps/web/templates/books/index.html.erb <h2>All books</h2> <% if books.any? %> <div id="books"> <% books.each do |book| %> <div class="book"> <h2><%= book.title %></h2> <p><%= book.author %></p> </div> <% end %> </div> <% else %> <p class="placeholder">There are no books yet.</p> <% end %>
If we run our functional test again, it will drop, since our controller action
still not expose the books for our view. We can write a test for this case:
# spec/web/controllers/books/index_spec.rb require 'spec_helper' require_relative '../../../../apps/web/controllers/books/index' describe Web::Controllers::Books::Index do let(:action) { Web::Controllers::Books::Index.new } let(:params) { Hash[] } let(:repository) { BookRepository.new } before do repository.clear @book = repository.create(title: 'TDD', author: 'Kent Beck') end it 'is successful' do response = action.call(params) response[0].must_equal 200 end it 'exposes all books' do action.call(params) action.exposures[:books].must_equal [@book] end end
Writing tests for action games usually has two sides: you make a statement about the response object, which is a Rack-compatible array of status, headers, and content; or about the fact that the data is visible from the action after we called it. Now we have indicated that the action shows the variable variable :books
, which we will do:
# apps/web/controllers/books/index.rb module Web::Controllers::Books class Index include Web::Action expose :books def call(params) @books = BookRepository.new.all end end end
Using the expose
action class method, we can expose the contents of the @books
instance @books
, which makes it available on the view. This is enough to pass the tests again!
% bundle exec rake Run options: --seed 59133 # Running: ......... Finished in 0.042065s, 213.9543 runs/s, 380.3633 assertions/s. 6 runs, 7 assertions, 0 failures, 0 errors, 0 skips
It remains only to create the ability to add new books to the system. The plan is simple: we will make a page with a form where you can enter details.
, , . :
# spec/web/features/add_book_spec.rb require 'features_helper' describe 'Add a book' do after do BookRepository.new.clear end it 'can create a new book' do visit '/books/new' within 'form#book-form' do fill_in 'Title', with: 'New book' fill_in 'Author', with: 'Some author' click_button 'Create' end current_path.must_equal('/books') assert page.has_content?('New book') end end
, , , .
, . New Book
:
% bundle exec hanami generate action web books#new
:
# apps/web/config/routes.rb get '/books/new', to: 'books#new'
, Hanami , HTML Book
:
apps/web/templates/books/new.html.erb
:
# apps/web/templates/books/new.html.erb <h2>Add book</h2> <%= form_for :book, '/books' do div class: 'input' do label :title text_field :title end div class: 'input' do label :author text_field :author end div class: 'controls' do submit 'Create Book' end end %>
<label>
,<div>
c Hanami HTML .
, . Books::Create
:
% bundle exec hanami generate action web books#create --method=post
:
# apps/web/config/routes.rb post '/books', to: 'books#create'
books#create
. -:
# spec/web/controllers/books/create_spec.rb require 'spec_helper' require_relative '../../../../apps/web/controllers/books/create' describe Web::Controllers::Books::Create do let(:action) { Web::Controllers::Books::Create.new } let(:params) { Hash[book: { title: 'Confident Ruby', author: 'Avdi Grimm' }] } before do BookRepository.new.clear end it 'creates a new book' do action.call(params) action.book.id.wont_be_nil action.book.title.must_equal params[:book][:title] end it 'redirects the user to the books listing' do response = action.call(params) response[0].must_equal 302 response[1]['Location'].must_equal '/books' end end
, . , , redirect_to
:
# apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action expose :book def call(params) @book = BookRepository.new.create(params[:book]) redirect_to '/books' end end end
, .
% bundle exec rake Run options: --seed 63592 # Running: ............... Finished in 0.081961s, 183.0142 runs/s, 305.0236 assertions/s. 12 runs, 14 assertions, 0 failures, 0 errors, 2 skips
!
! , . , , - ?
. !
, : ? bookshelf#new
, . -:
# spec/web/controllers/books/create_spec.rb require 'spec_helper' require_relative '../../../../apps/web/controllers/books/create' describe Web::Controllers::Books::Create do let(:action) { Web::Controllers::Books::Create.new } after do BookRepository.new.clear end describe 'with valid params' do let(:params) { Hash[book: { title: '1984', author: 'George Orwell' }] } it 'creates a new book' do action.call(params) action.book.id.wont_be_nil end it 'redirects the user to the books listing' do response = action.call(params) response[0].must_equal 302 response[1]['Location'].must_equal '/books' end end describe 'with invalid params' do let(:params) { Hash[book: {}] } it 're-renders the books#new view' do response = action.call(params) response[0].must_equal 422 end it 'sets errors attribute accordingly' do response = action.call(params) response[0].must_equal 422 action.params.errors[:book][:title].must_equal ['is missing'] action.params.errors[:book][:author].must_equal ['is missing'] end end end
: , . , .
, , , Hanami , . Hanami params
.
: ( , ) , β , , , .
, , ,
# apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action expose :book params do required(:book).schema do required(:title).filled(:str?) required(:author).filled(:str?) end end def call(params) if params.valid? @book = BookRepository.new.create(params[:book]) redirect_to '/books' else self.status = 422 end end end end
, URL. , ?
HTTP
422 ( ) .
, , . apps/web/templates/books/new.html.erb
.
# apps/web/views/books/create.rb module Web::Views::Books class Create include Web::View template 'books/new' end end
, Hanami , params
, . , , , .
.
, - , , . .
, , params
:
# spec/web/views/books/new_spec.rb require 'spec_helper' require_relative '../../../../apps/web/views/books/new' class NewBookParams < Hanami::Action::Params params do required(:book).schema do required(:title).filled(:str?) required(:author).filled(:str?) end end end describe Web::Views::Books::New do let(:params) { NewBookParams.new(book: {}) } let(:exposures) { Hash[params: params] } let(:template) { Hanami::View::Template.new('apps/web/templates/books/new.html.erb') } let(:view) { Web::Views::Books::New.new(template, exposures) } let(:rendered) { view.render } it 'displays list of errors when params contains errors' do params.valid? # trigger validations rendered.must_include('There was a problem with your submission') rendered.must_include('Title is missing') rendered.must_include('Author is missing') end end
:
# spec/web/features/add_book_spec.rb require 'features_helper' describe 'Add a book' do # Spec written earlier omitted for brevity it 'displays list of errors when params contains errors' do visit '/books/new' within 'form#book-form' do click_button 'Create' end current_path.must_equal('/books') assert page.has_content?('There was a problem with your submission') assert page.has_content?('Title must be filled') assert page.has_content?('Author must be filled') end end
params.errors
( ) .apps/web/templates/books/new.html.erb
:
<% unless params.valid? %> <div class="errors"> <h3>There was a problem with your submission</h3> <ul> <% params.error_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %>
, "is required", , . .
% bundle exec rake Run options: --seed 59940 # Running: .................. Finished in 0.078112s, 230.4372 runs/s, 473.6765 assertions/s. 15 runs, 27 assertions, 0 failures, 0 errors, 1 skips
, . "web":
# apps/web/config/routes.rb post '/books', to: 'books#create' get '/books/new', to: 'books#new' get '/books', to: 'books#index' root to: 'home#index'
Hanami REST- , :
resources :books, only: [:index, :new, :create] root to: 'home#index'
, , , ,routes
:
% bundle exec hanami routes Name Method Path Action books GET, HEAD /books Web::Controllers::Books::Index new_book GET, HEAD /books/new Web::Controllers::Books::New books POST /books Web::Controllers::Books::Create root GET, HEAD / Web::Controllers::Home::Index
hanami routes
( _path
_url
routes
), HTTP , .
, resources
, . , form_for
?
<%= form_for :book, '/books' do # ... end %>
, , , . routes
, , :
<%= form_for :book, routes.books_path do # ... end %>
apps/web/controllers/books/create.rb
:
redirect_to routes.books_path
Hanami !
, : Hanami, , ; ; , .
, . , the Hanami API , .
PS Translation Gang , , GeorgeGorbanev , .
Source: https://habr.com/ru/post/336814/
All Articles