standardapi 6.0.0.32 → 7.1.0

Sign up to get free protection for your applications and to get access to all the features.
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