Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

Simone Carle8 @weppos

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

I want to tell you a story

Slide 5

Slide 5 text

2013 2016 2014

Slide 6

Slide 6 text

2013 2016 2014

Slide 7

Slide 7 text

DNSimple approach to Rails Present Hanami Rails + Hanami

Slide 8

Slide 8 text

DNSimple approach to Rails

Slide 9

Slide 9 text

Be in control of your code Key maintenance principle

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

• 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

Slide 13

Slide 13 text

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 


Slide 14

Slide 14 text

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 


Slide 15

Slide 15 text

AcDveRecord We have special convenKons and guidelines.

Slide 16

Slide 16 text

• 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

Slide 17

Slide 17 text

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 


Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

MVC+

Slide 20

Slide 20 text

 ȕ Ư Request Controllers Commands Services Models

Slide 21

Slide 21 text

 ȕ Ư Request Controllers Commands Services Models

Slide 22

Slide 22 text

 ȕ Ư Request Controllers Commands Services Models

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

 ȕ Ư Request Controllers Commands Services Models

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

 ȕ Ư Request Controllers Commands Services Models

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

 ȕ Ư Request Controllers Commands Services Models

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

Why?  ȕ Ư Request Controllers Commands Services Models

Slide 32

Slide 32 text

Why?  ȕ Ư Request Controllers Commands Services Models

Slide 33

Slide 33 text

Why?  ȕ Ư Request Controllers Commands Services Models

Slide 34

Slide 34 text

Why?  ȕ Ư Request Commands Services Models

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

hanam irb.org

Slide 37

Slide 37 text

Components of Hanami ApplicaKons RouKng AcKons Views Models
 MigraKons Helpers Mailers Assets Command Line

Slide 38

Slide 38 text

Key feature of Hanami Modular Architecture ComposiKon Test-friendly

Slide 39

Slide 39 text

Why did adopted Hanami ?

Slide 40

Slide 40 text

+

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

Wrapping Up

Slide 46

Slide 46 text

! Experiment

Slide 47

Slide 47 text

" CommuniDes Get influenced by other

Slide 48

Slide 48 text

hMps:/ /dnsimple.com/slack

Slide 49

Slide 49 text

# Programming Languages Get influenced by other

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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