excon-hypermedia 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cb36e9f12e5909b7e4d4321184f39582882da139
4
- data.tar.gz: 3b6c39402e531bd8055c75a032aba0db380c6635
3
+ metadata.gz: 375d919cb0b7562e0664ae44c30d3bdd7b71acf3
4
+ data.tar.gz: 5d48e0eff3d6f00c2968bb32a330949833a81745
5
5
  SHA512:
6
- metadata.gz: 759eedec55a1da3d4aced37c4385dcafb38c44ba1e4636222398251808ac6972b52dc5962d471ee3547032605d204f7c2bff3e12d3395f1f6dfd82b3e41f08dc
7
- data.tar.gz: 08371cb0d025c67159f85481d2e2ef61cea5b8cb08e0dbf28917cd434f3fccdfb84ceaa0cef2af56d1a2f818c7b9f426f0fdd005731abb4b6ca66fb6b9b65e97
6
+ metadata.gz: cc9e8bfd092cef7babc130b59e8debd50eb84680a37d91da238ec84888ebdae22ea9808035cc324a52ba81833eb67daffb4a5cdd68856fe8c4209bb5d0ca5d77
7
+ data.tar.gz: 080f93835dba3d7648c37377293428dadaf1e60281511f8d014debf4dacb5f65555cb96de919461229c718fa0fa253481275a4beff9f9b383d0888184d9c0c56
data/.rubocop.yml CHANGED
@@ -5,3 +5,6 @@ AllCops:
5
5
 
6
6
  Metrics/LineLength:
7
7
  Max: 100
8
+
9
+ Lint/EndAlignment:
10
+ AlignWith: variable
data/README.md CHANGED
@@ -30,19 +30,20 @@ relations. It returns raw response bodies in string format.
30
30
 
31
31
  This gem adds a thin layer on top of [Excon][excon] to make it talk with an
32
32
  HyperMedia-enabled API. To let Excon know the connection supports HyperMedia,
33
- simply add the `hypermedia: true` option.
33
+ simply enable the correct middleware (either globally, or per-connection):
34
34
 
35
35
  ```ruby
36
- conn = Excon.new('http://www.example.com/api.json', hypermedia: true)
37
- conn.class # => Excon::Connection
36
+ Excon.defaults[:middlewares].push(Excon::HyperMedia::Middleware)
37
+
38
+ api = Excon.get('http://www.example.com/api.json')
39
+ api.class # => Excon::Response
38
40
  ```
39
41
 
40
- From that point on, you can use this single connection to make all requests. The
41
- `hypermedia` option will be passed on to all subsequent connection objects, as
42
- long as you keep chaining the requests from the original top-level connection.
42
+ Using the `HyperMedia` middleware, the `Excon::Response` object now knows how
43
+ to handle the HyperMedia aspect of the API:
43
44
 
44
45
  ```ruby
45
- product = conn.product(expand: { uid: 'hello' })
46
+ product = api.product(expand: { uid: 'hello' })
46
47
  product.class # => Excon::Connection
47
48
 
48
49
  response = product.get
@@ -53,16 +54,9 @@ response.body.class # => String
53
54
  As seen above, you can expand URI Template variables using the `expand` option,
54
55
  provided by the [`excon-addressable` library][excon-addressable].
55
56
 
56
- You can mark any connection object as hypermedia-aware not just the top-level
57
- entrypoint by passing in the `hypermedia: true` option:
58
-
59
- ```ruby
60
- user = Excon.new('http://www.example.com/users/jeanmertz', hypermedia: true)
61
- user.orders.class # => Excon::Connection
62
- ```
63
-
64
- Since each new resource is simply an `Excon::Connection` object, all
65
- [Excon-provided options][options] are available as well:
57
+ Since each new resource is simply an `Excon::Response` object, accessed through
58
+ the default `Excon::Connection` object, all [Excon-provided options][options]
59
+ are available as well:
66
60
 
67
61
  ```ruby
68
62
  product.get(idempotent: true, retry_limit: 6)
