Не так давно узнал про новый Ruby full-stack веб-фреймворк под названием Hanami — от яп. 花見, или flower viewing (любование цветами). Фреймворк был создан итальянским программистом по имени Luca Guidi совсем недавно (где-то в начале 2014 г.) и разрабатывается им же и постепенно растущим сообществом. Ещё недавно фреймворк назывался «Lotus», однако из-за возможных трений с IBM (которые владеют правами на одноимённую торговум марку), было принято стратегическое решение переименовать фреймворк.

Данная запись — первая часть из (я надеюсь) серии, в которой я собираюсь рассмотреть возможности фреймворка на примере создания игрушечного приложения — сервиса для хранения текста (Pastebin). В первой части мы создадим интерфейс, похожий на интерфейс pastebin'а IX.io, которым можно управлять с консоли с помощью утилиты curl.

Hanami

При создании Hanami преследовались следующие цели:

  • Легковесность.
  • Скорость.
  • Простота.
  • Безопасность.

Архитектурные решения, которые были приняты при разработке Hanami, направлены на то, чтобы упростить разработку модульных приложений. Hanami поощряет создание нескольких микроприложений вместо одного монолитного приложения.

Также Hanami предусматривает чёткое разделение логики, заложенной в компоненты архитектуры MVC (дальше в основном идёт сравнение с Rails + ActiveRecord):

  • Моделей в том смысле, в котором их понимают пользователи ActiveRecord, теперь нет — вместо них используются сущности (Entities). Entities представляют собой Plain Old Ruby Objects (PORO), выступающие как хранилища атрибутов объектов предметной области. Вся логика по созданию, сохранению, выборке Entities, теперь принадлежит отдельной сущности - репозиторию (Repository). Кроме того, всё запросы, которые предоставляет абстрактный репозиторий, закрыты (private) в терминах ООП; это заставляет программиста описывать все запросы как методы конкретного репозитория, заставляя его создавать дополнительный слой абстракциию
  • Контроллеров тоже нет! На самом деле, привычный Rails-контроллер — это обычный Ruby-модуль, который предоставляет пространство имён для своих действий (Actions). Action в Hanami — это класс с нуль-арным конструктором и методом #call, принимающим единственный аргумент — параметры запроса. Предназначение Actions — обработать запрос и предоставить объекты Exposures для Views (см. ниже). Именно Actions (а не Entities) ответственны за описание правил валидации параметров (внимательный читатель увидит по ссылке сходство правил с валидациями dry-validation из коллекции гемов dry-rb и будет совершенно прав — Hanami использует dry-validation начиная с версии 0.8).
  • Представления (Views) в Hanami соответствуют объектам, которые называют View Objects в Rails. Представления получают от действий Exposures, которые будут превращены шаблонами (Templates, или Views в терминах Rails), в тело ответа.

Такой подход к MVC значительно упрощает тестирование отдельных частей приложения в изоляции, в чём мы и убедимся в дальнейшем.

Setup

Создадим новый gemset, установим гем hanami и создадим новое приложение, указав, что мы хотим использовать RSpec для тестов и Postgres в качестве РСУБД:

  $ rvm gemset create hanami-pastebin
  $ rvm gemset use hanami-pastebin
  $ gem install hanami rubocop heroku
  $ hanami new pastebin --test=rspec --db=postgres

Установим зависимости и создадим первый коммит:

  $ cd pastebin
  $ bundle install
  $ rvm --create --ruby-version 2.3.1@hanami-pastebin
  $ git add .
  $ git commit -m 'Initial commit'

Travis

Я буду использовать Travis CI для автоматического прогона тестов.

  language: ruby
  sudo: false
  rvm:
    - 2.3.1
    - ruby-head
  cache: bundler
  script:
    - bundle exec rubocop
    - bundle exec rake spec
  before_script:
    - bundle exec hanami db prepare
  env:
    - HANAMI_ENV=test
  matrix:
    allow_failures:
      - rvm: ruby-head
Файл .travis.yml

Paste

