Skip to content

Commit

Permalink
Add host_routing plugin for routing based on request host header
Browse files Browse the repository at this point in the history
This offers a friendier routing API compared to using the
header_matchers plugin with the :host hash matcher, and it also
offers predicate method support.
  • Loading branch information
jeremyevans committed Dec 14, 2024
1 parent 8da3120 commit 311cfad
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
= master

* Add host_routing plugin for routing based on request host header (jeremyevans)

* Do not write non-String to response body when using custom_block_results plugin (jeremyevans)

= 3.86.0 (2024-11-12)
Expand Down
190 changes: 190 additions & 0 deletions lib/roda/plugins/host_routing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# frozen-string-literal: true

#
class Roda
module RodaPlugins
# The host_routing plugin adds support for more routing requests based on
# the requested host. It also adds predicate methods for checking
# whether a request was requested with the given host.
#
# When loading the plugin, you pass a block, which is used for configuring
# the plugin. For example, if you want to treat requests to api.example.com
# or api2.example.com as api requests, and treat other requests as www
# requests, you could use:
#
# plugin :host_routing do |hosts|
# hosts.to :api, "api.example.com", "api2.example.com"
# hosts.default :www
# end
#
# With this configuration, in your routing tree, you can call the +r.api+ and
# +r.www+ methods for dispatching to routing blocks only for those types of
# requests:
#
# route do |r|
# r.api do
# # requests to api.example.com or api2.example.com
# end
#
# r.www do
# # requests to other domains
# end
# end
#
# In addition to the routing methods, predicate methods are also added to the
# request object:
#
# route do |r|
# "#{r.api?}-#{r.www?}"
# end
# # Requests to api.example.com or api2.example.com return "true-false"
# # Other requests return "false-true"
#
# If the +:scope_predicates+ plugin option is given, predicate methods are also
# created in route block scope:
#
# plugin :host_routing, scope_predicates: true do |hosts|
# hosts.to :api, "api.example.com"
# hosts.default :www
# end
#
# route do |r|
# "#{api?}-#{www?}"
# end
#
# To handle hosts that match a certain format (such as all subdomains),
# where the specific host names are not known up front, you can provide a block
# when calling +hosts.default+. This block is passed the host name, or an empty
# string if no host name is provided, and is evaluated in route block scope.
# When using this support, you should also call +hosts.register+
# to register host types that could be returned by the block. For example, to
# handle api subdomains differently:
#
# plugin :host_routing do |hosts|
# hosts.to :api, "api.example.com"
# hosts.register :api_sub
# hosts.default :www do |host|
# :api_sub if host.end_with?(".api.example.com")
# end
# end
#
# This plugin uses the host method on the request to get the hostname (this method
# is defined by Rack).
module HostRouting
# Setup the host routing support. The block yields an object used to
# configure the plugin. Options:
#
# :scope_predicates :: Setup predicate methods in route block scope
# in addition to request scope.
def self.configure(app, opts=OPTS, &block)
hosts, host_hash, default_block, default_host = DSL.new.process(&block)
app.opts[:host_routing_hash] = host_hash
app.opts[:host_routing_default_host] = default_host

app.send(:define_method, :_host_routing_default, &default_block) if default_block

app::RodaRequest.class_exec do
hosts.each do |host|
host_sym = host.to_sym
define_method(host_sym){|&blk| always(&blk) if _host_routing_host == host}
alias_method host_sym, host_sym

meth = :"#{host}?"
define_method(meth){_host_routing_host == host}
alias_method meth, meth
end
end

if opts[:scope_predicates]
app.class_exec do
hosts.each do |host|
meth = :"#{host}?"
define_method(meth){@_request.send(meth)}
alias_method meth, meth
end
end
end
end

class DSL
def initialize
@hosts = []
@host_hash = {}
end

# Run the DSL for the given block.
def process(&block)
instance_exec(self, &block)

if !@default_host
raise RodaError, "must call default method inside host_routing plugin block to set default host"
end

@hosts.concat(@host_hash.values)
@hosts << @default_host
@hosts.uniq!
[@hosts.freeze, @host_hash.freeze, @default_block, @default_host].freeze
end

# Register hosts that can be returned. This is only needed if
# calling register with a block, where the block can return
# a value that doesn't match a host given to +to+ or +default+.
def register(*hosts)
@hosts = hosts
end

# Treat all given hostnames as routing to the give host.
def to(host, *hostnames)
hostnames.each do |hostname|
@host_hash[hostname] = host
end
end

# Register the default hostname. If a block is provided, it is
# called with the host if there is no match for one of the hostnames
# provided to +to+. If the block returns nil/false, the hostname
# given to this method is used.
def default(hostname, &block)
@default_host = hostname
@default_block = block
end
end
private_constant :DSL

module InstanceMethods
# Handle case where plugin is used without providing a block to
# +hosts.default+. This returns nil, ensuring that the hostname
# provided to +hosts.default+ will be used.
def _host_routing_default(_)
nil
end
end

module RequestMethods
private

# Cache the host to use in the host routing support, so the processing
# is only done once per request.
def _host_routing_host
@_host_routing_host ||= _get_host_routing_host
end