@@ -74,10 +68,8 @@ The gem is available as open source under the terms of the [MIT License](http://
74
68
 
75
69
  ## TODO
76
70
 
77
- * use Excon's Middleware system
78
71
  * make it easy to access attributes in response objects
79
72
  * properly handle curied-links and/or non-valid Ruby method name links
80
- * work correctly with Excon.get/post/delete shortcut methods
81
73
 
82
74
  [excon]: https://github.com/excon/excon
83
75
  [hypermedia]: https://en.wikipedia.org/wiki/HATEOAS
@@ -6,7 +6,7 @@ require 'excon/hypermedia/version'
6
6
 
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'excon-hypermedia'
9
- spec.version = Excon::Hypermedia::VERSION
9
+ spec.version = Excon::HyperMedia::VERSION
10
10
  spec.authors = %w(Jean Mertz)
11
11
  spec.email = %w(jean@mertz.fm)
12
12
 
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.add_development_dependency 'minitest', '~> 5.0'
23
23
  spec.add_development_dependency 'rubocop', '~> 0.40'
24
24
  spec.add_development_dependency 'pry', '~> 0.10'
25
+ spec.add_development_dependency 'm', '~> 1.5.0'
25
26
 
26
27
  spec.add_dependency 'excon', '~> 0.49'
27
28
  spec.add_dependency 'excon-addressable', '~> 0.1'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ module HyperMedia
5
+ module Ext
6
+ # Ext::Response
7
+ #
8
+ # Overloads the default `Excon::Response` to add a thin HyperMedia layer
9
+ # on top.
10
+ #
11
+ module Response
12
+ def method_missing(method_name, *params)
13
+ hypermedia_response.handle(method_name, *params) || super
14
+ end
15
+
16
+ def respond_to_missing?(method_name, include_private = false)
17
+ hypermedia_response.handle(method_name, *params) != false || super
18
+ end
19
+
20
+ def hypermedia_response
21
+ @hypermedia_response ||= HyperMedia::Response.new(self)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ # :nodoc:
28
+ class Response
29
+ prepend HyperMedia::Ext::Response
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ module HyperMedia
5
+ # Link
6
+ #
7
+ # This HyperMedia::Link object encapsulates a link pointing to a resource.
8
+ #
9
+ class Link
10
+ attr_reader :name
11
+
12
+ def initialize(name:, hash:)
13
+ @hash = hash
14
+ @name = name
15
+ end
16
+
17
+ def valid?
18
+ link_data.keys.any?
19
+ end
20
+
21
+ def invalid?
22
+ !valid?
23
+ end
24
+
25
+ def uri
26
+ ::Addressable::URI.parse(href)
27
+ end
28
+
29
+ def href
30
+ link_data['href']
31
+ end
32
+
33
+ private
34
+
35
+ def link_data
36
+ @hash.dig('_links', name.to_s) || {}
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ Excon.defaults[:middlewares].unshift(Excon::Addressable::Middleware)
4
+
5
+ module Excon
6
+ module HyperMedia
7
+ # Middleware
8
+ #
9
+ # This middleware sets the `hypermedia` datum to `true`, if the returned
10
+ # `Content-Type` header contains `hal+json`.
11
+ #
12
+ # If the `hypermedia` attribute is already set for the connection, it
13
+ # will be left alone by this middleware.
14
+ #
15
+ class Middleware < Excon::Middleware::Base
16
+ def request_call(datum)
17
+ return super unless (content_type = datum.dig(:response, :headers, 'Content-Type').to_s)
18
+
19
+ datum[:response][:hypermedia] = if datum[:hypermedia].nil?
20
+ content_type.include?('hal+json')
21
+ else
22
+ datum[:hypermedia]
23
+ end
24
+
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Excon
6
+ module HyperMedia
7
+ # Resource
8
+ #
9
+ # This HyperMedia::Resource object encapsulates the returned JSON and
10
+ # makes it easy to access the links and attributes.
11
+ #
12
+ class Resource
13
+ attr_reader :data
14
+
15
+ def initialize(body)
16
+ @body = body
17
+ end
18
+
19
+ def links
20
+ data.fetch('_links', {}).keys.map { |name| link(name) }
21
+ end
22
+
23
+ def link(link_name)
24
+ Link.new(name: link_name, hash: data)
25
+ end
26
+
27
+ def attributes
28
+ attributes = data.reject do |k, _|
29
+ k == '_links'
30
+ end
31
+
32
+ Struct.new(*attributes.keys.map(&:to_sym)).new(*attributes.values)
33
+ end
34
+
35
+ def type?(name)
36
+ return :link if link(name).valid?
37
+ return :attribute if attributes.respond_to?(name.to_s)
38
+
39
+ :unknown
40
+ end
41
+
42
+ def data
43
+ @data ||= JSON.parse(@body)
44
+ rescue JSON::ParserError
45
+ {}
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'excon/hypermedia/ext/response'
4
+
5
+ module Excon
6
+ module HyperMedia
7
+ # Response
8
+ #
9
+ # This HyperMedia::Response object helps determine valid subsequent
10
+ # requests and attribute values.
11
+ #
12
+ class Response
13
+ attr_reader :response
14
+
15
+ def initialize(response)
16
+ @response = response
17
+ end
18
+
19
+ # handle
20
+ #
21
+ # Correctly handle the hypermedia request.
22
+ #
23
+ def handle(method_name, *params)
24
+ return false if disabled?
25
+
26
+ case resource.type?(method_name)
27
+ when :link then return handle_link(method_name, params)
28
+ when :attribute then return handle_attribute(method_name)
29
+ end
30
+
31
+ respond_to?(method_name) ? send(method_name) : false
32
+ end
33
+
34
+ def links
35
+ resource.links
36
+ end
37
+
38
+ def attributes
39
+ resource.attributes
40
+ end
41
+
42
+ def resource
43
+ @resource ||= Resource.new(response.body)
44
+ end
45
+
46
+ def enabled?
47
+ response.data[:hypermedia] == true
48
+ end
49
+
50
+ def disabled?
51
+ !enabled?
52
+ end
53
+
54
+ private
55
+
56
+ def handle_link(name, params)
57
+ Excon.new(resource.link(name).href, params.first.to_h.merge(hypermedia: true))
58
+ end
59
+
60
+ def handle_attribute(name)
61
+ attributes[name.to_s]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Excon
3
- module Hypermedia
4
- VERSION = '0.1.0'
4
+ module HyperMedia
5
+ VERSION = '0.2.0'
5
6
  end
6
7
  end
@@ -1,17 +1,9 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'excon'
3
4
  require 'excon/addressable'
4
- require 'excon/hypermedia/hyper_media'
5
-
6
- # :nodoc:
7
- module Excon
8
- # HyperMedia addition to Excon.
9
- #
10
- module Hypermedia
11
- def new(url, params = {})
12
- params[:hypermedia] ? super.extend(HyperMedia) : super
13
- end
14
- end
15
-
16
- singleton_class.prepend Hypermedia
17
- end
5
+ require 'excon/hypermedia/link'
6
+ require 'excon/hypermedia/middleware'
7
+ require 'excon/hypermedia/resource'
8
+ require 'excon/hypermedia/response'
9
+ require 'excon/hypermedia/version'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
- # rubocop:disable Metrics/AbcSize, Metrics/LineLength
2
+ # rubocop:disable Metrics/LineLength
3
3
  require_relative '../test_helper'
4
4
 
5
5
  module Excon
@@ -9,75 +9,81 @@ module Excon
9
9
  #
10
10
  class HypermediaTest < Minitest::Test
11
11
  def entrypoint
12
- <<~EOF
13
- {
14
- "_links": {
15
- "hello": {
16
- "href":"http://www.example.com/hello/{location}"
17
- }
18
- }
19
- }
20
- EOF
12
+ '{ "_links": { "hello": { "href":"http://www.example.com/hello/{location}" } } }'
21
13
  end
22
14
 
23
- def hello_world
15
+ def hello_world # rubocop:disable Metrics/MethodLength
24
16
  <<~EOF
25
17
  {
26
18
  "_links": {
27
19
  "goodbye": {
28
20
  "href":"http://www.example.com/hello/world/goodbye{?message}"
29
21
  }
30
- }
31
- }
32
- EOF
33
- end
34
-
35
- def hello_universe
36
- <<~EOF
37
- {
38
- "_links": {
39
- "goodbye": {
40
- "href":"http://www.example.com/hello/universe/goodbye{?message}"
41
- }
42
- }
22
+ },
23
+ "uid": "hello",
24
+ "message": "goodbye!"
43
25
  }