Приступим к описанию объектов (одного объекта, на самом деле) предметной области, с которыми мы будем работать: это Paste (от англ. «paste» — вставлять), фрагменты текста, которые хранятся в Pastebin'ах. Paste обладает следующими атрибутами:

id
уникальный идентификатор (число), используемое как первичный ключ в соответствующей таблице Postgres.
content
хранимый текст.
token
идентификатор, который генерируется при создании Paste и используется для разрешения операций удаления и редактирования Paste.
created_at, updated_at
временные штампы, соответственно, создания и последнего изменения Paste.

Следующая команда сгенерирует класс для нашей модели (или Entity, тут создатели Hanami не очень последовательны):

  $ hanami generate model paste

Отредактируем lib/pastebin/entities/paste.rb и добавим туда вышеописанные атрибуты content, token, created_at, updated_at (id добавлен неявно самим фреймворком или SQL-адаптером, я не разбирался).

  class Paste
    include Hanami::Entity

    attributes :content, :token, :created_at, :updated_at
  end
Файл lib/pastebin/entities/paste.rb

Для версионирования таблиц Hanami, как и Rails, использует миграции. Создадим миграцию для Paste:

  $ hanami generate migration create_pastes

Откроем сгенерированную миграцию и добавим туда описания атрибутов вместе с их типами и ограничениями (внимательный читатель увидит, что миграции предоставлены гемом Sequel):

  Hanami::Model.migration do
    change do
      create_table :pastes do
        primary_key :id
        column :content, String, null: false
        column :token, String, null: false
        column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
        column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
      end
    end
  end
Файл db/migrations/[TIMESTAMP]_create_pastes.rb

Hanami придерживается идеологии 12factor касаемо того, как хранить конфигурацию приложения, поэтому такие настройки, как подключение к БД, задаются в .env.*-файлах. Внесём следующие изменения в файл .env.development, чтобы использовать для пользователя, под которым мы сидим в системе, peer аутентификацию к локальному серверу Postgres:

--- aaa 2016-11-10 20:15:09.219439160 +1000
+++ bbb 2016-11-09 18:40:03.139857824 +1000
@@ -1,5 +1,5 @@
 # Define ENV variables for development environment
-DATABASE_URL="postgres://localhost/pastebin_development"
+DATABASE_URL="postgres:/pastebin_development"
 SERVE_STATIC_ASSETS="true"
Изменения .env.development

Похожую правку нужно внести также в .env.test.

Укажем временные зоны для базы данных и приложения, а также укажем соответствие между сущностью, репозиторием и таблицей базы данных вместе с преобразованиями типов для атрибутов:

  # ...

  Hanami::Model.configure do

    # ...

    Sequel.database_timezone = :utc
    Sequel.application_timezone = :local

    # ...

    mapping do
      collection :pastes do
        entity Paste
        repository PasteRepository

        attribute :id, Integer
        attribute :content, String
        attribute :token, String
        attribute :created_at, DateTime
        attribute :updated_at, DateTime
      end
    end

    # ...

  end.load!
Файл lib/pastebin.rb

Следующие две команды создадут базу данных и выполнят миграции:

  $ hanami db create
  $ hanami db migrate

Для этих двух действий существует шорткат: команда hanami db prepare.

То же самое нужно выполнить для тестовой базы, т. е. с переменной окружения HANAMI_ENV=test:

  $ HANAMI_ENV=test hanami db prepare

API app

Как я уже упомянул выше, Hanami поощряет микросервисную архитектуру. Мы создадим приложение api, через которое будем осуществлять управление Paste'ами:

  $ hanami generate app api

Наше приложение будет предоствлять ресурс pastes с четырьмя действиями Create, Show, Update и Delete, соответствующими классическим операциям CRUD. Создадим маршруты для этого ресурса (синтаксис будет знаком рельсовикам):

  resources :pastes, except: %i(index new edit)
Файл apps/api/config/routes.rb

