sinatra-api 1.0.0 → 1.0.2

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.
data/.yardopts CHANGED
@@ -1 +1 @@
1
- --protected -m markdown -r README.md
1
+ --protected --private -m markdown -r README.md
@@ -22,18 +22,18 @@
22
22
  module Sinatra
23
23
  module API
24
24
  module Callbacks
25
- attr_accessor :api_callbacks
25
+ attr_accessor :callbacks
26
26
 
27
27
  def self.extended(base)
28
- base.api_callbacks = {}
28
+ base.callbacks = {}
29
29
  end
30
30
 
31
31
  def on(event, &callback)
32
- (self.api_callbacks[event.to_sym] ||= []) << callback
32
+ (self.callbacks[event.to_sym] ||= []) << callback
33
33
  end
34
34
 
35
35
  def trigger(event, *args)
36
- callbacks = self.api_callbacks[event.to_sym] || []
36
+ callbacks = self.callbacks[event.to_sym] || []
37
37
  callbacks.each do |callback|
38
38
  callback.call(*args)
39
39
  end
@@ -19,217 +19,11 @@
19
19
  # SOFTWARE.
20
20
  #
21
21
 
22
- module Sinatra
23
- # TODO: accept nested parameters
24
- module API
25
- public
26
-
27
- # attr_accessor :resource_aliases
28
-
29
- module Helpers
30
- def api_call?
31
- (request.accept || '').to_s.include?('json') ||
32
- (request.content_type||'').to_s.include?('json')
33
- end
34
-
35
- # Define the required API arguments map. Any item defined
36
- # not found in the supplied parameters of the API call will
37
- # result in a 400 RC with a proper message marking the missing
38
- # field.
39
- #
40
- # The map is a Hash of parameter keys and optional validator blocks.
41
- #
42
- # @example A map of required API call arguments
43
- # api_required!({ title: nil, user_id: nil })
44
- #
45
- # Each entry can be optionally mapped to a validation proc that will
46
- # be invoked *if* the field was supplied. The proc will be passed
47
- # the value of the field.
48
- #
49
- # If the value is invalid and you need to suspend the request, you
50
- # must return a String object with an appropriate error message.
51
- #
52
- # @example Rejecting a title if it's rude
53
- # api_required!({
54
- # :title => lambda { |t| return "Don't be rude" if t && t =~ /rude/ }
55
- # })
56
- #
57
- # @note
58
- # The supplied value passed to validation blocks is not pre-processed,
59
- # so you must make sure that you check for nils or bad values in validator blocks!
60
- def api_required!(args, h = params)
61
- args.each_pair { |name, cnd|
62
- if cnd.is_a?(Hash)
63
- api_required!(cnd, h[name])
64
- next
65
- end
66
-
67
- parse_api_argument(h, name, cnd, :required)
68
- }
69
- end
70
-
71
- # Same as #api_required! except that fields defined in this map
72
- # are optional and will be used only if they're supplied.
73
- #
74
- # @see #api_required!
75
- def api_optional!(args, h = params)
76
- args.each_pair { |name, cnd|
77
- if cnd.is_a?(Hash)
78
- api_optional!(cnd, h[name])
79
- next
80
- end
81
-
82
- parse_api_argument(h, name, cnd, :optional)
83
- }
84
- end
85
-
86
- # Consumes supplied parameters with the given keys from the API
87
- # parameter map, and yields the consumed values for processing by
88
- # the supplied block (if any).
89
- #
90
- # This is useful if:
91
- # 1. a certain parameter does not correspond to a model attribute
92
- # and needs to be renamed, or is used in a validation context
93
- # 2. the data needs special treatment
94
- # 3. the data needs to be (re)formatted
95
- #
96
- def api_consume!(keys)
97
- out = nil
98
-
99
- keys = [ keys ] unless keys.is_a?(Array)
100
- keys.each do |k|
101
- if val = @api[:required].delete(k.to_sym)
102
- out = val
103
- out = yield(val) if block_given?
104
- end
105
-
106
- if val = @api[:optional].delete(k.to_sym)
107
- out = val
108
- out = yield(val) if block_given?
109
- end
110
- end
111
-
112
- out
113
- end
114
-
115
- def api_transform!(key, &handler)
116
- if val = @api[:required][key.to_sym]
117
- @api[:required][key.to_sym] = yield(val) if block_given?
118
- end
119
-
120
- if val = @api[:optional][key.to_sym]
121
- @api[:optional][key.to_sym] = yield(val) if block_given?
122
- end
123
- end
124
-
125
- def api_has_param?(key)
126
- @api[:optional].has_key?(key)
127
- end
128
-
129
- def api_param(key)
130
- @api[:optional][key.to_sym] || @api[:required][key.to_sym]
131
- end
132
-
133
- # Returns a Hash of the *supplied* request parameters. Rejects
134
- # any parameter that was not defined in the REQUIRED or OPTIONAL
135
- # maps (or was consumed).
136
- #
137
- # @param Hash q A Hash of attributes to merge with the parameters,
138
- # useful for defining defaults
139
- def api_params(q = {})
140
- @api[:optional].deep_merge(@api[:required]).deep_merge(q)
141
- end
142
-
143
- def api_clear!()
144
- @api = { required: {}, optional: {} }
145
- end
146
-
147
- alias_method :api_reset!, :api_clear!
148
-
149
- # Attempt to locate a resource based on an ID supplied in a request parameter.
150
- #
151
- # If the param map contains a resource id (ie, :folder_id),
152
- # we attempt to locate and expose it to the route.
153
- #
154
- # A 404 is raised if:
155
- # 1. the scope is missing (@space for folder, @space or @folder for page)
156
- # 2. the resource couldn't be identified in its scope (@space or @folder)
157
- #
158
- # If the resources were located, they're accessible using @folder or @page.
159
- #
160
- # The route can be halted using the :requires => [] condition when it expects
161
- # a resource.
162
- #
163
- # @example using :requires to reject a request with an invalid @page
164
- # get '/folders/:folder_id/pages/:page_id', :requires => [ :page ] do
165
- # @page.show # page is good
166
- # @folder.show # so is its folder
167
- # end
168
- #
169
- def __api_locate_resource(r, container = nil)
170
-
171
- resource_id = params[r + '_id'].to_i
172
- rklass = r.camelize
173
-
174
- collection = case
175
- when container.nil?; eval "#{ResourcePrefix}#{rklass}"
176
- else; container.send("#{r.to_plural}")
177
- end
178
-
179
- puts "locating resource #{r} with id #{resource_id} from #{collection} [#{container}]"
180
-
181
- resource = collection.get(resource_id)
182
-
183
- if !resource
184
- m = "No such resource: #{rklass}##{resource_id}"
185
- if container
186
- m << " in #{container.class.name.to_s}##{container.id}"
187
- end
188
-
189
- halt 404, m
190
- end
191
-
192
- if respond_to?(:can?)
193
- unless can? :access, resource
194
- halt 403, "You do not have access to this #{rklass} resource."
195
- end
196
- end
197
-
198
- instance_variable_set('@'+r, resource)
199
-
200
- API.trigger :resource_located, resource, r
201
-
202
- API.aliases_for(r).each { |resource_alias|
203
- instance_variable_set('@'+resource_alias, resource)
204
- puts "API: resource #{rklass} exported to @#{resource_alias}"
205
- }
206
-
207
- resource
208
- end
209
-
210
- private
211
-
212
- def parse_api_argument(h = params, name, cnd, type)
213
- cnd ||= lambda { |*_| true }
214
- name = name.to_s
215
-
216
- unless [:required, :optional].include?(type)
217
- raise ArgumentError, 'API Argument type must be either :required or :optional'
218
- end
219
-
220
- if !h.has_key?(name)
221
- if type == :required
222
- halt 400, "Missing required parameter :#{name}"
223
- end
224
- else
225
- if cnd.respond_to?(:call)
226
- errmsg = cnd.call(h[name])
227
- halt 400, { :"#{name}" => errmsg } if errmsg && errmsg.is_a?(String)
228
- end
229
-
230
- @api[type][name.to_sym] = h[name]
231
- end
232
- end
22
+ module Sinatra::API
23
+ module Helpers
24
+ def api_call?
25
+ (request.accept || '').to_s.include?('json') ||
26
+ (request.content_type||'').to_s.include?('json')
233
27
  end
