herr 0.7.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +1 -0
  4. data/.travis.yml +15 -0
  5. data/.yardopts +2 -0
  6. data/CONTRIBUTING.md +26 -0
  7. data/Gemfile +10 -0
  8. data/LICENSE +7 -0
  9. data/README.md +990 -0
  10. data/Rakefile +11 -0
  11. data/UPGRADE.md +81 -0
  12. data/gemfiles/Gemfile.activemodel-3.2.x +7 -0
  13. data/gemfiles/Gemfile.activemodel-4.0 +7 -0
  14. data/gemfiles/Gemfile.activemodel-4.1 +7 -0
  15. data/gemfiles/Gemfile.activemodel-4.2 +7 -0
  16. data/her.gemspec +30 -0
  17. data/lib/her.rb +16 -0
  18. data/lib/her/api.rb +115 -0
  19. data/lib/her/collection.rb +12 -0
  20. data/lib/her/errors.rb +27 -0
  21. data/lib/her/middleware.rb +10 -0
  22. data/lib/her/middleware/accept_json.rb +17 -0
  23. data/lib/her/middleware/first_level_parse_json.rb +36 -0
  24. data/lib/her/middleware/parse_json.rb +21 -0
  25. data/lib/her/middleware/second_level_parse_json.rb +36 -0
  26. data/lib/her/model.rb +72 -0
  27. data/lib/her/model/associations.rb +141 -0
  28. data/lib/her/model/associations/association.rb +103 -0
  29. data/lib/her/model/associations/association_proxy.rb +46 -0
  30. data/lib/her/model/associations/belongs_to_association.rb +96 -0
  31. data/lib/her/model/associations/has_many_association.rb +100 -0
  32. data/lib/her/model/associations/has_one_association.rb +79 -0
  33. data/lib/her/model/attributes.rb +266 -0
  34. data/lib/her/model/base.rb +33 -0
  35. data/lib/her/model/deprecated_methods.rb +61 -0
  36. data/lib/her/model/http.rb +114 -0
  37. data/lib/her/model/introspection.rb +65 -0
  38. data/lib/her/model/nested_attributes.rb +45 -0
  39. data/lib/her/model/orm.rb +205 -0
  40. data/lib/her/model/parse.rb +227 -0
  41. data/lib/her/model/paths.rb +121 -0
  42. data/lib/her/model/relation.rb +164 -0
  43. data/lib/her/version.rb +3 -0
  44. data/spec/api_spec.rb +131 -0
  45. data/spec/collection_spec.rb +26 -0
  46. data/spec/middleware/accept_json_spec.rb +10 -0
  47. data/spec/middleware/first_level_parse_json_spec.rb +62 -0
  48. data/spec/middleware/second_level_parse_json_spec.rb +35 -0
  49. data/spec/model/associations_spec.rb +416 -0
  50. data/spec/model/attributes_spec.rb +268 -0
  51. data/spec/model/callbacks_spec.rb +145 -0
  52. data/spec/model/dirty_spec.rb +86 -0
  53. data/spec/model/http_spec.rb +194 -0
  54. data/spec/model/introspection_spec.rb +76 -0
  55. data/spec/model/nested_attributes_spec.rb +134 -0
  56. data/spec/model/orm_spec.rb +479 -0
  57. data/spec/model/parse_spec.rb +373 -0
  58. data/spec/model/paths_spec.rb +341 -0
  59. data/spec/model/relation_spec.rb +226 -0
  60. data/spec/model/validations_spec.rb +42 -0
  61. data/spec/model_spec.rb +31 -0
  62. data/spec/spec_helper.rb +26 -0
  63. data/spec/support/extensions/array.rb +5 -0
  64. data/spec/support/extensions/hash.rb +5 -0
  65. data/spec/support/macros/her_macros.rb +17 -0
  66. data/spec/support/macros/model_macros.rb +29 -0
  67. data/spec/support/macros/request_macros.rb +27 -0
  68. metadata +280 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 895649b69ac63c97d89e642b1e0ff7f4db53af6e
