hypermodel 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +44 -0
- data/Rakefile +5 -15
- data/lib/hypermodel/resource.rb +99 -0
- data/lib/hypermodel/responder.rb +15 -5
- data/lib/hypermodel/serializers/mongoid.rb +96 -39
- data/lib/hypermodel/version.rb +1 -1
- data/lib/hypermodel.rb +2 -1
- data/test/dummy/app/controllers/posts_controller.rb +2 -1
- data/test/dummy/app/models/blog.rb +6 -0
- data/test/dummy/app/models/post.rb +1 -0
- data/test/dummy/config/routes.rb +4 -2
- data/test/dummy/log/test.log +975 -0
- data/test/hypermodel_test.rb +10 -2
- metadata +8 -4
data/README.md
CHANGED
@@ -40,6 +40,50 @@ Now if you ask your API for a Post:
|
|
40
40
|
[{"_id"=>"4fb648996b98c9091900000d", "body"=>"Comment 1"},
|
41
41
|
{"_id"=>"4fb648996b98c9091900000e", "body"=>"Comment 2"}]}}
|
42
42
|
|
43
|
+
## Gotchas
|
44
|
+
|
45
|
+
These are some implementation gotchas which are welcome to be fixed if you can
|
46
|
+
think of workarounds :)
|
47
|
+
|
48
|
+
### Routes should reflect data model
|
49
|
+
|
50
|
+
For Hypermodel to generate `_links` correctly, the relationship/embedding
|
51
|
+
structure of the model must match exactly that of the app routing. That means,
|
52
|
+
that if you have `Blogs` that have many `Posts` which have many `Comments`,
|
53
|
+
the routes.rb file should reflect it:
|
54
|
+
|
55
|
+
````ruby
|
56
|
+
# config/routes.rb
|
57
|
+
resources :blogs do
|
58
|
+
resources :posts do
|
59
|
+
resources :comments
|
60
|
+
end
|
61
|
+
end
|
62
|
+
````
|
63
|
+
|
64
|
+
### Every resource controller must implement the :show action at least
|
65
|
+
|
66
|
+
Each resource and subresource must respond to the `show` action (so they can be
|
67
|
+
linked from anywhere, in the `_links` section).
|
68
|
+
|
69
|
+
### The first belongs_to decides the hierarchy chain
|
70
|
+
|
71
|
+
So if a `Post` belongs to an author and to a blog, and you want to access
|
72
|
+
posts through blogs, not authors, you have to put the `belongs_to :blog`
|
73
|
+
**before** `belongs_to :author`:
|
74
|
+
|
75
|
+
````ruby
|
76
|
+
# app/models/post.rb
|
77
|
+
class Post
|
78
|
+
include Mongoid::Document
|
79
|
+
|
80
|
+
belongs_to :blog
|
81
|
+
belongs_to :author
|
82
|
+
end
|
83
|
+
````
|
84
|
+
|
85
|
+
I know, lame.
|
86
|
+
|
43
87
|
## Contributing
|
44
88
|
|
45
89
|
* [List of hypermodel contributors][contributors]
|
data/Rakefile
CHANGED
@@ -4,25 +4,15 @@ begin
|
|
4
4
|
rescue LoadError
|
5
5
|
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
6
6
|
end
|
7
|
-
begin
|
8
|
-
require 'rdoc/task'
|
9
|
-
rescue LoadError
|
10
|
-
require 'rdoc/rdoc'
|
11
|
-
require 'rake/rdoctask'
|
12
|
-
RDoc::Task = Rake::RDocTask
|
13
|
-
end
|
14
7
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
rdoc.rdoc_files.include('lib/**/*.rb')
|
8
|
+
require 'yard'
|
9
|
+
YARD::Config.load_plugin('yard-tomdoc')
|
10
|
+
YARD::Rake::YardocTask.new do |t|
|
11
|
+
t.files = ['lib/**/*.rb']
|
12
|
+
t.options = %w(-r README.md)
|
21
13
|
end
|
22
14
|
|
23
15
|
|
24
|
-
|
25
|
-
|
26
16
|
Bundler::GemHelper.install_tasks
|
27
17
|
|
28
18
|
require 'rake/testtask'
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'hypermodel/serializers/mongoid'
|
2
|
+
|
3
|
+
module Hypermodel
|
4
|
+
# Public: Responsible for building the response in JSON-HAL format. It is
|
5
|
+
# meant to be used by Hypermodel::Responder.
|
6
|
+
#
|
7
|
+
# In future versions one will be able to subclass it and personalize a
|
8
|
+
# Resource for each diffent model, i.e. creating a PostResource.
|
9
|
+
class Resource
|
10
|
+
extend Forwardable
|
11
|
+
|
12
|
+
def_delegators :@serializer, :attributes, :record, :resources,
|
13
|
+
:sub_resources, :embedded_resources,
|
14
|
+
:embedding_resources
|
15
|
+
|
16
|
+
# Public: Recursive functino that traverses a record's referential
|
17
|
+
# hierarchy upwards.
|
18
|
+
#
|
19
|
+
# Returns a flattened Array with the hierarchy of records.
|
20
|
+
TraverseUpwards = lambda do |record|
|
21
|
+
serializer = Serializers::Mongoid.new(record)
|
22
|
+
|
23
|
+
parent_name, parent_resource = (
|
24
|
+
serializer.embedding_resources.first || serializer.resources.first
|
25
|
+
)
|
26
|
+
|
27
|
+
# If we have a parent
|
28
|
+
if parent_resource
|
29
|
+
# Recurse over parent hierarchies
|
30
|
+
[TraverseUpwards[parent_resource], record].flatten
|
31
|
+
else
|
32
|
+
# Final case, we are the topmost parent: return ourselves
|
33
|
+
[record]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Public: Initializes a Resource.
|
38
|
+
#
|
39
|
+
# record - A Mongoid instance of a model.
|
40
|
+
# controller - An ActionController instance.
|
41
|
+
#
|
42
|
+
# TODO: Detect record type (ActiveRecord, DataMapper, Mongoid, etc..) and
|
43
|
+
# choose the corresponding serializer.
|
44
|
+
def initialize(record, controller)
|
45
|
+
@record = record
|
46
|
+
@serializer = Serializers::Mongoid.new(record)
|
47
|
+
@controller = controller
|
48
|
+
end
|
49
|
+
|
50
|
+
# Public: Returns a Hash of the resource in JSON-HAL.
|
51
|
+
#
|
52
|
+
# opts - Options to pass to the resource to_json.
|
53
|
+
def to_json(*opts)
|
54
|
+
attributes.update(links).update(embedded).to_json(*opts)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Internal: Constructs the _links section of the response.
|
58
|
+
#
|
59
|
+
# Returns a Hash of the links of the resource. It will include, at least,
|
60
|
+
# a link to itself.
|
61
|
+
def links
|
62
|
+
_links = { self: polymorphic_url(record_with_ancestor_chain(@record)) }
|
63
|
+
|
64
|
+
resources.each do |name, resource|
|
65
|
+
_links.update(name => polymorphic_url(record_with_ancestor_chain(resource)))
|
66
|
+
end
|
67
|
+
|
68
|
+
sub_resources.each do |sub_resource|
|
69
|
+
_links.update(sub_resource => polymorphic_url(record_with_ancestor_chain(@record) << sub_resource))
|
70
|
+
end
|
71
|
+
|
72
|
+
{ _links: _links }
|
73
|
+
end
|
74
|
+
|
75
|
+
# Internal: Constructs the _embedded section of the response.
|
76
|
+
#
|
77
|
+
# Returns a Hash of the embedded resources of the resource.
|
78
|
+
def embedded
|
79
|
+
{ _embedded: embedded_resources }
|
80
|
+
end
|
81
|
+
|
82
|
+
# Internal: Returns the url wrapped in a Hash in HAL format.
|
83
|
+
def polymorphic_url(record_or_hash_or_array, options = {})
|
84
|
+
{ href: @controller.polymorphic_url(record_or_hash_or_array, options = {}) }
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
# Internal: Returns a flattened array of records representing the ancestor
|
90
|
+
# chain of a given record, including itself at the end.
|
91
|
+
#
|
92
|
+
# It is used to generate correct polymorphic URLs.
|
93
|
+
#
|
94
|
+
# record - the record whose ancestor chain we'd like to retrieve.
|
95
|
+
def record_with_ancestor_chain(record)
|
96
|
+
TraverseUpwards[record]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/hypermodel/responder.rb
CHANGED
@@ -1,8 +1,18 @@
|
|
1
|
-
require 'hypermodel/
|
1
|
+
require 'hypermodel/resource'
|
2
2
|
|
3
3
|
module Hypermodel
|
4
|
-
|
5
|
-
|
4
|
+
# Public: Responsible for exposing a resource in JSON-HAL format.
|
5
|
+
#
|
6
|
+
# Examples
|
7
|
+
#
|
8
|
+
# class PostsController < ApplicationController
|
9
|
+
# respond_to :json
|
10
|
+
#
|
11
|
+
# def show
|
12
|
+
# @post = Post.find params[:id]
|
13
|
+
# respond_with(@post, responder: Hypermodel::Responder)
|
14
|
+
# end
|
15
|
+
# end
|
6
16
|
class Responder
|
7
17
|
def self.call(*args)
|
8
18
|
controller = args[0]
|
@@ -15,10 +25,10 @@ module Hypermodel
|
|
15
25
|
controller.render json: responder
|
16
26
|
end
|
17
27
|
|
18
|
-
def initialize(resource_name, action,
|
28
|
+
def initialize(resource_name, action, record, controller)
|
19
29
|
@resource_name = resource_name
|
20
30
|
@action = action
|
21
|
-
@resource =
|
31
|
+
@resource = Resource.new(record, controller)
|
22
32
|
end
|
23
33
|
|
24
34
|
def to_json(*opts)
|
@@ -1,73 +1,130 @@
|
|
1
1
|
module Hypermodel
|
2
2
|
module Serializers
|
3
|
+
# Internal: A Mongoid serializer that complies with the Hypermodel
|
4
|
+
# Serializer API.
|
5
|
+
#
|
6
|
+
# It is used by Hypermodel::Resource to extract the attributes and
|
7
|
+
# resources of a given record.
|
3
8
|
class Mongoid
|
4
|
-
def initialize(resource, controller)
|
5
|
-
@resource = resource
|
6
|
-
@attributes = resource.attributes.dup
|
7
|
-
@controller = controller
|
8
|
-
end
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
10
|
+
# Public: Returns the Mongoid instance
|
11
|
+
attr_reader :record
|
12
|
+
|
13
|
+
# Public: Returns the attributes of the Mongoid instance
|
14
|
+
attr_reader :attributes
|
15
|
+
|
16
|
+
# Public: Initializes a Serializer::Mongoid.
|
17
|
+
#
|
18
|
+
# record - A Mongoid instance of a model.
|
19
|
+
def initialize(record)
|
20
|
+
@record = record
|
21
|
+
@attributes = record.attributes.dup
|
13
22
|
end
|
14
23
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
else
|
29
|
-
raise "Referenced relation type not implemented: #{relation}"
|
30
|
-
end
|
24
|
+
# Public: Returns a Hash with the resources that are linked to the
|
25
|
+
# record. It will be used by Hypermodel::Resource.
|
26
|
+
#
|
27
|
+
# An example of a linked resource could be the author of a post. Think
|
28
|
+
# of `/authors/:author_id`
|
29
|
+
#
|
30
|
+
# The format of the returned Hash must be the following:
|
31
|
+
#
|
32
|
+
# {resource_name: resource_instance}
|
33
|
+
#
|
34
|
+
# `resource_name` can be either a Symbol or a String.
|
35
|
+
def resources
|
36
|
+
relations = select_relations_by_type(::Mongoid::Relations::Referenced::In)
|
31
37
|
|
38
|
+
relations.inject({}) do |acc, (name, _)|
|
39
|
+
acc.update(name => @record.send(name))
|
32
40
|
end
|
41
|
+
end
|
33
42
|
|
34
|
-
|
43
|
+
# Public: Returns a Hash with the sub resources that are linked to the
|
44
|
+
# record. It will be used by Hypermodel::Resource. These resources need
|
45
|
+
# to be differentiated so Hypermodel::Resource can build the url.
|
46
|
+
#
|
47
|
+
# An example of a linked sub resource could be comments of a post.
|
48
|
+
# Think of `/posts/:id/comments`
|
49
|
+
#
|
50
|
+
# The format of the returned Hash must be the following:
|
51
|
+
#
|
52
|
+
# {:sub_resource, :another_subresource}
|
53
|
+
def sub_resources
|
54
|
+
select_relations_by_type(::Mongoid::Relations::Referenced::Many).keys
|
35
55
|
end
|
36
56
|
|
37
|
-
|
57
|
+
# Public: Returns a Hash with the embedded resources attributes. It will
|
58
|
+
# be used by Hypermodel::Resource.
|
59
|
+
#
|
60
|
+
# An example of an embedded resource could be the reviews of a post, or
|
61
|
+
# the addresses of a company. But you can really embed whatever you like.
|
62
|
+
#
|
63
|
+
# An example of the returning Hash could be the following:
|
64
|
+
#
|
65
|
+
# {"comments"=>
|
66
|
+
# [
|
67
|
+
# {"_id"=>"4fb941cb82b4d46162000007", "body"=>"Comment 1"},
|
68
|
+
# {"_id"=>"4fb941cb82b4d46162000008", "body"=>"Comment 2"}
|
69
|
+
# ]
|
70
|
+
# }
|
71
|
+
def embedded_resources
|
38
72
|
return {} if embedded_relations.empty?
|
39
73
|
|
40
|
-
embedded_relations.inject({
|
41
|
-
if attributes = extract_embedded_attributes(name
|
74
|
+
embedded_relations.inject({}) do |acc, (name, metadata)|
|
75
|
+
if attributes = extract_embedded_attributes(name)
|
42
76
|
@attributes.delete(name)
|
43
|
-
acc
|
77
|
+
acc.update(name => attributes)
|
44
78
|
end
|
45
79
|
acc
|
46
80
|
end
|
47
81
|
end
|
48
82
|
|
83
|
+
# Public: Returns a Hash with the resources that are embedding us (our
|
84
|
+
# immediate ancestors).
|
85
|
+
def embedding_resources
|
86
|
+
return {} if embedded_relations.empty?
|
87
|
+
|
88
|
+
embedded = select_embedded_by_type(::Mongoid::Relations::Embedded::In)
|
89
|
+
|
90
|
+
embedded.inject({}) do |acc, (name, _)|
|
91
|
+
acc.update(name => @record.send(name))
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
#######
|
49
96
|
private
|
97
|
+
#######
|
50
98
|
|
51
|
-
def
|
52
|
-
|
99
|
+
def select_relations_by_type(type)
|
100
|
+
referenced_relations.select do |name, metadata|
|
101
|
+
metadata.relation == type
|
102
|
+
end
|
103
|
+
end
|
53
104
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
(embedded = resource.send(name)) ? embedded.attributes : nil
|
58
|
-
else
|
59
|
-
raise "Embedded relation type not implemented: #{relation}"
|
105
|
+
def select_embedded_by_type(type)
|
106
|
+
embedded_relations.select do |name, metadata|
|
107
|
+
metadata.relation == type
|
60
108
|
end
|
61
109
|
end
|
62
110
|
|
111
|
+
def extract_embedded_attributes(name)
|
112
|
+
embedded = @record.send(name)
|
113
|
+
|
114
|
+
return {} unless embedded
|
115
|
+
return embedded.map(&:attributes) if embedded.respond_to?(:map)
|
116
|
+
|
117
|
+
embedded.attributes
|
118
|
+
end
|
119
|
+
|
63
120
|
def embedded_relations
|
64
|
-
@embedded_relations ||= @
|
121
|
+
@embedded_relations ||= @record.relations.select do |_, metadata|
|
65
122
|
metadata.relation.name =~ /Embedded/
|
66
123
|
end
|
67
124
|
end
|
68
125
|
|
69
126
|
def referenced_relations
|
70
|
-
@referenced_relations ||= @
|
127
|
+
@referenced_relations ||= @record.relations.select do |_, metadata|
|
71
128
|
metadata.relation.name =~ /Referenced/
|
72
129
|
end
|
73
130
|
end
|
data/lib/hypermodel/version.rb
CHANGED
data/lib/hypermodel.rb
CHANGED