hyperclient 0.8.5 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env rake
2
+
2
3
  require 'rubygems'
3
4
  require 'bundler'
4
5
  Bundler.setup :default, :test, :development
@@ -13,13 +14,6 @@ if ENV['COVERAGE']
13
14
  end
14
15
  end
15
16
 
16
- require 'yard'
17
- YARD::Config.load_plugin('yard-tomdoc')
18
- YARD::Rake::YardocTask.new do |t|
19
- t.files = ['lib/**/*.rb']
20
- t.options = %w(-r README.md)
21
- end
22
-
23
17
  require 'rake/testtask'
24
18
 
25
19
  Rake::TestTask.new(:test) do |t|
@@ -38,4 +32,4 @@ end
38
32
  require 'rubocop/rake_task'
39
33
  RuboCop::RakeTask.new(:rubocop)
40
34
 
41
- task default: [:rubocop, :test, :spinach]
35
+ task default: %i[test spinach rubocop]
@@ -1,6 +1,22 @@
1
1
  Upgrading Hyperclient
2
2
  =====================
3
3
 
4
+ ### Upgrading to >= 0.9.0
5
+
6
+ Previous versions of Hyperclient performed asynchronous requests using [futuroscope](https://github.com/codegram/futuroscope) by default, which could be disabled by providing the `:async` option to each Hyperclient instance. This has been removed and you can remove any such code.
7
+
8
+ ```ruby
9
+ api = Hyperclient.new('https://grape-with-roar.herokuapp.com/api') do |client|
10
+ client.options[:async] = false
11
+ end
12
+ ```
13
+
14
+ The default new behavior is synchronous. We recommend [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby) for your asynchronous needs.
15
+
16
+ Include [futuroscope](https://github.com/codegram/futuroscope) in your `Gemfile` and wrap Hyperclient requests into `Futuroscope::Future.new` blocks to get the old behavior.
17
+
18
+ See [#133](https://github.com/codegram/hyperclient/pull/133) and [#123](https://github.com/codegram/hyperclient/issues/123) for more information.
19
+
4
20
  ### Upgrading to >= 0.8.0
5
21
 
6
22
  ### Changes in curies
@@ -7,6 +7,11 @@ Feature: API navigation
7
7
  When I connect to the API
8
8
  Then I should be able to navigate to posts and authors
9
9
 
10
+ Scenario: Links
11
+ When I connect to the API
12
+ Then I should be able to paginate posts
13
+ Then I should be able to paginate authors
14
+
10
15
  Scenario: Templated links
11
16
  Given I connect to the API
12
17
  When I search for a post with a templated link
@@ -9,6 +9,19 @@ class Spinach::Features::ApiNavigation < Spinach::FeatureSteps
9
9
  assert_requested :get, 'http://api.example.org/authors'
10
10
  end
11
11
 
12
+ step 'I should be able to paginate posts' do
13
+ assert_kind_of Enumerator, api.posts.each
14
+ assert_equal 4, api.posts.to_a.count
15
+ assert_requested :get, 'http://api.example.org/posts'
16
+ assert_requested :get, 'http://api.example.org/posts?page=2'
17
+ assert_requested :get, 'http://api.example.org/posts?page=3'
18
+ end
19
+
20
+ step 'I should be able to paginate authors' do
21
+ assert_equal 1, api._links['api:authors'].to_a.count
22
+ assert_requested :get, 'http://api.example.org/authors'
23
+ end
24
+
12
25
  step 'I search for a post with a templated link' do
13
26
  api._links.search._expand(q: 'something')._resource
14
27
  end
@@ -18,7 +31,7 @@ class Spinach::Features::ApiNavigation < Spinach::FeatureSteps
18
31
  end
19
32
 
20
33
  step 'I search for posts by tag with a templated link' do
21
- api._links.tagged._expand(tags: %w(foo bar))._resource
34
+ api._links.tagged._expand(tags: %w[foo bar])._resource
22
35
  end
23
36
 
24
37
  step 'the API should receive the request for posts by tag with all the params' do
@@ -50,8 +63,8 @@ class Spinach::Features::ApiNavigation < Spinach::FeatureSteps
50
63
  step 'I should be able to count embedded items' do
51
64
  assert_equal 2, api._links.posts._resource._embedded.posts.count
52
65
  assert_equal 2, api.posts._embedded.posts.count
53
- assert_equal 2, api.posts.count
54
- assert_equal 2, api.posts.map.count
66
+ assert_equal 4, api.posts.count
67
+ assert_equal 4, api.posts.map.count
55
68
  end
56
69
 
57
70
  step 'I should be able to iterate over embedded items' do
@@ -59,6 +72,6 @@ class Spinach::Features::ApiNavigation < Spinach::FeatureSteps
59
72
  api.posts.each do |_post|
60
73
  count += 1
61
74
  end
62
- assert_equal 2, count
75
+ assert_equal 4, count
63
76
  end
64
77
  end
@@ -12,7 +12,7 @@ class Spinach::Features::DefaultConfig < Spinach::FeatureSteps
12
12
  end
13
13
 
14
14
  step 'I send some data to the API' do
15
- stub_request(:post, 'http://api.example.org/posts')
15
+ stub_request(:post, 'http://api.example.org/posts').to_return(headers: { 'Content-Type' => 'application/hal+json' })
16
16
  assert_equal 200, api._links.posts._post(title: 'My first blog post')._response.status
17
17
  end
18
18
 
@@ -25,7 +25,7 @@ class Spinach::Features::DefaultConfig < Spinach::FeatureSteps
25
25
  end
26
26
 
27
27
  step 'it should have been parsed as JSON' do
28
- @posts._attributes.total_posts.to_i.must_equal 2
29
- @posts._attributes['total_posts'].to_i.must_equal 2
28
+ @posts._attributes.total_posts.to_i.must_equal 4
29
+ @posts._attributes['total_posts'].to_i.must_equal 4
30
30
  end
31
31
  end
@@ -7,9 +7,15 @@ module API
7
7
  before do
8
8
  WebMock::Config.instance.query_values_notation = :flat_array
9
9
 
10
- stub_request(:any, %r{api.example.org*}).to_return(body: root_response, headers: { 'Content-Type' => 'application/hal+json' })
10
+ stub_request(:any, /api.example.org*/).to_return(body: root_response, headers: { 'Content-Type' => 'application/hal+json' })
11
+ stub_request(:get, 'api.example.org').to_return(body: root_response, headers: { 'Content-Type' => 'application/hal+json' })
12
+ stub_request(:get, 'api.example.org/authors').to_return(body: authors_response, headers: { 'Content-Type' => 'application/hal+json' })
11
13
  stub_request(:get, 'api.example.org/posts').to_return(body: posts_response, headers: { 'Content-Type' => 'application/hal+json' })
12
- stub_request(:get, 'api.example.org/posts/1').to_return(body: post_response, headers: { 'Content-Type' => 'application/hal+json' })
14
+ stub_request(:get, 'api.example.org/posts?page=2').to_return(body: posts_page2_response, headers: { 'Content-Type' => 'application/hal+json' })
15
+ stub_request(:get, 'api.example.org/posts?page=3').to_return(body: posts_page3_response, headers: { 'Content-Type' => 'application/hal+json' })
16
+ stub_request(:get, 'api.example.org/posts/1').to_return(body: post1_response, headers: { 'Content-Type' => 'application/hal+json' })
17
+ stub_request(:get, 'api.example.org/posts/2').to_return(body: post2_response, headers: { 'Content-Type' => 'application/hal+json' })
18
+ stub_request(:get, 'api.example.org/posts/3').to_return(body: post3_response, headers: { 'Content-Type' => 'application/hal+json' })
13
19
  stub_request(:get, 'api.example.org/page2').to_return(body: page2_response, headers: { 'Content-Type' => 'application/hal+json' })
14
20
  stub_request(:get, 'api.example.org/page3').to_return(body: page3_response, headers: { 'Content-Type' => 'application/hal+json' })
15
21
  end
@@ -15,13 +15,32 @@ module Spinach
15
15
  }'
16
16
  end
17
17
 
18
+ def authors_response
19
+ '{
20
+ "_links": {
21
+ "self": { "href": "/authors" }
22
+ },
23
+ "_embedded": {
24
+ "api:authors": [
25
+ {
26
+ "name": "Lorem Ipsum",
27
+ "_links": {
28
+ "self": { "href": "/authors/1" }
29
+ }
30
+ }
31
+ ]
32
+ }
33
+ }'
34
+ end
35
+
18
36
  def posts_response
19
37
  '{
20
38
  "_links": {
21
39
  "self": { "href": "/posts" },
40
+ "next": {"href": "/posts?page=2"},
22
41
  "last_post": {"href": "/posts/1"}
23
42
  },
24
- "total_posts": "2",
43
+ "total_posts": "4",
25
44
  "_embedded": {
26
45
  "posts": [
27
46
  {
@@ -43,7 +62,48 @@ module Spinach
43
62
  }'
44
63
  end
45
64
 
46
- def post_response
65
+ def posts_page2_response
66
+ '{
67
+ "_links": {
68
+ "self": { "href": "/posts?page=2" },
69
+ "next": { "href": "/posts?page=3" }
70
+ },
71
+ "total_posts": "4",
72
+ "_embedded": {
73
+ "posts": [
74
+ {
75
+ "title": "My third blog post",
76
+ "body": "Lorem ipsum dolor sit amet",
77
+ "_links": {
78
+ "self": { "href": "/posts/3" }
79
+ }
80
+ }
81
+ ]
82
+ }
83
+ }'
84
+ end
85
+
86
+ def posts_page3_response
87
+ '{
88
+ "_links": {
89
+ "self": { "href": "/posts?page=3" }
90
+ },
91
+ "total_posts": "4",
92
+ "_embedded": {
93
+ "posts": [
94
+ {
95
+ "title": "My third blog post",
96
+ "body": "Lorem ipsum dolor sit amet",
97
+ "_links": {
98
+ "self": { "href": "/posts/4" }
99
+ }
100
+ }
101
+ ]
102
+ }
103
+ }'
104
+ end
105
+
106
+ def post1_response
47
107
  '{
48
108
  "_links": {
49
109
  "self": { "href": "/posts/1" }
@@ -60,6 +120,40 @@ module Spinach
60
120
  }'
61
121
  end
62
122
 
123
+ def post2_response
124
+ '{
125
+ "_links": {
126
+ "self": { "href": "/posts/2" }
127
+ },
128
+ "title": "My first blog post",
129
+ "body": "Lorem ipsum dolor sit amet",
130
+ "_embedded": {
131
+ "comments": [
132
+ {
133
+ "title": "Some comment"
134
+ }
135
+ ]
136
+ }
137
+ }'
138
+ end
139
+
140
+ def post3_response
141
+ '{
142
+ "_links": {
143
+ "self": { "href": "/posts/3" }
144
+ },
145
+ "title": "My first blog post",
146
+ "body": "Lorem ipsum dolor sit amet",
147
+ "_embedded": {
148
+ "comments": [
149
+ {
150
+ "title": "Some comment"
151
+ }
152
+ ]
153
+ }
154
+ }'
155
+ end
156
+
63
157
  def page2_response
