rest_framework 0.8.15 → 0.8.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/VERSION +1 -1
- data/app/views/layouts/rest_framework.html.erb +149 -128
- data/app/views/rest_framework/_head.html.erb +249 -28
- data/app/views/rest_framework/_html_form.html.erb +55 -3
- data/app/views/rest_framework/_raw_form.html.erb +31 -3
- data/docs/CNAME +1 -0
- data/docs/Gemfile +4 -0
- data/docs/Gemfile.lock +264 -0
- data/docs/_config.yml +17 -0
- data/docs/_guide/1_routers.md +110 -0
- data/docs/_guide/2_controller_mixins.md +293 -0
- data/docs/_guide/3_serializers.md +60 -0
- data/docs/_guide/4_filtering_and_ordering.md +41 -0
- data/docs/_guide/5_pagination.md +21 -0
- data/docs/_includes/anchor_headings.html +144 -0
- data/docs/_includes/head.html +35 -0
- data/docs/_includes/header.html +58 -0
- data/docs/_layouts/default.html +11 -0
- data/docs/assets/css/rest_framework.css +159 -0
- data/docs/assets/images/favicon.ico +0 -0
- data/docs/assets/js/rest_framework.js +132 -0
- data/docs/index.md +133 -0
- data/lib/rest_framework/controller_mixins/base.rb +10 -3
- data/lib/rest_framework/controller_mixins/models.rb +120 -44
- data/lib/rest_framework/filters.rb +7 -9
- data/lib/rest_framework/serializers.rb +35 -12
- data/lib/rest_framework/utils.rb +11 -2
- data/lib/rest_framework/version.rb +4 -1
- data/lib/rest_framework.rb +2 -8
- metadata +22 -6
- data/app/views/rest_framework/_form_routes.html.erb +0 -10
    
        data/docs/index.md
    ADDED
    
    | @@ -0,0 +1,133 @@ | |
| 1 | 
            +
            ---
         | 
| 2 | 
            +
            ---
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            # Rails REST Framework
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            [](https://badge.fury.io/rb/rest_framework)
         | 
| 7 | 
            +
            [](https://github.com/gregschmit/rails-rest-framework/actions/workflows/pipeline.yml)
         | 
| 8 | 
            +
            [](https://coveralls.io/github/gregschmit/rails-rest-framework?branch=master)
         | 
| 9 | 
            +
            [](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 | 
            -
                   | 
| 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.  | 
| 93 | 
            -
                #  | 
| 94 | 
            -
                 | 
| 95 | 
            -
             | 
| 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 | 
| 105 | 
            -
                    # Otherwise, if fields is nil | 
| 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  | 
| 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 | 
            -
                   | 
| 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]  | 
| 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 | 
            -
                       | 
| 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 | 
| 414 | 
            +
              def get_fields
         | 
| 381 415 | 
             
                fields = self._get_specific_action_config(:action_fields, :fields)
         | 
| 382 | 
            -
                return self.class.get_fields(input_fields: fields | 
| 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. | 
| 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 | 
| 426 | 
            +
                return self.class.find_by_fields
         | 
| 394 427 | 
             
              end
         | 
| 395 428 |  | 
| 396 | 
            -
              # Get a list of parameters allowed for the current action. | 
| 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 | 
            -
                 | 
| 410 | 
            -
                 | 
| 411 | 
            -
                 | 
| 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 | 
            -
             | 
| 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 | 
            -
                       | 
| 467 | 
            +
                      hash_variations["#{f.singularize}_ids"] = []
         | 
| 418 468 | 
             
                    elsif ref.belongs_to?
         | 
| 419 | 
            -
                       | 
| 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 | 
            -
                       | 
| 475 | 
            +
                      hash_variations["#{f}_attributes"] = {}
         | 
| 426 476 | 
             
                    else
         | 
| 427 | 
            -
                       | 
| 477 | 
            +
                      hash_variations["#{f}_attributes"] = self.class.get_field_config(f)[:sub_fields]
         | 
| 428 478 | 
             
                    end
         | 
| 429 479 | 
             
                  end
         | 
| 430 480 |  | 
| 431 | 
            -
                   | 
| 432 | 
            -
             | 
| 433 | 
            -
                 | 
| 434 | 
            -
                @_get_allowed_parameters  | 
| 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  | 
| 458 | 
            -
                 | 
| 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 | 
| 496 | 
            -
                associations = self.get_fields | 
| 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. | 
| 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 | 
| 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. | 
| 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 | 
            -
             | 
| 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. | 
| 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 | 
| 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?( | 
| 231 | 
            +
                  if f.in?(column_names)
         | 
| 227 232 | 
             
                    columns << f
         | 
| 228 | 
            -
                  elsif ref =  | 
| 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 | 
            -
                         | 
| 246 | 
            -
                         | 
| 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 = " | 
| 257 | 
            -
                        serializer_methods[method_name] =  | 
| 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 | 
| 276 | 
            -
              # | 
| 277 | 
            -
               | 
| 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 | 
| 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. | 
| 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`.
         | 
    
        data/lib/rest_framework/utils.rb
    CHANGED
    
    | @@ -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 | 
            -
                  #  | 
| 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 | 
            -
                   | 
| 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
         | 
    
        data/lib/rest_framework.rb
    CHANGED
    
    | @@ -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 =  | 
| 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 |  |