├── .gitignore ├── .travis.yml ├── .yardopts ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── hyper_resource.gemspec ├── lib ├── hyper_resource.rb ├── hyper_resource │ ├── adapter.rb │ ├── adapter │ │ └── hal_json.rb │ ├── attributes.rb │ ├── configuration.rb │ ├── exceptions.rb │ ├── link.rb │ ├── links.rb │ ├── modules │ │ ├── config_attributes.rb │ │ ├── data_type.rb │ │ ├── deprecations.rb │ │ ├── http.rb │ │ └── internal_attributes.rb │ ├── objects.rb │ └── version.rb └── hyperresource.rb └── test ├── live ├── live_test.rb ├── live_test_server.rb ├── two_server_test.rb └── two_test_servers.rb ├── test_helper.rb └── unit ├── attributes_test.rb ├── caching_test.rb ├── config_test.rb ├── configuration_test.rb ├── embedded_test.rb ├── filters_test.rb ├── http_test.rb ├── hyper_resource_test.rb ├── link_test.rb ├── links_test.rb ├── respond_to_test.rb └── version_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | hyperresource-*.gem 2 | Gemfile.lock 3 | .yardoc/ 4 | doc/ 5 | .bundle/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.0 4 | - 1.8.7 5 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup-provider=redcarpet 2 | --markup=markdown 3 | --no-private 4 | 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2013 Pete Gamache, pete@gamache.org. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperResource [![Build Status](https://travis-ci.org/gamache/hyperresource.png?branch=master)](https://travis-ci.org/gamache/hyperresource) 2 | 3 | HyperResource is a Ruby client library for hypermedia web services. 4 | 5 | HyperResource makes using a hypermedia API feel like calling plain old 6 | methods on plain old objects. 7 | 8 | It is usable with no configuration other than API root endpoint, but 9 | also allows incoming data types to be extended with Ruby code. 10 | 11 | HyperResource supports the 12 | [HAL+JSON hypermedia format](http://stateless.co/hal_specification.html), 13 | with support for Siren and other hypermedia formats planned. 14 | 15 | For more insight into HyperResource's goals and design, 16 | [read the paper](http://petegamache.com/wsrest2014-gamache.pdf)! 17 | 18 | ## Hypermedia in a Nutshell 19 | 20 | Hypermedia APIs return a list of hyperlinks with each response. These 21 | links, each of which has a relation name or "rel", represent everything 22 | you can do to, with, or from the given response. They are URLs which 23 | can take arguments. The consumer of the hypermedia API uses these 24 | links to access the API's entire functionality. 25 | 26 | A primary advantage to hypermedia APIs is that a client library can 27 | write itself based on the hyperlinks coming back with each response. 28 | This removes both the chore of writing a custom client library in the 29 | first place, and also the process of pushing client updates to your 30 | users. 31 | 32 | ## HyperResource Philosophy 33 | 34 | An automatically-generated library can and should feel as comfortable as a 35 | custom client library. Hypermedia brings many promises, but this is one 36 | we can deliver on. 37 | 38 | If you're an API user, HyperResource will help you consume a hypermedia 39 | API with short, direct, elegant code. 40 | If you're an API designer, HyperResource is a great default client, or a 41 | smart starting point for a rich SDK. 42 | 43 | Link-driven APIs are the future, and proper tooling can make it The Jetsons 44 | instead of The Road Warrior. 45 | 46 | ## Install It 47 | 48 | Nothing special is required, just: `gem install hyperresource` 49 | 50 | HyperResource works on Ruby 1.8.7 to present, and JRuby in 1.8 mode or 51 | above. 52 | 53 | HyperResource uses the 54 | [uri_template](https://github.com/hannesg/uri_template) 55 | and [Faraday](https://github.com/lostisland/faraday) 56 | gems. 57 | 58 | ## Use It - Zero Configuration 59 | 60 | Set up API connection: 61 | 62 | ```ruby 63 | api = HyperResource.new(root: 'https://api.example.com', 64 | headers: {'Accept' => 'application/vnd.example.com.v1+json'}, 65 | auth: {basic: ['username', 'password']}) 66 | # => # 67 | ``` 68 | 69 | Now we can get the API's root resource, the gateway to everything else 70 | on the API. 71 | 72 | ```ruby 73 | api.get 74 | # => # 75 | ``` 76 | 77 | What'd we get back? 78 | 79 | ```ruby 80 | api.body 81 | # => { 'message' => 'Welcome to the Example.com API', 82 | # 'version' => 1, 83 | # '_links' => { 84 | # 'curies' => [{ 85 | # 'name' => 'example', 86 | # 'templated' => true, 87 | # 'href' => 'https://api.example.com/rels/{rel}' 88 | # }], 89 | # 'self' => {'href' => '/'}, 90 | # 'example:users' => {'href' => '/users{?email,last_name}', 'templated' => true}, 91 | # 'example:forums' => {'href' => '/forums{?title}', 'templated' => true} 92 | # } 93 | # } 94 | ``` 95 | 96 | Lovely. Let's find a user by their email. 97 | 98 | ```ruby 99 | jdoe_user = api.users(email: "jdoe@example.com").first 100 | # => # 101 | ``` 102 | 103 | HyperResource has performed some behind-the-scenes expansions here. 104 | 105 | First, the `example:users` link was 106 | added to the `api` object at the time the resource was 107 | loaded with `api.get`. And since the link rel has a 108 | [CURIE prefix](http://tools.ietf.org/html/draft-kelly-json-hal-06#section-8.2), 109 | HyperResource will allow a shortened version of its name, `users`. 110 | 111 | Then, calling `first` on the `users` link 112 | followed the link and loaded it automatically. 113 | 114 | Finally, calling `first` on the resource containing one set of 115 | embedded objects -- like this one -- delegates the method to 116 | `.objects.first`, which returns the first object in the resource. 117 | 118 | Here are some equivalent expressions to the above. HyperResource offers 119 | a very short, expressive syntax as its primary interface, 120 | but you can always fall back to explicit syntax if you like or need to. 121 | 122 | 123 | ``` 124 | api.users(email: "jdoe@example.com").first 125 | api.get.users(email: "jdoe@example.com").first 126 | api.get.links.users(email: "jdoe@example.com").first 127 | api.get.links['users'].where(email: "jdoe@example.com").first 128 | api.get.links['users'].where(email: "jdoe@example.com").get.first 129 | api.get.links['users'].where(email: "jdoe@example.com").get.objects.first[1][0] 130 | ``` 131 | 132 | ## Use It - ActiveResource-style 133 | 134 | If an API is returning data type information as part of the response, 135 | then we can assign those data types to ruby classes so that they can 136 | be extended. 137 | 138 | For example, in our hypothetical Example API above, a user object is 139 | returned with a custom media type bearing a 'type=User' modifier. We 140 | will extend the User class with a few convenience methods. 141 | 142 | ```ruby 143 | class ExampleAPI < HyperResource 144 | self.root = 'https://api.example.com' 145 | self.headers = {'Accept' => 'application/vnd.example.com.v1+json'} 146 | self.auth = {basic: ['username', 'password']} 147 | 148 | class User < ExampleAPI 149 | def full_name 150 | first_name + ' ' + last_name 151 | end 152 | end 153 | end 154 | 155 | api = ExampleApi.new 156 | 157 | user = api.users.where(email: 'jdoe@example.com').first 158 | # => # 159 | 160 | user.full_name 161 | # => "John Doe" 162 | ``` 163 | 164 | Don't worry if your API uses some other mechanism to indicate resource data 165 | type; you can override the `.get_data_type` method and 166 | implement your own logic. 167 | 168 | ## Configuration for Multiple Hosts 169 | 170 | HyperResource supports the concept of different APIs connected in one 171 | ecosystem by providing a mechanism to scope configuration parameters by 172 | a URL mask. This allows a simple way to provide separate 173 | authentication, headers, etc. to different services which link to each 174 | other. 175 | 176 | As a toy example, consider two servers. `http://localhost:12345/` returns: 177 | 178 | ```json 179 | { "name": "Server One", 180 | "_links": { 181 | "self": {"href": "http://localhost:12345/"}, 182 | "server_two": {"href": "http://localhost:23456/"} 183 | } 184 | } 185 | ``` 186 | 187 | And `http://localhost:23456/` returns: 188 | 189 | ```json 190 | { "name": "Server Two", 191 | "_links": { 192 | "self": {"href": "http://localhost:23456/"}, 193 | "server_one": {"href": "http://localhost:12345/"} 194 | } 195 | } 196 | ``` 197 | 198 | The following configuration would ensure proper namespacing of the two 199 | servers' response objects: 200 | 201 | ```ruby 202 | class APIEcosystem < HyperResource 203 | self.config( 204 | "localhost:12345" => {"namespace" => "ServerOneAPI"}, 205 | "localhost:23456" => {"namespace" => "ServerTwoAPI"} 206 | ) 207 | end 208 | 209 | root_one = APIEcosystem.new(root: ‘http://localhost:12345’).get 210 | root_one.name # => ‘Server One’ 211 | root_one.url # => ‘http://localhost:12345’ 212 | root_one.namespace # => ServerOneAPI 213 | 214 | root_two = root_one.server_two.get 215 | root_two.name # => ‘Server Two’ 216 | root_two.url # => ‘http://localhost:23456’ 217 | root_two.namespace # => ServerTwoAPI 218 | ``` 219 | 220 | Fuzzy matching of URL masks is provided by the 221 | [FuzzyURL](https://github.com/gamache/fuzzyurl/) gem; check there for 222 | full documentation of URL mask syntax. 223 | 224 | ## Error Handling 225 | 226 | HyperResource raises a `HyperResource::ClientError` on 4xx responses, 227 | and `HyperResource::ServerError` on 5xx responses. Catch one or both 228 | (`HyperResource::ResponseError`). The exceptions contain as much of 229 | `cause` (internal exception which led to this one), `response` 230 | (`Faraday::Response` object), and `body` (the decoded response 231 | as a `Hash`) as is possible at the time. 232 | 233 | ## Contributors 234 | 235 | Many thanks to the people who've sent pull requests and improved this code: 236 | 237 | * Jean-Charles d'Anthenaise Sisk ([@jasisk](https://github.com/jasisk)) 238 | * Ian Asaff ([@montague](https://github.com/montague)) 239 | * Julien Blanchard ([@julienXX](https://github.com/julienXX)) 240 | * Andrew Rollins ([@andrew311](https://github.com/andrew311)) 241 | * Étienne Barrié ([@etiennebarrie](https://github.com/etiennebarrie)) 242 | * James Martelletti ([@jmartelletti](https://github.com/jmartelletti)) 243 | * Frank Macreery ([@fancyremarker](https://github.com/fancyremarker)) 244 | * Chris Rollock ([@Sastafer](https://github.com/Sastafer)) 245 | * Pieter Vanmeerbeek ([@pvmeerbe](https://github.com/pvmeerbe)) 246 | 247 | ## Authorship and License 248 | 249 | Copyright 2013-2015 Pete Gamache, [pete@gamache.org](mailto:pete@gamache.org). 250 | 251 | Released under the MIT License. See LICENSE.txt. 252 | 253 | If you got this far, you should probably follow me on Twitter. 254 | [@gamache](https://twitter.com/gamache) 255 | 256 | 257 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | require './lib/hyper_resource/version' 3 | 4 | task :default => [:test] 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'test' 8 | t.libs << 'test/lib' 9 | t.test_files = FileList['test/unit/*_test.rb'] + 10 | FileList['test/live/*_test.rb'] 11 | t.warning = !!ENV['WARNINGS'] 12 | t.verbose = true 13 | end 14 | 15 | task :release do 16 | system(<<-EOT) 17 | git add lib/hyper_resource/version.rb 18 | git commit -m 'release v#{HyperResource::VERSION}' 19 | git push origin 20 | git tag v#{HyperResource::VERSION} 21 | git push --tags origin 22 | gem build hyper_resource.gemspec 23 | gem push hyperresource-#{HyperResource::VERSION}.gem 24 | EOT 25 | end 26 | 27 | task :test_server do 28 | require './test/live/live_test_server' 29 | port = ENV['PORT'] || ENV['port'] || 3000 30 | Rack::Handler::WEBrick.run(LiveTestServer.new, :Port => port) 31 | end 32 | 33 | -------------------------------------------------------------------------------- /hyper_resource.gemspec: -------------------------------------------------------------------------------- 1 | require './lib/hyper_resource/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'hyperresource' 5 | s.version = HyperResource::VERSION 6 | s.date = HyperResource::VERSION_DATE 7 | 8 | s.summary = 'Extensible hypermedia client for Ruby' 9 | s.description = <<-EOT 10 | HyperResource is a hypermedia client library for Ruby. Its goals are to 11 | interface directly with well-behaved hypermedia APIs, to allow the data 12 | returned from these APIs to optionally be extended by client-side code, 13 | and to present a modern replacement for ActiveResource. 14 | EOT 15 | s.homepage = 'https://github.com/gamache/hyperresource' 16 | s.authors = ['Pete Gamache'] 17 | s.email = 'pete@gamache.org' 18 | 19 | s.files = Dir['lib/**/*'] 20 | s.license = 'MIT' 21 | s.has_rdoc = true 22 | s.require_path = 'lib' 23 | 24 | s.required_ruby_version = '>= 1.8.7' 25 | 26 | s.add_dependency 'uri_template', '>= 0.5.2' 27 | s.add_dependency 'faraday', '>= 0.8.6' 28 | s.add_dependency 'json' 29 | s.add_dependency 'fuzzyurl', '0.2.2' 30 | 31 | s.add_development_dependency 'rake', '>= 10.0.4' 32 | s.add_development_dependency 'minitest', '>= 4.7.0' 33 | s.add_development_dependency 'mocha', '>= 0.13.3' 34 | s.add_development_dependency 'sinatra', '>= 1.4.0' 35 | #s.add_development_dependency 'debugger' 36 | end 37 | 38 | -------------------------------------------------------------------------------- /lib/hyper_resource.rb: -------------------------------------------------------------------------------- 1 | require 'hyper_resource/attributes' 2 | require 'hyper_resource/configuration' 3 | require 'hyper_resource/exceptions' 4 | require 'hyper_resource/link' 5 | require 'hyper_resource/links' 6 | require 'hyper_resource/objects' 7 | require 'hyper_resource/version' 8 | 9 | require 'hyper_resource/adapter' 10 | require 'hyper_resource/adapter/hal_json' 11 | 12 | require 'hyper_resource/modules/data_type' 13 | require 'hyper_resource/modules/deprecations' 14 | require 'hyper_resource/modules/http' 15 | require 'hyper_resource/modules/config_attributes' 16 | require 'hyper_resource/modules/internal_attributes' 17 | 18 | require 'rubygems' if RUBY_VERSION[0..2] == '1.8' 19 | 20 | require 'pp' 21 | 22 | ## HyperResource is the main resource base class. Normally it will be used 23 | ## through subclassing, though it may also be used directly. 24 | 25 | class HyperResource 26 | 27 | include HyperResource::Modules::ConfigAttributes 28 | include HyperResource::Modules::DataType 29 | include HyperResource::Modules::Deprecations 30 | include HyperResource::Modules::InternalAttributes 31 | include Enumerable 32 | 33 | private 34 | 35 | DEFAULT_HEADERS = { 'Accept' => 'application/json' } 36 | 37 | public 38 | 39 | ## Create a new HyperResource, given a hash of options. These options 40 | ## include: 41 | ## 42 | ## [root] The root URL of the resource. 43 | ## 44 | ## [auth] Authentication information. Currently only +{basic: 45 | ## ['key', 'secret']}+ is supported. 46 | ## 47 | ## [namespace] Class or class name, into which resources should be 48 | ## instantiated. 49 | ## 50 | ## [headers] Headers to send along with requests for this resource (as 51 | ## well as its eventual child resources, if any). 52 | ## 53 | ## [faraday_options] Configuration passed to +Faraday::Connection.initialize+, 54 | ## such as +{request: {timeout: 30}}+. 55 | ## 56 | def initialize(opts={}) 57 | self.root = opts[:root] if opts[:root] 58 | self.href = opts[:href] if opts[:href] 59 | 60 | self.hr_config = self.class.hr_config.clone 61 | 62 | self.adapter = opts[:adapter] if opts[:adapter] 63 | self.faraday_options = opts[:faraday_options] if opts[:faraday_options] 64 | self.auth = opts[:auth] if opts[:auth] 65 | 66 | self.headers = DEFAULT_HEADERS. 67 | merge(self.class.headers || {}). 68 | merge(opts[:headers] || {}) 69 | 70 | self.namespace = opts[:namespace] if opts[:namespace] 71 | if !self.namespace && self.class != HyperResource 72 | self.namespace = self.class.namespace || self.class.to_s 73 | end 74 | 75 | ## There's a little acrobatics in getting Attributes, Links, and Objects 76 | ## into the correct subclass. 77 | if self.class != HyperResource 78 | if self.class::Attributes == HyperResource::Attributes 79 | Object.module_eval( 80 | "class #{self.class}::Attributes < HyperResource::Attributes; end" 81 | ) 82 | end 83 | if self.class::Links == HyperResource::Links 84 | Object.module_eval( 85 | "class #{self.class}::Links < HyperResource::Links; end" 86 | ) 87 | end 88 | if self.class::Objects == HyperResource::Objects 89 | Object.module_eval( 90 | "class #{self.class}::Objects < HyperResource::Objects; end" 91 | ) 92 | end 93 | end 94 | 95 | self.attributes = self.class::Attributes.new(self) 96 | self.links = self.class::Links.new(self) 97 | self.objects = self.class::Objects.new(self) 98 | self.loaded = false 99 | end 100 | 101 | 102 | 103 | ## Creates a new resource given args :link, :resource, :href, :response, :url, 104 | ## and :body. Either :link or (:resource and :href and :url) are required. 105 | # @private 106 | def self.new_from(args) 107 | link = args[:link] 108 | resource = args[:resource] || link.resource 109 | href = args[:href] || link.href rescue nil 110 | url = args[:url] || URI.join(resource.root, href || '') 111 | response = args[:response] 112 | body = args[:body] || {} 113 | 114 | old_rsrc = resource 115 | new_class = old_rsrc.get_data_type_class(:resource => old_rsrc, 116 | :link => link, 117 | :url => url, 118 | :response => response, 119 | :body => body) 120 | new_rsrc = new_class.new(:root => old_rsrc.root, 121 | :href => href) 122 | new_rsrc.hr_config = old_rsrc.hr_config.clone 123 | new_rsrc.response = response 124 | new_rsrc.body = body 125 | new_rsrc.adapter.apply(body, new_rsrc) 126 | new_rsrc.loaded = true 127 | new_rsrc 128 | end 129 | 130 | # @private 131 | def new_from(args) 132 | self.class.new_from(args) 133 | end 134 | 135 | 136 | ## Returns true if one or more of this object's attributes has been 137 | ## reassigned. 138 | def changed?(*args) 139 | attributes.changed?(*args) 140 | end 141 | 142 | 143 | #### Filters 144 | 145 | ## +incoming_body_filter+ filters a hash of attribute keys and values 146 | ## on their way from a response body to a HyperResource. Override this 147 | ## in a subclass of HyperResource to implement filters on incoming data. 148 | def incoming_body_filter(attr_hash) 149 | attr_hash 150 | end 151 | 152 | ## +outgoing_body_filter+ filters a hash of attribute keys and values 153 | ## on their way from a HyperResource to a request body. Override this 154 | ## in a subclass of HyperResource to implement filters on outgoing data. 155 | def outgoing_body_filter(attr_hash) 156 | attr_hash 157 | end 158 | 159 | ## +outgoing_uri_filter+ filters a hash of attribute keys and values 160 | ## on their way from a HyperResource to a URL. Override this 161 | ## in a subclass of HyperResource to implement filters on outgoing URI 162 | ## parameters. 163 | def outgoing_uri_filter(attr_hash) 164 | attr_hash 165 | end 166 | 167 | 168 | #### Enumerable support 169 | 170 | ## Returns the *i*th object in the first collection of objects embedded 171 | ## in this resource. Returns nil on failure. 172 | def [](i) 173 | get unless loaded 174 | self.objects.first[1][i] rescue nil 175 | end 176 | 177 | ## Iterates over the objects in the first collection of embedded objects 178 | ## in this resource. 179 | def each(&block) 180 | get unless loaded 181 | self.objects.first[1].each(&block) rescue nil 182 | end 183 | 184 | #### Magic 185 | 186 | ## method_missing will load this resource if not yet loaded, then 187 | ## attempt to delegate to +attributes+, then +objects+, then +links+. 188 | ## Override with extreme care. 189 | def method_missing(method, *args) 190 | ## If not loaded, load and retry. 191 | unless loaded 192 | return self.get.send(method, *args) 193 | end 194 | 195 | 196 | ## Otherwise, try to match against attributes, then objects, then links. 197 | method = method.to_s 198 | if method[-1,1] == '=' 199 | return attributes[method[0..-2]] = args.first if attributes && attributes.has_key?(method[0..-2]) 200 | else 201 | return attributes[method] if attributes && attributes.has_key?(method.to_s) 202 | return objects[method] if objects && objects[method] 203 | if links && links[method] 204 | if args.count > 0 205 | return links[method].where(*args) 206 | else 207 | return links[method] 208 | end 209 | end 210 | end 211 | 212 | raise NoMethodError, "undefined method `#{method}' for #{self.inspect}" 213 | end 214 | 215 | ## respond_to? is patched to return +true+ if +method_missing+ would 216 | ## successfully delegate a method call to +attributes+, +links+, or 217 | ## +objects+. 218 | def respond_to?(method, *args) 219 | return true if self.links && self.links.respond_to?(method) 220 | return true if self.attributes && self.attributes.respond_to?(method) 221 | return true if self.objects && self.objects.respond_to?(method) 222 | super 223 | end 224 | 225 | 226 | def inspect # @private 227 | "#<#{self.class}:0x#{"%x" % self.object_id} @root=#{self.root.inspect} "+ 228 | "@href=#{self.href.inspect} @loaded=#{self.loaded} "+ 229 | "@namespace=#{self.namespace.inspect} ...>" 230 | end 231 | 232 | def self.user_agent # @private 233 | "HyperResource #{HyperResource::VERSION}" 234 | end 235 | 236 | def user_agent # @private 237 | self.class.user_agent 238 | end 239 | 240 | end 241 | 242 | -------------------------------------------------------------------------------- /lib/hyper_resource/adapter.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | 3 | ## HyperResource::Adapter is the interface/abstract base class for 4 | ## adapters to different hypermedia formats (e.g., HAL+JSON). New 5 | ## adapters must implement the public methods of this class. 6 | 7 | class Adapter 8 | class << self 9 | 10 | ## Serialize the given object into a string. 11 | def serialize(object) 12 | raise NotImplementedError, "This is an abstract method -- subclasses "+ 13 | "of HyperResource::Adapter must implement it." 14 | end 15 | 16 | ## Deserialize a given string into an object (Hash). 17 | def deserialize(string) 18 | raise NotImplementedError, "This is an abstract method -- subclasses "+ 19 | "of HyperResource::Adapter must implement it." 20 | end 21 | 22 | ## Use a given deserialized response object (Hash) to update a given 23 | ## resource (HyperResource), returning the updated resource. 24 | def apply(response, resource, opts={}) 25 | raise NotImplementedError, "This is an abstract method -- subclasses "+ 26 | "of HyperResource::Adapter must implement it." 27 | end 28 | 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/hyper_resource/adapter/hal_json.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' if RUBY_VERSION[0..2] == '1.8' 2 | require 'json' 3 | 4 | class HyperResource 5 | class Adapter 6 | 7 | ## HyperResource::Adapter::HAL_JSON provides support for the HAL+JSON 8 | ## hypermedia format by implementing the interface defined in 9 | ## HyperResource::Adapter. 10 | 11 | class HAL_JSON < Adapter 12 | class << self 13 | 14 | def serialize(object) 15 | JSON.dump(object) 16 | end 17 | 18 | def deserialize(string) 19 | JSON.parse(string) 20 | end 21 | 22 | def apply(response, resource, opts={}) 23 | if !response.kind_of?(Hash) 24 | raise ArgumentError, "'response' argument must be a Hash (got #{response.inspect})" 25 | end 26 | if !resource.kind_of?(HyperResource) 27 | raise ArgumentError, "'resource' argument must be a HyperResource (got #{resource.inspect})" 28 | end 29 | 30 | apply_objects(response, resource) 31 | apply_links(response, resource) 32 | apply_attributes(response, resource) 33 | resource.loaded = true 34 | new_href = response['_links']['self']['href'] rescue nil 35 | resource.href = new_href unless new_href.nil? && resource.href 36 | resource 37 | end 38 | 39 | 40 | private 41 | 42 | def apply_objects(resp, rsrc) 43 | return unless resp['_embedded'] 44 | objs = rsrc.objects 45 | 46 | resp['_embedded'].each do |name, collection| 47 | if collection.is_a? Hash 48 | href = collection['_links']['self']['href'] rescue nil 49 | objs[name] = 50 | rsrc.new_from(:resource => rsrc, 51 | :body => collection, 52 | :href => href) 53 | else 54 | objs[name] = collection.map do |obj| 55 | href = obj['_links']['self']['href'] rescue nil 56 | rsrc.new_from(:resource => rsrc, 57 | :body => obj, 58 | :href => href) 59 | end 60 | end 61 | end 62 | end 63 | 64 | 65 | def apply_links(resp, rsrc) 66 | return unless resp['_links'] 67 | links = rsrc.links 68 | 69 | resp['_links'].each do |rel, link_spec| 70 | if link_spec.is_a? Array 71 | links[rel] = link_spec.map do |link| 72 | new_link_from_spec(rsrc, link) 73 | end 74 | else 75 | links[rel] = new_link_from_spec(rsrc, link_spec) 76 | end 77 | end 78 | end 79 | 80 | def new_link_from_spec(resource, link_spec) 81 | resource.class::Link.new(resource, link_spec) 82 | end 83 | 84 | 85 | def apply_attributes(resp, rsrc) 86 | given_attrs = resp.reject{|k,v| %w(_links _embedded).include?(k)} 87 | filtered_attrs = rsrc.incoming_body_filter(given_attrs) 88 | 89 | filtered_attrs.keys.each do |attr| 90 | rsrc.attributes[attr] = filtered_attrs[attr] 91 | end 92 | 93 | rsrc.attributes._hr_clear_changed 94 | end 95 | 96 | end 97 | end 98 | end 99 | end 100 | 101 | -------------------------------------------------------------------------------- /lib/hyper_resource/attributes.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | class Attributes < Hash 3 | 4 | attr_accessor :_resource # @private 5 | 6 | def initialize(resource=nil) 7 | self._resource = resource || HyperResource.new 8 | end 9 | 10 | ## Returns +true+ if the given attribute has been changed since creation 11 | ## time, +false+ otherwise. 12 | ## If no attribute is given, return whether any attributes have been 13 | ## changed. 14 | def changed?(attr=nil) 15 | @_hr_changed ||= Hash.new(false) 16 | return @_hr_changed[attr.to_sym] if attr 17 | return @_hr_changed.keys.count > 0 18 | end 19 | 20 | ## Returns a hash of the attributes and values which have been changed 21 | ## since creation time. 22 | def changed_attributes 23 | @_hr_changed.select{|k,v| v}.keys.inject({}) {|h,k| h[k]=self[k]; h} 24 | end 25 | 26 | # @private 27 | def []=(attr, value) 28 | return self[attr] if self.has_key?(attr.to_s) && self[attr] == value 29 | _hr_mark_changed(attr) 30 | super(attr.to_s, value) 31 | end 32 | 33 | # @private 34 | def [](key) 35 | return super(key.to_s) if self.has_key?(key.to_s) 36 | return super(key.to_sym) if self.has_key?(key.to_sym) 37 | nil 38 | end 39 | 40 | # @private 41 | def method_missing(method, *args) 42 | method = method.to_s 43 | if self.has_key?(method) 44 | self[method] 45 | elsif method[-1,1] == '=' 46 | self[method[0..-2]] = args.first 47 | else 48 | raise NoMethodError, "undefined method `#{method}' for #{self.inspect}" 49 | end 50 | end 51 | 52 | # @private 53 | def respond_to?(method, *args) 54 | method = method.to_s 55 | return true if self.has_key?(method) 56 | return true if method[-1,1] == '=' && self.has_key?(method[0..-2]) 57 | super 58 | end 59 | 60 | # @private 61 | def _hr_clear_changed # @private 62 | @_hr_changed = nil 63 | end 64 | 65 | # @private 66 | def _hr_mark_changed(attr, is_changed=true) 67 | attr = attr.to_sym 68 | @_hr_changed ||= Hash.new(false) 69 | @_hr_changed[attr] = is_changed 70 | end 71 | 72 | end 73 | end 74 | 75 | -------------------------------------------------------------------------------- /lib/hyper_resource/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'fuzzyurl' 3 | 4 | class HyperResource 5 | 6 | ## HyperResource::Configuration is a class which implements a hostmask- 7 | ## scoped set of configurations. Key/value pairs are stored under hostmasks 8 | ## like 'api.example.com:8080', 'api.example.com', '*.example.com', or '*'. 9 | ## Values are retrieved using a hostname and a key, preferring more specific 10 | ## hostmasks when more than one matching hostmask and key are present. 11 | ## 12 | ## HyperResource users are not expected to use this class directly. 13 | class Configuration 14 | 15 | ## Creates a new HyperResource::Configuration, with the given initial 16 | ## internal state if provided. 17 | def initialize(cfg={}) 18 | @cfg = cfg 19 | @cfg['*'] ||= {} 20 | end 21 | 22 | ## Returns a deep copy of this object. 23 | def clone 24 | self.class.new.send(:initialize_copy, self) 25 | end 26 | 27 | ## Merges a given Configuration with this one. Deep-merges config 28 | ## attributes correctly. 29 | def merge(new) 30 | merged_cfg = {} 31 | new_cfg = new.send(:cfg) 32 | 33 | (new_cfg.keys | cfg.keys).each do |mask| 34 | new_cfg_attrs = new_cfg[mask] || {} 35 | cfg_attrs = cfg[mask] || {} 36 | merged_cfg[mask] = {} 37 | 38 | ## Do a hash merge when it makes sense, except when it doesn't. 39 | (new_cfg_attrs.keys | cfg_attrs.keys).each do |attr| 40 | if !(%w(namespace adapter).include?(attr)) && 41 | ((!cfg_attrs[attr] || cfg_attrs[attr].kind_of?(Hash)) && 42 | (!new_cfg_attrs[attr] || new_cfg_attrs[attr].kind_of?(Hash))) 43 | merged_cfg[mask][attr] = 44 | (cfg_attrs[attr] || {}).merge(new_cfg_attrs[attr] || {}) 45 | else 46 | merged_cfg[mask][attr] = new_cfg_attrs[attr] || cfg_attrs[attr] 47 | end 48 | end 49 | end 50 | self.class.new(merged_cfg) 51 | end 52 | 53 | ## Applies a given Configuration on top of this one. 54 | def merge!(new) 55 | initialize_copy(merge(new)) 56 | end 57 | 58 | ## Applies a given Hash of configurations on top of this one. 59 | def config(hash) 60 | merge!(self.class.new(hash)) 61 | end 62 | 63 | ## Returns this object as a Hash. 64 | def as_hash 65 | clone.send(:cfg) 66 | end 67 | 68 | ## Returns the value for a particular hostmask and key, or nil if not 69 | ## present. 70 | def get(mask, key) 71 | cfg[mask] ||= {} 72 | cfg[mask][key.to_s] 73 | end 74 | 75 | ## Sets a key and value pair for the given hostmask. 76 | def set(mask, key, value) 77 | cfg[mask] ||= {} 78 | cfg[mask][key.to_s] = value 79 | end 80 | 81 | ## Returns the best matching value for the given URL and key, or nil 82 | ## otherwise. 83 | def get_for_url(url, key) 84 | subconfig_for_url(url)[key.to_s] 85 | end 86 | 87 | ## Sets a key and value pair, using the given URL as the basis of the 88 | ## hostmask. Path, query, and fragment are ignored. 89 | def set_for_url(url, key, value) 90 | furl = FuzzyURL.new(url || '*') 91 | furl.path = nil 92 | furl.query = nil 93 | furl.fragment = nil 94 | set(furl.to_s, key, value) 95 | end 96 | 97 | 98 | ## Returns hostmasks from our config which match the given url. 99 | def matching_masks_for_url(url) 100 | url = url.to_s 101 | return ['*'] if !url || cfg.keys.count == 1 102 | @masks ||= {} ## key = mask string, value = FuzzyURL 103 | cfg.keys.each {|key| @masks[key] ||= FuzzyURL.new(key) } 104 | 105 | ## Test for matches, and sort by score. 106 | scores = {} 107 | cfg.keys.each {|key| scores[key] = @masks[key].match(url) } 108 | scores = Hash[ scores.select{|k,v| v} ] # remove nils 109 | scores.keys.sort_by{|k| [-scores[k], -k.length]} ## TODO length is cheesy 110 | end 111 | 112 | private 113 | 114 | def cfg 115 | @cfg 116 | end 117 | 118 | ## Performs a two-level-deep copy of old @cfg. 119 | def initialize_copy(old) 120 | old.send(:cfg).each do |mask, old_subcfg| 121 | new_subcfg = {} 122 | old_subcfg.each do |key, value| 123 | if value.kind_of?(Array) || value.kind_of?(Hash) 124 | value = value.clone 125 | end 126 | new_subcfg[key] = value 127 | end 128 | @cfg[mask] = new_subcfg 129 | end 130 | self 131 | end 132 | 133 | 134 | ## Returns a merged subconfig consisting of all matching hostmask 135 | ## subconfigs, giving priority to more specific hostmasks. 136 | def subconfig_for_url(url) 137 | matching_masks_for_url(url).inject({}) do |subcfg, mask| 138 | (cfg[mask] || {}).merge(subcfg) 139 | end 140 | end 141 | 142 | ## Returns the hostmasks which match the given url, sorted best match 143 | ## first. 144 | def get_possible_masks_for_host(host, port=80, masks=nil) 145 | masks ||= ["#{host}:#{port}", host] ## exact matches first 146 | host_parts = host.split('.') 147 | 148 | if host_parts.count < 2 149 | masks << '*' ## wildcard match last 150 | else 151 | parent_domain = host_parts[1..-1].join('.') 152 | masks << '*.' + parent_domain 153 | get_possible_masks_for_host(parent_domain, port, masks) 154 | end 155 | end 156 | 157 | end 158 | end 159 | 160 | -------------------------------------------------------------------------------- /lib/hyper_resource/exceptions.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | class Exception < ::StandardError 3 | ## The internal exception which led to this one, if any. 4 | attr_accessor :cause 5 | 6 | # @private 7 | def initialize(message, attrs={}) 8 | self.cause = attrs[:cause] 9 | super(message) 10 | end 11 | end 12 | 13 | class ResponseError < Exception 14 | ## The +Faraday::Response+ object which led to this exception. 15 | attr_accessor :response 16 | 17 | ## The deserialized response body which led to this exception. 18 | ## May be blank, e.g. in case of deserialization errors. 19 | attr_accessor :body 20 | 21 | # @private 22 | def initialize(message, attrs={}) 23 | self.response = attrs[:response] 24 | self.body = attrs[:body] 25 | 26 | ## Try to help out with the message 27 | if self.body 28 | if error = self.body['error'] 29 | message = "#{message} (#{error})" 30 | end 31 | elsif self.response 32 | message = "#{message} (\"#{self.response.inspect}\")" 33 | end 34 | 35 | super(message, attrs) 36 | end 37 | end 38 | 39 | class ClientError < ResponseError; end 40 | class ServerError < ResponseError; end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /lib/hyper_resource/link.rb: -------------------------------------------------------------------------------- 1 | require 'uri_template' 2 | require 'hyper_resource/modules/http' 3 | 4 | class HyperResource 5 | 6 | ## HyperResource::Link is an object to represent a hyperlink and its 7 | ## URL or body parameters, and to encapsulate HTTP calls involving this 8 | ## link. Links are typically created by HyperResource, not by end users. 9 | ## 10 | ## HTTP method calls return the response as a HyperResource (or subclass) 11 | ## object. Calling an unrecognized method on a link will automatically 12 | ## load the resource pointed to by this link, and repeat the method call 13 | ## on the resource object. 14 | ## 15 | ## A HyperResource::Link requires the resource it is based upon to remain 16 | ## in scope. In practice this is rarely a problem, as links are almost 17 | ## always accessed through the resource object. 18 | 19 | class Link 20 | 21 | include HyperResource::Modules::HTTP 22 | 23 | ## The literal href of this link; may be templated. 24 | attr_accessor :base_href 25 | 26 | ## An optional name describing this link. 27 | attr_accessor :name 28 | 29 | ## `true` if this link's href is a URI Template, `false` otherwise. 30 | attr_accessor :templated 31 | 32 | ## A hash of URL or request body parameters. 33 | attr_accessor :params 34 | 35 | ## Default HTTP method for implicit loading. 36 | attr_accessor :default_method 37 | 38 | ## The resource from which this link originates. 39 | attr_accessor :resource 40 | 41 | ## Returns a link based on the given resource and link specification 42 | ## hash. `link_spec` keys are: `href` (string, required), `templated` 43 | ## (boolean), `params` (hash), and `default_method` (string, default 44 | ## `"get"`). 45 | def initialize(resource, link_spec={}) 46 | unless link_spec.kind_of?(Hash) 47 | raise ArgumentError, "link_spec must be a Hash (got #{link_spec.inspect})" 48 | end 49 | link_spec = Hash[ link_spec.map{|(k,v)| [k.to_s, v]} ] ## stringify keys 50 | 51 | self.resource = resource 52 | self.base_href = link_spec['href'] 53 | self.name = link_spec['name'] 54 | self.templated = !!link_spec['templated'] 55 | self.params = link_spec['params'] || {} 56 | self.default_method = link_spec['method'] || 'get' 57 | @headers = link_spec['headers'] || {} 58 | end 59 | 60 | ## Returns this link's href, applying any URI template params. 61 | def href 62 | if self.templated 63 | filtered_params = self.resource.outgoing_uri_filter(params) 64 | URITemplate.new(self.base_href).expand(filtered_params) 65 | else 66 | self.base_href 67 | end 68 | end 69 | 70 | ## Returns this link's fully resolved URL, or nil if `resource.root` 71 | ## or `href` are malformed. 72 | def url 73 | begin 74 | URI.join(self.resource.root, self.href.to_s).to_s 75 | rescue StandardError 76 | nil 77 | end 78 | end 79 | 80 | ## Returns a new scope with the given params; that is, returns a copy of 81 | ## itself with the given params applied. 82 | def where(params) 83 | params = Hash[ params.map{|(k,v)| [k.to_s, v]} ] 84 | self.class.new(self.resource, 85 | 'href' => self.base_href, 86 | 'name' => self.name, 87 | 'templated' => self.templated, 88 | 'params' => self.params.merge(params), 89 | 'method' => self.default_method, 90 | 'headers' => @headers) 91 | end 92 | 93 | ## When called with a hash, returns a new scope with the given headers; 94 | ## that is, returns a copy of itself with the given headers applied. 95 | ## These headers will be merged with `resource.headers` at request time. 96 | ## 97 | ## When called with no arguments, returns the headers for this link. 98 | def headers(*args) 99 | if args.count == 0 100 | @headers 101 | else 102 | self.class.new(self.resource, 103 | 'href' => self.base_href, 104 | 'name' => self.name, 105 | 'templated' => self.templated, 106 | 'params' => self.params, 107 | 'method' => self.default_method, 108 | 'headers' => @headers.merge(args[0])) 109 | 110 | end 111 | end 112 | 113 | ## Unrecognized methods invoke an implicit load of the resource pointed 114 | ## to by this link. The method call is then repeated on the returned 115 | ## resource. 116 | def method_missing(method, *args) 117 | self.send(default_method || :get).send(method, *args) 118 | end 119 | 120 | end 121 | end 122 | 123 | -------------------------------------------------------------------------------- /lib/hyper_resource/links.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | 3 | ## HyperResource::Links is a modified Hash that permits lookup 4 | ## of a link by its link relation (rel), or an abbreviation thereof. 5 | ## It also provides read access through `method_missing`. 6 | ## It is typically created by HyperResource, not by end users. 7 | ## 8 | ## For example, a link with rel `someapi:widgets` is accessible 9 | ## by any of `self.widgets`, `self['widgets']`, `self[:widgets]`, and 10 | ## `self['someapi:widgets']. 11 | class Links < Hash 12 | 13 | # @private 14 | def initialize(resource=nil) 15 | ## We used to store the resource, but we didn't need to. Now we don't. 16 | end 17 | 18 | ## Stores a link for future retrieval by its link rel or abbreviations 19 | ## thereof. 20 | def []=(rel, link) 21 | rel = rel.to_s 22 | 23 | ## Every link must appear under its literal name. 24 | names = [rel] 25 | 26 | ## Extract 'foo' from e.g. 'http://example.com/foo', 27 | ## 'http://example.com/url#foo', 'somecurie:foo'. 28 | if m=rel.match(%r{[:/#](.+)}) 29 | names << m[1] 30 | end 31 | 32 | ## Underscore all non-word characters. 33 | underscored_names = names.map{|n| n.gsub(/[^a-zA-Z_]/, '_')} 34 | names = (names + underscored_names).uniq 35 | 36 | ## Register this link under every name we've come up with. 37 | names.each do |name| 38 | super(name, link) 39 | end 40 | end 41 | 42 | ## Retrieves a link by its rel. 43 | def [](rel) 44 | super(rel.to_s) 45 | end 46 | 47 | ## Provides links.somelink(:a => b) to links.somelink.where(:a => b) 48 | ## expansion. 49 | # @private 50 | def method_missing(method, *args) 51 | unless self[method] 52 | raise NoMethodError, "undefined method `#{method}' for #{self.inspect}" 53 | end 54 | 55 | if args.count > 0 56 | self[method].where(*args) 57 | else 58 | self[method] 59 | end 60 | end 61 | 62 | # @private 63 | def respond_to?(method, *args) 64 | return true if self.has_key?(method.to_s) 65 | super 66 | end 67 | end 68 | end 69 | 70 | -------------------------------------------------------------------------------- /lib/hyper_resource/modules/config_attributes.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | module Modules 3 | module ConfigAttributes 4 | 5 | ATTRS = [:auth, :headers, :namespace, :adapter, :faraday_options] 6 | 7 | def self.included(klass) 8 | klass.extend(ClassMethods) 9 | end 10 | 11 | # @private 12 | def hr_config 13 | @hr_config ||= self.class::Configuration.new 14 | end 15 | 16 | # @private 17 | def hr_config=(cfg) 18 | @hr_config = cfg 19 | end 20 | 21 | ## When called with no arguments, returns this resource's Configuration. 22 | ## When called with a hash, applies the given configuration parameters 23 | ## to this resource's Configuration. `hash` must be in the form: 24 | ## {'hostmask' => {'attr1' => {...}, 'attr2' => {...}, ...}} 25 | ## Valid attributes are `auth`, `headers`, `namespace`, `adapter`, 26 | ## `default_attributes`, and `faraday_options`. 27 | def config(hash=nil) 28 | return hr_config unless hash 29 | hr_config.config(hash) 30 | end 31 | 32 | 33 | ## Returns the auth config hash for this resource. 34 | def auth 35 | cfg_get(:auth) 36 | end 37 | 38 | ## Returns the auth config hash for the given url. 39 | def auth_for_url(url) 40 | self.hr_config.get_for_url(url, :auth) 41 | end 42 | 43 | ## Sets the auth config hash for this resource. 44 | ## Currently only the format `{:basic => ['username', 'password']}` 45 | ## is supported. 46 | def auth=(v) 47 | cfg_set(:auth, v) 48 | end 49 | 50 | 51 | ## Returns the headers hash for this resource. 52 | ## This is done by merging all applicable header configs. 53 | def headers 54 | matching_masks = self.hr_config.matching_masks_for_url(self.url) 55 | matching_masks.inject({}) do |hash, mask| 56 | hash.merge(self.hr_config.get(mask, 'headers') || {}) 57 | end 58 | end 59 | 60 | ## Returns the headers hash for the given url. 61 | def headers_for_url(url) 62 | self.hr_config.get_for_url(url, :headers) 63 | end 64 | 65 | ## Sets the headers hash for this resource. 66 | def headers=(v) 67 | cfg_set(:headers, v) 68 | end 69 | 70 | 71 | ## Returns the namespace string/class for this resource. 72 | def namespace 73 | cfg_get(:namespace) 74 | end 75 | 76 | ## Returns the namespace string/class for the given url. 77 | def namespace_for_url(url) 78 | self.hr_config.get_for_url(url, :namespace) 79 | end 80 | 81 | ## Sets the namespace string/class for this resource. 82 | def namespace=(v) 83 | cfg_set(:namespace, v) 84 | end 85 | 86 | 87 | ## Returns the adapter class for this resource. 88 | def adapter 89 | cfg_get(:adapter) || 90 | HyperResource::Adapter::HAL_JSON 91 | end 92 | 93 | ## Returns the adapter class for the given url. 94 | def adapter_for_url(url) 95 | self.hr_config.get_for_url(url, :adapter) || 96 | HyperResource::Adapter::HAL_JSON 97 | end 98 | 99 | ## Sets the adapter class for this resource. 100 | def adapter=(v) 101 | cfg_set(:adapter, v) 102 | end 103 | 104 | 105 | ## Returns the hash of default attributes for this resource. 106 | def default_attributes 107 | cfg_get(:default_attributes) 108 | end 109 | 110 | ## Returns the hash of default attributes for the given url. 111 | def default_attributes_for_url(url) 112 | self.hr_config.get_for_url(url, :default_attributes) 113 | end 114 | 115 | ## Sets the hash of default attributes for this resource. 116 | ## These attributes will be passed along with every HTTP request. 117 | def default_attributes=(v) 118 | cfg_set(:default_attributes, v) 119 | end 120 | 121 | 122 | ## Returns the Faraday connection options hash for this resource. 123 | def faraday_options 124 | cfg_get(:faraday_options) 125 | end 126 | 127 | ## Returns the Faraday connection options hash for this resource. 128 | def faraday_options_for_url(url) 129 | self.hr_config.get_for_url(url, :faraday_options) 130 | end 131 | 132 | ## Sets the Faraday connection options hash for this resource. 133 | ## These options will be used for all HTTP requests. 134 | def faraday_options=(v) 135 | cfg_set(:faraday_options, v) 136 | end 137 | 138 | 139 | private 140 | 141 | def cfg_get(key) 142 | hr_config.get_for_url(self.url, key) || 143 | self.class.hr_config.get_for_url(self.url, key) 144 | end 145 | 146 | ## Sets a config key-value pair for this resource. 147 | def cfg_set(key, value) 148 | hr_config.set_for_url(url.to_s, key, value) 149 | end 150 | 151 | public 152 | 153 | module ClassMethods 154 | 155 | def hr_config 156 | @hr_config ||= self::Configuration.new 157 | end 158 | 159 | ## When called with no arguments, returns this class's Configuration. 160 | ## When called with a hash, applies the given configuration parameters 161 | ## to this resource's Configuration. `hash` must be in the form: 162 | ## {'hostmask' => {'key1' => 'value1', 'key2' => 'value2', ...}} 163 | ## Valid keys are `auth`, `headers`, `namespace`, `adapter`, and 164 | ## `faraday_options`. 165 | def config(hash=nil) 166 | return hr_config unless hash 167 | hr_config.config(hash) 168 | end 169 | 170 | 171 | ## Returns the auth config hash for this resource class. 172 | def auth 173 | cfg_get(:auth) 174 | end 175 | 176 | ## Returns the auth config hash for the given url. 177 | def auth_for_url(url) 178 | self.hr_config.get_for_url(url, :auth) 179 | end 180 | 181 | ## Sets the auth config hash for this resource class. 182 | ## Currently only the format `{:basic => ['username', 'password']}` 183 | ## is supported. 184 | def auth=(v) 185 | cfg_set(:auth, v) 186 | end 187 | 188 | 189 | ## Returns the headers hash for this resource class. 190 | def headers 191 | cfg_get(:headers) 192 | end 193 | 194 | ## Returns the headers hash for the given url. 195 | def headers_for_url(url) 196 | self.hr_config.get_for_url(url, :headers) 197 | end 198 | 199 | ## Sets the headers hash for this resource class. 200 | def headers=(v) 201 | cfg_set(:headers, v) 202 | end 203 | 204 | 205 | ## Returns the namespace string/class for this resource class. 206 | def namespace 207 | cfg_get(:namespace) 208 | end 209 | 210 | ## Returns the namespace string/class for the given url. 211 | def namespace_for_url(url) 212 | self.hr_config.get_for_url(url, :namespace) 213 | end 214 | 215 | ## Sets the namespace string/class for this resource class. 216 | def namespace=(v) 217 | cfg_set(:namespace, v) 218 | end 219 | 220 | 221 | ## Returns the adapter class for this resource class. 222 | def adapter 223 | cfg_get(:adapter) || HyperResource::Adapter::HAL_JSON 224 | end 225 | 226 | ## Returns the adapter class for the given url. 227 | def adapter_for_url(url) 228 | self.hr_config.get_for_url(url, :adapter) || 229 | HyperResource::Adapter::HAL_JSON 230 | end 231 | 232 | ## Sets the adapter class for this resource class. 233 | def adapter=(v) 234 | cfg_set(:adapter, v) 235 | end 236 | 237 | 238 | ## Returns the hash of default attributes for this resource class. 239 | def default_attributes 240 | cfg_get(:default_attributes) 241 | end 242 | 243 | ## Returns the hash of default attributes for the given url. 244 | def default_attributes_for_url(url) 245 | self.hr_config.get_for_url(url, :default_attributes) 246 | end 247 | 248 | ## Sets the hash of default attributes for this resource class. 249 | ## These attributes will be passed along with every HTTP request. 250 | def default_attributes=(v) 251 | cfg_set(:default_attributes, v) 252 | end 253 | 254 | 255 | ## Returns the Faraday connection options hash for this resource class. 256 | def faraday_options 257 | cfg_get(:faraday_options) 258 | end 259 | 260 | ## Returns the Faraday connection options hash for this resource class. 261 | def faraday_options_for_url(url) 262 | self.hr_config.get_for_url(url, :faraday_options) 263 | end 264 | 265 | ## Sets the Faraday connection options hash for this resource class. 266 | ## These options will be used for all HTTP requests. 267 | def faraday_options=(v) 268 | cfg_set(:faraday_options, v) 269 | end 270 | 271 | 272 | private 273 | 274 | def cfg_get(key) 275 | value = hr_config.get_for_url(self.root, key) 276 | if value != nil 277 | value 278 | elsif superclass.respond_to?(:hr_config) 279 | superclass.hr_config.get_for_url(self.root, key) 280 | else 281 | nil 282 | end 283 | end 284 | 285 | def cfg_set(key, value) 286 | hr_config.set_for_url(self.root, key, value) 287 | end 288 | 289 | end 290 | 291 | end 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /lib/hyper_resource/modules/data_type.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | module Modules 3 | module DataType 4 | 5 | def self.included(klass) 6 | klass.extend(ClassMethods) 7 | end 8 | 9 | # @private 10 | def get_data_type_class(args) 11 | self.class.get_data_type_class(args) 12 | end 13 | 14 | # @private 15 | def get_data_type(args) 16 | self.class.get_data_type(args) 17 | end 18 | 19 | module ClassMethods 20 | 21 | ## Returns the class into which a given response should be 22 | ## instantiated. Class name is a combination of `resource.namespace` 23 | ## and `get_data_type(args)'. Creates this class if necessary. 24 | ## Args are :resource, :link, :response, :body, :url. 25 | # @private 26 | def get_data_type_class(args) 27 | url = args[:url] || args[:link].url 28 | namespace = args[:resource].namespace_for_url(url.to_s) 29 | namespace || "no namespace bish" 30 | return self unless namespace 31 | 32 | ## Make sure namespace class exists 33 | namespace_str = sanitize_class_name(namespace.to_s) 34 | if namespace.kind_of?(String) 35 | ns_class = eval(namespace_str) rescue nil 36 | if !ns_class 37 | Object.module_eval("class #{namespace_str} < #{self}; end") 38 | ns_class = eval(namespace_str) 39 | end 40 | end 41 | 42 | ## If there's no identifiable data type, return the namespace class. 43 | type = get_data_type(args) 44 | return ns_class unless type 45 | 46 | ## Make sure data type class exists 47 | type = type[0,1].upcase + type[1..-1] ## capitalize 48 | sanitized_type = sanitize_class_name(type) 49 | data_type_str = "#{namespace_str}::#{sanitized_type}" 50 | unless ns_class.constants.include?(sanitized_type.to_sym) 51 | Object.module_eval("class #{data_type_str} < #{namespace_str}; end") 52 | end 53 | eval(data_type_str) 54 | end 55 | 56 | ## Given a body Hash and a response Faraday::Response, detect and 57 | ## return a string describing this response's data type. 58 | ## Args are :body and :response. 59 | def get_data_type(args) 60 | type = get_data_type_from_body(args[:body]) 61 | type ||= get_data_type_from_response(args[:response]) 62 | end 63 | 64 | ## Given a Faraday::Response, inspects the Content-type for data 65 | ## type information and returns data type as a String, 66 | ## for instance returning `Widget` given a media 67 | ## type `application/vnd.example.com+hal+json;type=Widget`. 68 | ## Override this method to change behavior. 69 | ## Returns nil on failure. 70 | def get_data_type_from_response(response) 71 | return nil unless response 72 | return nil unless content_type = response['content-type'] 73 | return nil unless m=content_type.match(/;\s* type=([0-9A-Za-z:]+)/x) 74 | m[1] 75 | end 76 | 77 | ## Given a response body Hash, returns the response's data type as 78 | ## a string. By default, it looks for a `_data_type` field in the 79 | ## response. Override this method to change behavior. 80 | def get_data_type_from_body(body) 81 | return nil unless body 82 | body['_data_type'] || body['type'] 83 | end 84 | 85 | private 86 | 87 | ## Remove all non-word, non-colon elements from a class name. 88 | def sanitize_class_name(name) 89 | name.gsub(/[^_0-9A-Za-z:]/, '') 90 | end 91 | 92 | end 93 | 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/hyper_resource/modules/deprecations.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | module Modules 3 | module Deprecations 4 | 5 | def self.included(klass) 6 | klass.extend(ClassMethods) 7 | end 8 | 9 | ## Show a deprecation message. 10 | # @private 11 | def _hr_deprecate(*args) 12 | self.class._hr_deprecate(*args) 13 | end 14 | 15 | module ClassMethods 16 | ## Show a deprecation message. 17 | # @private 18 | def _hr_deprecate(message) 19 | STDERR.puts "#{message} (called from #{caller[2]})" 20 | end 21 | end 22 | 23 | 24 | ###### Deprecated stuff: 25 | 26 | ## +response_body+, +response_object+, and +deserialized_response+ 27 | ## are deprecated in favor of +body+. (Sorry. Naming things is hard.) 28 | ## Deprecated at 0.2. @private 29 | def response_body 30 | _hr_deprecate('HyperResource#response_body is deprecated. '+ 31 | 'Please use HyperResource#body instead.') 32 | body 33 | end 34 | 35 | # @private 36 | def response_object 37 | _hr_deprecate('HyperResource#response_object is deprecated. '+ 38 | 'Please use HyperResource#body instead.') 39 | body 40 | end 41 | 42 | # @private 43 | def deserialized_response 44 | _hr_deprecate('HyperResource#deserialized_response is deprecated. '+ 45 | 'Please use HyperResource#body instead.') 46 | body 47 | end 48 | 49 | 50 | ## Deprecated at 0.9: 51 | ## #create, #update, Link#create, Link#update 52 | 53 | end 54 | end 55 | end 56 | 57 | -------------------------------------------------------------------------------- /lib/hyper_resource/modules/http.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'uri' 3 | require 'json' 4 | require 'digest/md5' 5 | 6 | class HyperResource 7 | 8 | ## Returns this resource's fully qualified URL. Returns nil when 9 | ## `root` or `href` are malformed. 10 | def url 11 | begin 12 | URI.join(self.root, (self.href || '')).to_s 13 | rescue StandardError 14 | nil 15 | end 16 | end 17 | 18 | 19 | ## Performs a GET request to this resource's URL, and returns a 20 | ## new resource representing the response. 21 | def get 22 | to_link.get 23 | end 24 | 25 | ## Performs a GET request to this resource's URL, and returns a 26 | ## `Faraday::Response` object representing the response. 27 | def get_response 28 | to_link.get_response 29 | end 30 | 31 | ## Performs a POST request to this resource's URL, sending all of 32 | ## `attributes` as a request body unless an `attrs` Hash is given. 33 | ## Returns a new resource representing the response. 34 | def post(attrs=nil) 35 | to_link.post(attrs) 36 | end 37 | 38 | ## Performs a POST request to this resource's URL, sending all of 39 | ## `attributes` as a request body unless an `attrs` Hash is given. 40 | ## Returns a `Faraday::Response` object representing the response. 41 | def post_response(attrs=nil) 42 | to_link.post_response(attrs) 43 | end 44 | 45 | ## Performs a PUT request to this resource's URL, sending all of 46 | ## `attributes` as a request body unless an `attrs` Hash is given. 47 | ## Returns a new resource representing the response. 48 | def put(*args) 49 | to_link.put(*args) 50 | end 51 | 52 | ## Performs a PUT request to this resource's URL, sending all of 53 | ## `attributes` as a request body unless an `attrs` Hash is given. 54 | ## Returns a `Faraday::Response` object representing the response. 55 | def put_response(*args) 56 | to_link.put_response(*args) 57 | end 58 | 59 | ## Performs a PATCH request to this resource's URL, sending 60 | ## `attributes.changed_attributes` as a request body 61 | ## unless an `attrs` Hash is given. Returns a new resource 62 | ## representing the response. 63 | def patch(*args) 64 | self.to_link.patch(*args) 65 | end 66 | 67 | ## Performs a PATCH request to this resource's URL, sending 68 | ## `attributes.changed_attributes` as a request body 69 | ## unless an `attrs` Hash is given. 70 | ## Returns a `Faraday::Response` object representing the response. 71 | def patch_response(*args) 72 | self.to_link.patch_response(*args) 73 | end 74 | 75 | ## Performs a DELETE request to this resource's URL. Returns a new 76 | ## resource representing the response. 77 | def delete 78 | to_link.delete 79 | end 80 | 81 | ## Performs a DELETE request to this resource's URL. 82 | ## Returns a `Faraday::Response` object representing the response. 83 | def delete_response 84 | to_link.delete_response 85 | end 86 | 87 | ## Creates a Link representing this resource. Used for HTTP delegation. 88 | # @private 89 | def to_link(args={}) 90 | self.class::Link.new(self, 91 | :href => args[:href] || self.href, 92 | :params => args[:params] || self.attributes) 93 | end 94 | 95 | 96 | 97 | # @private 98 | def create(attrs) 99 | _hr_deprecate('HyperResource#create is deprecated. Please use '+ 100 | '#post instead.') 101 | to_link.post(attrs) 102 | end 103 | 104 | # @private 105 | def update(*args) 106 | _hr_deprecate('HyperResource#update is deprecated. Please use '+ 107 | '#put or #patch instead.') 108 | to_link.put(*args) 109 | end 110 | 111 | module Modules 112 | 113 | ## HyperResource::Modules::HTTP is included by HyperResource::Link. 114 | ## It provides support for GET, POST, PUT, PATCH, and DELETE. 115 | ## Each method returns a new object which is a kind_of HyperResource. 116 | module HTTP 117 | 118 | ## Loads and returns the resource pointed to by +href+. The returned 119 | ## resource will be blessed into its "proper" class, if 120 | ## +self.class.namespace != nil+. 121 | def get 122 | new_resource_from_response(self.get_response) 123 | end 124 | 125 | ## Performs a GET request on the given link, and returns the 126 | ## response as a `Faraday::Response` object. 127 | ## Does not parse the response as a `HyperResource` object. 128 | def get_response 129 | ## Adding default_attributes to URL query params is not automatic 130 | url = FuzzyURL.new(self.url || '') 131 | query_str = url[:query] || '' 132 | query_attrs = Hash[ query_str.split('&').map{|p| p.split('=')} ] 133 | attrs = (self.resource.default_attributes || {}).merge(query_attrs) 134 | attrs_str = attrs.inject([]){|pairs,(k,v)| pairs<<"#{k}=#{v}"}.join('&') 135 | if attrs_str != '' 136 | url = FuzzyURL.new(url.to_hash.merge(:query => attrs_str)) 137 | end 138 | faraday_connection.get(url.to_s) 139 | end 140 | 141 | ## By default, calls +post+ with the given arguments. Override to 142 | ## change this behavior. 143 | def create(*args) 144 | _hr_deprecate('HyperResource::Link#create is deprecated. Please use '+ 145 | '#post instead.') 146 | post(*args) 147 | end 148 | 149 | ## POSTs the given attributes to this resource's href, and returns 150 | ## the response resource. 151 | def post(attrs=nil) 152 | new_resource_from_response(post_response(attrs)) 153 | end 154 | 155 | ## POSTs the given attributes to this resource's href, and returns the 156 | ## response as a `Faraday::Response` object. 157 | ## Does not parse the response as a `HyperResource` object. 158 | def post_response(attrs=nil) 159 | attrs ||= self.resource.attributes 160 | attrs = (self.resource.default_attributes || {}).merge(attrs) 161 | response = faraday_connection.post do |req| 162 | req.body = self.resource.adapter.serialize(attrs) 163 | end 164 | response 165 | end 166 | 167 | ## By default, calls +put+ with the given arguments. Override to 168 | ## change this behavior. 169 | def update(*args) 170 | _hr_deprecate('HyperResource::Link#update is deprecated. Please use '+ 171 | '#put or #patch instead.') 172 | put(*args) 173 | end 174 | 175 | ## PUTs this resource's attributes to this resource's href, and returns 176 | ## the response resource. If attributes are given, +put+ uses those 177 | ## instead. 178 | def put(attrs=nil) 179 | new_resource_from_response(put_response(attrs)) 180 | end 181 | 182 | ## PUTs this resource's attributes to this resource's href, and returns 183 | ## the response as a `Faraday::Response` object. 184 | ## Does not parse the response as a `HyperResource` object. 185 | def put_response(attrs=nil) 186 | attrs ||= self.resource.attributes 187 | attrs = (self.resource.default_attributes || {}).merge(attrs) 188 | response = faraday_connection.put do |req| 189 | req.body = self.resource.adapter.serialize(attrs) 190 | end 191 | response 192 | end 193 | 194 | ## PATCHes this resource's changed attributes to this resource's href, 195 | ## and returns the response resource. If attributes are given, +patch+ 196 | ## uses those instead. 197 | def patch(attrs=nil) 198 | new_resource_from_response(patch_response(attrs)) 199 | end 200 | 201 | ## PATCHes this resource's changed attributes to this resource's href, 202 | ## and returns the response as a `Faraday::Response` object. 203 | ## Does not parse the response as a `HyperResource` object. 204 | def patch_response(attrs=nil) 205 | attrs ||= self.resource.attributes.changed_attributes 206 | attrs = (self.resource.default_attributes || {}).merge(attrs) 207 | response = faraday_connection.patch do |req| 208 | req.body = self.resource.adapter.serialize(attrs) 209 | end 210 | response 211 | end 212 | 213 | ## DELETEs this resource's href, and returns the response resource. 214 | def delete 215 | new_resource_from_response(delete_response) 216 | end 217 | 218 | ## DELETEs this resource's href, and returns the response as a 219 | ## `Faraday::Response` object. 220 | ## Does not parse the response as a `HyperResource` object. 221 | def delete_response 222 | faraday_connection.delete 223 | end 224 | 225 | private 226 | 227 | ## Returns a raw Faraday connection to this resource's URL, with proper 228 | ## headers (including auth). Threadsafe. 229 | def faraday_connection(url=nil) 230 | rsrc = self.resource 231 | url ||= self.url 232 | headers = (rsrc.headers_for_url(url) || {}).merge(self.headers) 233 | auth = rsrc.auth_for_url(url) || {} 234 | 235 | key = ::Digest::MD5.hexdigest({ 236 | 'faraday_connection' => { 237 | 'url' => url, 238 | 'headers' => headers, 239 | 'ba' => auth[:basic] 240 | } 241 | }.to_json) 242 | return Thread.current[key] if Thread.current[key] 243 | 244 | fo = rsrc.faraday_options_for_url(url) || {} 245 | fc = Faraday.new(fo.merge(:url => url)) 246 | fc.headers.merge!('User-Agent' => rsrc.user_agent) 247 | fc.headers.merge!(headers) 248 | if ba=auth[:basic] 249 | fc.basic_auth(*ba) 250 | end 251 | Thread.current[key] = fc 252 | end 253 | 254 | 255 | ## Given a Faraday::Response object, create a new resource 256 | ## object to represent it. The new resource will be in its 257 | ## proper class according to its configured `namespace` and 258 | ## the response's detected data type. 259 | def new_resource_from_response(response) 260 | status = response.status 261 | is_success = (status / 100 == 2) 262 | adapter = self.resource.adapter || HyperResource::Adapter::HAL_JSON 263 | 264 | body = nil 265 | unless empty_body?(response.body) 266 | begin 267 | body = adapter.deserialize(response.body) 268 | rescue StandardError => e 269 | if is_success 270 | raise HyperResource::ResponseError.new( 271 | "Error when deserializing response body", 272 | :response => response, 273 | :cause => e 274 | ) 275 | end 276 | 277 | end 278 | end 279 | 280 | new_rsrc = resource.new_from(:link => self, 281 | :body => body, 282 | :response => response) 283 | 284 | if status / 100 == 2 285 | return new_rsrc 286 | elsif status / 100 == 3 287 | raise NotImplementedError, 288 | "HyperResource has not implemented redirection." 289 | elsif status / 100 == 4 290 | raise HyperResource::ClientError.new(status.to_s, 291 | :response => response, 292 | :body => body) 293 | elsif status / 100 == 5 294 | raise HyperResource::ServerError.new(status.to_s, 295 | :response => response, 296 | :body => body) 297 | else ## 1xx? really? 298 | raise HyperResource::ResponseError.new("Unknown status #{status}", 299 | :response => response, 300 | :body => body) 301 | 302 | end 303 | end 304 | 305 | def empty_body?(body) 306 | return true if body.nil? 307 | return true if body.respond_to?(:empty?) && body.empty? 308 | return true if body.class == String && body =~ /^['"]+$/ # special case for status code with optional body, example Grape API with status 405 309 | false 310 | end 311 | 312 | end 313 | end 314 | end 315 | 316 | -------------------------------------------------------------------------------- /lib/hyper_resource/modules/internal_attributes.rb: -------------------------------------------------------------------------------- 1 | module HyperResource::Modules 2 | module InternalAttributes 3 | 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | 7 | base._hr_class_attributes.each do |attr| 8 | base._hr_class_attribute attr 9 | end 10 | 11 | (base._hr_attributes - base._hr_class_attributes).each do |attr| 12 | base.send(:attr_accessor, attr) 13 | end 14 | 15 | ## Fallback attributes fall back from instance to class. 16 | (base._hr_attributes & base._hr_class_attributes).each do |attr| 17 | base._hr_fallback_attribute attr 18 | end 19 | end 20 | 21 | module ClassMethods 22 | # @private 23 | def _hr_class_attributes 24 | [ :root ] 25 | end 26 | 27 | # @private 28 | def _hr_attributes 29 | [ :root, 30 | :href, 31 | :request, 32 | :response, 33 | :body, 34 | :attributes, 35 | :links, 36 | :objects, 37 | :loaded 38 | ] 39 | end 40 | 41 | ## Inheritable class attribute, kinda like in Rails. 42 | # @private 43 | def _hr_class_attribute(*names) 44 | names.map(&:to_sym).each do |name| 45 | instance_eval <<-EOT 46 | def #{name}=(val) 47 | @#{name} = val 48 | end 49 | def #{name} 50 | return @#{name} if defined?(@#{name}) 51 | return superclass.#{name} if superclass.respond_to?(:#{name}) 52 | nil 53 | end 54 | EOT 55 | end 56 | end 57 | 58 | ## Instance attributes which fall back to class attributes. 59 | # @private 60 | def _hr_fallback_attribute(*names) 61 | names.map(&:to_sym).each do |name| 62 | class_eval <<-EOT 63 | def #{name}=(val) 64 | @#{name} = val 65 | end 66 | def #{name} 67 | return @#{name} if defined?(@#{name}) 68 | return self.class.#{name} if self.class.respond_to?(:#{name}) 69 | nil 70 | end 71 | EOT 72 | end 73 | end 74 | 75 | end # ClassMethods 76 | 77 | end 78 | end 79 | 80 | -------------------------------------------------------------------------------- /lib/hyper_resource/objects.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | class Objects < Hash 3 | attr_accessor :_resource 4 | 5 | def initialize(resource=nil) 6 | self._resource = resource || HyperResource.new 7 | end 8 | 9 | # @private 10 | def []=(attr, value) 11 | super(attr.to_s, value) 12 | end 13 | 14 | ## When +key+ is a string, returns the array of objects under that name. 15 | ## When +key+ is a number, returns +ith(key)+. Returns nil on lookup 16 | ## failure. 17 | def [](key) 18 | case key 19 | when String, Symbol 20 | return super(key.to_s) if self.has_key?(key.to_s) 21 | return super(key.to_sym) if self.has_key?(key.to_sym) 22 | when Fixnum 23 | return ith(key) 24 | end 25 | nil 26 | end 27 | 28 | # @private 29 | def method_missing(method, *args) 30 | return self[method] if self[method] 31 | raise NoMethodError, "undefined method `#{method}' for #{self.inspect}" 32 | end 33 | 34 | # @private 35 | def respond_to?(method, *args) 36 | method = method.to_s 37 | return true if self.has_key?(method) 38 | super 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/hyper_resource/version.rb: -------------------------------------------------------------------------------- 1 | class HyperResource 2 | VERSION = '0.9.4' 3 | VERSION_DATE = '2015-11-18' 4 | end 5 | 6 | -------------------------------------------------------------------------------- /lib/hyperresource.rb: -------------------------------------------------------------------------------- 1 | require 'hyper_resource' 2 | 3 | -------------------------------------------------------------------------------- /test/live/live_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rack' 3 | require 'json' 4 | require File.expand_path('../live_test_server.rb', __FILE__) 5 | 6 | if ENV['NO_LIVE'] 7 | puts "Skipping live tests." 8 | exit 9 | elsif RUBY_VERSION[0..2] == '1.8' 10 | puts "Live tests don't run on Ruby 1.8; skipping." 11 | exit 12 | end 13 | 14 | launched_server = false 15 | server_thread = nil 16 | 17 | HR_TEST_PORT = ENV['HR_TEST_PORT'] || 25491 18 | 19 | class WhateverAPI < HyperResource; end 20 | 21 | MiniTest.after_run do 22 | server_thread.kill 23 | end 24 | 25 | describe HyperResource do 26 | before do 27 | unless launched_server 28 | server_thread = Thread.new do 29 | Rack::Handler::WEBrick.run( 30 | LiveTestServer.new, 31 | :Port => HR_TEST_PORT, 32 | :AccessLog => [], 33 | :Logger => WEBrick::Log::new("/dev/null", 7) 34 | ) 35 | end 36 | 37 | retries = 5 38 | begin 39 | HyperResource.new(:root => "http://localhost:#{HR_TEST_PORT}").get 40 | rescue Exception => e 41 | if ENV['DEBUG'] 42 | puts "#{e.class}: #{e}" if ENV['DEBUG'] 43 | puts caller[0..10] 44 | retries -= 1 45 | raise e if retries == 0 46 | end 47 | sleep 0.2 48 | retry if retries > 0 49 | end 50 | 51 | launched_server = true 52 | end 53 | end 54 | 55 | private 56 | 57 | def make_new_api_resource 58 | WhateverAPI.new(:root => "http://localhost:#{HR_TEST_PORT}/") 59 | end 60 | 61 | def expect_deprecation(rsrc) 62 | rsrc.class.expects(:_hr_deprecate).returns(true) 63 | end 64 | 65 | public 66 | 67 | describe 'live tests' do 68 | 69 | it 'works at all' do 70 | root = make_new_api_resource.get 71 | root.wont_be_nil 72 | root.name.must_equal 'whatever API' 73 | root.must_be_kind_of HyperResource 74 | root.must_be_instance_of WhateverAPI::Root 75 | end 76 | 77 | it 'follows links' do 78 | root = make_new_api_resource.get 79 | root.links.must_respond_to :widgets 80 | widgets = root.widgets.get 81 | widgets.must_be_kind_of HyperResource 82 | widgets.must_be_instance_of WhateverAPI::WidgetSet 83 | end 84 | 85 | it 'loads resources automatically from method_missing' do 86 | api = make_new_api_resource 87 | api.widgets.wont_be_nil 88 | end 89 | 90 | it 'observes proper classing' do 91 | api = make_new_api_resource 92 | api.must_be_instance_of WhateverAPI 93 | root = api.get 94 | root.must_be_instance_of WhateverAPI::Root 95 | root.links.must_be_instance_of WhateverAPI::Root::Links 96 | root.attributes.must_be_instance_of WhateverAPI::Root::Attributes 97 | 98 | root.widgets.must_be_instance_of WhateverAPI::Root::Link 99 | root.widgets.first.class.to_s.must_equal 'WhateverAPI::Widget' 100 | end 101 | 102 | it 'sends default_attributes' do 103 | api = make_new_api_resource 104 | api.default_attributes = {:a => 'b'} 105 | root = api.get 106 | root.sent_params.must_equal({'a' => 'b'}) 107 | end 108 | 109 | it 'can update (DEPRECATED)' do 110 | root = make_new_api_resource.get 111 | widget = root.widgets.first 112 | widget.name = "Awesome Widget dood" 113 | expect_deprecation(widget) 114 | resp = widget.update 115 | resp.attributes.must_equal widget.attributes 116 | resp.wont_equal widget 117 | end 118 | 119 | it 'can create (DEPRECATED)' do 120 | widget_set = make_new_api_resource.widgets.get 121 | expect_deprecation(widget_set) 122 | new_widget = widget_set.create(:name => "Cool Widget brah") 123 | new_widget.class.to_s.must_equal 'WhateverAPI::Widget' 124 | new_widget.name.must_equal "Cool Widget brah" 125 | end 126 | 127 | it 'can delete' do 128 | root = make_new_api_resource.get 129 | widget = root.widgets.first 130 | del = widget.delete 131 | del.class.to_s.must_equal 'WhateverAPI::Message' 132 | del.message.must_equal "Deleted widget." 133 | end 134 | 135 | it 'can post without implicitly performing a get' do 136 | link = make_new_api_resource.post_only_widgets 137 | widget = link.post(:name => 'Cool Widget brah') 138 | widget.class.to_s.must_equal 'WhateverAPI::PostOnlyWidget' 139 | widget.name.must_equal "Cool Widget brah" 140 | end 141 | 142 | it 'caches headers separately across instances' do 143 | api1 = HyperResource.new( 144 | :root => "http://localhost:#{HR_TEST_PORT}/conditional_widgets" 145 | ) 146 | api1.get.type.must_equal 'antiwidget' 147 | 148 | api2 = HyperResource.new( 149 | :root => "http://localhost:#{HR_TEST_PORT}/conditional_widgets", 150 | :headers => { 'WIDGET' => 'true' } 151 | ) 152 | api2.get.type.must_equal 'widget' 153 | end 154 | 155 | describe "invocation styles" do 156 | it 'can use HyperResource with no namespace' do 157 | api = HyperResource.new(:root => "http://localhost:#{HR_TEST_PORT}/") 158 | root = api.get 159 | root.loaded.must_equal true 160 | root.class.to_s.must_equal 'HyperResource' 161 | end 162 | 163 | it 'can use HyperResource with a namespace' do 164 | api = HyperResource.new(:root => "http://localhost:#{HR_TEST_PORT}/", 165 | :namespace => 'NsTestApi') 166 | root = api.get 167 | root.loaded.must_equal true 168 | root.class.to_s.must_equal 'NsTestApi::Root' 169 | end 170 | 171 | class NsExtTestApi < HyperResource 172 | class Root < NsExtTestApi 173 | def foo; :foo end 174 | end 175 | end 176 | it 'can use HyperResource with a namespace which is extended' do 177 | api = HyperResource.new(:root => "http://localhost:#{HR_TEST_PORT}/", 178 | :namespace => 'NsExtTestApi') 179 | root = api.get 180 | root.loaded.must_equal true 181 | root.class.to_s.must_equal 'NsExtTestApi::Root' 182 | root.must_respond_to :foo 183 | root.foo.must_equal :foo 184 | end 185 | 186 | 187 | class SubclassTestApi < HyperResource 188 | self.root = "http://example.com" 189 | end 190 | it 'can use a subclass of HR' do 191 | ## Test class-level setter 192 | api = SubclassTestApi.new 193 | api.root.must_equal "http://example.com" 194 | 195 | SubclassTestApi.root = "http://localhost:#{HR_TEST_PORT}/" 196 | api = SubclassTestApi.new 197 | root = api.get 198 | root.loaded.must_equal true 199 | root.class.to_s.must_equal 'SubclassTestApi::Root' 200 | end 201 | end 202 | 203 | describe 'configuration testing' do 204 | before do 205 | @new_api_resource_short = WhateverAPI.new( 206 | :root => "http://localhost:#{HR_TEST_PORT}/", 207 | :faraday_options => { 208 | :request => {:timeout => 0.001} 209 | } 210 | ) 211 | end 212 | 213 | it 'accepts custom timeout parameters' do 214 | p = proc { @new_api_resource_short.get.slow_widgets.first } 215 | if defined?(Faraday::TimeoutError) 216 | p.must_raise(Faraday::TimeoutError) 217 | elsif defined?(Faraday::Error::TimeoutError) 218 | p.must_raise(Faraday::Error::TimeoutError) 219 | else 220 | raise RuntimeError, "Unknown version of Faraday." 221 | end 222 | end 223 | 224 | it 'passes the configuration to subclasses' do 225 | api_short_child = @new_api_resource_short.get 226 | api_short_child.faraday_options[:request][:timeout].must_equal 0.001 227 | end 228 | end 229 | 230 | 231 | end # describe 'live tests' 232 | 233 | end # describe HyperResource 234 | 235 | -------------------------------------------------------------------------------- /test/live/live_test_server.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'sinatra' 3 | require 'json' 4 | 5 | class LiveTestServer < Sinatra::Base 6 | 7 | get '/' do 8 | params = request.env['rack.request.query_hash'] 9 | json_params = JSON.dump(params) 10 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Root' 11 | <<-EOT 12 | { "name": "whatever API", 13 | "sent_params": #{json_params}, 14 | "_links": { 15 | "curies": [{ 16 | "name": "whatever", 17 | "templated": true, 18 | "href": "/rels{?rel}" 19 | }], 20 | "self": {"href":"/"}, 21 | "whatever:widgets": {"href":"/widgets"}, 22 | "whatever:slow_widgets": {"href":"/slow_widgets"}, 23 | "whatever:conditional_widgets": {"href":"/conditional_widgets"}, 24 | "whatever:post_only_widgets": {"href":"/post_only_widgets"} 25 | } 26 | } 27 | EOT 28 | end 29 | 30 | get '/widgets' do 31 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=WidgetSet' 32 | <<-EOT 33 | { "name": "My Widgets", 34 | "_links": { 35 | "curies": [{ 36 | "name": "whatever", 37 | "templated": true, 38 | "href": "/rels{?rel}" 39 | }], 40 | "self": {"href":"/widgets"}, 41 | "whatever:root": {"href":"/"} 42 | }, 43 | "_embedded": { 44 | "widgets": [ 45 | { "name": "Widget 1", 46 | "_data_type": "Widget", 47 | "_links": { 48 | "curies": [{ 49 | "name": "whatever", 50 | "templated": true, 51 | "href": "/rels{?rel}" 52 | }], 53 | "self": {"href": "/widgets/1"}, 54 | "whatever:widgets": {"href": "/widgets"} 55 | } 56 | } 57 | ] 58 | } 59 | } 60 | EOT 61 | end 62 | 63 | put '/widgets/1' do 64 | params = JSON.parse(request.env["rack.input"].read) 65 | if params["name"] != 'Awesome Widget dood' 66 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Error' 67 | [422, JSON.dump({:error => "Name was wrong; you sent #{params.inspect}"})] 68 | else 69 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Widget' 70 | <<-EOT 71 | { "name": "#{params["name"]}", 72 | "_data_type": "Widget", 73 | "_links": { 74 | "curies": [{ 75 | "name": "whatever", 76 | "templated": true, 77 | "href": "/rels{?rel}" 78 | }], 79 | "self": {"href": "/widgets/1"}, 80 | "whatever:widgets": {"href": "/widgets"} 81 | } 82 | } 83 | EOT 84 | end 85 | end 86 | 87 | post '/widgets' do 88 | params = JSON.parse(request.env["rack.input"].read) 89 | if params["name"] != 'Cool Widget brah' 90 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Error' 91 | [422, JSON.dump(:error => "Name was wrong; you sent #{params.inspect}")] 92 | else 93 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Widget' 94 | [201, headers, <<-EOT ] 95 | { "name": "#{params["name"]}", 96 | "_data_type": "Widget", 97 | "_links": { 98 | "curies": [{ 99 | "name": "whatever", 100 | "templated": true, 101 | "href": "/rels{?rel}" 102 | }], 103 | "self": {"href":"/widgets/2"}, 104 | "whatever:widgets": {"href": "/widgets"}, 105 | "whatever:root": {"href":"/"} 106 | } 107 | } 108 | EOT 109 | end 110 | end 111 | 112 | delete '/widgets/1' do 113 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Message' 114 | <<-EOT 115 | { "message": "Deleted widget.", 116 | "_data_type": "Message", 117 | "_links": { 118 | "curies": [{ 119 | "name": "whatever", 120 | "templated": true, 121 | "href": "/rels{?rel}" 122 | }], 123 | "self": {"href":"/widgets/1"}, 124 | "whatever:widgets": {"href": "/widgets"}, 125 | "whatever:root": {"href":"/"} 126 | } 127 | } 128 | EOT 129 | end 130 | 131 | # To test short timeouts 132 | get '/slow_widgets' do 133 | sleep 2 134 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=SlowWidgetSet' 135 | <<-EOT 136 | { "name": "My Slow Widgets", 137 | "_data_type": "SlowWidgetSet", 138 | "_links": { 139 | "curies": [{ 140 | "name": "whatever", 141 | "templated": true, 142 | "href": "/rels{?rel}" 143 | }], 144 | "self": {"href":"/slow_widgets"}, 145 | "whatever:root": {"href":"/"} 146 | }, 147 | "_embedded": { 148 | "slow_widgets": [] 149 | } 150 | } 151 | EOT 152 | end 153 | 154 | # To test header caching 155 | get '/conditional_widgets' do 156 | if request.env['HTTP_WIDGET'] == 'true' 157 | <<-EOT 158 | { "type": "widget", 159 | "_links": { 160 | "self": {"href": "/conditional_widgets"} 161 | } 162 | } 163 | EOT 164 | else 165 | <<-EOT 166 | { "type": "antiwidget", 167 | "_links": { 168 | "self": {"href": "/conditional_widgets"} 169 | } 170 | } 171 | EOT 172 | end 173 | end 174 | 175 | ## To test HR::Link#post 176 | post '/post_only_widgets' do 177 | params = JSON.parse(request.env["rack.input"].read) 178 | if params["name"] != 'Cool Widget brah' 179 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Error' 180 | [422, JSON.dump(:error => "Name was wrong; you sent #{params.inspect}")] 181 | else 182 | headers['Content-type'] = 'application/vnd.example.v1+hal+json;type=Widget' 183 | [201, headers, <<-EOT ] 184 | { "name": "#{params["name"]}", 185 | "_data_type":"PostOnlyWidget", 186 | "_links": { 187 | "curies": [{ 188 | "name": "whatever", 189 | "templated": true, 190 | "href": "/rels{?rel}" 191 | }], 192 | "self": {"href":"/post_only_widgets/2"}, 193 | "whatever:widgets": {"href": "/widgets"}, 194 | "whatever:root": {"href":"/"} 195 | } 196 | } 197 | EOT 198 | end 199 | end 200 | end 201 | 202 | -------------------------------------------------------------------------------- /test/live/two_server_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'sinatra' 3 | require 'json' 4 | require File.expand_path('../two_test_servers.rb', __FILE__) 5 | 6 | 7 | if ENV['NO_LIVE'] 8 | puts "Skipping live tests." 9 | exit 10 | elsif RUBY_VERSION[0..2] == '1.8' 11 | puts "Live tests don't run on Ruby 1.8; skipping." 12 | exit 13 | end 14 | 15 | 16 | launched_servers = false 17 | server_one = nil 18 | server_two = nil 19 | 20 | MiniTest.after_run do 21 | server_one.kill 22 | server_two.kill 23 | end 24 | 25 | describe 'APIEcosystem' do 26 | launched_servers = false 27 | before do 28 | unless launched_servers 29 | server_one = Thread.new do 30 | Rack::Handler::WEBrick.run( 31 | ServerOne.new, 32 | :Port => PORT_ONE.to_i, 33 | :AccessLog => [], 34 | :Logger => WEBrick::Log::new("/dev/null", 7) 35 | ) 36 | end 37 | server_two = Thread.new do 38 | Rack::Handler::WEBrick.run( 39 | ServerTwo.new, 40 | :Port => PORT_TWO.to_i, 41 | :AccessLog => [], 42 | :Logger => WEBrick::Log::new("/dev/null", 7) 43 | ) 44 | end 45 | 46 | retries = 5 47 | begin 48 | HyperResource.new(root: "http://localhost:#{PORT_ONE}").get 49 | HyperResource.new(root: "http://localhost:#{PORT_TWO}").get 50 | rescue Exception => e 51 | if ENV['DEBUG'] 52 | puts "#{e.class}: #{e}" if ENV['DEBUG'] 53 | puts caller[0..10] 54 | retries -= 1 55 | raise e if retries == 0 56 | end 57 | sleep 0.2 58 | retry if retries > 0 59 | end 60 | 61 | launched_servers = true 62 | end 63 | end 64 | 65 | 66 | class APIEcosystem < HyperResource 67 | self.config( 68 | "localhost:#{PORT_ONE}" => { 69 | "namespace" => "ServerOneAPI", 70 | "default_attributes" => {"server" => "1"}, 71 | "headers" => {"X-Server" => "1"}, 72 | "auth" => {:basic => ["server_one", ""]}, 73 | "faraday_options" => {:request => {:timeout => 1}}, 74 | }, 75 | "localhost:#{PORT_TWO}" => { 76 | "namespace" => "ServerTwoAPI", 77 | "default_attributes" => {"server" => "2"}, 78 | "headers" => {"X-Server" => "2"}, 79 | "auth" => {:basic => ["server_two", ""]}, 80 | "faraday_options" => {:request => {:timeout => 2}}, 81 | } 82 | ) 83 | end 84 | 85 | describe 'live tests' do 86 | 87 | it 'uses the right config for server one' do 88 | root_one = APIEcosystem.new(:root => "http://localhost:#{PORT_ONE}").get 89 | root_one.class.to_s.must_equal 'ServerOneAPI' 90 | root_one.sent_params.must_equal({"server" => "1"}) 91 | root_one.headers['X-Server'].must_equal "1" 92 | root_one.auth.must_equal({:basic => ["server_one", ""]}) 93 | root_one.faraday_options.must_equal({:request => {:timeout => 1}}) 94 | end 95 | 96 | it 'uses the right config for server two' do 97 | root_two = APIEcosystem.new(:root => "http://localhost:#{PORT_TWO}").get 98 | root_two.class.to_s.must_equal 'ServerTwoAPI' 99 | root_two.sent_params.must_equal({"server" => "2"}) 100 | root_two.headers['X-Server'].must_equal "2" 101 | root_two.auth.must_equal({:basic => ["server_two", ""]}) 102 | root_two.faraday_options.must_equal({:request => {:timeout => 2}}) 103 | end 104 | 105 | it 'can go back and forth' do 106 | root_one = APIEcosystem.new(:root => "http://localhost:#{PORT_ONE}").get 107 | root_two = root_one.server_two.server_one.server_two.get 108 | root_two.class.to_s.must_equal 'ServerTwoAPI' 109 | end 110 | 111 | end 112 | 113 | end 114 | 115 | -------------------------------------------------------------------------------- /test/live/two_test_servers.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | PORT_ONE = ENV['TEST_PORT_ONE'] || 42774 4 | PORT_TWO = ENV['TEST_PORT_TWO'] || 42775 5 | 6 | class ServerOne < Sinatra::Base 7 | get '/' do 8 | params = request.env['rack.request.query_hash'] 9 | headers['Content-Type'] = 'application/hal+json' 10 | <<-EOT 11 | { "name": "Server One", 12 | "sent_params": #{JSON.dump(params)}, 13 | "_links": { 14 | "self": {"href": "http://localhost:#{PORT_ONE}/"}, 15 | "server_two": {"href": "http://localhost:#{PORT_TWO}/"} 16 | } 17 | } 18 | EOT 19 | end 20 | end 21 | 22 | class ServerTwo < Sinatra::Base 23 | get '/' do 24 | params = request.env['rack.request.query_hash'] 25 | headers['Content-Type'] = 'application/hal+json' 26 | <<-EOT 27 | { "name": "Server Two", 28 | "sent_params": #{JSON.dump(params)}, 29 | "_links": { 30 | "self": {"href": "http://localhost:#{PORT_TWO}/"}, 31 | "server_one": {"href": "http://localhost:#{PORT_ONE}/"} 32 | } 33 | } 34 | EOT 35 | end 36 | end 37 | 38 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'minitest/pride' 3 | require 'mocha/setup' 4 | #require 'debugger' 5 | require 'hyperresource' 6 | 7 | HAL_BODY = { 8 | 'attr1' => 'val1', 9 | 'attr2' => 'val2', 10 | 'attr3' => nil, 11 | '_links' => { 12 | 'curies' => [ 13 | { 'name' => 'foo', 14 | 'templated' => true, 15 | 'href' => 'http://example.com/api/rels/{rel}' 16 | } 17 | ], 18 | 'self' => {'href' => '/obj1/'}, 19 | 'foo:foobars' => [ 20 | { 'name' => 'foobar', 21 | 'templated' => true, 22 | 'href' => 'http://example.com/foobars/{foobar}' 23 | } 24 | ] 25 | }, 26 | '_embedded' => { 27 | 'obj1s' => [ 28 | { 'attr3' => 'val3', 29 | 'attr4' => 'val4', 30 | '_links' => { 31 | 'self' => {'href' => '/obj1/1'}, 32 | 'next' => {'href' => '/obj1/2'} 33 | } 34 | }, 35 | { 'attr3' => 'val5', 36 | 'attr4' => 'val6', 37 | '_links' => { 38 | 'self' => {'href' => '/obj1/2'}, 39 | 'previous' => {'href' => '/obj1/1'} 40 | } 41 | } 42 | ] 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /test/unit/attributes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource::Attributes do 4 | class TestAPI < HyperResource; end 5 | 6 | describe 'accessors' do 7 | before do 8 | @rsrc = TestAPI.new(:root => 'http://example.com') 9 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 10 | @attrs = @rsrc.attributes 11 | end 12 | 13 | it "provides access to all attributes" do 14 | @attrs.attr1.must_equal 'val1' 15 | @attrs.attr2.must_equal 'val2' 16 | end 17 | 18 | it 'supports null attributes' do 19 | @attrs.attr3.must_equal nil 20 | end 21 | 22 | it 'leaves _links and _embedded alone' do 23 | assert_raises NoMethodError do 24 | @attrs._links 25 | end 26 | assert_raises NoMethodError do 27 | @attrs._embedded 28 | end 29 | end 30 | 31 | it 'allows values to be changed' do 32 | @attrs.attr1 = :foo 33 | @attrs.attr1.must_equal :foo 34 | end 35 | end 36 | 37 | describe 'changed' do 38 | before do 39 | @rsrc = TestAPI.new 40 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 41 | end 42 | 43 | it 'marks attributes as changed' do 44 | @rsrc.changed?(:attr1).must_equal false 45 | @rsrc.changed?.must_equal false 46 | @rsrc.attr1 = :wowie_zowie 47 | @rsrc.changed?(:attr1).must_equal true 48 | @rsrc.changed?(:attr2).must_equal false 49 | @rsrc.changed?.must_equal true 50 | end 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /test/unit/caching_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'HyperResource caching' do 4 | class TestAPI < HyperResource; end 5 | 6 | before do 7 | @rsrc = TestAPI.new(:root => 'http://example.com') 8 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 9 | end 10 | 11 | it 'can be dumped with Marshal.dump' do 12 | dump = Marshal.dump(@rsrc) 13 | new_rsrc = Marshal.load(dump) 14 | assert new_rsrc.attr1 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/unit/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource do 4 | describe '#config' do 5 | it 'inherits different masks' do 6 | res1 = HyperResource.new(:root => 'http://example.com') 7 | res1.config('res1' => {'test1' => 123, 8 | 'test2' => 123}) 9 | 10 | link = HyperResource::Link.new(res1, :href => '/') 11 | res2 = HyperResource.new_from(:link => link, 12 | :resource => res1, 13 | :body => {}) 14 | res2.config('res2' => {'test2' => 234}) 15 | 16 | res2.config.get('res2', 'test2').must_equal 234 17 | res2.config.get('res1', 'test1').must_equal 123 18 | end 19 | 20 | it 'merges the config attribute hashes on the same mask, non-hash values' do 21 | res1 = HyperResource.new(:root => 'http://example.com') 22 | res1.config('res1' => {'test1' => 123, 23 | 'test2' => 123}) 24 | 25 | link = HyperResource::Link.new(res1, :href => '/') 26 | res2 = HyperResource.new_from(:link => link, 27 | :resource => res1, 28 | :body => {}) 29 | res2.config('res1' => {'test2' => 234}) 30 | 31 | res2.config.get('res1', 'test2').must_equal 234 32 | res2.config.get('res1', 'test1').must_equal 123 33 | end 34 | 35 | it 'merges the config attribute hashes on the same mask, hash values' do 36 | res1 = HyperResource.new(:root => 'http://example.com') 37 | res1.config('res1' => {'test1' => {'a' => 'b', 'c' => 'd'}}) 38 | 39 | link = HyperResource::Link.new(res1, :href => '/') 40 | res2 = HyperResource.new_from(:link => link, 41 | :resource => res1, 42 | :body => {}) 43 | res2.config('res1' => {'test1' => {'a' => 'test'}}) 44 | 45 | res2.config.get('res1', 'test1')['a'].must_equal 'test' 46 | res2.config.get('res1', 'test1')['c'].must_equal 'd' 47 | end 48 | 49 | end 50 | end 51 | 52 | -------------------------------------------------------------------------------- /test/unit/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | 4 | describe HyperResource::Configuration do 5 | 6 | describe '#get and #set' do 7 | it 'works' do 8 | cfg = HyperResource::Configuration.new 9 | cfg.set('test', 'a', 'b') 10 | cfg.get('test', 'a').must_equal 'b' 11 | cfg.get('test2', 'a').must_be_nil 12 | cfg.get('test', 'b').must_be_nil 13 | end 14 | end 15 | 16 | describe '#clone' do 17 | it 'works' do 18 | cfg = HyperResource::Configuration.new 19 | b = 'b' 20 | cfg.set('test', 'a', b) 21 | newcfg = cfg.clone 22 | 23 | newcfg.get('test', 'a').must_equal 'b' 24 | 25 | ## No shared structure at first level 26 | newcfg.set('test', 'a', 'c') 27 | cfg.get('test', 'a').must_equal 'b' 28 | end 29 | end 30 | 31 | describe '#merge' do 32 | it 'merges' do 33 | cfg1 = HyperResource::Configuration.new 34 | cfg1.set('test1', 'a', 'b') 35 | cfg1.set('test2', 'c', 'd') 36 | 37 | cfg2 = HyperResource::Configuration.new 38 | cfg2.set('test1', 'a', 'e') 39 | cfg2.set('test3', 'f', 'g') 40 | 41 | merged = cfg1.merge(cfg2) 42 | merged.get('test1', 'a').must_equal 'e' 43 | merged.get('test2', 'c').must_equal 'd' 44 | merged.get('test3', 'f').must_equal 'g' 45 | end 46 | end 47 | 48 | describe '#merge!' do 49 | it 'mergebangs' do 50 | cfg1 = HyperResource::Configuration.new 51 | cfg1.set('test1', 'a', 'b') 52 | cfg1.set('test2', 'c', 'd') 53 | 54 | cfg2 = HyperResource::Configuration.new 55 | cfg2.set('test1', 'a', 'e') 56 | cfg2.set('test3', 'f', 'g') 57 | 58 | cfg1.merge!(cfg2) 59 | cfg1.get('test1', 'a').must_equal 'e' 60 | cfg1.get('test2', 'c').must_equal 'd' 61 | cfg1.get('test3', 'f').must_equal 'g' 62 | end 63 | end 64 | 65 | describe '#as_hash' do 66 | it 'works' do 67 | cfg = HyperResource::Configuration.new 68 | cfg.set('test1', 'a', {'b' => 1}) 69 | cfg.set('test2', 'c', 'd') 70 | 71 | hash = cfg.as_hash 72 | hash['test1']['a'].must_equal({'b' => 1}) 73 | hash['test2']['c'].must_equal('d') 74 | 75 | hash['test1']['a']['b'] = 2 76 | cfg.get('test1', 'a')['b'].must_equal 1 77 | end 78 | end 79 | 80 | describe '#config' do 81 | it 'works' do 82 | cfg = HyperResource::Configuration.new 83 | cfg.set('test1', 'y', 'z') 84 | cfg.config( 85 | '*' => {'a' => 'b'}, 86 | 'example.com' => {'a' => 'c', 'd' => 'e'} 87 | ) 88 | 89 | cfg.send(:cfg).must_equal( 90 | 'test1' => {'y' => 'z'}, 91 | '*' => {'a' => 'b'}, 92 | 'example.com' => {'a' => 'c', 'd' => 'e'} 93 | ) 94 | end 95 | end 96 | 97 | describe '#get_for_url' do 98 | it 'returns exact matches' do 99 | cfg = HyperResource::Configuration.new 100 | cfg.set('example.com', 'a', 'b') 101 | 102 | cfg.get_for_url('http://example.com', 'a').must_equal 'b' 103 | cfg.get_for_url('https://example.com/v1/omg', 'a').must_equal 'b' 104 | cfg.get_for_url('http://example.com?utm_source=friendster', 'a').must_equal 'b' 105 | cfg.get_for_url('http://example.com', 'a').must_equal 'b' 106 | 107 | cfg.get_for_url('http://example2.com', 'a').must_be_nil 108 | cfg.get_for_url('http://www.example.com', 'a').must_be_nil 109 | cfg.get_for_url('http://example.com.au', 'a').must_be_nil 110 | end 111 | 112 | it 'returns subdomain wildcard matches' do 113 | cfg = HyperResource::Configuration.new 114 | cfg.set('*.example.com', 'a', 'b') 115 | 116 | cfg.get_for_url('http://www.example.com', 'a').must_equal 'b' 117 | cfg.get_for_url('https://www.example.com/INDEX.HTM', 'a').must_equal 'b' 118 | cfg.get_for_url('http://www4.us.example.com', 'a').must_equal 'b' 119 | 120 | cfg.get_for_url('http://example.com', 'a').must_be_nil 121 | end 122 | end 123 | 124 | describe '#set_for_url' do 125 | it 'works' do 126 | cfg = HyperResource::Configuration.new 127 | cfg.set_for_url('http://example.com', 'a', 'b') 128 | cfg.get_for_url('example.com', 'a').must_equal 'b' 129 | cfg.get('http://example.com', 'a').must_equal 'b' 130 | end 131 | end 132 | 133 | describe '#get_possible_masks_for_host' do 134 | it 'generates correct hostmasks' do 135 | cfg = HyperResource::Configuration.new 136 | masks = cfg.send(:get_possible_masks_for_host, 'www4.us.example.com') 137 | masks.must_equal ["www4.us.example.com:80", 138 | "www4.us.example.com", 139 | "*.us.example.com", 140 | "*.example.com", 141 | "*.com", 142 | "*"] 143 | end 144 | end 145 | 146 | describe '#matching_masks_for_url' do 147 | it 'returns existing hostmasks' do 148 | cfg = HyperResource::Configuration.new 149 | cfg.set('www4.us.example.com:12345', 'a', 'b') 150 | cfg.set('www4.us.example.com', 'a', 'b') 151 | cfg.set('*.example.com', 'a', 'b') 152 | cfg.set('*', 'a', 'b') 153 | cfg.set('something', 'y', 'z') 154 | masks = cfg.send(:matching_masks_for_url, 'http://www4.us.example.com:12345') 155 | masks.must_equal ["www4.us.example.com:12345", 156 | "www4.us.example.com", 157 | "*.example.com", 158 | "*"] 159 | end 160 | end 161 | 162 | describe '#subconfig_for_url' do 163 | it 'merges hostmask configs correctly' do 164 | cfg = HyperResource::Configuration.new 165 | cfg.set('www4.us.example.com', 'a', 'b') 166 | cfg.set('*.example.com', 'd', 'e') 167 | cfg.set('*', 'a', 'c') 168 | 169 | subconfig = cfg.send(:subconfig_for_url, 'http://www4.us.example.com') 170 | subconfig['a'].must_equal 'b' 171 | subconfig['d'].must_equal 'e' 172 | end 173 | end 174 | 175 | end 176 | 177 | -------------------------------------------------------------------------------- /test/unit/embedded_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe "Embedded Resources" do 4 | class TestAPI < HyperResource; self.root = 'http://example.com' end 5 | 6 | it 'supports an array of embedded resources' do 7 | hyper = TestAPI.new 8 | hyper.objects.class.to_s.must_equal 'TestAPI::Objects' 9 | body = { "_embedded" => { 10 | "foo" => [ 11 | {"_links" => {"self" => {"href" => "http://example.com/"}}} 12 | ] 13 | } 14 | } 15 | hyper.adapter.apply(body, hyper) 16 | hyper.foo.must_be_instance_of Array 17 | hyper.foo.first.href.must_equal 'http://example.com/' 18 | end 19 | 20 | it 'supports a single embedded resource' do 21 | hyper = TestAPI.new 22 | body = { "_embedded" => { 23 | "foo" => 24 | {"_links" => {"self" => {"href" => "http://example.com/"}}} 25 | } 26 | } 27 | hyper.adapter.apply(body, hyper) 28 | hyper.foo.href.must_equal 'http://example.com/' 29 | end 30 | 31 | it 'supports an array of embedded resources with no "_links" property' do 32 | hyper = TestAPI.new 33 | body = { "_embedded" => { 34 | "foo" => [ 35 | {} 36 | ] 37 | } 38 | } 39 | hyper.adapter.apply(body, hyper) 40 | hyper.foo.first.href.must_be_nil 41 | end 42 | 43 | it 'supports an embedded resource with no "_links" property' do 44 | hyper = TestAPI.new 45 | body = { "_embedded" => { 46 | "foo" => 47 | {} 48 | } 49 | } 50 | hyper.adapter.apply(body, hyper) 51 | hyper.foo.href.must_be_nil 52 | end 53 | 54 | it 'supports an array of embedded resources with no "self" link' do 55 | hyper = TestAPI.new 56 | body = { "_embedded" => { 57 | "foo" => [ 58 | {"_links" => {"bar" => {"href" => "http://example.com/"}}} 59 | ] 60 | } 61 | } 62 | hyper.adapter.apply(body, hyper) 63 | hyper.foo.first.href.must_be_nil 64 | end 65 | 66 | it 'supports an embedded resource with no "self" link' do 67 | hyper = TestAPI.new 68 | body = { "_embedded" => { 69 | "foo" => 70 | {"_links" => {"bar" => {"href" => "http://example.com/"}}} 71 | } 72 | } 73 | hyper.adapter.apply(body, hyper) 74 | hyper.foo.href.must_be_nil 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/unit/filters_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe 'HyperResource#incoming_body_filter' do 4 | class IncomingBodyFilterAPI < HyperResource 5 | def incoming_body_filter(hash) 6 | super(Hash[ hash.map{|(k,v)| ["awesome_#{k}", "awesomer #{v}"]} ]) 7 | end 8 | end 9 | 10 | before do 11 | @rsrc = IncomingBodyFilterAPI.new(:root => 'http://example.com') 12 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 13 | end 14 | 15 | it 'filters incoming attributes' do 16 | assert_raises NoMethodError do 17 | @rsrc.attr1 18 | end 19 | @rsrc.awesome_attr1.must_equal 'awesomer val1' 20 | end 21 | end 22 | 23 | describe 'HyperResource#outgoing_uri_filter' do 24 | class OutgoingUriFilterAPI < HyperResource 25 | def outgoing_uri_filter(hash) 26 | super(Hash[ 27 | hash.map do |(k,v)| 28 | if k=='foobar' 29 | [k, "OMGOMG_#{v}"] 30 | else 31 | [k,v] 32 | end 33 | end 34 | ]) 35 | end 36 | end 37 | 38 | before do 39 | @rsrc = OutgoingUriFilterAPI.new(:root => 'http://example.com') 40 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 41 | end 42 | 43 | it 'filters outgoing uri params' do 44 | foobar_link = @rsrc.links.foobars.first 45 | foobar_link.must_be_instance_of OutgoingUriFilterAPI::Link 46 | link_with_params = foobar_link.where(:foobar => "test") 47 | link_with_params.href.must_equal "http://example.com/foobars/OMGOMG_test" 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /test/unit/http_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | stub_connection = Faraday.new do |builder| 4 | builder.adapter :test do |stub| 5 | stub.get('/') {[ 200, {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Root'}, 6 | <<-EOT 7 | { "name":"Stub API", 8 | "_links": { 9 | "self": {"href": "/"}, 10 | "dummies": {"href": "/dummies{?email,name}", "templated": true} 11 | } 12 | } 13 | EOT 14 | ]} 15 | 16 | joe_dummy = <<-EOT 17 | { "first_name": "Joe", "last_name": "Dummy", 18 | "_links": { 19 | "self": {"href": "/dummies/1"}, 20 | "root": {"href": "/"} 21 | } 22 | } 23 | EOT 24 | 25 | stub.get('/dummies') {[ 200, {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Dummies'}, 26 | <<-EOT 27 | { "_embedded": { "dummies": [ #{joe_dummy} ] }, 28 | "_links": { 29 | "self": {"href": "/dummies"}, 30 | "root": {"href": "/"} 31 | } 32 | } 33 | EOT 34 | ]} 35 | 36 | stub.post('/dummies') {[ 37 | 201, 38 | { 'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Dummies', 39 | 'Location' => '/dummies/1'}, 40 | joe_dummy 41 | ]} 42 | 43 | stub.get('/dummies/1') {[ 44 | 200, 45 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Dummy'}, 46 | joe_dummy 47 | ]} 48 | 49 | stub.put('/dummies/1') {[ 50 | 200, 51 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Dummy'}, 52 | joe_dummy 53 | ]} 54 | 55 | stub.patch('/dummies/1') {[ 56 | 200, 57 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Dummy'}, 58 | joe_dummy 59 | ]} 60 | 61 | stub.delete('/dummies/1') {[ 62 | 200, 63 | {'Content-type' => 'application/vnd.dummy.v1+hal+json'}, 64 | '' 65 | ]} 66 | 67 | # from Rack specs 'Content-type' => 'application/vnd.dummy.v1+hal+json' 68 | stub.get('/204_with_nil_body') {[ 69 | 204, 70 | {}, 71 | nil 72 | ]} 73 | stub.get('/204_with_empty_string_body') {[ 74 | 204, 75 | {}, 76 | '' 77 | ]} 78 | 79 | stub.get('/204_with_empty_hash_body') {[ 80 | 204, 81 | {}, 82 | {} 83 | ]} 84 | 85 | 86 | stub.get('/404') {[ 87 | 404, 88 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Error'}, 89 | '{"error": "Not found", "_links": {"root":{"href":"/"}}}' 90 | ]} 91 | 92 | stub.get('/405_without_body') {[ 93 | 405, 94 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Error'}, 95 | '""' 96 | ]} 97 | 98 | stub.get('/500') {[ 99 | 500, 100 | {'Content-type' => 'application/vnd.dummy.v1+hal+json;type=Error'}, 101 | '{"error": "Internal server error", "_links": {"root":{"href":"/"}}}' 102 | ]} 103 | 104 | stub.get('/garbage') {[ 105 | 200, 106 | {'Content-type' => 'application/json'}, 107 | '!@#$%!@##%$!@#$%^ (This is very invalid JSON)' 108 | ]} 109 | end 110 | end 111 | 112 | describe HyperResource::Modules::HTTP do 113 | class DummyAPI < HyperResource 114 | class Link < HyperResource::Link 115 | end 116 | end 117 | 118 | before do 119 | DummyAPI::Link.any_instance.stubs(:faraday_connection).returns(stub_connection) 120 | end 121 | 122 | describe 'GET' do 123 | it 'works at a basic level' do 124 | hr = DummyAPI.new(:root => 'http://example.com/') 125 | root = hr.get 126 | root.wont_be_nil 127 | root.must_be_kind_of HyperResource 128 | root.must_be_instance_of DummyAPI::Root 129 | root.links.must_be_instance_of DummyAPI::Root::Links 130 | assert root.links.dummies 131 | end 132 | 133 | it 'raises client error' do 134 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '404') 135 | begin 136 | hr.get 137 | assert false # shouldn't get here 138 | rescue HyperResource::ClientError => e 139 | e.response.wont_be_nil 140 | end 141 | end 142 | 143 | it 'Accepts response without a body (example status 204 with nil body)' do 144 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '204_with_nil_body') 145 | root = hr.get 146 | root.wont_be_nil 147 | root.links.must_be_empty 148 | root.attributes.must_be_empty 149 | root.objects.must_be_empty 150 | root.must_be_kind_of HyperResource 151 | root.must_be_instance_of DummyAPI 152 | 153 | root.response.status.must_equal 204 154 | root.response.body.must_be_nil 155 | end 156 | 157 | it 'Accepts response without a body (example status 204 with empty string body)' do 158 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '204_with_empty_string_body') 159 | root = hr.get 160 | root.wont_be_nil 161 | root.links.must_be_empty 162 | root.attributes.must_be_empty 163 | root.objects.must_be_empty 164 | root.must_be_kind_of HyperResource 165 | root.must_be_instance_of DummyAPI 166 | 167 | root.response.status.must_equal 204 168 | root.response.body.must_equal('') 169 | end 170 | 171 | it 'Accepts response without a body (example status 204 with empty hash body)' do 172 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '204_with_empty_hash_body') 173 | root = hr.get 174 | root.wont_be_nil 175 | root.links.must_be_empty 176 | root.attributes.must_be_empty 177 | root.objects.must_be_empty 178 | root.must_be_kind_of HyperResource 179 | root.must_be_instance_of DummyAPI 180 | 181 | root.response.status.must_equal 204 182 | root.response.body.must_equal({}) 183 | end 184 | 185 | it 'raises client error and accepts empty body for a status 405' do 186 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '405_without_body') 187 | begin 188 | hr.get 189 | assert false # shouldn't get here 190 | rescue HyperResource::ClientError => e 191 | e.response.wont_be_nil 192 | e.response.status.must_equal 405 193 | end 194 | end 195 | 196 | it 'raises server error' do 197 | hr = DummyAPI.new(:root => 'http://example.com/', :href => '500') 198 | begin 199 | hr.get 200 | assert false # shouldn't get here 201 | rescue HyperResource::ServerError => e 202 | e.response.wont_be_nil 203 | end 204 | end 205 | 206 | it 'raises response error' do 207 | hr = DummyAPI.new(:root => 'http://example.com/', :href => 'garbage') 208 | begin 209 | hr.get 210 | assert false # shouldn't get here 211 | rescue HyperResource::ResponseError => e 212 | e.response.wont_be_nil 213 | e.cause.must_be_kind_of Exception 214 | end 215 | end 216 | 217 | it 'does get_response' do 218 | hr = DummyAPI.new(:root => 'http://example.com/') 219 | root = hr.get_response 220 | root.wont_be_nil 221 | root.must_be_kind_of Faraday::Response 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /test/unit/hyper_resource_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource do 4 | before do 5 | @rsrc = HyperResource.new(:root => 'http://example.com', :href => '/obj1/') 6 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 7 | end 8 | 9 | describe 'method_missing' do 10 | it "uses method_missing on :attr methods" do 11 | @rsrc.attr1.must_equal 'val1' 12 | @rsrc.attributes.attr1.must_equal 'val1' 13 | @rsrc.attr2.must_equal 'val2' 14 | @rsrc.attributes.attr2.must_equal 'val2' 15 | end 16 | 17 | it 'uses method_missing on :attr= methods' do 18 | @rsrc.attr1 = :foo 19 | @rsrc.attr1.must_equal :foo 20 | end 21 | 22 | it 'uses method_missing on :attr= methods for attributes with nil value' do 23 | @rsrc.attr3 = :foo 24 | @rsrc.attr3.must_equal :foo 25 | end 26 | 27 | it 'uses method_missing on :link methods' do 28 | @rsrc.self.must_be_instance_of HyperResource::Link 29 | @rsrc.links.self.must_be_instance_of HyperResource::Link 30 | end 31 | 32 | it 'uses method_missing on :obj methods' do 33 | @rsrc.obj1s.must_be_instance_of Array 34 | @rsrc.objects.obj1s.must_be_instance_of Array 35 | end 36 | end 37 | 38 | describe 'Enumerable support' do 39 | it 'implements each' do 40 | vals = [] 41 | @rsrc.each {|r| vals << r.attr3} 42 | vals.must_equal ['val3', 'val5'] 43 | end 44 | 45 | it 'supports []' do 46 | @rsrc.first.must_equal @rsrc[0] 47 | end 48 | 49 | it 'supports map' do 50 | @rsrc.map(&:attr3).must_equal ['val3', 'val5'] 51 | end 52 | end 53 | 54 | describe '#to_link' do 55 | it 'converts into a link' do 56 | link = @rsrc.send :to_link 57 | link.href.must_equal @rsrc.href 58 | end 59 | end 60 | 61 | end 62 | 63 | -------------------------------------------------------------------------------- /test/unit/link_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource::Link do 4 | before do 5 | hr = HyperResource.new 6 | @link = HyperResource::Link.new(hr, {'href' => '/foo{?blarg}', 7 | 'name' => 'foo', 8 | 'templated' => true}) 9 | end 10 | 11 | describe '#where' do 12 | it 'where does not mutate original link' do 13 | link2 = @link.where('blarg' => 22) 14 | @link.params['blarg'].must_be_nil 15 | link2.params['blarg'].must_equal 22 16 | end 17 | end 18 | 19 | describe '#href' do 20 | it 'href fills in URI template params' do 21 | link2 = @link.where('blarg' => 22) 22 | link2.href.must_equal '/foo?blarg=22' 23 | end 24 | end 25 | 26 | describe '#name' do 27 | it 'comes from the link spec' do 28 | @link.name.must_equal 'foo' 29 | end 30 | 31 | it 'is kept when using where' do 32 | link2 = @link.where('blarg' => 42) 33 | link2.name.must_equal 'foo' 34 | end 35 | end 36 | 37 | describe '#resource' do 38 | it 'resource creates a new HyperResource instance' do 39 | @link.resource.must_be_instance_of HyperResource 40 | end 41 | 42 | it 'returned resource has not been loaded yet' do 43 | @link.resource.response.must_be_nil 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/unit/links_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource::Links do 4 | class TestAPI < HyperResource; end 5 | 6 | before do 7 | @rsrc = TestAPI.new 8 | @rsrc.adapter.apply(HAL_BODY, @rsrc) 9 | @links = @rsrc.links 10 | end 11 | 12 | describe '#init_from_hal' do 13 | it 'provides readers for all links, including CURIE names' do 14 | assert @links.self 15 | assert @links.foobars 16 | assert @links.send('foo:foobars') 17 | end 18 | 19 | it 'creates reader hash keys for all links, including CURIE names' do 20 | @links['self'].wont_be_nil 21 | @links['foobars'].wont_be_nil 22 | @links['foo:foobars'].wont_be_nil 23 | end 24 | 25 | it 'creates all links as HyperResource::Link or subclass' do 26 | @links.self.must_be_kind_of HyperResource::Link 27 | end 28 | 29 | it 'handles link arrays' do 30 | @links.foobars.must_be_kind_of Array 31 | @links.send(:'foo:foobars').must_be_kind_of Array 32 | @links.foobars.first.must_be_kind_of HyperResource::Link 33 | end 34 | end 35 | 36 | describe 'implicit .where' do 37 | it 'link accessor calls .where when called with args' do 38 | link = @links.self(:blarg => 1) 39 | link.must_be_kind_of HyperResource::Link 40 | link.params['blarg'].must_equal 1 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/unit/respond_to_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource do 4 | class NoMethodsAPI < HyperResource; end 5 | 6 | before do 7 | @api = NoMethodsAPI.new(:root => 'http://example.com') 8 | @api.adapter.apply(HAL_BODY, @api) 9 | end 10 | 11 | describe 'respond_to' do 12 | it "doesn't create methods" do 13 | @api.methods.wont_include(:attr1) 14 | @api.attributes.methods.wont_include(:attr1) 15 | @api.methods.wont_include(:obj1s) 16 | @api.objects.methods.wont_include(:obj1s) 17 | @api.methods.wont_include(:foobars) 18 | @api.links.methods.wont_include(:foobars) 19 | end 20 | 21 | it "responds_to the right things" do 22 | @api.must_respond_to(:attr1) 23 | @api.attributes.must_respond_to(:attr1) 24 | @api.must_respond_to(:obj1s) 25 | @api.objects.must_respond_to(:obj1s) 26 | @api.must_respond_to(:foobars) 27 | @api.links.must_respond_to(:foobars) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/unit/version_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | describe HyperResource::VERSION do 4 | it 'should be above zero' do 5 | Gem::Version.new(HyperResource::VERSION).must_be :>, 6 | Gem::Version.new('0') 7 | end 8 | end 9 | --------------------------------------------------------------------------------