diff --git a/spec/lucky/subdomain_spec.cr b/spec/lucky/subdomain_spec.cr new file mode 100644 index 000000000..67e7566be --- /dev/null +++ b/spec/lucky/subdomain_spec.cr @@ -0,0 +1,145 @@ +require "../spec_helper" + +include ContextHelper + +abstract class BaseAction < Lucky::Action + include Lucky::Subdomain + accepted_formats [:html], default: :html +end + +class Simple::Index < BaseAction + require_subdomain + + get "/simple" do + plain_text subdomain + end +end + +class OptionalSubdomain::Index < BaseAction + get "/optional" do + plain_text subdomain? || "none" + end +end + +class Specific::Index < BaseAction + require_subdomain "foo" + + get "/specific" do + plain_text subdomain + end +end + +class Regex::Index < BaseAction + require_subdomain /www\d/ + + get "/regex" do + plain_text subdomain + end +end + +class Multiple::Index < BaseAction + require_subdomain ["test", "staging", /(prod|production)/] + + get "/multiple" do + plain_text subdomain + end +end + +describe Lucky::Subdomain do + it "handles general subdomain expectation" do + request = build_request(host: "foo.example.com") + response = Simple::Index.new(build_context(request), params).call + response.body.should eq "foo" + end + + it "handles optional subdomain" do + request = build_request(host: "qa.example.com") + response = OptionalSubdomain::Index.new(build_context(request), params).call + response.body.should eq "qa" + + request = build_request(host: "example.com") + response = OptionalSubdomain::Index.new(build_context(request), params).call + response.body.should eq "none" + end + + it "raises error if subdomain missing" do + request = build_request(host: "example.com") + expect_raises(Lucky::InvalidSubdomainError) do + Simple::Index.new(build_context(request), params).call + end + end + + it "handles specific subdomain expectation" do + request = build_request(host: "foo.example.com") + response = Specific::Index.new(build_context(request), params).call + response.body.should eq "foo" + end + + it "raises error if subdomain does not match specific" do + request = build_request(host: "admin.example.com") + expect_raises(Lucky::InvalidSubdomainError) do + Specific::Index.new(build_context(request), params).call + end + end + + it "handles regex subdomain expectation" do + request = build_request(host: "www4.example.com") + response = Regex::Index.new(build_context(request), params).call + response.body.should eq "www4" + end + + it "raises error if subdomain does not match regex" do + request = build_request(host: "4www.example.com") + expect_raises(Lucky::InvalidSubdomainError) do + Regex::Index.new(build_context(request), params).call + end + end + + it "handles multiple options for expectation" do + request = build_request(host: "test.example.com") + response = Multiple::Index.new(build_context(request), params).call + response.body.should eq "test" + + request = build_request(host: "staging.example.com") + response = Multiple::Index.new(build_context(request), params).call + response.body.should eq "staging" + + request = build_request(host: "prod.example.com") + response = Multiple::Index.new(build_context(request), params).call + response.body.should eq "prod" + + request = build_request(host: "production.example.com") + response = Multiple::Index.new(build_context(request), params).call + response.body.should eq "production" + end + + it "raises error if subdomain does not match any expectations" do + request = build_request(host: "development.example.com") + expect_raises(Lucky::InvalidSubdomainError) do + Multiple::Index.new(build_context(request), params).call + end + end + + it "has configuration for urls with larger tld length" do + Lucky::Subdomain.temp_config(tld_length: 2) do + request = build_request(host: "foo.example.co.uk") + response = Simple::Index.new(build_context(request), params).call + response.body.should eq "foo" + end + end + + it "will fail if using ip address" do + request = build_request(host: "development.127.0.0.1:3000") + expect_raises(Lucky::InvalidSubdomainError) do + Simple::Index.new(build_context(request), params).call + end + end + + it "will not fail if using localhost and port with tld length set to 0" do + Lucky::Subdomain.temp_config(tld_length: 0) do + request = build_request(host: "foo.locahost:3000") + response = Simple::Index.new(build_context(request), params).call + response.body.should eq "foo" + end + end +end diff --git a/spec/support/context_helper.cr b/spec/support/context_helper.cr index 30401c513..4a82efccb 100644 --- a/spec/support/context_helper.cr +++ b/spec/support/context_helper.cr @@ -5,11 +5,12 @@ module ContextHelper method = "GET", body = "", content_type = "", - fixed_length : Bool = false + fixed_length : Bool = false, + host = "example.com" ) : HTTP::Request headers = HTTP::Headers.new headers.add("Content-Type", content_type) - headers.add("Host", "example.com") + headers.add("Host", host) if fixed_length body = HTTP::FixedLengthContent.new(IO::Memory.new(body), body.size) end diff --git a/src/lucky/errors.cr b/src/lucky/errors.cr index 38ec91256..392089327 100644 --- a/src/lucky/errors.cr +++ b/src/lucky/errors.cr @@ -237,4 +237,22 @@ module Lucky MESSAGE end end + + class InvalidSubdomainError < Error + def initialize(@host : String?, @expected : Lucky::Subdomain::Matcher) + end + + def message : String + if @host.nil? + "Expected to find a subdomain but did not find a hostname on the request." + elsif @expected == true + "Expected request to have a subdomain but did not find one." + else + <<-MESSAGE + Expected subdomain matcher(s): #{@expected.pretty_inspect} + Did not match host: #{@host} + MESSAGE + end + end + end end diff --git a/src/lucky/subdomain.cr b/src/lucky/subdomain.cr new file mode 100644 index 000000000..607e23624 --- /dev/null +++ b/src/lucky/subdomain.cr @@ -0,0 +1,77 @@ +module Lucky::Subdomain + # Taken from https://github.com/rails/rails/blob/afc6abb674b51717dac39ea4d9e2252d7e40d060/actionpack/lib/action_dispatch/http/url.rb#L8 + IP_HOST_REGEXP = /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ + + Habitat.create do + # tld_length is the number of Top Level Domain segments separated by periods + # the default is 1 because most domains end in ".com" or ".org" + # The tld_length should be changed to 2 when you have a ".co.uk" domain for example + # It can also be changed to 0 for local development so that you can use `tenant.localhost:3000` + setting tld_length : Int32 = 1 + end + + alias Matcher = String | Regex | Bool | Array(String | Regex) | Array(String) | Array(Regex) + + # Sets up a subdomain requirement for an action + # + # ``` + # require_subdomain # subdomain required but can be anything + # require_subdomain "admin" # subdomain required and must equal "admin" + # require_subdomain /(dev|qa|prod)/ # subdomain required and must match regex + # require_subdomain ["tenant1", "tenant2", /tenant\d/] # subdomain required and must match one of the items in the array + # ``` + # + # The subdomain can then be accessed from within the route block by calling `subdomain`. + # + # If you don't want to require a subdomain but still want to check if one is passed + # you can still call `subdomain?` without using `require_subdomain`. + # Just know that `subdomain?` is nilable. + macro require_subdomain(matcher = true) + before _match_subdomain + + private def subdomain : String + subdomain?.not_nil! + end + + private def _match_subdomain + _match_subdomain({{ matcher }}) + end + end + + private def subdomain : String + {% raise "No subdomain available without calling `require_subdomain` first." %} + end + + private def subdomain? : String? + host = request.hostname + return if host.nil? || IP_HOST_REGEXP.matches?(host) + + parts = host.split('.') + parts.pop(settings.tld_length + 1) + + parts.empty? ? nil : parts.join(".") + end + + private def _match_subdomain(matcher : Matcher) + expected = [matcher].flatten.compact + return continue if expected.empty? + + actual = subdomain? + result = expected.any? do |expected_subdomain| + case expected_subdomain + when true + actual.present? + when Symbol + actual.to_s == expected_subdomain.to_s + else + expected_subdomain === actual + end + end + + if result + continue + else + raise InvalidSubdomainError.new(host: request.hostname, expected: matcher) + end + end +end