Upgrade to Pro — share decks privately, control downloads, hide ads and more …

building modern web apps with hanami

Avatar for Vitaliy Pecherin Vitaliy Pecherin
September 10, 2018
20

building modern web apps with hanami

Avatar for Vitaliy Pecherin

Vitaliy Pecherin

September 10, 2018
Tweet

Transcript

  1. Requirements • several applications • REST API • scalable •

    lightweight • painless • no active-whatever
  2. IoC Container is a mechanism for implementing automatic dependency injection.

    It manages object creating and its life time and also injects dependencies to the class. IoC container creates an object of the specified class and also injects all the dependency objects through constructor, property or method at run time and disposes it at the appropriate time. This is done so that we don't have to create and manage objects manually.
  3. # sample/Gemfile # … gem 'dry-system' gem 'dry-system-hanami', github: ‘davydovanton/dry-system-hanami'

    # … # sample/system/container.rb require 'dry/system/container' class Container < Dry::System::Container extend Dry::System::Hanami::Resolver register_folder! 'sample/repositories' register_folder! 'sample/entities' end # sample/config/initializers/application.rb 
 Container.finalize! Register dependencies
  4. Resolving dependencies # sample/lib/sample/repositories/user_repository.rb class UserRepository < Hanami::Repository end #

    sample/apps/web/controllers/users/index.rb module Web module Controllers module Users class Index include Web::Action include Import[ user_repository: 'repositories.user' ] def call(_params) # ... users = user_repository.all # ... end end end end end # sample/system/import.rb Import = Container.injector
  5. Tips & tricks using dry-container for working with configuration: #

    sample/system/boot/config.rb Container.boot(:config) do |container| container.namespace(:config) do namespace(:urls) do register(:user_email_confirmation) { ENV.fetch('USER_EMAIL_CONFIRMATION_URL') } end end end # sample/lib/sample/util/external_urls.rb module Sample module Util class ExternalUrls include Import[ user_email_confirmation: 'config.urls.user_email_confirmation', ] def user_email_confirmation_url(token) "#{user_email_confirmation}?token=#{token}" end end end end
  6. Tips & tricks declare your own registry and resolvers: #

    sample/system/container.rb class Container < Dry::System::Container # ... class Registry < Dry::Container::Registry def call(container, key, item, options) { memoize: true }.merge(options) if item.is_a?(Proc) super end end class Resolver < Dry::Container::Resolver def call(container, key) # whatever super end end configure do |config| config.registry = Registry.new config.resolver = Resolver.new end # ... end
  7. Operation is a callable object which presents any part of

    application business logic and evaluates it by steps, e.g. user creation, users listing, user removal and etc. Operations flow is based on the railway oriented programming.
  8. Usage require "dry/transaction" class CreateUser include Dry::Transaction step :validate step

    :create private def validate(input) # returns Success(valid_data) or Failure(validation) end def create(input) # returns Success(user) end end https://dry-rb.org/gems/dry-transaction/basic-usage/
  9. Cons of dry-transaction • doesn't have conditionals between steps •

    not convenient to pass state from one step to another • database transactions looks awful https://medium.com/pepegramming/do-notation-1e0840a6dbe0 https://www.morozov.is/2018/05/27/do-notation-ruby.html
  10. User create operation with do-notation # sample/Gemfile # ... gem

    ‘dry-monads' gem ‘bcrypt' # ... # sample/lib/sample/util/password_digitizer.rb module Sample module Util class PasswordDigitizer def call(password) BCrypt::Password.create(password).to_s end end end end
  11. # sample/lib/sample/domain/users/operations/create.rb module Sample module Domain module Users module Operations

    class Create include Dry::Monads::Result::Mixin include Dry::Monads::Do.for(:call) include Import[ user: 'repositories.user', digitizer: 'util.password_digitizer' ] def call(input) input = yield validate(input) input = yield build_password(input) user = yield create(input) Success(user) end private def validate(input) # ... end def create(input) Success( user_repo.create(input) ) end def build_password(input) Success(input.merge( encrypted_password: digitizer.call(input[:password]) )) end end end end end end
  12. Domain users list operation # domain/users/operations/list.rb module Sample module Domain

    module Users module Operations class List include Dry::Monads::Result::Mixin include Dry::Monads::Do.for(:call) include Import[ user: 'repositories.user' ] def call(input) input = yield validate(input) relation = user.find_all_by(input) Success(relation) end private def validate(input) # ... end end end end end end
  13. App users list operation # sample/apps/web/operations/users/list.rb module Web module Operations

    module Users class List include Dry::Monads::Result::Mixin include Dry::Monads::Do.for(:call) include Import[ list: 'domain.users.operations.list', pagination_query: 'queries.pagination' ] def call(input) relation = yield list.call(input) collection = pagination_query.call(relation) Success(collection) end end end end end
  14. Separate container for apps layer # sample/apps/web/system/container.rb require 'dry/system/container' module

    Web class Container < Dry::System::Container setting :path_prefix configure do |config| config.path_prefix = Pathname('apps/web') config.auto_register = %w[operations].map do |dir| config.path_prefix.join(dir) end end load_paths! Hanami.root.join('apps') end end # sample/apps/web/system/import.rb require_relative './container' module Web AppImport = Web::Container.injector end
  15. Finalize it! # sample/apps/web/application.rb # ... require_relative './system/import' module Web

    class Application < Hanami::Application # ... end end Web::Container.finalize!
  16. Usage # sample/apps/web/controllers/users/index.rb module Web module Controllers module Users class

    Index include Web::Action include AppImport[ list: 'web.operations.users.list' ] params do # ... end def call(params) # ... users = list.call(params) # ... end end end end end
  17. Controller validations # sample/apps/web/controllers/users/create.rb module Web module Controllers module Users

    class Create include Web::Action include Import[ create_user: ‘domain.users.operations.create’ ] params do required(:name).filled(:str?) required(:email).filled(:str?) required(:password).filled(:str?) end def call(params) if params.valid? # ... create_user.call(params) else # ... end end end end end end
  18. Custom predicates # sample/lib/sample/schemas/predicates.rb module Schemas module Predicates include Hanami::Validations::Predicates

    predicate(:uuid?) do |value| /\A(urn:uuid:)?[\da-f]{8}-([\da-f]{4}-)[\da-f]{12}\z/i.match?(value) end end end # sample/apps/web/params.rb require_relative '../../lib/sample/schemas/predicates' module Web class Params < Hanami::Action::Params predicates Schemas::Predicates end end
  19. Using custom predicates # sample/apps/web/controllers/users/create.rb module Web module Controllers module

    Users class Create include Web::Action include Import[ create_user: ‘domain.users.operations.create’ ] class Params < Web::Params params do # ... required(:inviter_id).filled(:uuid?) end end params Params def call(params) if params.valid? # ... create_user.call(params) else # ... end end end end end end
  20. # sample/apps/web/params.rb require_relative '../../lib/sample/schemas/predicates' module Web class Params < Hanami::Action::Params

    attr_reader :result predicates Schemas::Predicates end end # sample/apps/web/helpers/params_validation.rb module Web module Helpers module ParamsValidation def validate_params!(params) result = params.result return result.output if result.success? halt( 422, { code: :invalid, error: result.messages }.to_json ) end end end end
  21. Usage # sample/apps/web/application.rb module Web class Application < Hanami::Application #

    ... configure do # ... load_paths << [ 'helpers', 'controllers', ] controller.prepare do # ... include Web::Helpers::ParamsValidation # ... end end # ... end end
  22. Usage # sample/apps/web/controllers/users/create.rb module Web module Controllers module Users class

    Create include Web::Action include Import[ create_user: ‘domain.users.operations.create’ ] class Params < Web::Params params do required(:name).filled(:str?) required(:email).filled(:str?) required(:password).filled(:str?) required(:inviter_id).filled(:uuid?) end end params Params def call(params) input = validate_params!(params) # ... end end end end end
  23. # sample/lib/sample/domain/users/schemas/create.rb module Sample module Domain module Users module Schemas

    Create = Dry::Validation.Schema(::Schemas::Base) do configure do option :repo def uniq?(value) repo.email_uniq?(value) end end required(:name).filled(:str?, format?: /\A[a-zA-Z]\z/, max_size?: 255) required(:email).filled(:str?, :email?, :unique?, max_size?: 255) required(:password).filled(:str?, :strong?) optional(:inviter_id).filled(:uuid?) end end end end end
  24. # sample/lib/sample/domain/users/operations/create.rb module Sample module Domain module Users module Operations

    class Create include Dry::Monads::Result::Mixin include Dry::Monads::Do.for(:call) include Import[ user: 'repositories.user', digitizer: 'util.password_digitizer' ] def call(input) input = yield validate(input) input = yield build_password(input) user = yield create(input) Success(user) end private def validate(input) result = Schemas::Create.with(repo: user).(input) if result.success? Success(result.output) else Failure(error) end end # ... end end end end end
  25. Usage # sample/Gemfile # ... gem 'multi_json' gem 'oj' gem

    'representable' # ... # sample/apps/web/serializers/user.rb module Web module Serializers class User < Representable::Decorator include Representable::JSON property :id property :email property :name property :inviter_id end end end
  26. Usage # sample/apps/web/helpers/respond_with.rb module Web module Helpers module RespondWith DEFAULT_STATUS

    = 200 def respond_with(result, serializer:, status: DEFAULT_STATUS) if result.success? respond_with_success( result.value!, serializer: serializer, status: status ) else respond_with_failure(result.failure) end end def respond_with_success(result, serializer:, status: DEFAULT_STATUS) self.body = serializer.new(result).to_json self.status = status end def respond_with_failure(failure) # ... end end end end
  27. Usage # sample/apps/web/controllers/users/create.rb module Web module Controllers module Users class

    Create include Web::Action include Import[ create_user: 'domain.users.operations.create' ] class Params < Web::Params params do required(:name).filled(:str?) required(:email).filled(:str?) required(:password).filled(:str?) required(:inviter_id).filled(:uuid?) end end params Params def call(params) input = validate_params!(params) result = create_user.call(input) respond_with(result, serializer: Serializers::User) end end end end end
  28. • too much abstractions (hanami-model, rom-rb, sequel) • ROM 3.3

    • poor hanami-model API • ROM API is unclear • no polymorphic associations
  29. Model # sample/lib/models/base_model.rb BaseModel = Class.new(Sequel::Model) class BaseModel # ...

    end # sample/lib/models/user.rb class User < BaseModel # ... end
  30. Repository # sample/lib/repositories/base_repository.rb require 'hanami/utils/class_attribute' class BaseRepository include Hanami::Utils::ClassAttribute class_attribute

    :model def self.inherited(child) model_const_name = child.name.sub('Repository', '') require_relative "../models/#{Inflecto.underscore(model_const_name)}" child.model = Object.const_get(model_const_name) end end # sample/lib/repositories/user_repository.rb class UserRepository < BaseRepository # ... end