44
- EOF
26
+ EOF
45
27
  end
46
28
 
47
29
  def setup
48
30
  Excon.defaults[:mock] = true
31
+ Excon.defaults[:middlewares].push(Excon::HyperMedia::Middleware)
32
+
33
+ response = { headers: { 'Content-Type' => 'application/hal+json' } }
34
+ Excon.stub({ method: :get, path: '/api' }, response.merge(body: entrypoint))
35
+ Excon.stub({ method: :get, path: '/hello/world' }, response.merge(body: hello_world))
36
+ Excon.stub({ method: :get, path: '/hello/world/goodbye', query: nil }, response.merge(body: 'bye!'))
37
+ Excon.stub({ method: :get, path: '/hello/world/goodbye', query: 'message=farewell' }, response.merge(body: 'farewell'))
38
+ end
39
+
40
+ def client
41
+ Excon.get('http://www.example.com/api')
42
+ end
43
+
44
+ def test_request
45
+ response = client.hello(expand: { location: 'world' }).get
46
+
47
+ assert response.body.include?('http://www.example.com/hello/world/goodbye{?message}')
48
+ end
49
+
50
+ def test_nested_request
51
+ hello = client.hello(expand: { location: 'world' })
52
+ response = hello.get.goodbye.get
49
53
 