4
+ data.tar.gz: c62273356117a58566e4276e85f9f30a696dc650
5
+ SHA512:
6
+ metadata.gz: ca95a2041c09b94d2aec3809de44e13ded4134cb6c89e045fca79c793828576386454044781a6a20d4ebd19403f94b5d164b294ca7eb8cb6369ff7b732d54f13
7
+ data.tar.gz: 9a2260095bda92e11ad1c3866d75f46a7b50df62d28662248f4cf11ac7798f67079e9ad1543d3e04b6e8d279b171e950426ec6392eb16cc126ca7ce9f9161593
@@ -0,0 +1,4 @@
1
+ /Gemfile.lock
2
+ /pkg
3
+ /tmp
4
+ /coverage
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour --format=Fivemat
@@ -0,0 +1,15 @@
1
+ language: ruby
2
+
3
+ sudo: false
4
+
5
+ rvm:
6
+ - 2.0.0
7
+ - 1.9.3
8
+
9
+ gemfile:
10
+ - gemfiles/Gemfile.activemodel-4.2
11
+ - gemfiles/Gemfile.activemodel-4.1
12
+ - gemfiles/Gemfile.activemodel-4.0
13
+ - gemfiles/Gemfile.activemodel-3.2.x
14
+
15
+ script: "echo 'COME ON!' && bundle exec rake spec"
@@ -0,0 +1,2 @@
1
+ --protected
2
+ --no-private
@@ -0,0 +1,26 @@
1
+ # How to contribute
2
+
3
+ _(This file is heavily based on [factory\_girl\_rails](https://github.com/thoughtbot/factory_girl_rails/blob/master/CONTRIBUTING.md)’s Contribution Guide)_
4
+
5
+ We love pull requests. Here’s a quick guide:
6
+
7
+ * Fork the repository.
8
+ * Run `rake spec` (to make sure you start with a clean slate).
9
+ * Implement your feature or fix.
10
+ * Add examples that describe it (in the `spec` directory). Only refactoring and documentation changes require no new tests. If you are adding functionality or fixing a bug, we need examples!
11
+ * Make sure `rake spec` passes after your modifications.
12
+ * Commit (bonus points for doing it in a `feature-*` branch).
13
+ * Push to your fork and send your pull request!
14
+
15
+ If we have not replied to your pull request in three or four days, do not hesitate to post another comment in it — yes, we can be lazy sometimes.
16
+
17
+ ## Syntax Guide
18
+
19
+ Do not hesitate to submit patches that fix syntax issues. Some may have slipped under our nose.
20
+
21
+ * Two spaces, no tabs (but you already knew that, right?).
22
+ * No trailing whitespace. Blank lines should not have any space. There are few things we **hate** more than trailing whitespace. Seriously.
23
+ * `MyClass.my_method(my_arg)` not `my_method( my_arg )` or `my_method my_arg`.
24
+ * `[:foo, :bar]` and not `[ :foo, :bar ]`, `{ :foo => :bar }` and not `{:foo => :bar}`
25
+ * `a = b` and not `a=b`.
26
+ * Follow the conventions you see used in the source already.
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "https://rubygems.org"
2
+ gemspec
3
+
4
+ if RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] && RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] >= '1.9.3'
5
+ gem 'activemodel', '>= 3.2.0'
6
+ gem 'activesupport', '>= 3.2.0'
7
+ else
8
+ gem 'activemodel', '~> 3.2.0'
9
+ gem 'activesupport', '~> 3.2.0'
10
+ end
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2012-2013 Rémi Prévost
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,990 @@
1
+ <p align="center">
2
+ Herr is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects.<br /> It is designed to build applications that are powered by a RESTful API instead of a database.
3
+ <br /><br />
4
+ <a href="https://rubygems.org/gems/herr"><img src="http://img.shields.io/gem/v/her.svg" /></a>
5
+ <a href="https://codeclimate.com/github/hderms/herr"><img src="http://img.shields.io/codeclimate/github/hderms/herr.svg" /></a>
6
+ <a href="https://travis-ci.org/hderms/herr"><img src="http://img.shields.io/travis/hderms/herr/master.svg" /></a>
7
+ </p>
8
+
9
+ ---
10
+ ## IMPORTANT
11
+ This is forked from the gem `her`, which was extraordinarily useful, but had not undergone maintenance at a rate sufficient for my needs. Please check [https://github.com/remiprev/her] and see if maintenance has resumed before using this gem.
12
+
13
+ ## Installation
14
+
15
+ In your Gemfile, add:
16
+
17
+ ```ruby
18
+ gem "herr"
19
+ ```
20
+
21
+ That’s it!
22
+
23
+ ## Usage
24
+
25
+ _For a complete reference of all the methods you can use, check out [the documentation](http://rdoc.info/github/remiprev/her)._
26
+
27
+ First, you have to define which API your models will be bound to. For example, with Rails, you would create a new `config/initializers/her.rb` file with these lines:
28
+
29
+ ```ruby
30
+ # config/initializers/her.rb
31
+ Her::API.setup url: "https://api.example.com" do |c|
32
+ # Request
33
+ c.use Faraday::Request::UrlEncoded
34
+
35
+ # Response
36
+ c.use Her::Middleware::DefaultParseJSON
37
+
38
+ # Adapter
39
+ c.use Faraday::Adapter::NetHttp
40
+ end
41
+ ```
42
+
43
+ And then to add the ORM behavior to a class, you just have to include `Her::Model` in it:
44
+
45
+ ```ruby
46
+ class User
47
+ include Her::Model
48
+ end
49
+ ```
50
+
51
+ After that, using Her is very similar to many ActiveRecord-like ORMs:
52
+
53
+ ```ruby
54
+ User.all
55
+ # GET "https://api.example.com/users" and return an array of User objects
56
+
57
+ User.find(1)
58
+ # GET "https://api.example.com/users/1" and return a User object
59
+
60
+ @user = User.create(fullname: "Tobias Fünke")
61
+ # POST "https://api.example.com/users" with `fullname=Tobias+Fünke` and return the saved User object
62
+
63
+ @user = User.new(fullname: "Tobias Fünke")
64
+ @user.occupation = "actor"
65
+ @user.save
66
+ # POST "https://api.example.com/users" with `fullname=Tobias+Fünke&occupation=actor` and return the saved User object
67
+
68
+ @user = User.find(1)
69
+ @user.fullname = "Lindsay Fünke"
70
+ @user.save
71
+ # PUT "https://api.example.com/users/1" with `fullname=Lindsay+Fünke` and return the updated User object
72
+ ```
73
+
74
+ ### ActiveRecord-like methods
75
+
76
+ These are the basic ActiveRecord-like methods you can use with your models:
77
+
78
+ ```ruby
79
+ class User
80
+ include Her::Model
81
+ end
82
+
83
+ # Update a fetched resource
84
+ user = User.find(1)
85
+ user.fullname = "Lindsay Fünke" # OR user.assign_attributes(fullname: "Lindsay Fünke")
86
+ user.save # returns false if it fails, errors in user.response_errors array
87
+ # PUT "/users/1" with `fullname=Lindsay+Fünke`
88
+
89
+ # Update a resource without fetching it
90
+ User.save_existing(1, fullname: "Lindsay Fünke")
91
+ # PUT "/users/1" with `fullname=Lindsay+Fünke`
92
+
93
+ # Destroy a fetched resource
94
+ user = User.find(1)
95
+ user.destroy
96
+ # DELETE "/users/1"
97
+
98
+ # Destroy a resource without fetching it
99
+ User.destroy_existing(1)
100
+ # DELETE "/users/1"
101
+
102
+ # Fetching a collection of resources
103
+ User.all
104
+ # GET "/users"
105
+ User.where(moderator: 1).all
106
+ # GET "/users?moderator=1"
107
+
108
+ # Create a new resource
109
+ User.create(fullname: "Maeby Fünke")
110
+ # POST "/users" with `fullname=Maeby+Fünke`
111
+
112
+ # Save a new resource
113
+ user = User.new(fullname: "Maeby Fünke")
114
+ user.save! # raises Her::Errors::ResourceInvalid if it fails
115
+ # POST "/users" with `fullname=Maeby+Fünke`
116
+ ```
117
+
118
+ You can look into the [`her-example`](https://github.com/remiprev/her-example) repository for a sample application using Her.
119
+
120
+ ## Middleware
121
+
122
+ Since Her relies on [Faraday](https://github.com/lostisland/faraday) to send HTTP requests, you can choose the middleware used to handle requests and responses. Using the block in the `setup` call, you have access to Faraday’s `connection` object and are able to customize the middleware stack used on each request and response.
123
+
124
+ ### Authentication
125
+
126
+ Her doesn’t support authentication by default. However, it’s easy to implement one with request middleware. Using the `setup` block, we can add it to the middleware stack.
127
+
128
+ For example, to add a token header to your API requests in a Rails application, you could use the excellent [`request_store`](https://rubygems.org/gems/request_store) gem like this:
129
+
130
+ ```ruby
131
+ # app/controllers/application_controller.rb
132
+ class ApplicationController < ActionController::Base
133
+ before_filter :set_user_api_token
134
+
135
+ protected
136
+ def set_user_api_token
137
+ RequestStore.store[:my_api_token] = current_user.api_token # or something similar based on `session`
138
+ end
139
+ end
140
+
141
+ # lib/my_token_authentication.rb
142
+ class MyTokenAuthentication < Faraday::Middleware
143
+ def call(env)
144
+ env[:request_headers]["X-API-Token"] = RequestStore.store[:my_api_token]
145
+ @app.call(env)
146
+ end
147
+ end
148
+
149
+ # config/initializers/her.rb
150
+ require "lib/my_token_authentication"
151
+
152
+ Her::API.setup url: "https://api.example.com" do |c|
153
+ # Request
154
+ c.use MyTokenAuthentication
155
+ c.use Faraday::Request::UrlEncoded
156
+
157
+ # Response
158
+ c.use Her::Middleware::DefaultParseJSON
159
+
160
+ # Adapter
161
+ c.use Faraday::Adapter::NetHttp
162
+ end
163
+ ```
164
+
165
+ Now, each HTTP request made by Her will have the `X-API-Token` header.
166
+
167
+ ### OAuth
168
+
169
+ Using the `faraday_middleware` and `simple_oauth` gems, it’s fairly easy to use OAuth authentication with Her.
170
+
171
+ In your Gemfile:
172
+
173
+ ```ruby
174
+ gem "her"
175
+ gem "faraday_middleware"
176
+ gem "simple_oauth"
177
+ ```
178
+
179
+ In your Ruby code:
180
+
181
+ ```ruby
182
+ # Create an application on `https://dev.twitter.com/apps` to set these values
183
+ TWITTER_CREDENTIALS = {
184
+ consumer_key: "",
185
+ consumer_secret: "",
186
+ token: "",
187
+ token_secret: ""
188
+ }
189
+
190
+ Her::API.setup url: "https://api.twitter.com/1/" do |c|
191
+ # Request
192
+ c.use FaradayMiddleware::OAuth, TWITTER_CREDENTIALS
193
+
194
+ # Response
195
+ c.use Her::Middleware::DefaultParseJSON
196
+
197
+ # Adapter
198
+ c.use Faraday::Adapter::NetHttp
199
+ end
200
+
201
+ class Tweet
202
+ include Her::Model
203
+ end
204
+
205
+ @tweets = Tweet.get("/statuses/home_timeline.json")
206
+ ```
207
+
208
+ See the *Authentication* middleware section for an example of how to pass different credentials based on the current user.
209
+
210
+ ### Parsing JSON data
211
+
212
+ By default, Her handles JSON data. It expects the resource/collection data to be returned at the first level.
213
+
214
+ ```javascript
215
+ // The response of GET /users/1
216
+ { "id" : 1, "name" : "Tobias Fünke" }
217
+
218
+ // The response of GET /users
219
+ [{ "id" : 1, "name" : "Tobias Fünke" }]
220
+ ```
221
+
222
+ However, if you want Her to be able to parse the data from a single root element (usually based on the model name), you’ll have to use the `parse_root_in_json` method (See the **JSON attributes-wrapping** section).
223
+
224
+ Also, you can define your own parsing method using a response middleware. The middleware should set `env[:body]` to a hash with three symbol keys: `:data`, `:errors` and `:metadata`. The following code uses a custom middleware to parse the JSON data:
225
+
226
+ ```ruby
227
+ # Expects responses like:
228
+ #
229
+ # {
230
+ # "result": { "id": 1, "name": "Tobias Fünke" },
231
+ # "errors": []
232
+ # }
233
+ #
234
+ class MyCustomParser < Faraday::Response::Middleware
235
+ def on_complete(env)
236
+ json = MultiJson.load(env[:body], symbolize_keys: true)
237
+ env[:body] = {
238
+ data: json[:result],
239
+ errors: json[:errors],
240
+ metadata: json[:metadata]
241
+ }
242
+ end
243
+ end
244
+
245
+ Her::API.setup url: "https://api.example.com" do |c|
246
+ # Response
247
+ c.use MyCustomParser
248
+
249
+ # Adapter
250
+ c.use Faraday::Adapter::NetHttp
251
+ end
252
+ ```
253
+
254
+ ### Caching
255
+
256
+ Again, using the `faraday_middleware` and `memcached` gems makes it very easy to cache requests and responses.
257
+
258
+ In your Gemfile:
259
+
260
+ ```ruby
261
+ gem "her"
262
+ gem "faraday_middleware"
263
+ gem "memcached"
264
+ ```
265
+
266
+ In your Ruby code:
267
+
268
+ ```ruby
269
+ Her::API.setup url: "https://api.example.com" do |c|
270
+ # Request
271
+ c.use FaradayMiddleware::Caching, Memcached::Rails.new('127.0.0.1:11211')
272
+
273
+ # Response
274
+ c.use Her::Middleware::DefaultParseJSON
275
+
276
+ # Adapter
277
+ c.use Faraday::Adapter::NetHttp
278
+ end
279
+
280
+ class User
281
+ include Her::Model
282
+ end
283
+
284
+ @user = User.find(1)
285
+ # GET "/users/1"
286
+
287
+ @user = User.find(1)
288
+ # This request will be fetched from memcached
289
+ ```
290
+
291
+ ## Advanced Features
292
+
293
+ Here’s a list of several useful features available in Her.
294
+
295
+ ### Associations
296
+
297
+ Examples use this code:
298
+
299
+ ```ruby
300
+ class User
301
+ include Her::Model
302
+ has_many :comments
303
+ has_one :role
304
+ belongs_to :organization
305
+ end
306
+
307
+ class Comment
308
+ include Her::Model
309
+ end
310
+
311
+ class Role
312
+ include Her::Model
313
+ end
314
+
315
+ class Organization
316
+ include Her::Model
317
+ end
318
+ ```
319
+
320
+ #### Fetching data
321
+
322
+ You can define `has_many`, `has_one` and `belongs_to` associations in your models. The association data is handled in two different ways.
323
+
324
+ 1. If Her finds association data when parsing a resource, that data will be used to create the associated model objects on the resource.
325
+ 2. If no association data was included when parsing a resource, calling a method with the same name as the association will fetch the data (providing there’s an HTTP request available for it in the API).
326
+
327
+ For example, if there’s association data in the resource, no extra HTTP request is made when calling the `#comments` method and an array of resources is returned:
328
+
329
+ ```ruby
330
+ @user = User.find(1)
331
+ # GET "/users/1", response is:
332
+ # {
333
+ # "id": 1,
334
+ # "name": "George Michael Bluth",
335
+ # "comments": [
336
+ # { "id": 1, "text": "Foo" },
337
+ # { "id": 2, "text": "Bar" }
338
+ # ],
339
+ # "role": { "id": 1, "name": "Admin" },
340
+ # "organization": { "id": 2, "name": "Bluth Company" }
341
+ # }
342
+
343
+ @user.comments
344
+ # => [#<Comment id=1 text="Foo">, #<Comment id=2 text="Bar">]
345
+
346
+ @user.role
347
+ # => #<Role id=1 name="Admin">
348
+
349
+ @user.organization
350
+ # => #<Organization id=2 name="Bluth Company">
351
+ ```
352
+
353
+ If there’s no association data in the resource, Her makes a HTTP request to retrieve the data.
354
+
355
+ ```ruby
356
+ @user = User.find(1)
357
+ # GET "/users/1", response is { "id": 1, "name": "George Michael Bluth", "organization_id": 2 }
358
+
359
+ # has_many association:
360
+ @user.comments
361
+ # GET "/users/1/comments"
362
+ # => [#<Comment id=1>, #<Comment id=2>]
363
+
364
+ @user.comments.where(approved: 1)
365
+ # GET "/users/1/comments?approved=1"
366
+ # => [#<Comment id=1>]
367
+
368
+ # has_one association:
369
+ @user.role
370
+ # GET "/users/1/role"
371
+ # => #<Role id=1>
372
+
373
+ # belongs_to association:
374
+ @user.organization
375
+ # (the organization id comes from :organization_id, by default)
376
+ # GET "/organizations/2"
377
+ # => #<Organization id=2>
378
+ ```
379
+
380
+ Subsequent calls to `#comments`, `#role` and `#organization` will not trigger extra HTTP requests and will return the cached objects.
381
+
382
+ #### Creating data
383
+
384
+ You can use the association methods to build new objects and save them.
385
+
386
+ ```ruby
387
+ @user = User.find(1)
388
+ @user.comments.build(body: "Just a draft")
389
+ # => [#<Comment body="Just a draft" user_id=1>]
390
+
391
+ @user.comments.create(body: "Hello world.")
392
+ # POST "/users/1/comments" with `body=Hello+world.`
393
+ # => [#<Comment id=3 body="Hello world." user_id=1>]
394
+ ```
395
+
396
+ You can also explicitly request a new object via the API when using ``build``. This is useful if you're dealing with default attributes.
397
+
398
+ ```ruby
399
+ class Comment
400
+ include Her::Model
401
+ request_new_object_on_build true
402
+ end
403
+
404
+ @user = User.find(1)
405
+ @user.comments.build(body: "Just a draft")
406
+ # GET "/users/1/comments/new" with `body=Just+a+draft.`
407
+ # => [#<Comment id=nil body="Just a draft" archived=false user_id=1>]
408
+ ```
409
+
410
+ #### Notes about paths
411
+
412
+ Resources must always have all the required attributes to build their complete path. For example, if you have these models:
413
+
414
+ ```ruby
415
+ class User
416
+ include Her::Model
417
+ collection_path "organizations/:organization_id/users"
418
+ end
419
+
420
+ class Organization
421
+ include Her::Model
422
+ has_many :users
423
+ end
424
+ ```
425
+
426
+ Her expects all `User` resources to have an `:organization_id` (or `:_organization_id`) attribute. Otherwise, calling mostly all methods, like `User.all`, will thrown an exception like this one:
427
+
428
+ ```ruby
429
+ Her::Errors::PathError: Missing :_organization_id parameter to build the request path. Path is `organizations/:organization_id/users`. Parameters are `{ … }`.
430
+ ```
431
+
432
+ ### Validations
433
+
434
+ Her includes `ActiveModel::Validations` so you can declare validations the same way you do in Rails.
435
+
436
+ However, validations must be triggered manually — they are not run, for example, when calling `#save` on an object, or `#create` on a model class.
437
+
438
+ ```ruby
439
+ class User
440
+ include Her::Model
441
+
442
+ attributes :fullname, :email
443
+ validates :fullname, presence: true
444
+ validates :email, presence: true
445
+ end
446
+
447
+ @user = User.new(fullname: "Tobias Fünke")
448
+ @user.valid? # => false
449
+
450
+ @user.save
451
+ # POST "/users" with `fullname=Tobias+Fünke` will still be called, even if the user is not valid
452
+ ```
453
+
454
+ ### Dirty attributes
455
+
456
+ Her includes `ActiveModel::Dirty` so you can keep track of the attributes that have changed in an object.
457
+
458
+ ```ruby
459
+ class User
460
+ include Her::Model
461
+
462
+ attributes :fullname, :email
463
+ end
464
+
465
+ @user = User.new(fullname: "Tobias Fünke")
466
+ @user.fullname_changed? # => true
467
+ @user.changes # => { :fullname => [nil, "Tobias Fünke"] }
468
+
469
+ @user.save
470
+ # POST "/users" with `fullname=Tobias+Fünke`
471
+
472
+ @user.fullname_changed? # => false
473
+ @user.changes # => {}
474
+ ```
475
+
476
+ To update only the modified attributes specify `:send_only_modified_attributes => true` in the setup.
477
+
478
+ ### Callbacks
479
+
480
+ You can add *before* and *after* callbacks to your models that are triggered on specific actions. You can use symbols or blocks.
481
+
482
+ ```ruby
483
+ class User
484
+ include Her::Model
485
+ before_save :set_internal_id
486
+ after_find { |u| u.fullname.upcase! }
487
+
488
+ def set_internal_id
489
+ self.internal_id = 42 # Will be passed in the HTTP request
490
+ end
491
+ end
492
+
493
+ @user = User.create(fullname: "Tobias Fünke")
494
+ # POST "/users" with `fullname=Tobias+Fünke&internal_id=42`
495
+
496
+ @user = User.find(1)
497
+ @user.fullname # => "TOBIAS FUNKE"
498
+ ```
499
+
500
+ The available callbacks are:
501
+
502
+ * `before_save`
503
+ * `before_create`
504
+ * `before_update`
505
+ * `before_destroy`
506
+ * `after_save`
507
+ * `after_create`
508
+ * `after_update`
509
+ * `after_destroy`
510
+ * `after_find`
511
+ * `after_initialize`
512
+
513
+ ### JSON attributes-wrapping
514
+
515
+ Her supports *sending* and *parsing* JSON data wrapped in a root element (to be compatible with Rails’ `include_root_in_json` setting), like so:
516
+
517
+ #### Sending
518
+
519
+ If you want to send all data to your API wrapped in a *root* element based on the model name.
520
+
521
+ ```ruby
522
+ class User
523
+ include Her::Model
524
+ include_root_in_json true
525
+ end
526
+
527
+ class Article
528
+ include Her::Model
529
+ include_root_in_json :post
530
+ end
531
+
532
+ User.create(fullname: "Tobias Fünke")
533
+ # POST "/users" with `user[fullname]=Tobias+Fünke`
534
+
535
+ Article.create(title: "Hello world.")
536
+ # POST "/articles" with `post[title]=Hello+world`
537
+ ```
538
+
539
+ #### Parsing
540
+
541
+ If the API returns data wrapped in a *root* element based on the model name.
542
+
543
+ ```ruby
544
+ class User
545
+ include Her::Model
546
+ parse_root_in_json true
547
+ end
548
+
549
+ class Article
550
+ include Her::Model
551
+ parse_root_in_json :post
552
+ end
553
+
554
+ user = User.create(fullname: "Tobias Fünke")
555
+ # POST "/users" with `fullname=Tobias+Fünke`, response is { "user": { "fullname": "Tobias Fünke" } }
556
+ user.fullname # => "Tobias Fünke"
557
+
558
+ article = Article.create(title: "Hello world.")
559
+ # POST "/articles" with `title=Hello+world.`, response is { "post": { "title": "Hello world." } }
560
+ article.title # => "Hello world."
561
+ ```
562
+
563
+ Of course, you can use both `include_root_in_json` and `parse_root_in_json` at the same time.
564
+
565
+ #### ActiveModel::Serializers support
566
+
567
+ If the API returns data in the default format used by the
568
+ [ActiveModel::Serializers](https://github.com/rails-api/active_model_serializers)
569
+ project you need to configure Her as follows:
570
+
571
+ ```ruby
572
+ class User
573
+ include Her::Model
574
+ parse_root_in_json true, format: :active_model_serializers
575
+ end
576
+
577
+ user = Users.find(1)
578
+ # GET "/users/1", response is { "user": { "id": 1, "fullname": "Lindsay Fünke" } }
579
+
580
+ users = Users.all
581
+ # GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 1, "fullname": "Tobias Fünke" }] }
582
+ ```
583
+
584
+ #### JSON API support
585
+
586
+ If the API returns data in the [JSON API format](http://jsonapi.org/) you need
587
+ to configure Her as follows:
588
+
589
+ ```ruby
590
+ class User
591
+ include Her::Model
592
+ parse_root_in_json true, format: :json_api
593
+ end
594
+
595
+ user = Users.find(1)
596
+ # GET "/users/1", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }] }
597
+
598
+ users = Users.all
599
+ # GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 2, "fullname": "Tobias Fünke" }] }
600
+ ```
601
+
602
+ ### Custom requests
603
+
604
+ You can easily define custom requests for your models using `custom_get`, `custom_post`, etc.
605
+
606
+ ```ruby
607
+ class User
608
+ include Her::Model
609
+
610
+ custom_get :popular, :unpopular
611
+ custom_post :from_default
612
+ end
613
+
614
+ User.popular
615
+ # GET "/users/popular"
616
+ # => [#<User id=1>, #<User id=2>]
617
+
618
+ User.unpopular
619
+ # GET "/users/unpopular"
620
+ # => [#<User id=3>, #<User id=4>]
621
+
622
+ User.from_default(name: "Maeby Fünke")
623
+ # POST "/users/from_default" with `name=Maeby+Fünke`
624
+ # => #<User id=5 name="Maeby Fünke">
625
+ ```
626
+
627
+ You can also use `get`, `post`, `put` or `delete` (which maps the returned data to either a collection or a resource).
628
+
629
+ ```ruby
630
+ class User
631
+ include Her::Model
632
+ end
633
+
634
+ User.get(:popular)
635
+ # GET "/users/popular"
636
+ # => [#<User id=1>, #<User id=2>]
637
+
638
+ User.get(:single_best)
639
+ # GET "/users/single_best"
640
+ # => #<User id=1>
641
+ ```
642
+
643
+ You can also use `get_raw` which yields the parsed data and the raw response from the HTTP request. Other HTTP methods are supported (`post_raw`, `put_raw`, etc.).
644
+
645
+ ```ruby
646
+ class User
647
+ include Her::Model
648
+
649
+ def self.total
650
+ get_raw(:stats) do |parsed_data, response|
651
+ parsed_data[:data][:total_users]
652
+ end
653
+ end
654
+ end
655
+
656
+ User.total
657
+ # GET "/users/stats"
658
+ # => 42
659
+ ```
660
+
661
+ You can also use full request paths (with strings instead of symbols).
662
+
663
+ ```ruby
664
+ class User
665
+ include Her::Model
666
+ end
667
+
668
+ User.get("/users/popular")
669
+ # GET "/users/popular"
670
+ # => [#<User id=1>, #<User id=2>]
671
+ ```
672
+
673
+ ### Custom paths
674
+
675
+ You can define custom HTTP paths for your models:
676
+
677
+ ```ruby
678
+ class User
679
+ include Her::Model
680
+ collection_path "/hello_users/:id"
681
+ end
682
+
683
+ @user = User.find(1)
684
+ # GET "/hello_users/1"
685
+ ```
686
+
687
+ You can also include custom variables in your paths:
688
+
689
+ ```ruby
690
+ class User
691
+ include Her::Model
692
+ collection_path "/organizations/:organization_id/users"
693
+ end
694
+
695
+ @user = User.find(1, _organization_id: 2)
696
+ # GET "/organizations/2/users/1"
697
+
698
+ @user = User.all(_organization_id: 2)
699
+ # GET "/organizations/2/users"
700
+
701
+ @user = User.new(fullname: "Tobias Fünke", organization_id: 2)
702
+ @user.save
703
+ # POST "/organizations/2/users" with `fullname=Tobias+Fünke`
704
+ ```
705
+
706
+ ### Custom primary keys
707
+
708
+ If your record uses an attribute other than `:id` to identify itself, specify it using the `primary_key` method:
709
+
710
+ ```ruby
711
+ class User
712
+ include Her::Model
713
+ primary_key :_id
714
+ end
715
+
716
+ user = User.find("4fd89a42ff204b03a905c535")
717
+ # GET "/users/4fd89a42ff204b03a905c535", response is { "_id": "4fd89a42ff204b03a905c535", "name": "Tobias" }
718
+
719
+ user.destroy
720
+ # DELETE "/users/4fd89a42ff204b03a905c535"
721
+ ```
722
+
723
+ ### Inheritance
724
+
725
+ If all your models share the same settings, you might want to make them children of a class and only include `Her::Model` in that class. However, there are a few settings that don’t get passed to the children classes:
726
+
727
+ * `root_element`
728
+ * `collection_path` and `resource_path`
729
+
730
+ Those settings are based on the class name, so you don’t have to redefine them each time you create a new children class (but you still can). Every other setting is inherited from the parent (associations, scopes, JSON settings, etc.).
731
+
732
+ ```ruby
733
+ module MyAPI
734
+ class Model
735
+ include Her::Model
736
+
737
+ parse_root_in_json true
738
+ include_root_in_json true
739
+ end
740
+ end
741
+
742
+ class User < MyAPI::Model
743
+ end
744
+
745
+ User.find(1)
746
+ # GET "/users/1"
747
+ ```
748
+
749
+ ### Scopes
750
+
751
+ Just like with ActiveRecord, you can define named scopes for your models. Scopes are chainable and can be used within other scopes.
752
+
753
+ ```ruby
754
+ class User
755
+ include Her::Model
756
+
757
+ scope :by_role, -> { |role| where(role: role) }
758
+ scope :admins, -> { by_role('admin') }
759
+ scope :active, -> { where(active: 1) }
760
+ end
761
+
762
+ @admins = User.admins
763
+ # GET "/users?role=admin"
764
+
765
+ @moderators = User.by_role('moderator')
766
+ # GET "/users?role=moderator"
767
+
768
+ @active_admins = User.active.admins # @admins.active would have worked here too
769
+ # GET "/users?role=admin&active=1"
770
+ ```
771
+
772
+ A neat trick you can do with scopes is interact with complex paths.
773
+
774
+ ```ruby
775
+ class User
776
+ include Her::Model
777
+
778
+ collection_path "organizations/:organization_id/users"
779
+ scope :for_organization, -> { |id| where(organization_id: id) }
780
+ end
781
+
782
+ @user = User.for_organization(3).find(2)
783
+ # GET "/organizations/3/users/2"
784
+
785
+ @user = User.for_organization(3).create(fullname: "Tobias Fünke")
786
+ # POST "/organizations/3" with `fullname=Tobias+Fünke`
787
+ ```
788
+
789
+ ### Multiple APIs
790
+
791
+ It is possible to use different APIs for different models. Instead of calling `Her::API.setup`, you can create instances of `Her::API`:
792
+
793
+ ```ruby
794
+ # config/initializers/her.rb
795
+ MY_API = Her::API.new
796
+ MY_API.setup url: "https://my-api.example.com" do |c|
797
+ # Response
798
+ c.use Her::Middleware::DefaultParseJSON
799
+
800
+ # Adapter
801
+ c.use Faraday::Adapter::NetHttp
802
+ end
803
+
804
+ OTHER_API = Her::API.new
805
+ OTHER_API.setup url: "https://other-api.example.com" do |c|
806
+ # Response
807
+ c.use Her::Middleware::DefaultParseJSON
808
+
809
+ # Adapter
810
+ c.use Faraday::Adapter::NetHttp
811
+ end
812
+ ```
813
+
814
+ You can then define which API a model will use:
815
+
816
+ ```ruby
817
+ class User
818
+ include Her::Model
819
+ use_api MY_API
820
+ end
821
+
822
+ class Category
823
+ include Her::Model
824
+ use_api OTHER_API
825
+ end
826
+
827
+ User.all
828
+ # GET "https://my-api.example.com/users"
829
+
830
+ Category.all
831
+ # GET "https://other-api.example.com/categories"
832
+ ```
833
+
834
+ ### SSL
835
+
836
+ When initializing `Her::API`, you can pass any parameter supported by `Faraday.new`. So [to use HTTPS](https://github.com/lostisland/faraday/wiki/Setting-up-SSL-certificates), you can use Faraday’s `:ssl` option.
837
+
838
+ ```ruby
839
+ ssl_options = { ca_path: "/usr/lib/ssl/certs" }
840
+ Her::API.setup url: "https://api.example.com", ssl: ssl_options do |c|
841
+ # Response
842
+ c.use Her::Middleware::DefaultParseJSON
843
+
844
+ # Adapter
845
+ c.use Faraday::Adapter::NetHttp
846
+ end
847
+ ```
848
+
849
+ ## Testing
850
+
851
+ Suppose we have these two models bound to your API:
852
+
853
+ ```ruby
854
+ # app/models/user.rb
855
+ class User
856
+ include Her::Model
857
+ custom_get :popular
858
+ end
859
+
860
+ # app/models/post.rb
861
+ class Post
862
+ include Her::Model
863
+ custom_get :recent, :archived
864
+ end
865
+ ```
866
+
867
+ In order to test them, we’ll have to stub the remote API requests. With [RSpec](https://github.com/rspec/rspec-core), we can do this like so:
868
+
869
+ ```ruby
870
+ # spec/spec_helper.rb
871
+ RSpec.configure do |config|
872
+ config.include(Module.new do
873
+ def stub_api_for(klass)
874
+ klass.use_api (api = Her::API.new)
875
+
876
+ # Here, you would customize this for your own API (URL, middleware, etc)
877
+ # like you have done in your application’s initializer
878
+ api.setup url: "http://api.example.com" do |c|
879
+ c.use Her::Middleware::FirstLevelParseJSON
880
+ c.adapter(:test) { |s| yield(s) }
881
+ end
882
+ end
883
+ end)
884
+ end
885
+ ```
886
+
887
+ Then, in your tests, we can specify what (fake) HTTP requests will return:
888
+
889
+ ```ruby
890
+ # spec/models/user.rb
891
+ describe User do
892
+ before do
893
+ stub_api_for(User) do |stub|
894
+ stub.get("/users/popular") { |env| [200, {}, [{ id: 1, name: "Tobias Fünke" }, { id: 2, name: "Lindsay Fünke" }].to_json] }
895
+ end
896
+ end
897
+
898
+ describe :popular do
899
+ subject { User.popular }
900
+ its(:length) { should == 2 }
901
+ its(:errors) { should be_empty }
902
+ end
903
+ end
904
+ ```
905
+
906
+ We can redefine the API for a model as many times as we want, like for more complex tests:
907
+
908
+ ```ruby
909
+ # spec/models/user.rb
910
+ describe Post do
911
+ describe :recent do
912
+ before do
913
+ stub_api_for(Post) do |stub|
914
+ stub.get("/posts/recent") { |env| [200, {}, [{ id: 1 }, { id: 2 }].to_json] }
915
+ end
916
+ end
917
+
918
+ subject { Post.recent }
919
+ its(:length) { should == 2 }
920
+ its(:errors) { should be_empty }
921
+ end
922
+
923
+ describe :archived do
924
+ before do
925
+ stub_api_for(Post) do |stub|
926
+ stub.get("/posts/archived") { |env| [200, {}, [{ id: 1 }, { id: 2 }].to_json] }
927
+ end
928
+ end
929
+
930
+ subject { Post.archived }
931
+ its(:length) { should == 2 }
932
+ its(:errors) { should be_empty }
933
+ end
934
+ end
935
+ ```
936
+
937
+ ## Upgrade
938
+
939
+ See the [UPGRADE.md](https://github.com/remiprev/her/blob/master/UPGRADE.md) for backward compatibility issues.
940
+
941
+ ## Her IRL
942
+
943
+ Most projects I know that use Her are internal or private projects but here’s a list of public ones:
944
+
945
+ * [tumbz](https://github.com/remiprev/tumbz)
946
+ * [crowdher](https://github.com/simonprev/crowdher)
947
+ * [vodka](https://github.com/magnolia-fan/vodka)
948
+ * [webistrano_cli](https://github.com/chytreg/webistrano_cli)
949
+ * [ASMALLWORLD](https://www.asmallworld.com)
950
+
951
+ ## History
952
+
953
+ I told myself a few months ago that it would be great to build a gem to replace Rails’ [ActiveResource](http://api.rubyonrails.org/classes/ActiveResource/Base.html) since it was barely maintained (and now removed from Rails 4.0), lacking features and hard to extend/customize. I had built a few of these REST-powered ORMs for client projects before but I decided I wanted to write one for myself that I could release as an open-source project.
954
+
955
+ Most of Her’s core concepts were written on a Saturday morning of April 2012 ([first commit](https://github.com/remiprev/her/commit/689d8e88916dc2ad258e69a2a91a283f061cbef2) at 7am!).
956
+
957
+ ## Contribute
958
+
959
+ Yes please! Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues). There’s no such thing as a bad pull request — even if it’s for a typo, a small improvement to the code or the documentation!
960
+
961
+ See [CONTRIBUTING.md](https://github.com/remiprev/her/blob/master/CONTRIBUTING.md) for best practices.
962
+
963
+ ### Contributors
964
+
965
+ These [fine folks](https://github.com/remiprev/her/contributors) helped with Her:
966
+
967
+ * [@jfcixmedia](https://github.com/jfcixmedia)
968
+ * [@EtienneLem](https://github.com/EtienneLem)
969
+ * [@rafaelss](https://github.com/rafaelss)
970
+ * [@tysontate](https://github.com/tysontate)
971
+ * [@nfo](https://github.com/nfo)
972
+ * [@simonprevost](https://github.com/simonprevost)
973
+ * [@jmlacroix](https://github.com/jmlacroix)
974
+ * [@thomsbg](https://github.com/thomsbg)
975
+ * [@calmyournerves](https://github.com/calmyournerves)
976
+ * [@luflux](https://github.com/luxflux)
977
+ * [@simonc](https://github.com/simonc)
978
+ * [@pencil](https://github.com/pencil)
979
+ * [@joanniclaborde](https://github.com/joanniclaborde)
980
+ * [@seanreads](https://github.com/seanreads)
981
+ * [@jonkarna](https://github.com/jonkarna)
982
+ * [@aclevy](https://github.com/aclevy)
983
+ * [@stevschmid](https://github.com/stevschmid)
984
+ * [@prognostikos](https://github.com/prognostikos)
985
+ * [@dturnerTS](https://github.com/dturnerTS)
986
+ * [@kritik](https://github.com/kritik)
987
+
988
+ ## License
989
+
990
+ Her is © 2012-2013 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [MIT license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file.