Skip to content

Commit 02ad914

Browse files
rainerborenerafaelfranca
authored andcommitted
feat(action_dispatch): allow custom domain extractor class
1 parent 1fcd079 commit 02ad914

File tree

4 files changed

+151
-3
lines changed

4 files changed

+151
-3
lines changed

actionpack/lib/action_dispatch/http/url.rb

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,105 @@ module URL
1111
HOST_REGEXP = /(^[^:]+:\/\/)?(\[[^\]]+\]|[^:]+)(?::(\d+$))?/
1212
PROTOCOL_REGEXP = /^([^:]+)(:)?(\/\/)?$/
1313

14+
# DomainExtractor provides utility methods for extracting domain and subdomain
15+
# information from host strings. This module is used internally by Action Dispatch
16+
# to parse host names and separate the domain from subdomains based on the
17+
# top-level domain (TLD) length.
18+
#
19+
# The module assumes a standard domain structure where domains consist of:
20+
# - Subdomains (optional, can be multiple levels)
21+
# - Domain name
22+
# - Top-level domain (TLD, can be multiple levels like .co.uk)
23+
#
24+
# For example, in "api.staging.example.co.uk":
25+
# - Subdomains: ["api", "staging"]
26+
# - Domain: "example.co.uk" (with tld_length=2)
27+
# - TLD: "co.uk"
28+
module DomainExtractor
29+
extend self
30+
31+
# Extracts the domain part from a host string, including the specified
32+
# number of top-level domain components.
33+
#
34+
# The domain includes the main domain name plus the TLD components.
35+
# The +tld_length+ parameter specifies how many components from the right
36+
# should be considered part of the TLD.
37+
#
38+
# ==== Parameters
39+
#
40+
# [+host+]
41+
# The host string to extract the domain from.
42+
#
43+
# [+tld_length+]
44+
# The number of domain components that make up the TLD. For example,
45+
# use 1 for ".com" or 2 for ".co.uk".
46+
#
47+
# ==== Examples
48+
#
49+
# # Standard TLD (tld_length = 1)
50+
# DomainExtractor.domain_from("www.example.com", 1)
51+
# # => "example.com"
52+
#
53+
# # Country-code TLD (tld_length = 2)
54+
# DomainExtractor.domain_from("www.example.co.uk", 2)
55+
# # => "example.co.uk"
56+
#
57+
# # Multiple subdomains
58+
# DomainExtractor.domain_from("api.staging.myapp.herokuapp.com", 1)
59+
# # => "herokuapp.com"
60+
#
61+
# # Single component (returns the host itself)
62+
# DomainExtractor.domain_from("localhost", 1)
63+
# # => "localhost"
64+
def domain_from(host, tld_length)
65+
host.split(".").last(1 + tld_length).join(".")
66+
end
67+
68+
# Extracts the subdomain components from a host string as an Array.
69+
#
70+
# Returns all the components that come before the domain and TLD parts.
71+
# The +tld_length+ parameter is used to determine where the domain begins
72+
# so that everything before it is considered a subdomain.
73+
#
74+
# ==== Parameters
75+
#
76+
# [+host+]
77+
# The host string to extract subdomains from.
78+
#
79+
# [+tld_length+]
80+
# The number of domain components that make up the TLD. This affects
81+
# where the domain boundary is calculated.
82+
#
83+
# ==== Examples
84+
#
85+
# # Standard TLD (tld_length = 1)
86+
# DomainExtractor.subdomains_from("www.example.com", 1)
87+
# # => ["www"]
88+
#
89+
# # Country-code TLD (tld_length = 2)
90+
# DomainExtractor.subdomains_from("api.staging.example.co.uk", 2)
91+
# # => ["api", "staging"]
92+
#
93+
# # No subdomains
94+
# DomainExtractor.subdomains_from("example.com", 1)
95+
# # => []
96+
#
97+
# # Single subdomain with complex TLD
98+
# DomainExtractor.subdomains_from("www.mysite.co.uk", 2)
99+
# # => ["www"]
100+
#
101+
# # Multiple levels of subdomains
102+
# DomainExtractor.subdomains_from("dev.api.staging.example.com", 1)
103+
# # => ["dev", "api", "staging"]
104+
def subdomains_from(host, tld_length)
105+
parts = host.split(".")
106+
parts[0..-(tld_length + 2)]
107+
end
108+
end
109+
14110
mattr_accessor :secure_protocol, default: false
15111
mattr_accessor :tld_length, default: 1
112+
mattr_accessor :domain_extractor, default: DomainExtractor
16113

