prx_hyperresource 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f915cfe28034820646356966a937d0d0aa356239272d2b4c67c09bf1229fadf
4
+ data.tar.gz: 67725008931bf8c1333d5f06b3267a3fadeea8540d572973f1f01f31097dd344
5
+ SHA512:
6
+ metadata.gz: ebe26180a2258b3f037191612663ec06b49f5b17cfba2a33b0222cd565135eeb19f8f4ff075615243e0565be120966705f3d5dfbc6caa307f20b4dda2b039def
7
+ data.tar.gz: f2ed869789f03947e5ed8863db56515ab9e65308bfadbf0b9b9b0d5aaf9bfce3202d77f200d87d8aa5b50500c121812b290db02271a291088f07594aef8fbbf4
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ First release as `prx_hyperresource`, PRX's fork of
6
+ [hyperresource](https://github.com/gamache/hyperresource) 0.9.4. The library
7
+ is still loaded as `HyperResource`, and `require "hyperresource"` still works.
8
+
9
+ - Support Ruby 4: require `cgi`, which is no longer in Ruby's stdlib.
10
+ - Match URL masks without the `fuzzyurl` gem, but with similar logic.
11
+ - Treat URLs without a protocol as `https` (port 443) when matching masks.
12
+ - Support Faraday 2 basic auth, using the authorization middleware.
13
+ - Don't share cached connections between resources with different `faraday_options`.
14
+ - Default to `application/json` unless a Content-Type is configured.
15
+ - Prefer headers from more specific masks over `*`.
16
+ - Require Ruby 3.3 or later.
17
+ - Remove `HyperResource::VERSION_DATE` and the gemspec `date`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ Copyright 2013 Pete Gamache, pete@gamache.org.
2
+ Copyright 2025-2026 PRX.
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining
5
+ a copy of this software and associated documentation files (the
6
+ "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish,
8
+ distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to
10
+ the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # HyperResource
2
+
3
+ PRX's maintained fork of
4
+ [gamache/hyperresource](https://github.com/gamache/hyperresource), published
5
+ as `prx_hyperresource`. See updates in the [CHANGELOG.md](CHANGELOG.md).
6
+
7
+ HyperResource is a Ruby client library for hypermedia web services.
8
+
9
+ HyperResource makes using a hypermedia API feel like calling plain old
10
+ methods on plain old objects.
11
+
12
+ It is usable with no configuration other than API root endpoint, but
13
+ also allows incoming data types to be extended with Ruby code.
14
+
15
+ HyperResource supports the
16
+ [HAL+JSON hypermedia format](http://stateless.co/hal_specification.html),
17
+ with support for Siren and other hypermedia formats planned.
18
+
19
+ For more insight into HyperResource's goals and design,
20
+ [read the paper](http://petegamache.com/wsrest2014-gamache.pdf)!
21
+
22
+ ## Hypermedia in a Nutshell
23
+
24
+ Hypermedia APIs return a list of hyperlinks with each response. These
25
+ links, each of which has a relation name or "rel", represent everything
26
+ you can do to, with, or from the given response. They are URLs which
27
+ can take arguments. The consumer of the hypermedia API uses these
28
+ links to access the API's entire functionality.
29
+
30
+ A primary advantage to hypermedia APIs is that a client library can
31
+ write itself based on the hyperlinks coming back with each response.
32
+ This removes both the chore of writing a custom client library in the
33
+ first place, and also the process of pushing client updates to your
34
+ users.
35
+
36
+ ## HyperResource Philosophy
37
+
38
+ An automatically-generated library can and should feel as comfortable as a
39
+ custom client library. Hypermedia brings many promises, but this is one
40
+ we can deliver on.
41
+
42
+ If you're an API user, HyperResource will help you consume a hypermedia
43
+ API with short, direct, elegant code.
44
+ If you're an API designer, HyperResource is a great default client, or a
45
+ smart starting point for a rich SDK.
46
+
47
+ Link-driven APIs are the future, and proper tooling can make it The Jetsons
48
+ instead of The Road Warrior.
49
+
50
+ ## Install It
51
+
52
+ Add `gem "prx_hyperresource"` to your Gemfile, or `gem install prx_hyperresource`.
53
+ It is loaded as `HyperResource`, and `require "hyperresource"` still works.
54
+
55
+ HyperResource requires Ruby 3.3 or later.
56
+
57
+ HyperResource uses the
58
+ [uri_template](https://github.com/hannesg/uri_template)
59
+ and [Faraday](https://github.com/lostisland/faraday)
60
+ gems.
61
+
62
+ ## Use It - Zero Configuration
63
+
64
+ Set up API connection:
65
+
66
+ ```ruby
67
+ api = HyperResource.new(root: 'https://api.example.com',
68
+ headers: {'Accept' => 'application/vnd.example.com.v1+json'},
69
+ auth: {basic: ['username', 'password']})
70
+ # => #<HyperResource:0xABCD1234 @root="https://api.example.com" @href="" @namespace=nil ... >
71
+ ```
72
+
73
+ Now we can get the API's root resource, the gateway to everything else
74
+ on the API.
75
+
76
+ ```ruby
77
+ api.get
78
+ # => #<HyperResource:0xABCD1234 @root="https://api.example.com" @href="" @namespace=nil ... >
79
+ ```
80
+
81
+ What'd we get back?
82
+
83
+ ```ruby
84
+ api.body
85
+ # => { 'message' => 'Welcome to the Example.com API',
86
+ # 'version' => 1,
87
+ # '_links' => {
88
+ # 'curies' => [{
89
+ # 'name' => 'example',
90
+ # 'templated' => true,
91
+ # 'href' => 'https://api.example.com/rels/{rel}'
92
+ # }],
93
+ # 'self' => {'href' => '/'},
94
+ # 'example:users' => {'href' => '/users{?email,last_name}', 'templated' => true},
95
+ # 'example:forums' => {'href' => '/forums{?title}', 'templated' => true}
96
+ # }
97
+ # }
98
+ ```
99
+
100
+ Lovely. Let's find a user by their email.
101
+
102
+ ```ruby
103
+ jdoe_user = api.users(email: "jdoe@example.com").first
104
+ # => #<HyperResource:0x12312312 ...>
105
+ ```
106
+
107
+ HyperResource has performed some behind-the-scenes expansions here.
108
+
109
+ First, the `example:users` link was
110
+ added to the `api` object at the time the resource was
111
+ loaded with `api.get`. And since the link rel has a
112
+ [CURIE prefix](http://tools.ietf.org/html/draft-kelly-json-hal-06#section-8.2),
113
+ HyperResource will allow a shortened version of its name, `users`.
114
+
115
+ Then, calling `first` on the `users` link
116
+ followed the link and loaded it automatically.
117
+
118
+ Finally, calling `first` on the resource containing one set of
119
+ embedded objects -- like this one -- delegates the method to
120
+ `.objects.first`, which returns the first object in the resource.
121
+
122
+ Here are some equivalent expressions to the above. HyperResource offers
123
+ a very short, expressive syntax as its primary interface,
124
+ but you can always fall back to explicit syntax if you like or need to.
125
+
126
+
127
+ ```
128
+ api.users(email: "jdoe@example.com").first
129
+ api.get.users(email: "jdoe@example.com").first
130
+ api.get.links.users(email: "jdoe@example.com").first
131
+ api.get.links['users'].where(email: "jdoe@example.com").first
132
+ api.get.links['users'].where(email: "jdoe@example.com").get.first
133
+ api.get.links['users'].where(email: "jdoe@example.com").get.objects.first[1][0]
134
+ ```
135
+
136
+ ## Use It - ActiveResource-style
137
+
138
+ If an API is returning data type information as part of the response,
139
+ then we can assign those data types to ruby classes so that they can
140
+ be extended.
141
+
142
+ For example, in our hypothetical Example API above, a user object is
143
+ returned with a custom media type bearing a 'type=User' modifier. We
144
+ will extend the User class with a few convenience methods.
145
+
146
+ ```ruby
147
+ class ExampleAPI < HyperResource
148
+ self.root = 'https://api.example.com'
149
+ self.headers = {'Accept' => 'application/vnd.example.com.v1+json'}
150
+ self.auth = {basic: ['username', 'password']}
151
+
152
+ class User < ExampleAPI
153
+ def full_name
154
+ first_name + ' ' + last_name
155
+ end
156
+ end
157
+ end
158
+
159
+ api = ExampleApi.new
160
+
161
+ user = api.users.where(email: 'jdoe@example.com').first
162
+ # => #<ExampleApi::User:0xffffffff ...>
163
+
164
+ user.full_name
165
+ # => "John Doe"
166
+ ```
167
+
168
+ Don't worry if your API uses some other mechanism to indicate resource data
169
+ type; you can override the `.get_data_type` method and
170
+ implement your own logic.
171
+
172
+ ## Configuration for Multiple Hosts
173
+
174
+ HyperResource supports the concept of different APIs connected in one
175
+ ecosystem by providing a mechanism to scope configuration parameters by
176
+ a URL mask. This allows a simple way to provide separate
177
+ authentication, headers, etc. to different services which link to each
178
+ other.
179
+
180
+ As a toy example, consider two servers. `http://localhost:12345/` returns:
181
+
182
+ ```json
183
+ { "name": "Server One",
184
+ "_links": {
185
+ "self": {"href": "http://localhost:12345/"},
186
+ "server_two": {"href": "http://localhost:23456/"}
187
+ }
188
+ }
189
+ ```
190
+
191
+ And `http://localhost:23456/` returns:
192
+
193
+ ```json
194
+ { "name": "Server Two",
195
+ "_links": {
196
+ "self": {"href": "http://localhost:23456/"},
197
+ "server_one": {"href": "http://localhost:12345/"}
198
+ }
199
+ }
200
+ ```
201
+
202
+ The following configuration would ensure proper namespacing of the two
203
+ servers' response objects:
204
+
205
+ ```ruby
206
+ class APIEcosystem < HyperResource
207
+ self.config(
208
+ "localhost:12345" => {"namespace" => "ServerOneAPI"},
209
+ "localhost:23456" => {"namespace" => "ServerTwoAPI"}
210
+ )
211
+ end
212
+
213
+ root_one = APIEcosystem.new(root: ‘http://localhost:12345’).get
214
+ root_one.name # => ‘Server One’
215
+ root_one.url # => ‘http://localhost:12345’
216
+ root_one.namespace # => ServerOneAPI
217
+
218
+ root_two = root_one.server_two.get
219
+ root_two.name # => ‘Server Two’
220
+ root_two.url # => ‘http://localhost:23456’
221
+ root_two.namespace # => ServerTwoAPI
222
+ ```
223
+
224
+ URL masks are matched by HyperResource itself; it no longer uses the
225
+ [FuzzyURL](https://github.com/gamache/fuzzyurl/) gem. A mask may include a
226
+ protocol, hostname, and port, e.g. `https://api.example.com:8080`,
227
+ `api.example.com`, `*.example.com`, or `*`. Omitted parts match anything,
228
+ and more specific masks take priority. A URL without a protocol is treated
229
+ as `https`, so it matches `https://` and `:443` masks, but not `http://` or
230
+ `:80` masks.
231
+
232
+ ## Error Handling
233
+
234
+ HyperResource raises a `HyperResource::ClientError` on 4xx responses,
235
+ and `HyperResource::ServerError` on 5xx responses. Catch one or both
236
+ (`HyperResource::ResponseError`). The exceptions contain as much of
237
+ `cause` (internal exception which led to this one), `response`
238
+ (`Faraday::Response` object), and `body` (the decoded response
239
+ as a `Hash`) as is possible at the time.
240
+
241
+ ## Contributors
242
+
243
+ Many thanks to the people who've sent pull requests and improved this code:
244
+
245
+ * Jean-Charles d'Anthenaise Sisk ([@jasisk](https://github.com/jasisk))
246
+ * Ian Asaff ([@montague](https://github.com/montague))
247
+ * Julien Blanchard ([@julienXX](https://github.com/julienXX))
248
+ * Andrew Rollins ([@andrew311](https://github.com/andrew311))
249
+ * Étienne Barrié ([@etiennebarrie](https://github.com/etiennebarrie))
250
+ * James Martelletti ([@jmartelletti](https://github.com/jmartelletti))
251
+ * Frank Macreery ([@fancyremarker](https://github.com/fancyremarker))
252
+ * Chris Rollock ([@Sastafer](https://github.com/Sastafer))
253
+ * Pieter Vanmeerbeek ([@pvmeerbe](https://github.com/pvmeerbe))
254
+
255
+ ## Authorship and License
256
+
257
+ Copyright 2013-2015 Pete Gamache, [pete@gamache.org](mailto:pete@gamache.org).
258
+ Fork maintained by [PRX](https://github.com/PRX) since 2025.
259
+
260
+ Released under the MIT License. See LICENSE.txt.
261
+
262
+ If you got this far, you should probably follow @gamache on Twitter.
263
+ [@gamache](https://twitter.com/gamache)
@@ -0,0 +1,105 @@
1
+ require "rubygems" if RUBY_VERSION[0..2] == "1.8"
2
+ require "json"
3
+
4
+ class HyperResource
5
+ class Adapter
6
+ ## HyperResource::Adapter::HAL_JSON provides support for the HAL+JSON
7
+ ## hypermedia format by implementing the interface defined in
8
+ ## HyperResource::Adapter.
9
+
10
+ class HAL_JSON < Adapter # standard:disable Naming/ClassAndModuleCamelCase
11
+ class << self
12
+ def serialize(object)
13
+ JSON.dump(object)
14
+ end
15
+
16
+ def deserialize(string)
17
+ JSON.parse(string)
18
+ end
19
+
20
+ def apply(response, resource, opts = {})
21
+ if !response.is_a?(Hash)
22
+ raise ArgumentError, "'response' argument must be a Hash (got #{response.inspect})"
23
+ end
24
+ if !resource.is_a?(HyperResource)
25
+ raise ArgumentError, "'resource' argument must be a HyperResource (got #{resource.inspect})"
26
+ end
27
+
28
+ apply_objects(response, resource)
29
+ apply_links(response, resource)
30
+ apply_attributes(response, resource)
31
+ resource.loaded = true
32
+ new_href = begin
33
+ response["_links"]["self"]["href"]
34
+ rescue
35
+ nil
36
+ end
37
+ resource.href = new_href unless new_href.nil? && resource.href
38
+ resource
39
+ end
40
+
41
+ private
42
+
43
+ def apply_objects(resp, rsrc)
44
+ return unless resp["_embedded"]
45
+ objs = rsrc.objects
46
+
47
+ resp["_embedded"].each do |name, collection|
48
+ if collection.is_a? Hash
49
+ href = begin
50
+ collection["_links"]["self"]["href"]
51
+ rescue
52
+ nil
53
+ end
54
+ objs[name] =
55
+ rsrc.new_from(resource: rsrc,
56
+ body: collection,
57
+ href: href)
58
+ else
59
+ objs[name] = collection.map do |obj|
60
+ href = begin
61
+ obj["_links"]["self"]["href"]
62
+ rescue
63
+ nil
64
+ end
65
+ rsrc.new_from(resource: rsrc,
66
+ body: obj,
67
+ href: href)
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def apply_links(resp, rsrc)
74
+ return unless resp["_links"]
75
+ links = rsrc.links
76
+
77
+ resp["_links"].each do |rel, link_spec|
78
+ links[rel] = if link_spec.is_a? Array
79
+ link_spec.map do |link|
80
+ new_link_from_spec(rsrc, link)
81
+ end
82
+ else
83
+ new_link_from_spec(rsrc, link_spec)
84
+ end
85
+ end
86
+ end
87
+
88
+ def new_link_from_spec(resource, link_spec)
89
+ resource.class::Link.new(resource, link_spec)
90
+ end
91
+
92
+ def apply_attributes(resp, rsrc)
93
+ given_attrs = resp.except("_links", "_embedded")
94
+ filtered_attrs = rsrc.incoming_body_filter(given_attrs)
95
+
96
+ filtered_attrs.keys.each do |attr|
97
+ rsrc.attributes[attr] = filtered_attrs[attr]
98
+ end
99
+
100
+ rsrc.attributes._hr_clear_changed
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,28 @@
1
+ class HyperResource
2
+ ## HyperResource::Adapter is the interface/abstract base class for
3
+ ## adapters to different hypermedia formats (e.g., HAL+JSON). New
4
+ ## adapters must implement the public methods of this class.
5
+
6
+ class Adapter
7
+ class << self
8
+ ## Serialize the given object into a string.
9
+ def serialize(object)
10
+ raise NotImplementedError, "This is an abstract method -- subclasses " \
11
+ "of HyperResource::Adapter must implement it."
12
+ end
13
+
14
+ ## Deserialize a given string into an object (Hash).
15
+ def deserialize(string)
16
+ raise NotImplementedError, "This is an abstract method -- subclasses " \
17
+ "of HyperResource::Adapter must implement it."
18
+ end
19
+
20
+ ## Use a given deserialized response object (Hash) to update a given
21
+ ## resource (HyperResource), returning the updated resource.
22
+ def apply(response, resource, opts = {})
23
+ raise NotImplementedError, "This is an abstract method -- subclasses " \
24
+ "of HyperResource::Adapter must implement it."
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,73 @@
1
+ class HyperResource
2
+ class Attributes < Hash
3
+ attr_accessor :_resource # @private
4
+
5
+ def initialize(resource = nil)
6
+ self._resource = resource || HyperResource.new
7
+ end
8
+
9
+ ## Returns +true+ if the given attribute has been changed since creation
10
+ ## time, +false+ otherwise.
11
+ ## If no attribute is given, return whether any attributes have been
12
+ ## changed.
13
+ def changed?(attr = nil)
14
+ @_hr_changed ||= Hash.new(false)
15
+ return @_hr_changed[attr.to_sym] if attr
16
+ @_hr_changed.keys.count > 0
17
+ end
18
+
19
+ ## Returns a hash of the attributes and values which have been changed
20
+ ## since creation time.
21
+ def changed_attributes
22
+ @_hr_changed.select { |k, v| v }.keys.each_with_object({}) { |k, h|
23
+ h[k] = self[k]
24
+ }
25
+ end
26
+
27
+ # @private
28
+ def []=(attr, value)
29
+ return if has_key?(attr.to_s) && self[attr] == value
30
+ _hr_mark_changed(attr)
31
+ super(attr.to_s, value)
32
+ end
33
+
34
+ # @private
35
+ def [](key)
36
+ return super(key.to_s) if has_key?(key.to_s)
37
+ return super(key.to_sym) if has_key?(key.to_sym)
38
+ nil
39
+ end
40
+
41
+ # @private
42
+ def method_missing(method, *args) # standard:disable Style/MissingRespondToMissing
43
+ method = method.to_s
44
+ if has_key?(method)
45
+ self[method]
46
+ elsif method[-1, 1] == "="
47
+ self[method[0..-2]] = args.first
48
+ else
49
+ raise NoMethodError, "undefined method `#{method}' for #{inspect}"
50
+ end
51
+ end
52
+
53
+ # @private
54
+ def respond_to?(method, *args)
55
+ method = method.to_s
56
+ return true if has_key?(method)
57
+ return true if method[-1, 1] == "=" && has_key?(method[0..-2])
58
+ super
59
+ end
60
+
61
+ # @private
62
+ def _hr_clear_changed # @private
63
+ @_hr_changed = nil
64
+ end
65
+
66
+ # @private
67
+ def _hr_mark_changed(attr, is_changed = true)
68
+ attr = attr.to_sym
69
+ @_hr_changed ||= Hash.new(false)
70
+ @_hr_changed[attr] = is_changed
71
+ end
72
+ end
73
+ end