rutter 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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