api_presenter 0.1.0 → 0.2.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.
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: