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 +4 -4
- data/README.md +22 -6
- data/lib/api_presenter.rb +7 -6
- data/lib/api_presenter/concerns/presentable.rb +63 -0
- data/lib/api_presenter/parse_include_params.rb +25 -0
- data/lib/api_presenter/resolvers/base.rb +16 -0
- data/lib/api_presenter/resolvers/included_collections_resolver.rb +125 -0
- data/lib/api_presenter/resolvers/policies_resolver.rb +66 -0
- data/lib/api_presenter/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eaf8be856cb0170356c5d277cbafaa8fa188e536
|
4
|
+
data.tar.gz: d8bdf073896bd010eb6ae4c82df92bbdc05e99b9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
|
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
|
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
|
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.
|
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-
|
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:
|