her 0.1.1 → 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ Copyright 2012 Rémi Prévost.
2
+ You may use this work without restrictions, as long as this notice is included.
3
+ The work is provided "as is" without warranty of any kind, neither express nor implied.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Build Status](https://secure.travis-ci.org/remiprev/her.png)](http://travis-ci.org/remiprev/her)
4
4
 
5
- Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects. It is designed to build applications that are powered by a RESTful API.
5
+ Her is an ORM (Object Relational Mapper) that maps REST resources to Ruby objects. It is designed to build applications that are powered by a RESTful API and no database.
6
6
 
7
7
  ## Installation
8
8
 
@@ -19,6 +19,7 @@ That’s it!
19
19
  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 this line:
20
20
 
21
21
  ```ruby
22
+ # config/initializers/her.rb
22
23
  Her::API.setup :base_uri => "https://api.example.com"
23
24
  ```
24
25
 
@@ -33,15 +34,82 @@ end
33
34
  After that, using Her is very similar to many ActiveModel-like ORMs:
34
35
 
35
36
  ```ruby
36
- User.all # => Fetches "https://api.example.com/users" and return an array of User objects
37
- User.find(1) # => Fetches "https://api.example.com/users/1" and return a User object
37
+ User.all
38
+ # GET https://api.example.com/users and return an array of User objects
39
+
40
+ User.find(1)
41
+ # GET https://api.example.com/users/1 and return a User object
42
+
43
+ @user = User.create(:fullname => "Tobias Fünke")
44
+ # POST "https://api.example.com/users" with the data and return a User object
45
+
46
+ @user = User.new(:fullname => "Tobias Fünke")
47
+ @user.occupation = "actor"
48
+ @user.save
49
+ # POST https://api.example.com/users with the data and return a User object
50
+
51
+ @user = User.find(1)
52
+ @user.fullname = "Lindsay Fünke"
53
+ @user.save
54
+ # PUT https://api.example.com/users/1 with the data and return+update the User object
55
+ ```
56
+
57
+ ## Parsing data
58
+
59
+ By default, Her handles JSON data. It expects the data to be formatted in a certain structure. The default is this:
60
+
61
+ ```javascript
62
+ // The response of GET /users/1
63
+ {
64
+ "data" : {
65
+ "id" : 1,
66
+ "name" : "Tobias Fünke"
67
+ }
68
+ }
69
+
70
+ // The response of GET /users
71
+ {
72
+ "data" : [
73
+ {
74
+ "id" : 1,
75
+ "name" : "Tobias Fünke"
76
+ },
77
+ {
78
+ "id" : 2,
79
+ "name" : "Lindsay Fünke"
80
+ }
81
+ ],
82
+ "metadata" : {
83
+ "page" : 1,
84
+ "per_page" : 10
85
+ }
86
+ }
87
+ ```
88
+
89
+ However, you can define your own parsing method, with `Her::API.parse_with`. The `parse_with` method takes a block which will be executed each time data from an HTTP response needs to be parsed. The block is expected to return a hash with three keys: `data`, `errors` and `metadata`. The following code enables parsing JSON data and treating this data as first-level properties:
90
+
91
+ ```ruby
92
+ Her::API.setup :base_uri => "https://api.example.com"
93
+ Her::API.parse_with |response|
94
+ json = JSON.parse(response.body, :symbolize_names => true)
95
+ errors = json.delete(:errors)
96
+ {
97
+ :data => json,
98
+ :errors => errors || [],
99
+ :metadata => {}
100
+ }
101
+ end
102
+
103
+ # User.find(1) will now expect "https://api.example.com/users/1" to return something like '{ "id": 1, "name": "Tobias Fünke" }'
38
104
  ```
39
105
 
106
+ This feature is not stable and might change in the future, probably by using a middleware through [Faraday](https://github.com/technoweenie/faraday).
107
+
40
108
  ## Relationships
41
109
 
42
- You can define `has_many` relationships in your models. The relationship data is handled in two different ways. When parsing a resource from JSON data, if there’s a relationship data included, it will be used to create new Ruby objects.
110
+ You can define `has_many`, `has_one` and `belongs_to` relationships in your models. The relationship data is handled in two different ways. When parsing a resource from JSON data, if there’s a relationship data included, it will be used to create new Ruby objects.
43
111
 
44
- If no relationship data was included when parsing a resource, calling a method with the same name as the relationship will fetch the data (providing there’s an HTTP request available for it).
112
+ If no relationship data was included when parsing a resource, calling a method with the same name as the relationship will fetch the data (providing there’s an HTTP request available for it in the API).
45
113
 
46
114
  For example, with this setup:
47
115
 
@@ -49,32 +117,58 @@ For example, with this setup:
49
117
  class User
50
118
  include Her::Model
51
119
  has_many :comments
120
+ has_one :role
121
+ belongs_to :organization
52
122
  end
53
123
 
54
124
  class Comment
55
125
  include Her::Model
56
126
  end
127
+
128
+ class Role
129
+ include Her::Model
130
+ end
131
+
132
+ class Organization
133
+ include Her::Model
134
+ end
57
135
  ```
58
136
 
59
- Including relationship data in the resource, no extra HTTP request is made when calling the `#comments` method:
137
+ If there’s relationship data in the resource, no extra HTTP request is made when calling the `#comments` method and an array of resources are returned:
60
138
 
61
139
  ```ruby
62
- @user = User.find(1) # { :data => { :id => 1, :name => "Rémi Prévost", :comments => [{ :id => 1, :text => "Foo" }, { :id => 2, :text => "Bar" }] }}
140
+ @user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth", :comments => [{ :id => 1, :text => "Foo" }, { :id => 2, :text => "Bar" }], :role => { :id => 1, :name => "Admin" }, :organization => { :id => 2, :name => "Bluth Company" } }}
63
141
  @user.comments # => [#<Comment id=1>, #<Comment id=2>] fetched directly from @user
142
+ @user.role # => #<Role id=1> fetched directly from @user
143
+ @user.organization # => #<Organization id=2> fetched directly from @user
64
144
  ```
65
145
 
66
146
  If there’s no relationship data in the resource, an extra HTTP request (to `GET /users/1/comments`) is made when calling the `#comments` method:
67
147
 
68
148
  ```ruby
69
- @user = User.find(1) # { :data => { :id => 1, :name => "Rémi Prévost" }}
149
+ @user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth" }}
70
150
  @user.comments # => [#<Comment id=1>, #<Comment id=2>] fetched from /users/1/comments
71
151
  ```
72
152
 
73
- However, subsequent calls to `#comments` will not trigger the extra HTTP request.
153
+ For `has_one` relationship, an extra HTTP request (to `GET /users/1/role`) is made when calling the `#role` method:
154
+
155
+ ```ruby
156
+ @user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth" }}
157
+ @user.role # => #<Role id=1> fetched from /users/1/role
158
+ ```
159
+
160
+ For `belongs_to` relationship, an extra HTTP request (to `GET /organizations/2`) is made when calling the `#organization` method:
161
+
162
+ ```ruby
163
+ @user = User.find(1) # { :data => { :id => 1, :name => "George Michael Bluth", :organization_id => 2 }}
164
+ @user.organization # => #<Organization id=2> fetched from /organizations/2
165
+ ```
166
+
167
+ However, subsequent calls to `#comments` or `#role` will not trigger the extra HTTP request.
74
168
 
75
169
  ## Custom requests
76
170
 
77
- You can easily add custom methods for your models. You can either use `get_collection` (which maps the returned data to a collection of resources), `get_resource` (which maps the returned data to a single resource) or `get_raw` (which yields the parsed data return from the HTTP request).
171
+ You can easily add custom methods for your models. You can either use `get_collection` (which maps the returned data to a collection of resources), `get_resource` (which maps the returned data to a single resource) or `get_raw` (which yields the parsed data return from the HTTP request). Other HTTP methods are supported (`post_raw`, `put_resource`, etc.)
78
172
 
79
173
  ```ruby
80
174
  class User
@@ -94,3 +188,55 @@ end
94
188
  User.popular # => [#<User id=1>, #<User id=2>]
95
189
  User.total # => 42
96
190
  ```
191
+
192
+ ## Multiple APIs
193
+
194
+ It is possible to use different APIs for different models. Instead of calling `Her::API.setup`, you can create instances of `Her::API`:
195
+
196
+ ```ruby
197
+ # config/initializers/her.rb
198
+ $my_api = Her::API.new
199
+ $my_api.setup :base_uri => "https://my_api.example.com"
200
+
201
+ $other_api = Her::API.new
202
+ $other_api.setup :base_uri => "https://other_api.example.com"
203
+ ```
204
+
205
+ You can then define which API a model will use:
206
+
207
+ ```ruby
208
+ class User
209
+ include Her::Model
210
+ uses_api $my_api
211
+ end
212
+
213
+ class Category
214
+ include Her::Model
215
+ uses_api $other_api
216
+ end
217
+
218
+ User.all
219
+ # GET https://my_api.example.com/users
220
+
221
+ Category.all
222
+ # GET https://other_api.example.com/categories
223
+ ```
224
+
225
+ ## Things to be done
226
+
227
+ * Deleting resources
228
+ * Support for Faraday middleware to handle caching, alternative formats, etc.
229
+ * Hooks before save, update, create, destroy, etc.
230
+ * Better error handling
231
+ * Better introspection for debug
232
+ * Better documentation
233
+
234
+ ## Contributors
235
+
236
+ Feel free to contribute and submit issues/pull requests [on GitHub](https://github.com/remiprev/her/issues).
237
+
238
+ Take a look at the `spec` folder before you do, and make sure `bundle exec rake spec` passes after your modifications :)
239
+
240
+ ## License
241
+
242
+ Her is © 2012 [Rémi Prévost](http://exomel.com) and may be freely distributed under the [LITL license](https://github.com/remiprev/her/blob/master/LICENSE). See the `LICENSE` file.
data/lib/her/model.rb CHANGED
@@ -29,6 +29,7 @@ module Her
29
29
 
30
30
  # Define default settings
31
31
  collection_path "#{self.to_s.downcase.pluralize}"
32
+ item_path "#{self.to_s.downcase}"
32
33
  uses_api Her::API.default_api
33
34
  end
34
35
  end
@@ -19,6 +19,18 @@ module Her
19
19
  @her_collection_path = path
20
20
  end # }}}
21
21
 
22
+ # Defines a custom item path for the resource
23
+ #
24
+ # @example
25
+ # class User
26
+ # include Her::Model
27
+ # item_path "user"
28
+ # end
29
+ def item_path(path=nil) # {{{
30
+ return @her_item_path unless path
31
+ @her_item_path = path
32
+ end # }}}
33
+
22
34
  # Main request wrapper around Her::API. Used to make custom request to the API.
23
35
  # @private
24
36
  def request(attrs={}, &block) # {{{
data/lib/her/model/orm.rb CHANGED
@@ -9,15 +9,6 @@ module Her
9
9
  @data = self.class.parse_relationships(@data)
10
10
  end # }}}
11
11
 
12
- # Initialize a collection of resources with raw data from an HTTP request
13
- #
14
- # @example
15
- # User.get("/users/popular") { |data| User.new_collection(data) }
16
- def new_collection(parsed_data) # {{{
17
- collection_data = parsed_data[:data]
18
- Her::Model::ORM.initialize_collection(self.to_s.downcase.to_sym, collection_data)
19
- end # }}}
20
-
21
12
  # Initialize a collection of resources
22
13
  # @private
23
14
  def self.initialize_collection(name, collection_data) # {{{
@@ -40,6 +31,12 @@ module Her
40
31
  end
41
32
  end # }}}
42
33
 
34
+ # Initialize a collection of resources with raw data from an HTTP request
35
+ def new_collection(parsed_data) # {{{
36
+ collection_data = parsed_data[:data]
37
+ Her::Model::ORM.initialize_collection(self.to_s.downcase.to_sym, collection_data)
38
+ end # }}}
39
+
43
40
  # Fetch a specific resource based on an ID
44
41
  def find(id, params={}) # {{{
45
42
  request(params.merge(:_method => :get, :_path => "#{@her_collection_path}/#{id}")) do |parsed_data|
@@ -50,7 +47,7 @@ module Her
50
47
  # Fetch a collection of resources
51
48
  def all(params={}) # {{{
52
49
  request(params.merge(:_method => :get, :_path => "#{@her_collection_path}")) do |parsed_data|
53
- Her::Model::ORM.initialize_collection(to_s.downcase.pluralize, parsed_data[:data])
50
+ new_collection(parsed_data)
54
51
  end
55
52
  end # }}}
56
53
 
@@ -14,7 +14,15 @@ module Her
14
14
  @her_relationships ||= {}
15
15
  @her_relationships.each_pair do |type, relationships|
16
16
  relationships.each do |relationship|
17
- data[relationship[:name]] = Her::Model::ORM.initialize_collection(relationship[:name], data[relationship[:name]]) if data.include?(relationship[:name])
17
+ if data.include?(relationship[:name])
18
+ if type == :has_many
19
+ data[relationship[:name]] = Her::Model::ORM.initialize_collection(relationship[:name], data[relationship[:name]])
20
+ elsif type == :has_one
21
+ data[relationship[:name]] = Object.const_get(relationship[:name].to_s.classify).new(data[relationship[:name]])
22
+ elsif type == :belongs_to
23
+ data[relationship[:name]] = Object.const_get(relationship[:name].to_s.classify).new(data[relationship[:name]])
24
+ end
25
+ end
18
26
  end
19
27
  end
20
28
  data
@@ -24,9 +32,9 @@ module Her
24
32
  #
25
33
  # * `User.has_many :comments` is used to check if the "user" JSON
26
34
  # resource we receive has a `comments` key and map it to an array
27
- # of Comment.new objects
35
+ # of Comment objects
28
36
  # * `User.has_many :comments` creates a User.comments method to would
29
- # make an extra HTTP request if there was no "comments" key
37
+ # make an extra HTTP request (to `/users/:id/comments`) if there was no "comments" key
30
38
  def has_many(name, attrs={}) # {{{
31
39
  @her_relationships ||= {}
32
40
  (@her_relationships[:has_many] ||= []) << attrs.merge(:name => name)
@@ -38,18 +46,38 @@ module Her
38
46
  end
39
47
  end # }}}
40
48
 
41
- # Define a *belongs_to* relationship for the resource
49
+ # Define an *has_one* relationship for the resource
42
50
  #
43
- # * `User.belongs_to :organzation` is used to check if the "user" JSON
44
- # resource we receive has an `organzation` key and map it to
45
- # an Organization.new object
51
+ # * `User.has_one :category` is used to check if the "category" JSON
52
+ # resource we receive has a `category` key and map it to a Category object
53
+ # * `User.has_one :category` creates a User.category method to would
54
+ # make an extra HTTP request (to `/users/category`) if there was no "category" key
55
+ def has_one(name, attrs={}) # {{{
56
+ @her_relationships ||= {}
57
+ (@her_relationships[:has_one] ||= []) << attrs.merge(:name => name)
58
+ collection_path = @her_collection_path
59
+
60
+ define_method(name) do
61
+ return @data[name] if @data.include?(name) # Do not fetch from API again if we have it in @data
62
+ self.class.get_resource("#{collection_path}/#{id}/#{Object.const_get(name.to_s.classify).item_path}")
63
+ end
64
+ end # }}}
65
+
66
+ # Define a *belongs_to* relationship for the resource
46
67
  #
47
- # * `User.belongs_to :organzation` creates a User.organzation method
68
+ # * `User.belongs_to :organization` is used to check if the "organization" JSON
69
+ # resource we receive has a `organization` key and map it to an Organization object
70
+ # * `User.belongs_to :organization` creates a User.organization method to would
71
+ # make an extra HTTP request (to `/organizations/:organization_id`) if there was no "organization" key
48
72
  def belongs_to(name, attrs={}) # {{{
49
73
  @her_relationships ||= {}
50
74
  (@her_relationships[:belongs_to] ||= []) << attrs.merge(:name => name)
75
+ collection_path = @her_collection_path
51
76
 
52
- # TODO Write some code here
77
+ define_method(name) do
78
+ return @data[name] if @data.include?(name) # Do not fetch from API again if we have it in @data
79
+ self.class.get_resource("#{Object.const_get(name.to_s.classify).collection_path}/#{@data["#{name}_id".to_sym]}")
80
+ end
53
81
  end # }}}
54
82
  end
55
83
  end
data/lib/her/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Her
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.5"
3
3
  end
data/spec/model_spec.rb CHANGED
@@ -214,12 +214,12 @@ describe Her::Model do
214
214
  end
215
215
  end # }}}
216
216
 
217
- it "handle resource data update without saving it" do
217
+ it "handle resource data update without saving it" do # {{{
218
218
  @user = User.find(1)
219
219
  @user.fullname.should == "Tobias Fünke"
220
220
  @user.fullname = "Kittie Sanchez"
221
221
  @user.fullname.should == "Kittie Sanchez"
222
- end
222
+ end # }}}
223
223
 
224
224
  it "handle resource update through #save on an existing resource" do # {{{
225
225
  @user = User.find(1)
@@ -250,6 +250,17 @@ describe Her::Model do
250
250
  User.relationships[:has_many].should == [{ :name => :comments }, { :name => :posts }]
251
251
  end # }}}
252
252
 
253
+ it "handles a single 'has_one' relationship" do # {{{
254
+ User.has_one :category
255
+ User.relationships[:has_one].should == [{ :name => :category }]
256
+ end # }}}
257
+
258
+ it "handles multiples 'has_one' relationship" do # {{{
259
+ User.has_one :category
260
+ User.has_one :role
261
+ User.relationships[:has_one].should == [{ :name => :category }, { :name => :role }]
262
+ end # }}}
263
+
253
264
  it "handles a single belongs_to relationship" do # {{{
254
265
  User.belongs_to :organization
255
266
  User.relationships[:belongs_to].should == [{ :name => :organization }]
@@ -265,20 +276,34 @@ describe Her::Model do
265
276
  context "handling relationships" do
266
277
  before do # {{{
267
278
  Her::API.setup :base_uri => "https://api.example.com"
268
- FakeWeb.register_uri(:get, "https://api.example.com/users/1", :body => { :data => { :id => 1, :name => "Tobias Fünke", :comments => [{ :id => 2, :body => "Tobias, you blow hard!" }, { :id => 3, :body => "I wouldn't mind kissing that man between the cheeks, so to speak" }] } }.to_json)
269
- FakeWeb.register_uri(:get, "https://api.example.com/users/2", :body => { :data => { :id => 2, :name => "Lindsay Fünke" } }.to_json)
279
+ FakeWeb.register_uri(:get, "https://api.example.com/users/1", :body => { :data => { :id => 1, :name => "Tobias Fünke", :comments => [{ :id => 2, :body => "Tobias, you blow hard!" }, { :id => 3, :body => "I wouldn't mind kissing that man between the cheeks, so to speak" }], :role => { :id => 1, :body => "Admin" }, :organization => { :id => 1, :name => "Bluth Company" }, :organization_id => 1 } }.to_json)
280
+ FakeWeb.register_uri(:get, "https://api.example.com/users/2", :body => { :data => { :id => 2, :name => "Lindsay Fünke", :organization_id => 1 } }.to_json)
270
281
  FakeWeb.register_uri(:get, "https://api.example.com/users/2/comments", :body => { :data => [{ :id => 4, :body => "They're having a FIRESALE?" }, { :id => 5, :body => "Is this the tiny town from Footloose?" }] }.to_json)
282
+ FakeWeb.register_uri(:get, "https://api.example.com/users/2/role", :body => { :data => { :id => 2, :body => "User" } }.to_json)
283
+ FakeWeb.register_uri(:get, "https://api.example.com/organizations/1", :body => { :data => { :id => 1, :name => "Bluth Company" } }.to_json)
271
284
 
272
285
  Object.instance_eval { remove_const :User } if Object.const_defined?(:User)
273
286
  class User
274
287
  include Her::Model
275
288
  has_many :comments
289
+ has_one :role
290
+ belongs_to :organization
291
+ end
292
+
293
+ Object.instance_eval { remove_const :Organization } if Object.const_defined?(:Organization)
294
+ class Organization
295
+ include Her::Model
276
296
  end
277
297
 
278
298
  Object.instance_eval { remove_const :Comment } if Object.const_defined?(:Comment)
279
299
  class Comment
280
300
  include Her::Model
281
301
  end
302
+
303
+ Object.instance_eval { remove_const :Role } if Object.const_defined?(:Role)
304
+ class Role
305
+ include Her::Model
306
+ end
282
307
  end # }}}
283
308
 
284
309
  it "maps an array of included data" do # {{{
@@ -286,6 +311,12 @@ describe Her::Model do
286
311
  @user.comments.length.should == 2
287
312
  @user.comments.first.id.should == 2
288
313
  @user.comments.first.body.should == "Tobias, you blow hard!"
314
+
315
+ @user.role.id.should == 1
316
+ @user.role.body.should == "Admin"
317
+
318
+ @user.organization.id.should == 1
319
+ @user.organization.name.should == "Bluth Company"
289
320
  end # }}}
290
321
 
291
322
  it "fetches data that was not included" do # {{{
@@ -293,6 +324,12 @@ describe Her::Model do
293
324
  @user.comments.length.should == 2
294
325
  @user.comments.first.id.should == 4
295
326
  @user.comments.first.body.should == "They're having a FIRESALE?"
327
+
328
+ @user.role.id.should == 2
329
+ @user.role.body.should == "User"
330
+
331
+ @user.organization.id.should == 1
332
+ @user.organization.name.should == "Bluth Company"
296
333
  end # }}}
297
334
  end
298
335
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: her
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.5
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-04-09 00:00:00.000000000Z
12
+ date: 2012-04-11 00:00:00.000000000Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rake
@@ -165,6 +165,7 @@ files:
165
165
  - .gitignore
166
166
  - .travis.yml
167
167
  - Gemfile
168
+ - LICENSE
168
169
  - README.md
169
170
  - Rakefile
170
171
  - her.gemspec
@@ -193,7 +194,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
193
194
  version: '0'
194
195
  segments:
195
196
  - 0
196
- hash: -3007322587400233058
197
+ hash: -4167601811325738400
197
198
  required_rubygems_version: !ruby/object:Gem::Requirement
198
199
  none: false
199
200
  requirements:
@@ -202,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
202
203
  version: '0'
203
204
  segments:
204
205
  - 0
205
- hash: -3007322587400233058
206
+ hash: -4167601811325738400
206
207
  requirements: []
207
208
  rubyforge_project:
208
209
  rubygems_version: 1.8.18