50
- Excon.stub({ method: :get, path: '/api' }, body: entrypoint, status: 200)
51
- Excon.stub({ method: :get, path: '/hello/world' }, body: hello_world, status: 200)
52
- Excon.stub({ method: :get, path: '/hello/world/goodbye', query: nil }, body: 'bye!', status: 200)
53
- Excon.stub({ method: :get, path: '/hello/world/goodbye', query: 'message=farewell' }, body: 'farewell', status: 200)
54
- Excon.stub({ method: :get, path: '/hello/universe' }, body: hello_universe, status: 200)
54
+ assert_equal 'bye!', response.body
55
55
  end
56
56
 
57
- def test_hypermedia_request
58
- conn = Excon.new('http://www.example.com/api', hypermedia: true)
59
- conn2 = conn.hello(expand: { location: 'world' })
60
- conn3 = conn.hello(expand: { location: 'universe' })
57
+ def test_nested_query_parameters
58
+ hello = client.hello(expand: { location: 'world' })
59
+ response = hello.get.goodbye(expand: { message: 'farewell' }).get
61
60
 
62
- assert_equal '/hello/world', conn2.data[:path]
63
- assert conn2.get.body.include?('http://www.example.com/hello/world/goodbye{?message}')
61
+ assert_equal 'farewell', response.body
62
+ end
63
+
64
+ def test_expand_in_get
65
+ response = client.hello.get(expand: { location: 'world' })
64
66
 
65
- assert_equal '/hello/universe', conn3.data[:path]
66
- assert conn3.get.body.include?('http://www.example.com/hello/universe/goodbye{?message}')
67
+ assert response.body.include?('http://www.example.com/hello/world/goodbye{?message}')
67
68
  end
68
69
 
69
- def test_nested_hypermedia_request
70
- conn = Excon.new('http://www.example.com/api', hypermedia: true)
71
- conn2 = conn.hello(expand: { location: 'world' }).goodbye
72
- conn3 = conn.hello(expand: { location: 'world' }).goodbye(expand: { message: 'farewell' })
70
+ def test_attribute
71
+ response = client.hello(expand: { location: 'world' }).get
72
+
73
+ assert_equal response.uid, 'hello'
74
+ assert_equal response.message, 'goodbye!'
75
+ end
76
+
77
+ def test_links
78
+ response = client.hello(expand: { location: 'world' }).get
79
+
80
+ assert_equal response.links.first.name, 'goodbye'
81
+ end
73
82
 
74
- assert_equal '/hello/world/goodbye', conn2.data[:path]
75
- assert_nil conn2.data[:query]
76
- assert_equal 'bye!', conn2.get.body
83
+ def test_attributes
84
+ response = client.hello(expand: { location: 'world' }).get
77
85
 
78
- assert_equal '/hello/world/goodbye', conn3.data[:path]
79
- assert_equal 'message=farewell', conn3.data[:query]
80
- assert_equal 'farewell', conn3.get.body
86
+ assert_equal response.attributes.uid, 'hello'
81
87
  end
82
88
 
