api_presenter 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4779e47afef65722b830ee6c85455f3f9853cf40
4
- data.tar.gz: f5b677a9c48fcac592de8b9e7e631f06924e3dd5
3
+ metadata.gz: eaf8be856cb0170356c5d277cbafaa8fa188e536
4
+ data.tar.gz: d8bdf073896bd010eb6ae4c82df92bbdc05e99b9
5
5
  SHA512:
6
- metadata.gz: 5f3a9f458b103dbadea74c3844b129a53a9328af660e0bdca3215183c0ceccd92d97c9bc0a76050e1a0e6fcb13e331dcb9bc09a193d16041db20fa3b4baa181e
7
- data.tar.gz: 91e99b5f3919c87cad9997a8d6341dd3cd613894c5f986e2a7b0feb1d47ca7a00c4f8d7bbfef3d5bf832fd3158b12f5415f4d6b4b3c59ac5e4248858862a3249
6
+ metadata.gz: 21e538cd8234557a3ccd599da45b9d8faf7ee313043b5ffe31e67047475ad7db44f601df1d1b105e83bf48edf1067a40e9ff98d99a59a77ea63101fd646191c0
7
+ data.tar.gz: 80c528d1d70fd8822922ffd76ba594aee3f0afdbf082d17d93e7d0b856b28574bff5bcca80cc13348387de29893e83a848aaedf9b5eb7dbae5fc57d4ea50307d
data/README.md CHANGED
@@ -54,7 +54,7 @@ end
54
54
 
55
55
  When clients request posts (the primary collection), they may want any or all of the above data for those posts.
56
56
 
57
- ### Create your Presenter
57
+ ### 1. Create your Presenter
58
58
 
59
59
  ```ruby
60
60
  class PostPresenter < ApiPresenter::Base
@@ -78,20 +78,20 @@ end
78
78
 
79
79
  Presenters can define up to three methods:
80
80
 
81
- * `associations_map` The includable resources for the ActiveRecord model (Post, in this case). Consists of the model name as key and traversla required to preload/load them. In most cases, the value of `associations` will correspond directly to associations on the primary model.
81
+ * `associations_map` The includable resources for the ActiveRecord model (`Post`, in this case). Consists of the model name as key and traversal required to preload/load them. In most cases, the value of `associations` will correspond directly to associations on the primary model.
82
82
  * `policy_methods` A list of Pundit policy methods to resolve for the primary collection.
83
83
  * `policy_associations` Additional records to preload in order to optimize policies that must traverse asscoiations.
84
84
 
85
- ### Enable your controllers
85
+ ### 2. Enable your controllers
86
86
 
87
- ApiPresenter provides a controller concern that executes the Presenter. This process analyzes your params, preloads records as needed, and produces a `@presenter` object you can work with.
87
+ Your presentable collection can be an `ActiveRecord::Relation`, an array of records, or even a single record. Just call `present` on it. The preloads will be performed, and the included collections/policies will be available in the `@presenter` instance variable.
88
88
 
89
89
  ```ruby
90
90
  class ApplicationController
91
91
  include ApiPresenter::Concerns::Presentable
92
92
  end
93
93
 
94
- class PostsCOntroller < ApplicationController
94
+ class PostsController < ApplicationController
95
95
  def index
96
96
  posts = PostQuery.records(current_user, params)
97
97
  present posts
@@ -104,7 +104,7 @@ class PostsCOntroller < ApplicationController
104
104
  end
105
105
  ```
106
106
 
107
- ### Render the result
107
+ ### 3. Render the result
108
108
 
109
109
  How you ultimately render the primary collection and the data produced by ApiPresenter is up to you. `@presenter` has the following properties:
110
110
 
@@ -125,6 +125,22 @@ end
125
125
  json.partial!("api/shared/included_collections_and_meta", presenter: @presenter)
126
126
  ```
127
127
 
