diff --git a/spec/ext/marten/routing/map.cr b/spec/ext/marten/routing/map.cr new file mode 100644 index 000000000..dcd70123a --- /dev/null +++ b/spec/ext/marten/routing/map.cr @@ -0,0 +1,17 @@ +class Marten::Routing::Map + def exposed_localizing? + @localizing + end + + def exposed_localizing=(value) + @localizing = value + end + + def exposed_root? + @root + end + + def exposed_root=(value) + @root = value + end +end diff --git a/spec/marten/routing/map_spec.cr b/spec/marten/routing/map_spec.cr index edae38243..d1880afc2 100644 --- a/spec/marten/routing/map_spec.cr +++ b/spec/marten/routing/map_spec.cr @@ -1,6 +1,73 @@ require "./spec_helper" describe Marten::Routing::Match do + describe "#localized" do + it "raises if the map is not the root one" do + map = Marten::Routing::Map.new + + expect_raises(Marten::Routing::Errors::InvalidRouteMap, "Cannot define localized routes in a non-root map") do + map.localized do + path t("routes.blog"), Marten::Handlers::Base, name: "blog" + end + end + end + + it "allows to define localized routes" do + map = Marten::Routing::Map.new + map.exposed_root = true + + map.localized do + path t("routes.foo_bar"), Marten::Handlers::Base, name: "foo_bar" + end + + map.rules.size.should eq 1 + map.rules.first.should be_a Marten::Routing::Rule::Localized + + map.reverse("foo_bar").should eq "/en/foo/bar" + + I18n.with_locale(:fr) do + map.reverse("foo_bar").should eq "/fr/foo-french/bar-french" + end + end + + it "allows to define localized routes without the default locale prefix" do + map = Marten::Routing::Map.new + map.exposed_root = true + + map.localized(prefix_default_locale: false) do + path t("routes.foo_bar"), Marten::Handlers::Base, name: "foo_bar" + end + + map.rules.size.should eq 1 + map.rules.first.should be_a Marten::Routing::Rule::Localized + + map.reverse("foo_bar").should eq "/foo/bar" + + I18n.with_locale(:fr) do + map.reverse("foo_bar").should eq "/fr/foo-french/bar-french" + end + end + + it "is consistent with multiple invocations" do + map = Marten::Routing::Map.new + map.exposed_root = true + + map.localized do + path t("routes.foo_bar"), Marten::Handlers::Base, name: "foo_bar" + + map.exposed_localizing?.should be_true + + map.localized do + path t("routes.foo_bar"), Marten::Handlers::Base, name: "other_foo_bar" + + map.exposed_localizing?.should be_true + end + end + + map.exposed_localizing?.should be_false + end + end + describe "#path" do it "can be used with a translated path" do map = Marten::Routing::Map.new @@ -36,6 +103,26 @@ describe Marten::Routing::Match do map.path("/bis", Marten::Handlers::Base, name: "home") end end + + context "while localizing" do + it "allows to define localized routes" do + map = Marten::Routing::Map.new + map.exposed_root = true + + map.localized do + path t("routes.foo_bar"), Marten::Handlers::Base, name: "foo_bar" + path t("routes.foo_bar_with_args"), Marten::Handlers::Base, name: "foo_bar_with_args" + end + + map.reverse("foo_bar").should eq "/en/foo/bar" + map.reverse("foo_bar_with_args", param1: 42, param2: "hello-world").should eq "/en/foo/42/bar/hello-world" + + I18n.with_locale(:fr) do + map.reverse("foo_bar").should eq "/fr/foo-french/bar-french" + map.reverse("foo_bar_with_args", param1: 42, param2: "hello-world").should eq "/fr/foo/42/bar/hello-world" + end + end + end end describe "#resolve" do diff --git a/spec/marten/routing/reverser_spec.cr b/spec/marten/routing/reverser_spec.cr index 7fe0aa3e8..cb8d8c953 100644 --- a/spec/marten/routing/reverser_spec.cr +++ b/spec/marten/routing/reverser_spec.cr @@ -229,5 +229,55 @@ describe Marten::Routing::Reverser do ) reverser.reverse({param1: "hello-world"}.to_h).should be_nil end + + it "returns the interpolated path for matching parameters when prefixed and localized paths are used" do + reverser = Marten::Routing::Reverser.new( + "path:name", + { + "en" => "/this-is-a-test/%{param1}/xyz/%{param2}", + "fr" => "/ceci-est-un-test/%{param1}/xyz/%{param2}", + } of String? => String, + { + "param1" => Marten::Routing::Parameter.registry["slug"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + reverser.prefix_locales = true + reverser.prefix_default_locale = true + + I18n.with_locale("en") do + reverser.reverse({param1: "hello-world", param2: 42}.to_h).should eq "/en/this-is-a-test/hello-world/xyz/42" + end + + I18n.with_locale("fr") do + reverser.reverse({param1: "hello-world", param2: 42}.to_h).should eq "/fr/ceci-est-un-test/hello-world/xyz/42" + end + end + + it "does not prefix the path if the locale is the default locale and the prefix_default_locale option is false" do + reverser = Marten::Routing::Reverser.new( + "path:name", + { + "en" => "/this-is-a-test/%{param1}/xyz/%{param2}", + "fr" => "/ceci-est-un-test/%{param1}/xyz/%{param2}", + } of String? => String, + { + "param1" => Marten::Routing::Parameter.registry["slug"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + reverser.prefix_locales = true + reverser.prefix_default_locale = false + + I18n.with_locale("en") do + reverser.reverse({param1: "hello-world", param2: 42}.to_h).should eq "/this-is-a-test/hello-world/xyz/42" + end + + I18n.with_locale("fr") do + reverser.reverse({param1: "hello-world", param2: 42}.to_h).should eq "/fr/ceci-est-un-test/hello-world/xyz/42" + end + end end end diff --git a/spec/marten/routing/rule/localized_spec.cr b/spec/marten/routing/rule/localized_spec.cr new file mode 100644 index 000000000..35e7176d2 --- /dev/null +++ b/spec/marten/routing/rule/localized_spec.cr @@ -0,0 +1,161 @@ +require "./spec_helper" + +describe Marten::Routing::Rule::Localized do + describe "#name" do + it "raises NotImplementedError" do + rule = Marten::Routing::Rule::Localized.new + + expect_raises(NotImplementedError, "Localized rules don't provide names") do + rule.name + end + end + end + + describe "#resolve" do + it "returns the expected match if the path starts with the current locale prefix and if the rule matches" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + + I18n.with_locale("en") do + rule.resolve("/en/home/xyz/my-slug/count/42/display").should be_a Marten::Routing::Match + end + + I18n.with_locale("fr") do + rule.resolve("/fr/home/xyz/my-slug/count/42/display").should be_a Marten::Routing::Match + end + end + + it "returns the expected match when the default locale is not prefixed" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new(prefix_default_locale: false) + + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + + I18n.with_locale("en") do + rule.resolve("/home/xyz/my-slug/count/42/display").should be_a Marten::Routing::Match + end + + I18n.with_locale("fr") do + rule.resolve("/fr/home/xyz/my-slug/count/42/display").should be_a Marten::Routing::Match + end + end + + it "returns nil if the path does not start with the current locale prefix" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + + I18n.with_locale("fr") do + rule.resolve("/home/xyz/my-slug/count/42/display").should be_nil + rule.resolve("/en/home/xyz/my-slug/count/42/display").should be_nil + end + end + + it "returns nil if the rule does not match" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + + I18n.with_locale("en") do + rule.resolve("/en/home/xyz/my-slug/count/not-a-number/display").should be_nil + end + + I18n.with_locale("fr") do + rule.resolve("/fr/home/xyz/my-slug/count/not-a-number/display").should be_nil + end + end + end + + describe "#reversers" do + it "returns the expected reversers when the default locale is prefixed" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new(prefix_default_locale: true) + + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + rule.rules << Marten::Routing::Rule::Path.new( + "/other/path//count//display", + Marten::Handlers::Base, + name: "other_path" + ) + + reversers = rule.exposed_reversers + + reversers.size.should eq 2 + + reversers[0].prefix_locales?.should be_true + reversers[0].prefix_default_locale?.should be_true + reversers[0].name.should eq "home_xyz" + reversers[0].path_for_interpolation.should eq "/home/xyz/%{sid}/count/%{number}/display" + reversers[0].parameters.size.should eq 2 + reversers[0].parameters["sid"].should be_a Marten::Routing::Parameter::Slug + reversers[0].parameters["number"].should be_a Marten::Routing::Parameter::Integer + + reversers[1].prefix_locales?.should be_true + reversers[1].prefix_default_locale?.should be_true + reversers[1].name.should eq "other_path" + reversers[1].path_for_interpolation.should eq "/other/path/%{sid}/count/%{number}/display" + reversers[1].parameters.size.should eq 2 + reversers[1].parameters["sid"].should be_a Marten::Routing::Parameter::Slug + reversers[1].parameters["number"].should be_a Marten::Routing::Parameter::Integer + end + + it "returns the expected reversers when the default locale is not prefixed" do + rule = Marten::Routing::Rule::LocalizedSpec::TestLocalizedRule.new(prefix_default_locale: false) + + rule.rules << Marten::Routing::Rule::Path.new( + "/home/xyz//count//display", + Marten::Handlers::Base, + name: "home_xyz" + ) + rule.rules << Marten::Routing::Rule::Path.new( + "/other/path//count//display", + Marten::Handlers::Base, + name: "other_path" + ) + + reversers = rule.exposed_reversers + + reversers.size.should eq 2 + + reversers[0].prefix_locales?.should be_true + reversers[0].prefix_default_locale?.should be_false + reversers[0].name.should eq "home_xyz" + reversers[0].path_for_interpolation.should eq "/home/xyz/%{sid}/count/%{number}/display" + reversers[0].parameters.size.should eq 2 + reversers[0].parameters["sid"].should be_a Marten::Routing::Parameter::Slug + reversers[0].parameters["number"].should be_a Marten::Routing::Parameter::Integer + + reversers[1].prefix_locales?.should be_true + reversers[1].prefix_default_locale?.should be_false + reversers[1].name.should eq "other_path" + reversers[1].path_for_interpolation.should eq "/other/path/%{sid}/count/%{number}/display" + reversers[1].parameters.size.should eq 2 + reversers[1].parameters["sid"].should be_a Marten::Routing::Parameter::Slug + reversers[1].parameters["number"].should be_a Marten::Routing::Parameter::Integer + end + end +end + +module Marten::Routing::Rule::LocalizedSpec + class TestLocalizedRule < Marten::Routing::Rule::Localized + def exposed_reversers + reversers + end + end +end diff --git a/spec/marten_spec.cr b/spec/marten_spec.cr index f5b7d3099..a7c198e1a 100644 --- a/spec/marten_spec.cr +++ b/spec/marten_spec.cr @@ -13,6 +13,13 @@ describe Marten do end end + describe "#routes" do + it "returns the main routes map with the root flag set to true" do + Marten.routes.should be_a Marten::Routing::Map + Marten.routes.exposed_root?.should be_true + end + end + describe "#setup" do context "with root path" do around_each do |t| diff --git a/src/marten.cr b/src/marten.cr index 28ff09e62..dfb62a239 100644 --- a/src/marten.cr +++ b/src/marten.cr @@ -129,7 +129,9 @@ module Marten # This method returns the main routes map, initialized according to the routes configuration and allowing to perform # reverse URL resolutions. def self.routes - @@routes ||= Routing::Map.new + @@routes ||= Routing::Map.new.tap do |map| + map.root = true + end end # Returns the settings of the application. diff --git a/src/marten/routing/errors.cr b/src/marten/routing/errors.cr index 10658a284..85c0d804f 100644 --- a/src/marten/routing/errors.cr +++ b/src/marten/routing/errors.cr @@ -7,6 +7,9 @@ module Marten # Represents an error raised when a route cannot be reversed. class NoReverseMatch < Exception; end + # Represents an error raised when an invalid route map is defined. + class InvalidRouteMap < Exception; end + # Represents an error raised when an invalid route name is defined. class InvalidRuleName < Exception; end diff --git a/src/marten/routing/map.cr b/src/marten/routing/map.cr index 34e3af904..8ab47e2b2 100644 --- a/src/marten/routing/map.cr +++ b/src/marten/routing/map.cr @@ -4,7 +4,10 @@ require "./rule/**" module Marten module Routing class Map + @current_localized_rule : Rule::Localized? = nil + @localizing : Bool = false @reversers : Hash(String, Reverser)? = nil + @root : Bool = false getter rules @@ -22,6 +25,42 @@ module Marten with self yield self end + # Allows to define routes that are localized. + # + # When localized routes are defined, the current locale will be automatically prepended to the path of the + # localized routes and the routes map will be able to resolve paths in a locale-aware manner. By default, the + # locale prefix will be added to the path of the localized routes for all locales (including the default + # locale). It is however possible to customize this behavior by passing `false` to the `prefix_default_locale` + # argument (so that the locale prefix is not added for the default locale). + # + # For example: + # + # ``` + # Marten::Routing::Map.draw do + # localized do + # path t("routes.blog"), Blogging::BlogHandler, name: "blog" + # path t("routes.post_detail"), Blogging::PostHandler, name: "post_detail" + # end + # end + # ``` + def localized(prefix_default_locale = true, &) : Nil + raise Errors::InvalidRouteMap.new("Cannot define localized routes in a non-root map") if !root? + + begin + previous_localizing = localizing? + previous_current_localized_rule = @current_localized_rule + + self.localizing = true + self.current_localized_rule = Rule::Localized.new(prefix_default_locale) + + with self yield self + ensure + rules << current_localized_rule.not_nil! + self.localizing = (previous_localizing == true) + self.current_localized_rule = previous_current_localized_rule + end + end + # Inserts a new path into the routes map. # # The target associated with the considered path must be a `Marten::Routing::Map` @@ -52,7 +91,7 @@ module Marten # `Marten::Routing::Match` object if a match is found. If no match is found a # `Marten::Routing::Errors::NoResolveMatch` exception is raised. def resolve(path : String) : Match - match = @rules.each do |r| + match = rules.each do |r| matched = r.resolve(path) break matched unless matched.nil? end @@ -85,6 +124,10 @@ module Marten protected getter namespace + protected getter? root + + protected setter root + protected def reversers : Hash(String, Reverser) @reversers ||= begin h = {} of String => Reverser @@ -118,6 +161,13 @@ module Marten private INTERPOLATION_PARAMETER_RE = /%{([a-zA-Z_0-9]+)}/ + private getter current_localized_rule + + private getter? localizing + + private setter current_localized_rule + private setter localizing + private def insert_path( path : String | TranslatedPath, target : Marten::Handlers::Base.class | Map, @@ -135,7 +185,16 @@ module Marten end end - unless @rules.find { |r| r.name == name }.nil? + rules_to_check = localizing? ? rules + current_localized_rule.not_nil!.rules : rules + rules_to_check = rules_to_check.flat_map do |rule| + if rule.is_a?(Rule::Localized) + rule.rules + else + rule + end + end + + unless rules_to_check.reject(Rule::Localized).find { |r| r.name == name }.nil? raise Errors::InvalidRuleName.new("A '#{name}' route already exists") end @@ -148,7 +207,11 @@ module Marten rule = Rule::Map.new(path, target, name) end - @rules << rule + if localizing? + current_localized_rule.not_nil!.rules << rule + else + rules << rule + end end private def path_with_duplicated_parameters?(path_for_interpolation) diff --git a/src/marten/routing/reverser.cr b/src/marten/routing/reverser.cr index 3e3b51a61..705fbc699 100644 --- a/src/marten/routing/reverser.cr +++ b/src/marten/routing/reverser.cr @@ -6,9 +6,18 @@ module Marten # parameters and such parameters are handled accordingly when the `#reverse` method is called. The main # `Marten::Routing::Map#reverse` method makes use of reverser objects internally in order to perform routes lookups. class Reverser + @prefix_default_locale : Bool = true + @prefix_locales : Bool = false + getter name getter parameters + getter? prefix_default_locale + getter? prefix_locales + + setter prefix_default_locale + setter prefix_locales + def initialize( @name : String, path_for_interpolation : String, @@ -75,7 +84,13 @@ module Marten # if not all expected parameters were passed this means that the lookup is not successful. return unless url_params.size == @parameters.size - path_for_interpolation % url_params + path = path_for_interpolation % url_params + + if prefix_locales? && (prefix_default_locale? || I18n.locale != Marten.settings.i18n.default_locale) + "/#{I18n.locale}#{path}" + else + path + end end protected getter path_for_interpolations diff --git a/src/marten/routing/rule/localized.cr b/src/marten/routing/rule/localized.cr new file mode 100644 index 000000000..5be440f64 --- /dev/null +++ b/src/marten/routing/rule/localized.cr @@ -0,0 +1,48 @@ +module Marten + module Routing + module Rule + class Localized < Base + @reversers : Array(Reverser)? + + getter rules + + def initialize(@prefix_default_locale = true) + @rules = Array(Rule::Base).new + end + + def name + raise NotImplementedError.new("Localized rules don't provide names") + end + + def resolve(path : String) : Nil | Match + return unless path.starts_with?(locale_prefix) + + inner_path = path[(locale_prefix.size - 1)..] + rules.each do |r| + matched = r.resolve(inner_path) + break matched unless matched.nil? + end + end + + protected def reversers : Array(Reverser) + @reversers ||= rules.flat_map(&.reversers).tap do |reversers| + reversers.each do |reverser| + reverser.prefix_locales = true + reverser.prefix_default_locale = prefix_default_locale? + end + end + end + + private getter? prefix_default_locale + + private def locale_prefix : String + if I18n.locale == Marten.settings.i18n.default_locale && !prefix_default_locale? + "/" + else + "/#{I18n.locale}/" + end + end + end + end + end +end