83
89
  def teardown
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../test_helper'
3
+
4
+ module Excon
5
+ # LinkTest
6
+ #
7
+ # Validate the workings of `Excon::HyperResource::Link`.
8
+ #
9
+ class LinkTest < Minitest::Test
10
+ def body # rubocop:disable Metrics/MethodLength
11
+ <<~EOF
12
+ {
13
+ "_links": {
14
+ "hello": {
15
+ "href": "http://www.example.com/hello/{location}"
16
+ }
17
+ },
18
+ "uid": "universe",
19
+ "hello": "world"
20
+ }
21
+ EOF
22
+ end
23
+
24
+ def data
25
+ JSON.parse(body)
26
+ end
27
+
28
+ def link
29
+ @link ||= Excon::HyperMedia::Link.new(name: 'hello', hash: data)
30
+ end
31
+
32
+ def invalid_link
33
+ @invalid_link ||= Excon::HyperMedia::Link.new(name: 'goodbye', hash: data)
34
+ end
35
+
36
+ def test_link
37
+ assert_equal link.name, 'hello'
38
+ end
39
+
40
+ def test_valid_link
41
+ assert link.valid?
42
+ end
43
+
44
+ def test_invalid_link
45
+ refute invalid_link.valid?
46
+ end
47
+
48
+ def test_uri
49
+ assert_equal link.uri.to_s, data['_links']['hello']['href']
50
+ end
51
+
52
+ def test_href
53
+ assert_equal link.href, data['_links']['hello']['href']
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../test_helper'
3
+
4
+ module Excon
5
+ # ResourceTest
6
+ #
7
+ # Validate the workings of `Excon::HyperResource::Resource`.
8
+ #
9
+ class ResourceTest < Minitest::Test
10
+ def body # rubocop:disable Metrics/MethodLength
11
+ <<~EOF
12
+ {
13
+ "_links": {
14
+ "hello": {
15
+ "href": "http://www.example.com/hello/{location}"
16
+ }
17
+ },
18
+ "uid": "universe",
19
+ "hello": "world"
20
+ }
21
+ EOF
22
+ end
23
+
24
+ def data
25
+ @data ||= JSON.parse(body)
26
+ end
27
+
28
+ def resource
29
+ @resource ||= Excon::HyperMedia::Resource.new(body)
30
+ end
31
+
32
+ def test_resource
33
+ assert_equal data, resource.data
34
+ end
35
+
36
+ def test_links
37
+ assert_equal data['_links']['hello']['href'], resource.links.first.href
38
+ end
39
+
40
+ def test_attributes
41
+ assert_equal resource.attributes.uid, 'universe'
42
+ assert_equal resource.attributes.hello, 'world'
43
+
44
+ refute resource.attributes.respond_to?('_links')
45
+ end
46
+ end
47
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: excon-hypermedia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2016-05-17 00:00:00.000000000 Z
12
+ date: 2016-05-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: bundler
@@ -81,6 +81,20 @@ dependencies:
81
81
  - - "~>"
82
82
  - !ruby/object:Gem::Version
83
83
  version: '0.10'
84
+ - !ruby/object:Gem::Dependency
85
+ name: m
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: 1.5.0
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: 1.5.0
84
98
  - !ruby/object:Gem::Dependency
85
99
  name: excon
86
100
  requirement: !ruby/object:Gem::Requirement
@@ -126,9 +140,15 @@ files:
126
140
  - Rakefile
127
141
  - excon-hypermedia.gemspec
128
142
  - lib/excon/hypermedia.rb
129
- - lib/excon/hypermedia/hyper_media.rb
143
+ - lib/excon/hypermedia/ext/response.rb
144
+ - lib/excon/hypermedia/link.rb
145
+ - lib/excon/hypermedia/middleware.rb
146
+ - lib/excon/hypermedia/resource.rb
147
+ - lib/excon/hypermedia/response.rb
130
148
  - lib/excon/hypermedia/version.rb
131
149
  - test/excon/hypermedia_test.rb
150
+ - test/excon/link_test.rb
151
+ - test/excon/resource_test.rb
132
152
  - test/test_helper.rb
133
153
  homepage: https://github.com/JeanMertz/excon-hypermedia
134
154
  licenses:
@@ -155,4 +175,3 @@ signing_key:
155
175
  specification_version: 4
156
176
  summary: Excon, with Hypermedia traversing baked in.
157
177
  test_files: []
158
- has_rdoc:
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'json'
3
-
4
- module Excon
5
- # HyperMedia
6
- #
7
- module HyperMedia
8
- def method_missing(method_name, *params)
9
- return super unless (url = entrypoint.dig('_links', method_name.to_s, 'href'))
10
-
11
- Excon.new(url, params.first.to_h.merge(hypermedia: true))
12
- end
13
-
14
- def respond_to_missing?(method_name, include_private = false)
15
- entrypoint.dig('_links', method_name.to_s, 'href') ? true : super
16
- end
17
-
18
- private
19
-
20
- def entrypoint
21
- @entrypoint ||= JSON.parse(get.body)
22
- rescue JSON::ParserError
23
- {}
24
- end
25
- end
26
- end