234
28
  end
235
29
  end
@@ -0,0 +1,167 @@
1
+ # Copyright (c) 2013 Algol Labs, LLC. <dev@algollabs.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a
4
+ # copy of this software and associated documentation files (the "Software"),
5
+ # to deal in the Software without restriction, including without limitation
6
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
+ # and/or sell copies of the Software, and to permit persons to whom the
8
+ # Software is furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ #
21
+
22
+ module Sinatra::API
23
+ # API for defining parameters an endpoint requires or accepts, their types,
24
+ # and optional validators.
25
+ #
26
+ # TODO: accept nested parameters
27
+ module Parameters
28
+ # Define the required API arguments map. Any item defined
29
+ # not found in the supplied parameters of the API call will
30
+ # result in a 400 RC with a proper message marking the missing
31
+ # field.
32
+ #
33
+ # The map is a Hash of parameter keys and optional validator blocks.
34
+ #
35
+ # @example A map of required API call arguments
36
+ # api_required!({ title: nil, user_id: nil })
37
+ #
38
+ # Each entry can be optionally mapped to a validation proc that will
39
+ # be invoked *if* the field was supplied. The proc will be passed
40
+ # the value of the field.
41
+ #
42
+ # If the value is invalid and you need to suspend the request, you
43
+ # must return a String object with an appropriate error message.
44
+ #
45
+ # @example Rejecting a title if it's rude
46
+ # api_required!({
47
+ # :title => lambda { |t| return "Don't be rude" if t && t =~ /rude/ }
48
+ # })
49
+ #
50
+ # @note
51
+ # The supplied value passed to validation blocks is not pre-processed,
52
+ # so you must make sure that you check for nils or bad values in validator blocks!
53
+ def api_required!(args, h = params)
54
+ args.each_pair do |name, cnd|
55
+ if cnd.is_a?(Hash)
56
+ api_required!(cnd, h[name])
57
+ next
58
+ end
59
+
60
+ parse_api_argument(h, name, cnd, :required)
61
+ end
62
+ end
63
+
64
+ # Same as #api_required! except that fields defined in this map
65
+ # are optional and will be used only if they're supplied.
66
+ #
67
+ # @see #api_required!
68
+ def api_optional!(args, h = params)
69
+ args.each_pair { |name, cnd|
70
+ if cnd.is_a?(Hash)
71
+ api_optional!(cnd, h[name])
72
+ next
73
+ end
74
+
75
+ parse_api_argument(h, name, cnd, :optional)
76
+ }
77
+ end
78
+
79
+ # Consumes supplied parameters with the given keys from the API
80
+ # parameter map, and yields the consumed values for processing by
81
+ # the supplied block (if any).
82
+ #
83
+ # This is useful if:
84
+ # 1. a certain parameter does not correspond to a model attribute
85
+ # and needs to be renamed, or is used in a validation context
86
+ # 2. the data needs special treatment
87
+ # 3. the data needs to be (re)formatted
88
+ #
89
+ def api_consume!(keys)
90
+ out = nil
91
+
92
+ keys = [ keys ] unless keys.is_a?(Array)
93
+ keys.each do |k|
94
+ if val = @api[:required].delete(k.to_sym)
95
+ out = val
96
+ out = yield(val) if block_given?
97
+ end
98
+
99
+ if val = @api[:optional].delete(k.to_sym)
100
+ out = val
101
+ out = yield(val) if block_given?
102
+ end
103
+ end
104
+
105
+ out
106
+ end
107
+
108
+ def api_transform!(key, &handler)
109
+ if val = @api[:required][key.to_sym]
110
+ @api[:required][key.to_sym] = yield(val) if block_given?
111
+ end
112
+
113
+ if val = @api[:optional][key.to_sym]
114
+ @api[:optional][key.to_sym] = yield(val) if block_given?
115
+ end
116
+ end
117
+
118
+ def api_has_param?(key)
119
+ @api[:optional].has_key?(key)
120
+ end
121
+
122
+ def api_param(key)
123
+ @api[:optional][key.to_sym] || @api[:required][key.to_sym]
124
+ end
125
+
126
+ # Returns a Hash of the *supplied* request parameters. Rejects
127
+ # any parameter that was not defined in the REQUIRED or OPTIONAL
128
+ # maps (or was consumed).
129
+ #
130
+ # @param [Hash] q
131
+ # A Hash of attributes to merge with the parameters, useful for defining
132
+ # defaults.
133
+ def api_params(q = {})
134
+ @api[:optional].deep_merge(@api[:required]).deep_merge(q)
135
+ end
136
+
137
+ def api_clear!()
138
+ @api = { required: {}, optional: {} }
139
+ end
140
+
141
+ alias_method :api_reset!, :api_clear!
142
+
143
+ private
144
+
145
+ def parse_api_argument(h = params, name, cnd, type)
146
+ cnd ||= lambda { |*_| true }
147
+ name = name.to_s
148
+
149
+ unless [:required, :optional].include?(type)
150
+ raise ArgumentError, 'API Argument type must be either :required or :optional'
151
+ end
152
+
153
+ if !h.has_key?(name)
154
+ if type == :required
155
+ halt 400, "Missing required parameter :#{name}"
156
+ end
157
+ else
158
+ if cnd.respond_to?(:call)
159
+ errmsg = cnd.call(h[name])
160
+ halt 400, { :"#{name}" => errmsg } if errmsg && errmsg.is_a?(String)
161
+ end
162
+
163
+ @api[type][name.to_sym] = h[name]
164
+ end
165
+ end
166
+ end
167
+ end
@@ -5,11 +5,7 @@ module Sinatra
5
5
 
