sinatra-api 1.0.2 → 1.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.yardopts +1 -1
- data/lib/sinatra/api.rb +50 -27
- data/lib/sinatra/api/callbacks.rb +29 -0
- data/lib/sinatra/api/config.rb +42 -0
- data/lib/sinatra/api/error_handler.rb +54 -0
- data/lib/sinatra/api/helpers.rb +1 -0
- data/lib/sinatra/api/parameter_validator.rb +90 -0
- data/lib/sinatra/api/parameter_validators/float_validator.rb +17 -0
- data/lib/sinatra/api/parameter_validators/integer_validator.rb +17 -0
- data/lib/sinatra/api/parameter_validators/string_validator.rb +17 -0
- data/lib/sinatra/api/parameters.rb +62 -20
- data/lib/sinatra/api/resource_aliases.rb +1 -1
- data/lib/sinatra/api/resources.rb +22 -3
- data/lib/sinatra/api/version.rb +1 -1
- data/spec/helpers/api_calls.rb +33 -0
- data/spec/helpers/rack_test_api_response.rb +45 -0
- data/spec/helpers/rspec_api_response_matchers.rb +135 -0
- data/spec/integration/parameter_validators/float_validator_spec.rb +85 -0
- data/spec/integration/parameter_validators/integer_validator_spec.rb +66 -0
- data/spec/integration/parameter_validators/string_validator_spec.rb +25 -0
- data/spec/integration/parameter_validators_spec.rb +3 -0
- data/spec/integration/parameters_spec.rb +144 -0
- data/spec/integration/resources_spec.rb +17 -0
- data/spec/spec_helper.rb +5 -11
- data/spec/unit/callbacks_spec.rb +1 -98
- metadata +16 -2
- data/spec/integration/helpers_spec.rb +0 -92
@@ -17,7 +17,7 @@ module Sinatra
|
|
17
17
|
return if key.to_s == resource_alias
|
18
18
|
|
19
19
|
self.resource_aliases[key] << resource_alias
|
20
|
-
logger.debug "API resource #{original} is now aliased as #{resource_alias}"
|
20
|
+
# logger.debug "API resource #{original} is now aliased as #{resource_alias}"
|
21
21
|
end
|
22
22
|
|
23
23
|
def aliases_for(resource)
|
@@ -24,6 +24,22 @@ module Sinatra::API
|
|
24
24
|
# and optional validators.
|
25
25
|
#
|
26
26
|
module Resources
|
27
|
+
|
28
|
+
def self.included(app)
|
29
|
+
app.set(:requires) do |*resources|
|
30
|
+
condition do
|
31
|
+
@required = resources.collect { |r| r.to_s }
|
32
|
+
@required.each do |r|
|
33
|
+
@parent_resource = api_locate_resource(r, @parent_resource)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
Sinatra::API.on :request do |instance|
|
39
|
+
@parent_resource = nil
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
27
43
|
private
|
28
44
|
|
29
45
|
# Attempt to locate a resource based on an ID supplied in a request parameter.
|
@@ -32,8 +48,9 @@ module Sinatra::API
|
|
32
48
|
# we attempt to locate and expose it to the route.
|
33
49
|
#
|
34
50
|
# A 404 is raised if:
|
35
|
-
#
|
36
|
-
#
|
51
|
+
#
|
52
|
+
# 1. the scope is missing
|
53
|
+
# 2. the resource couldn't be found in its scope
|
37
54
|
#
|
38
55
|
# If the resources were located, they're accessible using @folder or @page.
|
39
56
|
#
|
@@ -41,6 +58,7 @@ module Sinatra::API
|
|
41
58
|
# a resource.
|
42
59
|
#
|
43
60
|
# @example using :requires to reject a request with an invalid @page
|
61
|
+
#
|
44
62
|
# get '/folders/:folder_id/pages/:page_id', :requires => [ :page ] do
|
45
63
|
# @page.show # page is good
|
46
64
|
# @folder.show # so is its folder
|
@@ -55,7 +73,8 @@ module Sinatra::API
|
|
55
73
|
else; container.send("#{r.to_plural}")
|
56
74
|
end
|
57
75
|
|
58
|
-
|
76
|
+
Sinatra::API.logger.debug "locating resource #{r} with id #{resource_id} " +
|
77
|
+
"from #{collection} [#{container}]"
|
59
78
|
|
60
79
|
resource = collection.get(resource_id)
|
61
80
|
|
data/lib/sinatra/api/version.rb
CHANGED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'spec/helpers/rack_test_api_response'
|
2
|
+
require 'spec/helpers/rspec_api_response_matchers'
|
3
|
+
|
4
|
+
# Converts a Rack::Test HTTP mock response into an API one.
|
5
|
+
#
|
6
|
+
# @see Rack::Test::APIResponse
|
7
|
+
#
|
8
|
+
# @example usage
|
9
|
+
# rc = api_call get '/api/endpoint'
|
10
|
+
# rc.should fail(400, 'some error explanation')
|
11
|
+
# rc.should succeed(201)
|
12
|
+
# rc.messages.empty?.should be_true
|
13
|
+
# rc.status.should == :error
|
14
|
+
#
|
15
|
+
# @example using expect {}
|
16
|
+
# expect { api_call get '/foobar' }.to succeed(200)
|
17
|
+
def api_call(rack_response)
|
18
|
+
Rack::Test::APIResponse.new(rack_response)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Does the same thing as #api_call but wraps it into a block.
|
22
|
+
#
|
23
|
+
# @example usage
|
24
|
+
# api { get '/api/endpoint' }.should fail(403, 'not authorized')
|
25
|
+
#
|
26
|
+
# @see #api_call
|
27
|
+
def api(&block)
|
28
|
+
api_call(block.yield)
|
29
|
+
end
|
30
|
+
|
31
|
+
RSpec.configure do |config|
|
32
|
+
config.include RSpec::APIResponseMatchers
|
33
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Rack
|
2
|
+
module Test
|
3
|
+
class APIResponse
|
4
|
+
attr_reader :rr, :body, :status, :messages, :http_rc
|
5
|
+
|
6
|
+
def initialize(rack_response)
|
7
|
+
@rr = rack_response
|
8
|
+
@http_rc = @rr.status
|
9
|
+
begin;
|
10
|
+
@body = JSON.parse(@rr.body.empty? ? '{}' : @rr.body)
|
11
|
+
@body = @body.with_indifferent_access
|
12
|
+
rescue JSON::ParserError => e
|
13
|
+
raise "Invalid API response;" +
|
14
|
+
"body could not be parsed as JSON:\n#{@rr.body}\nException: #{e.message}"
|
15
|
+
end
|
16
|
+
|
17
|
+
@status = :success
|
18
|
+
@messages = []
|
19
|
+
|
20
|
+
unless blank?
|
21
|
+
if @body.is_a?(Hash)
|
22
|
+
@status = @body["status"].to_sym if @body.has_key?("status")
|
23
|
+
@messages = @body["messages"] if @body.has_key?("messages")
|
24
|
+
elsif @body.is_a?(Array)
|
25
|
+
@messages = @body
|
26
|
+
else
|
27
|
+
@messages = @body
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def blank?
|
33
|
+
@body.empty?
|
34
|
+
end
|
35
|
+
|
36
|
+
def succeeded?
|
37
|
+
!blank? && @status == :success
|
38
|
+
end
|
39
|
+
|
40
|
+
def failed?
|
41
|
+
!blank? && @status == :error
|
42
|
+
end
|
43
|
+
end # APIResponse
|
44
|
+
end # Test
|
45
|
+
end # Rack
|
@@ -0,0 +1,135 @@
|
|
1
|
+
module RSpec
|
2
|
+
module APIResponseMatchers
|
3
|
+
class Fail
|
4
|
+
def initialize(http_rc = 400, *keywords)
|
5
|
+
@http_rc = http_rc
|
6
|
+
@keywords = (keywords || []).flatten
|
7
|
+
@raw_keywords = @keywords.dup
|
8
|
+
end
|
9
|
+
|
10
|
+
def matches?(api_rc)
|
11
|
+
if api_rc.is_a?(Proc)
|
12
|
+
api_rc = api_rc.yield
|
13
|
+
end
|
14
|
+
|
15
|
+
@api_rc = api_rc
|
16
|
+
|
17
|
+
return false if api_rc.http_rc != @http_rc
|
18
|
+
return false if api_rc.status != :error
|
19
|
+
|
20
|
+
if @keywords.empty?
|
21
|
+
return true if api_rc.messages.empty?
|
22
|
+
return false
|
23
|
+
end
|
24
|
+
|
25
|
+
if @keywords.size == 1
|
26
|
+
@keywords = @keywords.first.split(/\s/)
|
27
|
+
end
|
28
|
+
|
29
|
+
@keywords = Regexp.new(@keywords.join('.*'), 'i')
|
30
|
+
|
31
|
+
matched = false
|
32
|
+
api_rc.messages.each { |m|
|
33
|
+
if m.match(@keywords)
|
34
|
+
matched = true
|
35
|
+
break
|
36
|
+
end
|
37
|
+
}
|
38
|
+
|
39
|
+
matched
|
40
|
+
end # Fail#matches
|
41
|
+
|
42
|
+
def failure_message
|
43
|
+
m = "Expected: \n"
|
44
|
+
|
45
|
+
if @api_rc.status != :error
|
46
|
+
m << "* The API response status to be :error, but got #{@api_rc.status}\n"
|
47
|
+
end
|
48
|
+
|
49
|
+
if @api_rc.http_rc != @http_rc
|
50
|
+
m << "* The HTTP RC to be #{@http_rc}, but got #{@api_rc.http_rc}\n"
|
51
|
+
end
|
52
|
+
|
53
|
+
formatted_keywords = @raw_keywords.join(' ')
|
54
|
+
|
55
|
+
if @raw_keywords.any? && @api_rc.messages.any?
|
56
|
+
m << "* One of the following API response messages: \n"
|
57
|
+
m << @api_rc.messages.collect.with_index { |m, i| "\t#{i+1}. #{m}" }.join("\n")
|
58
|
+
m << "\n to be matched by the keywords: #{formatted_keywords}\n"
|
59
|
+
|
60
|
+
elsif @raw_keywords.any? && @api_rc.messages.empty?
|
61
|
+
m << "* The API response to contain some messages (got 0) and for at least\n" <<
|
62
|
+
" one of them to match the keywords #{formatted_keywords}\n"
|
63
|
+
|
64
|
+
elsif @raw_keywords.empty? && @api_rc.messages.any?
|
65
|
+
m << "* The API response to contain no messages, but got: \n"
|
66
|
+
m << @api_rc.messages.collect.with_index { |m, i| "\t#{i+1}. #{m}" }.join("\n")
|
67
|
+
m << "\n"
|
68
|
+
end
|
69
|
+
|
70
|
+
m
|
71
|
+
end
|
72
|
+
|
73
|
+
# def negative_failure_message
|
74
|
+
# "expected API response [status:#{@api_rc.status}] not to be :error, " <<
|
75
|
+
# "and API response [messages: #{@api_rc.messages}] not to match '#{@keywords}'"
|
76
|
+
# end
|
77
|
+
end # Fail
|
78
|
+
|
79
|
+
class Success
|
80
|
+
def initialize(http_rc)
|
81
|
+
@http_rc = http_rc
|
82
|
+
end
|
83
|
+
|
84
|
+
def matches?(api_rc)
|
85
|
+
if api_rc.is_a?(Proc)
|
86
|
+
api_rc = api_rc.yield
|
87
|
+
end
|
88
|
+
|
89
|
+
@api_rc = api_rc
|
90
|
+
|
91
|
+
return false unless @http_rc.include?(api_rc.http_rc)
|
92
|
+
return false if api_rc.status != :success
|
93
|
+
|
94
|
+
true
|
95
|
+
end
|
96
|
+
|
97
|
+
def failure_message
|
98
|
+
m = "Expected:\n"
|
99
|
+
|
100
|
+
if @api_rc.status != :success
|
101
|
+
m << "* The API response status to be :success, but got #{@api_rc.status}\n"
|
102
|
+
end
|
103
|
+
|
104
|
+
if @api_rc.http_rc != @http_rc
|
105
|
+
m << "* The HTTP RC to be #{@http_rc}, but got #{@api_rc.http_rc}\n"
|
106
|
+
end
|
107
|
+
|
108
|
+
if @api_rc.messages.any?
|
109
|
+
m << "* The API response messages to be empty, but got: \n"
|
110
|
+
m << @api_rc.messages.collect.with_index { |m, i| "\t#{i+1}. #{m}" }.join("\n")
|
111
|
+
m << "\n"
|
112
|
+
end
|
113
|
+
|
114
|
+
m
|
115
|
+
end
|
116
|
+
|
117
|
+
# def negative_failure_message
|
118
|
+
# m = "expected API response [status:#{@api_rc.status}] not to be :success"
|
119
|
+
# if @api_rc.messages.any?
|
120
|
+
# m << ", and no messages, but got: #{@api_rc.messages}"
|
121
|
+
# end
|
122
|
+
# m
|
123
|
+
# end
|
124
|
+
end
|
125
|
+
|
126
|
+
def fail(http_rc = 400, *keywords)
|
127
|
+
Fail.new(http_rc, keywords)
|
128
|
+
end
|
129
|
+
|
130
|
+
def succeed(http_rc = 200..205)
|
131
|
+
http_rc = http_rc.respond_to?(:to_a) ? http_rc.to_a : [ http_rc ]
|
132
|
+
Success.new(http_rc.flatten)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
describe Sinatra::API::FloatValidator do
|
2
|
+
include_examples 'integration specs'
|
3
|
+
|
4
|
+
it 'should accept a float' do
|
5
|
+
app.post '/' do
|
6
|
+
api_parameter! :rate, required: true, type: :float
|
7
|
+
api_params.to_json
|
8
|
+
end
|
9
|
+
|
10
|
+
rc = api_call post '/', { rate: 5.75 }.to_json
|
11
|
+
rc.should succeed
|
12
|
+
rc.body[:rate].should == 5.75
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should accept a float with scientific notation' do
|
16
|
+
app.post '/' do
|
17
|
+
api_parameter! :rate, required: true, type: :float
|
18
|
+
api_params.to_json
|
19
|
+
end
|
20
|
+
|
21
|
+
rc = api_call post '/', { rate: 5.75e3 }.to_json
|
22
|
+
rc.should succeed
|
23
|
+
rc.body[:rate].should == 5.75e3
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'should accept a really big decimal' do
|
27
|
+
app.post '/' do
|
28
|
+
api_parameter! :rate, required: true, type: :float
|
29
|
+
api_params.to_json
|
30
|
+
end
|
31
|
+
|
32
|
+
rc = api_call post '/', { rate: 123456789.75e24 }.to_json
|
33
|
+
rc.should succeed
|
34
|
+
rc.body[:rate].should == 123456789.75e24
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'should accept an integer' do
|
38
|
+
app.post '/' do
|
39
|
+
api_parameter! :rate, required: true, type: :float
|
40
|
+
api_params.to_json
|
41
|
+
end
|
42
|
+
|
43
|
+
rc = api_call post '/', { rate: 5 }.to_json
|
44
|
+
rc.should succeed
|
45
|
+
rc.body[:rate].should == 5.0
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'should reject a non-numeric value' do
|
49
|
+
app.post '/' do
|
50
|
+
api_parameter! :rate, required: true, type: :float
|
51
|
+
api_params.to_json
|
52
|
+
end
|
53
|
+
|
54
|
+
rc = api_call post '/', { rate: 'asdf' }.to_json
|
55
|
+
rc.should fail(400, 'not a valid float')
|
56
|
+
|
57
|
+
rc = api_call post '/', { rate: [] }.to_json
|
58
|
+
rc.should fail(400, 'not a valid float')
|
59
|
+
|
60
|
+
rc = api_call post '/', { rate: {} }.to_json
|
61
|
+
rc.should fail(400, 'not a valid float')
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'should coerce a float' do
|
65
|
+
app.post '/' do
|
66
|
+
api_parameter! :rate, required: true, type: :float
|
67
|
+
api_params.to_json
|
68
|
+
end
|
69
|
+
|
70
|
+
rc = api_call post '/', { rate: "5.8" }.to_json
|
71
|
+
rc.should succeed
|
72
|
+
rc.body[:rate].should == 5.8
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'should not coerce a float' do
|
76
|
+
app.post '/' do
|
77
|
+
api_parameter! :rate, required: true, type: :float, coerce: false
|
78
|
+
api_params.to_json
|
79
|
+
end
|
80
|
+
|
81
|
+
rc = api_call post '/', { rate: "15.25" }.to_json
|
82
|
+
rc.should succeed
|
83
|
+
rc.body[:rate].should == "15.25"
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
describe Sinatra::API::IntegerValidator do
|
2
|
+
include_examples 'integration specs'
|
3
|
+
|
4
|
+
it 'should accept a numeric value' do
|
5
|
+
app.post '/' do
|
6
|
+
api_parameter! :rate, required: true, type: :integer
|
7
|
+
api_params.to_json
|
8
|
+
end
|
9
|
+
|
10
|
+
rc = api_call post '/', { rate: 5 }.to_json
|
11
|
+
rc.should succeed
|
12
|
+
rc.body[:rate].should == 5
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should reject a non-numeric value' do
|
16
|
+
app.post '/' do
|
17
|
+
api_parameter! :rate, required: true, type: :integer
|
18
|
+
api_params.to_json
|
19
|
+
end
|
20
|
+
|
21
|
+
rc = api_call post '/', { rate: 'asdf' }.to_json
|
22
|
+
rc.should fail(400, 'not a valid integer')
|
23
|
+
|
24
|
+
rc = api_call post '/', { rate: '5.73' }.to_json
|
25
|
+
rc.should fail(400, 'not a valid integer')
|
26
|
+
|
27
|
+
rc = api_call post '/', { rate: [] }.to_json
|
28
|
+
rc.should fail(400, 'not a valid integer')
|
29
|
+
|
30
|
+
rc = api_call post '/', { rate: {} }.to_json
|
31
|
+
rc.should fail(400, 'not a valid integer')
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should round a float' do
|
35
|
+
app.post '/' do
|
36
|
+
api_parameter! :rate, required: true, type: :integer
|
37
|
+
api_params.to_json
|
38
|
+
end
|
39
|
+
|
40
|
+
rc = api_call post '/', { rate: 5.75 }.to_json
|
41
|
+
rc.should succeed
|
42
|
+
rc.body[:rate].should == 5
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'should coerce an integer' do
|
46
|
+
app.post '/' do
|
47
|
+
api_parameter! :rate, required: true, type: :integer
|
48
|
+
api_params.to_json
|
49
|
+
end
|
50
|
+
|
51
|
+
rc = api_call post '/', { rate: "15" }.to_json
|
52
|
+
rc.should succeed
|
53
|
+
rc.body[:rate].should == 15
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should not coerce an integer' do
|
57
|
+
app.post '/' do
|
58
|
+
api_parameter! :rate, required: true, type: :integer, coerce: false
|
59
|
+
api_params.to_json
|
60
|
+
end
|
61
|
+
|
62
|
+
rc = api_call post '/', { rate: "15" }.to_json
|
63
|
+
rc.should succeed
|
64
|
+
rc.body[:rate].should == "15"
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
describe Sinatra::API::StringValidator do
|
2
|
+
include_examples 'integration specs'
|
3
|
+
|
4
|
+
it 'should accept a string value' do
|
5
|
+
app.get '/' do
|
6
|
+
api_parameter! :name, required: true, type: :string
|
7
|
+
api_parameter :name
|
8
|
+
end
|
9
|
+
|
10
|
+
get '/', { name: 'foo' }
|
11
|
+
last_response.status.should == 200
|
12
|
+
last_response.body.should == 'foo'
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should reject a non-string value' do
|
16
|
+
app.post '/' do
|
17
|
+
api_parameter! :name, required: true, type: :string
|
18
|
+
api_parameter :name
|
19
|
+
end
|
20
|
+
|
21
|
+
rc = api_call post '/', { name: 5 }.to_json
|
22
|
+
rc.should fail(400, '')
|
23
|
+
rc.body[:field_errors][:name].should match(/expected.*string/i)
|
24
|
+
end
|
25
|
+
end
|