Привет, Хабр! Какое-то время назад я заинтересовался фреймворком Hanami и, чтоб лучше отложилось в голове, начал переводить официальное руководство для новичков. Этим и хочу поделиться с сообществом. Переводить я старался ближе к оригиналу, но я более читатель, чем писатель и, если у вас будут замечания к переводу, не стесняйтесь их высказывать, я поправлю.
В этом руководстве мы создадим свой первый проект в Hanami, сделаем простое веб приложение. Мы коснемся всех основных компонентов фреймворка и покроем все написанное тестами.
Что такое Hanami?
Hanami это Ruby MVC фреймворк, состоящий из множества микро-библиотек.
У него простой, стабильный API, минимальный DSL, и предпочтение к использованию простых объектов магическим, переусложненным классам с многими ответственностями.
Цена использования простых объектов с чистыми обязанностями — больше шаблонного кода. Hanami предоставляет способы уменьшить эту лишнюю работу, обслуживая код низкоуровневых реализаций.
Почему Hanami?
Есть три причины, по которым стоит выбрать Hanami:
Hanami легковесен
Код Hanami относительно короткий. Он отвечает только за те вещи, которые нужны, чтобы не писать самому то, что нужно каждому веб-приложению. Hanami поставляется с несколькими опциональными модулями, и другие библиотеки также могут быть легко подключены. Смысл Hanami в архитектуре.
Если тебе надоедает "Rails way", то стоит обратить внимание на Hanami
Hanami основывает экшны контроллеров на классах, что упрощает их тестирование в изоляции.
Hanami также поощряет тебя писать бизнес логику в интеракторах (сценариях использования).
Вьюхи отделены от шаблонов, так что их логика тоже может быть протестирована в изоляции.
Hanami потокобезопасен
Использование многопоточности это отличный способ повысить производительность приложений. Не должно быть сложно написать потокобезопасный код, и Hanami (целое приложение или его части) являются потокобезопасными.
Руководства
Руководства объясняют высокоуровневые компоненты и как их готовить (настраивать, использовать и тестировать) в полноценном приложении. Воображаемый проект, о котором пойдет речь, называется "Книжная полка": онлайновое сообщество, в котором делятся чтивом и покупают книги.
Getting Started
Вступительное слово создателя
Привет. Если ты читаешь эту страницу, вероятно, ты хочешь узнать о Hanami больше. Это отлично, поздравляю! Если ты ищешь новые способы строить поддерживаемые, безопасные, быстрые и тестируемые приложения, ты в хороших руках. Hanami создан для таких же как ты.
Стоит предупредить тебя, независимо от того, совсем ты новичок, или опытный разработчик процесс обучения будет сложным. После 10 лет, в течение которых у тебя сложился определенный взгляд на разработку, возможно, будет сложно сломать некоторые свои привычки. Изменения это всегда вызов самому себе.
Иногда будет казаться, что какие-то фичи выглядят не особенно здраво, но не всегда дело в твоих взглядах. Это может быть делом привычки, ошибкой в проектировании или даже багом. Мы с сообществом из лучших побуждений стараемся улучшать Hanami каждый день.
В этом руководстве мы создадим свой первый проект в Hanami, сделаем простое веб приложение 'книжная полка'. Мы коснемся всех основных компонентов фреймворка и покроем все написанное тестами.
Если столкнешься с какими-то сложностями или просто запутаешься, не сдавайся, заходи в наш чат и задавай вопросы. Там всегда найдется кто-то, кто будет рад помочь.
Развлекайся,
Luca Guidi
Создатель Hanami
Предисловие
Перед тем, как мы начнем, уточним некоторые вещи. Для начала, предположим, что необходимы некоторые базовые знания о разработке веб приложений.
Ты должен быть знаком с некоторыми вещами вроде Bundler, Rake, уметь работать в терминале и строить приложения с использованием паттерна Model, View, Controller.
Позднее в руководстве мы будем использовать базу данных SQLite Postgresql.
Чтобы идти дальше, тебе потребуется работающая версия Ruby 2.3 или выше и SQLite 3+.
Создадим новый Hanami проект
Чтобы создать проект в Hanami, нас сначала нужно установить gem Hanami через Rubygems.
Затем мы сможем использовать консольную команду hanami new
, чтобы сгенерировать новый проект:
% gem install hanami
% hanami new bookshelf
По умолчанию, проект будет настроен для использования SQLite. Для настоящих проектов вы можете указать другой движок базы данных, например, Postgres:
% hanami new bookshelf --database=postgres
Это создаст новую папку bookshelf
в той, из которой запускали команду. Посмотрите, что она будет содержать:
% cd bookshelf
% tree -L 1
.
+-- Gemfile
+-- Rakefile
+-- apps
+-- config
+-- config.ru
+-- db
+-- lib
+-- public
L-- spec
6 directories, 3 files
Вот что, как минимум, стоит об этом знать:
Gemfile
определяет наши Rubygems зависимости (используя Bundler).Rakefile
описывает наши Rake задачи.apps
содержит одно или несколько Rack совместимых приложений.
Здесь мы можем найти первое сгенерированное Hanami приложение, называющеесяWeb
.
Там мы найдем наши контроллеры, вьюхи, маршруты и шаблоны.config
содержит (внезапно!) конфигурационные файлы.config.ru
(rack up) для Rack серверов.db
содержит нашу схему базы данных и миграции.lib
содержит нашу бизнес логику и модель предметной области, включая сущности и репозитории.public
будет содержать скомпилированные ассеты и статические файлы.spec
содержит наши тесты.
Двинемся дальше и установим указанные в Gemfile зависимости с помощью Bundler; затем запустим сервер в режиме разработки:
% bundle install
% bundle exec hanami server
И… встречаем твой первый Hanami проект по адресу http://localhost:2300! Мы должны увидеть в браузере примерно такую картину:
Архитектура Hanami
Архитектура Hanami позволяет содержать несколько Hanami (и Rack) приложений в одном процессе Ruby.
Эти приложения находятся в каталоге apps/
. Каждое из них может быть компонентом твоего продукта, таким как пользовательский интерфейс, панель управления, дашборд или, например, HTTP API..
Все это части механизма доставки бизнес логики, живущей в папке lib/
. Это место, где описаны наши модели предметной области, их взаимодействие друг с другом, составляющие функциональные возможности предоставляемые наши продуктом.
На архитектуру Hanami сильно повлияли идеи Чистой архитектуры дяди Боба.
Пишем наш первый тест
Стартовый экран приложения, который мы видели в браузере, это страница, которая показывается по умолчанию, пока мы не определили ни одного маршрута.
Hanami поощряет Behavior Driven Development (BDD) как способ разработки веб приложений. Перед созданием нашей первой страницы, мы сначала напишем высокоуровневый тест, описывающий ее работу:
# 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
Обратите внимание, что несмотря на то, что Hanami из коробки поддерживает Разработку Через Поведение (BDD), вас не принуждают пользоваться каким-то особенным фреймворком для тестирования — также не требуется какой-то особенной интеграции или библиотек.
Мы начнем с Minitest (который по умолчанию), но также мы можем использовать RSpec если создадим проект с опцией --test=rspec.
Hanami будет в этом случае генерировать хелперы и шаблоны файлов для него.
Выполняем требования
У нас уже есть тест и мы можем видеть, как он падает:
% 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
Пора сделать, чтобы он проходил. Напишем несколько строк кода, требуемого для успешного прохождения теста, шаг за шагом.
Первое, что нам нужно добавить это маршрут:
# apps/web/config/routes.rb
root to: 'home#index'
Мы перенаправляем корневой (root) URL нашего приложения в экшн index
контроллера home
(смотри routing guide для более подробного объяснения). Теперь мы можем создать сам экшн index
.
# apps/web/controllers/home/index.rb
module Web::Controllers::Home
class Index
include Web::Action
def call(params)
end
end
end
Это пустой экшн и в нем не содержится никакой логики. Каждый экшн имеет соответствующий вид, который представляет объект Ruby, который нужно отдать в ответ на запрос, прилетевший в экшн.
# apps/web/views/home/index.rb
module Web::Views::Home
class Index
include Web::View
end
end
… который, в свою очередь, тоже пуст и не делает ничего кроме рендеринга шаблона. По умолчанию это файл erb
, но никто не запретит вам использовать slim
или haml
. Его нам придется поправить, чтобы тест прошел. От нас требуется лишь добавить заголовок Bookshelf.
# apps/web/templates/home/index.html.erb
<h1>Bookshelf</h1>
Сохраним изменения, запустим тесты еще раз и на этот раз должны пройти. Великолепно!
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
Генерируем новые экшены
Давайте же воспользуемся тем, что узнали о главных компонентах Hanami чтобы добавить новый экшн. Проект Bookshelf предназначен для учета книг.
Мы будем хранить информацию о книгах в базе данных и дадим пользователю возможность управлять ей. Первым шагом будет показ списка всех книг, хранящихся в системе.
Опишем функциональность, к которой мы стремимся, с помощью фич-теста:
# 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
Тест достаточно прост и падает, так как адрес /books
пока что не распознается приложением. Создадим экшн контроллера, чтобы исправить это.
Генераторы Hanami
Hanami поставляется с некоторыми генераторами чтобы писать меньше шаблонного кода, обрамляющего новую функциональность.
Наберите в терминале:
% bundle exec hanami generate action web books#index
Эта команда должна сгенерировать новый экшн index в контроллереbooks приложения web.
Что даст нам пустой экшн, вид и шаблон, а также добавит маршрут в apps/web/config/routes.rb
:
get '/books', to: 'books#index'
В ZSH ты можешь получить ошибку zsh: no matches found: books#index
. В этом случае, попробуй другой синтаксис:
% hanami generate action web books/index
Теперь, чтобы тест прошел, нам нужно лишь сделать шаблон в файле apps/web/templates/books/index.html.erb
похожим на:
<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>
Сохрани изменения и увидишь, что тесты проходят!
Терминология контроллеров и экшнов может сбивать с толку, так что стоит прояснить: экшены составляют основу приложения Hanami; контроллеры же просто являются модулями, объединяющими несколько экшнов.
В общем, несмотря на концептуальное присутствие "контроллеров" в приложении, на практике мы будем работать только с экшнами.
Мы использовали генератор чтобы создать новую точку входа в приложение. Но стоит обратить внимание на то, что наш новый шаблон содержит тот же <h1>
что и в home/index.html.erb
. Давайте это исправим.
Макеты
Для избежания повторения одних и тех же строк от шаблона к шаблону, мы можем использовать макет. Откроем файл apps/web/templates/application.html.erb
и сделаем похожим на это:
<!DOCTYPE HTML>
<html>
<head>
<title>Bookshelf</title>
</head>
<body>
<h1>Bookshelf</h1>
<%= yield %>
</body>
</html>
Теперь ты можешь убрать дублирующиеся строки из остальных шаблонов.
Макет похож на любой другой шаблон, но он используется для оборачивания стандартных шаблонов. Ключевое слово yield
заменяется содержимым обычного шаблона. Это отличное место для повторяющихся элементов вроде шапки, футера или меню.
Миграции для изменения схемы Базы Данных
Жестко зашитые в шаблон книги это надувательство, если быть честными. Пора добавить живых данных в приложение.
Во-первых, нам нужна таблица в базе данных, для хранения данных о книгах. Мы можем использовать миграцию, чтобы ее создать. Воспользуемся генератором для создания пустой миграции:
% bundle exec hanami generate migration create_books
Это даст нам файл c именем вроде db/migrations/20161115110038_create_books.rb
, отредактируем его:
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 предоставляет специальный язык для описания изменений в схеме базы данных. Ты можешь узнать о том, как им пользоваться из руководства по миграциям.
В этом случае, мы определяем новую таблицу с колонками для каждого атрибута нашей сущности.
Давайте подготовим базу данных:
% bundle exec hanami db prepare
Выражаем наши данные в виде Сущностей
Мы храним книги в нашей базе данных и показываем их на странице. Для этого, нам нужен способ читать и писать в БД. Представляем сущности и репозитории:
- сущность это объект из предметной области (типа
Book
) уникально определяемый по его идентификатору. - репозиторий находится между сущностями и слоем, обеспечивающим непрерывность их существования.
Сущности абсолютно независимы от базы данных. Это делает их легкими и просто тестируемыми.
По этой причине нам нужен репозиторий для сохранения данных от которых Book
зависит.
Подробнее о сущностях и репозиториях в руководству по моделям.
Hanami предоставляет генератор для моделей, так что давайте создадим сущность Book
и соответствующий репозиторий:
% 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
Генератор дает нам сущность, репозиторий и сопутствующие файлы для тестов.
Работаем с Сущностями
Сущности это что-то близкое по сути к простым объектам Ruby. Мы должны сфокусироваться на поведении, которого мы от них хотим и уже потом на том, как их сохранять.
Прямо сейчас нам нужен простой класс сущности:
# lib/bookshelf/entities/book.rb
class Book < Hanami::Entity
end
Этот класс сгенерирует геттеры и сеттеры для каждого атрибута, передаваемого как параметр при инициализации. Мы можем в этом убедиться написав модульный тест:
# 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
Использование Репозиториев
Теперь мы готовы поиграть с репозиторием. С помощью команды Hanami console
, мы запустим IRb в контексте нашего приложения, что позволит нам использовать существующие объекты:
% 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 имеют методы для загрузки как одной, так и нескольких сущностей из БД; а также для создания и обновления существующих. Также можно определить в репозитории новые методы для собственных запросов.
В итоге, мы видели как Hanami использует сущности и репозитории для моделирования наших данных. Сущности отражают поведение, а репозитории используются для связи сущностей и хранилища данных. Мы можем использовать миграции, чтобы применить изменения к схеме базы данных.
Показываем динамические данные
С нашим новым опытом моделирования данных, мы можем заставить страницу со списком книг показывать изменяющиеся данные. Приспособим для этого написанный ранее тест:
# 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
Мы создали требуемые записи в тесте и затем заявили, что число классов книг на странице им соответствует. Когда мы запустим тесты заново, скорее всего увидим ошибку, связанную с базой данных — помните, что мы уже мигрировали development базу, но еще не провели миграцию базы test.
% HANAMI_ENV=test bundle exec hanami db prepare
Сейчас мы можем изменить шаблон и убрать статический HTML. Наши вьюхи должны пройтись по всем доступным записям и отрендерить их. Напишем тест, требующий такого изменения для вида:
# 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
Мы указали, что страница индекса будет показывать сообщение когда нет книг и нечего показывать; когда же книги есть, то будет показывать их список. Обратите внимание, что рендеринг вьюхи с данными относительно прямолинеен. Hanami спроектирован из простых объектов с минимальными интерфейсами, что позволяет их легко протестировать по отдельности, к тому же они отлично работают вместе.
Давайте перепишем наш шаблон, чтобы воплотить задуманное:
# 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 %>
Если же мы прогоним наш функциональный тест снова, он упадет, так как наш экшн контроллера
все еще не выставляет напоказ (expose) книги для нашей вьюхи. Мы можем написать тест для этого дела:
# 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
Написание тестов для экшенов обычно имеет две стороны: ты делаешь утверждение относительно объекта ответа, который представляет собой Rack-совместимый массив из статуса, заголовков и контента; или про то, что из экшна видны данные после того, как мы его вызвали. Сейчас мы указали, что экшн показывает переменную переменную :books
, что мы и сделаем:
# 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
Используя метод класса экшна expose
, мы можем выставить напоказ содержимое переменной экземпляра @books
, что делает ее доступной во вьюхе. Этого достаточно, чтобы тесты опять проходили!
% 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
Строение форм для создания записей
Остается только создать возможность добавлять новые книги в систему. План прост: мы сделаем страницу с формой, куда можно ввести подробности.
Когда пользователь отправит форму, мы построим новую сущность, сохраним ее и перенаправим пользователя на страницу со списком книг. Вырази историю в тесте:
# 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'
Воплощаем экшн 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, прочти исходный код и следи за блогом.
P.S. Сейчас этот перевод находится в репозитории Translation Gang, где продолжается работа над переводом, а практически все остальные части документации перевел GeorgeGorbanev, за что ему большой респект.