standardapi 6.0.0.32 → 7.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -58
  3. data/lib/standard_api/access_control_list.rb +148 -0
  4. data/lib/standard_api/controller.rb +116 -27
  5. data/lib/standard_api/helpers.rb +17 -10
  6. data/lib/standard_api/includes.rb +9 -0
  7. data/lib/standard_api/middleware/query_encoding.rb +3 -3
  8. data/lib/standard_api/middleware.rb +5 -0
  9. data/lib/standard_api/railtie.rb +30 -2
  10. data/lib/standard_api/route_helpers.rb +64 -14
  11. data/lib/standard_api/test_case/calculate_tests.rb +15 -8
  12. data/lib/standard_api/test_case/create_tests.rb +7 -9
  13. data/lib/standard_api/test_case/destroy_tests.rb +19 -7
  14. data/lib/standard_api/test_case/index_tests.rb +10 -6
  15. data/lib/standard_api/test_case/schema_tests.rb +7 -1
  16. data/lib/standard_api/test_case/show_tests.rb +8 -7
  17. data/lib/standard_api/test_case/update_tests.rb +15 -15
  18. data/lib/standard_api/test_case.rb +13 -3
  19. data/lib/standard_api/version.rb +1 -1
  20. data/lib/standard_api/views/application/_record.json.jbuilder +18 -17
  21. data/lib/standard_api/views/application/_record.streamer +40 -37
  22. data/lib/standard_api/views/application/_schema.json.jbuilder +20 -8
  23. data/lib/standard_api/views/application/_schema.streamer +22 -8
  24. data/lib/standard_api/views/application/new.streamer +1 -1
  25. data/lib/standard_api.rb +5 -0
  26. data/test/standard_api/caching_test.rb +14 -4
  27. data/test/standard_api/controller/include_test.rb +107 -0
  28. data/test/standard_api/controller/subresource_test.rb +157 -0
  29. data/test/standard_api/helpers_test.rb +34 -17
  30. data/test/standard_api/nested_attributes/belongs_to_test.rb +71 -0
  31. data/test/standard_api/nested_attributes/has_and_belongs_to_many_test.rb +70 -0
  32. data/test/standard_api/nested_attributes/has_many_test.rb +85 -0
  33. data/test/standard_api/nested_attributes/has_one_test.rb +71 -0
  34. data/test/standard_api/route_helpers_test.rb +56 -0
  35. data/test/standard_api/standard_api_test.rb +182 -44
  36. data/test/standard_api/test_app/app/controllers/acl/account_acl.rb +15 -0
  37. data/test/standard_api/test_app/app/controllers/acl/camera_acl.rb +7 -0
  38. data/test/standard_api/test_app/app/controllers/acl/photo_acl.rb +13 -0
  39. data/test/standard_api/test_app/app/controllers/acl/property_acl.rb +33 -0
  40. data/test/standard_api/test_app/app/controllers/acl/reference_acl.rb +7 -0
  41. data/test/standard_api/test_app/controllers.rb +28 -43
  42. data/test/standard_api/test_app/models.rb +76 -7
  43. data/test/standard_api/test_app/test/factories.rb +7 -3
  44. data/test/standard_api/test_app/views/photos/_photo.json.jbuilder +1 -0
  45. data/test/standard_api/test_app/views/photos/_photo.streamer +2 -1
  46. data/test/standard_api/test_app/views/sessions/create.json.jbuilder +1 -0
  47. data/test/standard_api/test_app/views/sessions/create.streamer +3 -0
  48. data/test/standard_api/test_app.rb +12 -1
  49. data/test/standard_api/test_helper.rb +21 -0
  50. metadata +59 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f8e9f6eee237614e9c303c8fe9e2e624b081272627f9a7aa3f680276872c1a9
4
- data.tar.gz: 73ede0c9b6229117b4781071441ac0581a06b16592f95f04959932c010ecb8f0
3
+ metadata.gz: 88dd56c94d20d8649c5f755509be1aee880f7dff0133602a4fe46c0408c4e65b
4
+ data.tar.gz: 3f14301f672ac171f9e2a39620de9f1913300572164978857ea12fcb1bb008de
5
5
  SHA512:
