$30 off During Our Annual Pro Sale. View Details »

Developing and maintaining a platform with Rails and Hanami (RailsConf 2016)

Developing and maintaining a platform with Rails and Hanami (RailsConf 2016)

Simone Carletti

May 06, 2016
Tweet

More Decks by Simone Carletti

Other Decks in Programming

Transcript

  1. Developing and maintaining a
    pla/orm with Rails and Hanami
    Simone Carle, /
    / DNSimple

    View Slide

  2. Simone Carle8
    @weppos

    View Slide

  3. View Slide

  4. I want to tell you a story

    View Slide

  5. 2013 2016
    2014

    View Slide

  6. 2013 2016
    2014

    View Slide

  7. DNSimple approach to Rails
    Present Hanami
    Rails + Hanami

    View Slide

  8. DNSimple approach to Rails

    View Slide

  9. Be in control of your code
    Key maintenance principle

    View Slide

  10. Custom API
    Any external dependency we introduce, we wrap it
    behind a custom API.

    View Slide

  11. require 'phony'


    module Dnsimple


    # Phone is a wrapper for the current phone validation library.

    module Phone


    WHITELIST = {

    "+3912312345678" => "+39 12312345678"

    }


    # Makes a plausibility check on the number.

    def self.plausible?(number)

    whitelisted?(number) ||

    Phony.plausible?(number)

    end


    def self.format(number)

    whitelisted?(number) ||

    Phony.formatted(Phony.normalize(number), parentheses: false)

    end

    View Slide

  12. • Side features or extensions can be introduced without
    an extensive change to the codebase
    • IncompaKbiliKes can be addressed in a single place
    • When and if needed, the external dependency can be
    replaced with liMle effort
    • TesKng doesn't require intensive stubbing

    View Slide

  13. module Dnsimple

    class AliasResolver


    class << self

    def adapter

    @adapter ||= NullAdapter.new

    end


    def resolve(name)

    adapter.resolve(name)

    end


    def enable_test!

    self.adapter = test_adapter.new

    end

    end

    # NullAdapter is a special adapter

    # that discards every resolve request.

    class NullAdapter

    def resolve(name)

    Result.new(name, [], [])

    end

    end


    class TestAdapter

    def resolve(*)

    result = @stubs.shift

    result ? Result.new(*result) : Result.new

    end

    end


    class GoAdapter

    BIN = File.join(Rails.root, "bin", "dsalias")


    def resolve(name)

    #

    end

    end

    View Slide

  14. Dnsimple::AliasResolver.enable_test!


    describe "a methods that interacts with the resolver" do

    before do

    AliasResolver.adapter.test :stub, "fully.qualified.host"

    end


    it "does something" do

    # ...

    end

    end

    View Slide

  15. AcDveRecord
    We have special convenKons and guidelines.

    View Slide

  16. • Methods defined in ActiveRecord::Base are not
    allowed outside the Models
    • Models MUST expose custom API to perform operaKons
    • Callbacks are allowed only for data integrity
    • Query methods in ActiveRecord::Base are not allowed
    outside the Models or Finders
    • Scopes can't be invoked directly outside Models or
    Finders and they exist only to support Finders

    View Slide

  17. require 'model_finder'


    class SuffixFinder

    include ModelFinder::Finder


    def self.find(identifier)

    find_by_tld(identifier)

    end


    def self.find!(identifier)

    find(identifier) or not_found!(identifier)

    end


    def self.find_by_tld(identifier)

    tld = identifier.to_s.downcase

    enabled.where(tld: tld).take

    end


    def self.suffix_listing

    scope.enabled

    end

    def self.newgtld_suffixes

    scope.new_tlds.enabled.order_alpha

    end


    def self.gtld_suffixes

    scope.global_tlds.enabled.order_alpha

    end


    def self.cctld_suffixes

    scope.cc_tlds.enabled.order_alpha

    end


    def self.privacy_supported_tlds

    scope.where(whois_privacy: true).pluck(:tld)

    end


    private


    def self.not_found!(identifier)

    raise(ActiveRecord::RecordNotFound,
    "TLD `#{identifier}` not found")

    end


    end

    View Slide

  18. These guidelines are the result of
    years of experiments, discussions
    and refactoring.

    View Slide

  19. MVC+

    View Slide


  20. ȕ Ư
    Request Controllers Commands Services Models

    View Slide


  21. ȕ Ư
    Request Controllers Commands Services Models

    View Slide


  22. ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  23. class DomainHostingController
    def new
    # ...
    end
    def create
    @result = DomainCreateCommand.execute(command_context, this_account, domain_params)
    @domain = @result.data
    if @result.successful?
    redirect_to domain_path(@domain)
    else
    format.html { render action: "new" }
    end
    end
    private
    def domain_params
    DomainParams.new(params)
    end
    end

    View Slide


  24. ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  25. class DomainCreateCommand
    include Command::Command
    include Dnsimple::AccountRules::CommandMethods
    def execute(account, domain_attributes)
    return if error_account_domainlimit(account, domain_attributes[:name])
    domain_service = DomainService.new
    domain = domain_service.create(domain_attributes[:name], account, domain_attributes.to_h)
    if domain
    Activity.track_domain(context, domain, :create)
    Dnsimple::Notifier::Events::DomainCreate.new(context.actor, domain).deliver
    Dnsimple::Notifier::Events::ZoneCreate.new(context.actor, domain.zone).deliver
    else
    result.error = I18n.t("command.message_error_validation_failed")
    end
    rescue # something
    # set an appropriate error message
    ensure
    result.data = domain
    end
    end

    View Slide


  26. ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  27. class Dnsimple::Services::DomainService
    def initialize(secondary_dns_service = Dnsimple::Services::SecondaryDnsService.new)
    @secondary_dns_service = secondary_dns_service
    end
    def create(name, account, domain_attributes)
    ActiveRecord::Base.transaction do
    domain = Domain.new
    domain.create_domain(name, account, domain_attributes)
    zone_service = ZoneService.new
    zone_service.create(domain)
    end
    end
    def delete(domain)
    # ...
    end
    def lock(domain)
    # ...
    end
    end

    View Slide

  28. class Dnsimple::Services::ZoneService
    def create(domain)
    zone = Zone.new
    zone.create_zone(domain, domain.account)
    create_system_records(zone)
    refresh(zone)
    end
    def create_system_records(zone)
    # ...
    end
    def refresh(zone)
    # ...
    end
    end

    View Slide


  29. ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  30. class Domain
    # Creates a new domain.
    #
    # This method auto-assigns internal tokens used for hashing
    # and persists the object in the database.
    #
    # Returns true if the object is created, false otherwise.
    def create_domain(name, account, attributes = {}, options = {})
    self.attributes = attributes
    self.name = self.class.nameize(name)
    self.account = account
    assign_token
    creating(options) do
    # ...
    end
    end
    # Changes the account ownership of this domain.
    #
    # Returns true if the object is updated, false otherwise.
    def move_to_account(account)
    update_attribute(:account_id, account.id)
    end
    end

    View Slide

  31. Why?

    ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  32. Why?

    ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  33. Why?

    ȕ Ư
    Request Controllers Commands Services Models

    View Slide

  34. Why?

    ȕ Ư
    Request Commands Services Models

    View Slide

  35. View Slide

  36. hanam
    irb.org

    View Slide

  37. Components of Hanami
    ApplicaKons
    RouKng
    AcKons
    Views
    Models

    MigraKons
    Helpers
    Mailers
    Assets
    Command Line

    View Slide

  38. Key feature of Hanami
    Modular Architecture
    ComposiKon
    Test-friendly

    View Slide

  39. Why did
    adopted Hanami ?

    View Slide

  40. +

    View Slide

  41. Rails.application.routes.draw do
    scope as: 'api', constraints: Dnsimple::ApiRouteConstraint, format: false do
    # v2
    mount Api::V2::App.new, at: '/v2'
    end
    # ...
    end
    Hanami::Router.define do
    get "/:account_id/contacts", to: "contacts#index", api: "listContacts"
    post "/:account_id/contacts", to: "contacts#create", api: "createContact"
    get "/:account_id/contacts/:contact_id", to: "contacts#show", api: "getContact"
    patch "/:account_id/contacts/:contact_id", to: "contacts#update", api: "updateContact"
    delete "/:account_id/contacts/:contact_id", to: "contacts#destroy", api: "deleteContact"
    get "/tlds", to: "tlds#index", api: "listTlds"
    get "/tlds/:tld_id", to: "tlds#show", api: "getTld"
    get "/whoami", to: "authentication_context#show", api: "whoami"
    post "/oauth/access_token", to: "oauths#access_token", api: "oauthToken"
    # ...
    end

    View Slide

  42. module Controllers::Domains

    class Create

    def call(params)

    @result = DomainCreateCommand.execute(

    command_context, 

    this_account, 

    DomainParams.new(params))

    @domain = @result.data


    if @result.successful?

    render DomainSerializer.new(@domain), 201

    else

    render ErrorSerializer.new(@result.error, @domain),
    end

    end

    end

    end
    class DomainHostingController

    def create

    @result = DomainCreateCommand.execute(

    command_context,

    this_account,

    domain_params)

    @domain = @result.data


    if @result.successful?

    redirect_to domain_path(@domain)

    else

    format.html { render action: "new" }

    end

    end

    end

    View Slide

  43. module Api::V2

    module Controllers::RegistrarWhoisPrivacy


    class Enable

    include Hanami::Action

    before :require_subscription!, :require_good_standing!


    def call(params)

    # code

    end

    end


    class Disable

    include Hanami::Action

    before :require_subscription!


    def call(params)

    # code

    end

    end


    end

    end

    View Slide

  44. module Api::V2

    Hanami::Controller.configure do

    handle_exceptions Rails.env.production?


    prepare do

    include Rendering

    include Errors

    include NotFoundHandler

    include Authentication

    include Subscription

    include Throttling


    def validate_account!

    (params[:account_id].present? && params[:account_id] != ACCOUNT_WILDCARD) or
    error(400, "Parameter `:account_id` is required")

    end


    def command_context

    actor = (authentication_context.user || authentication_context.account).to_actor

    Command::Context.new(actor: actor, authenticated: authentication_context)

    end

    end

    end

    end

    View Slide

  45. Wrapping Up

    View Slide

  46. ! Experiment

    View Slide

  47. " CommuniDes
    Get influenced by other

    View Slide

  48. hMps:/
    /dnsimple.com/slack

    View Slide

  49. # Programming Languages
    Get influenced by other

    View Slide

  50. Credits
    hMps:/
    /www.flickr.com/photos/sakaida_design/17100388500/

    View Slide

  51. Thanks!
    DNSimple
    dnsimple.com
    @dnsimple
    Simone Carle8
    simonecarle8.com
    @weppos

    View Slide