Следующий фрагмент устанавливает форматы запроса и ответа по умолчанию, а также отключает Layout для данного приложения (он нам всё равно не нужен, т. к. мы не генерируем HTML):

  require 'hanami/helpers'
  require 'hanami/assets'

  module Api
    class Application < Hanami::Application
      configure do
        # ...

        # Default format for the requests that don't specify an HTTP_ACCEPT header
        # Argument: A symbol representation of a mime type, default to :html
        #
        default_request_format :html

        # Default format for responses that doesn't take into account the request format
        # Argument: A symbol representation of a mime type, default to :html
        #
        default_response_format :text

        # See https://gitter.im/hanami/chat?at=58235cbac2c2b0744e339ac9
        controller.format text: 'text/plain'

        # ...

        layout nil

        # ...
      end
    end
  end

  # ...
Файл apps/api/application.rb

Строка controller.format ... — это костыль, чтобы обойти проблему, связанную с тем, что Hanami хочет генерировать ответ для формата text с помощью шаблонов *.conf.erb; подробности по ссылке.

Так как мы отключили Layout для данного приложения, то можно удалить представление и шаблон для Layout:

  $ rm apps/api/templates/application.html.erb
  $ rm apps/api/views/application_layout.rb

Create

Приступим к созданию действий; первое действие — это создание нового Paste:

  $ hanami generate action api pastes#create --method=post

Генератор действий создаёт класс для действия, представление и шаблон (и тесты для действия и представления), а также добавляет новый маршрут. Т. к. мы уже заранее указали все маршруты, то его нужно удалить. То же самое нужно будет выполнить при генерации остальных действий, в дальнейшем я про это говорить уже не буду.

Генератор создаёт шаблон для формата html, а нам нужен text, так что переименуем его:

  $ mv apps/api/templates/pastes/create.{html,text}.erb

Как хорошие программисты, которые придерживаются концепции BDD там, где это имеет смысл, мы начнём с написания тестов, которые будут описывать поведение нашего действия:

  require_relative '../../../../apps/api/controllers/pastes/create'

  RSpec.describe Api::Controllers::Pastes::Create do
    let(:action) { described_class.new }

    after do
      PasteRepository.clear
    end

    context 'with invalid parameters' do
      let(:params) { Hash[] }

      it 'is 422 Unprocessable Entity' do
        response = action.call(params)
        expect(response[0]).to eq(422)
      end
    end

    context 'with valid parameters' do
      let(:params) { Hash[content: 'some content'] }

      it 'is successful' do
        response = action.call(params)
        expect(response[0]).to eq(200)
      end
    end
  end
Файл spec/api/controllers/pastes/create_spec.rb

Как видно из тестов, создание Paste должно быть успешно в случае, если мы передадим содержимое Paste; если же попытаться создать Paste без содержимого, то мы должны получить ошибку (HTTP-статус 422).

Дальше опишем поведение представления: оно просто должно генерировать текст, который будет содержать ссылку на новый Paste и идентификатор (токен), который понадобится, если мы захотим отредактировать или удалить созданный Paste:

  require_relative '../../../../apps/api/views/pastes/create'

  RSpec.describe Api::Views::Pastes::Create do
    let(:paste) { Paste.new(id: 42, content: 'some content', token: 'some token') }
    let(:exposures) { Hash[paste: paste] }
    let(:template)  { Hanami::View::Template.new('apps/api/templates/pastes/create.text.erb') }
    let(:view)      { described_class.new(template, exposures) }
    let(:rendered)  { view.render }

    it 'displays the URL of the paste' do
      expect(rendered).to match(%r{/api/pastes/42})
    end

    it 'displays the access token' do
      expect(rendered).to match('some token')
    end
  end
Файл spec/api/views/pastes/create_spec.rb

Как мы видим из примеров выше, особенности архитектуры Hanami значительно упрощают юнит-тестирование компонентов приложения в изоляции. Чтобы протестировать действие, мы должны передать экземляру класса действия хеш с параметрами запроса и проверить сгенерированный ответ (ответ, кстати, совместим с Rack — это простой массив из трёх элементов: статус, заголовки, тело). То же самое с представлениями — мы передаём Exposures которые генерирует действие, экземляру класса представления и проверяем сгенерированный ответ. Те, у кого есть опыт тестирования Rails-приложений, должны оценить простоту и отсутствие магии.