6
- metadata.gz: 904192ff81007b628b672e094dfaad19f4b2c49a0e4119631dd46c320a85d0e2c06200b5570d29d3b70ded73046b843dfd28ce3e40358d3b2294bbf9bb7b50f6
7
- data.tar.gz: f9ed735aabd0830d6b2c7a390d4f2bca3bf8bcc1d8b806b615a76462404f58c9b65241c75e7b7e62abe84539705249ed10c82f9f5dcc4cc1ee4911e51513200f
6
+ metadata.gz: e3c282a6b8898d1c1451e381dea4cf57c3cd58b65bccfba6a0b5f9d302242287bb233dd43b4a078c51f1711a32a449d47c157626a92cc752939d8040ef704534
7
+ data.tar.gz: 56d90b5dc3d5d04924b7bd5e5fe6a98cfe9cf4f6fa79d30c933abff3e5c6f6b69a6718a699bb5199183f05372f99d8038f99dc7e5ff1b011dca4da978ee4d5ae
data/README.md CHANGED
@@ -1,36 +1,27 @@
1
1
  # StandardAPI
2
2
 
3
- StandardAPI makes it easy to expost a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
3
+ StandardAPI makes it easy to expose a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
4
4
  interface to your Rails models.
5
5
 
6
6
  # Installation
7
7
 
8
8
  gem install standardapi
9
9
 
10
- In your Gemfile:
10
+ In your `Gemfile`:
11
11
 
12
12
  gem 'standardapi', require: 'standard_api'
13
13
 
