excon-hypermedia 0.3.0 → 0.4.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: 6c0a3189fb4185c1db3c29a86b699ea1de7549db
4
- data.tar.gz: b4d04af297a0ea267d0a2dce0765385bfd5d8483
3
+ metadata.gz: 38a442095c082e7251b1a72d8e88084833357a3d
4
+ data.tar.gz: f0ff7d16042fa3d35bfca84b216b6e145f55db2d
5
5
  SHA512:
6
- metadata.gz: f7576259b25677acc8f9b23171705babafff2bd0510c055723bee712e1f77a329355b090be9544631aabdd199c0e4c068f0818765ffcf1c4abdd5f03a94b004a
7
- data.tar.gz: eb29ba745ea6cf03434f3232fd459e7e9bf3db791fba56fc88772e95a10800510f54e5c793e8771df9b77bae240522fd024b305f8bbf5186236d7a0681c815fd
6
+ metadata.gz: 6ce27164e467d58bd426538b59cb917761c246d36dcb744d43e807169415ca225280cbd9965d41a631c907b2abeed49794090e3800a78e51a05b2f0811f781f1
7
+ data.tar.gz: 4670c0806fa665a012b0238598cc724296e5252b79735f116954aba80649b116773560d07aeebad932cbfd0f39550bd1a21248376d2ab25ccc6f49f1615fdf9e
data/README.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Teaches [Excon][] how to talk to [HyperMedia APIs][hypermedia].
4
4
 
