rutter 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutter
4
+ # Represents a mounted app route.
5
+ #
6
+ # @see Rutter::Route
7
+ #
8
+ # @private
9
+ class Mount < Route
10
+ # Matches the app pattern against environment.
11
+ #
12
+ # @param env [Hash]
13
+ # Rack environment hash.
14
+ #
15
+ # @return [nil, String]
16
+ # Returns the matching substring or nil on no match.
17
+ def match?(env)
18
+ @pattern.peek(env["PATH_INFO"])
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rutter
4
+ # Conveniences for inflecting and working with names in Rutter.
5
+ module Naming
6
+ module_function
7
+
8
+ # Return a downcased and underscore separated version of the string.
9
+ #
10
+ # Revised version of `Hanami::Utils::String.underscore` implementation.
11
+ #
12
+ # @param string [String]
13
+ # String to be transformed.
14
+ #
15
+ # @return [String]
16
+ # The transformed string.
17
+ #
18
+ # @example
19
+ # string = "RutterNaming"
20
+ # Rutter::Naming.underscore(string) # => 'rutter_naming'
21
+ def underscore(string)
22
+ string = +string.to_s
23
+ string.gsub!("::", "/")
24
+ string.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
25
+ string.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
26
+ string.gsub!(/[[:space:]]|\-/, '\1_\2')
27
+ string.downcase!
28
+ string
29
+ end
30
+
31
+ # Return a CamelCase version of the string.
32
+ #
33
+ # Revised version of `Hanami::Utils::String.classify` implementation.
34
+ #
35
+ # @param string [String]
36
+ # String to be transformed.
37
+ #
38
+ # @return [String]
39
+ # The transformed string
40
+ #
41
+ # @example
42
+ # string = "rutter_naming"
43
+ # Rutter::Naming.classify(string) # => 'RutterNaming'
44
+ def classify(string)
45
+ words = underscore(string).split(%r{_|::|\/|\-}).map!(&:capitalize)
46
+ delimiters = underscore(string).scan(%r{_|::|\/|\-})
47
+ delimiters.map! { |delimiter| delimiter == "_" ? "" : "::" }
48
+ words.zip(delimiters).join
49
+ end
50
+
51
+ # Normalize the given string/symbol to a valid route name.
52
+ #
53
+ # @param string [String, Symbol]
54
+ # Name to be normalized.
55
+ #
56
+ # @return [Symbol]
57
+ # Normalized route name.
58
+ def route_name(string)
59
+ string = underscore(string)
60
+ string.tr!("/", "_")
61
+ string.gsub!(/[_]{2,}/, "_")
62
+ string.gsub!(/\A_|_\z/, "")
63
+ string.to_sym
64
+ end
65
+
66
+ # Normalize the given path.
67
+ #
68
+ # @param path [String]
69
+ # Path to be normalized.
70
+ #
71
+ # @return [String]
72
+ # Normalized path.
73
+ def cleanpath(path)
74
+ Pathname.new("/#{path}").cleanpath.to_s
75
+ end
76
+
77
+ # Join the given arguments with the separator.
78
+ #
79
+ # @overload join(part)
80
+ # @param part [String, nil]
81
+ # First part to join.
82
+ # @overload join(part)
83
+ # @param ... [String, nil]
84
+ # Another part to join.
85
+ #
86
+ # @param sep [String]
87
+ # Part separator.
88
+ #
89
+ # @return [String]
90
+ def join(*part, sep: "/")
91
+ part.reject { |s| s.nil? || s == "" }
92
+ .join(sep)
93
+ end
94
+ end
95
+ end
data/lib/rutter/route.rb CHANGED
@@ -1,168 +1,111 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
3
+ require "mustermann"
4
4
 
5
5
  module Rutter
6
6
  # Represents a single route.
7
7
  #
8
- # @attr_reader method [String]
9
- # Request method to match.
10
- # @attr_reader path [String]
11
- # Path to match.
12
- # @attr_reader endpoint [Hash]
13
- # Route endpoint.
8
+ # @!attribute [r] path
9
+ # @return [String] Raw path template.
10
+ # @!attribute [r] endpoint
11
+ # @return [Hash] Route endpoint.
12
+ #
13
+ # @private
14
14
  class Route
15
- # Valid request verbs.
16
- VERBS = %w[GET POST PUT PATCH DELETE OPTIONS HEAD TRACE].freeze
17
-
18
- attr_reader :method, :path, :endpoint
15
+ attr_reader :path
16
+ attr_reader :endpoint
19
17
 
20
18
  # Initializes the route.
21
19
  #
22
- # @param method [Symbol, String]
23
- # Requets method to match.
24
20
  # @param path [String]