Напишем действие для создания нового Paste:

  module Api::Controllers::Pastes
    class Create
      include Api::Action

      params do
        required(:content).filled(:str?)
      end

      expose :paste

      def call(params)
        halt 422 unless params.valid?
        @paste = PasteRepository.create(Paste.new(content: params[:content],
                                                  token: generate_token))
      end

      private

      def generate_token
        SecureRandom.hex(8)
      end
    end
  end
Файл apps/api/controllers/pastes/create.rb

Тут мы:

  • указываем правила валидации параметров запроса;
  • указываем, что переменная экземпляра @paste должна быть доступна (exposed) для представления;
  • выбрасываем ошибку, если параметры не проходят валидацию;
  • создаём новый Paste, генерируем для него токен, сохранён в переменную экземпляра @paste, значение которой будет доступно в представлении.

Представление просто предоствляет два вспомогательных метода, которые возвращают URL созданного Paste и созданный для него токен без HTML escaping (объект paste здесь — это exposed-переменная @paste экземпляра действия):

  module Api::Views::Pastes
    class Create
      include Api::View

      def paste_url
        raw routes.paste_url(paste.id)
      end

      def access_token
        raw paste.token
      end
    end
  end
Файл apps/api/views/pastes/create.rb

Код шаблона использует определённые в представлении методы:

  URL: <%= paste_url %>
  Access token: <%= access_token %>
Файл apps/api/templates/pastes/create.text.erb

Теперь написанные тесты должны проходить успешно. Проверим и мы создание Paste вручную; для этого запустим development-сервер (hanami server) и воспользуемся утилитой curl (для подробностей использования curl предлагается почитать мануал):

  $ curl -F 'content=<-' http://localhost:2300/api/pastes <<CONTENT
  class Greeter
    def initialize(salutation: 'Hello')
      @salutation = salutation
    end

    def greet(who)
      puts "#{@salutation}, #{who}!"
    end
  end
  CONTENT
  URL: http://localhost:2300/api/pastes/32
  Access token: a4444f889d9dd4cb

Show

Теперь напишем код, которые будет отображать созданные Paste. Воспользуемся генератором:

$ hanami generate action api pastes#show

Для действия Show нам не понадобится шаблон, поэтому удалим его:

$ rm apps/api/templates/pastes/show.html.erb

Тесты для действия проверяют, что если передать идентификатор несуществующего Paste, мы получим ошибку 404:

  require_relative '../../../../apps/api/controllers/pastes/show'

  RSpec.describe Api::Controllers::Pastes::Show do
    let(:action) { described_class.new }
    let(:id) { 42 }
    let(:params) { Hash[id: id] }

    before do
      allow(PasteRepository).to receive(:find).with(id).and_return(paste)
      expect(PasteRepository).to receive(:find).with(id)
    end

    context 'with an existing paste' do
      let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }

      it 'is successful' do
        response = action.call(params)
        expect(response[0]).to eq(200)
      end
    end

    context 'with a non-existing paste' do
      let(:paste) { nil }

      it 'is 404 Not Found' do
        response = action.call(params)
        expect(response[0]).to eq(404)
      end
    end
  end
Файл spec/api/controllers/pastes/show_spec.rb

Представление просто должно возвращать содержимое Paste (внимательный читатель может заметить, что в качестве шаблона, который мы удалили, в конструктор представления передаётся nil):

  require_relative '../../../../apps/api/views/pastes/show'

  RSpec.describe Api::Views::Pastes::Show do
    let(:paste) { Paste.new(id: 42, content: 'some content', token: 'deadbeef') }
    let(:exposures) { Hash[paste: paste] }
    let(:template)  { nil }
    let(:view)      { described_class.new(template, exposures) }
    let(:rendered)  { view.render }

    it 'renders paste content' do
      expect(rendered).to eq(paste.content)
    end
  end
