restify 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,142 @@
1
+ require 'rack/utils'
2
+
3
+ module Restify
4
+ #
5
+ # A {Response} is returned from an {Adapter} and described
6
+ # a HTTP response. That includes status code, headers and
7
+ # body.
8
+ #
9
+ # A {Response} is also responsible for decoding its body
10
+ # according its content type.
11
+ #
12
+ class Response
13
+ #
14
+ # Map of status symbols to codes. From Rack::Utils.
15
+ #
16
+ # @example
17
+ # SYMBOL_TO_STATUS_CODE[:ok] #=> 200
18
+ #
19
+ SYMBOL_TO_STATUS_CODE = Rack::Utils::SYMBOL_TO_STATUS_CODE
20
+
21
+ # Map of status codes to symbols.
22
+ #
23
+ # @example
24
+ # STATUS_CODE_TO_SYMBOL[200] #=> :ok
25
+ #
26
+ STATUS_CODE_TO_SYMBOL = SYMBOL_TO_STATUS_CODE.invert
27
+
28
+ # Response body as string.
29
+ #
30
+ # @return [String] Response body.
31
+ #
32
+ attr_reader :body
33
+
34
+ # Response headers as hash.
35
+ #
36
+ # @return [Hash<String, String>] Response headers.
37
+ #
38
+ attr_reader :headers
39
+
40
+ # Response status code.
41
+ #
42
+ # @return [Fixnum] Status code.
43
+ #
44
+ attr_reader :code
45
+
46
+ # Response status symbol.
47
+ #
48
+ # @example
49
+ # response.status #=> :ok
50
+ #
51
+ # @return [Symbol] Status symbol.
52
+ #
53
+ attr_reader :status
54
+
55
+ # Response status message.
56
+ #
57
+ # @return [String] Status message.
58
+ #
59
+ attr_reader :message
60
+
61
+ # The request that led to this response.
62
+ #
63
+ # @return [Request] Request object.
64
+ #
65
+ attr_reader :request
66
+
67
+ # @api private
68
+ #
69
+ def initialize(request, code, headers, body)
70
+ @request = request
71
+ @code = code
72
+ @status = STATUS_CODE_TO_SYMBOL[code]
73
+ @headers = headers
74
+ @body = body
75
+ @message = Rack::Utils::HTTP_STATUS_CODES[code]
76
+ end
77
+
78
+ # Return URL of this response.
79
+ #
80
+ def url
81
+ request.uri
82
+ end
83
+
84
+ # Return list of links from the Link header.
85
+ #
86
+ # @return [Array<Link>] Links.
87
+ #
88
+ def links
89
+ @links ||= begin
90
+ if headers['Link']
91
+ begin
92
+ Link.parse(headers['Link'])
93
+ rescue ArgumentError => e
94
+ warn e
95
+ []
96
+ end
97
+ else
98
+ []
99
+ end
100
+ end
101
+ end
102
+
103
+ # Return list of relations extracted from links.
104
+ #
105
+ # @return [Array<Relation>] Relations.
106
+ #
107
+ def relations(client)
108
+ relations = {}
109
+ links.each do |link|
110
+ if (rel = link.metadata['rel'])
111
+ relations[rel] = Relation.new(client, link.uri)
112
+ end
113
+ end
114
+ relations
115
+ end
116
+
117
+ # Return decoded body according to content type.
118
+ # Will return `nil` if content cannot be decoded.
119
+ #
120
+ # @return [Array, Hash, NilClass] Decoded response body.
121
+ #
122
+ def decoded_body
123
+ @decoded_body ||= begin
124
+ case headers['Content-Type']
125
+ when /\Aapplication\/json($|;)/
126
+ MultiJson.load body
127
+ else
128
+ nil
129
+ end
130
+ end
131
+ end
132
+
133
+ # Check if response is successful e.g. the status code
134
+ # is on of 2XX.
135
+ #
136
+ # @return [Boolean] True if status code is 2XX otherwise false.
137
+ #
138
+ def success?
139
+ (200...300) === code
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,13 @@
1
+ module Restify
2
+ module VERSION
3
+ MAJOR = 0
4
+ MINOR = 1
5
+ PATCH = 0
6
+ STAGE = nil
7
+ STRING = [MAJOR, MINOR, PATCH, STAGE].reject(&:nil?).join('.').freeze
8
+
9
+ def self.to_s
10
+ STRING
11
+ end
12
+ end
13
+ end
data/restify.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'restify/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'restify'
8
+ spec.version = Restify::VERSION
9
+ spec.authors = ['Jan Graichen']
10
+ spec.email = ['jg@altimos.de']
11
+ spec.summary = %q{An experimental hypermedia REST client that uses parallel, keep-alive and pipelined requests by default.}
12
+ spec.description = %q{An experimental hypermedia REST client that uses parallel, keep-alive and pipelined requests by default.}
13
+ spec.homepage = 'https://github.com/jgraichen/restify'
14
+ spec.license = 'LGPLv3'
15
+
16
+ spec.files = Dir['**/*'].grep(%r{^((bin|lib|test|spec|features)/|.*\.gemspec|.*LICENSE.*|.*README.*|.*CHANGELOG.*)})
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_runtime_dependency 'obligation', '~> 0.1'
22
+ spec.add_runtime_dependency 'addressable', '~> 2.3'
23
+ spec.add_runtime_dependency 'em-http-request', '~> 1.1'
24
+ spec.add_runtime_dependency 'activesupport', '>= 3.2', '< 5'
25
+ spec.add_runtime_dependency 'multi_json'
26
+ spec.add_runtime_dependency 'rack'
27
+
28
+ spec.add_development_dependency 'bundler', '~> 1.5'
29
+ end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ describe Restify::Link do
4
+ describe 'class' do
5
+ describe '#parse' do
6
+ it 'should parse link with quotes' do
7
+ links = described_class
8
+ .parse('<http://example.org/search{?query}>; rel="search"')
9
+
10
+ expect(links).to have(1).item
11
+ expect(links[0].uri.pattern).to eq 'http://example.org/search{?query}'
12
+ expect(links[0].metadata).to eq 'rel' => 'search'
13
+ end
14
+
15
+ it 'should parse link without quotes' do
16
+ links = described_class
17
+ .parse('<http://example.org/search{?query}>; rel=search')
18
+
19
+ expect(links).to have(1).item
20
+ expect(links[0].uri.pattern).to eq 'http://example.org/search{?query}'
21
+ expect(links[0].metadata).to eq 'rel' => 'search'
22
+ end
23
+
24
+ it 'should parse multiple links' do
25
+ links = described_class
26
+ .parse('<p://h.tld/p>; rel=abc, <p://h.tld/b>; a=b; c="d"')
27
+
28
+ expect(links).to have(2).item
29
+ expect(links[0].uri.pattern).to eq 'p://h.tld/p'
30
+ expect(links[0].metadata).to eq 'rel' => 'abc'
31
+ expect(links[1].uri.pattern).to eq 'p://h.tld/b'
32
+ expect(links[1].metadata).to eq 'a' => 'b', 'c' => 'd'
33
+ end
34
+
35
+ it 'should parse link w/o meta' do
36
+ links = described_class.parse('<p://h.tld/b>')
37
+
38
+ expect(links[0].uri.pattern).to eq 'p://h.tld/b'
39
+ end
40
+
41
+ it 'should parse on invalid URI' do
42
+ links = described_class.parse('<hp://:&*^/fwbhg3>')
43
+
44
+ expect(links[0].uri.pattern).to eq 'hp://:&*^/fwbhg3'
45
+ end
46
+
47
+ it 'should error on invalid header' do
48
+ expect { described_class.parse('</>; rel="s", abc-invalid') }
49
+ .to raise_error ArgumentError, /Invalid token at \d+:/i
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,96 @@
1
+ require 'spec_helper'
2
+
3
+ describe Restify::Resource do
4
+ let(:client) { double 'client' }
5
+ let(:relations) { {} }
6
+ let(:attributes) { {} }
7
+ let(:res) { described_class.new(client, relations, attributes) }
8
+
9
+ describe '#rel?' do
10
+ before do
11
+ res.relations['users'] = true
12
+ res.relations[:projects] = true
13
+ end
14
+
15
+ it 'should match relations' do
16
+ expect(res.rel?(:users)).to eq true
17
+ expect(res.rel?('users')).to eq true
18
+ expect(res.rel?(:projects)).to eq true
19
+ expect(res.rel?('projects')).to eq true
20
+ expect(res.rel?('fuu')).to eq false
21
+
22
+ expect(res).to have_rel :users
23
+ expect(res).to have_rel :projects
24
+
25
+ expect(res.relation?(:users)).to eq true
26
+ expect(res.relation?('users')).to eq true
27
+ expect(res.relation?(:projects)).to eq true
28
+ expect(res.relation?('projects')).to eq true
29
+ expect(res.relation?('fuu')).to eq false
30
+
31
+ expect(res).to have_relation :users
32
+ expect(res).to have_relation :projects
33
+ end
34
+ end
35
+
36
+ describe '#rel' do
37
+ let(:users) { double 'users rel' }
38
+ let(:projects) { double 'projects rel' }
39
+ before do
40
+ res.relations['users'] = users
41
+ res.relations[:projects] = projects
42
+ end
43
+
44
+ it 'should return relation' do
45
+ expect(res.rel(:users)).to eq users
46
+ expect(res.rel('users')).to eq users
47
+ expect(res.rel(:projects)).to eq projects
48
+ expect(res.rel('projects')).to eq projects
49
+ expect { res.rel(:fuu) }.to raise_error KeyError
50
+
51
+ expect(res.relation(:users)).to eq users
52
+ expect(res.relation('users')).to eq users
53
+ expect(res.relation(:projects)).to eq projects
54
+ expect(res.relation('projects')).to eq projects
55
+ expect { res.relation(:fuu) }.to raise_error KeyError
56
+ end
57
+ end
58
+
59
+ describe '#key?' do
60
+ let(:attributes) { {a: 0, 'b' => 1, 0 => 2} }
61
+
62
+ it 'should test for key inclusion' do
63
+ expect(res.key?(:a)).to eq true
64
+ expect(res.key?(:b)).to eq true
65
+ expect(res.key?('a')).to eq true
66
+ expect(res.key?('b')).to eq true
67
+ expect(res.key?(0)).to eq true
68
+
69
+ expect(res.key?(:c)).to eq false
70
+ expect(res.key?('d')).to eq false
71
+
72
+ expect(res).to have_key :a
73
+ expect(res).to have_key :b
74
+ expect(res).to have_key 'a'
75
+ expect(res).to have_key 'b'
76
+ expect(res).to have_key 0
77
+
78
+ expect(res).to_not have_key :c
79
+ expect(res).to_not have_key 'd'
80
+ end
81
+ end
82
+
83
+ describe '#each' do
84
+ let(:attributes) { {a: 0, b: 1} }
85
+
86
+ it 'should yield' do
87
+ expect { |cb| res.each(&cb) }.to yield_control.twice
88
+ expect { |cb| res.each(&cb) }.to yield_successive_args ['a', 0], ['b', 1]
89
+ end
90
+
91
+ it 'should return enumerator' do
92
+ expect(res.each).to be_a Enumerator
93
+ expect(res.each.to_a).to eq [['a', 0], ['b', 1]]
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,219 @@
1
+ require 'spec_helper'
2
+
3
+ describe Restify do
4
+ context 'as a dynamic HATEOAS client' do
5
+ before do
6
+ stub_request(:get, 'http://localhost/base').to_return do
7
+ <<-EOF.gsub(/^ {10}/, '')
8
+ HTTP/1.1 200 OK
9
+ Content-Type: application/json
10
+ Transfer-Encoding: chunked
11
+ Link: <http://localhost/base/users{/id}>; rel="users"
12
+ Link: <http://localhost/base/courses{/id}>; rel="courses"
13
+
14
+ {
15
+ "profile_url": "http://localhost/base/profile",
16
+ "search_url": "http://localhost/base/search?q={query}",
17
+ "mirror_url": null
18
+ }
19
+ EOF
20
+ end
21
+
22
+ stub_request(:get, 'http://localhost/base/users').to_return do
23
+ <<-EOF.gsub(/^ {10}/, '')
24
+ HTTP/1.1 200 OK
25
+ Content-Type: application/json
26
+ Transfer-Encoding: chunked
27
+
28
+ [{
29
+ "name": "John Smith",
30
+ "url": "http://localhost/base/users/john.smith",
31
+ "blurb_url": "http://localhost/base/users/john.smith/blurb"
32
+ },
33
+ {
34
+ "name": "Jane Smith",
35
+ "self_url": "http://localhost/base/user/jane.smith"
36
+ }]
37
+ EOF
38
+ end
39
+
40
+ stub_request(:post, 'http://localhost/base/users')
41
+ .with(body: {})
42
+ .to_return do
43
+ <<-EOF.gsub(/^ {12}/, '')
44
+ HTTP/1.1 422 Unprocessable Entity
45
+ Content-Type: application/json
46
+ Transfer-Encoding: chunked
47
+
48
+ {"errors":{"name":["can't be blank"]}}
49
+ EOF
50
+ end
51
+
52
+ stub_request(:post, 'http://localhost/base/users')
53
+ .with(body: {name: 'John Smith'})
54
+ .to_return do
55
+ <<-EOF.gsub(/^ {12}/, '')
56
+ HTTP/1.1 201 Created
57
+ Content-Type: application/json
58
+ Location: http://localhost/base/users/john.smith
59
+ Transfer-Encoding: chunked
60
+
61
+ {
62
+ "name": "John Smith",
63
+ "url": "http://localhost/base/users/john.smith",
64
+ "blurb_url": "http://localhost/base/users/john.smith/blurb"
65
+ }
66
+ EOF
67
+ end
68
+
69
+ stub_request(:get, 'http://localhost/base/users/john.smith')
70
+ .to_return do <<-EOF.gsub(/^ {10}/, '')
71
+ HTTP/1.1 200 OK
72
+ Content-Type: application/json
73
+ Link: <http://localhost/base/users/john.smith>; rel="self"
74
+ Transfer-Encoding: chunked
75
+
76
+ {
77
+ "name": "John Smith",
78
+ "url": "http://localhost/base/users/john.smith"
79
+ }
80
+ EOF
81
+ end
82
+
83
+ stub_request(:get, 'http://localhost/base/users/john.smith/blurb')
84
+ .to_return do <<-EOF.gsub(/^ {10}/, '')
85
+ HTTP/1.1 200 OK
86
+ Content-Type: application/json
87
+ Link: <http://localhost/base/users/john.smith>; rel="user"
88
+ Transfer-Encoding: chunked
89
+
90
+ {
91
+ "title": "Prof. Dr. John Smith",
92
+ "image": "http://example.org/avatar.png"
93
+ }
94
+ EOF
95
+ end
96
+ end
97
+
98
+ let(:c) do
99
+ Restify.new('http://localhost/base').value
100
+ end
101
+
102
+ context 'within threads' do
103
+ it 'should consume the API' do
104
+ # Let's get all users
105
+
106
+ # Therefore we need the `users` relations of our root
107
+ # resource.
108
+ users_relation = c.rel(:users)
109
+
110
+ # The relation is a `Restify::Relation` and provides
111
+ # method to enqueue e.g. GET or POST requests with
112
+ # parameters to fill in possible URI template placeholders.
113
+ expect(users_relation).to be_a Restify::Relation
114
+
115
+ # Let's create a user first.
116
+ # This method returns instantly and returns an `Obligation`.
117
+ # This `Obligation` represents the future value.
118
+ # We can pass parameters to a request. They will be used
119
+ # to expand the URI template behind the relation. Additional
120
+ # fields will be encoding in e.g. JSON and send if not a GET
121
+ # request.
122
+ create_user_promise = users_relation.post
123
+ expect(create_user_promise).to be_a Obligation
124
+
125
+ # We can do other things while the request is processed in
126
+ # the background. When we need the response with can call
127
+ # {#value} on the promise that will block the thread until
128
+ # the result is here.
129
+ begin
130
+ create_user_promise.value
131
+ rescue Restify::ClientError => e
132
+ # Because we forgot to send a "name" the server complains
133
+ # with an error code that will lead to a raised error.
134
+
135
+ expect(e.status).to eq :unprocessable_entity
136
+ expect(e.code).to eq 422
137
+ expect(e.errors).to eq 'name' => ["can't be blank"]
138
+ end
139
+
140
+ # Let's try again.
141
+ created_user = users_relation.post(name: 'John Smith').value
142
+
143
+ # The server returns a 201 Created response with the created
144
+ # resource.
145
+ expect(created_user.status).to eq :created
146
+ expect(created_user.code).to eq 201
147
+
148
+ expect(created_user).to have_key :name
149
+ expect(created_user[:name]).to eq 'John Smith'
150
+
151
+ # Let's follow the "Location" header.
152
+ followed_resource = created_user.follow.value
153
+
154
+ expect(followed_resource.status).to eq :ok
155
+ expect(followed_resource.code).to eq 200
156
+
157
+ expect(followed_resource).to have_key :name
158
+ expect(followed_resource[:name]).to eq 'John Smith'
159
+
160
+ # Now we will fetch a list of all users.
161
+ users = users_relation.get.value
162
+
163
+ # We get a collection back (Restify::Collection).
164
+ expect(users).to have(2).items
165
+
166
+ # Let's get the first one.
167
+ user = users.first
168
+
169
+ # We have all our attributes and relations here as defined in the
170
+ # responses from the server.
171
+ expect(user).to have_key :name
172
+ expect(user[:name]).to eq 'John Smith'
173
+ expect(user).to have_relation :self
174
+ expect(user).to have_relation :blurb
175
+
176
+ # Let's get the blurb.
177
+ blurb = user.rel(:blurb).get.value
178
+
179
+ expect(blurb).to have_key :title
180
+ expect(blurb).to have_key :image
181
+
182
+ expect(blurb[:title]).to eq 'Prof. Dr. John Smith'
183
+ expect(blurb[:image]).to eq 'http://example.org/avatar.png'
184
+ end
185
+ end
186
+
187
+ context 'within eventmachine' do
188
+ it 'should consume the API' do
189
+ pending
190
+
191
+ EventMachine.run do
192
+ users_promise = c.rel(:users).get
193
+ users_promise.then do |users|
194
+ expect(users).to have(2).items
195
+
196
+ user = users.first
197
+ expect(user).to have_key :name
198
+ expect(user[:name]).to eq 'John Smith'
199
+ expect(user).to have_relation :self
200
+ expect(user).to have_relation :blurb
201
+
202
+ user.rel(:blurb).get.then do |blurb|
203
+ expect(blurb).to have_key :title
204
+ expect(blurb).to have_key :image
205
+
206
+ expect(blurb[:title]).to eq 'Prof. Dr. John Smith'
207
+ expect(blurb[:image]).to eq 'http://example.org/avatar.png'
208
+
209
+ EventMachine.stop
210
+ @done = true
211
+ end
212
+ end
213
+ end
214
+
215
+ expect(@done).to be true
216
+ end
217
+ end
218
+ end
219
+ end