14
- In `config/application.rb:
14
+ Optionally in `config/application.rb`:
15
15
 
16
- require_relative 'boot'
17
-
18
- require 'rails/all'
19
- require 'standard_api/middleware/query_encoding'
20
-
21
- # Require the gems listed in Gemfile, including any gems
22
- # you've limited to :test, :development, or :production.
23
- Bundler.require(*Rails.groups)
24
-
25
- module Tester
16
+ module MyApplication
26
17
  class Application < Rails::Application
27
18
  # Initialize configuration defaults for originally generated Rails version.
28
- config.load_defaults 5.2
19
+ config.load_defaults 7.0
29
20
 
30
- # Settings in config/environments/* take precedence over those specified here.
31
- # Application configuration can go into files in config/initializers
32
- # -- all .rb files in that directory are automatically loaded after loading
33
- # the framework and any gems in your application.
21
+ # QueryEncoding middleware intercepts and parses the query string
22
+ # as MessagePack if the `Query-Encoding` header is set to `application/msgpack`
23
+ # which allows GET request with types as opposed to all values being interpeted
24
+ # as strings
34
25
  config.middleware.insert_after Rack::MethodOverride, StandardAPI::Middleware::QueryEncoding
35
26
  end
36
27
  end
@@ -41,66 +32,96 @@ StandardAPI is a module that can be included into any controller to expose a API
41
32
  for. Alternatly, it can be included into `ApplicationController`, giving all
42
33
  inherited controllers an exposed API.
43
34
 
44
- class ApplicationController < ActiveController::Base
45
- include StandardAPI::Controller
46
-
47
- end
48
-
49
- By default any paramaters passed to update and create are whitelisted with by
50
- the method named after the model the controller represents. For example, the
51
- following will only allow the `caption` attribute of the `Photo` model to be
52
- updated.
53
-
54
35
  class PhotosController < ApplicationController
55
36
  include StandardAPI
56
37
 
38
+ # Allowed paramaters
39
+ # By default any paramaters passed to update and create are whitelisted by
40
+ # the method named after the model the controller represents. For example,
41
+ # the following will only allow the `caption` attribute of the `Photo`
42
+ # model to be set on update or create.
57
43
  def photo_params
58
44
  [:caption]
59
45
  end
46
+
47
+ # Allowed orderings
48
+ # The ordering is whitelisted as well, you will mostly likely want to
49
+ # ensure indexes have been created on these columns. In this example the
50
+ # response can be ordered by any permutation of `id`, `created_at`, and
51
+ # `updated_at`.
52
+ def photo_orders
53
+ [:id, :created_at, :updated_at]
54
+ end
55
+
56
+ # Allowed includes
57
+ # Similarly, the includes (including of relationships in the reponse) are
58
+ # whitelisted. Note how includes can also support nested includes. In this
59
+ # case when including the author, the photos that the author took can also
60
+ # be included.
61
+ def photo_includes
62
+ { author: [:photos] }
63
+ end
60
64
  end
61
65
 
62
- If greater control of the allowed paramaters is required, the `model_params`
63
- method can be overridden. It simply returns a set of `StrongParameters`.
66
+ ##### Access Control List
64
67
 
65
- class PhotosController < ApplicationController
66
- include StandardAPI
67
-
68
- def model_params
69
- if @photo.author == current_user
70
- [:caption]
71
- else
72
- [:comment]
73
- end
74
- end
68
+ For greater control of the allowed paramaters and nesting of paramaters
69
+ `StandardAPI::AccessControlList` is available. To use it include it in your base
70
+ controller:
71
+
72
+ class ApplicationController
73
+ include StandardAPI::Control
74
+ include StandardAPI::AccessControlList
75
75
  end
76
76
 
77
- Similarly, the ordering and includes (including of relationships in the reponse)
78
- is whitelisted as well.
77
+ Then create an ACL file for each model you want in `app/controllers/acl`.
79
78
 
80
- Full Example:
79
+ Taking the above example we would remove the `photo_*` methods and create the
80
+ following files:
81
81
 
82
- class PhotosController < ApplicationController
83
- including StandardAPI
82
+ `app/controllers/acl/photo_acl.rb`:
84
83
 
85
- # Allowed paramaters
86
- def photo_params
87
- [:caption]
84
+ module PhotoACL
85
+ # Allowed attributes
86
+ def attributes
87
+ [ :caption ]
88
88
  end
89
-
90
- # Allowed orderings
91
- def photo_orders
92
- [:id, :created_at, :updated_at]
89
+
90
+ # Allowed saving / creating nested attributes
91
+ def nested
92
+ [ :camera ]
93
93
  end
94
-
94
+
95
+ # Allowed orders
96
+ def orders
97
+ [ :id, :created_at, :updated_at ]
98
+ end
99
+
95
100
  # Allowed includes
96
- def photo_includes
97
- { author: [:photos] }
101
+ def includes
102
+ [ :author ]
98
103
  end
104
+ end
99
105
 
106
+ `app/controllers/acl/author_acl.rb`:
107
+
108
+ module AuthorACL
109
+ def includes
110
+ [ :photos ]
111
+ end
100
112
  end
101
113
 
102
- Note how includes can also support nested includes. So in this case when
103
- including the author, the photos that the author took can also be included.
114
+ All of these methods are optional and will be included in ApplicationController
115
+ for StandardAPI to determine allowed attributes, nested attributes, orders and
116
+ includes.
117
+
118
+ `includes` now returns a shallow Array, StandardAPI can how determine including
119
+ an `author` and the author's `photos` is allowed by looking at what includes are
120
+ allowed on photo and author.
121
+
122
+ The `nested` function tells StandardAPI what relations on `Photo` are allowed to
123
+ be set with the API and will determine what attributes are allowed by looking
124
+ for a `camera_acl` file.
104
125
 
105
126
  # API Usage
106
127
  Resources can be queried via REST style end points
@@ -208,7 +229,7 @@ And example contoller and it's tests.
208
229
  # The mask is then applyed to all actions when querring ActiveRecord
209
230
  # Will only allow photos that have id one. For more on the syntax see
210
231
  # the activerecord-filter gem.
211
- def current_mask
232
+ def mask_for(table_name)
212
233
  { id: 1 }
213
234
  end
214
235
 
@@ -231,3 +252,4 @@ StandardAPI Resource Interface
231
252
  | `/models?where[id][]=1&where[id][]=2` | `{ where: { id: [1,2] } }` | `SELECT * FROM models WHERE id IN (1, 2)` | `[{ id: 1 }, { id: 2 }]` |
232
253
 
233
254
 
255
+
@@ -0,0 +1,148 @@
1
+ module StandardAPI
2
+ module AccessControlList
3
+
4
+ def self.traverse(path, prefix: nil, &block)
5
+ path.children.each do |child|
6
+ if child.file? && child.basename('.rb').to_s.ends_with?('_acl')
7
+ block.call([prefix, child.basename('.rb').to_s].compact.join('/'))
8
+ elsif child.directory?
9
+ traverse(child, prefix: [prefix, child.basename.to_s].compact.join('/'), &block)
10
+ end
11
+ end
12
+ end
13
+
14
+ def self.included(application_controller)
15
+ acl_dir = Rails.application.root.join('app', 'controllers', 'acl')
16
+ return if !acl_dir.exist?
17
+
18
+ traverse(acl_dir) do |child|
19
+ mod = child.classify.constantize
20
+ prefix = child.delete_suffix('_acl').gsub('/', '_')
21
+
22
+ [:orders, :includes, :attributes].each do |m|
23
+ next if !mod.instance_methods.include?(m)
24
+ mod.send :alias_method, "#{prefix}_#{m}".to_sym, m
25
+ mod.send :remove_method, m
26
+ end
27
+
28
+ if mod.instance_methods.include?(:nested)
29
+ mod.send :alias_method, "nested_#{prefix}_attributes".to_sym, :nested
30
+ mod.send :remove_method, :nested
31
+ end
32
+
33
+ if mod.instance_methods.include?(:filter)
34
+ mod.send :alias_method, "filter_#{prefix}_params".to_sym, :filter
35
+ mod.send :remove_method, :filter
36
+ end
37
+
38
+ application_controller.include mod
39
+ end
40
+ end
41
+
42
+ def model_orders
43
+ if self.respond_to?("#{model.model_name.singular}_orders", true)
44
+ self.send("#{model.model_name.singular}_orders")
45
+ else
46
+ []
47
+ end
48
+ end
49
+
50
+ def model_params
51
+ if self.respond_to?("filter_#{model_name(model)}_params", true)
52
+ self.send("filter_#{model_name(model)}_params", params[model_name(model)], id: params[:id])
53
+ else
54
+ filter_model_params(params[model_name(model)], model.base_class)
55
+ end
56
+ end
57
+
58
+ def filter_model_params(model_params, model, id: nil, allow_id: nil)
59
+ permitted_params = if model_params && self.respond_to?("#{model_name(model)}_attributes", true)
60
+ permits = self.send("#{model_name(model)}_attributes")
61
+
62
+ allow_id ? model_params.permit(permits, :id) : model_params.permit(permits)
63
+ else
64
+ ActionController::Parameters.new
65
+ end
66
+
67
+ if self.respond_to?("nested_#{model_name(model)}_attributes", true)
68
+ self.send("nested_#{model_name(model)}_attributes").each do |relation|
69
+ relation = model.reflect_on_association(relation)
70
+ attributes_key = "#{relation.name}"
71
+
72
+ if model_params.has_key?(attributes_key)
73
+ filter_method = "filter_#{relation.klass.base_class.model_name.singular}_params"
74
+ if model_params[attributes_key].nil?
75
+ permitted_params[attributes_key] = nil
76
+ elsif model_params[attributes_key].is_a?(Array) && model_params[attributes_key].all? { |a| a.keys.map(&:to_sym) == [:id] }
77
+ permitted_params["#{relation.name.to_s.singularize}_ids"] = model_params[attributes_key].map{|a| a['id']}
78
+ elsif self.respond_to?(filter_method, true)
79
+ permitted_params[attributes_key] = if model_params[attributes_key].is_a?(Array)
80
+ models = relation.klass.find(model_params[attributes_key].map { |i| i['id'] }.compact)
81
+ model_params[attributes_key].map { |i|
82
+ i_params = self.send(filter_method, i, allow_id: true)
83
+ if i_params['id']
84
+ r = models.find { |r| r.id == i_params['id'] }
85
+ r.assign_attributes(i_params)
86
+ r
87
+ else
88
+ relation.klass.new(i_params)
89
+ end
90
+ }
91
+ else
92
+ i_params = self.send(filter_method, model_params[attributes_key], allow_id: true)
93
+ if i_params['id']
94
+ r = relation.klass.find(i_params['id'])
95
+ r.assign_attributes(i_params)
96
+ r
97
+ else
98
+ relation.klass.new(i_params)
99
+ end
100
+ end
101
+ else
102
+ permitted_params[attributes_key] = if model_params[attributes_key].is_a?(Array)
103
+ models = relation.klass.find(model_params[attributes_key].map { |i| i['id'] }.compact)
104
+ model_params[attributes_key].map { |i|
105
+ i_params = filter_model_params(i, relation.klass.base_class, allow_id: true)
106
+ if i_params['id']
107
+ r = models.find { |r| r.id == i_params['id'] }
108
+ r.assign_attributes(i_params)
109
+ r
110
+ else
111
+ relation.klass.new(i_params)
112
+ end
113
+ }
114
+ else
115
+ i_params = filter_model_params(model_params[attributes_key], relation.klass.base_class, allow_id: true)
116
+ if i_params['id']
117
+ r = relation.klass.find(i_params['id'])
118
+ r.assign_attributes(i_params)
119
+ r
120
+ else
121
+ relation.klass.new(i_params)
122
+ end
123
+ end
124
+ end
125
+ elsif relation.collection? && model_params.has_key?("#{relation.name.to_s.singularize}_ids")
126
+ permitted_params["#{relation.name.to_s.singularize}_ids"] = model_params["#{relation.name.to_s.singularize}_ids"]
127
+ elsif model_params.has_key?(relation.foreign_key)
128
+ permitted_params[relation.foreign_key] = model_params[relation.foreign_key]
129
+ permitted_params[relation.foreign_type] = model_params[relation.foreign_type] if relation.polymorphic?
130
+ end
131
+
132
+ permitted_params.permit!
133
+ end
134
+ end
135
+
136
+ permitted_params
137
+ end
138
+
139
+ def model_name(model)
140
+ if model.model_name.singular.starts_with?('habtm_')
141
+ model.reflect_on_all_associations.map { |a| a.klass.base_class.model_name.singular }.sort.join('_')
142
+ else
143
+ model.model_name.singular
144
+ end
145
+ end
146
+
147
+ end
148
+ end
@@ -1,12 +1,13 @@
1
1
  module StandardAPI
2
2
  module Controller
3
3
 
4
- delegate :preloadables, to: :helpers
4
+ delegate :preloadables, :model_partial, to: :helpers
5
5
 
6
6
  def self.included(klass)
7
7
  klass.helper_method :includes, :orders, :model, :models, :resource_limit,
8
8
  :default_limit
9
9
  klass.before_action :set_standardapi_headers
10
+ klass.before_action :includes, except: [:destroy, :add_resource, :remove_resource]
10
11
  klass.rescue_from StandardAPI::ParameterMissing, with: :bad_request
11
12
  klass.rescue_from StandardAPI::UnpermittedParameters, with: :bad_request
12
13
  klass.append_view_path(File.join(File.dirname(__FILE__), 'views'))
@@ -73,7 +74,7 @@ module StandardAPI
73
74
  end
74
75
  else
75
76
  if request.format == :html
76
- render :edit, status: :bad_request
77
+ render :new, status: :bad_request
77
78
  else
78
79
  render :show, status: :bad_request
79
80
  end
@@ -96,45 +97,97 @@ module StandardAPI
96
97
  render :show, status: :ok
97
98
  end
98
99
  else
99
- render :show, status: :bad_request
100
+ if request.format == :html
101
+ render :edit, status: :bad_request
102
+ else
103
+ render :show, status: :bad_request
104
+ end
100
105
  end
101
106
  end
102
107
 
103
108
  def destroy
104
- resources.find(params[:id]).destroy!
109
+ records = resources.find(params[:id].split(','))
110
+ model.transaction { records.each(&:destroy!) }
111
+
105
112
  head :no_content
106
113
  end
107
114
 
108
115
  def remove_resource
109
116
  resource = resources.find(params[:id])
110
- subresource_class = resource.association(params[:relationship]).klass
111
- subresource = subresource_class.find_by_id(params[:resource_id])
112
-
113
- if(subresource)
114
- result = resource.send(params[:relationship]).delete(subresource)
115
- head result ? :no_content : :bad_request
116
- else
117
- head :not_found
117
+ association = resource.association(params[:relationship])
118
+
119
+ result = case association
120
+ when ActiveRecord::Associations::CollectionAssociation
121
+ association.delete(association.klass.find(params[:resource_id]))
122
+ when ActiveRecord::Associations::SingularAssociation
123
+ if resource.send(params[:relationship])&.id&.to_s == params[:resource_id]
124
+ resource.update(params[:relationship] => nil)
125
+ end
118
126
  end
127
+ head result ? :no_content : :not_found
119
128
  end
120
129
 
121
130
  def add_resource
122
131
  resource = resources.find(params[:id])
123
-
124
- subresource_class = resource.association(params[:relationship]).klass
125
- subresource = subresource_class.find_by_id(params[:resource_id])
126
- if(subresource)
127
- result = resource.send(params[:relationship]) << subresource
128
- head result ? :created : :bad_request
132
+ association = resource.association(params[:relationship])
133
+ subresource = association.klass.find(params[:resource_id])
134
+
135
+ result = case association
136
+ when ActiveRecord::Associations::CollectionAssociation
137
+ association.concat(subresource)
138
+ when ActiveRecord::Associations::SingularAssociation
139
+ resource.update(params[:relationship] => subresource)
140
+ end
141
+ head result ? :created : :bad_request
142
+ rescue ActiveRecord::RecordNotUnique
143
+ render json: {errors: [
144
+ "Relationship between #{resource.class.name} and #{subresource.class.name} violates unique constraints"
145
+ ]}, status: :bad_request
146
+ end
147
+
148
+ def create_resource
149
+ resource = resources.find(params[:id])
150
+ association = resource.association(params[:relationship])
151
+
152
+ subresource_params = if self.respond_to?("filter_#{model_name(association.klass)}_params", true)
153
+ self.send("filter_#{model_name(association.klass)}_params", params[model_name(association.klass)], id: params[:id])
154
+ elsif self.respond_to?("#{association.klass.model_name.singular}_params", true)
155
+ params.require(association.klass.model_name.singular).permit(self.send("#{association.klass.model_name.singular}_params"))
156
+ elsif self.respond_to?("filter_model_params", true)
157
+ filter_model_params(params[model_name(association.klass)], association.klass.base_class)
129
158
  else
130
- head :not_found
159
+ ActionController::Parameters.new
160
+ end
161
+
162
+ subresource = association.klass.new(subresource_params)
163
+
164
+ result = case association
165
+ when ActiveRecord::Associations::CollectionAssociation
166
+ association.concat(subresource)
167
+ when ActiveRecord::Associations::SingularAssociation
168
+ resource.update(params[:relationship] => subresource)
131
169
  end
132
170
 
171
+ partial = model_partial(subresource)
172
+ partial_record_name = partial.split('/').last.to_sym
173
+ if result
174
+ render partial: partial, locals: {partial_record_name => subresource}, status: :created
175
+ else
176
+ render partial: partial, locals: {partial_record_name => subresource}, status: :bad_request
177
+ end
133
178
  end
134
179
 
180
+ def mask
181
+ @mask ||= Hash.new do |hash, key|
182
+ hash[key] = mask_for(key)
183
+ end
184
+ end
185
+
135
186
  # Override if you want to support masking
136
- def current_mask
137
- @current_mask ||= {}
187
+ def mask_for(table_name)
188
+ # case table_name
189
+ # when 'accounts'
190
+ # end
138
191
  end
139
192
 
140
193
  module ClassMethods
@@ -157,7 +210,11 @@ module StandardAPI
157
210
  end
158
211
 
159
212
  def model
160
- self.class.model
213
+ if action_name&.end_with?('_resource')
214
+ self.class.model.reflect_on_association(params[:relationship]).klass
215
+ else
216
+ self.class.model
217
+ end
161
218
  end
162
219
 
163
220
  def models
@@ -189,7 +246,7 @@ module StandardAPI
189
246
  if self.respond_to?("#{model.model_name.singular}_params", true)
190
247
  params.require(model.model_name.singular).permit(self.send("#{model.model_name.singular}_params"))
191
248
  else
192
- []
249
+ ActionController::Parameters.new
193
250
  end
194
251
  end
195
252
 
@@ -207,7 +264,7 @@ module StandardAPI
207
264
  end
208
265
 
209
266
  def resources
210
- query = model.filter(params['where']).filter(current_mask[model.table_name])
267
+ query = self.class.model.filter(params['where']).filter(mask[self.class.model.table_name.to_sym])
211
268
 
212
269
  if params[:distinct_on]
213
270
  query = query.distinct_on(params[:distinct_on])
@@ -225,9 +282,29 @@ module StandardAPI
225
282
 
226
283
  query
227
284
  end
285
+
286
+ def nested_includes(model, attributes)
287
+ includes = {}
288
+ attributes&.each do |key, value|
289
+ if association = model.reflect_on_association(key)
290
+ includes[key] = nested_includes(association.klass, value)
291
+ end
292
+ end
293
+ includes
294
+ end
228
295
 
229
296
  def includes
230
- @includes ||= StandardAPI::Includes.sanitize(params[:include], model_includes)
297
+ @includes ||= if params[:include]
298
+ StandardAPI::Includes.sanitize(params[:include], model_includes)
299
+ else
300
+ {}
301
+ end
302
+
303
+ if (action_name == 'create' || action_name == 'update') && model && params.has_key?(model.model_name.singular)
304
+ @includes.reverse_merge!(nested_includes(model, params[model.model_name.singular].to_unsafe_h))
305
+ end
306
+
307
+ @includes
231
308
  end
232
309
 
233
310
  def required_orders
@@ -309,7 +386,19 @@ module StandardAPI
309
386
  @selects = []
310
387
  @selects << params[:group_by] if params[:group_by]
311
388
  Array(params[:select]).each do |select|
312
- select.each do |func, column|
389
+ select.each do |func, value|
390
+ distinct = false
391
+
392
+ column = case value
393
+ when ActionController::Parameters
394
+ # TODO: Add support for other aggregate expressions
395
+ # https://www.postgresql.org/docs/current/sql-expressions.html#SYNTAX-AGGREGATES
396
+ distinct = !value[:distinct].nil?
397
+ value[:distinct]
398
+ else
399
+ value
400
+ end
401
+
313
402
  if (parts = column.split(".")).length > 1
314
403
  @model = parts[0].singularize.camelize.constantize
315
404
  column = parts[1]
@@ -318,7 +407,7 @@ module StandardAPI
318
407
  column = column == '*' ? Arel.star : column.to_sym
319
408
  if functions.include?(func.to_s.downcase)
320
409
  node = (defined?(@model) ? @model : model).arel_table[column].send(func)
321
- node.distinct = true if params[:distinct]
410
+ node.distinct = distinct
322
411
  @selects << node
323
412
  end
324
413
  end
@@ -1,5 +1,11 @@
1
1
  module StandardAPI
2
2
  module Helpers
3
+
4
+ def serialize_attribute(json, record, name, type)
5
+ value = record.send(name)
6
+
7
+ json.set! name, type == :binary ? value&.unpack1('H*') : value
8
+ end
3
9
 
4
10
  def preloadables(record, includes)
5
11
  preloads = {}
@@ -80,9 +86,10 @@ module StandardAPI
80
86
  end
81
87
  end
82
88
 
83
- def can_cache_relation?(klass, relation, subincludes)
89
+ def can_cache_relation?(record, relation, subincludes)
90
+ return false if record.new_record?
84
91
  cache_columns = ["#{relation}_cached_at"] + cached_at_columns_for_includes(subincludes).map {|c| "#{relation}_#{c}"}
85
- if (cache_columns - klass.column_names).empty?
92
+ if (cache_columns - record.class.column_names).empty?
86
93
  true
87
94
  else
88
95
  false
@@ -91,18 +98,18 @@ module StandardAPI
91
98
 
92
99
  def association_cache_key(record, relation, subincludes)
93
100
  timestamp = ["#{relation}_cached_at"] + cached_at_columns_for_includes(subincludes).map {|c| "#{relation}_#{c}"}
94
- timestamp.map! { |col| record.send(col) }
101
+ timestamp = (timestamp & record.class.column_names).map! { |col| record.send(col) }
95
102
  timestamp = timestamp.max
96
103
 
97
104
  case association = record.class.reflect_on_association(relation)
98
105
  when ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasAndBelongsToManyReflection, ActiveRecord::Reflection::HasOneReflection, ActiveRecord::Reflection::ThroughReflection
99
- "#{record.model_name.cache_key}/#{record.id}/#{includes_to_cache_key(relation, subincludes)}-#{timestamp.utc.to_s(record.cache_timestamp_format)}"
106
+ "#{record.model_name.cache_key}/#{record.id}/#{includes_to_cache_key(relation, subincludes)}-#{timestamp.utc.to_fs(record.cache_timestamp_format)}"
100
107
  when ActiveRecord::Reflection::BelongsToReflection
101
108
  klass = association.options[:polymorphic] ? record.send(association.foreign_type).constantize : association.klass
102
109
  if subincludes.empty?
103
- "#{klass.model_name.cache_key}/#{record.send(association.foreign_key)}-#{timestamp.utc.to_s(klass.cache_timestamp_format)}"
110
+ "#{klass.model_name.cache_key}/#{record.send(association.foreign_key)}-#{timestamp.utc.to_fs(klass.cache_timestamp_format)}"
104
111
  else
105
- "#{klass.model_name.cache_key}/#{record.send(association.foreign_key)}/#{digest_hash(sort_hash(subincludes))}-#{timestamp.utc.to_s(klass.cache_timestamp_format)}"
112
+ "#{klass.model_name.cache_key}/#{record.send(association.foreign_key)}/#{digest_hash(sort_hash(subincludes))}-#{timestamp.utc.to_fs(klass.cache_timestamp_format)}"
106
113
  end
107
114
  else
108
115
  raise ArgumentError, 'Unkown association type'
@@ -155,7 +162,9 @@ module StandardAPI
155
162
 
156
163
  def json_column_type(sql_type)
157
164
  case sql_type
158
- when 'timestamp without time zone'
165
+ when 'binary', 'bytea'
166
+ 'binary'
167
+ when /timestamp(\(\d+\))? without time zone/
159
168
  'datetime'
160
169
  when 'time without time zone'
161
170
  'datetime'
@@ -163,9 +172,7 @@ module StandardAPI
163
172
  'string'
164
173
  when 'json'
165
174
  'hash'
166
- when 'bigint'
167
- 'integer'
168
- when 'integer'
175
+ when 'smallint', 'bigint', 'integer'
169
176
  'integer'
170
177
  when 'jsonb'
171
178
  'hash'
@@ -20,6 +20,15 @@ module StandardAPI
20
20
  normalized[k] = case k.to_s
21
21
  when 'when', 'where', 'order'
22
22
  case v
23
+ when Array
24
+ v.map do |x|
25
+ case x
26
+ when Hash then x.to_h
27
+ when ActionController::Parameters then x.to_unsafe_h
28
+ else
29
+ x
30
+ end
31
+ end
23
32
  when Hash then v.to_h
24
33
  when ActionController::Parameters then v.to_unsafe_h
25
34
  end