hypermodel 0.0.1 → 0.1.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.
- 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