6
6
  def self.extended(base)
7
7
  base.resource_aliases = {}
8
- base.on :resource_located do |resource, name|
9
- base.aliases_for(name).each do |resource_alias|
10
- instance_variable_set("@#{resource_alias}", resource)
11
- end
12
- end
8
+ base.on :resource_located, &method(:export_alias)
13
9
  end
14
10
 
15
11
  def alias_resource(original, resource_alias)
@@ -31,6 +27,15 @@ module Sinatra
31
27
  def reset_aliases!
32
28
  self.resource_aliases = {}
33
29
  end
30
+
31
+ private
32
+
33
+ def self.export_alias(resource, name)
34
+ base = Sinatra::API
35
+ base.aliases_for(name).each do |resource_alias|
36
+ base.instance.instance_variable_set("@#{resource_alias}", resource)
37
+ end
38
+ end
34
39
  end
35
40
  end
36
41
  end
@@ -0,0 +1,84 @@
1
+ # Copyright (c) 2013 Algol Labs, LLC. <dev@algollabs.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a
4
+ # copy of this software and associated documentation files (the "Software"),
5
+ # to deal in the Software without restriction, including without limitation
6
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
+ # and/or sell copies of the Software, and to permit persons to whom the
8
+ # Software is furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ #
21
+
22
+ module Sinatra::API
23
+ # API for defining parameters an endpoint requires or accepts, their types,
24
+ # and optional validators.
25
+ #
26
+ module Resources
27
+ private
28
+
29
+ # Attempt to locate a resource based on an ID supplied in a request parameter.
30
+ #
31
+ # If the param map contains a resource id (ie, :folder_id),
32
+ # we attempt to locate and expose it to the route.
33
+ #
34
+ # A 404 is raised if:
35
+ # 1. the scope is missing (@space for folder, @space or @folder for page)
36
+ # 2. the resource couldn't be identified in its scope (@space or @folder)
37
+ #
38
+ # If the resources were located, they're accessible using @folder or @page.
39
+ #
40
+ # The route can be halted using the :requires => [] condition when it expects
41
+ # a resource.
42
+ #
43
+ # @example using :requires to reject a request with an invalid @page
44
+ # get '/folders/:folder_id/pages/:page_id', :requires => [ :page ] do
45
+ # @page.show # page is good
46
+ # @folder.show # so is its folder
47
+ # end
48
+ #
49
+ def api_locate_resource(r, container = nil)
50
+ resource_id = params[r + '_id'].to_i
51
+ rklass = r.camelize
52
+
53
+ collection = case
54
+ when container.nil?; eval "#{ResourcePrefix}#{rklass}"
55
+ else; container.send("#{r.to_plural}")
56
+ end
57
+
58
+ puts "locating resource #{r} with id #{resource_id} from #{collection} [#{container}]"
59
+
60
+ resource = collection.get(resource_id)
61
+
62
+ if !resource
63
+ m = "No such resource: #{rklass}##{resource_id}"
64
+ if container
65
+ m << " in #{container.class.name.to_s}##{container.id}"
66
+ end
67
+
68
+ halt 404, m
69
+ end
70
+
71
+ if respond_to?(:can?)
72
+ unless can? :access, resource
73
+ halt 403, "You do not have access to this #{rklass} resource."
74
+ end
75
+ end
76
+
77
+ instance_variable_set('@'+r, resource)
78
+
79
+ Sinatra::API.trigger :resource_located, resource, r
80
+
81
+ resource
82
+ end
83
+ end
84
+ end
@@ -21,6 +21,6 @@
21
21
 
