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 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
- RDoc::Task.new(:rdoc) do |rdoc|
16
- rdoc.rdoc_dir = 'rdoc'
17
- rdoc.title = 'Hypermodel'
18
- rdoc.options << '--line-numbers'
19
- rdoc.rdoc_files.include('README.rdoc')
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
@@ -1,8 +1,18 @@
1
- require 'hypermodel/serializers/mongoid'
1
+ require 'hypermodel/resource'
2
2
 
3
3
  module Hypermodel
4
- Serializer = Serializers::Mongoid
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, resource, controller)
28
+ def initialize(resource_name, action, record, controller)
19
29
  @resource_name = resource_name
20
30
  @action = action
21
- @resource = Serializer.new(resource, controller)
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
- def to_json(*opts)
11
- attrs = @attributes.update(links).update(embedded)
12
- attrs.to_json(*opts)
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
- def links
16
- hash = { self: { href: @controller.polymorphic_url(@resource) } }
17
-
18
- referenced_relations.each do |name, metadata|
19
- related = @resource.send(name)
20
- relation = metadata.relation
21
-
22
- if relation == ::Mongoid::Relations::Referenced::In
23
- unless related.nil? || (related.respond_to?(:empty?) && related.empty?)
24
- hash.update(name => { href: @controller.polymorphic_url(related) })
25
- end
26
- elsif relation == ::Mongoid::Relations::Referenced::Many
27
- hash.update(name => { href: @controller.polymorphic_url([@resource, name]) })
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
- { _links: hash }
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
- def embedded
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({ _embedded: {} }) do |acc, (name, metadata)|
41
- if attributes = extract_embedded_attributes(name, metadata)
74
+ embedded_relations.inject({}) do |acc, (name, metadata)|
75
+ if attributes = extract_embedded_attributes(name)
42
76
  @attributes.delete(name)
43
- acc[:_embedded][name] = attributes
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 extract_embedded_attributes(name, metadata)
52
- relation = metadata.relation
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
- if relation == ::Mongoid::Relations::Embedded::Many
55
- @resource.send(name).map { |embedded| embedded.attributes }
56
- elsif relation == ::Mongoid::Relations::Embedded::One
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 ||= @resource.relations.select do |_, metadata|
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 ||= @resource.relations.select do |_, metadata|
127
+ @referenced_relations ||= @record.relations.select do |_, metadata|
71
128
  metadata.relation.name =~ /Referenced/
72
129
  end
73
130
  end
@@ -1,3 +1,3 @@
1
1
  module Hypermodel
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
data/lib/hypermodel.rb CHANGED
@@ -1,5 +1,6 @@
1
+ # Public: A Hypermodel is a representation of a resource in a JSON-HAL format.
2
+ # To learn more about JSON HAL see http://stateless.co/hal_specification.html
1
3
  module Hypermodel
2
4
  end
3
5
 
4
6
  require 'hypermodel/responder'
5
-
@@ -2,7 +2,8 @@ class PostsController < ApplicationController
2
2
  respond_to :json
3
3
 
4
4
  def show
5
- @post = Post.find params[:id]
5
+ @blog = Blog.find params[:blog_id]
6
+ @post = @blog.posts.find params[:id]
6
7
  respond_with(@post, responder: Hypermodel::Responder)
7
8
  end
8
9
  end
@@ -0,0 +1,6 @@
1
+ class Blog
2
+ include Mongoid::Document
3
+
4
+ field :title
5
+ has_many :posts
6
+ end
@@ -5,6 +5,7 @@ class Post
5
5
  field :title, type: String
6
6
  field :body, type: String
7
7
 
8
+ belongs_to :blog
8
9
  belongs_to :author
9
10
  has_many :reviews
10
11
  embeds_many :comments
@@ -1,6 +1,8 @@
1
1
  Dummy::Application.routes.draw do
2
- resources :posts do
3
- resources :reviews
2
+ resources :blogs do
3
+ resources :posts do
4
+ resources :reviews
5
+ end
4
6
  end
5
7
  resources :authors
6
8
  end