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