22
22
  module Sinatra
23
23
  module API
24
- VERSION = "1.0.0"
24
+ VERSION = "1.0.2"
25
25
  end
26
26
  end
data/lib/sinatra/api.rb CHANGED
@@ -30,6 +30,8 @@ require 'sinatra/api/version'
30
30
  require 'sinatra/api/callbacks'
31
31
  require 'sinatra/api/helpers'
32
32
  require 'sinatra/api/resource_aliases'
33
+ require 'sinatra/api/resources'
34
+ require 'sinatra/api/parameters'
33
35
 
34
36
  module Sinatra
35
37
  module API
@@ -37,37 +39,48 @@ module Sinatra
37
39
  extend ResourceAliases
38
40
 
39
41
  class << self
42
+ # @!attribute logger
43
+ # @return [ActiveSupport::Logger]
44
+ # A Logger instance.
40
45
  attr_accessor :logger
41
- end
42
46
 
43
- ResourcePrefix = '::'
47
+ # @!attribute instance
48
+ # @return [Sinatra::Application]
49
+ # The Sinatra instance that is evaluating the current request.
50
+ attr_accessor :instance
44
51
 
45
- # Parse a JSON construct from a string stream.
46
- #
47
- # Override this to use a custom JSON parser, if necessary.
48
- #
49
- # @param [String] stream The raw JSON stream.
50
- #
51
- # @return [Hash] A Hash of the parsed JSON.
52
- def parse_json(stream)
53
- ::JSON.parse(stream)
52
+ # Parse a JSON construct from a string stream.
53
+ #
54
+ # Override this to use a custom JSON parser, if necessary.
55
+ #
56
+ # @param [String] stream The raw JSON stream.
57
+ #
58
+ # @return [Hash] A Hash of the parsed JSON.
59
+ def parse_json(stream)
60
+ ::JSON.parse(stream)
61
+ end
54
62
  end
