From 8ffabc4d9e25c60525a69cab24f7b2e88d743cd8 Mon Sep 17 00:00:00 2001 From: Morgan Aubert Date: Mon, 18 Nov 2024 18:43:34 -0400 Subject: [PATCH] #52 - Add support for translated route paths --- spec/ext/marten/routing/reverser.cr | 5 + spec/marten/routing/map_spec.cr | 52 ++++-- spec/marten/routing/path/spec/spec_helper.cr | 1 + spec/marten/routing/path/spec/static_spec.cr | 156 ++++++++++++++++++ .../routing/path/spec/translated_spec.cr | 151 +++++++++++++++++ spec/marten/routing/path/spec_helper.cr | 1 + spec/marten/routing/reverser_spec.cr | 135 ++++++++++++++- spec/marten/routing/rule/base_spec.cr | 2 +- spec/marten/routing/translated_path_spec.cr | 42 +++++ spec/test_project.cr | 3 + spec/test_project/locales/en.yml | 3 + spec/test_project/locales/fr.yml | 3 + src/marten.cr | 6 + src/marten/cli/manage/command/routes.cr | 4 +- src/marten/routing/map.cr | 124 ++++++++------ src/marten/routing/path/match.cr | 17 ++ src/marten/routing/path/spec/base.cr | 16 ++ src/marten/routing/path/spec/static.cr | 43 +++++ src/marten/routing/path/spec/translated.cr | 82 +++++++++ src/marten/routing/reverser.cr | 44 ++++- src/marten/routing/rule/base.cr | 20 ++- src/marten/routing/rule/map.cr | 30 +--- src/marten/routing/rule/path.cr | 43 +---- src/marten/routing/translated_path.cr | 19 +++ 24 files changed, 869 insertions(+), 133 deletions(-) create mode 100644 spec/ext/marten/routing/reverser.cr create mode 100644 spec/marten/routing/path/spec/spec_helper.cr create mode 100644 spec/marten/routing/path/spec/static_spec.cr create mode 100644 spec/marten/routing/path/spec/translated_spec.cr create mode 100644 spec/marten/routing/path/spec_helper.cr create mode 100644 spec/marten/routing/translated_path_spec.cr create mode 100644 spec/test_project/locales/fr.yml create mode 100644 src/marten/routing/path/match.cr create mode 100644 src/marten/routing/path/spec/base.cr create mode 100644 src/marten/routing/path/spec/static.cr create mode 100644 src/marten/routing/path/spec/translated.cr create mode 100644 src/marten/routing/translated_path.cr diff --git a/spec/ext/marten/routing/reverser.cr b/spec/ext/marten/routing/reverser.cr new file mode 100644 index 000000000..6eeccc443 --- /dev/null +++ b/spec/ext/marten/routing/reverser.cr @@ -0,0 +1,5 @@ +class Marten::Routing::Reverser + def exposed_path_for_interpolations + @path_for_interpolations + end +end diff --git a/spec/marten/routing/map_spec.cr b/spec/marten/routing/map_spec.cr index 0561b99bc..edae38243 100644 --- a/spec/marten/routing/map_spec.cr +++ b/spec/marten/routing/map_spec.cr @@ -2,6 +2,13 @@ require "./spec_helper" describe Marten::Routing::Match do describe "#path" do + it "can be used with a translated path" do + map = Marten::Routing::Map.new + map.path(Marten::Routing::TranslatedPath.new("routes.foo_bar"), Marten::Handlers::Base, name: "foo_bar") + + map.reverse("foo_bar").should eq "/foo/bar" + end + it "raises if the inserted rule is an empty string" do map = Marten::Routing::Map.new expect_raises( @@ -29,23 +36,6 @@ describe Marten::Routing::Match do map.path("/bis", Marten::Handlers::Base, name: "home") end end - - it "raises if the inserted rule is a path that contains duplicated parameter names" do - map = Marten::Routing::Map.new - expect_raises(Marten::Routing::Errors::InvalidRulePath) do - map.path("/path/xyz//test/bad", Marten::Handlers::Base, name: "home") - end - end - - it "raises if the inserted rule is a map that contains duplicated parameter names" do - map = Marten::Routing::Map.new - expect_raises(Marten::Routing::Errors::InvalidRulePath) do - sub_map = Marten::Routing::Map.draw do - path("/bad//foobar", Marten::Handlers::Base, name: "home") - end - map.path("/path/xyz/", sub_map, name: "included") - end - end end describe "#resolve" do @@ -149,6 +139,27 @@ describe Marten::Routing::Match do map.reverse("home", sid: "hello-world") end end + + it "raises if the inserted rule is a path that contains duplicated parameter names" do + map = Marten::Routing::Map.new + map.path("/path/xyz//test/bad", Marten::Handlers::Base, name: "home") + + expect_raises(Marten::Routing::Errors::InvalidRulePath) do + map.reverse("home", id: 42) + end + end + + it "raises if the inserted rule is a map that contains duplicated parameter names" do + map = Marten::Routing::Map.new + sub_map = Marten::Routing::Map.draw do + path("/bad//foobar", Marten::Handlers::Base, name: "home") + end + map.path("/path/xyz/", sub_map, name: "included") + + expect_raises(Marten::Routing::Errors::InvalidRulePath) do + map.reverse("included:home", id: 42) + end + end end describe "#reverse(name, params)" do @@ -268,4 +279,11 @@ describe Marten::Routing::Match do end end end + + describe "#t" do + it "returns a translated path" do + map = Marten::Routing::Map.new + map.t("simple.translation").should eq Marten::Routing::TranslatedPath.new("simple.translation") + end + end end diff --git a/spec/marten/routing/path/spec/spec_helper.cr b/spec/marten/routing/path/spec/spec_helper.cr new file mode 100644 index 000000000..cba784c4c --- /dev/null +++ b/spec/marten/routing/path/spec/spec_helper.cr @@ -0,0 +1 @@ +require "../spec_helper" diff --git a/spec/marten/routing/path/spec/static_spec.cr b/spec/marten/routing/path/spec/static_spec.cr new file mode 100644 index 000000000..fc7865076 --- /dev/null +++ b/spec/marten/routing/path/spec/static_spec.cr @@ -0,0 +1,156 @@ +require "./spec_helper" + +describe Marten::Routing::Path::Spec::Static do + describe "#parameters" do + it "returns the path specification parameters" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + spec.parameters.should eq( + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + end + end + + describe "#path_for_interpolation" do + it "returns the path for interpolation" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + spec.path_for_interpolation.should eq("/test/%{param1}/xyz/%{param2}") + end + end + + describe "#regex" do + it "returns the path specification regex" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + spec.regex.should eq(/^\/test\/(?\d+)\/xyz\/(?\d+)$/) + end + end + + describe "#resolve" do + it "returns the expected match if the path matches a specification without parameters" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/foo\/bar$/, + "/foo/bar", + {} of String => Marten::Routing::Parameter::Base + ) + + match = spec.resolve("/foo/bar") + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(8) + match.try(&.parameters).should eq(Marten::Routing::MatchParameters.new) + end + + it "returns the expected match if the path matches a specification with parameters" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + match = spec.resolve("/test/123/xyz/456") + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(17) + match.try(&.parameters).should eq( + { + "param1" => 123, + "param2" => 456, + } + ) + end + + it "returns nil if the path does not match a specification without parameters" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/foo\/bar$/, + "/foo/bar", + {} of String => Marten::Routing::Parameter::Base + ) + + spec.resolve("/bad").should be_nil + end + + it "returns nil if the path does not match a specification with parameters because the path does not match" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + spec.resolve("/bad/123/xyz/456").should be_nil + end + + it "returns nil if the path does not match a specification with parameters because the parameters do not match" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + spec.resolve("/test/notanumber/xyz/4567").should be_nil + end + end + + describe "#reverser" do + it "returns the expected reverser" do + spec = Marten::Routing::Path::Spec::Static.new( + /^\/test\/(?\d+)\/xyz\/(?\d+)$/, + "/test/%{param1}/xyz/%{param2}", + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + + reverser = spec.reverser("route:name") + reverser.should be_a(Marten::Routing::Reverser) + reverser.name.should eq("route:name") + reverser.path_for_interpolation.should eq("/test/%{param1}/xyz/%{param2}") + reverser.exposed_path_for_interpolations.should eq( + { + nil => "/test/%{param1}/xyz/%{param2}", + } of String? => String + ) + reverser.parameters.should eq( + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["int"], + } + ) + end + end +end diff --git a/spec/marten/routing/path/spec/translated_spec.cr b/spec/marten/routing/path/spec/translated_spec.cr new file mode 100644 index 000000000..e7f1422af --- /dev/null +++ b/spec/marten/routing/path/spec/translated_spec.cr @@ -0,0 +1,151 @@ +require "./spec_helper" + +describe Marten::Routing::Path::Spec::Translated do + describe "#resolve" do + it "returns the expected match if the path matches the spec for the current locale" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_with_args"), + nil, + ) + + match = spec.resolve("/foo/123/bar/abc") + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(16) + match.try(&.parameters).should eq( + { + "param1" => 123, + "param2" => "abc", + } + ) + end + + it "returns the expected match if the translated path matches the spec for a non-default locale" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar"), + nil, + ) + + match = I18n.with_locale("fr") { spec.resolve("/foo-french/bar-french") } + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(22) + match.try(&.parameters).should eq(Marten::Routing::MatchParameters.new) + end + + it "returns the expected match if the path matches the default locale path when no translation exists" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_with_args"), + nil, + ) + + match = I18n.with_locale("fr") { spec.resolve("/foo/123/bar/abc") } + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(16) + match.try(&.parameters).should eq( + { + "param1" => 123, + "param2" => "abc", + } + ) + end + + it "returns the expected match if the path matches the spec for the current locale when a regex suffix is used" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_with_args"), + "/suffix/test", + ) + + match = spec.resolve("/foo/123/bar/abc/suffix/test") + + match.should be_a(Marten::Routing::Path::Match) + match.try(&.end_index).should eq(28) + match.try(&.parameters).should eq( + { + "param1" => 123, + "param2" => "abc", + } + ) + end + + it "returns nil if the the path does not match the spec for the current locale" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_with_args"), + nil, + ) + + spec.resolve("/unknown/foo/123/bar/xyz").should be_nil + end + + it "returns nil if the the path does not match the spec for a non-default locale" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar"), + nil, + ) + + I18n.with_locale("fr") { spec.resolve("/unknown/foo-french/bar-french") }.should be_nil + end + end + + describe "#reverser" do + it "returns the expected reverser for a path without parameters" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar"), + nil, + ) + + reverser = spec.reverser("route:name") + reverser.should be_a(Marten::Routing::Reverser) + reverser.name.should eq("route:name") + reverser.exposed_path_for_interpolations.should eq( + { + nil => "/foo/bar", + "en" => "/foo/bar", + "fr" => "/foo-french/bar-french", + "es" => "/foo/bar", + } of String? => String + ) + reverser.parameters.should eq(Marten::Routing::MatchParameters.new) + end + + it "returns the expected reverser for a path with parameters" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_with_args"), + nil, + ) + + reverser = spec.reverser("route:name") + reverser.should be_a(Marten::Routing::Reverser) + reverser.name.should eq("route:name") + reverser.exposed_path_for_interpolations.should eq( + { + nil => "/foo/%{param1}/bar/%{param2}", + "en" => "/foo/%{param1}/bar/%{param2}", + "fr" => "/foo/%{param1}/bar/%{param2}", + "es" => "/foo/%{param1}/bar/%{param2}", + } of String? => String + ) + reverser.parameters.should eq( + { + "param1" => Marten::Routing::Parameter.registry["int"], + "param2" => Marten::Routing::Parameter.registry["string"], + } + ) + end + + it "raises if no translation can be found for the default locale" do + spec = Marten::Routing::Path::Spec::Translated.new( + Marten::Routing::TranslatedPath.new("routes.foo_bar_unknown"), + nil, + ) + + expect_raises( + Marten::Routing::Errors::InvalidRulePath, + "No default locale translation found for route associated with 'routes.foo_bar_unknown' translation key" + ) do + spec.reverser("route:name") + end + end + end +end diff --git a/spec/marten/routing/path/spec_helper.cr b/spec/marten/routing/path/spec_helper.cr new file mode 100644 index 000000000..cba784c4c --- /dev/null +++ b/spec/marten/routing/path/spec_helper.cr @@ -0,0 +1 @@ +require "../spec_helper" diff --git a/spec/marten/routing/reverser_spec.cr b/spec/marten/routing/reverser_spec.cr index 7608c00f8..7fe0aa3e8 100644 --- a/spec/marten/routing/reverser_spec.cr +++ b/spec/marten/routing/reverser_spec.cr @@ -1,6 +1,98 @@ require "./spec_helper" describe Marten::Routing::Reverser do + describe "#combine" do + it "returns the expected reverser when the current reverser has no name" do + reverser = Marten::Routing::Reverser.new( + "", + { + nil => "/test/%{param1}/xyz/%{param2}", + "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"], + } + ) + + combined = reverser.combine( + Marten::Routing::Reverser.new( + "other:name", + { + nil => "/other/%{param3}", + "en" => "/other/%{param3}", + "fr" => "/other/%{param3}", + } of String? => String, + { + "param3" => Marten::Routing::Parameter.registry["slug"], + } + ) + ) + + combined.name.should eq "other:name" + combined.exposed_path_for_interpolations.should eq( + { + nil => "/test/%{param1}/xyz/%{param2}/other/%{param3}", + "en" => "/this-is-a-test/%{param1}/xyz/%{param2}/other/%{param3}", + "fr" => "/ceci-est-un-test/%{param1}/xyz/%{param2}/other/%{param3}", + } of String? => String + ) + combined.parameters.should eq( + { + "param1" => Marten::Routing::Parameter.registry["slug"], + "param2" => Marten::Routing::Parameter.registry["int"], + "param3" => Marten::Routing::Parameter.registry["slug"], + } + ) + end + + it "returns the expected reverser when the current reverser has a name" do + reverser = Marten::Routing::Reverser.new( + "path:name", + { + nil => "/test/%{param1}/xyz/%{param2}", + "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"], + } + ) + + combined = reverser.combine( + Marten::Routing::Reverser.new( + "other:name", + { + nil => "/other/%{param3}", + "en" => "/other/%{param3}", + "fr" => "/other/%{param3}", + } of String? => String, + { + "param3" => Marten::Routing::Parameter.registry["slug"], + } + ) + ) + + combined.name.should eq "path:name:other:name" + combined.exposed_path_for_interpolations.should eq( + { + nil => "/test/%{param1}/xyz/%{param2}/other/%{param3}", + "en" => "/this-is-a-test/%{param1}/xyz/%{param2}/other/%{param3}", + "fr" => "/ceci-est-un-test/%{param1}/xyz/%{param2}/other/%{param3}", + } of String? => String + ) + combined.parameters.should eq( + { + "param1" => Marten::Routing::Parameter.registry["slug"], + "param2" => Marten::Routing::Parameter.registry["int"], + "param3" => Marten::Routing::Parameter.registry["slug"], + } + ) + end + end + describe "#name" do it "returns the reverser path name" do reverser = Marten::Routing::Reverser.new( @@ -16,7 +108,7 @@ describe Marten::Routing::Reverser do end describe "#path_for_interpolation" do - it "returns the reverser path for interpolation" do + it "returns the expected path for interpolation when a single path is used" do reverser = Marten::Routing::Reverser.new( "path:name", "/test/%{param1}/xyz/%{param2}", @@ -27,6 +119,47 @@ describe Marten::Routing::Reverser do ) reverser.path_for_interpolation.should eq "/test/%{param1}/xyz/%{param2}" end + + it "returns the expected path for interpolation per-locale paths are used based on the current locale" 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"], + } + ) + + I18n.with_locale("en") do + reverser.path_for_interpolation.should eq "/this-is-a-test/%{param1}/xyz/%{param2}" + end + + I18n.with_locale("fr") do + reverser.path_for_interpolation.should eq "/ceci-est-un-test/%{param1}/xyz/%{param2}" + end + end + + it "fallbacks to the default path if the current locale does not have a specific path for interpolation" do + reverser = Marten::Routing::Reverser.new( + "path:name", + { + nil => "/test/%{param1}/xyz/%{param2}", + "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"], + } + ) + + I18n.with_locale("es") do + reverser.path_for_interpolation.should eq "/test/%{param1}/xyz/%{param2}" + end + end end describe "#parameters" do diff --git a/spec/marten/routing/rule/base_spec.cr b/spec/marten/routing/rule/base_spec.cr index 149fe7d84..45b16ca12 100644 --- a/spec/marten/routing/rule/base_spec.cr +++ b/spec/marten/routing/rule/base_spec.cr @@ -52,7 +52,7 @@ module Marten::Routing::Rule::BaseSpec getter parameters def initialize(path : String) - @regex, @path_for_interpolation, @parameters = path_to_regex(path) + @regex, @path_for_interpolation, @parameters = self.class.path_to_regex(path) end def resolve(path : String) : Nil | Match diff --git a/spec/marten/routing/translated_path_spec.cr b/spec/marten/routing/translated_path_spec.cr new file mode 100644 index 000000000..bc27969dd --- /dev/null +++ b/spec/marten/routing/translated_path_spec.cr @@ -0,0 +1,42 @@ +require "./spec_helper" + +describe Marten::Routing::TranslatedPath do + describe "#==" do + it "returns true if the objects are the same" do + path1 = Marten::Routing::TranslatedPath.new("path.to.translation") + path2 = path1 + + path1.should eq path2 + end + + it "returns true if the keys are the same" do + path1 = Marten::Routing::TranslatedPath.new("path.to.translation") + path2 = Marten::Routing::TranslatedPath.new("path.to.translation") + + path1.should eq path2 + end + + it "returns false if the keys are different" do + path1 = Marten::Routing::TranslatedPath.new("path.to.translation") + path2 = Marten::Routing::TranslatedPath.new("other.translation") + + path1.should_not eq path2 + end + end + + describe "#key" do + it "returns the key of the translated path" do + path = Marten::Routing::TranslatedPath.new("path.to.translation") + + path.key.should eq "path.to.translation" + end + end + + describe "#to_s" do + it "returns the translated path" do + path = Marten::Routing::TranslatedPath.new("simple.translation") + + path.to_s.should eq "This is a simple translation" + end + end +end diff --git a/spec/test_project.cr b/spec/test_project.cr index 2567a8424..8399dae26 100644 --- a/spec/test_project.cr +++ b/spec/test_project.cr @@ -97,6 +97,9 @@ Marten.configure :test do |config| config.assets.root = "spec/assets" config.media_files.root = "spec/media" + + config.i18n.default_locale = "en" + config.i18n.available_locales = ["en", "fr", "es"] end NESTED_ROUTES_2 = Marten::Routing::Map.draw do diff --git a/spec/test_project/locales/en.yml b/spec/test_project/locales/en.yml index fc254046e..f9246cd3a 100644 --- a/spec/test_project/locales/en.yml +++ b/spec/test_project/locales/en.yml @@ -5,3 +5,6 @@ en: pluralization: one: "One item" other: "%{count} items" + routes: + foo_bar: "/foo/bar" + foo_bar_with_args: "/foo//bar/" diff --git a/spec/test_project/locales/fr.yml b/spec/test_project/locales/fr.yml new file mode 100644 index 000000000..b1ec60df6 --- /dev/null +++ b/spec/test_project/locales/fr.yml @@ -0,0 +1,3 @@ +fr: + routes: + foo_bar: "/foo-french/bar-french" diff --git a/src/marten.cr b/src/marten.cr index f082935fb..fdb503816 100644 --- a/src/marten.cr +++ b/src/marten.cr @@ -150,6 +150,7 @@ module Marten setup_media_files setup_templates setup_i18n + setup_routes end # :nodoc: @@ -185,6 +186,11 @@ module Marten ) end + # :nodoc: + def self.setup_routes : Nil + routes.setup + end + # :nodoc: def self.setup_settings : Nil settings.setup diff --git a/src/marten/cli/manage/command/routes.cr b/src/marten/cli/manage/command/routes.cr index 8af00f26c..fe7dd41f3 100644 --- a/src/marten/cli/manage/command/routes.cr +++ b/src/marten/cli/manage/command/routes.cr @@ -16,7 +16,7 @@ module Marten print_path(rule, parent_path, parent_name) when Marten::Routing::Rule::Map rule_name = parent_name ? "#{parent_name}:#{rule.name}" : rule.name - rule_path = parent_path + rule.path + rule_path = parent_path + rule.path.to_s process_routes_map(rule.map, parent_path: rule_path, parent_name: rule_name) end end @@ -26,7 +26,7 @@ module Marten parts = [] of String empty_parent_name = parent_name.nil? || parent_name.empty? - parts << style(parent_path + rule.path, fore: :light_blue) + parts << style(parent_path + rule.path.to_s, fore: :light_blue) parts << style("[#{empty_parent_name ? rule.name : "#{parent_name}:#{rule.name}"}]", fore: :light_yellow) parts << "›" parts << style(rule.handler.name, fore: :light_green) diff --git a/src/marten/routing/map.cr b/src/marten/routing/map.cr index d071da5ad..34e3af904 100644 --- a/src/marten/routing/map.cr +++ b/src/marten/routing/map.cr @@ -1,6 +1,11 @@ +require "./path/**" +require "./rule/**" + module Marten module Routing class Map + @reversers : Hash(String, Reverser)? = nil + getter rules def self.draw(namespace : String | Symbol | Nil = nil, &) @@ -11,7 +16,6 @@ module Marten def initialize(@namespace : String | Symbol | Nil = nil) @rules = [] of Rule::Base - @reversers = {} of String => Reverser end def draw(&) @@ -31,54 +35,15 @@ module Marten # # The target associated with the considered path must be a handler (subclass of `Marten::Handlers::Base`). # Each pair must be given a name that will be used to uniquely identify the route. - def path(path : String, target : Marten::Handlers::Base.class, name : String | Symbol) : Nil + def path(path : String | TranslatedPath, target : Marten::Handlers::Base.class, name : String | Symbol) : Nil insert_path(path, target, name) end - private def insert_path( - path : String, - target : Marten::Handlers::Base.class | Map, - rule_name : String | Symbol | Nil - ) : Nil - name = rule_name.to_s - - unless rule_name.nil? - raise Errors::InvalidRuleName.new("Route names cannot be empty") if name.empty? - - if name.includes?(':') - raise Errors::InvalidRuleName.new( - "Cannot use '#{name}' as a valid route name: route names cannot contain ':'" - ) - end - end - - unless @rules.find { |r| r.name == name }.nil? - raise Errors::InvalidRuleName.new("A '#{name}' route already exists") - end - - if target.is_a?(Marten::Handlers::Base.class) - rule = Rule::Path.new(path, target, name.to_s) - else # Nested routes map - # Use Map::namespace only if defined and no name was given for this path - name = target.namespace.to_s if name.empty? && target.namespace - - rule = Rule::Map.new(path, target, name) - end - - @rules << rule - - # Inserts the reversers associated with the newly added rule to the local list of reversers in order to ease - # later reverse operations. No paths with duplicated params are allowed. - rule.reversers.each do |reverser| - if path_with_duplicated_parameters?(reverser.path_for_interpolation) - raise Errors::InvalidRulePath.new( - "The '#{reverser.name}' route contains duplicated parameters: " \ - "#{reverser.path_for_interpolation}" - ) - end - - @reversers[reverser.name] = reverser - end + # Returns a translated path. + # + # `path` must be a valid translation key. + def t(path : String) : TranslatedPath + TranslatedPath.new(path) end # Resolves a path - identify a route matching a specific path. @@ -119,10 +84,73 @@ module Marten end protected getter namespace - protected getter reversers + + protected def reversers : Hash(String, Reverser) + @reversers ||= begin + h = {} of String => Reverser + + rules.each do |rule| + # Inserts the reversers associated with the newly added rule to the local list of reversers in order to ease + # later reverse operations. No paths with duplicated params are allowed. + rule.reversers.each do |reverser| + if path_with_duplicated_parameters?(reverser.path_for_interpolation) + raise Errors::InvalidRulePath.new( + "The '#{reverser.name}' route contains duplicated parameters: " \ + "#{reverser.path_for_interpolation}" + ) + end + + h[reverser.name] = reverser + end + end + + h + end + end + + protected def setup : Nil + # Ensure that the reversers are reloaded when the routes are setup. + @reversers = nil + rules.select(Rule::Map).each { |rule| rule.as(Rule::Map).map.setup } + + reversers + end private INTERPOLATION_PARAMETER_RE = /%{([a-zA-Z_0-9]+)}/ + private def insert_path( + path : String | TranslatedPath, + target : Marten::Handlers::Base.class | Map, + rule_name : String | Symbol | Nil + ) : Nil + name = rule_name.to_s + + unless rule_name.nil? + raise Errors::InvalidRuleName.new("Route names cannot be empty") if name.empty? + + if name.includes?(':') + raise Errors::InvalidRuleName.new( + "Cannot use '#{name}' as a valid route name: route names cannot contain ':'" + ) + end + end + + unless @rules.find { |r| r.name == name }.nil? + raise Errors::InvalidRuleName.new("A '#{name}' route already exists") + end + + if target.is_a?(Marten::Handlers::Base.class) + rule = Rule::Path.new(path, target, name.to_s) + else # Nested routes map + # Use Map::namespace only if defined and no name was given for this path + name = target.namespace.to_s if name.empty? && target.namespace + + rule = Rule::Map.new(path, target, name) + end + + @rules << rule + end + private def path_with_duplicated_parameters?(path_for_interpolation) matches = path_for_interpolation.scan(INTERPOLATION_PARAMETER_RE) parameter_names = matches.reduce([] of String) { |acc, match| acc + match.captures } @@ -133,7 +161,7 @@ module Marten reversed = nil begin - reverser = @reversers[name] + reverser = reversers[name] reversed = reverser.reverse(params) rescue KeyError raise Errors::NoReverseMatch.new("'#{name}' does not match any registered route") diff --git a/src/marten/routing/path/match.cr b/src/marten/routing/path/match.cr new file mode 100644 index 000000000..613bcae2c --- /dev/null +++ b/src/marten/routing/path/match.cr @@ -0,0 +1,17 @@ +module Marten + module Routing + module Path + # Represents a match result for a path. + # + # This struct is returned by the `#resolve` method of a path spec object. It contains the extracted parameters + # and the end index of the match in the path string. + struct Match + getter end_index + getter parameters + + def initialize(@parameters : MatchParameters, @end_index : Int32) + end + end + end + end +end diff --git a/src/marten/routing/path/spec/base.cr b/src/marten/routing/path/spec/base.cr new file mode 100644 index 000000000..87cc7eac1 --- /dev/null +++ b/src/marten/routing/path/spec/base.cr @@ -0,0 +1,16 @@ +module Marten + module Routing + module Path + module Spec + # Represents the base path specification. + abstract class Base + # Resolves the path against the path specification. + abstract def resolve(path : String) : Path::Match? + + # Returns a reverser for the path specification. + abstract def reverser(name : String) : Reverser + end + end + end + end +end diff --git a/src/marten/routing/path/spec/static.cr b/src/marten/routing/path/spec/static.cr new file mode 100644 index 000000000..b3b8f2374 --- /dev/null +++ b/src/marten/routing/path/spec/static.cr @@ -0,0 +1,43 @@ +require "./base" + +module Marten + module Routing + module Path + module Spec + # Represents a static path specification. + # + # A static path specification is used for non-translated paths. Such specifications are derived from a set of + # parameters, a path for interpolation, and a path regex. + class Static < Base + getter parameters + getter path_for_interpolation + getter regex + + def initialize( + @regex : Regex, + @path_for_interpolation : String, + @parameters : Hash(String, Parameter::Base) + ) + end + + def resolve(path : String) : Path::Match? + match = @regex.match(path) + return if match.nil? + + kwargs = MatchParameters.new + match.named_captures.each do |name, value| + param_handler = @parameters[name] + kwargs[name] = param_handler.loads(value.to_s) + end + + Path::Match.new(kwargs, match.end) + end + + def reverser(name : String) : Reverser + Reverser.new(name, @path_for_interpolation, @parameters) + end + end + end + end + end +end diff --git a/src/marten/routing/path/spec/translated.cr b/src/marten/routing/path/spec/translated.cr new file mode 100644 index 000000000..81e81fd5e --- /dev/null +++ b/src/marten/routing/path/spec/translated.cr @@ -0,0 +1,82 @@ +require "./base" + +module Marten + module Routing + module Path + module Spec + # Represents a translated path specification. + # + # A translated path specification is a path specification whose path can be translated into multiple locales. + # As such, it contains a mapping of locales to static path specifications that are determined by translating a + # translated path using the project's locales. + class Translated < Base + @path_info_mapping : Hash(String, Static)? + + def initialize(@translated_path : TranslatedPath, @regex_suffix : String? = nil) + end + + def resolve(path : String) : Path::Match? + return unless path_info_mapping.has_key?(I18n.locale) + + path_info_mapping[I18n.locale].resolve(path) + end + + def reverser(name : String) : Reverser + path_for_interpolations = Hash(String?, String).new + + path_for_interpolations[nil] = path_info_mapping[Marten.settings.i18n.default_locale].path_for_interpolation + path_info_mapping.each do |locale, path_info| + path_for_interpolations[locale] = path_info.path_for_interpolation + end + + Reverser.new(name, path_for_interpolations, path_info_mapping.values.first.parameters) + end + + private getter regex_suffix + private getter translated_path + + private def path_info_mapping : Hash(String, Static) + @path_info_mapping ||= begin + h = {} of String => Static + + # Determine the list of locales to translate the path into. + available_locales = [Marten.settings.i18n.default_locale] + if !(a = Marten.settings.i18n.available_locales).nil? + available_locales += a + end + + # Generate a path info object for each locale. + available_locales.each do |locale| + I18n.with_locale(locale) do + path = begin + I18n.t!(translated_path.key, default: nil) + rescue I18n::Errors::MissingTranslation + if locale == Marten.settings.i18n.default_locale + raise Errors::InvalidRulePath.new( + "No default locale translation found for route associated with '#{translated_path.key}' " \ + "translation key" + ) + end + + nil + end + + h[locale] = if !path.nil? + regex, path_for_interpolation, parameters = Rule::Base.path_to_regex(path, regex_suffix) + Path::Spec::Static.new(regex, path_for_interpolation, parameters) + else + # If the translation is missing and the locale is not the default one, we fallback to + # the default locale translation. + h[Marten.settings.i18n.default_locale] + end + end + end + + h + end + end + end + end + end + end +end diff --git a/src/marten/routing/reverser.cr b/src/marten/routing/reverser.cr index 3106ba414..3e3b51a61 100644 --- a/src/marten/routing/reverser.cr +++ b/src/marten/routing/reverser.cr @@ -7,16 +7,52 @@ module Marten # `Marten::Routing::Map#reverse` method makes use of reverser objects internally in order to perform routes lookups. class Reverser getter name - getter path_for_interpolation getter parameters def initialize( @name : String, - @path_for_interpolation : String, + path_for_interpolation : String, @parameters = {} of String => Parameter::Base ) + @path_for_interpolations = {} of String? => String + @path_for_interpolations[nil] = path_for_interpolation end + def initialize( + @name : String, + @path_for_interpolations : Hash(String?, String), + @parameters = {} of String => Parameter::Base + ) + end + + # Combines the current reverser with another reverser. + # + # The new reverser will have a combined name, path, and parameters. + def combine(other : Reverser) : Reverser + new_name = name.empty? ? other.name : "#{name}:#{other.name}" + + new_path_for_interpolations = Hash(String?, String).new + @path_for_interpolations.each do |locale, path_for_interpolation| + next if other.path_for_interpolations[locale]?.nil? + + new_path_for_interpolations[locale] = path_for_interpolation + other.path_for_interpolations[locale] + end + + Reverser.new( + new_name, + new_path_for_interpolations, + parameters.merge(other.parameters) + ) + end + + # Returns the path for interpolation for the current locale. + def path_for_interpolation : String + @path_for_interpolations[I18n.locale]? || @path_for_interpolations[nil] + end + + # Reverses the route for the given parameters. + # + # If the parameters do not match the expected parameters for the route, `nil` is returned. def reverse(params : Nil | Hash(String | Symbol, Parameter::Types)) : Nil | String url_params = {} of String => String @@ -39,8 +75,10 @@ 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_for_interpolation % url_params end + + protected getter path_for_interpolations end end end diff --git a/src/marten/routing/rule/base.cr b/src/marten/routing/rule/base.cr index dcd6be4df..90413fe21 100644 --- a/src/marten/routing/rule/base.cr +++ b/src/marten/routing/rule/base.cr @@ -6,10 +6,8 @@ module Marten abstract def resolve(path : String) : Nil | Match protected abstract def reversers : Array(Reverser) - private PARAMETER_RE = /<(?P\w+)(?::(?P[^>:]+))?>/ - private PARAMETER_NAME_RE = /^[a-z_][a-zA-Z_0-9]*$/ - - private def path_to_regex(path : String) + # :nodoc: + def self.path_to_regex(path : String, regex_suffix : String? = nil) processed_path = path.dup regex_parts = ["^"] path_for_interpolation = "" @@ -49,7 +47,19 @@ module Marten path_for_interpolation += "%{#{parameter_name}}" end - {Regex.new(regex_parts.join("")), path_for_interpolation, parameters} + {Regex.new(regex_parts.join("") + (regex_suffix || "")), path_for_interpolation, parameters} + end + + private PARAMETER_RE = /<(?P\w+)(?::(?P[^>:]+))?>/ + private PARAMETER_NAME_RE = /^[a-z_][a-zA-Z_0-9]*$/ + + private def path_to_path_info(path : String, regex_suffix : String? = nil) + regex, path_for_interpolation, parameters = self.class.path_to_regex(path, regex_suffix) + Routing::Path::Spec::Static.new(regex, path_for_interpolation, parameters) + end + + private def path_to_path_info(path : TranslatedPath, regex_suffix : String? = nil) + Routing::Path::Spec::Translated.new(path, regex_suffix) end end end diff --git a/src/marten/routing/rule/map.cr b/src/marten/routing/rule/map.cr index 14444cc74..1cae124c3 100644 --- a/src/marten/routing/rule/map.cr +++ b/src/marten/routing/rule/map.cr @@ -2,30 +2,22 @@ module Marten module Routing module Rule class Map < Base - @regex : Regex - @path_for_interpolation : String - @parameters : Hash(String, Parameter::Base) - @reversers : Nil | Array(Reverser) + @path_info : Routing::Path::Spec::Base + @reversers : Array(Reverser)? getter map getter name getter path - def initialize(@path : String, @map : Marten::Routing::Map, @name : String) - @regex, @path_for_interpolation, @parameters = path_to_regex(@path) + def initialize(@path : String | TranslatedPath, @map : Marten::Routing::Map, @name : String) + @path_info = path_to_path_info(path) end def resolve(path : String) : Nil | Match - match = @regex.match(path) + match = @path_info.resolve(path) return if match.nil? - kwargs = MatchParameters.new - match.named_captures.each do |name, value| - param_handler = @parameters[name] - kwargs[name] = param_handler.loads(value.to_s) - end - - new_path = path[match.end..] + new_path = path[match.end_index..] sub_match = @map.rules.each do |rule| matched = rule.resolve(new_path) break matched unless matched.nil? @@ -33,18 +25,12 @@ module Marten return if sub_match.nil? - Match.new(sub_match.handler, kwargs.merge!(sub_match.kwargs)) + Match.new(sub_match.handler, match.parameters.merge(sub_match.kwargs)) end protected def reversers : Array(Reverser) @reversers ||= @map.reversers.values.map do |reverser| - name = @name.empty? ? reverser.name : "#{@name}:#{reverser.name}" - - Reverser.new( - name, - @path_for_interpolation + reverser.path_for_interpolation, - @parameters.merge(reverser.parameters) - ) + @path_info.reverser(@name).combine(reverser) end end end diff --git a/src/marten/routing/rule/path.cr b/src/marten/routing/rule/path.cr index f26e614a0..18c2358be 100644 --- a/src/marten/routing/rule/path.cr +++ b/src/marten/routing/rule/path.cr @@ -2,51 +2,26 @@ module Marten module Routing module Rule class Path < Base - @regex : Regex - @path_for_interpolation : String - @parameters : Hash(String, Parameter::Base) - @reversers : Nil | Array(Reverser) + @path_info : Routing::Path::Spec::Base + @reversers : Array(Reverser)? + getter handler getter name getter path - getter handler - def initialize(@path : String, @handler : Marten::Handlers::Base.class, @name : String) - @regex, @path_for_interpolation, @parameters = path_to_regex(@path) + def initialize(@path : String | TranslatedPath, @handler : Marten::Handlers::Base.class, @name : String) + @path_info = path_to_path_info(path, regex_suffix: "$") end def resolve(path : String) : Match? - if @parameters.size == 0 - resolve_without_parameters(path) - else - resolve_with_parameters(path) - end - end - - protected def reversers : Array(Reverser) - @reversers ||= [Reverser.new(@name, @path_for_interpolation, @parameters)] - end - - private def path_to_regex(_path) - regex, path_for_interpolation, parameters = super - {Regex.new("#{regex.source}$"), path_for_interpolation, parameters} - end - - private def resolve_with_parameters(path : String) : Match? - match = @regex.match(path) + match = @path_info.resolve(path) return if match.nil? - kwargs = MatchParameters.new - match.named_captures.each do |name, value| - param_handler = @parameters[name] - kwargs[name] = param_handler.loads(value.to_s) - end - - Match.new(@handler, kwargs) + Match.new(@handler, match.parameters) end - private def resolve_without_parameters(path : String) : Match? - Match.new(@handler, MatchParameters.new) if @path == path + protected def reversers : Array(Reverser) + @reversers ||= [@path_info.reverser(@name)] end end end diff --git a/src/marten/routing/translated_path.cr b/src/marten/routing/translated_path.cr new file mode 100644 index 000000000..83c60d07f --- /dev/null +++ b/src/marten/routing/translated_path.cr @@ -0,0 +1,19 @@ +module Marten + module Routing + # Represents a translated path used in routes. + struct TranslatedPath + getter key + + def initialize(@key : String) + end + + def ==(other : TranslatedPath) + super || key == other.key + end + + def to_s(io) + io << I18n.t(key) + end + end + end +end