restorm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +4 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +31 -0
  5. data/.rubocop_todo.yml +232 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +55 -0
  8. data/.yardopts +2 -0
  9. data/CONTRIBUTING.md +26 -0
  10. data/Gemfile +10 -0
  11. data/HER_README.md +1065 -0
  12. data/LICENSE +7 -0
  13. data/README.md +7 -0
  14. data/Rakefile +11 -0
  15. data/UPGRADE.md +101 -0
  16. data/gemfiles/Gemfile.activemodel-4.2 +6 -0
  17. data/gemfiles/Gemfile.activemodel-5.0 +6 -0
  18. data/gemfiles/Gemfile.activemodel-5.1 +6 -0
  19. data/gemfiles/Gemfile.activemodel-5.2 +6 -0
  20. data/gemfiles/Gemfile.faraday-1.0 +6 -0
  21. data/lib/restorm/api.rb +121 -0
  22. data/lib/restorm/collection.rb +13 -0
  23. data/lib/restorm/errors.rb +29 -0
  24. data/lib/restorm/json_api/model.rb +42 -0
  25. data/lib/restorm/middleware/accept_json.rb +18 -0
  26. data/lib/restorm/middleware/first_level_parse_json.rb +37 -0
  27. data/lib/restorm/middleware/json_api_parser.rb +37 -0
  28. data/lib/restorm/middleware/parse_json.rb +22 -0
  29. data/lib/restorm/middleware/second_level_parse_json.rb +37 -0
  30. data/lib/restorm/middleware.rb +12 -0
  31. data/lib/restorm/model/associations/association.rb +128 -0
  32. data/lib/restorm/model/associations/association_proxy.rb +44 -0
  33. data/lib/restorm/model/associations/belongs_to_association.rb +95 -0
  34. data/lib/restorm/model/associations/has_many_association.rb +100 -0
  35. data/lib/restorm/model/associations/has_one_association.rb +79 -0
  36. data/lib/restorm/model/associations.rb +141 -0
  37. data/lib/restorm/model/attributes.rb +322 -0
  38. data/lib/restorm/model/base.rb +33 -0
  39. data/lib/restorm/model/deprecated_methods.rb +61 -0
  40. data/lib/restorm/model/http.rb +119 -0
  41. data/lib/restorm/model/introspection.rb +67 -0
  42. data/lib/restorm/model/nested_attributes.rb +45 -0
  43. data/lib/restorm/model/orm.rb +299 -0
  44. data/lib/restorm/model/parse.rb +223 -0
  45. data/lib/restorm/model/paths.rb +125 -0
  46. data/lib/restorm/model/relation.rb +209 -0
  47. data/lib/restorm/model.rb +75 -0
  48. data/lib/restorm/version.rb +3 -0
  49. data/lib/restorm.rb +19 -0
  50. data/restorm.gemspec +29 -0
  51. data/spec/api_spec.rb +120 -0
  52. data/spec/collection_spec.rb +41 -0
  53. data/spec/json_api/model_spec.rb +169 -0
  54. data/spec/middleware/accept_json_spec.rb +11 -0
  55. data/spec/middleware/first_level_parse_json_spec.rb +63 -0
  56. data/spec/middleware/json_api_parser_spec.rb +52 -0
  57. data/spec/middleware/second_level_parse_json_spec.rb +35 -0
  58. data/spec/model/associations/association_proxy_spec.rb +29 -0
  59. data/spec/model/associations_spec.rb +911 -0
  60. data/spec/model/attributes_spec.rb +354 -0
  61. data/spec/model/callbacks_spec.rb +176 -0
  62. data/spec/model/dirty_spec.rb +133 -0
  63. data/spec/model/http_spec.rb +201 -0
  64. data/spec/model/introspection_spec.rb +81 -0
  65. data/spec/model/nested_attributes_spec.rb +135 -0
  66. data/spec/model/orm_spec.rb +704 -0
  67. data/spec/model/parse_spec.rb +520 -0
  68. data/spec/model/paths_spec.rb +348 -0
  69. data/spec/model/relation_spec.rb +247 -0
  70. data/spec/model/validations_spec.rb +43 -0
  71. data/spec/model_spec.rb +45 -0
  72. data/spec/spec_helper.rb +25 -0
  73. data/spec/support/macros/her_macros.rb +17 -0
  74. data/spec/support/macros/model_macros.rb +36 -0
  75. data/spec/support/macros/request_macros.rb +27 -0
  76. metadata +203 -0
