grape-path-helpers 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,16 @@
1
+ require 'grape'
2
+ require 'active_support'
3
+ require 'active_support/core_ext/class'
4
+
5
+ require 'grape-path-helpers/decorated_route'
6
+ require 'grape-path-helpers/named_route_matcher'
7
+ require 'grape-path-helpers/all_routes'
8
+ require 'grape-path-helpers/route_displayer'
9
+
10
+ # Load the Grape route helper for Rails
11
+ module GrapePathHelpers
12
+ require 'grape-path-helpers/railtie' if defined?(Rails)
13
+ end
14
+
15
+ Grape::API.extend GrapePathHelpers::AllRoutes
16
+ Grape::Endpoint.send(:include, GrapePathHelpers::NamedRouteMatcher)
@@ -0,0 +1,18 @@
1
+ module GrapePathHelpers
2
+ # methods to extend Grape::API's behavior so it can get a
3
+ # list of routes from all APIs and decorate them with
4
+ # the DecoratedRoute class
5
+ module AllRoutes
6
+ def decorated_routes
7
+ # memoize so that construction of decorated routes happens once
8
+ @decorated_routes ||= all_routes
9
+ .map { |r| DecoratedRoute.new(r) }
10
+ .sort_by { |r| -r.dynamic_path_segments.count }
11
+ end
12
+
13
+ def all_routes
14
+ routes = subclasses.flat_map { |s| s.send(:prepare_routes) }
15
+ routes.uniq { |r| r.options.merge(path: r.path) }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,168 @@
1
+ module GrapePathHelpers
2
+ # wrapper around Grape::Route that adds a helper method
3
+ class DecoratedRoute
4
+ attr_reader :route, :helper_names, :helper_arguments,
5
+ :extension, :route_options
6
+
7
+ def self.sanitize_method_name(string)
8
+ string.gsub(/\W|^[0-9]/, '_')
9
+ end
10
+
11
+ def initialize(route)
12
+ @route = route
13
+ @route_options = route.options
14
+ @helper_names = []
15
+ @helper_arguments = required_helper_segments
16
+ @extension = default_extension
17
+ define_path_helpers
18
+ end
19
+
20
+ def default_extension
21
+ pattern = /\((\.\:?\w+)\)$/
22
+ match = route_path.match(pattern)
23
+ return '' unless match
24
+ ext = match.captures.first
25
+ if ext == '.:format'
26
+ ''
27
+ else
28
+ ext
29
+ end
30
+ end
31
+
32
+ def define_path_helpers
33
+ route_versions.each do |version|
34
+ route_attributes = { version: version, format: extension }
35
+ method_name = path_helper_name(route_attributes)
36
+ @helper_names << method_name
37
+ define_path_helper(method_name, route_attributes)
38
+ end
39
+ end
40
+
41
+ def define_path_helper(method_name, route_attributes)
42
+ method_body = <<-RUBY
43
+ def #{method_name}(attributes = {})
44
+ attrs = #{route_attributes}.merge(attributes)
45
+
46
+ query_params = attrs.delete(:params)
47
+ content_type = attrs.delete(:format)
48
+ path = '/' + path_segments_with_values(attrs).join('/')
49
+
50
+ path + content_type + query_string(query_params)
51
+ end
52
+ RUBY
53
+ instance_eval method_body
54
+ end
55
+
56
+ def query_string(params)
57
+ if params.nil?
58
+ ''
59
+ else
60
+ '?' + params.to_param
61
+ end
62
+ end
63
+
64
+ def route_versions
65
+ if route_version.is_a?(Array)
66
+ route_version
67
+ elsif route_version
68
+ version_pattern = /[^\[",\]\s]+/
69
+ route_version.scan(version_pattern)
70
+ else
71
+ [nil]
72
+ end
73
+ end
74
+
75
+ def path_helper_name(opts = {})
76
+ if route_options[:as]
77
+ name = route_options[:as].to_s
78
+ else
79
+ segments = path_segments_with_values(opts)
80
+
81
+ name = if segments.empty?
82
+ 'root'
83
+ else
84
+ segments.join('_')
85
+ end
86
+ end
87
+
88
+ sanitized_name = self.class.sanitize_method_name(name)
89
+ sanitized_name + '_path'
90
+ end
91
+
92
+ def segment_to_value(segment, opts = {})
93
+ if dynamic_segment?(segment)
94
+ options = route.options.merge(stringify_keys(opts))
95
+ key = segment.slice(1..-1).to_sym
96
+ options[key]
97
+ else
98
+ segment
99
+ end
100
+ end
101
+
102
+ def path_segments_with_values(opts)
103
+ segments = path_segments.map { |s| segment_to_value(s, opts) }
104
+ segments.reject(&:blank?)
105
+ end
106
+
107
+ def path_segments
108
+ pattern = %r{\(/?\.:?\w+\)|/|\??\*}
109
+ route_path.split(pattern).reject(&:blank?)
110
+ end
111
+
112
+ def dynamic_path_segments
113
+ segments = path_segments.select do |segment|
114
+ dynamic_segment?(segment)
115
+ end
116
+ segments.map { |s| s.slice(1..-1) }
117
+ end
118
+
119
+ def dynamic_segment?(segment)
120
+ segment.start_with?(':')
121
+ end
122
+
123
+ def required_helper_segments
124
+ segments_in_options = dynamic_path_segments.select do |segment|
125
+ route.options[segment.to_sym]
126
+ end
127
+ dynamic_path_segments - segments_in_options
128
+ end
129
+
130
+ def special_keys
131
+ %w[format params]
132
+ end
133
+
134
+ def uses_segments_in_path_helper?(segments)
135
+ segments = segments.reject { |x| special_keys.include?(x) }
136
+
137
+ if required_helper_segments.empty? && segments.any?
138
+ false
139
+ else
140
+ required_helper_segments.all? { |x| segments.include?(x) }
141
+ end
142
+ end
143
+
144
+ def route_path
145
+ route.path
146
+ end
147
+
148
+ def route_version
149
+ route.version
150
+ end
151
+
152
+ def route_namespace
153
+ route.namespace
154
+ end
155
+
156
+ def route_method
157
+ route.request_method
158
+ end
159
+
160
+ private
161
+
162
+ def stringify_keys(original)
163
+ original.each_with_object({}) do |(key, value), hash|
164
+ hash[key.to_sym] = value
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,36 @@
1
+ module GrapePathHelpers
2
+ # methods to extend Grape::Endpoint so that calls
3
+ # to unknown methods will look for a route with a matching
4
+ # helper function name
5
+ module NamedRouteMatcher
6
+ def method_missing(method_id, *arguments)
7
+ super unless method_id.to_s =~ /_path$/
8
+ segments = arguments.first || {}
9
+
10
+ route = Grape::API.decorated_routes.detect do |r|
11
+ route_match?(r, method_id, segments)
12
+ end
13
+
14
+ if route
15
+ route.send(method_id, *arguments)
16
+ else
17
+ super
18
+ end
19
+ end
20
+
21
+ def respond_to_missing?(method_name, _include_private = false)
22
+ Grape::API.decorated_routes.detect do |r|
23
+ route_match?(r, method_name, {})
24
+ end
25
+ end
26
+
27
+ def route_match?(route, method_name, segments)
28
+ return false unless route.respond_to?(method_name)
29
+ # rubocop:disable Metrics/LineLength
30
+ raise ArgumentError, 'Helper options must be a hash' unless segments.is_a?(Hash)
31
+ # rubocop:enable Metrics/LineLength
32
+ requested_segments = segments.keys.map(&:to_s)
33
+ route.uses_segments_in_path_helper?(requested_segments)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ module GrapePathHelpers
2
+ # Pulls in Rake helper for displaying route helper methods
3
+ class Railtie < Rails::Railtie
4
+ rake_tasks do
5
+ files = File.join(File.dirname(__FILE__), '../tasks/*.rake')
6
+ Dir[files].each { |f| load f }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ module GrapePathHelpers
2
+ # class for displaying the path, helper method name,
3
+ # and required arguments for every Grape::Route.
4
+ class RouteDisplayer
5
+ def route_attributes
6
+ Grape::API.decorated_routes.map do |route|
7
+ {
8
+ route_path: route.route_path,
9
+ route_method: route.route_method,
10
+ helper_names: route.helper_names,
11
+ helper_arguments: route.helper_arguments
12
+ }
13
+ end
14
+ end
15
+
16
+ def display
17
+ puts("== GRAPE ROUTE HELPERS ==\n\n")
18
+ route_attributes.each do |attributes|
19
+ printf("%s: %s\n", 'Verb', attributes[:route_method])
20
+ printf("%s: %s\n", 'Path', attributes[:route_path])
21
+ printf("%s: %s\n",
22
+ 'Helper Method',
23
+ attributes[:helper_names].join(', '))
24
+ printf("%s: %s\n",
25
+ 'Arguments',
26
+ attributes[:helper_arguments].join(', '))
27
+ puts("\n")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1 @@
1
+ Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f }
@@ -0,0 +1,4 @@
1
+ # Gem version
2
+ module GrapePathHelpers
3
+ VERSION = '1.0.0'.freeze
4
+ end
@@ -0,0 +1 @@
1
+ require 'grape-path-helpers'
@@ -0,0 +1,6 @@
1
+ namespace :grape do
2
+ desc 'Print route helper methods.'
3
+ task route_helpers: :environment do
4
+ GrapePathHelpers::RouteDisplayer.new.display
5
+ end
6
+ end
@@ -0,0 +1,37 @@
1
+ require 'spec_helper'
2
+
3
+ describe GrapePathHelpers::AllRoutes do
4
+ Grape::API.extend described_class
5
+
6
+ describe '#all_routes' do
7
+ context 'when API is mounted within another API' do
8
+ let(:mounting_api) { Spec::Support::MountedAPI }
9
+
10
+ it 'does not include the same route twice' do
11
+ mounting_api
12
+
13
+ # A route is unique if no other route shares the same set of options
14
+ all_route_options = Grape::API.all_routes.map do |r|
15
+ r.instance_variable_get(:@options).merge(path: r.path)
16
+ end
17
+
18
+ duplicates = all_route_options.select do |o|
19
+ all_route_options.count(o) > 1
20
+ end
21
+
22
+ expect(duplicates).to be_empty
23
+ end
24
+ end
25
+
26
+ # rubocop:disable Metrics/LineLength
27
+ context 'when there are multiple POST routes with the same namespace in the same API' do
28
+ it 'returns all POST routes' do
29
+ expected_routes = Spec::Support::MultiplePostsAPI.routes.map(&:path)
30
+
31
+ all_routes = Grape::API.all_routes
32
+ expect(all_routes.map(&:path)).to include(*expected_routes)
33
+ end
34
+ end
35
+ # rubocop:enable Metrics/LineLength
36
+ end
37
+ end
@@ -0,0 +1,241 @@
1
+ require 'spec_helper'
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ describe GrapePathHelpers::DecoratedRoute do
5
+ let(:api) { Spec::Support::API }
6
+
7
+ let(:routes) do
8
+ api.routes.map do |route|
9
+ described_class.new(route)
10
+ end
11
+ end
12
+
13
+ let(:index_route) do
14
+ routes.detect { |route| route.route_namespace == '/cats' }
15
+ end
16
+
17
+ let(:show_route) do
18
+ routes.detect { |route| route.route_namespace == '/cats/:id' }
19
+ end
20
+
21
+ let(:catch_all_route) do
22
+ routes.detect { |route| route.route_path =~ /\*/ }
23
+ end
24
+
25
+ let(:custom_route) do
26
+ routes.detect { |route| route.route_path =~ /custom_name/ }
27
+ end
28
+
29
+ let(:ping_route) do
30
+ routes.detect { |route| route.route_path =~ /ping/ }
31
+ end
32
+
33
+ describe '#sanitize_method_name' do
34
+ it 'removes characters that are illegal in Ruby method names' do
35
+ illegal_names = ['beta-1', 'name_with_+', 'name_with_(']
36
+ sanitized = illegal_names.map do |name|
37
+ described_class.sanitize_method_name(name)
38
+ end
39
+ expect(sanitized).to match_array(%w[beta_1 name_with__ name_with__])
40
+ end
41
+
42
+ it 'only replaces integers if they appear at the beginning' do
43
+ illegal_name = '1'
44
+ legal_name = 'v1'
45
+ expect(described_class.sanitize_method_name(illegal_name)).to eq('_')
46
+ expect(described_class.sanitize_method_name(legal_name)).to eq('v1')
47
+ end
48
+ end
49
+
50
+ describe '#helper_names' do
51
+ context 'when a route is given a custom helper name' do
52
+ it 'uses the custom name instead of the dynamically generated one' do
53
+ expect(custom_route.helper_names.first)
54
+ .to eq('my_custom_route_name_path')
55
+ end
56
+
57
+ it 'returns the correct path' do
58
+ expect(
59
+ custom_route.my_custom_route_name_path
60
+ ).to eq('/api/v1/custom_name.json')
61
+ end
62
+ end
63
+
64
+ context 'when an API has multiple POST routes in a resource' do
65
+ let(:api) { Spec::Support::MultiplePostsAPI }
66
+
67
+ it 'it creates a helper for each POST route' do
68
+ expect(routes.size).to eq(2)
69
+ end
70
+ end
71
+
72
+ context 'when an API has multiple versions' do
73
+ let(:api) { Spec::Support::APIWithMultipleVersions }
74
+
75
+ it "returns the route's helper name for each version" do
76
+ helper_names = ping_route.helper_names
77
+ expect(helper_names.size).to eq(api.versions.size)
78
+ end
79
+
80
+ it 'returns an array of the routes versions' do
81
+ expect(ping_route.route_version).to eq(%w[beta alpha v1])
82
+ end
83
+ end
84
+
85
+ context 'when an API has one version' do
86
+ it "returns the route's helper name for that version" do
87
+ helper_name = show_route.helper_names.first
88
+ expect(helper_name).to eq('api_v1_cats_path')
89
+ end
90
+ end
91
+ end
92
+
93
+ describe '#helper_arguments' do
94
+ context 'when no user input is needed to generate the correct path' do
95
+ it 'returns an empty array' do
96
+ expect(index_route.helper_arguments).to eq([])
97
+ end
98
+ end
99
+
100
+ context 'when user input is needed to generate the correct path' do
101
+ it 'returns an array of required segments' do
102
+ expect(show_route.helper_arguments).to eq(['id'])
103
+ end
104
+ end
105
+ end
106
+
107
+ describe '#path_segments_with_values' do
108
+ context 'when path has dynamic segments' do
109
+ it 'replaces segments with corresponding values found in @options' do
110
+ opts = { id: 1 }
111
+ result = show_route.path_segments_with_values(opts)
112
+ expect(result).to include(1)
113
+ end
114
+
115
+ context 'when options contains string keys' do
116
+ it 'replaces segments with corresponding values found in the options' do
117
+ opts = { 'id' => 1 }
118
+ result = show_route.path_segments_with_values(opts)
119
+ expect(result).to include(1)
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ describe '#path_helper_name' do
126
+ it "returns the name of a route's helper method" do
127
+ expect(index_route.path_helper_name).to eq('api_v1_cats_path')
128
+ end
129
+
130
+ context 'when the path is the root path' do
131
+ let(:api_with_root) do
132
+ Class.new(Grape::API) do
133
+ get '/' do
134
+ end
135
+ end
136
+ end
137
+
138
+ let(:root_route) do
139
+ grape_route = api_with_root.routes.first
140
+ described_class.new(grape_route)
141
+ end
142
+
143
+ it 'returns "root_path"' do
144
+ result = root_route.path_helper_name
145
+ expect(result).to eq('root_path')
146
+ end
147
+ end
148
+
149
+ context 'when the path is a catch-all path' do
150
+ it 'returns a name without the glob star' do
151
+ result = catch_all_route.path_helper_name
152
+ expect(result).to eq('api_v1_path_path')
153
+ end
154
+ end
155
+ end
156
+
157
+ describe '#segment_to_value' do
158
+ context 'when segment is dynamic' do
159
+ it 'returns the value the segment corresponds to' do
160
+ result = index_route.segment_to_value(':version')
161
+ expect(result).to eq('v1')
162
+ end
163
+
164
+ context 'when segment is found in options' do
165
+ it 'returns the value found in options' do
166
+ options = { id: 1 }
167
+ result = show_route.segment_to_value(':id', options)
168
+ expect(result).to eq(1)
169
+ end
170
+ end
171
+ end
172
+
173
+ context 'when segment is static' do
174
+ it 'returns the segment' do
175
+ result = index_route.segment_to_value('api')
176
+ expect(result).to eq('api')
177
+ end
178
+ end
179
+ end
180
+
181
+ describe 'path helper method' do
182
+ context 'when given a "params" key' do
183
+ context 'when value under "params" key is a hash' do
184
+ it 'creates a query string' do
185
+ query = { foo: :bar, baz: :zot }
186
+ path = index_route.api_v1_cats_path(params: query)
187
+ expect(path).to eq('/api/v1/cats.json?' + query.to_param)
188
+ end
189
+ end
190
+
191
+ context 'when value under "params" is not a hash' do
192
+ it 'coerces the value into a string' do
193
+ path = index_route.api_v1_cats_path(params: 1)
194
+ expect(path).to eq('/api/v1/cats.json?1')
195
+ end
196
+ end
197
+ end
198
+
199
+ # handle different Grape::Route#route_path formats in Grape 0.12.0
200
+ context 'when route_path contains a specific format' do
201
+ it 'returns the correct path with the correct format' do
202
+ path = index_route.api_v1_cats_path
203
+ expect(path).to eq('/api/v1/cats.json')
204
+ end
205
+ end
206
+
207
+ context 'when helper does not require arguments' do
208
+ it 'returns the correct path' do
209
+ path = index_route.api_v1_cats_path
210
+ expect(path).to eq('/api/v1/cats.json')
211
+ end
212
+ end
213
+
214
+ context 'when arguments are needed required to construct the right path' do
215
+ context 'when not missing arguments' do
216
+ it 'returns the correct path' do
217
+ path = show_route.api_v1_cats_path(id: 1)
218
+ expect(path).to eq('/api/v1/cats/1.json')
219
+ end
220
+ end
221
+ end
222
+
223
+ context "when a route's API has multiple versions" do
224
+ let(:api) { Spec::Support::APIWithMultipleVersions }
225
+
226
+ it 'returns a path for each version' do
227
+ expect(ping_route.alpha_ping_path).to eq('/alpha/ping')
228
+ expect(ping_route.beta_ping_path).to eq('/beta/ping')
229
+ expect(ping_route.v1_ping_path).to eq('/v1/ping')
230
+ end
231
+ end
232
+
233
+ context 'when a format is given' do
234
+ it 'returns the path with a correct extension' do
235
+ path = show_route.api_v1_cats_path(id: 1, format: '.xml')
236
+ expect(path).to eq('/api/v1/cats/1.xml')
237
+ end
238
+ end
239
+ end
240
+ end
241
+ # rubocop:enable Metrics/BlockLength