# Determine the host to use for the host routing support. Tries the
# following, in order:
#
# * An exact match for a hostname given in +hosts.to+
# * The return value of the +hosts.default+ block, if given
# * The default value provided in the +hosts.default+ call
def _get_host_routing_host
host = self.host || ""

roda_class.opts[:host_routing_hash][host] ||
scope._host_routing_default(host) ||
roda_class.opts[:host_routing_default_host]
end
end
end

register_plugin(:host_routing, HostRouting)
end
end
123 changes: 123 additions & 0 deletions spec/plugin/host_routing_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
require_relative "../spec_helper"

describe "host_routing plugin" do
it "adds support for routing based on host name" do
app(:bare) do
plugin :host_routing do |hosts|
hosts.to :t1, "t1.example.com"
hosts.to :t2, "t2.example.com", "tx.example.com"
hosts.default :t1
end

route do |r|
r.t1 do
"t1-#{r.t1?}-#{r.t2?}"
end

r.t2 do
"t2-#{r.t1?}-#{r.t2?}"
end
end
end

2.times do
body.must_equal 't1-true-false'
body('HTTP_HOST'=>"t1.example.com").must_equal 't1-true-false'
body('HTTP_HOST'=>"t2.example.com").must_equal 't2-false-true'
body('HTTP_HOST'=>"tx.example.com").must_equal 't2-false-true'
@app = Class.new(@app)
end
end

it "hosts.default accepts a block evaluated in route block scope" do
app(:bare) do
plugin :host_routing do |hosts|
hosts.register :t2, :t3
hosts.default :t1 do |host|
if host.start_with?('t2.example.com')
:t2
elsif request.GET['b']
:t3
end
end
end

route do |r|
r.t1 do
"t1-#{r.t1?}-#{r.t2?}-#{r.t3?}"
end

r.t2 do
"t2-#{r.t1?}-#{r.t2?}-#{r.t3?}"
end

r.t3 do
"t3-#{r.t1?}-#{r.t2?}-#{r.t3?}"
end
end
end

body.must_equal 't1-true-false-false'
body('HTTP_HOST'=>"t2.example.com").must_equal 't2-false-true-false'
body('QUERY_STRING'=>"b=1").must_equal 't3-false-false-true'
body('SERVER_NAME'=>"t2.example.com").must_equal 't2-false-true-false'
body('HTTP_X_FORWARDED_HOST'=>"t2.example.com").must_equal 't2-false-true-false'
end

it "supports :scope_predicates option for also defining predicates in route block scope" do
app(:bare) do
plugin :host_routing, :scope_predicates=>true do |hosts|
hosts.to :t2, "t2.example.com"
hosts.default :t1
end

route do |r|
r.t1 do
"t1-#{t1?}-#{t2?}"
end

r.t2 do
"t2-#{t1?}-#{t2?}"
end
end
end

body('HTTP_HOST'=>"t2.example.com").must_equal 't2-false-true'
body('HTTP_HOST'=>"t1.example.com").must_equal 't1-true-false'
end

it "uses empty string for missing host" do
app(:bare) do
plugin :host_routing, :scope_predicates=>true do |hosts|
hosts.to :t2, ""
hosts.default :t1
end

route do |r|
r.t1 do
"t1-#{r.t1?}-#{r.t2?}"
end

r.t2 do
"t2-#{r.t1?}-#{r.t2?}"
end
end
end

if Rack.release >= '2.1' && !ENV["LINT"]
# Old rack versions would return host ":" for no SERVER_NAME and no SERVER_PORT
# Rack::Lint support in spec/helper forces SERVER_NAME=example.com
body.must_equal 't2-false-true'
end
unless Rack.release =~ /\A2\.2/
# Rack 2.2 uses ":80" host in this case
body('SERVER_NAME'=>'', 'SERVER_PORT'=>"80").must_equal 't2-false-true'
end
body('HTTP_HOST'=>"t1.example.com").must_equal 't1-true-false'
end

it "errors if default host is not provided" do
proc{app.plugin(:host_routing){}}.must_raise Roda::RodaError
app.plugin(:host_routing){|x| x.default :x}
end
end
1 change: 1 addition & 0 deletions www/pages/documentation.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<li><a href="rdoc/classes/Roda/RodaPlugins/Head.html">head</a>: Treat HEAD requests like GET requests with an empty response body.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/HmacPaths.html">hmac_paths</a>: Prevent path enumeration and support access control using HMACs in paths.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/Hooks.html">hooks</a>: Adds before/after hook methods.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/HostRouting.html">host_routing</a>: Adds support for routing based on the host header.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/MatchHook.html">match_hook</a>: Adds a hook method which is called when a path segment is matched.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/MatchHookArgs.html">match_hook_args</a>: Similar to match_hook plugin, but supports passing matchers and block args to hooks.</li>
<li><a href="rdoc/classes/Roda/RodaPlugins/MultiRoute.html">multi_route</a>: Allows dispatching to multiple named route blocks in a single call.</li>
Expand Down

0 comments on commit 311cfad

Please sign in to comment.