55
63
 
64
+ ResourcePrefix = '::'
65
+
56
66
  def self.registered(app)
67
+ base = self
57
68
  self.logger = ActiveSupport::Logger.new(STDOUT)
58
69
 
59
- app.helpers Helpers
70
+ app.helpers Helpers, Parameters, Resources
60
71
  app.before do
72
+ base.instance = self
73
+
61
74
  @api = { required: {}, optional: {} }
62
75
  @parent_resource = nil
63
76
 
64
77
  if api_call?
65
78
  request.body.rewind
66
- body = request.body.read.to_s || ''
79
+ raw_json = request.body.read.to_s || ''
67
80
 
68
- unless body.empty?
81
+ unless raw_json.empty?
69
82
  begin
70
- params.merge!(parse_json(body))
83
+ params.merge!(base.parse_json(raw_json))
71
84
  rescue ::JSON::ParserError => e
72
85
  logger.warn e.message
73
86
  logger.warn e.backtrace
@@ -82,7 +95,7 @@ module Sinatra
82
95
  condition do
83
96
  @required = resources.collect { |r| r.to_s }
84
97
  @required.each do |r|
85
- @parent_resource = __api_locate_resource(r, @parent_resource)
98
+ @parent_resource = api_locate_resource(r, @parent_resource)
86
99
  end
87
100
  end
88
101
  end
@@ -0,0 +1,15 @@
1
+ describe Sinatra::API::Helpers do
2
+ include_examples 'integration specs'
3
+
4
+ it 'should catch and reject malformed JSON' do
5
+ app.post '/' do
6
+ api_required!({
7
+ id: nil
8
+ })
9
+ end
10
+
11
+ post '/', 'x]'
12
+ last_response.status.should == 400
13
+ last_response.body.should match(/malformed json/i)
14
+ end
15
+ end
@@ -1,5 +1,8 @@
1
1
  shared_examples_for "integration specs" do
2
2
  before :each do
3
3
  Router.purge('/')
4
+
5
+ header 'Content-Type', 'application/json'
6
+ header 'Accept', 'application/json'
4
7
  end
5
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -132,10 +132,13 @@ files:
132
132
  - lib/sinatra/api.rb
133
133
  - lib/sinatra/api/callbacks.rb
134
134
  - lib/sinatra/api/helpers.rb
135
+ - lib/sinatra/api/parameters.rb
135
136
  - lib/sinatra/api/resource_aliases.rb
137
+ - lib/sinatra/api/resources.rb
136
138
  - lib/sinatra/api/version.rb
137
139
  - spec/unit/callbacks_spec.rb
138
140
  - spec/unit/resource_aliases_spec.rb
141
+ - spec/integration/api_spec.rb
139
142
  - spec/integration/helpers_spec.rb
140
143
  - spec/integration/resource_aliases_spec.rb
141
144
  - spec/support/integration_specs.rb