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 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