5
+ * [Installation](#installation)
6
+ * [Quick Start](#quick-start)
7
+ * [Usage](#usage)
8
+ * [resources](#resources)
9
+ * [links](#links)
10
+ * [relations](#relations)
11
+ * [properties](#properties)
12
+ * [embedded](#embedded)
13
+ * [License](#license)
14
+
5
15
  ## Installation
6
16
 
7
17
  Add this line to your application's Gemfile:
@@ -22,6 +32,22 @@ Or install it yourself as:
22
32
  gem install excon-hypermedia
23
33
  ```
24
34
 
35
+ ## Quick Start
36
+
37
+ ```ruby
38
+ Excon.defaults[:middlewares].push(Excon::HyperMedia::Middleware)
39
+
40
+ api = Excon.get('https://www.example.org/api.json')
41
+ api.class # => Excon::Response
42
+
43
+ product = api.rel('product', expand: { uid: 'bicycle' })
44
+ product.class # => Excon::Connection
45
+
46
+ response = product.get
47
+ response.class # => Excon::Response
48
+ response.resource.name # => 'bicycle'
49
+ ```
50
+
25
51
  ## Usage
26
52
 
27
53
  To let Excon know the API supports HyperMedia, simply enable the correct
@@ -34,65 +60,202 @@ api = Excon.get('https://www.example.org/api.json')
34
60
  api.class # => Excon::Response
35
61
  ```
36
62
 
37
- Using the `HyperMedia` middleware, the `Excon::Response` object now knows how
38
- to handle the HyperMedia aspect of the API:
63
+ > NOTE: we'll use the following JSON response body in the below examples:
64
+ >
65
+ > **https://www.example.org/api.json**
66
+ >
67
+ > ```json
68
+ > {
69
+ > "_links": {
70
+ > "self": {
71
+ > "href": "https://www.example.org/api.json"
72
+ > },
73
+ > "product": {
74
+ > "href": "https://www.example.org/product/{uid}",
75
+ > "templated": true
76
+ > }
77
+ > }
78
+ > }
79
+ > ```
80
+ >
81
+ > **https://www.example.org/product/bicycle**
82
+ >
83
+ > ```json
84
+ > {
85
+ > "_links": {
86
+ > "self": {
87
+ > "href": "https://www.example.org/product/bicycle"
88
+ > }
89
+ > },
90
+ > "bike-type": "Mountain Bike",
91
+ > "BMX": false,
92
+ > "derailleurs": {
93
+ > "back": 7,
94
+ > "front": 3
95
+ > },
96
+ > "name": "bicycle",
97
+ > "reflectors": true,
98
+ > "_embedded": {
99
+ > "pump": {
100
+ > "_links": {
101
+ > "self": "https://www.example.org/product/pump"
102
+ > },
103
+ > "weight": "2kg",
104
+ > "type": "Floor Pump",
105
+ > "valve-type": "Presta"
106
+ > }
107
+ > }
108
+ > }
109
+
110
+ With this middleware injected in the stack, Excon's model is now expanded with
111
+ several key concepts:
112
+
113
+ ### resources
114
+
115
+ A **resource** is the representation of the object returned by the API. Almost
116
+ all other concepts and methods originate from this object.
117
+
118
+ Use the newly available `Excon::Response#resource` method to access the resource
119
+ object:
39
120
 
40
121
  ```ruby
41
- product = api.product(expand: { uid: 'bicycle' })
42
- product.class # => Excon::Connection
122
+ api.resource.class # => Excon::HyperMedia::ResourceObject
123
+ ```
43
124
 
44
- response = product.get
45
- response.class # => Excon::Response
46
- response.body.class # => String
125
+ A resource has several methods exposed:
126
+
127
+ ```ruby
128
+ api.resource.public_methods(false) # => [:_links, :_properties, :_embedded]
129
+ ```
130
+
131
+ Each of these methods represents one of the following HyperMedia concepts.
132
+
133
+ ### links
134
+
135
+ A resource has links, that point to related resources (and itself), these can be
136
+ accessed as well:
137
+
138
+ ```ruby
139
+ api.resource._links.class # => Excon::HyperMedia::ResourceObject::Links
140
+ ```
141
+
142
+ You can get a list of valid links using `keys`:
143
+
144
+ ```ruby
145
+ api.resource._links.keys # => ['self', 'product']
146
+ ```
147
+
148
+ Each links is represented by a `LinkObject` instance:
149
+
150
+ ```ruby
151
+ api.resource._links.product.class # => Excon::HyperMedia::LinkObject
152
+ api.resource._links.product.href # => 'https://www.example.org/product/{uid}'
153
+ api.resource._links.product.templated # => true
154
+ ```
155
+
156
+ ### relations
157
+
158
+ Links are the primary way to traverse between relations. This is what makes a
159
+ HyperMedia-based API "self-discoverable".
160
+
161
+ To go from one resource, to the next, you use the `rel` (short for relation)
162
+ method. This method is available on any `LinkObject` instance.
163
+
164
+ Using `rel`, returns an `Excon::Connection` object, the same as if you where to
165
+ call `Excon.new`:
166
+
167
+ ```ruby
168
+ relation = api.resource._links.self.rel
169
+ relation.class # => Excon::Connection
170
+ ```
171
+
172
+ Since the returned object is of type `Excon::Connection`, all
173
+ [Excon-provided options][options] are available as well:
174
+
175
+ ```ruby
176
+ relation.get(idempotent: true, retry_limit: 6)
177
+ ```
178
+
179
+ `Excon::Response` also has a convenient delegation to `LinkObject#rel`:
180
+
181
+ ```ruby
182
+ relation = api.rel('self').get
183
+ ```
184
+
185
+ Once you call `get` (or `post`, or any other valid Excon request method), you
186
+ are back where you started, with a new `Excon::Response` object, imbued with
187
+ HyperMedia powers:
188
+
189
+ ```ruby
190
+ relation.resource._links.keys # => ['self', 'product']
191
+ ```
192
+
193
+ In this case, we ended up back with the same type of object as before. To go
194
+ anywhere meaningful, we want to use the `product` rel:
195
+
196
+ ```ruby
197
+ product = api.rel('product', expand: { uid: 'bicycle' }).get
47
198
  ```
48
199
 
49
200
  As seen above, you can expand URI Template variables using the `expand` option,
50
201
  provided by the [`excon-addressable` library][excon-addressable].
51
202
 
52
- Since each new resource is simply an `Excon::Response` object, accessed through
53
- the default `Excon::Connection` object, all [Excon-provided options][options]
54
- are available as well:
203
+ ### properties
204
+
205
+ Properties are what make a resource unique, they tell us more about the state of
206
+ the resource, they are the key/value pairs that define the resource.
207
+
208
+ In HAL/JSON terms, this is everything returned by the response body, excluding
209
+ the `_links` and `_embedded` sections:
55
210
 
56
211
  ```ruby
57
- product.get(idempotent: true, retry_limit: 6)
212
+ product.resource.name # => "bicycle"
213
+ product.resource.reflectors # => true
58
214
  ```
59
215
 
60
- ### Links
61
-
62
- You can access all links in a resource using the `links` method:
216
+ Nested properties are supported as well:
63
217
 
64
218
  ```ruby
65
- api.links.first.class # => Excon::HyperMedia::Link
66
- api.links.first.name # => 'product'
67
- api.links.first.href # => 'https://www.example.org/product/{uid}'
219
+ product.resource.derailleurs.class # => Excon::HyperMedia::ResourceObject::Properties
220
+ product.resource.derailleurs.front # => 3
221
+ product.resource.derailleurs.back # => 7
68
222
  ```
69
223
 
70
- You can also access a link directly, using its name:
224
+ Property names that aren't valid method names can always be accessed using the
225
+ hash notation:
71
226
 
72
227
  ```ruby
73
- api.link('product').href # => 'https://www.example.org/product/{uid}'
228
+ product.resource['bike-type'] # => 'Mountain Bike'
229
+ product.resource['BMX'] # => false
230
+ product.resource.bmx # => false
74
231
  ```
75
232
 
76
- If you want to access a relation through its link, but can't use the implicit
77
- naming, you can use the `rel` method:
233
+ Properties should be implicitly accessed on a resource, but are internally
234
+ accessed via the `_properties` method:
78
235
 
79
236
  ```ruby
80
- api.links.last.name # => 'customer-orders'
237
+ product.resource._properties.class # => Excon::HyperMedia::ResourceObject::Properties
238
+ ```
81
239
 
82
- # won't work, due to invalid ruby method name:
83
- api.customer-orders(expand: { sort: 'desc' }) # => NoMethodError: undefined method `customer'
240
+ The `Properties` object inherits its logics from `Enumerable`:
84
241
 
85
- # works
86
- api.rel('customer-orders', expand: { sort: 'desc' })
242
+ ```ruby
243
+ product.resource._properties.to_h.class # => Hash
244
+ product.resource._properties.first # => ['name', 'bicycle']
87
245
  ```
88
246
 
89
- ### Attributes
247
+ ### embedded
248
+
249
+ Embedded resources are resources that are available through link relations, but
250
+ embedded in the current resource for easier access.
251
+
252
+ For more information on this concept, see the [formal specification][_embedded].
90
253
 
91
- Attributes are available through the `attributes` method:
254
+ Embedded resources work the same as the top-level resource:
92
255
 
93
256
  ```ruby
94
- product.attributes.to_h # => { uid: 'bicycle', stock: 5 }
95
- product.attributes.uid # => 'bicycle'
257
+ product._embedded.pump.class # => Excon::HyperMedia::ResourceObject
258
+ product._embedded.pump.weight # => '2kg'
96
259
  ```
97
260
 
98
261
  ## License
@@ -103,3 +266,4 @@ The gem is available as open source under the terms of the [MIT License](http://
103
266
  [hypermedia]: https://en.wikipedia.org/wiki/HATEOAS
104
267
  [excon-addressable]: https://github.com/JeanMertz/excon-addressable
105
268
  [options]: https://github.com/excon/excon#options
269
+ [_embedded]: https://tools.ietf.org/html/draft-kelly-json-hal-08#section-4.1.2
@@ -9,6 +9,8 @@ module Excon
9
9
  # on top.
10
10
  #
11
11
  module Response
12
+ private
13
+
12
14
  def method_missing(method_name, *params)
13
15
  hypermedia_response.handle(method_name, *params) || super
14
16
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Excon
6
+ module HyperMedia
7
+ # Collection
8
+ #
9
+ # Given a `Hash`, provides dot-notation properties and other helper methods.
10
+ #
11
+ module Collection
12
+ include Enumerable
13
+
14
+ def initialize(collection = {})
15
+ @collection ||= collection
16
+ to_properties
17
+ end
18
+
19
+ def each(&block)
20
+ collection.each(&block)
21
+ end
22
+
23
+ def keys
24
+ @collection.keys
25
+ end
26
+
27
+ def key?(key)
28
+ collection.key?(key.to_s)
29
+ end
30
+
31
+ def [](key)
32
+ to_property(key)
33
+ end
34
+
35
+ private
36
+
37
+ def to_properties
38
+ collection.each do |key, value|
39
+ key = key.downcase
40
+ next unless /[@$"]/ !~ key.to_sym.inspect
41
+
42
+ singleton_class.class_eval { attr_reader key }
43
+ instance_variable_set("@#{key}", property(value))
44
+ end
45
+ end
46
+
47
+ def property(value)
48
+ value.respond_to?(:keys) ? self.class.new(value) : value
49
+ end
50
+
51
+ def to_property(key)
52
+ key?(key) ? property(collection[key]) : nil
53
+ end
54
+
55
+ def to_property!(key)
56
+ key?(key) ? to_property(key) : method_missing(key)
57
+ end
58
+
59
+ attr_reader :collection
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ module HyperMedia
5
+ # Link
6
+ #
7
+ # Encapsulates a link pointing to a resource.
8
+ #
9
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5
10
+ #
11
+ class LinkObject
12
+ include Collection
13
+
14
+ # href
15
+ #
16
+ # The "href" property is REQUIRED.
17
+ #
18
+ # Its value is either a URI [RFC3986] or a URI Template [RFC6570].
19
+ #
20
+ # If the value is a URI Template then the Link Object SHOULD have a
21
+ # "templated" attribute whose value is true.
22
+ #
23
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.1
24
+ #
25
+ def href
26
+ to_property!(:href)
27
+ end
28
+
29
+ # The "templated" property is OPTIONAL.
30
+ #
31
+ # Its value is boolean and SHOULD be true when the Link Object's "href"
32
+ # property is a URI Template.
33
+ #
34
+ # Its value SHOULD be considered false if it is undefined or any other
35
+ # value than true.
36
+ #
37
+ # @see: https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.2
38
+ #
39
+ def templated
40
+ to_property(__method__) || false
41
+ end
42
+
43
+ # type
44
+ #
45
+ # The "type" property is OPTIONAL.
46
+ #
47
+ # Its value is a string used as a hint to indicate the media type
48
+ # expected when dereferencing the target resource.
49
+ #
50
+ # @see: https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.3
51
+ #
52
+ def type
53
+ to_property!(__method__)
54
+ end
55
+
56
+ # deprecation
57
+ #
58
+ # The "deprecation" property is OPTIONAL.
59
+ #
60
+ # Its presence indicates that the link is to be deprecated (i.e.
61
+ # removed) at a future date. Its value is a URL that SHOULD provide
62
+ # further information about the deprecation.
63
+ #
64
+ # A client SHOULD provide some notification (for example, by logging a
65
+ # warning message) whenever it traverses over a link that has this
66
+ # property. The notification SHOULD include the deprecation property's
67
+ # value so that a client manitainer can easily find information about
68
+ # the deprecation.
69
+ #
70
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.4
71
+ #
72
+ def deprecation
73
+ to_property!(__method__)
74
+ end
75
+
76
+ # name
77
+ #
78
+ # The "name" property is OPTIONAL.
79
+ #
80
+ # Its value MAY be used as a secondary key for selecting Link Objects
81
+ # which share the same relation type.
82
+ #
83
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.5
84
+ #
85
+ def name
86
+ to_property!(__method__)
87
+ end
88
+
89
+ # profile
90
+ #
91
+ # The "profile" property is OPTIONAL.
92
+ #
93
+ # Its value is a string which is a URI that hints about the profile (as
94
+ # defined by [I-D.wilde-profile-link]) of the target resource.
95
+ #
96
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.6
97
+ #
98
+ def profile
99
+ to_property!(__method__)
100
+ end
101
+
102
+ # title
103
+ #
104
+ # The "title" property is OPTIONAL.
105
+ #
106
+ # Its value is a string and is intended for labelling the link with a
107
+ # human-readable identifier (as defined by [RFC5988]).
108
+ #
109
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.7
110
+ #
111
+ def title
112
+ to_property!(__method__)
113
+ end
114
+
115
+ # hreflang
116
+ #
117
+ # The "hreflang" property is OPTIONAL.
118
+ #
119
+ # Its value is a string and is intended for indicating the language of
120
+ # the target resource (as defined by [RFC5988]).
121
+ #
122
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-5.8
123
+ #
124
+ def hreflang
125
+ to_property!(__method__)
126
+ end
127
+
128
+ # uri
129
+ #
130
+ # Returns a URI representation of the provided "href" property.
131
+ #
132
+ # @return [URI] URI object of the "href" property
133
+ #
134
+ def uri
135
+ ::Addressable::URI.parse(href)
136
+ end
137
+
138
+ # rel
139
+ #
140
+ # Returns an `Excon::Connection` instance, based on the current link.
141
+ #
142
+ # @return [Excon::Connection] Connection object based on current link
143
+ #
144
+ def rel(params = {})
145
+ Excon.new(href, params)
146
+ end
147
+
148
+ private def property(value)
149
+ value
150
+ end
151
+ end
152
+ end
153
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ Excon.defaults[:middlewares].delete(Excon::Addressable::Middleware)
3
4
  Excon.defaults[:middlewares].unshift(Excon::Addressable::Middleware)
4
5
 
5
6
  module Excon
@@ -16,6 +17,7 @@ module Excon
16
17
  def request_call(datum)
17
18
  return super unless (content_type = datum.dig(:response, :headers, 'Content-Type').to_s)
18
19
 
20
+ datum[:response] ||= {}
19
21
  datum[:response][:hypermedia] = if datum[:hypermedia].nil?
20
22
  content_type.include?('hal+json')
21
23
  else
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ module HyperMedia
5
+ class ResourceObject
6
+ # Links
7
+ #
8
+ # Represents a collection of links part of a resource.
9
+ #
10
+ class Embedded
11
+ include Collection
12
+
13
+ private def property(value)
14
+ if value.respond_to?(:to_ary)
15
+ value.map { |v| ResourceObject.new(v) }
16
+ else
17
+ ResourceObject.new(value)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Excon
4
+ module HyperMedia
5
+ class ResourceObject
6
+ # Links
7
+ #
8
+ # Represents a collection of links part of a resource.
9
+ #
10
+ class Links
11
+ include Collection
12
+
13
+ private def property(value)
14
+ value.respond_to?(:to_ary) ? value.map { |v| LinkObject.new(v) } : LinkObject.new(value)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Excon
6
+ module HyperMedia
7
+ class ResourceObject
8
+ # Properties
9
+ #
10
+ # Provides consistent access to resource properties.
11
+ #
12
+ class Properties
13
+ include Collection
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'excon/hypermedia/resource_object/embedded'
4
+ require 'excon/hypermedia/resource_object/links'
5
+ require 'excon/hypermedia/resource_object/properties'
6
+
7
+ module Excon
8
+ module HyperMedia
9
+ # ResourceObject
10
+ #
11
+ # Represents a resource.
12
+ #
13
+ class ResourceObject
14
+ RESERVED_PROPERTIES = %w(_links _embedded).freeze
15
+
16
+ def initialize(data)
17
+ @data = data
18
+
19
+ _properties.each do |key, value|
20
+ key = key.downcase
21
+ next unless /[@$"]/ !~ key.to_sym.inspect
22
+
23
+ singleton_class.class_eval { attr_reader key }
24
+ instance_variable_set("@#{key}", value.respond_to?(:keys) ? Properties.new(value) : value)
25
+ end
26
+ end
27
+
28
+ def _properties
29
+ @_properties ||= Properties.new(@data.reject { |k, _| RESERVED_PROPERTIES.include?(k) })
30
+ end
31
+
32
+ # _links
33
+ #
34
+ # The reserved "_links" property is OPTIONAL.
35
+ #
36
+ # It is an object whose property names are link relation types (as
37
+ # defined by [RFC5988]) and values are either a Link Object or an array
38
+ # of Link Objects. The subject resource of these links is the Resource
39
+ # Object of which the containing "_links" object is a property.
40
+ #
41
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-4.1.1
42
+ #
43
+ def _links
44
+ @_links ||= Links.new(@data['_links'])
45
+ end
46
+
47
+ # _embedded
48
+ #
49
+ # The reserved "_embedded" property is OPTIONAL
50
+ #
51
+ # It is an object whose property names are link relation types (as
52
+ # defined by [RFC5988]) and values are either a Resource Object or an
53
+ # array of Resource Objects.
54
+ #
55
+ # Embedded Resources MAY be a full, partial, or inconsistent version of
56
+ # the representation served from the target URI.
57
+ #
58
+ # @see https://tools.ietf.org/html/draft-kelly-json-hal-08#section-4.1.2
59
+ #
60
+ def _embedded
61
+ @_embedded ||= Embedded.new(@data['_embedded'])
62
+ end
63
+
64
+ def [](key)
65
+ _properties[key]
66
+ end
67
+ end
68
+ end
69
+ end