service-client 0.0.14
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/.rvmrc +1 -0
- data/.travis.yml +9 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile +17 -0
- data/Guardfile +10 -0
- data/LICENSE +22 -0
- data/README.md +117 -0
- data/Rakefile +10 -0
- data/lib/service-client.rb +103 -0
- data/lib/service-client/adapter/faraday.rb +61 -0
- data/lib/service-client/base_response.rb +9 -0
- data/lib/service-client/bound_route.rb +40 -0
- data/lib/service-client/error.rb +4 -0
- data/lib/service-client/raw_interface.rb +48 -0
- data/lib/service-client/redirection.rb +7 -0
- data/lib/service-client/response.rb +13 -0
- data/lib/service-client/response_error.rb +7 -0
- data/lib/service-client/route.rb +18 -0
- data/lib/service-client/route_collection.rb +18 -0
- data/lib/service-client/routing_error.rb +2 -0
- data/lib/service-client/service_error.rb +8 -0
- data/lib/service-client/url_pattern.rb +20 -0
- data/lib/service-client/version.rb +5 -0
- data/service-client.gemspec +20 -0
- data/spec/adapter/faraday_spec.rb +73 -0
- data/spec/adapter/test_server.ru +13 -0
- data/spec/client_spec.rb +195 -0
- data/spec/spec_helper.rb +5 -0
- metadata +112 -0
data/.gitignore
ADDED
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm use 1.9.3
|
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# 0.0.14 / 2013-08-10
|
2
|
+
|
3
|
+
* Adds a way to specify Faraday builder
|
4
|
+
|
5
|
+
# 0.0.13 / 2013-07-03
|
6
|
+
|
7
|
+
* Adds error message to ServiceErrors
|
8
|
+
|
9
|
+
# 0.0.12
|
10
|
+
|
11
|
+
* Fixes GET request parameter handling in Faraday adapter
|
12
|
+
* Adds JSON Content-Type to each request
|
13
|
+
|
14
|
+
# 0.0.11
|
15
|
+
|
16
|
+
* Escapes hashes and arrays in GET query parameters properly
|
17
|
+
|
18
|
+
# 0.0.10
|
19
|
+
|
20
|
+
* Makes GET requests use query parameters instead of a JSON body
|
21
|
+
|
22
|
+
# 0.0.9
|
23
|
+
|
24
|
+
- Does not error out on empty response bodies anymore
|
25
|
+
|
26
|
+
# 0.0.8 - 07th November 2012
|
27
|
+
|
28
|
+
- Handles 304 - Not Modified responses correctly
|
29
|
+
|
30
|
+
# 0.0.7 - 03rd October 2012
|
31
|
+
|
32
|
+
- Fixes the wrongly named Authorization HTTP header
|
33
|
+
|
34
|
+
# 0.0.6 - 03rd October 2012
|
35
|
+
|
36
|
+
- Moves the token to be passed in to each request instead of per client instance
|
37
|
+
|
38
|
+
# 0.0.5 - 03rd October 2012
|
39
|
+
|
40
|
+
- Adds OAuth authentication
|
41
|
+
|
42
|
+
# 0.0.4 - 14th August 2012
|
43
|
+
|
44
|
+
- Fixed bug that prevented the default adapter to be used correctly.
|
45
|
+
|
46
|
+
# 0.0.3 - 13th August 2012
|
47
|
+
|
48
|
+
The start.
|
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in service-client.gemspec
|
4
|
+
gemspec
|
5
|
+
|
6
|
+
gem 'rake'
|
7
|
+
|
8
|
+
group :development, :test do
|
9
|
+
gem 'realweb'
|
10
|
+
gem 'guard-minitest'
|
11
|
+
gem 'guard-bundler'
|
12
|
+
gem 'rack-test'
|
13
|
+
end
|
14
|
+
|
15
|
+
platforms :jruby do
|
16
|
+
gem "jruby-openssl"
|
17
|
+
end
|
data/Guardfile
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
guard :bundler do
|
2
|
+
watch('Gemfile')
|
3
|
+
end
|
4
|
+
|
5
|
+
guard 'minitest' do
|
6
|
+
watch(%r|^spec/(.*)_spec\.rb|)
|
7
|
+
watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
|
8
|
+
watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "spec/client_spec.rb" }
|
9
|
+
watch(%r|^spec/spec_helper\.rb|) { "spec" }
|
10
|
+
end
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Thorben Schröder
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
# Service::Client
|
2
|
+
|
3
|
+
Service::Client is a generic client gem to access our services. It is the base for explicit clients for each service so that those explicit clients are easy and fast to implement and maintain.
|
4
|
+
|
5
|
+
This gem should not be used as an "end-user solution".
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
### Client creation
|
10
|
+
|
11
|
+
Each client must be instantiated with a base URL to a service.
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
client = Service::Client.new('http://some=service.example.com/')
|
15
|
+
```
|
16
|
+
|
17
|
+
### Creating routes
|
18
|
+
|
19
|
+
A client route describes a single REST HTTP end-point at the service.
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
client.urls.add(:author, :post, '/authors/')
|
23
|
+
client.urls.add(:author, :get, '/authors/:id:')
|
24
|
+
client.urls.add(:review, :post, '/author/:author_id:/books/:book_id:')
|
25
|
+
```
|
26
|
+
|
27
|
+
### Requests
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
client.post(client.urls.author, token, name: 'Peter Lustig')
|
31
|
+
client.get(client.urls.author(123), token)
|
32
|
+
client.post(client.urls.review(author_id: 123, book_id: 456), token, name: 'Ronald Review', comment: 'This book is the bomb!')
|
33
|
+
```
|
34
|
+
|
35
|
+
Each ``Service::Client`` instance supports the ``get``, ``post``, ``put`` and ``delete`` methods. They all share the same method signature of:
|
36
|
+
|
37
|
+
```ruby
|
38
|
+
client.method(URL, TOKEN, BODY_HASH)
|
39
|
+
```
|
40
|
+
|
41
|
+
The URL is a relative URL to the base url the client has been created with. TOKEN is an OAuth token to authenticat the request. The BODY_HASH is any Ruby hash. The hash becomes the body of the HTTP request after it has been dumped to JSON.
|
42
|
+
|
43
|
+
The ``client.urls`` method makes all the created routes available as an easy to use URL builder. The URL builder takes zero arguments when the URL does not have any arguments. It can also take an array of the URL paramters in their respective order or a hash to built the URL by it's named parameters.
|
44
|
+
|
45
|
+
### Response
|
46
|
+
|
47
|
+
#### Success
|
48
|
+
|
49
|
+
If the HTTP response comes with a 200 status code, the client returns a ``Service::Client::Response`` object. That allows you to query for the JSON decoded data that came along with the body of the HTTP response. If the body was empty that data is just ``true``. You can also reach for the raw HTTP response:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
response = client.get(client.urls.author(123), token)
|
53
|
+
puts "retrieved a book written by #{response.data['name']} with an HTTP status code of #{response.raw.status}"
|
54
|
+
```
|
55
|
+
|
56
|
+
#### Redirections
|
57
|
+
|
58
|
+
For any redirecting responses the client raises a ``Service::Client::Redirection`` which can be queried for the redirection location:
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
begin
|
62
|
+
client.get(client.urls.author(123), token)
|
63
|
+
rescue Client::Service::Redirection => redirection
|
64
|
+
puts "The client has been redirected to: #{redirection.location}"
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
#### Errors
|
69
|
+
|
70
|
+
For any other response codes the client raises a ``Service::Client::ServiceError`` if the response body was a JSON encoded object with an ``error`` key on the root level.
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
begin
|
74
|
+
client.get(client.urls.author(678), token)
|
75
|
+
rescue Client::Service::ServiceError => e
|
76
|
+
puts "A service error has occured. Error description: #{e.error}"
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
If the body was not JSON encoded at all or did not include the ``error`` key on the root level a ``Service::Client::ResponseError`` is raised.
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
begin
|
84
|
+
client.get(client.urls.author(678), token)
|
85
|
+
rescue Client::Service::Error => e
|
86
|
+
puts "An error has occured. Error code: #{e.response.status} Error body: #{e.response.body}"
|
87
|
+
end
|
88
|
+
```
|
89
|
+
|
90
|
+
### Raw HTTP requests
|
91
|
+
|
92
|
+
The client exposes an interface that can be used to issue raw HTTP requests to any URL relative to the given base URL at creation time.
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
response = client.raw.post('/authors/123/books', JSON.dump({title: 'The guide to a higher enlightment', isbn: '1234567'}))
|
96
|
+
```
|
97
|
+
|
98
|
+
The raw client supports the ``get``, ``post``, ``put`` and ``delete`` methods. They all share the same method signature of:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
client.raw.method(URL, BODY, OPTIONS)
|
102
|
+
```
|
103
|
+
|
104
|
+
The options are a hash. Possible arguments are:
|
105
|
+
|
106
|
+
* **``headers``**: A hash (string:string) to set the request headers.
|
107
|
+
|
108
|
+
### Raw responses
|
109
|
+
|
110
|
+
Raw requests return a ``Rack::Response`` object that makes it easy to access the status code, headers and body of the response.
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
response = client.raw.get('/author/123/books')
|
114
|
+
response.body # "[{title: 'The guide to a higher enlightment', isbn: '1234567', id: 456}, {title: 'Some book', isbn: '23464527', id: 789}]"
|
115
|
+
response.status # 200
|
116
|
+
response.header # {"Some-Header" => "Some Value", "Another-Header" => "Another Value"}
|
117
|
+
```
|
data/Rakefile
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require "service-client/version"
|
2
|
+
require "service-client/error"
|
3
|
+
require "service-client/routing_error"
|
4
|
+
require "service-client/service_error"
|
5
|
+
require "service-client/response_error"
|
6
|
+
require "service-client/raw_interface"
|
7
|
+
require "service-client/adapter/faraday"
|
8
|
+
require "service-client/url_pattern"
|
9
|
+
require "service-client/bound_route"
|
10
|
+
require "service-client/route"
|
11
|
+
require "service-client/route_collection"
|
12
|
+
require "service-client/base_response"
|
13
|
+
require "service-client/response"
|
14
|
+
require "service-client/redirection"
|
15
|
+
|
16
|
+
require 'json'
|
17
|
+
require 'cgi'
|
18
|
+
|
19
|
+
module Service
|
20
|
+
class Client
|
21
|
+
attr_reader :base_url
|
22
|
+
|
23
|
+
def initialize(base_url)
|
24
|
+
@base_url = base_url
|
25
|
+
end
|
26
|
+
|
27
|
+
def raw
|
28
|
+
@raw_interface ||= RawInterface.new(self)
|
29
|
+
end
|
30
|
+
|
31
|
+
def routes
|
32
|
+
@routes ||= RouteCollection.new
|
33
|
+
end
|
34
|
+
alias urls routes
|
35
|
+
|
36
|
+
def get(bound_route, token, body_hash = nil)
|
37
|
+
request(:get, token, bound_route, body_hash)
|
38
|
+
end
|
39
|
+
|
40
|
+
def put(bound_route, token, body_hash = nil)
|
41
|
+
request(:put, token, bound_route, body_hash)
|
42
|
+
end
|
43
|
+
|
44
|
+
def post(bound_route, token, body_hash = nil)
|
45
|
+
request(:post, token, bound_route, body_hash)
|
46
|
+
end
|
47
|
+
|
48
|
+
def delete(bound_route, token, body_hash = nil)
|
49
|
+
request(:delete, token, bound_route, body_hash)
|
50
|
+
end
|
51
|
+
|
52
|
+
protected
|
53
|
+
def request(method, token, bound_route, body_hash)
|
54
|
+
url = bound_route.url_for_method(method)
|
55
|
+
|
56
|
+
body = nil
|
57
|
+
if method == :get
|
58
|
+
url = append_body_hash_to_url(url, body_hash)
|
59
|
+
else
|
60
|
+
body = body_hash ? JSON.dump(body_hash) : ''
|
61
|
+
end
|
62
|
+
|
63
|
+
headers = {
|
64
|
+
'AUTHORIZATION' => "Bearer #{token}",
|
65
|
+
'Content-Type' => 'application/json'
|
66
|
+
}
|
67
|
+
|
68
|
+
raw_response = raw.request(method, url, body, headers: headers)
|
69
|
+
case raw_response.status
|
70
|
+
when 200, 201, 304
|
71
|
+
Response.new(raw_response)
|
72
|
+
when 301, 302, 303, 307
|
73
|
+
raise Redirection.new(raw_response)
|
74
|
+
else
|
75
|
+
error = nil
|
76
|
+
begin
|
77
|
+
error = JSON.parse(raw_response.body.first)['error']
|
78
|
+
rescue JSON::ParserError
|
79
|
+
# treat invalid JSON the same as non-present error field
|
80
|
+
end
|
81
|
+
if error
|
82
|
+
raise ServiceError.new(error)
|
83
|
+
else
|
84
|
+
raise ResponseError.new(raw_response)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def append_body_hash_to_url(url, body_hash)
|
90
|
+
return url if !body_hash || body_hash.empty?
|
91
|
+
|
92
|
+
uri = URI.parse(url)
|
93
|
+
|
94
|
+
if body_hash && !body_hash.empty?
|
95
|
+
uri.query += '&' if uri.query
|
96
|
+
uri.query ||= ''
|
97
|
+
uri.query += Faraday::Utils.build_nested_query(body_hash)
|
98
|
+
end
|
99
|
+
|
100
|
+
uri.to_s
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'uri'
|
3
|
+
require 'rack'
|
4
|
+
|
5
|
+
module Service
|
6
|
+
class Client::Adapter
|
7
|
+
class Faraday
|
8
|
+
def initialize(options = {})
|
9
|
+
@adapter = options.delete :adapter
|
10
|
+
@builder = options.delete :builder
|
11
|
+
end
|
12
|
+
|
13
|
+
def request(method, url, body, options)
|
14
|
+
uri = URI.parse(url)
|
15
|
+
|
16
|
+
connection = create_connection(uri)
|
17
|
+
|
18
|
+
response = send_request(connection, method, uri, body, options)
|
19
|
+
|
20
|
+
Rack::Response.new(response.body || '', response.status, response.headers)
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
def send_request(connection, method, uri, body, options)
|
25
|
+
connection.send(method) do |request|
|
26
|
+
request.url path(uri)
|
27
|
+
request.body = body
|
28
|
+
if method == :get && uri.query
|
29
|
+
request.params = ::Faraday::Utils.parse_nested_query(uri.query)
|
30
|
+
end
|
31
|
+
request.headers = options[:headers] || {}
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def path(uri)
|
36
|
+
"#{uri.path}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def base_url(uri)
|
40
|
+
"#{uri.scheme}://#{auth(uri)}#{uri.host}:#{uri.port}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def auth(uri)
|
44
|
+
(uri.user && uri.password) ? "#{uri.user}:#{uri.password}@" : ''
|
45
|
+
end
|
46
|
+
|
47
|
+
def create_connection(uri)
|
48
|
+
::Faraday.new(:url => base_url(uri)) do |faraday|
|
49
|
+
# if this returns false it skips the adapter selection later
|
50
|
+
builder_response = @builder ? @builder.call(faraday) : true
|
51
|
+
|
52
|
+
if @adapter && builder_response
|
53
|
+
faraday.adapter *@adapter
|
54
|
+
else
|
55
|
+
faraday.adapter ::Faraday.default_adapter
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class Service::Client::BoundRoute
|
2
|
+
def initialize(route, args)
|
3
|
+
@route = route
|
4
|
+
@args = args || []
|
5
|
+
end
|
6
|
+
|
7
|
+
def url_for_method(method)
|
8
|
+
pattern = @route.pattern_for(method)
|
9
|
+
|
10
|
+
raise Service::Client::RoutingError.new("Method #{method} unsupported!") unless pattern
|
11
|
+
|
12
|
+
pattern.filled_with(options_for_pattern(pattern))
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
def options_for_pattern(pattern)
|
17
|
+
if args_are_a_hash?
|
18
|
+
@args.first
|
19
|
+
else
|
20
|
+
options_for_pattern_from_args_array(pattern)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def args_are_a_hash?
|
25
|
+
@args.size == 1 && @args.first.kind_of?(Hash)
|
26
|
+
end
|
27
|
+
|
28
|
+
def options_for_pattern_from_args_array(pattern)
|
29
|
+
if @args.size != pattern.placeholders.size
|
30
|
+
raise Service::Client::RoutingError.new("Number of URL arguments does not match! Given: #{@args.inspect} Expected: #{pattern.placeholders.inspect}")
|
31
|
+
end
|
32
|
+
|
33
|
+
cloned_args = @args.clone
|
34
|
+
options = {}
|
35
|
+
pattern.placeholders.each do |placeholder|
|
36
|
+
options[placeholder] = cloned_args.shift
|
37
|
+
end
|
38
|
+
options
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module Service
|
4
|
+
class Client
|
5
|
+
class RawInterface
|
6
|
+
def initialize(client)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
def get(url, body, options)
|
11
|
+
request(:get, url, body, options)
|
12
|
+
end
|
13
|
+
|
14
|
+
def put(url, body, options)
|
15
|
+
request(:put, url, body, options)
|
16
|
+
end
|
17
|
+
|
18
|
+
def post(url, body, options)
|
19
|
+
request(:post, url, body, options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def delete(url, body, options)
|
23
|
+
request(:delete, url, body, options)
|
24
|
+
end
|
25
|
+
|
26
|
+
def adapter
|
27
|
+
@adapter ||= default_adapter
|
28
|
+
end
|
29
|
+
|
30
|
+
def adapter=(new_adapter)
|
31
|
+
@adapter = new_adapter
|
32
|
+
end
|
33
|
+
|
34
|
+
def request(method, url, body, options)
|
35
|
+
adapter.request(method, absolutize_url(url), body, options)
|
36
|
+
end
|
37
|
+
|
38
|
+
protected
|
39
|
+
def default_adapter
|
40
|
+
Adapter::Faraday.new
|
41
|
+
end
|
42
|
+
|
43
|
+
def absolutize_url(url)
|
44
|
+
URI::join(@client.base_url, url).to_s
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
class Service::Client::Response
|
4
|
+
include Service::Client::BaseResponse
|
5
|
+
|
6
|
+
attr_reader :data
|
7
|
+
|
8
|
+
def initialize(raw_response)
|
9
|
+
super(raw_response)
|
10
|
+
body = raw.body.first
|
11
|
+
@data = body.empty? ? true : JSON.parse(body)
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Service::Client::Route
|
2
|
+
def add_pattern(method, pattern)
|
3
|
+
patterns[method] = Service::Client::UrlPattern.new(pattern)
|
4
|
+
end
|
5
|
+
|
6
|
+
def bind(*args)
|
7
|
+
Service::Client::BoundRoute.new(self, args)
|
8
|
+
end
|
9
|
+
|
10
|
+
def pattern_for(method)
|
11
|
+
patterns[method]
|
12
|
+
end
|
13
|
+
|
14
|
+
protected
|
15
|
+
def patterns
|
16
|
+
@patterns ||= {}
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Service::Client::RouteCollection
|
2
|
+
def add(name, method, pattern)
|
3
|
+
name = name.to_sym
|
4
|
+
|
5
|
+
route = routes[name] ||= Service::Client::Route.new
|
6
|
+
route.add_pattern(method, pattern)
|
7
|
+
end
|
8
|
+
|
9
|
+
protected
|
10
|
+
def routes
|
11
|
+
@routes ||= {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_missing(name, *args)
|
15
|
+
raise Service::Client::RoutingError.new("No route named #{name}") unless route = routes[name.to_sym]
|
16
|
+
route.bind(*args)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Service::Client::UrlPattern
|
2
|
+
def initialize(pattern)
|
3
|
+
@pattern = pattern
|
4
|
+
@pattern.scan(/:([^:]+):/).each do |placeholder|
|
5
|
+
placeholders << placeholder.first.to_sym
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def filled_with(options)
|
10
|
+
url = @pattern.clone
|
11
|
+
placeholders.each do |placeholder|
|
12
|
+
url.gsub!(/:#{Regexp.escape(placeholder.to_s)}:/, options[placeholder].to_s)
|
13
|
+
end
|
14
|
+
url
|
15
|
+
end
|
16
|
+
|
17
|
+
def placeholders
|
18
|
+
@placeholders ||= []
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/service-client/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Thorben Schröder"]
|
6
|
+
gem.email = ["stillepost@gmail.com"]
|
7
|
+
gem.description = %q{Service::Client is a generic client gem to access our services. It is the base for explicit clients for each service so that those explicit clients are easy and fast to implement and maintain.}
|
8
|
+
gem.summary = %q{Service::Client is a generic client gem to access our services.}
|
9
|
+
gem.homepage = ""
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "service-client"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Service::Client::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency 'faraday', '0.8.1'
|
19
|
+
gem.add_dependency 'json'
|
20
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require 'realweb'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
describe Service::Client::Adapter::Faraday do
|
6
|
+
before do
|
7
|
+
$__service_client_test_server ||= RealWeb.start_server_in_thread(File.expand_path("../test_server.ru", __FILE__))
|
8
|
+
@server = $__service_client_test_server
|
9
|
+
@url = @server.base_uri.to_s
|
10
|
+
@adapter = Service::Client::Adapter::Faraday.new
|
11
|
+
end
|
12
|
+
|
13
|
+
after do
|
14
|
+
unless $__service_client_stop_defined
|
15
|
+
self.class.class_eval do
|
16
|
+
at_exit do
|
17
|
+
puts "Shutting down server"
|
18
|
+
$__service_client_test_server.stop
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
$__service_client_stop_defined = true
|
23
|
+
end
|
24
|
+
|
25
|
+
headers = {
|
26
|
+
'USER-AGENT' => 'service-client-spec',
|
27
|
+
'REFERER' => 'http://super.example.com/'
|
28
|
+
}
|
29
|
+
body = 'This is a test'
|
30
|
+
[:get, :put, :post, :delete].each do |method|
|
31
|
+
it "sends correct #{method} requests" do
|
32
|
+
response = @adapter.request(method, @url, body, {headers: headers})
|
33
|
+
response.status.must_equal 200
|
34
|
+
request = JSON.parse(response.body.first)
|
35
|
+
request['body'].must_equal body
|
36
|
+
request['method'].must_equal method.to_s.upcase
|
37
|
+
headers.each do |key, value|
|
38
|
+
request['headers'][key].must_equal value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
it "can use other Faraday adapters" do
|
44
|
+
ran = false
|
45
|
+
app = lambda {|env| ran = true; [200, {'Content-Type' => 'text/html'}, ["ran"]]}
|
46
|
+
adapter = Service::Client::Adapter::Faraday.new(adapter: [:rack, app])
|
47
|
+
adapter.request(:get, 'http://example.com/adapter_change', '', {})
|
48
|
+
ran.must_equal true
|
49
|
+
end
|
50
|
+
|
51
|
+
describe 'faraday builder' do
|
52
|
+
it 'can be used' do
|
53
|
+
ran = false
|
54
|
+
builder_called = false
|
55
|
+
app = lambda {|env| ran = true; [200, {'Content-Type' => 'text/html'}, ["ran"]]}
|
56
|
+
adapter = Service::Client::Adapter::Faraday.new(adapter: [:rack, app], builder: lambda {|faraday| builder_called = true})
|
57
|
+
adapter.request(:get, 'http://example.com/adapter_change', '', {})
|
58
|
+
ran.must_equal true
|
59
|
+
builder_called.must_equal true
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'can skip the adapter call' do
|
63
|
+
ran = false
|
64
|
+
builder_called = false
|
65
|
+
app = lambda {|env| ran = true; [200, {'Content-Type' => 'text/html'}, ["ran"]]}
|
66
|
+
adapter = Service::Client::Adapter::Faraday.new(adapter: [:rack, app], builder: lambda {|faraday| builder_called = true; false})
|
67
|
+
adapter.request(:get, 'http://example.com/adapter_change', '', {})
|
68
|
+
# must be false because the rack adapter is not used as the builder returns false
|
69
|
+
ran.must_equal false
|
70
|
+
builder_called.must_equal true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
run lambda {|env|
|
4
|
+
request = Rack::Request.new(env)
|
5
|
+
method = request.request_method
|
6
|
+
headers = Hash[env.select {|k,v| k =~ /^HTTP_/}.map {|k,v| [k.gsub(/^HTTP_/, '').gsub('_', '-'), v]}]
|
7
|
+
body = request.body.read
|
8
|
+
[200, { 'Content-Type' => 'application/json' }, [JSON.dump(
|
9
|
+
method: method,
|
10
|
+
headers: headers,
|
11
|
+
body: body
|
12
|
+
)]]
|
13
|
+
}
|
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
require_relative './spec_helper'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
TOKEN = '123'
|
5
|
+
|
6
|
+
def must_send_request(method, url, json = nil, options = {}, &blck)
|
7
|
+
@client.raw.adapter.expect :request, Rack::Response.new('', 200, {}), [method, url, json ? JSON.dump(json) : nil, options]
|
8
|
+
yield
|
9
|
+
@client.raw.adapter.verify
|
10
|
+
@client.raw.adapter = MiniTest::Mock.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def must_raise_response_error(body)
|
14
|
+
raw_response = Rack::Response.new(body, 500, {})
|
15
|
+
@client.raw.adapter.expect :request, raw_response, [:get, 'http://example.com/authors/123', body, {}]
|
16
|
+
begin
|
17
|
+
@client.get(@client.urls.author(123), TOKEN)
|
18
|
+
flunk "Must raise Service::Client::ResponseError but didn't!"
|
19
|
+
rescue Service::Client::ResponseError => e
|
20
|
+
e.response.status.must_equal 500
|
21
|
+
e.response.body.must_equal [body]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe Service::Client do
|
26
|
+
before do
|
27
|
+
@client = Service::Client.new('http://example.com')
|
28
|
+
@client.raw.adapter = MiniTest::Mock.new
|
29
|
+
end
|
30
|
+
|
31
|
+
describe "raw interface" do
|
32
|
+
it "is exposed" do
|
33
|
+
@client.raw.must_be_instance_of Service::Client::RawInterface
|
34
|
+
end
|
35
|
+
|
36
|
+
args = ['/bla', :body, :options]
|
37
|
+
url, body, options = args
|
38
|
+
[:get, :put, :post, :delete].each do |method|
|
39
|
+
it "passes #{method} requests through to the HTTP adapter" do
|
40
|
+
@client.raw.adapter.expect :request, true, [method, 'http://example.com/bla', body, options]
|
41
|
+
@client.raw.send(method, *args)
|
42
|
+
@client.raw.adapter.verify
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
it "uses the faraday adapter as a default" do
|
48
|
+
Service::Client.new('http://example.com').raw.adapter.must_be_instance_of Service::Client::Adapter::Faraday
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "high level interface" do
|
52
|
+
before do
|
53
|
+
@client.urls.add(:author, :post, '/authors/')
|
54
|
+
@client.urls.add(:author, :get, '/authors/:id:')
|
55
|
+
@client.urls.add(:author_with_query, :get, '/authors/:id:/another-fixed-part?blub=1')
|
56
|
+
@client.urls.add(:review, :post, '/authors/:author_id:/books/:book_id:')
|
57
|
+
end
|
58
|
+
|
59
|
+
it "calls the right url with the right method after adding it" do
|
60
|
+
must_send_request(:post, 'http://example.com/authors/', {name: 'Peter Lustig'}, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
61
|
+
@client.post(@client.urls.author, TOKEN, name: 'Peter Lustig')
|
62
|
+
end
|
63
|
+
|
64
|
+
must_send_request(:get, 'http://example.com/authors/123', nil, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
65
|
+
@client.get(@client.urls.author(123), TOKEN)
|
66
|
+
end
|
67
|
+
|
68
|
+
must_send_request(:post, 'http://example.com/authors/123/books/456', {name: 'Ronald Review', comment: 'This book is the bomb!'}, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
69
|
+
@client.post(@client.urls.review(author_id: 123, book_id: 456), TOKEN, name: 'Ronald Review', comment: 'This book is the bomb!')
|
70
|
+
end
|
71
|
+
|
72
|
+
must_send_request(:post, 'http://example.com/authors/123/books/456', {name: 'Ronald Review', comment: 'This book is the bomb!'}, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
73
|
+
@client.post(@client.urls.review(123, 456), TOKEN, name: 'Ronald Review', comment: 'This book is the bomb!')
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
it "uses query parameters instead of JSON bodies for GET requests" do
|
78
|
+
must_send_request(:get, 'http://example.com/authors/123?some=arguments&are=cool%26not%3Dbody&array%5B%5D=12&array%5B%5D=gla&hash%5Bblub%5D=123&hash%5Bbla%5D=moep', nil, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
79
|
+
@client.get(@client.urls.author(123), TOKEN, {some: 'arguments', are: "cool¬=body", array: ["12", 'gla'], hash: {"blub" => "123", "bla" => "moep"}})
|
80
|
+
end
|
81
|
+
|
82
|
+
must_send_request(:get, 'http://example.com/authors/123/another-fixed-part?blub=1&some=arguments&are=cool%26not%3Dbody', nil, headers: {'AUTHORIZATION' => "Bearer #{TOKEN}", 'Content-Type' => 'application/json'}) do
|
83
|
+
@client.get(@client.urls.author_with_query(123), TOKEN, {some: 'arguments', are: "cool¬=body"})
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
it "raises an error when no route for a given method/resource combination exist" do
|
88
|
+
lambda {
|
89
|
+
@client.post(@client.urls.author(123), TOKEN)
|
90
|
+
}.must_raise Service::Client::RoutingError
|
91
|
+
|
92
|
+
lambda {
|
93
|
+
@client.get(@client.urls.author, TOKEN)
|
94
|
+
}.must_raise Service::Client::RoutingError
|
95
|
+
|
96
|
+
lambda {
|
97
|
+
@client.get(@client.urls.review(123, 456), TOKEN, name: 'Ronald Review', comment: 'This book is the bomb!')
|
98
|
+
}.must_raise Service::Client::RoutingError
|
99
|
+
|
100
|
+
lambda {
|
101
|
+
@client.get(@client.urls.comments, TOKEN)
|
102
|
+
}.must_raise Service::Client::RoutingError
|
103
|
+
end
|
104
|
+
|
105
|
+
describe "responses" do
|
106
|
+
describe "successful" do
|
107
|
+
statuses = [200, 201]
|
108
|
+
statuses.each do |status|
|
109
|
+
describe "with status #{status}" do
|
110
|
+
before do
|
111
|
+
@body = JSON.dump(name: 'Peter Lustig', age: 76)
|
112
|
+
@headers = {}
|
113
|
+
raw_response = Rack::Response.new(@body, status, @headers)
|
114
|
+
@client.raw.adapter.expect :request, raw_response, [:get, 'http://example.com/authors/123', '', {}]
|
115
|
+
@response = @client.get(@client.urls.author(123), TOKEN)
|
116
|
+
end
|
117
|
+
|
118
|
+
it "has the raw data" do
|
119
|
+
@response.raw.status.must_equal status
|
120
|
+
@response.raw.body.must_equal [@body]
|
121
|
+
@headers.each do |key, value|
|
122
|
+
@response.raw.header[key].must_equal value
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
it "parses them" do
|
127
|
+
@response.data['name'].must_equal 'Peter Lustig'
|
128
|
+
@response.data['age'].must_equal 76
|
129
|
+
end
|
130
|
+
|
131
|
+
it "returns true for data if the response is empty" do
|
132
|
+
raw_response = Rack::Response.new('', status, {})
|
133
|
+
@client.raw.adapter.expect :request, raw_response, [:get, 'http://example.com/authors/456', '', {}]
|
134
|
+
@response = @client.get(@client.urls.author(456), TOKEN)
|
135
|
+
@response.data.must_equal true
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe "redirections" do
|
142
|
+
statuses = [301, 302, 303, 307]
|
143
|
+
statuses.each do |status|
|
144
|
+
it "raises a Service::Client::Redirection with the location for HTTP status #{status}" do
|
145
|
+
@client.raw.adapter.expect :request, Rack::Response.new('', status, {Location: 'http://example.com/somewhere/else'}), [:get, 'http://example.com/authors/123', '', {}]
|
146
|
+
|
147
|
+
begin
|
148
|
+
@client.get(@client.urls.author(123), TOKEN)
|
149
|
+
flunk "Must raise Service::Client::Redirection but didn't!"
|
150
|
+
rescue Service::Client::Redirection => e
|
151
|
+
e.location.must_equal 'http://example.com/somewhere/else'
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
describe "errors" do
|
158
|
+
describe "when error field is present" do
|
159
|
+
before do
|
160
|
+
@body = JSON.dump(error: 'This is why!')
|
161
|
+
end
|
162
|
+
|
163
|
+
error_states = [400, 401, 403, 404, 500]
|
164
|
+
error_states.each do |error_state|
|
165
|
+
it "raises an Service::Client::ServiceError for HTTP status code #{error_state}" do
|
166
|
+
raw_response = Rack::Response.new(@body, error_state, {})
|
167
|
+
@client.raw.adapter.expect :request, raw_response, [:get, 'http://example.com/authors/123', @body, {}]
|
168
|
+
|
169
|
+
begin
|
170
|
+
@client.get(@client.urls.author(123), TOKEN)
|
171
|
+
flunk "Must raise Service::Client::ServiceError but didn't!"
|
172
|
+
rescue Service::Client::ServiceError => e
|
173
|
+
e.error.must_equal 'This is why!'
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
describe "raises Service::Client::ResponseError when body is" do
|
180
|
+
it "empty" do
|
181
|
+
must_raise_response_error('')
|
182
|
+
end
|
183
|
+
|
184
|
+
it "invalid JSON" do
|
185
|
+
must_raise_response_error('some stuff but not json')
|
186
|
+
end
|
187
|
+
|
188
|
+
it "valid JSON but has no error field" do
|
189
|
+
must_raise_response_error(JSON.dump(missing: 'error', field: true))
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: service-client
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.14
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Thorben Schröder
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-08-11 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: faraday
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - '='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.8.1
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - '='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 0.8.1
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: json
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
description: Service::Client is a generic client gem to access our services. It is
|
47
|
+
the base for explicit clients for each service so that those explicit clients are
|
48
|
+
easy and fast to implement and maintain.
|
49
|
+
email:
|
50
|
+
- stillepost@gmail.com
|
51
|
+
executables: []
|
52
|
+
extensions: []
|
53
|
+
extra_rdoc_files: []
|
54
|
+
files:
|
55
|
+
- .gitignore
|
56
|
+
- .rvmrc
|
57
|
+
- .travis.yml
|
58
|
+
- CHANGELOG.md
|
59
|
+
- Gemfile
|
60
|
+
- Guardfile
|
61
|
+
- LICENSE
|
62
|
+
- README.md
|
63
|
+
- Rakefile
|
64
|
+
- lib/service-client.rb
|
65
|
+
- lib/service-client/adapter/faraday.rb
|
66
|
+
- lib/service-client/base_response.rb
|
67
|
+
- lib/service-client/bound_route.rb
|
68
|
+
- lib/service-client/error.rb
|
69
|
+
- lib/service-client/raw_interface.rb
|
70
|
+
- lib/service-client/redirection.rb
|
71
|
+
- lib/service-client/response.rb
|
72
|
+
- lib/service-client/response_error.rb
|
73
|
+
- lib/service-client/route.rb
|
74
|
+
- lib/service-client/route_collection.rb
|
75
|
+
- lib/service-client/routing_error.rb
|
76
|
+
- lib/service-client/service_error.rb
|
77
|
+
- lib/service-client/url_pattern.rb
|
78
|
+
- lib/service-client/version.rb
|
79
|
+
- service-client.gemspec
|
80
|
+
- spec/adapter/faraday_spec.rb
|
81
|
+
- spec/adapter/test_server.ru
|
82
|
+
- spec/client_spec.rb
|
83
|
+
- spec/spec_helper.rb
|
84
|
+
homepage: ''
|
85
|
+
licenses: []
|
86
|
+
post_install_message:
|
87
|
+
rdoc_options: []
|
88
|
+
require_paths:
|
89
|
+
- lib
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
91
|
+
none: false
|
92
|
+
requirements:
|
93
|
+
- - ! '>='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubyforge_project:
|
104
|
+
rubygems_version: 1.8.25
|
105
|
+
signing_key:
|
106
|
+
specification_version: 3
|
107
|
+
summary: Service::Client is a generic client gem to access our services.
|
108
|
+
test_files:
|
109
|
+
- spec/adapter/faraday_spec.rb
|
110
|
+
- spec/adapter/test_server.ru
|
111
|
+
- spec/client_spec.rb
|
112
|
+
- spec/spec_helper.rb
|