25
- # Path template.
26
- # @param endpoint [String, Proc, Hash]
27
- # Route endpoint.
28
- # @param controller_suffix [String, false]
29
- # Suffix for string controllers.
30
- #
31
- # @return [self]
21
+ # Path template to match.
22
+ # @param endpoint [#call]
23
+ # Rack endpoint.
24
+ # @param constraints [Hash]
25
+ # Route segment constraints.
32
26
  #
33
- # @raise [ArgumentError]
34
- # If request method is unknown.
27
+ # @return [void]
35
28
  #
36
29
  # @private
37
- def initialize(method, path, endpoint, controller_suffix: nil)
38
- @method = method.to_s.upcase
39
-
40
- unless VERBS.include?(method)
41
- raise ArgumentError, "invalid verb: '#{method}'"
42
- end
43
-
44
- @controller_suffix = controller_suffix || ""
45
- @path = normalize_path(path)
46
- @dynamic = false
47
- @pattern = path_to_pattern(@path).freeze
48
- @endpoint = build_endpoint_hash(endpoint).freeze
49
-
30
+ def initialize(path, endpoint, constraints = nil)
31
+ @path = Naming.cleanpath(path)
32
+ @endpoint = endpoint_to_hash(endpoint)
33
+ @pattern = ::Mustermann.new(@path, capture: constraints)
50
34
  freeze
51
35
  end
52
36
 
53
- # Matches the given path to the route pattern.
37
+ # Matches the route pattern against environment.
54
38
  #
55
- # @param path_info [String]
56
- # Requested path to match against.
39
+ # @param env [Hash]
40
+ # Rack environment hash.
57
41
  #
58
42
  # @return [Boolean]
59
- def match?(path_info)
60
- @dynamic ? path_info.match?(@pattern) : path_info == @path
43
+ def match?(env)
44
+ @pattern === env["PATH_INFO"] # rubocop:disable Style/CaseEquality
61
45
  end
62
46
 
63
- # Extract params from the given path.
47
+ # Generates a path from the given arguments.
64
48
  #
65
- # NOTE: If the path does not match, `nil` is returned.
49
+ # @overload expand(key: value)
50
+ # @param key [String, Integer, Array]
51
+ # Key value.
52
+ # @overload expand(key: value, key2: value2)
53
+ # @param key2 [String, Integer, Array]
54
+ # Key value.
66
55
  #
67
- # @param path [String]
68
- # Requested path to extract params from.
56
+ # @return [String]
57
+ # Generated path.
69
58
  #
70
- # @return [nil, Hash<String => String>]
71
- def params(path)
72
- return unless (result = path.match(@pattern))
73
- values = result.captures.map { |v| CGI.unescape(v) if v }
74
- Hash[result.names.zip(values)]
59
+ # @raise [ArguemntError]
60
+ # If the path cannot be generated. Mostly due to missing key(s).
61
+ def expand(**args)
62
+ @pattern.expand(:append, **args)
63
+ rescue ::Mustermann::ExpandError => e
64
+ raise ArgumentError, e.message
75
65
  end
76
66
 
77
- # Expands the pattern with the given arguments.
78
- #
79
- # @param params [Hash]
80
- # Expand parameters.
81
- #
82
- # @return [String] Expanded string.
83
- #
84
- # @example basic example
85
- # route = Route.new("GET", "/books/:id/:title", -> {})
86
- #
87
- # route.expand(id: 54, title: "eloquent-ruby")
88
- # # => "/books/54/eloquent-ruby"
67
+ # Extract params from the given path.
89
68
  #
90
- # @example with query
91
- # route = Route.new("GET", "/books/:id/reviews", -> {})
69
+ # @param path [String]
70
+ # Path used to extract params from.
92
71
  #
93
- # route.expand(id: 54, status: "approved")
94
- # # => "/books/54/reviews?status=approved"
95
- def expand(params = {})
96
- string = if @dynamic
97
- new_path = path.gsub(SEGMENT_MATCH) do |match|
98
- params.delete(match[1..-1].to_sym)
99
- end
100
- new_path.gsub(/\(|\)/, "")
101
- else
102
- path.dup
103
- end
104
-
105
- string = normalize_path(string)
106
- return string if params.empty?
107
-
108
- "#{string}?#{Rack::Utils.build_nested_query(params)}"
72
+ # @return [Hash]
73
+ def params(path)
74
+ @pattern.params(path)
109
75
  end
110
76
 
111
- private
112
-
113
- # Segment for dynamic parts.
77
+ # Calls the endpoint.
114
78
  #