Файл spec/api/views/pastes/show_spec.rb

Код действия, я полагаю, не нуждается в комментариях:

  module Api::Controllers::Pastes
    class Show
      include Api::Action

      expose :paste

      def call(params)
        @paste = PasteRepository.find(params[:id])
        halt 404 if @paste.nil?
      end
    end
  end
Файл apps/api/controllers/pastes/show.rb

Представление определяет один-единственный метод — render. Наличие этого метода в представлении означает, что оно берёт генерацию ответа на себя, не прибегая к помощи шаблонов. Здесь мы просто возвращаем содержимое Paste:

  module Api::Views::Pastes
    class Show
      include Api::View

      def render
        raw paste.content
      end
    end
  end
Файл apps/api/views/pastes/show.rb

Проверим, что мы можем читать созданные ранее Paste:

  $ curl http://localhost:2300/api/pastes/32
  class Greeter
    def initialize(salutation: 'Hello')
      @salutation = salutation
    end

    def greet(who)
      puts "#{@salutation}, #{who}!"
    end
  end

Update

Для действия Update (и Delete дальше) нам не нужны представления, поэтому мы запускаем генератор с ключём --skip-view, при этом генерация шаблона также пропускается:

  $ hanami generate action api pastes#update --skip-view

При тестировании контроллера мы проверяем, что если передать неправильный токен мы получим ошибку (403 Forbidden):

  require_relative '../../../../apps/api/controllers/pastes/update'

  RSpec.describe Api::Controllers::Pastes::Update do
    let(:action) { described_class.new }
    let(:id) { 42 }
    let(:access_token) { 'token' }
    let(:content) { 'new content' }
    let(:params) { Hash[id: 42, access_token: access_token, content: content] }

    context 'with invalid params' do
      context 'when access token is missing' do
        let(:access_token) { nil }

        it 'is 422 Unprocessable Entity' do
          response = action.call(params)
          expect(response[0]).to eq(422)
        end
      end

      context 'when content is missing' do
        let(:content) { nil }

        it 'is 422 Unprocessable Entity' do
          response = action.call(params)
          expect(response[0]).to eq(422)
        end
      end
    end

    context 'with valid params' do
      before do
        allow(PasteRepository).to receive(:find).with(id).and_return(paste)
        expect(PasteRepository).to receive(:find).with(id)
      end

      context 'with a non-existing paste' do
        let(:paste) { nil }

        it 'is 404 Not Found' do
          response = action.call(params)
          expect(response[0]).to eq(404)
        end
      end

      context 'with an existing paste' do
        let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }

        context 'with a matching access token' do
          let(:access_token) { 'deadbeef' }

          it 'is 204 No Content' do
            response = action.call(params)
            expect(response[0]).to eq(204)
          end
        end

        context 'with a non-matching access token' do
          let(:access_token) { 'deadbabe' }

          it 'is 403 Forbidden' do
            response = action.call(params)
            expect(response[0]).to eq(403)
          end
        end
      end
    end
  end
Файл spec/api/controllers/update_spec.rb
  module Api::Controllers::Pastes
    class Update
      include Api::Action

      params do
        required(:id).filled(:int?)
        required(:access_token).filled(:str?)
        required(:content).filled(:str?)
      end

      def call(params)
        halt 422 unless params.valid?

        paste = PasteRepository.find(params[:id])

        halt 404 if paste.nil?
        halt 403 unless paste.token == params[:access_token]

        paste.update(content: params[:content], updated_at: Time.now)
        PasteRepository.update(paste)
        self.status = 204
      end
    end
  end
Файл apps/api/controllers/update.rb

