rest_framework 0.8.15 → 0.8.17

Sign up to get free protection for your applications and to get access to all the features.
data/docs/index.md ADDED
@@ -0,0 +1,133 @@
1
+ ---
2
+ ---
3
+
4
+ # Rails REST Framework
5
+
6
+ [![Gem Version](https://badge.fury.io/rb/rest_framework.svg)](https://badge.fury.io/rb/rest_framework)
7
+ [![Pipeline](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml/badge.svg)](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml)
8
+ [![Coverage](https://coveralls.io/repos/github/gregschmit/rails-rest-framework/badge.svg?branch=master)](https://coveralls.io/github/gregschmit/rails-rest-framework?branch=master)
9
+ [![Maintainability](https://api.codeclimate.com/v1/badges/ba5df7706cb544d78555/maintainability)](https://codeclimate.com/github/gregschmit/rails-rest-framework/maintainability)
10
+
11
+ A framework for DRY RESTful APIs in Ruby on Rails.
12
+
13
+ **The Problem**: Building controllers for APIs usually involves writing a lot of redundant CRUD
14
+ logic, and routing them can be obnoxious. Building and maintaining features like ordering,
15
+ filtering, and pagination can be tedious.
16
+
17
+ **The Solution**: This framework implements browsable API responses, CRUD actions for your models,
18
+ and features like ordering/filtering/pagination, so you can focus on building awesome APIs.
19
+
20
+ Website/Guide: [rails-rest-framework.com](https://rails-rest-framework.com)
21
+
22
+ Demo: [demo.rails-rest-framework.com](https://demo.rails-rest-framework.com)
23
+
24
+ Source: [github.com/gregschmit/rails-rest-framework](https://github.com/gregschmit/rails-rest-framework)
25
+
26
+ YARD Docs: [rubydoc.info/gems/rest_framework](https://rubydoc.info/gems/rest_framework)
27
+
28
+ ## Installation
29
+
30
+ Add this line to your application's Gemfile:
31
+
32
+ ```ruby
33
+ gem 'rest_framework'
34
+ ```
35
+
36
+ And then execute:
37
+
38
+ ```shell
39
+ $ bundle install
40
+ ```
41
+
42
+ Or install it yourself with:
43
+
44
+ ```shell
45
+ $ gem install rest_framework
46
+ ```
47
+
48
+ ## Quick Usage Tutorial
49
+
50
+ ### Controller Mixins
51
+
52
+ To transform a controller into a RESTful controller, you can either include `BaseControllerMixin`,
53
+ `ReadOnlyModelControllerMixin`, or `ModelControllerMixin`. `BaseControllerMixin` provides a `root`
54
+ action and a simple interface for routing arbitrary additional actions:
55
+
56
+ ```ruby
57
+ class ApiController < ApplicationController
58
+ include RESTFramework::BaseControllerMixin
59
+ self.extra_actions = {test: [:get]}
60
+
61
+ def test
62
+ render api_response({message: "Test successful!"})
63
+ end
64
+ end
65
+ ```
66
+
67
+ `ModelControllerMixin` assists with providing the standard model CRUD for your controller.
68
+
69
+ ```ruby
70
+ class Api::MoviesController < ApiController
71
+ include RESTFramework::ModelControllerMixin
72
+
73
+ self.recordset = Movie.where(enabled: true)
74
+ end
75
+ ```
76
+
77
+ `ReadOnlyModelControllerMixin` only enables list/show actions, but since we're naming this
78
+ controller in a way that doesn't make the model obvious, we can set that explicitly:
79
+
80
+ ```ruby
81
+ class Api::ReadOnlyMoviesController < ApiController
82
+ include RESTFramework::ReadOnlyModelControllerMixin
83
+
84
+ self.model = Movie
85
+ end
86
+ ```
87
+
88
+ Note that you can also override the `get_recordset` instance method to override the API behavior
89
+ dynamically per-request.
90
+
91
+ ### Routing
92
+
93
+ You can use Rails' `resource`/`resources` routers to route your API, however if you want
94
+ `extra_actions` / `extra_member_actions` to be routed automatically, then you can use `rest_route`
95
+ for non-resourceful controllers, or `rest_resource` / `rest_resources` resourceful routers. You can
96
+ also use `rest_root` to route the root of your API:
97
+
98
+ ```ruby
99
+ Rails.application.routes.draw do
100
+ rest_root :api # will find `api_controller` and route the `root` action to '/api'
101
+ namespace :api do
102
+ rest_resources :movies
103
+ rest_resources :users
104
+ end
105
+ end
106
+ ```
107
+
108
+ Or if you want the API root to be routed to `Api::RootController#root`:
109
+
110
+ ```ruby
111
+ Rails.application.routes.draw do
112
+ namespace :api do
113
+ rest_root # will route `Api::RootController#root` to '/' in this namespace ('/api')
114
+ rest_resources :movies
115
+ rest_resources :users
116
+ end
117
+ end
118
+ ```
119
+
120
+ ## Development/Testing
121
+
122
+ After you clone the repository, cd'ing into the directory should create a new gemset if you are
123
+ using RVM. Then run `bundle install` to install the appropriate gems.
124
+
125
+ To run the test suite:
126
+
127
+ ```shell
128
+ $ rails test
129
+ ```
130
+
131
+ The top-level `bin/rails` proxies all Rails commands to the test project, so you can operate it via
132
+ the usual commands. Ensure you run `rails db:setup` before running `rails server` or
133
+ `rails console`.
@@ -8,8 +8,16 @@ require_relative "../utils"
8
8
  module RESTFramework::BaseControllerMixin
9
9
  RRF_BASE_CONTROLLER_CONFIG = {
10
10
  filter_pk_from_request_body: true,
11
- exclude_body_fields: [
12
- :created_at, :created_by, :created_by_id, :updated_at, :updated_by, :updated_by_id
11
+ exclude_body_fields: %w[
12
+ created_at
13
+ created_by
14
+ created_by_id
15
+ updated_at
16
+ updated_by
17
+ updated_by_id
18
+ _method
19
+ utf8
20
+ authenticity_token
13
21
  ].freeze,
14
22
  extra_actions: nil,
15
23
  extra_member_actions: nil,
@@ -323,7 +331,6 @@ module RESTFramework::BaseControllerMixin
323
331
  @json_payload = payload.to_json if self.class.serialize_to_json
324
332
  @xml_payload = payload.to_xml if self.class.serialize_to_xml
325
333
  end
326
- @template_logo_text ||= "Rails REST Framework"
327
334
  @title ||= self.class.get_title
328
335
  @description ||= self.class.description
329
336
  @route_props, @route_groups = RESTFramework::Utils.get_routes(
@@ -3,6 +3,15 @@ require_relative "../filters"
3
3
 
4
4
  # This module provides the core functionality for controllers based on models.
5
5
  module RESTFramework::BaseModelControllerMixin
6
+ BASE64_REGEX = /data:(.*);base64,(.*)/
7
+ BASE64_TRANSLATE = ->(field, value) {
8
+ _, content_type, payload = value.match(BASE64_REGEX).to_a
9
+ return {
10
+ io: StringIO.new(Base64.decode64(payload)),
11
+ content_type: content_type,
12
+ filename: "image_#{field}#{Rack::Mime::MIME_TYPES.invert[content_type]}",
13
+ }
14
+ }
6
15
  include RESTFramework::BaseControllerMixin
7
16
 
8
17
  RRF_BASE_MODEL_CONTROLLER_CONFIG = {
@@ -89,20 +98,18 @@ module RESTFramework::BaseModelControllerMixin
89
98
  return self.get_model.human_attribute_name(s, default: super)
90
99
  end
91
100
 
92
- # Get the available fields. Returning `nil` indicates that anything should be accepted. If
93
- # `fallback` is true, then we should fallback to this controller's model columns, or an empty
94
- # array. This should always return an array of strings, no symbols, and possibly `nil` (only if
95
- # `fallback` is false).
96
- def get_fields(input_fields: nil, fallback: true)
97
- input_fields ||= self.fields if fallback
101
+ # Get the available fields. Fallback to this controller's model columns, or an empty array. This
102
+ # should always return an array of strings.
103
+ def get_fields(input_fields: nil)
104
+ input_fields ||= self.fields
98
105
 
99
106
  # If fields is a hash, then parse it.
100
107
  if input_fields.is_a?(Hash)
101
108
  return RESTFramework::Utils.parse_fields_hash(
102
109
  input_fields, self.get_model, exclude_associations: self.exclude_associations
103
110
  )
104
- elsif !input_fields && fallback
105
- # Otherwise, if fields is nil and fallback is true, then fallback to columns.
111
+ elsif !input_fields
112
+ # Otherwise, if fields is nil, then fallback to columns.
106
113
  model = self.get_model
107
114
  return model ? RESTFramework::Utils.fields_for(
108
115
  model, exclude_associations: self.exclude_associations
@@ -148,7 +155,9 @@ module RESTFramework::BaseModelControllerMixin
148
155
  end
149
156
 
150
157
  # Get metadata about the resource's fields.
151
- def get_fields_metadata
158
+ def fields_metadata
159
+ return @_fields_metadata if @_fields_metadata
160
+
152
161
  # Get metadata sources.
153
162
  model = self.get_model
154
163
  fields = self.get_fields.map(&:to_s)
@@ -156,8 +165,14 @@ module RESTFramework::BaseModelControllerMixin
156
165
  column_defaults = model.column_defaults
157
166
  reflections = model.reflections
158
167
  attributes = model._default_attributes
159
-
160
- return fields.map { |f|
168
+ readonly_attributes = model.readonly_attributes
169
+ exclude_body_fields = self.exclude_body_fields.map(&:to_s)
170
+ rich_text_association_names = model.reflect_on_all_associations(:has_one)
171
+ .collect(&:name)
172
+ .select { |n| n.to_s.start_with?("rich_text_") }
173
+ attachment_reflections = model.attachment_reflections
174
+
175
+ return @_fields_metadata = fields.map { |f|
161
176
  # Initialize metadata to make the order consistent.
162
177
  metadata = {
163
178
  type: nil,
@@ -173,6 +188,11 @@ module RESTFramework::BaseModelControllerMixin
173
188
  metadata[:primary_key] = true
174
189
  end
175
190
 
191
+ # Determine if the field is a read-only attribute.
192
+ if metadata[:primary_key] || f.in?(readonly_attributes) || f.in?(exclude_body_fields)
193
+ metadata[:read_only] = true
194
+ end
195
+
176
196
  # Determine `type`, `required`, `label`, and `kind` based on schema.
177
197
  if column = columns[f]
178
198
  metadata[:kind] = "column"
@@ -249,9 +269,22 @@ module RESTFramework::BaseModelControllerMixin
249
269
  }.compact
250
270
  end
251
271
 
272
+ # Determine if this is an ActionText "rich text".
273
+ if :"rich_text_#{f}".in?(rich_text_association_names)
274
+ metadata[:kind] = "rich_text"
275
+ end
276
+
277
+ # Determine if this is an ActiveStorage attachment.
278
+ if ref = attachment_reflections[f]
279
+ metadata[:kind] = "attachment"
280
+ metadata[:attachment] = {
281
+ macro: ref.macro,
282
+ }
283
+ end
284
+
252
285
  # Determine if this is just a method.
253
- if model.method_defined?(f)
254
- metadata[:kind] ||= "method"
286
+ if !metadata[:kind] && model.method_defined?(f)
287
+ metadata[:kind] = "method"
255
288
  end
256
289
 
257
290
  # Collect validator options into a hash on their type, while also updating `required` based
@@ -290,7 +323,8 @@ module RESTFramework::BaseModelControllerMixin
290
323
  def get_options_metadata
291
324
  return super.merge(
292
325
  {
293
- fields: self.get_fields_metadata,
326
+ primary_key: self.get_model.primary_key,
327
+ fields: self.fields_metadata,
294
328
  callbacks: self._process_action_callbacks.as_json,
295
329
  },
296
330
  )
@@ -377,9 +411,9 @@ module RESTFramework::BaseModelControllerMixin
377
411
  end
378
412
 
379
413
  # Get a list of fields, taking into account the current action.
380
- def get_fields(fallback: false)
414
+ def get_fields
381
415
  fields = self._get_specific_action_config(:action_fields, :fields)
382
- return self.class.get_fields(input_fields: fields, fallback: fallback)
416
+ return self.class.get_fields(input_fields: fields)
383
417
  end
384
418
 
385
419
  # Pass fields to get dynamic metadata based on which fields are available.
@@ -387,14 +421,12 @@ module RESTFramework::BaseModelControllerMixin
387
421
  return self.class.get_options_metadata
388
422
  end
389
423
 
390
- # Get a list of find_by fields for the current action. Do not fallback to columns in case the user
391
- # wants to find by virtual columns.
424
+ # Get a list of find_by fields for the current action.
392
425
  def get_find_by_fields
393
- return self.class.find_by_fields || self.get_fields
426
+ return self.class.find_by_fields
394
427
  end
395
428
 
396
- # Get a list of parameters allowed for the current action. By default we do not fallback to
397
- # columns so arbitrary fields can be submitted if no fields are defined.
429
+ # Get a list of parameters allowed for the current action.
398
430
  def get_allowed_parameters
399
431
  return @_get_allowed_parameters if defined?(@_get_allowed_parameters)
400
432
 
@@ -403,35 +435,54 @@ module RESTFramework::BaseModelControllerMixin
403
435
  :allowed_parameters,
404
436
  )
405
437
  return @_get_allowed_parameters if @_get_allowed_parameters
406
- return @_get_allowed_parameters = nil unless fields = self.get_fields
407
438
 
408
439
  # For fields, automatically add `_id`/`_ids` and `_attributes` variations for associations.
409
- id_variations = []
410
- variations = {}
411
- @_get_allowed_parameters = fields.map { |f|
440
+ variations = []
441
+ hash_variations = {}
442
+ reflections = self.class.get_model.reflections
443
+ @_get_allowed_parameters = self.get_fields.map { |f|
412
444
  f = f.to_s
413
- next f unless ref = self.class.get_model.reflections[f]
445
+
446
+ # ActiveStorage Integration: `has_one_attached`.
447
+ if reflections.key?("#{f}_attachment")
448
+ next f
449
+ end
450
+
451
+ # ActiveStorage Integration: `has_many_attached`.
452
+ if reflections.key?("#{f}_attachments")
453
+ hash_variations[f] = []
454
+ next nil
455
+ end
456
+
457
+ # ActionText Integration.
458
+ if reflections.key?("rich_test_#{f}")
459
+ next f
460
+ end
461
+
462
+ # Return field if it's not an association.
463
+ next f unless ref = reflections[f]
414
464
 
415
465
  if self.class.permit_id_assignment
416
466
  if ref.collection?
417
- variations["#{f.singularize}_ids"] = []
467
+ hash_variations["#{f.singularize}_ids"] = []
418
468
  elsif ref.belongs_to?
419
- id_variations << "#{f}_id"
469
+ variations << "#{f}_id"
420
470
  end
421
471
  end
422
472
 
423
473
  if self.class.permit_nested_attributes_assignment
424
474
  if self.class.allow_all_nested_attributes
425
- variations["#{f}_attributes"] = {}
475
+ hash_variations["#{f}_attributes"] = {}
426
476
  else
427
- variations["#{f}_attributes"] = self.class.get_field_config(f)[:sub_fields]
477
+ hash_variations["#{f}_attributes"] = self.class.get_field_config(f)[:sub_fields]
428
478
  end
429
479
  end
430
480
 
431
- next f
432
- }.flatten
433
- @_get_allowed_parameters += id_variations
434
- @_get_allowed_parameters << variations
481
+ # Associations are not allowed to be submitted in their bare form.
482
+ next nil
483
+ }.compact
484
+ @_get_allowed_parameters += variations
485
+ @_get_allowed_parameters << hash_variations
435
486
  return @_get_allowed_parameters
436
487
  end
437
488
 
@@ -454,14 +505,8 @@ module RESTFramework::BaseModelControllerMixin
454
505
  def get_body_params(data: nil)
455
506
  data ||= request.request_parameters
456
507
 
457
- # Filter the request body and map to strings. Return all params if we cannot resolve a list of
458
- # allowed parameters or fields.
459
- body_params = if allowed_parameters = self.get_allowed_parameters
460
- data = ActionController::Parameters.new(data)
461
- data.permit(*allowed_parameters)
462
- else
463
- data
464
- end
508
+ # Filter the request body with strong params.
509
+ body_params = ActionController::Parameters.new(data).permit(*self.get_allowed_parameters)
465
510
 
466
511
  # Filter primary key if configured.
467
512
  if self.class.filter_pk_from_request_body
@@ -471,6 +516,27 @@ module RESTFramework::BaseModelControllerMixin
471
516
  # Filter fields in `exclude_body_fields`.
472
517
  (self.class.exclude_body_fields || []).each { |f| body_params.delete(f) }
473
518
 
519
+ # ActiveStorage Integration: Translate base64 encoded attachments to upload objects.
520
+ #
521
+ # rubocop:disable Layout/LineLength
522
+ #
523
+ # Good example base64 image:
524
+ # data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=
525
+ #
526
+ # rubocop:enable Layout/LineLength
527
+ self.class.get_model.attachment_reflections.keys.each do |k|
528
+ next unless (body_params[k].is_a?(String) && body_params[k].match?(BASE64_REGEX)) ||
529
+ (body_params[k].is_a?(Array) && body_params[k].all? { |v|
530
+ v.is_a?(String) && v.match?(BASE64_REGEX)
531
+ })
532
+
533
+ if body_params[k].is_a?(Array)
534
+ body_params[k] = body_params[k].map { |v| BASE64_TRANSLATE.call(k, v) }
535
+ else
536
+ body_params[k] = BASE64_TRANSLATE.call(k, body_params[k])
537
+ end
538
+ end
539
+
474
540
  return body_params
475
541
  end
476
542
  alias_method :get_create_params, :get_body_params
@@ -492,8 +558,18 @@ module RESTFramework::BaseModelControllerMixin
492
558
 
493
559
  # Get the recordset but with any associations included to avoid N+1 queries.
494
560
  def get_recordset_with_includes
495
- reflections = self.class.get_model.reflections.keys
496
- associations = self.get_fields(fallback: true).select { |f| f.in?(reflections) }
561
+ reflections = self.class.get_model.reflections
562
+ associations = self.get_fields.map { |f|
563
+ if reflections.key?(f)
564
+ f.to_sym
565
+ elsif reflections.key?("rich_text_#{f}")
566
+ :"rich_text_#{f}"
567
+ elsif reflections.key?("#{f}_attachment")
568
+ :"#{f}_attachment"
569
+ elsif reflections.key?("#{f}_attachments")
570
+ :"#{f}_attachments"
571
+ end
572
+ }.compact
497
573
 
498
574
  if associations.any?
499
575
  return self.get_recordset.includes(associations)
@@ -11,11 +11,10 @@ end
11
11
  # A simple filtering backend that supports filtering a recordset based on fields defined on the
12
12
  # controller class.
13
13
  class RESTFramework::ModelFilter < RESTFramework::BaseFilter
14
- # Get a list of filterset fields for the current action. Fallback to columns because we don't want
15
- # to try filtering by any query parameter because that could clash with other query parameters.
14
+ # Get a list of filterset fields for the current action.
16
15
  def _get_fields
17
16
  # Always return a list of strings; `@controller.get_fields` already does this.
18
- return @controller.class.filterset_fields&.map(&:to_s) || @controller.get_fields(fallback: true)
17
+ return @controller.class.filterset_fields&.map(&:to_s) || @controller.get_fields
19
18
  end
20
19
 
21
20
  # Filter params for keys allowed by the current action's filterset_fields/fields config.
@@ -64,8 +63,7 @@ end
64
63
 
65
64
  # A filter backend which handles ordering of the recordset.
66
65
  class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
67
- # Get a list of ordering fields for the current action. Do not fallback to columns in case the
68
- # user wants to order by a virtual column.
66
+ # Get a list of ordering fields for the current action.
69
67
  def _get_fields
70
68
  return @controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
71
69
  end
@@ -88,7 +86,8 @@ class RESTFramework::ModelOrderingFilter < RESTFramework::BaseFilter
88
86
  column = field
89
87
  direction = :asc
90
88
  end
91
- next unless !fields || column.in?(fields)
89
+
90
+ next if !column.in?(fields) && column.split(".").first.in?(fields)
92
91
 
93
92
  ordering[column] = direction
94
93
  end
@@ -113,15 +112,14 @@ end
113
112
 
114
113
  # Multi-field text searching on models.
115
114
  class RESTFramework::ModelSearchFilter < RESTFramework::BaseFilter
116
- # Get a list of search fields for the current action. Fallback to columns but only grab a few
117
- # common string-like columns by default.
115
+ # Get a list of search fields for the current action.
118
116
  def _get_fields
119
117
  if search_fields = @controller.class.search_fields
120
118
  return search_fields&.map(&:to_s)
121
119
  end
122
120
 
123
121
  columns = @controller.class.get_model.column_names
124
- return @controller.get_fields(fallback: true).select { |f|
122
+ return @controller.get_fields.select { |f|
125
123
  f.in?(RESTFramework.config.search_columns) && f.in?(columns)
126
124
  }
127
125
  end
@@ -219,13 +219,18 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
219
219
  includes = {}
220
220
  methods = []
221
221
  serializer_methods = {}
222
+
223
+ column_names = @model.column_names
224
+ reflections = @model.reflections
225
+ attachment_reflections = @model.attachment_reflections
226
+
222
227
  fields.each do |f|
223
228
  field_config = @controller.class.get_field_config(f)
224
229
  next if field_config[:write_only]
225
230
 
226
- if f.in?(@model.column_names)
231
+ if f.in?(column_names)
227
232
  columns << f
228
- elsif ref = @model.reflections[f]
233
+ elsif ref = reflections[f]
229
234
  sub_columns = []
230
235
  sub_methods = []
231
236
  field_config[:sub_fields].each do |sf|
@@ -242,9 +247,8 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
242
247
  # If we need to limit the number of serialized association records, then dynamically add a
243
248
  # serializer method to do so.
244
249
  if limit = self._get_associations_limit
245
- method_name = "__rrf_limit_method_#{f}"
246
- serializer_methods[method_name] = f
247
- self.define_singleton_method(method_name) do |record|
250
+ serializer_methods[f] = f
251
+ self.define_singleton_method(f) do |record|
248
252
  next record.send(f).limit(limit).as_json(**sub_config)
249
253
  end
250
254
  else
@@ -253,8 +257,8 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
253
257
 
254
258
  # If we need to include the association count, then add it here.
255
259
  if @controller.class.native_serializer_include_associations_count
256
- method_name = "__rrf_count_method_#{f}"
257
- serializer_methods[method_name] = "#{f}.count"
260
+ method_name = "#{f}.count"
261
+ serializer_methods[method_name] = method_name
258
262
  self.define_singleton_method(method_name) do |record|
259
263
  next record.send(f).count
260
264
  end
@@ -262,6 +266,24 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
262
266
  else
263
267
  includes[f] = sub_config
264
268
  end
269
+ elsif ref = reflections["rich_text_#{f}"]
270
+ # ActionText Integration: Define rich text serializer method.
271
+ serializer_methods[f] = f
272
+ self.define_singleton_method(f) do |record|
273
+ next record.send(f).to_s
274
+ end
275
+ elsif ref = attachment_reflections[f]
276
+ # ActiveStorage Integration: Define attachment serializer method.
277
+ serializer_methods[f] = f
278
+ if ref.macro == :has_one_attached
279
+ self.define_singleton_method(f) do |record|
280
+ next record.send(f).attachment&.url
281
+ end
282
+ else
283
+ self.define_singleton_method(f) do |record|
284
+ next record.send(f).map(&:url)
285
+ end
286
+ end
265
287
  elsif @model.method_defined?(f)
266
288
  methods << f
267
289
  end
@@ -272,9 +294,10 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
272
294
  }
273
295
  end
274
296
 
275
- # Get the raw serializer config. Use `deep_dup` on any class mutables (array, hash, etc) to avoid
276
- # mutating class state.
277
- def _get_raw_serializer_config
297
+ # Get the raw serializer config, prior to any adjustments from the request.
298
+ #
299
+ # Use `deep_dup` on any class mutables (array, hash, etc) to avoid mutating class state.
300
+ def get_raw_serializer_config
278
301
  # Return a locally defined serializer config if one is defined.
279
302
  if local_config = self.get_local_native_serializer_config
280
303
  return local_config.deep_dup
@@ -286,7 +309,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
286
309
  end
287
310
 
288
311
  # If the config wasn't determined, build a serializer config from controller fields.
289
- if @model && fields = @controller&.get_fields(fallback: true)
312
+ if @model && fields = @controller&.get_fields
290
313
  return self._get_controller_serializer_config(fields.deep_dup)
291
314
  end
292
315
 
@@ -296,7 +319,7 @@ class RESTFramework::NativeSerializer < RESTFramework::BaseSerializer
296
319
 
297
320
  # Get a configuration passable to `serializable_hash` for the object, filtered if required.
298
321
  def get_serializer_config
299
- return filter_from_request(self._get_raw_serializer_config)
322
+ return filter_from_request(self.get_raw_serializer_config)
300
323
  end
301
324
 
302
325
  # Serialize a single record and merge results of `serializer_methods`.
@@ -180,7 +180,12 @@ module RESTFramework::Utils
180
180
  return model.column_names.reject { |c|
181
181
  c.in?(foreign_keys)
182
182
  } + model.reflections.map { |association, ref|
183
- # Exclude certain associations (by default, active storage and action text associations).
183
+ # Ignore associations for which we have custom integrations.
184
+ if ref.class_name.in?(%w(ActiveStorage::Attachment ActiveStorage::Blob ActionText::RichText))
185
+ next nil
186
+ end
187
+
188
+ # Exclude user-specified associations.
184
189
  if ref.class_name.in?(RESTFramework.config.exclude_association_classes)
185
190
  next nil
186
191
  end
@@ -192,7 +197,11 @@ module RESTFramework::Utils
192
197
  end
193
198
 
194
199
  next association
195
- }.compact
200
+ }.compact + model.reflect_on_all_associations(:has_one).collect(&:name).select { |n|
201
+ n.to_s.start_with?("rich_text_")
202
+ }.map { |n|
203
+ n.to_s.delete_prefix("rich_text_")
204
+ } + model.attachment_reflections.keys
196
205
  end
197
206
 
198
207
  # Get the sub-fields that may be serialized and filtered/ordered for a reflection.
@@ -28,7 +28,10 @@ module RESTFramework
28
28
  end
29
29
 
30
30
  def self.stamp_version
31
- File.write(VERSION_FILEPATH, RESTFramework::VERSION)
31
+ # Only stamp the version if it's not unknown.
32
+ if RESTFramework::VERSION != "0.unknown"
33
+ File.write(VERSION_FILEPATH, RESTFramework::VERSION)
34
+ end
32
35
  end
33
36
 
34
37
  def self.unstamp_version
@@ -21,11 +21,7 @@ module RESTFramework
21
21
  # Global configuration should be kept minimal, as controller-level configurations allows multiple
22
22
  # APIs to be defined to behave differently.
23
23
  class Config
24
- DEFAULT_EXCLUDE_ASSOCIATION_CLASSES = %w(
25
- ActionText::RichText
26
- ActiveStorage::Attachment
27
- ActiveStorage::Blob
28
- ).freeze
24
+ DEFAULT_EXCLUDE_ASSOCIATION_CLASSES = [].freeze
29
25
  DEFAULT_LABEL_FIELDS = %w(name label login title email username url).freeze
30
26
  DEFAULT_SEARCH_COLUMNS = DEFAULT_LABEL_FIELDS + %w(description note).freeze
31
27
 
@@ -78,9 +74,7 @@ module RESTFramework
78
74
  end
79
75
 
80
76
  def self.features
81
- return @features ||= {
82
- html_forms: false,
83
- }
77
+ return @features ||= {}
84
78
  end
85
79
  end
86
80