115
- # @private
116
- DYNAMIC_SEGMENT = "[^/.,;?]+"
117
-
118
- # Segment for wildcards.
79
+ # @param env [Hash]
80
+ # Rack's environment hash.
119
81
  #
120
- # @private
121
- WILDCARD_SEGMENT = ".*"
122
-
123
- # Match segment regexp.
82
+ # @return [Array]
83
+ # Rack response array.
124
84
  #
125
85
  # @private
126
- SEGMENT_MATCH = /((:|\*)[\w]+)/
127
-
128
- # @private
129
- def path_to_pattern(path)
130
- pattern = path.dup
131
-
132
- if pattern.include?("(")
133
- @dynamic = true
134
- pattern = pattern.gsub("(", "(?:").gsub(")", ")?")
135
- end
136
-
137
- pattern = pattern.gsub(SEGMENT_MATCH) do |match|
138
- @dynamic = true
139
- segment = match[0] == ":" ? DYNAMIC_SEGMENT : WILDCARD_SEGMENT
140
- "(?<#{match[1..-1]}>#{segment})"
141
- end
142
-
143
- /\A#{pattern}\z/
86
+ def call(env)
87
+ env["rutter.params"] ||= {}
88
+ env["rutter.params"].merge!(params(env["PATH_INFO"]))
89
+ env["rutter.action"] = @endpoint[:action]
90
+
91
+ ctrl = @endpoint[:controller]
92
+ ctrl = ::Object.const_get(ctrl) if ctrl.is_a?(String)
93
+ ctrl.call(env)
144
94
  end
145
95
 
146
- # @private
147
- def normalize_path(path)
148
- path = path.gsub(%r{[\/]{2,}}, "/")
149
- .gsub(%r{\A[\/]+|[\/]+\z}, "")
150
- .downcase
151
-
152
- "/#{path}"
153
- end
96
+ private
154
97
 
155
98
  # @private
156
- def build_endpoint_hash(endpoint)
157
- if endpoint.is_a?(String)
158
- ctrl, action = endpoint.gsub(/[:]{3,}/, "::")
159
- .gsub(/\A[:]+|[:]+\z/, "")
160
- .split("#")
161
-
162
- { controller: "#{ctrl}#{@controller_suffix}", action: action }
163
- else
164
- { controller: endpoint, action: nil }
165
- end
99
+ def endpoint_to_hash(endpoint)
100
+ ctrl, action = if endpoint.is_a?(String)
101
+ ctrl, action = endpoint.split("#")
102
+ ctrl = Naming.classify(ctrl)
103
+ [ctrl, action]
104
+ else
105
+ [endpoint, nil]
106
+ end
107
+
108
+ { controller: ctrl, action: action }
166
109
  end
167
110
  end
168
111
  end
data/lib/rutter/routes.rb CHANGED
@@ -3,24 +3,51 @@
3
3
  require "forwardable"
4
4
 
5
5
  module Rutter
6
- # Routes helper.
6
+ # Routes URL builder.
7
+ #
8
+ # @see Rutter::Builder#path
9
+ # @see Rutter::Builder#url
10
+ #
11
+ # @example
12
+ # router = Rutter.new(base: "http://rutter.org") do
13
+ # get "/login", to: "sessions#new", as: :login
14
+ # get "/books/:id", to: "books#show", as: :book
15
+ # end.freeze
16
+ #
17
+ # routes = Rutter::Routes.new(router)
18
+ #
19
+ # routes.login_path
20
+ # # => "/login"
21
+ # routes.login_path(return_to: "/")
22
+ # # => "/login?return_to=/"
23
+ # routes.book_path(id: 82)
24
+ # # => "/books/82"
25
+ #
26
+ # routes.login_url
27
+ # # => "http://rutter.org/login"
28
+ # routes.login_url(return_to: "/")
29
+ # # => "http://rutter.org/login?return_to=/"
30
+ # routes.book_url(id: 82)
31
+ # # => "http://rutter.org/books/82"
7
32
  class Routes
8
33
  extend Forwardable
9
34
 
10
- # Delegate path and url method to the builder.
35
+ # Delegate path and url method to the router.
11
36
  #
12
37
  # @see Rutter::Builder#path
13
38
  # @see Rutter::Builder#url
14
- def_delegators :@builder, :path, :url
39
+ def_delegators :@router, :path, :url
15
40
 
16
41
  # Initializes the helper.
17
42
  #
18
- # @param builder [Rutter::Builder]
19
- # Route builder object.
43
+ # @param router [Rutter::Builder]
44
+ # Route router object.
20
45
  #
21
- # @return [self]
22
- def initialize(builder)
23
- @builder = builder
46
+ # @return [void]
47
+ #
48
+ # @private
49
+ def initialize(router)
50
+ @router = router
24
51
  end