data/HER_README.md ADDED
@@ -0,0 +1,1065 @@
1
+
2
+ <p align="center">
3
+ <a href="https://github.com/remiprev/her">
4
+ <img src="http://i.imgur.com/43KEchq.png" alt="Her" />
5
+ </a>
6
+ <br />
7
+ Her 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.
8
+ <br /><br />
9
+ <a href="https://rubygems.org/gems/her"><img src="http://img.shields.io/gem/v/her.svg" /></a>
10
+ <a href="https://codeclimate.com/github/remiprev/her"><img src="http://img.shields.io/codeclimate/github/remiprev/her.svg" /></a>
11
+ <a href='https://gemnasium.com/remiprev/her'><img src="http://img.shields.io/gemnasium/remiprev/her.svg" /></a>
12
+ <a href="https://travis-ci.org/remiprev/her"><img src="http://img.shields.io/travis/remiprev/her/master.svg" /></a>
13
+ <a href="https://gitter.im/her-orm/Lobby"><img src="https://badges.gitter.im/her-orm/Lobby.png" alt="Gitter chat" title="" data-pin-nopin="true"></a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ In your Gemfile, add:
21
+
22
+ ```ruby
23
+ gem "her"
24
+ ```
25
+
26
+ That’s it!
27
+
28
+ ## Usage
29
+
30
+ _For a complete reference of all the methods you can use, check out [the documentation](http://rdoc.info/github/remiprev/her)._
31
+
32
+ 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:
33
+
34
+ ```ruby
35
+ # config/initializers/her.rb
36
+ Her::API.setup url: "https://api.example.com" do |c|
37
+ # Request
38
+ c.use Faraday::Request::UrlEncoded
39
+
40
+ # Response
41
+ c.use Her::Middleware::DefaultParseJSON
42
+
43
+ # Adapter
44
+ c.use Faraday::Adapter::NetHttp
45
+ end
46
+ ```
47
+
48
+ And then to add the ORM behavior to a class, you just have to include `Her::Model` in it:
49
+
50
+ ```ruby
51
+ class User
52
+ include Her::Model
53
+ end
54
+ ```
55
+
56
+ After that, using Her is very similar to many ActiveRecord-like ORMs:
57
+
58
+ ```ruby
59
+ User.all
60
+ # GET "https://api.example.com/users" and return an array of User objects
61
+
62
+ User.find(1)
63
+ # GET "https://api.example.com/users/1" and return a User object
64
+
65
+ @user = User.create(fullname: "Tobias Fünke")
66
+ # POST "https://api.example.com/users" with `fullname=Tobias+Fünke` and return the saved User object
67
+
68
+ @user = User.new(fullname: "Tobias Fünke")
69
+ @user.occupation = "actor"
70
+ @user.save
71
+ # POST "https://api.example.com/users" with `fullname=Tobias+Fünke&occupation=actor` and return the saved User object
72
+
73
+ @user = User.find(1)
74
+ @user.fullname = "Lindsay Fünke"
75
+ @user.save
76
+ # PUT "https://api.example.com/users/1" with `fullname=Lindsay+Fünke` and return the updated User object
77
+ ```
78
+
79
+ ### ActiveRecord-like methods
80
+
81
+ These are the basic ActiveRecord-like methods you can use with your models:
82
+
83
+ ```ruby
84
+ class User
85
+ include Her::Model
86
+ end
87
+
88
+ # Update a fetched resource
89
+ user = User.find(1)
90
+ user.fullname = "Lindsay Fünke" # OR user.assign_attributes(fullname: "Lindsay Fünke")
91
+ user.save # returns false if it fails, errors in user.response_errors array
92
+ # PUT "/users/1" with `fullname=Lindsay+Fünke`
93
+
94
+ user.update_attributes(fullname: "Maeby Fünke")
95
+ # PUT "/users/1" with `fullname=Maeby+Fünke`
96
+
97
+ # => PUT /users/1 { "id": 1, "name": "new new name" }
98
+ # Update a resource without fetching it
99
+ User.save_existing(1, fullname: "Lindsay Fünke")
100
+ # PUT "/users/1" with `fullname=Lindsay+Fünke`
101
+
102
+ # Destroy a fetched resource
103
+ user = User.find(1)
104
+ user.destroy
105
+ # DELETE "/users/1"
106
+
107
+ # Destroy a resource without fetching it
108
+ User.destroy_existing(1)
109
+ # DELETE "/users/1"
110
+
111
+ # Fetching a collection of resources
112
+ User.all
113
+ # GET "/users"
114
+ User.where(moderator: 1).all
115
+ # GET "/users?moderator=1"
116
+
117
+ # Create a new resource
118
+ User.create(fullname: "Maeby Fünke")
119
+ # POST "/users" with `fullname=Maeby+Fünke`
120
+
121
+ # Save a new resource
122
+ user = User.new(fullname: "Maeby Fünke")
123
+ user.save! # raises Her::Errors::ResourceInvalid if it fails
124
+ # POST "/users" with `fullname=Maeby+Fünke`
125
+ ```
126
+
127
+ You can look into the [`her-example`](https://github.com/remiprev/her-example) repository for a sample application using Her.
128
+
129
+ ## Middleware
130
+
131
+ 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.
132
+
133
+ ### Authentication
134
+
135
+ 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.
136
+
137
+ 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:
138
+
139
+ ```ruby
140
+ # app/controllers/application_controller.rb
141
+ class ApplicationController < ActionController::Base
142
+ before_filter :set_user_api_token
143
+
144
+ protected
145
+ def set_user_api_token
146
+ RequestStore.store[:my_api_token] = current_user.api_token # or something similar based on `session`
147
+ end
148
+ end
149
+
150
+ # lib/my_token_authentication.rb
151
+ class MyTokenAuthentication < Faraday::Middleware
152
+ def call(env)
153
+ env[:request_headers]["X-API-Token"] = RequestStore.store[:my_api_token]
154
+ @app.call(env)
155
+ end
156
+ end
157
+
158
+ # config/initializers/her.rb
159
+ require "lib/my_token_authentication"
160
+
161
+ Her::API.setup url: "https://api.example.com" do |c|
162
+ # Request
163
+ c.use MyTokenAuthentication
164
+ c.use Faraday::Request::UrlEncoded
165
+
166
+ # Response
167
+ c.use Her::Middleware::DefaultParseJSON
168
+
169
+ # Adapter
170
+ c.use Faraday::Adapter::NetHttp
171
+ end
172
+ ```
173
+
174
+ Now, each HTTP request made by Her will have the `X-API-Token` header.
175
+
176
+ ### Basic Http Authentication
177
+ Her can use basic http auth by adding a line to your initializer
178
+
179
+ ```ruby
180
+ # config/initializers/her.rb
181
+ Her::API.setup url: "https://api.example.com" do |c|
182
+ # Request
183
+ c.use Faraday::Request::BasicAuthentication, 'myusername', 'mypassword'
184
+ c.use Faraday::Request::UrlEncoded
185
+
186
+ # Response
187
+ c.use Her::Middleware::DefaultParseJSON
188
+
189
+ # Adapter
190
+ c.use Faraday::Adapter::NetHttp
191
+ end
192
+ ```
193
+
194
+ ### OAuth
195
+
196
+ Using the `faraday_middleware` and `simple_oauth` gems, it’s fairly easy to use OAuth authentication with Her.
197
+
198
+ In your Gemfile:
199
+
200
+ ```ruby
201
+ gem "her"
202
+ gem "faraday_middleware"
203
+ gem "simple_oauth"
204
+ ```
205
+
206
+ In your Ruby code:
207
+
208
+ ```ruby
209
+ # Create an application on `https://dev.twitter.com/apps` to set these values
210
+ TWITTER_CREDENTIALS = {
211
+ consumer_key: "",
212
+ consumer_secret: "",
213
+ token: "",
214
+ token_secret: ""
215
+ }
216
+
217
+ Her::API.setup url: "https://api.twitter.com/1/" do |c|
218
+ # Request
219
+ c.use FaradayMiddleware::OAuth, TWITTER_CREDENTIALS
220
+
221
+ # Response
222
+ c.use Her::Middleware::DefaultParseJSON
223
+
224
+ # Adapter
225
+ c.use Faraday::Adapter::NetHttp
226
+ end
227
+
228
+ class Tweet
229
+ include Her::Model
230
+ end
231
+
232
+ @tweets = Tweet.get("/statuses/home_timeline.json")
233
+ ```
234
+
235
+ See the [*Authentication middleware section*](#authentication) for an example of how to pass different credentials based on the current user.
236
+
237
+ ### Parsing JSON data
238
+
239
+ By default, Her handles JSON data. It expects the resource/collection data to be returned at the first level.
240
+
241
+ ```javascript
242
+ // The response of GET /users/1
243
+ { "id" : 1, "name" : "Tobias Fünke" }
244
+
245
+ // The response of GET /users
246
+ [{ "id" : 1, "name" : "Tobias Fünke" }]
247
+ ```
248
+
249
+ 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**](#json-attributes-wrapping) section).
250
+
251
+ 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:
252
+
253
+ ```ruby
254
+ # Expects responses like:
255
+ #
256
+ # {
257
+ # "result": { "id": 1, "name": "Tobias Fünke" },
258
+ # "errors": []
259
+ # }
260
+ #
261
+ class MyCustomParser < Faraday::Response::Middleware
262
+ def on_complete(env)
263
+ json = MultiJson.load(env[:body], symbolize_keys: true)
264
+ env[:body] = {
265
+ data: json[:result],
266
+ errors: json[:errors],
267
+ metadata: json[:metadata]
268
+ }
269
+ end
270
+ end
271
+
272
+ Her::API.setup url: "https://api.example.com" do |c|
273
+ # Response
274
+ c.use MyCustomParser
275
+
276
+ # Adapter
277
+ c.use Faraday::Adapter::NetHttp
278
+ end
279
+ ```
280
+
281
+ ### Caching
282
+
283
+ Again, using the `faraday_middleware` and `memcached` gems makes it very easy to cache requests and responses.
284
+
285
+ In your Gemfile:
286
+
287
+ ```ruby
288
+ gem "her"
289
+ gem "faraday_middleware"
290
+ gem "memcached"
291
+ ```
292
+
293
+ In your Ruby code:
294
+
295
+ ```ruby
296
+ Her::API.setup url: "https://api.example.com" do |c|
297
+ # Request
298
+ c.use FaradayMiddleware::Caching, Memcached::Rails.new('127.0.0.1:11211')
299
+
300
+ # Response
301
+ c.use Her::Middleware::DefaultParseJSON
302
+
303
+ # Adapter
304
+ c.use Faraday::Adapter::NetHttp
305
+ end
306
+
307
+ class User
308
+ include Her::Model
309
+ end
310
+
311
+ @user = User.find(1)
312
+ # GET "/users/1"
313
+
314
+ @user = User.find(1)
315
+ # This request will be fetched from memcached
316
+ ```
317
+
318
+ ## Advanced Features
319
+
320
+ Here’s a list of several useful features available in Her.
321
+
322
+ ### Associations
323
+
324
+ Examples use this code:
325
+
326
+ ```ruby
327
+ class User
328
+ include Her::Model
329
+ has_many :comments
330
+ has_one :role
331
+ belongs_to :organization
332
+ end
333
+
334
+ class Comment
335
+ include Her::Model
336
+ end
337
+
338
+ class Role
339
+ include Her::Model
340
+ end
341
+
342
+ class Organization
343
+ include Her::Model
344
+ end
345
+ ```
346
+
347
+ #### Fetching data
348
+
349
+ You can define `has_many`, `has_one` and `belongs_to` associations in your models. The association data is handled in two different ways.
350
+
351
+ 1. If Her finds association data when parsing a resource, that data will be used to create the associated model objects on the resource.
352
+ 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).
353
+
354
+ 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:
355
+
356
+ ```ruby
357
+ @user = User.find(1)
358
+ # GET "/users/1", response is:
359
+ # {
360
+ # "id": 1,
361
+ # "name": "George Michael Bluth",
362
+ # "comments": [
363
+ # { "id": 1, "text": "Foo" },
364
+ # { "id": 2, "text": "Bar" }
365
+ # ],
366
+ # "role": { "id": 1, "name": "Admin" },
367
+ # "organization": { "id": 2, "name": "Bluth Company" }
368
+ # }
369
+
370
+ @user.comments
371
+ # => [#<Comment id=1 text="Foo">, #<Comment id=2 text="Bar">]
372
+
373
+ @user.role
374
+ # => #<Role id=1 name="Admin">
375
+
376
+ @user.organization
377
+ # => #<Organization id=2 name="Bluth Company">
378
+ ```
379
+
380
+ If there’s no association data in the resource, Her makes a HTTP request to retrieve the data.
381
+
382
+ ```ruby
383
+ @user = User.find(1)
384
+ # GET "/users/1", response is { "id": 1, "name": "George Michael Bluth", "organization_id": 2 }
385
+
386
+ # has_many association:
387
+ @user.comments
388
+ # GET "/users/1/comments"
389
+ # => [#<Comment id=1>, #<Comment id=2>]
390
+
391
+ @user.comments.where(approved: 1)
392
+ # GET "/users/1/comments?approved=1"
393
+ # => [#<Comment id=1>]
394
+
395
+ # has_one association:
396
+ @user.role
397
+ # GET "/users/1/role"
398
+ # => #<Role id=1>
399
+
400
+ # belongs_to association:
401
+ @user.organization
402
+ # (the organization id comes from :organization_id, by default)
403
+ # GET "/organizations/2"
404
+ # => #<Organization id=2>
405
+ ```
406
+
407
+ Subsequent calls to `#comments`, `#role` and `#organization` will not trigger extra HTTP requests and will return the cached objects.
408
+
409
+ #### Creating data
410
+
411
+ You can use the association methods to build new objects and save them.
412
+
413
+ ```ruby
414
+ @user = User.find(1)
415
+ @user.comments.build(body: "Just a draft")
416
+ # => [#<Comment body="Just a draft" user_id=1>]
417
+
418
+ @user.comments.create(body: "Hello world.", user_id: 1)
419
+ # POST "/comments" with `body=Hello+world.&user_id=1`
420
+ # => [#<Comment id=3 body="Hello world." user_id=1>]
421
+ ```
422
+
423
+ You can also explicitly request a new object via the API when using ``build``. This is useful if you're dealing with default attributes.
424
+
425
+ ```ruby
426
+ class Comment
427
+ include Her::Model
428
+ request_new_object_on_build true
429
+ end
430
+
431
+ @user = User.find(1)
432
+ @user.comments.build(body: "Just a draft")
433
+ # GET "/users/1/comments/new" with `body=Just+a+draft.`
434
+ # => [#<Comment id=nil body="Just a draft" archived=false user_id=1>]
435
+ ```
436
+
437
+ #### Notes about paths
438
+
439
+ Resources must always have all the required attributes to build their complete path. For example, if you have these models:
440
+
441
+ ```ruby
442
+ class User
443
+ include Her::Model
444
+ collection_path "organizations/:organization_id/users"
445
+ end
446
+
447
+ class Organization
448
+ include Her::Model
449
+ has_many :users
450
+ end
451
+ ```
452
+
453
+ Her expects all `User` resources to have an `:organization_id` (or `:_organization_id`) attribute. Otherwise, calling mostly all methods, like `User.all`, will throw an exception like this one:
454
+
455
+ ```ruby
456
+ Her::Errors::PathError: Missing :_organization_id parameter to build the request path. Path is `organizations/:organization_id/users`. Parameters are `{ … }`.
457
+ ```
458
+
459
+ #### Associations with custom attributes
460
+
461
+ Associations can also be made using custom attributes:
462
+
463
+ ```ruby
464
+ class User
465
+ include Her::Model
466
+ belongs_to :owns, class_name: "Organization"
467
+ end
468
+
469
+ class Organization
470
+ include Her::Model
471
+ has_many :owners, class_name: "User"
472
+ end
473
+ ```
474
+
475
+ ### Validations
476
+
477
+ Her includes `ActiveModel::Validations` so you can declare validations the same way you do in Rails.
478
+
479
+ However, validations must be triggered manually — they are not run, for example, when calling `#save` on an object, or `#create` on a model class.
480
+
481
+ ```ruby
482
+ class User
483
+ include Her::Model
484
+
485
+ attributes :fullname, :email
486
+ validates :fullname, presence: true
487
+ validates :email, presence: true
488
+ end
489
+
490
+ @user = User.new(fullname: "Tobias Fünke")
491
+ @user.valid? # => false
492
+
493
+ @user.save
494
+ # POST "/users" with `fullname=Tobias+Fünke` will still be called, even if the user is not valid
495
+ ```
496
+
497
+ ### Dirty attributes
498
+
499
+ Her includes `ActiveModel::Dirty` so you can keep track of the attributes that have changed in an object.
500
+
501
+ ```ruby
502
+ class User
503
+ include Her::Model
504
+
505
+ attributes :fullname, :email
506
+ end
507
+
508
+ @user = User.new(fullname: "Tobias Fünke")
509
+ @user.fullname_changed? # => true
510
+ @user.changes # => { :fullname => [nil, "Tobias Fünke"] }
511
+
512
+ @user.save
513
+ # POST "/users" with `fullname=Tobias+Fünke`
514
+
515
+ @user.fullname_changed? # => false
516
+ @user.changes # => {}
517
+ ```
518
+
519
+ To update only the modified attributes specify `:send_only_modified_attributes => true` in the setup.
520
+
521
+ ### Callbacks
522
+
523
+ You can add *before* and *after* callbacks to your models that are triggered on specific actions. You can use symbols or blocks.
524
+
525
+ ```ruby
526
+ class User
527
+ include Her::Model
528
+ before_save :set_internal_id
529
+ after_find { |u| u.fullname.upcase! }
530
+
531
+ def set_internal_id
532
+ self.internal_id = 42 # Will be passed in the HTTP request
533
+ end
534
+ end
535
+
536
+ @user = User.create(fullname: "Tobias Fünke")
537
+ # POST "/users" with `fullname=Tobias+Fünke&internal_id=42`
538
+
539
+ @user = User.find(1)
540
+ @user.fullname # => "TOBIAS FUNKE"
541
+ ```
542
+
543
+ The available callbacks are:
544
+
545
+ * `before_save`
546
+ * `before_create`
547
+ * `before_update`
548
+ * `before_destroy`
549
+ * `after_save`
550
+ * `after_create`
551
+ * `after_update`
552
+ * `after_destroy`
553
+ * `after_find`
554
+ * `after_initialize`
555
+
556
+ ### JSON attributes-wrapping
557
+
558
+ Her supports *sending* and *parsing* JSON data wrapped in a root element (to be compatible with Rails’ `include_root_in_json` setting), like so:
559
+
560
+ #### Sending
561
+
562
+ If you want to send all data to your API wrapped in a *root* element based on the model name.
563
+
564
+ ```ruby
565
+ class User
566
+ include Her::Model
567
+ include_root_in_json true
568
+ end
569
+
570
+ class Article
571
+ include Her::Model
572
+ include_root_in_json :post
573
+ end
574
+
575
+ User.create(fullname: "Tobias Fünke")
576
+ # POST "/users" with `user[fullname]=Tobias+Fünke`
577
+
578
+ Article.create(title: "Hello world.")
579
+ # POST "/articles" with `post[title]=Hello+world`
580
+ ```
581
+
582
+ #### Parsing
583
+
584
+ If the API returns data wrapped in a *root* element based on the model name.
585
+
586
+ ```ruby
587
+ class User
588
+ include Her::Model
589
+ parse_root_in_json true
590
+ end
591
+
592
+ class Article
593
+ include Her::Model
594
+ parse_root_in_json :post
595
+ end
596
+
597
+ user = User.create(fullname: "Tobias Fünke")
598
+ # POST "/users" with `fullname=Tobias+Fünke`, response is { "user": { "fullname": "Tobias Fünke" } }
599
+ user.fullname # => "Tobias Fünke"
600
+
601
+ article = Article.create(title: "Hello world.")
602
+ # POST "/articles" with `title=Hello+world.`, response is { "post": { "title": "Hello world." } }
603
+ article.title # => "Hello world."
604
+ ```
605
+
606
+ Of course, you can use both `include_root_in_json` and `parse_root_in_json` at the same time.
607
+
608
+ #### ActiveModel::Serializers support
609
+
610
+ If the API returns data in the default format used by the
611
+ [ActiveModel::Serializers](https://github.com/rails-api/active_model_serializers)
612
+ project you need to configure Her as follows:
613
+
614
+ ```ruby
615
+ class User
616
+ include Her::Model
617
+ parse_root_in_json true, format: :active_model_serializers
618
+ end
619
+
620
+ user = Users.find(1)
621
+ # GET "/users/1", response is { "user": { "id": 1, "fullname": "Lindsay Fünke" } }
622
+
623
+ users = Users.all
624
+ # GET "/users", response is { "users": [{ "id": 1, "fullname": "Lindsay Fünke" }, { "id": 1, "fullname": "Tobias Fünke" }] }
625
+ ```
626
+
627
+ #### JSON API support
628
+
629
+ To consume a JSON API 1.0 compliant service, it must return data in accordance with the [JSON API spec](http://jsonapi.org/). The general format
630
+ of the data is as follows:
631
+
632
+ ```json
633
+ { "data": {
634
+ "type": "developers",
635
+ "id": "6ab79c8c-ec5a-4426-ad38-8763bbede5a7",
636
+ "attributes": {
637
+ "language": "ruby",
638
+ "name": "avdi grimm",
639
+ }
640
+ }
641
+ ```
642
+
643
+ Then to setup your models:
644
+
645
+ ```ruby
646
+ class Contributor
647
+ include Her::JsonApi::Model
648
+
649
+ # defaults to demodulized, pluralized class name, e.g. contributors
650
+ type :developers
651
+ end
652
+ ```
653
+
654
+ Finally, you'll need to use the included JsonApiParser Her middleware:
655
+
656
+ ```ruby
657
+ Her::API.setup url: 'https://my_awesome_json_api_service' do |c|
658
+ # Request
659
+ c.use FaradayMiddleware::EncodeJson
660
+
661
+ # Response
662
+ c.use Her::Middleware::JsonApiParser
663
+
664
+ # Adapter
665
+ c.use Faraday::Adapter::NetHttp
666
+ end
667
+ ```
668
+
669
+ ### Custom requests
670
+
671
+ You can easily define custom requests for your models using `custom_get`, `custom_post`, etc.
672
+
673
+ ```ruby
674
+ class User
675
+ include Her::Model
676
+
677
+ custom_get :popular, :unpopular
678
+ custom_post :from_default, :activate
679
+ end
680
+
681
+ User.popular
682
+ # GET "/users/popular"
683
+ # => [#<User id=1>, #<User id=2>]
684
+
685
+ User.unpopular
686
+ # GET "/users/unpopular"
687
+ # => [#<User id=3>, #<User id=4>]
688
+
689
+ User.from_default(name: "Maeby Fünke")
690
+ # POST "/users/from_default" with `name=Maeby+Fünke`
691
+ # => #<User id=5 name="Maeby Fünke">
692
+
693
+ User.activate(id: 6)
694
+ # POST "/users/6/activate"
695
+ # => #<User id=6>
696
+ ```
697
+
698
+ You can also use `get`, `post`, `put` or `delete` (which maps the returned data to either a collection or a resource).
699
+
700
+ ```ruby
701
+ class User
702
+ include Her::Model
703
+ end
704
+
705
+ User.get(:popular)
706
+ # GET "/users/popular"
707
+ # => [#<User id=1>, #<User id=2>]
708
+
709
+ User.get(:single_best)
710
+ # GET "/users/single_best"
711
+ # => #<User id=1>
712
+ ```
713
+
714
+ 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.).
715
+
716
+ ```ruby
717
+ class User
718
+ include Her::Model
719
+
720
+ def self.total
721
+ get_raw(:stats) do |parsed_data, response|
722
+ parsed_data[:data][:total_users]
723
+ end
724
+ end
725
+ end
726
+
727
+ User.total
728
+ # GET "/users/stats"
729
+ # => 42
730
+ ```
731
+
732
+ You can also use full request paths (with strings instead of symbols).
733
+
734
+ ```ruby
735
+ class User
736
+ include Her::Model
737
+ end
738
+
739
+ User.get("/users/popular")
740
+ # GET "/users/popular"
741
+ # => [#<User id=1>, #<User id=2>]
742
+ ```
743
+
744
+ ### Custom paths
745
+
746
+ You can define custom HTTP paths for your models:
747
+
748
+ ```ruby
749
+ class User
750
+ include Her::Model
751
+ collection_path "/hello_users/:id"
752
+ end
753
+
754
+ @user = User.find(1)
755
+ # GET "/hello_users/1"
756
+ ```
757
+
758
+ You can also include custom variables in your paths:
759
+
760
+ ```ruby
761
+ class User
762
+ include Her::Model
763
+ collection_path "/organizations/:organization_id/users"
764
+ end
765
+
766
+ @user = User.find(1, _organization_id: 2)
767
+ # GET "/organizations/2/users/1"
768
+
769
+ @user = User.all(_organization_id: 2)
770
+ # GET "/organizations/2/users"
771
+
772
+ @user = User.new(fullname: "Tobias Fünke", organization_id: 2)
773
+ @user.save
774
+ # POST "/organizations/2/users" with `fullname=Tobias+Fünke`
775
+ ```
776
+
777
+ ### Custom primary keys
778
+
779
+ If your record uses an attribute other than `:id` to identify itself, specify it using the `primary_key` method:
780
+
781
+ ```ruby
782
+ class User
783
+ include Her::Model
784
+ primary_key :_id
785
+ end
786
+
787
+ user = User.find("4fd89a42ff204b03a905c535")
788
+ # GET "/users/4fd89a42ff204b03a905c535", response is { "_id": "4fd89a42ff204b03a905c535", "name": "Tobias" }
789
+
790
+ user.destroy
791
+ # DELETE "/users/4fd89a42ff204b03a905c535"
792
+ ```
793
+
794
+ ### Inheritance
795
+
796
+ 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:
797
+
798
+ * `root_element`
799
+ * `collection_path` and `resource_path`
800
+
801
+ 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.).
802
+
803
+ ```ruby
804
+ module MyAPI
805
+ class Model
806
+ include Her::Model
807
+
808
+ parse_root_in_json true
809
+ include_root_in_json true
810
+ end
811
+ end
812
+
813
+ class User < MyAPI::Model
814
+ end
815
+
816
+ User.find(1)
817
+ # GET "/users/1"
818
+ ```
819
+
820
+ ### Scopes
821
+
822
+ Just like with ActiveRecord, you can define named scopes for your models. Scopes are chainable and can be used within other scopes.
823
+
824
+ ```ruby
825
+ class User
826
+ include Her::Model
827
+
828
+ scope :by_role, ->(role) { where(role: role) }
829
+ scope :admins, -> { by_role('admin') }
830
+ scope :active, -> { where(active: 1) }
831
+ end
832
+
833
+ @admins = User.admins
834
+ # GET "/users?role=admin"
835
+
836
+ @moderators = User.by_role('moderator')
837
+ # GET "/users?role=moderator"
838
+
839
+ @active_admins = User.active.admins # @admins.active would have worked here too
840
+ # GET "/users?role=admin&active=1"
841
+ ```
842
+
843
+ A neat trick you can do with scopes is interact with complex paths.
844
+
845
+ ```ruby
846
+ class User
847
+ include Her::Model
848
+
849
+ collection_path "organizations/:organization_id/users"
850
+ scope :for_organization, ->(id) { where(organization_id: id) }
851
+ end
852
+
853
+ @user = User.for_organization(3).find(2)
854
+ # GET "/organizations/3/users/2"
855
+
856
+ @user = User.for_organization(3).create(fullname: "Tobias Fünke")
857
+ # POST "/organizations/3" with `fullname=Tobias+Fünke`
858
+ ```
859
+
860
+ ### Multiple APIs
861
+
862
+ It is possible to use different APIs for different models. Instead of calling `Her::API.setup`, you can create instances of `Her::API`:
863
+
864
+ ```ruby
865
+ # config/initializers/her.rb
866
+ MY_API = Her::API.new
867
+ MY_API.setup url: "https://my-api.example.com" do |c|
868
+ # Response
869
+ c.use Her::Middleware::DefaultParseJSON
870
+
871
+ # Adapter
872
+ c.use Faraday::Adapter::NetHttp
873
+ end
874
+
875
+ OTHER_API = Her::API.new
876
+ OTHER_API.setup url: "https://other-api.example.com" do |c|
877
+ # Response
878
+ c.use Her::Middleware::DefaultParseJSON
879
+
880
+ # Adapter
881
+ c.use Faraday::Adapter::NetHttp
882
+ end
883
+ ```
884
+
885
+ You can then define which API a model will use:
886
+
887
+ ```ruby
888
+ class User
889
+ include Her::Model
890
+ use_api MY_API
891
+ end
892
+
893
+ class Category
894
+ include Her::Model
895
+ use_api OTHER_API
896
+ end
897
+
898
+ User.all
899
+ # GET "https://my-api.example.com/users"
900
+
901
+ Category.all
902
+ # GET "https://other-api.example.com/categories"
903
+ ```
904
+
905
+ ### SSL
906
+
907
+ 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.
908
+
909
+ ```ruby
910
+ ssl_options = { ca_path: "/usr/lib/ssl/certs" }
911
+ Her::API.setup url: "https://api.example.com", ssl: ssl_options do |c|
912
+ # Response
913
+ c.use Her::Middleware::DefaultParseJSON
914
+
915
+ # Adapter
916
+ c.use Faraday::Adapter::NetHttp
917
+ end
918
+ ```
919
+
920
+ ## Testing
921
+
922
+ Suppose we have these two models bound to your API:
923
+
924
+ ```ruby
925
+ # app/models/user.rb
926
+ class User
927
+ include Her::Model
928
+ custom_get :popular
929
+ end
930
+
931
+ # app/models/post.rb
932
+ class Post
933
+ include Her::Model
934
+ custom_get :recent, :archived
935
+ end
936
+ ```
937
+
938
+ 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:
939
+
940
+ ```ruby
941
+ # spec/spec_helper.rb
942
+ RSpec.configure do |config|
943
+ config.include(Module.new do
944
+ def stub_api_for(klass)
945
+ klass.use_api (api = Her::API.new)
946
+
947
+ # Here, you would customize this for your own API (URL, middleware, etc)
948
+ # like you have done in your application’s initializer
949
+ api.setup url: "http://api.example.com" do |c|
950
+ c.use Her::Middleware::FirstLevelParseJSON
951
+ c.adapter(:test) { |s| yield(s) }
952
+ end
953
+ end
954
+ end)
955
+ end
956
+ ```
957
+
958
+ Then, in your tests, we can specify what (fake) HTTP requests will return:
959
+
960
+ ```ruby
961
+ # spec/models/user.rb
962
+ describe User do
963
+ before do
964
+ stub_api_for(User) do |stub|
965
+ stub.get("/users/popular") { |env| [200, {}, [{ id: 1, name: "Tobias Fünke" }, { id: 2, name: "Lindsay Fünke" }].to_json] }
966
+ end
967
+ end
968
+
969
+ describe :popular do
970
+ subject { User.popular }
971
+ its(:length) { should == 2 }
972
+ its(:errors) { should be_empty }
973
+ end
974
+ end
975
+ ```
976
+
977
+ We can redefine the API for a model as many times as we want, like for more complex tests:
978
+
979
+ ```ruby
980
+ # spec/models/user.rb
981
+ describe Post do
982
+ describe :recent do
983
+ before do
984
+ stub_api_for(Post) do |stub|
985
+ stub.get("/posts/recent") { |env| [200, {}, [{ id: 1 }, { id: 2 }].to_json] }
986
+ end
987
+ end
988
+
989
+ subject { Post.recent }
990
+ its(:length) { should == 2 }
991
+ its(:errors) { should be_empty }
992
+ end
993
+
994
+ describe :archived do
995
+ before do
996
+ stub_api_for(Post) do |stub|
997
+ stub.get("/posts/archived") { |env| [200, {}, [{ id: 1 }, { id: 2 }].to_json] }
998
+ end
999
+ end
1000
+
1001
+ subject { Post.archived }
1002
+ its(:length) { should == 2 }
1003
+ its(:errors) { should be_empty }
1004
+ end
1005
+ end
1006
+ ```
1007
+
1008
+ ## Upgrade
1009
+
1010
+ See the [UPGRADE.md](https://github.com/remiprev/her/blob/master/UPGRADE.md) for backward compatibility issues.
1011
+
1012
+ ## Her IRL
1013
+
1014
+ Most projects I know that use Her are internal or private projects but here’s a list of public ones:
1015
+
1016
+ * [tumbz](https://github.com/remiprev/tumbz)
1017
+ * [zoho-ruby](https://github.com/errorstudio/zoho-ruby)
1018
+ * [crowdher](https://github.com/simonprev/crowdher)
1019
+ * [vodka](https://github.com/magnolia-fan/vodka)
1020
+ * [webistrano_cli](https://github.com/chytreg/webistrano_cli)
1021
+ * [ASMALLWORLD](https://www.asmallworld.com)
1022
+
1023
+ ## History
1024
+
1025
+ 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.
1026
+
1027
+ 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!).
1028
+
1029
+ ## Maintainers
1030
+ The gem is currently maintained by [@zacharywelch](https://github.com/zacharywelch) and [@edtjones](https://github.com/edtjones).
1031
+
1032
+ ## Contribute
1033
+
1034
+ 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!
1035
+
1036
+ See [CONTRIBUTING.md](https://github.com/remiprev/her/blob/master/CONTRIBUTING.md) for best practices.
1037
+
1038
+ ### Contributors
1039
+
1040
+ These [fine folks](https://github.com/remiprev/her/contributors) helped with Her:
1041
+
1042
+ * [@jfcixmedia](https://github.com/jfcixmedia)
1043
+ * [@EtienneLem](https://github.com/EtienneLem)
1044
+ * [@rafaelss](https://github.com/rafaelss)
1045
+ * [@tysontate](https://github.com/tysontate)
1046
+ * [@nfo](https://github.com/nfo)
1047
+ * [@simonprevost](https://github.com/simonprevost)
1048
+ * [@jmlacroix](https://github.com/jmlacroix)
1049
+ * [@thomsbg](https://github.com/thomsbg)
1050
+ * [@calmyournerves](https://github.com/calmyournerves)
1051
+ * [@luflux](https://github.com/luxflux)
1052
+ * [@simonc](https://github.com/simonc)
1053
+ * [@pencil](https://github.com/pencil)
1054
+ * [@joanniclaborde](https://github.com/joanniclaborde)
1055
+ * [@seanreads](https://github.com/seanreads)
1056
+ * [@jonkarna](https://github.com/jonkarna)
1057
+ * [@aclevy](https://github.com/aclevy)
1058
+ * [@stevschmid](https://github.com/stevschmid)
1059
+ * [@prognostikos](https://github.com/prognostikos)
1060
+ * [@dturnerTS](https://github.com/dturnerTS)
1061
+ * [@kritik](https://github.com/kritik)
1062
+
1063
+ ## License
1064
+
1065
+ 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.