64
158
  '{
65
159
  "_links": {
@@ -1,10 +1,9 @@
1
- # -*- encoding: utf-8 -*-
2
- require File.expand_path('../lib/hyperclient/version', __FILE__)
1
+ require File.expand_path('lib/hyperclient/version', __dir__)
3
2
 
4
3
  Gem::Specification.new do |gem|
5
4
  gem.authors = ['Oriol Gual']
6
5
  gem.email = ['oriol.gual@gmail.com']
7
- gem.description = 'HyperClient is a Ruby Hypermedia API client.'
6
+ gem.description = 'Hyperclient is a Ruby Hypermedia API client.'
8
7
  gem.summary = ''
9
8
  gem.homepage = 'https://github.com/codegram/hyperclient/'
10
9
  gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
@@ -14,11 +13,8 @@ Gem::Specification.new do |gem|
14
13
  gem.require_paths = ['lib']
15
14
  gem.version = Hyperclient::VERSION
16
15
 
16
+ gem.add_dependency 'addressable'
17
17
  gem.add_dependency 'faraday', '>= 0.9.0'
18
- gem.add_dependency 'futuroscope'
19
- gem.add_dependency 'faraday_middleware'
20
18
  gem.add_dependency 'faraday_hal_middleware'
21
- gem.add_dependency 'uri_template'
22
- gem.add_dependency 'net-http-digest_auth'
23
- gem.add_dependency 'faraday-digestauth'
19
+ gem.add_dependency 'faraday_middleware'
24
20
  end
@@ -35,7 +35,7 @@ module Hyperclient
35
35
  end
36
36
 
37
37
  # Public: Returns a value from the collection for the given key.
38
- # If the key cant be found, there are several options:
38
+ # If the key can't be found, there are several options:
39
39
  # With no other arguments, it will raise an KeyError exception;
40
40
  # if default is given, then that will be returned;
41
41
  #
@@ -41,7 +41,8 @@ module Hyperclient
41
41
  # Returns a new expanded url.
42
42
  def expand(rel)
43
43
  return rel unless rel && templated?
44
- href.gsub('{rel}', rel) if href
44
+
45
+ href&.gsub('{rel}', rel)
45
46
  end
46
47
  end
47
48
  end
@@ -1,6 +1,5 @@
1
1
  require 'faraday_middleware'
2
2
  require 'faraday_hal_middleware'
3
- require_relative '../faraday/connection'
4
3
 
5
4
  module Hyperclient
6
5
  # Public: Exception that is raised when trying to modify an
@@ -30,7 +29,7 @@ module Hyperclient
30
29
  extend Forwardable
31
30
 
32
31
  # Public: Delegates common methods to be used with the Faraday connection.
33
- def_delegators :connection, :basic_auth, :digest_auth, :token_auth, :params, :params=
32
+ def_delegators :connection, :params, :params=
34
33
 
35
34
  # Public: Initializes an EntryPoint.
36
35
  #
@@ -38,9 +37,11 @@ module Hyperclient
38
37
  def initialize(url, &_block)
39
38
  @link = { 'href' => url }
40
39
  @entry_point = self
41
- @options = { async: true }
40
+ @options = {}
42
41
  @connection = nil
43
42
  @resource = nil
43
+ @key = nil
44
+ @uri_variables = nil
44
45
  yield self if block_given?
45
46
  end
46
47
 
@@ -55,12 +56,12 @@ module Hyperclient
55
56
  @faraday_options ||= options.dup
56
57
  if block_given?
57
58
  raise ConnectionAlreadyInitializedError if @connection
59
+
58
60
  @faraday_block = if @faraday_options.delete(:default) == false
59
61
  block
60
62
  else
61
63
  lambda do |conn|
62
- default_faraday_block.call conn
63
- yield conn
64
+ default_faraday_block.call(conn, &block)
64
65
  end
65
66
  end
66
67
  else
@@ -73,6 +74,7 @@ module Hyperclient
73
74
  # Returns a Hash.
74
75
  def headers
75
76
  return @connection.headers if @connection
77
+
76
78
  @headers ||= default_headers
77
79
  end
78
80
 
@@ -81,6 +83,7 @@ module Hyperclient
81
83
  # value - A Hash containing headers to include with every API request.
82
84
  def headers=(value)
83
85
  raise ConnectionAlreadyInitializedError if @connection
86
+
84
87
  @headers = value
85
88
  end
86
89
 
@@ -96,6 +99,7 @@ module Hyperclient
96
99
  # value - A Hash containing options to pass to Faraday
97
100
  def faraday_options=(value)
98
101
  raise ConnectionAlreadyInitializedError if @connection
102
+
99
103
  @faraday_options = value
100
104
  end
101
105
 
@@ -111,14 +115,13 @@ module Hyperclient
111
115
  # value - A Proc accepting a Faraday::Connection.
112
116
  def faraday_block=(value)
113
117
  raise ConnectionAlreadyInitializedError if @connection
118
+
114
119
  @faraday_block = value
115
120
  end
116
121
 
117
122
  # Public: Read/Set options.
118
123
  #
119
- # value - A Hash containing the client options. Use { async: false } to
120
- # to disable the default behavior of performing requests asynchronously
121
- # using futures.
124
+ # value - A Hash containing the client options.
122
125
  attr_accessor :options
123
126
 
124
127
  private
@@ -133,11 +136,14 @@ module Hyperclient
133
136
  #
134
137
  # Returns a block.
135
138
  def default_faraday_block
136
- lambda do |connection|
139
+ lambda do |connection, &block|
137
140
  connection.use Faraday::Response::RaiseError
138
141
  connection.use FaradayMiddleware::FollowRedirects
139
142
  connection.request :hal_json
140
143
  connection.response :hal_json, content_type: /\bjson$/
144
+
145
+ block&.call(connection)
146
+
141
147
  connection.adapter :net_http
142
148
  connection.options.params_encoder = Faraday::FlatParamsEncoder
143
149
  end
@@ -1,10 +1,11 @@
1
- require 'uri_template'
2
- require 'futuroscope'
1
+ require 'addressable'
3
2
 
4
3
  module Hyperclient
5
4
  # Internal: The Link is used to let a Resource interact with the API.
6
5
  #
7
6
  class Link
7
+ include Enumerable
8
+
8
9
  # Public: Initializes a new Link.
9
10
  #
10
11
  # key - The key or name of the link.
@@ -20,6 +21,25 @@ module Hyperclient
20
21
  @resource = nil
21
22
  end
22
23
 
24
+ # Public: Each implementation to allow the class to use the Enumerable
25
+ # benefits for paginated, embedded items.
26
+ #
27
+ # Returns an Enumerator.
28
+ def each(&block)
29
+ if block_given?
30
+ current = self
31
+ while current
32
+ coll = current.respond_to?(@key) ? current.send(@key) : _resource
33
+ coll.each(&block)
34
+ break unless current._links[:next]
35
+
36
+ current = current._links.next
37
+ end
38
+ else
39
+ to_enum(:each)
40
+ end
41
+ end
42
+
23
43
  # Public: Indicates if the link is an URITemplate or a regular URI.
24
44
  #
25
45
  # Returns true if it is templated.
@@ -40,7 +60,8 @@ module Hyperclient
40
60
  # Public: Returns the url of the Link.
41
61
  def _url
42
62
  return @link['href'] unless _templated?
43
- @url ||= _uri_template.expand(@uri_variables || {})
63
+
64
+ @url ||= _uri_template.expand(@uri_variables || {}).to_s
44
65
  end
45
66
 
46
67
  # Public: Returns an array of variables from the URITemplate.
@@ -126,7 +147,8 @@ module Hyperclient
126
147
  # Internal: Delegate the method further down the API if the resource cannot serve it.
127
148
  def method_missing(method, *args, &block)
128
149
  if _resource.respond_to?(method.to_s)
129
- _resource.send(method, *args, &block) || delegate_method(method, *args, &block)
150
+ result = _resource.send(method, *args, &block)
151
+ result.nil? ? delegate_method(method, *args, &block) : result
130
152
  else
131
153
  super
132
154
  end
@@ -137,8 +159,10 @@ module Hyperclient
137
159
  # This allows `api.posts` instead of `api._links.posts.embedded.posts`
138
160
  def delegate_method(method, *args, &block)
139
161
  return unless @key && _resource.respond_to?(@key)
162
+
140
163
  @delegate ||= _resource.send(@key)
141
- return unless @delegate && @delegate.respond_to?(method.to_s)
164
+ return unless @delegate&.respond_to?(method.to_s)
165
+
142
166
  @delegate.send(method, *args, &block)
143
167
  end
144
168
 
@@ -162,20 +186,12 @@ module Hyperclient
162
186
 
163
187
  # Internal: Memoization for a URITemplate instance
164
188
  def _uri_template
165
- @uri_template ||= URITemplate.new(@link['href'])
189
+ @uri_template ||= Addressable::Template.new(@link['href'])
166
190
  end
167
191
 
168
192
  def http_method(method, body = nil)
169
193
  @resource = begin
170
- response =
171
- if @entry_point.options[:async]
172
- Futuroscope::Future.new do
173
- @entry_point.connection.run_request(method, _url, body, nil)
174
- end
175
- else
176
- @entry_point.connection.run_request(method, _url, body, nil)
177
- end
178
-
194
+ response = @entry_point.connection.run_request(method, _url, body, nil)
179
195
  Resource.new(response.body, @entry_point, response)
180
196
  end
181
197
  end