grape-path-helpers 1.0.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,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