17114
class << self
18115
# Returns the domain part of a host given the domain level.
@@ -96,12 +193,11 @@ def add_anchor(path, anchor)
96193
end
97194

98195
def extract_domain_from(host, tld_length)
99-
host.split(".").last(1 + tld_length).join(".")
196+
domain_extractor.domain_from(host, tld_length)
100197
end
101198

102199
def extract_subdomains_from(host, tld_length)
103-
parts = host.split(".")
104-
parts[0..-(tld_length + 2)]
200+
domain_extractor.subdomains_from(host, tld_length)
105201
end
106202

107203
def build_host_url(host, port, protocol, options, path)

actionpack/lib/action_dispatch/railtie.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class Railtie < Rails::Railtie # :nodoc:
5555
ActionDispatch::Http::URL.secure_protocol = app.config.force_ssl
5656
ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length
5757

58+
unless app.config.action_dispatch.domain_extractor.nil?
59+
ActionDispatch::Http::URL.domain_extractor = app.config.action_dispatch.domain_extractor
60+
end
61+
5862
ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets
5963
ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator
6064

actionpack/test/dispatch/request_test.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,34 @@ class RequestDomain < BaseRequestTest
333333
end
334334
end
335335

336+
class RequestDomainExtractor < BaseRequestTest
337+
module CustomExtractor
338+
extend self
339+
340+
def domain_from(_, _)
341+
"world"
342+
end
343+
344+
def subdomains_from(_, _)
345+
["hello"]
346+
end
347+
end
348+
349+
setup { ActionDispatch::Http::URL.domain_extractor = CustomExtractor }
350+
351+
teardown { ActionDispatch::Http::URL.domain_extractor = ActionDispatch::Http::URL::DomainExtractor }
352+
353+
test "domain" do
354+
request = stub_request "HTTP_HOST" => "foobar.foobar.com"
355+
assert_equal "world", request.domain
356+
end
357+
358+
test "subdomains" do
359+
request = stub_request "HTTP_HOST" => "foobar.foobar.com"
360+
assert_equal "hello", request.subdomain
361+
end
362+
end
363+
336364
class RequestPort < BaseRequestTest
337365
test "standard_port" do
338366
request = stub_request

guides/source/configuring.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,6 +2069,26 @@ Specifies the default character set for all renders. Defaults to `nil`.
20692069

20702070
Sets the TLD (top-level domain) length for the application. Defaults to `1`.
20712071

2072+
#### `config.action_dispatch.domain_extractor`
2073+
2074+
Configures the domain extraction strategy used by Action Dispatch for parsing host names into domain and subdomain components. This must be an object that responds to `domain_from(host, tld_length)` and `subdomains_from(host, tld_length)` methods.
2075+
2076+
Defaults to `ActionDispatch::Http::URL::DomainExtractor`, which provides the standard domain parsing logic. You can provide a custom extractor to implement specialized domain parsing behavior:
2077+
2078+
```ruby
2079+
class CustomDomainExtractor
2080+
def self.domain_from(host, tld_length)
2081+
# Custom domain extraction logic
2082+
end
2083+
2084+
def self.subdomains_from(host, tld_length)
2085+
# Custom subdomain extraction logic
2086+
end
2087+
end
2088+
2089+
config.action_dispatch.domain_extractor = CustomDomainExtractor
2090+
```
2091+
20722092
#### `config.action_dispatch.ignore_accept_header`
20732093

20742094
Is used to determine whether to ignore accept headers from a request. Defaults to `false`.

0 commit comments

Comments
 (0)