128
+ ### api/posts/show.json.jbuilder
129
+
130
+ ```ruby
131
+ json.post do
132
+ json.partial!(@post)
133
+ end
134
+ json.partial!("api/shared/included_collections_and_meta", presenter: @presenter)
135
+ ```
136
+
137
+ ```ruby
138
+ json.posts(@presenter.collection) do |post|
139
+ json.partial!(post)
140
+ end
141
+ json.partial!("api/shared/included_collections_and_meta", presenter: @presenter)
142
+ ```
143
+
128
144
  ### api/shared/included_collections_and_meta
129
145
 
130
146
  ```ruby
data/lib/api_presenter.rb CHANGED
@@ -1,10 +1,4 @@
1
1
  require 'pundit'
2
- require 'api_presenter/parse_include_params'
3
- require 'api_presenter/version'
4
- require 'api_presenter/concerns/presentable'
5
- require 'api_presenter/resolvers/base'
6
- require 'api_presenter/resolvers/policies_resolver'
7
- require 'api_presenter/resolvers/included_collections_resolver'
8
2
 
9
3
  module ApiPresenter
10
4
  class Base
@@ -202,3 +196,10 @@ module ApiPresenter
202
196
  end
203
197
  end
204
198
  end
199
+
200
+ require 'api_presenter/parse_include_params'
201
+ require 'api_presenter/version'
202
+ require 'api_presenter/concerns/presentable'
203
+ require 'api_presenter/resolvers/base'
204
+ require 'api_presenter/resolvers/policies_resolver'
205
+ require 'api_presenter/resolvers/included_collections_resolver'
@@ -0,0 +1,63 @@
1
+ require 'active_support/concern'
2
+
3
+ module ApiPresenter
4
+ module Concerns
5
+ module Presentable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ # Instantiates presenter for the given relation, array of records, or single record
11
+ #
12
+ # @example Request with included records
13
+ # GET /api/posts?include=categories,subCategories,users
14
+ #
15
+ # @example Request with policies
16
+ # GET /api/posts?policies=true
17
+ #
18
+ # @example Request with included records and policies
19
+ # GET /api/posts?include=categories,subCategories,users&policies=true
20
+ #
21
+ # @example Request with count only
22
+ # GET /api/posts?count=true
23
+ #
24
+ # @example PostsController
25
+ # include ApiPresenter::Concerns::Presentable
26
+ #
27
+ # def index
28
+ # posts = Post.page
29
+ # present posts
30
+ # end
31
+ #
32
+ # def show
33
+ # @post = Post.find(params[:id])
34
+ # present @post
35
+ # end
36
+ #
37
+ # @param relation_or_record [ActiveRecord::Relation, Array<ActiveRecord::Base>, ActiveRecord::Base]
38
+ #
39
+ def present(relation_or_record)
40
+ klass, relation = if relation_or_record.is_a?(ActiveRecord::Relation)
41
+ [relation_or_record.klass, relation_or_record]
42
+ else
43
+ record_array = Array.wrap(relation_or_record)
44
+ [record_array.first.class, record_array]
45
+ end
46
+
47
+ @presenter = presenter_klass(klass).call(
48
+ current_user: defined?(current_user) ? current_user : nil,
49
+ relation: relation,
50
+ params: params
51
+ )
52
+ end
53
+
54
+ # Progressive search for klass's Presenter
55
+ def presenter_klass(klass)
56
+ "#{klass.name}Presenter".safe_constantize ||
57
+ "#{klass.base_class.name}Presenter".safe_constantize ||
58
+ ApiPresenter::Base
59
+ end
60
+
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,25 @@
1
+ module ApiPresenter
2
+
3
+ # Parses values into array of acceptable association map keys:
4
+ # * Removes blanks and dups
5
+ # * Underscores camel-cased keys
6
+ # * Converts to symbol
7
+ #
8
+ # @param values [String, Array<String>, Array<Symbol>] Comma-delimited string or array
9
+ #
10
+ # @return [Array<Symbol>]
11
+ #
12
+ class ParseIncludeParams
13
+ def self.call(values)
14
+ return [] if values.blank?
15
+
16
+ array = values.is_a?(Array) ? values.dup : values.split(',')
17
+ array.select!(&:present?)
18
+ array.map! { |value| value.try(:underscore) || value }
19
+ array.uniq!
20
+ array.map!(&:to_sym)
21
+
22
+ array
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ require 'active_support/core_ext/module'
2
+
3
+ module ApiPresenter
4
+ module Resolvers
5
+ class Base
6
+
7
+ attr_reader :presenter
8
+
9
+ delegate :current_user, :relation, to: :presenter
10
+
11
+ def initialize(presenter)
12
+ @presenter = presenter
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,125 @@
1
+ module ApiPresenter
2
+ module Resolvers
3
+ # Handles loading of associated collections and policies, or counts,
4
+ # for the given collection or single record.
5
+ class IncludedCollectionsResolver < Resolvers::Base
6
+
7
+ attr_reader :resolved_collections
8
+
9
+ delegate :associations_map, :included_collection_names, to: :presenter
10
+
11
+ # @param presenter [ApiPresenter::Base]
12
+ def initialize(presenter)
13
+ super(presenter)
14
+ preload if relation.is_a?(ActiveRecord::Relation) && resolved_associations_map.any?
15
+ end
16
+
17
+ def call
18
+ resolve_collections
19
+ self
20
+ end
21
+
22
+ private
23
+
24
+ # ActiveRecord preload of included collections
25
+ def preload
26
+ presenter.preload(resolved_associations_map.flat_map {|k,v| v[:associations]})
27
+ end
28
+
29
+ # Whitelists included collections against classes that current_user is permitted to see and any specified conditions
30
+ #
31
+ # @param association_keys [Array]
32
+ #
33
+ # @return [Hash] Whitelisted copy of `associations_map`
34
+ #
35
+ def resolved_associations_map
36
+ @resolved_associations_map ||= included_collection_names.inject({}) do |hash, included_collection_name|
37
+ if resolve_collection?(included_collection_name)
38
+ hash[included_collection_name] = { associations: associations_map[included_collection_name][:associations] }
39
+ end
40
+ hash
41
+ end
42
+ end
43
+
44
+ def resolve_collection?(included_collection_name)
45
+ associations_map[included_collection_name] &&
46
+ collection_policy_permits?(included_collection_name) &&
47
+ collection_condition_permits?(included_collection_name)
48
+ end
49
+
50
+ # Runs policy check to ensure current_user is allowed to view objects of the given type
51
+ # TODO: configuration to allow pass if no policy defined, with configurable warning log
52
+ def collection_policy_permits?(included_collection_name)
53
+ Pundit.policy(current_user, included_collection_name.to_s.classify.constantize).index?
54
+ end
55
+
56
+ # Runs check on included association condition if present.
57
+ #
58
+ # Conditions can be either a string, which will be interpolated, or
59
+ # a symbol, which will execute the corresponding method. Both
60
+ # options must return a truthy result.
61
+ #
62
+ # @example String condition
63
+ # def associations_map
64
+ # {
65
+ # categories: { associations: :category, condition: 'current_user.admin?' }
66
+ # }
67
+ # }
68
+ #
69
+ # @example Method condition
70
+ # def associations_map
71
+ # {
72
+ # categories: { associations: :category, condition: :admin? }
73
+ # }
74
+ # }
75
+ #
76
+ # @return [Boolean]
77
+ def collection_condition_permits?(included_collection_name)
78
+ if (condition = associations_map[included_collection_name][:condition])
79
+ if condition.is_a?(String)
80
+ presenter.instance_eval(condition)
81
+ elsif condition.is_a?(Symbol)
82
+ presenter.send(condition)
83
+ end
84
+ else
85
+ true
86
+ end
87
+ end
88
+
89
+ # Map requested collections from relation
90
+ def resolve_collections
91
+ @resolved_collections = resolved_associations_map.inject({}) do |hash, (k,v)|
92
+ collection_records = []
93
+ collection_associations = Array.wrap(v[:associations])
94
+ collection_associations.each do |association|
95
+ add_records_from_collection_association(relation, association, collection_records)
96
+ end
97
+ collection_records.flatten!
98
+ collection_records.compact!
99
+ collection_records.uniq!
100
+ hash[k] = collection_records
101
+ hash
102
+ end
103
+ end
104
+
105
+ # Recursive method that traverses n-nested associations to get at the requested records
106
+ #
107
+ # @param current_relation [ActiveRecord::Relation] Original relation or nested association, changes during recursion
108
+ # @param association [Symbol, Hash] Source association key or nested hash association
109
+ # @param collection_records [Array] Concatenated included collection records
110
+ #
111
+ def add_records_from_collection_association(current_relation, association, collection_records)
112
+ if association.is_a?(Hash)
113
+ association.each do |k,v|
114
+ nested_association = current_relation.flat_map { |record| record.send(k) }.compact.uniq
115
+ Array.wrap(v).each do |nested_association_association|
116
+ add_records_from_collection_association(nested_association, nested_association_association, collection_records)
117
+ end
118
+ end
119
+ else
120
+ collection_records << current_relation.map { |record| record.send(association) }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,66 @@
1
+ module ApiPresenter
2
+ module Resolvers
3
+ class PoliciesResolver < Resolvers::Base
4
+
5
+ attr_reader :presenter, :resolved_policies
6
+
7
+ delegate :policy_associations, :policy_methods, to: :presenter
8
+
9
+ # Optimally resolves designated policies for current_user and supplied records.
10
+ #
11
+ # Use where it is desirable for an API response to include permissions (policy)
12
+ # metadata so that a client can correctly present resource actions.
13
+ #
14
+ # Initialize and preload policy associations for the given relation
15
+ #
16
+ # @param presenter [ApiPresenter::Base]
17
+ def initialize(presenter)
18
+ super(presenter)
19
+ preload if relation.is_a?(ActiveRecord::Relation) && policy_associations.present?
20
+ end
21
+
22
+ # Resolves policies and combines them into an id-based hash
23
+ #
24
+ # @return [PolicyPresenter::Base]
25
+ #
26
+ def call
27
+ resolve_policies
28
+ self
29
+ end
30
+
31
+ private
32
+
33
+ # Preload any associations required to optimize policy methods that traverse models
34
+ def preload
35
+ presenter.preload(policy_associations)
36
+ end
37
+
38
+ # Run policies for each record in the relation
39
+ def resolve_policies
40
+ @resolved_policies = relation.map do |record|
41
+ policy_definition = Pundit.policy(current_user, record)
42
+ record_policies = { :"#{id_attribute}" => record.id }
43
+ Array.wrap(policy_methods).each do |policy_method|
44
+ record_policies[policy_method] = policy_definition.send("#{policy_method}?")
45
+ end
46
+ record_policies
47
+ end
48
+ end
49
+
50
+ # @example Post -> "post_id"
51
+ #
52
+ # @return [String]
53
+ #
54
+ def id_attribute
55
+ @id_attribute ||= begin
56
+ klass = if relation.is_a?(ActiveRecord::Relation)
57
+ relation.klass
58
+ else
59
+ relation.first.class
60
+ end
61
+ "#{klass.base_class.name.underscore}_id"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,3 +1,3 @@
1
1
  module ApiPresenter
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_presenter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuval Kordov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2016-10-28 00:00:00.000000000 Z
12
+ date: 2016-11-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -128,6 +128,11 @@ files:
128
128
  - bin/console
129
129
  - bin/setup
130
130
  - lib/api_presenter.rb
131
+ - lib/api_presenter/concerns/presentable.rb
132
+ - lib/api_presenter/parse_include_params.rb
133
+ - lib/api_presenter/resolvers/base.rb
134
+ - lib/api_presenter/resolvers/included_collections_resolver.rb
135
+ - lib/api_presenter/resolvers/policies_resolver.rb
131
136
  - lib/api_presenter/version.rb
132
137
  homepage: http://github.com/uberllama/api_presenter
133
138
  licenses: