hyperclient 0.8.5 → 1.0.1

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.
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