Проверим обновление Paste (воспользуемся ключём -v чтобы убедиться, что сервер отвечает с кодом 204 No Content, который мы возвращаем в случае успеха):

  $ curl -X PATCH -F 'content=<-' -F 'access_token=a4444f889d9dd4cb' http://localhost:2300/api/pastes/32 -v <<CONTENT
  puts 'Hello, World!'
  CONTENT
  ,*   Trying 127.0.0.1...
  ,* Connected to localhost (127.0.0.1) port 2300 (#0)
  ,* Initializing NSS with certpath: sql:/etc/pki/nssdb
  > PATCH /api/pastes/32 HTTP/1.1
  > Host: localhost:2300
  > User-Agent: curl/7.47.1
  > Accept: */*
  > Content-Length: 280
  > Expect: 100-continue
  > Content-Type: multipart/form-data; boundary=------------------------ec240f3405d3c952
  >
  ,* Done waiting for 100-continue
  < HTTP/1.1 204 No Content
  < Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
  < Date: Thu, 10 Nov 2016 12:22:28 GMT
  < Connection: Keep-Alive
  <
  ,* Connection #0 to host localhost left intact

Убедимся, что Paste действительно обновился:

  $ curl http://localhost:2300/api/pastes/32
  puts 'Hello, World!'

А вот что будет, если указать неправильный токен:

  $ curl -X PATCH -F 'content=<-' -F 'access_token=deadbabe' http://localhost:2300/api/pastes/32 -v <<CONTENT
  puts 'Hello, World!'
  CONTENT
  ,*   Trying 127.0.0.1...
  ,* Connected to localhost (127.0.0.1) port 2300 (#0)
  ,* Initializing NSS with certpath: sql:/etc/pki/nssdb
  > PATCH /api/pastes/32 HTTP/1.1
  > Host: localhost:2300
  > User-Agent: curl/7.47.1
  > Accept: */*
  > Content-Length: 272
  > Expect: 100-continue
  > Content-Type: multipart/form-data; boundary=------------------------656dca4be12196e9
  >
  ,* Done waiting for 100-continue
  < HTTP/1.1 403 Forbidden
  < X-Frame-Options: DENY
  < X-Content-Type-Options: nosniff
  < X-Xss-Protection: 1; mode=block
  < Content-Security-Policy: form-action 'self'; frame-ancestors 'self'; base-uri 'self'; default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; font-src 'self'; object-src 'none'; plugin-types application/pdf; child-src 'self'; frame-src 'self'; media-src 'self'
  < Content-Type: text/plain; charset=utf-8
  < Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
  < Date: Thu, 10 Nov 2016 12:25:25 GMT
  < Content-Length: 9
  < Connection: Keep-Alive
  ,* HTTP error before end of send, stop sending
  <
  ,* Closing connection 0
  Forbidden

Destroy

Сгенерируем заключительное действие из четвёрки CRUD:

  $ hanami generate action api pastes#destroy --skip-view

И тесты, и код действия схожи с предыдущими примерами, поэтому я их комментировать не буду.

  require_relative '../../../../apps/api/controllers/pastes/destroy'

  RSpec.describe Api::Controllers::Pastes::Destroy do
    let(:action) { described_class.new }
    let(:id) { 42 }
    let(:access_token) { 'token' }
    let(:params) { Hash[id: 42, access_token: access_token] }

    context 'with invalid params' do
      context 'when access token is missing' do
        let(:access_token) { nil }

        it 'is 422 Unprocessable Entity' do
          response = action.call(params)
          expect(response[0]).to eq(422)
        end
      end
    end

    context 'with valid params' do
      before do
        allow(PasteRepository).to receive(:find).with(id).and_return(paste)
        expect(PasteRepository).to receive(:find).with(id)
      end

      context 'with a non-existing paste' do
        let(:paste) { nil }

        it 'is 404 Not Found' do
          response = action.call(params)
          expect(response[0]).to eq(404)
        end
      end

      context 'with an existing paste' do
        let(:paste) { Paste.new(id: id, content: 'some content', token: 'deadbeef') }

        context 'with a matching access token' do
          let(:access_token) { 'deadbeef' }

          it 'is 204 No Content' do
            response = action.call(params)
            expect(response[0]).to eq(204)
          end
        end

        context 'with a non-matching access token' do
          let(:access_token) { 'deadbabe' }

          it 'is 403 Forbidden' do
            response = action.call(params)
            expect(response[0]).to eq(403)
          end
        end
      end
    end
  end
Файл spec/api/controllers/destroy_spec.rb

Экшен:

  module Api::Controllers::Pastes
    class Destroy
      include Api::Action

      params do
        required(:id).filled(:int?)
        required(:access_token).filled(:str?)
      end

      def call(params)
        halt 422 unless params.valid?

        paste = PasteRepository.find(params[:id])

        halt 404 if paste.nil?
        halt 403 unless paste.token == params[:access_token]

        PasteRepository.delete(paste)
        self.status = 204
      end
    end
  end
Файл apps/api/controllers/create.rb

Попробуем удалить наш Paste с помощью curl (статус 204 No Content — признак успешного результата):

  curl -X DELETE -F 'access_token=a4444f889d9dd4cb' http://localhost:2300/api/pastes/32 -v
  ,*   Trying 127.0.0.1...
  ,* Connected to localhost (127.0.0.1) port 2300 (#0)
  ,* Initializing NSS with certpath: sql:/etc/pki/nssdb
  > DELETE /api/pastes/32 HTTP/1.1
  > Host: localhost:2300
  > User-Agent: curl/7.47.1
  > Accept: */*
  > Content-Length: 163
  > Expect: 100-continue
  > Content-Type: multipart/form-data; boundary=------------------------7e4cdb7d87023317
  >
  ,* Done waiting for 100-continue
  < HTTP/1.1 204 No Content
  < Server: WEBrick/1.3.1 (Ruby/2.3.1/2016-04-26)
  < Date: Thu, 10 Nov 2016 12:29:43 GMT
  < Connection: Keep-Alive
  <
  ,* Connection #0 to host localhost left intact

Heroku

А теперь развернём наше приложение на Heroku. Для начала настроим Puma в качестве веб-сервера для продакшена.

Добавим гем puma в Gemfile:

  ruby '2.3.1'

  # ...snip...

  group :production do
    gem 'puma'
  end
Файл Gemfile

Заставим Heroku запускать Puma:

  web: bundle exec puma -C config/puma.rb
Файл Procfile

Конфиг Puma:

  threads_count = ENV.fetch('HANAMI_MAX_THREADS') { 5 }.to_i
  threads threads_count, threads_count

  port        ENV.fetch('PORT') { 3000 }
  environment ENV.fetch('HANAMI_ENV') { 'development' }

  workers ENV.fetch('WEB_CONCURRENCY') { 2 }
Файл config/puma.rb

Создадим новое приложение Heroku, добавим аддон Postgres и укажем окружение по умолчанию:

  $ heroku create hanami-pastebin # pick yourself a unique name
  $ heroku addons:create heroku-postgresql
  $ heroku config:add HANAMI_ENV=production
  $ heroku config:add SERVE_STATIC_ASSETS=true

Установив значение SERVE_STATIC_ASSETS в true, мы говорим Hanami, что статику должно отдавать само приложение, а не какой-нибудь сервер типа Nginx (которые на Heroku не доступен).

Укажем хост и HTTPS-соединение для продакшена (хост должен соответствовать имени Heroku-приложения, которое мы создали минутой раньше):

  # ...

  module Api
    class Application < Hanami::Application
      # ...

      ##
      # PRODUCTION
      #
      configure :production do
        # ...

        scheme 'https'
        host   'hanami-pastebin.herokuapp.com'
        port   443

        # ...
      end

      # ...
    end
  end
Файл apps/api/application.rb

Теперь можно отправить наше приложение на Heroku и убедиться, что всё работает!

  $ git push heroku master

Заключение

В данной статье были рассмотрены основные особенности фреймворка Hanami на примере простого приложения. Некоторые моменты остались за кадром (например, Hanami Persistence Layer я почти не затронул, а Mailers и Form Helpers так и вообще не упомянул); в следующих частях (если они будут) я постараюсь уделить больше внимания остальным компонентам фреймворка.

Код приложения, соответствующего этой части серии, находится в своей ветке репозитория smaximov/pastebin.