25
52
 
26
53
  protected
@@ -29,7 +56,7 @@ module Rutter
29
56
  def method_missing(method_name, *args)
30
57
  named_route, type = method_name.to_s.split(/\_(path|url)\z/)
31
58
  return super unless type
32
- @builder.public_send(type, named_route.to_sym, *args)
59
+ @router.public_send(type, named_route.to_sym, *args)
33
60
  end
34
61
 
35
62
  # @private
data/lib/rutter/scope.rb CHANGED
@@ -1,67 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rutter
4
- # Scoped set of routes.
4
+ # Represents a scoped set.
5
+ #
6
+ # @see Rutter::Builder#scope
7
+ #
8
+ # @private
5
9
  class Scope
6
10
  # Initializes the scope.
7
11
  #
8
12
  # @param router [Rutter::Builder]
9
- # Root router object.
10
- # @option opts [String] :path (nil)
11
- # Path prefix
12
- # @option opts [String] :namespace (nil)
13
- # Namespace prefix
14
- # @option opts [String, Symbol] :as (nil)
15
- # Name prefix
13
+ # Router object.
14
+ # @param path [String]
15
+ # Scope path prefix.
16
+ # @param namespace [String, Symbol]
17
+ # Scope namespace.
18
+ # @param as [Symbol]
19
+ # Scope name prefix.
20
+ #
16
21
  # @yield
17
- # Scope context.
22
+ # Block is evaluated inside the created scope context.
18
23
  #
19
- # @return [self]
24
+ # @return [void]
20
25
  #
21
26
  # @private
22
- def initialize(router, **opts, &block)
27
+ def initialize(router, path: nil, namespace: nil, as: nil, &block)
23
28
  @router = router
24
- @path_prefix = opts[:path]
25
- @namespace_prefix = opts[:namespace]
26
- @name_prefix = opts[:as]
29
+ @path = path
30
+ @namespace = namespace
31
+ @as = as
27
32
 
28
33
  instance_eval(&block) if block_given?
29
34
  end
30
35
 
31
- # @see Rutter::Builder#scope
32
- def scope(**opts, &block)
33
- opts[:as] = "#{@name_prefix}_#{opts[:as]}" if @name_prefix
34
- opts[:path] = "#{@path_prefix}/#{opts[:path]}" if @path_prefix
35
-
36
- if @namespace_prefix
37
- opts[:namespace] = "#{@namespace_prefix}::#{opts[:namespace]}"
38
- end
39
-
40
- Scope.new(@router, **opts, &block)
36
+ # @see Rutter::Builder#mount
37
+ def mount(app, at:)
38
+ @router.mount app, at: Naming.join(@path, at)
41
39
  end
42
40
 
43
- # @see Rutter::Builder#mount
44
- def mount(app, at:, **opts)
45
- at = "#{@path_prefix}/#{at}" if @path_prefix
46
- @router.mount app, at: at, **opts
41
+ # @see Rutter::Builder#scope
42
+ def scope(path: nil, namespace: nil, as: nil, &block)
43
+ Scope.new(self, path: path, namespace: namespace, as: as, &block)
47
44
  end
48
45
 
49
- # @see Rutter::Builder#root
50
- def root(to:, as: :root)
51
- get "/", to: to, as: as
46
+ # @see Rutter::Builder#namespace
47
+ def namespace(name, &block)
48
+ scope path: name, namespace: name, as: name, &block
52
49
  end
53
50
 
54
51
  # @see Rutter::Builder#add
55
- Route::VERBS.each do |verb|
56
- verb_method = verb.downcase
57
- define_method verb_method do |path, **opts|
58
- path = "#{@path_prefix}/#{path}" if @path_prefix
59
- opts[:as] = "#{@name_prefix}_#{opts[:as]}" if opts[:as]
60
- if @namespace_prefix && opts[:to].is_a?(String)
61
- opts[:to] = "#{@namespace_prefix}::#{opts[:to]}"
62
- end
52
+ def add(verb, path, to:, as: nil, constraints: nil)
53
+ path = Naming.join(@path, path)
54
+ to = Naming.join(@namespace, to) if to.is_a?(String)
55
+ as = Naming.join(@as, as) if as
63
56
 
64
- @router.public_send verb_method, path, **opts
57
+ @router.add verb, path, to: to, as: as, constraints: constraints
58
+ end
59
+
60
+ # @see Rutter::Builder#add
61
+ VERBS.each do |verb|
62
+ define_method verb.downcase do |path, to:, as: nil, constraints: nil|
63
+ add verb, path, to: to, as: as, constraints: constraints
65
64
  end
66
65
  end
67
66
  end