restify 0.1.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,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