jpie 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -1
- data/README.md +88 -3
- data/Rakefile +22 -0
- data/lib/json_api/configuration.rb +5 -1
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +7 -2
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +2 -1
- data/lib/json_api/resources/active_storage_blob_resource.rb +1 -9
- data/lib/json_api/resources/concerns/model_class_helpers.rb +25 -6
- data/lib/json_api/resources/resource.rb +9 -0
- data/lib/json_api/resources/resource_loader.rb +68 -19
- data/lib/json_api/routing.rb +24 -19
- data/lib/json_api/support/resource_identifier.rb +21 -4
- data/lib/json_api/support/type_conversion.rb +42 -8
- data/lib/json_api/version.rb +1 -1
- metadata +1 -8
- data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +0 -67
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +0 -44
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +0 -92
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +0 -55
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +0 -72
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +0 -114
- data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +0 -73
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e425e58d806e51b4cb6ed58345acdc3169079011dca02fa8dfae18b57bbe70c
|
|
4
|
+
data.tar.gz: e0f9316d7a675048c5c63442e9f897452ffa62358d3ca73d8e8aa288122a623c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36c41c6de0027699b5534abe379930f67fc67698aa8016635a294b8ba5624ace0bedaeaebddfbf5708c3ad58696527b1e15f177ac84c36c272581cff123a52ec
|
|
7
|
+
data.tar.gz: c948938233596ef3345bc7186e8f78575f37c1057d9675850634ddb53c36506de7b26b89f14555fdca8659124ae8ec0b9908da39324f87cff90dd82aa2c8cd8d
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
jpie (1.0
|
|
4
|
+
jpie (1.1.0)
|
|
5
5
|
actionpack (~> 8.0, >= 8.0.0)
|
|
6
6
|
rails (~> 8.0, >= 8.0.0)
|
|
7
7
|
|
|
@@ -95,6 +95,7 @@ GEM
|
|
|
95
95
|
crass (1.0.6)
|
|
96
96
|
date (3.5.0)
|
|
97
97
|
diff-lcs (1.6.2)
|
|
98
|
+
docile (1.4.1)
|
|
98
99
|
drb (2.2.3)
|
|
99
100
|
erb (6.0.0)
|
|
100
101
|
erubi (1.13.1)
|
|
@@ -266,6 +267,12 @@ GEM
|
|
|
266
267
|
rubocop (~> 1.81)
|
|
267
268
|
ruby-progressbar (1.13.0)
|
|
268
269
|
securerandom (0.4.1)
|
|
270
|
+
simplecov (0.22.0)
|
|
271
|
+
docile (~> 1.1)
|
|
272
|
+
simplecov-html (~> 0.11)
|
|
273
|
+
simplecov_json_formatter (~> 0.1)
|
|
274
|
+
simplecov-html (0.13.2)
|
|
275
|
+
simplecov_json_formatter (0.1.4)
|
|
269
276
|
simpleidn (0.2.3)
|
|
270
277
|
sqlite3 (2.8.0-aarch64-linux-gnu)
|
|
271
278
|
sqlite3 (2.8.0-aarch64-linux-musl)
|
|
@@ -315,6 +322,7 @@ DEPENDENCIES
|
|
|
315
322
|
rubocop-performance (~> 1.0)
|
|
316
323
|
rubocop-rails (~> 2.0)
|
|
317
324
|
rubocop-rspec (~> 3.0)
|
|
325
|
+
simplecov (~> 0.22)
|
|
318
326
|
sqlite3 (>= 2.1)
|
|
319
327
|
|
|
320
328
|
BUNDLED WITH
|
data/README.md
CHANGED
|
@@ -14,6 +14,7 @@ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API
|
|
|
14
14
|
- Configurable pagination options
|
|
15
15
|
- Content negotiation with Accept and Content-Type headers
|
|
16
16
|
- Support for polymorphic and STI relationships
|
|
17
|
+
- Namespace support for API versioning and modular organization
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -679,6 +680,91 @@ This is useful when you need access to features available in `ActionController::
|
|
|
679
680
|
|
|
680
681
|
**Note:** The configuration must be set before the gem's controllers are loaded. Set it in a Rails initializer that loads before `json_api` is required.
|
|
681
682
|
|
|
683
|
+
### Namespace Support
|
|
684
|
+
|
|
685
|
+
The gem supports namespaced resources, controllers, and models for API versioning or modular organization.
|
|
686
|
+
|
|
687
|
+
#### Configuration Options
|
|
688
|
+
|
|
689
|
+
```ruby
|
|
690
|
+
JSONAPI.configure do |config|
|
|
691
|
+
# Type format in JSON:API responses: :flat (default), :prefixed, or :underscore
|
|
692
|
+
# :flat -> "vendors" (namespace inferred from route context)
|
|
693
|
+
# :prefixed -> "api/v1/vendors"
|
|
694
|
+
# :underscore -> "api_v1_vendors"
|
|
695
|
+
config.namespace_type_format = :flat
|
|
696
|
+
|
|
697
|
+
# Model mapping: :same_namespace (default) or :flat
|
|
698
|
+
# :same_namespace -> API::V1::VendorResource maps to API::V1::Vendor
|
|
699
|
+
# :flat -> API::V1::VendorResource maps to Vendor
|
|
700
|
+
config.namespace_model_mapping = :same_namespace
|
|
701
|
+
|
|
702
|
+
# Fallback behavior when namespaced resource not found: true (default) or false
|
|
703
|
+
# When true, if API::V1::VendorResource not found, try VendorResource
|
|
704
|
+
config.namespace_fallback = true
|
|
705
|
+
end
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
#### Namespaced Routes
|
|
709
|
+
|
|
710
|
+
Use Rails' standard `namespace` or `scope` with `module:` to define namespaced JSON:API resources:
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
# config/routes.rb
|
|
714
|
+
Rails.application.routes.draw do
|
|
715
|
+
namespace :api do
|
|
716
|
+
namespace :v1 do
|
|
717
|
+
jsonapi_resources :widgets
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
This will:
|
|
724
|
+
|
|
725
|
+
- Look for `API::V1::WidgetsController` (falls back to `JSONAPI::ResourcesController` if not found)
|
|
726
|
+
- Load `API::V1::WidgetResource` (falls back to `WidgetResource` if `namespace_fallback: true`)
|
|
727
|
+
- Pass `jsonapi_namespace: "api/v1"` to the controller
|
|
728
|
+
|
|
729
|
+
#### Namespaced Resources
|
|
730
|
+
|
|
731
|
+
Define resources within namespaces that match your route structure:
|
|
732
|
+
|
|
733
|
+
```ruby
|
|
734
|
+
# app/resources/api/v1/widget_resource.rb
|
|
735
|
+
module API
|
|
736
|
+
module V1
|
|
737
|
+
class WidgetResource < JSONAPI::Resource
|
|
738
|
+
attributes :name, :description
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
#### Per-Resource Type Format
|
|
745
|
+
|
|
746
|
+
Override the global `namespace_type_format` for individual resources:
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
class API::V1::WidgetResource < JSONAPI::Resource
|
|
750
|
+
self.type_format = :prefixed # This resource will serialize as "api/v1/widgets"
|
|
751
|
+
|
|
752
|
+
attributes :name
|
|
753
|
+
end
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
#### Explicit Model Class
|
|
757
|
+
|
|
758
|
+
Override the automatic model class resolution:
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
class API::V1::WidgetResource < JSONAPI::Resource
|
|
762
|
+
self.model_class = Widget # Use flat Widget model regardless of config
|
|
763
|
+
|
|
764
|
+
attributes :name
|
|
765
|
+
end
|
|
766
|
+
```
|
|
767
|
+
|
|
682
768
|
## Authorization
|
|
683
769
|
|
|
684
770
|
The gem provides two optional authorization hooks:
|
|
@@ -1182,8 +1268,7 @@ Content-Type: application/vnd.api+json
|
|
|
1182
1268
|
"filename": "avatar.jpg",
|
|
1183
1269
|
"content_type": "image/jpeg",
|
|
1184
1270
|
"byte_size": 102400,
|
|
1185
|
-
"checksum": "abc123..."
|
|
1186
|
-
"url": "/rails/active_storage/blobs/.../avatar.jpg"
|
|
1271
|
+
"checksum": "abc123..."
|
|
1187
1272
|
},
|
|
1188
1273
|
"links": {
|
|
1189
1274
|
"self": "/active_storage_blobs/1",
|
|
@@ -1193,7 +1278,7 @@ Content-Type: application/vnd.api+json
|
|
|
1193
1278
|
}
|
|
1194
1279
|
```
|
|
1195
1280
|
|
|
1196
|
-
The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`,
|
|
1281
|
+
The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`, and `checksum` attributes. The download URL is available via the `links.download` property.
|
|
1197
1282
|
|
|
1198
1283
|
### Attaching Files
|
|
1199
1284
|
|
data/Rakefile
CHANGED
|
@@ -6,3 +6,25 @@ require "rspec/core/rake_task"
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
8
|
task default: :spec
|
|
9
|
+
|
|
10
|
+
# Override release task to require OTP code
|
|
11
|
+
# Usage: GEM_HOST_OTP_CODE=123456 rake release
|
|
12
|
+
Rake::Task["release"].enhance do
|
|
13
|
+
# This runs after the original release task
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Rake::Task["release"].prerequisites.unshift("release:require_otp")
|
|
17
|
+
|
|
18
|
+
namespace :release do
|
|
19
|
+
task :require_otp do
|
|
20
|
+
unless ENV["GEM_HOST_OTP_CODE"]
|
|
21
|
+
abort <<~ERROR
|
|
22
|
+
ERROR: GEM_HOST_OTP_CODE environment variable is required for release.
|
|
23
|
+
|
|
24
|
+
Usage: GEM_HOST_OTP_CODE=123456 rake release
|
|
25
|
+
|
|
26
|
+
Get your OTP code from your authenticator app.
|
|
27
|
+
ERROR
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
|
|
6
|
-
:authorization_scope, :document_meta_resolver
|
|
6
|
+
:authorization_scope, :document_meta_resolver,
|
|
7
|
+
:namespace_type_format, :namespace_model_mapping, :namespace_fallback
|
|
7
8
|
|
|
8
9
|
def initialize
|
|
9
10
|
@default_page_size = 25
|
|
@@ -13,6 +14,9 @@ module JSONAPI
|
|
|
13
14
|
@authorization_scope = nil
|
|
14
15
|
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
15
16
|
@base_controller_class = "ActionController::API"
|
|
17
|
+
@namespace_type_format = :flat
|
|
18
|
+
@namespace_model_mapping = :same_namespace
|
|
19
|
+
@namespace_fallback = true
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def base_controller_class=(value)
|
|
@@ -6,7 +6,7 @@ module JSONAPI
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
|
-
attr_reader :resource_class, :model_class
|
|
9
|
+
attr_reader :resource_class, :model_class, :jsonapi_namespace
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
protected
|
|
@@ -15,8 +15,13 @@ module JSONAPI
|
|
|
15
15
|
@resource_name = params[:resource_type].to_s.singularize
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def set_jsonapi_namespace
|
|
19
|
+
@jsonapi_namespace = params[:jsonapi_namespace].presence
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
def set_resource_class
|
|
19
|
-
|
|
23
|
+
namespace = params[:jsonapi_namespace].presence
|
|
24
|
+
@resource_class = JSONAPI::ResourceLoader.find(params[:resource_type], namespace:)
|
|
20
25
|
@model_class = @resource_class.model_class
|
|
21
26
|
rescue JSONAPI::ResourceLoader::MissingResourceClass => e
|
|
22
27
|
render_resource_not_found_error(e.message)
|
|
@@ -9,7 +9,8 @@ module JSONAPI
|
|
|
9
9
|
|
|
10
10
|
def load_jsonapi_resource
|
|
11
11
|
@resource_name = params[:resource_type]&.singularize
|
|
12
|
-
@
|
|
12
|
+
@jsonapi_namespace = params[:jsonapi_namespace].presence
|
|
13
|
+
@resource_class = JSONAPI::ResourceLoader.find(@resource_name, namespace: @jsonapi_namespace) if @resource_name
|
|
13
14
|
@model_class = @resource_class.model_class if @resource_class
|
|
14
15
|
@resource = @model_class.find(params[:id]) if params[:id] && @model_class
|
|
15
16
|
rescue JSONAPI::ResourceLoader::MissingResourceClass
|
|
@@ -2,18 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
class ActiveStorageBlobResource < Resource
|
|
5
|
-
attributes :filename, :content_type, :byte_size, :checksum
|
|
5
|
+
attributes :filename, :content_type, :byte_size, :checksum
|
|
6
6
|
|
|
7
7
|
def self.model_class
|
|
8
8
|
::ActiveStorage::Blob
|
|
9
9
|
end
|
|
10
|
-
|
|
11
|
-
def url
|
|
12
|
-
return nil unless resource.persisted?
|
|
13
|
-
|
|
14
|
-
Rails.application.routes.url_helpers.rails_blob_path(resource, only_path: true)
|
|
15
|
-
rescue StandardError
|
|
16
|
-
"/rails/active_storage/blobs/#{resource.signed_id}/#{resource.filename}"
|
|
17
|
-
end
|
|
18
10
|
end
|
|
19
11
|
end
|
|
@@ -5,23 +5,42 @@ module JSONAPI
|
|
|
5
5
|
module ModelClassHelpers
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
module NamespaceResolution
|
|
9
|
+
def resolve_model_class_name
|
|
10
|
+
resource_name = name.sub(/Resource$/, "")
|
|
11
|
+
flat_namespace_mapping? ? resource_name.demodulize : resource_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def flat_namespace_mapping?
|
|
15
|
+
JSONAPI.configuration.namespace_model_mapping == :flat
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resource_namespace
|
|
19
|
+
name.deconstantize.presence&.underscore
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
8
23
|
class_methods do
|
|
24
|
+
include NamespaceResolution
|
|
25
|
+
|
|
26
|
+
attr_writer :model_class
|
|
27
|
+
|
|
9
28
|
def resource_for_model(model_class)
|
|
10
|
-
|
|
11
|
-
resource_const.safe_constantize if resource_const.respond_to?(:safe_constantize) || defined?(ActiveSupport)
|
|
29
|
+
"#{model_class.name}Resource".safe_constantize
|
|
12
30
|
rescue NameError
|
|
13
31
|
nil
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
def model_class
|
|
17
|
-
|
|
35
|
+
return @model_class if defined?(@model_class) && @model_class
|
|
36
|
+
|
|
37
|
+
resolve_model_class_name.constantize
|
|
18
38
|
end
|
|
19
39
|
|
|
20
40
|
def safe_model_class
|
|
21
|
-
return nil unless respond_to?(:name) && name
|
|
22
|
-
return nil unless defined?(ActiveSupport)
|
|
41
|
+
return nil unless respond_to?(:name) && name && defined?(ActiveSupport)
|
|
23
42
|
|
|
24
|
-
|
|
43
|
+
resolve_model_class_name&.safe_constantize
|
|
25
44
|
rescue NoMethodError
|
|
26
45
|
nil
|
|
27
46
|
end
|
|
@@ -16,6 +16,15 @@ module JSONAPI
|
|
|
16
16
|
include Resources::MetaDsl
|
|
17
17
|
include Resources::ModelClassHelpers
|
|
18
18
|
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :type_format
|
|
21
|
+
|
|
22
|
+
def inherited(subclass)
|
|
23
|
+
super
|
|
24
|
+
subclass.type_format = type_format
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def initialize(record = nil, context = {})
|
|
20
29
|
@record = record
|
|
21
30
|
@context = context
|
|
@@ -3,33 +3,82 @@
|
|
|
3
3
|
module JSONAPI
|
|
4
4
|
class ResourceLoader
|
|
5
5
|
class MissingResourceClass < JSONAPI::Error
|
|
6
|
-
def initialize(resource_type)
|
|
7
|
-
|
|
6
|
+
def initialize(resource_type, namespace: nil)
|
|
7
|
+
class_hint = build_class_hint(resource_type, namespace)
|
|
8
|
+
super("Resource class for '#{resource_type}' not found. Define #{class_hint} < JSONAPI::Resource")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def build_class_hint(resource_type, namespace)
|
|
14
|
+
base = "#{resource_type.singularize.classify}Resource"
|
|
15
|
+
namespace.present? ? "#{namespace.to_s.camelize}::#{base}" : base
|
|
8
16
|
end
|
|
9
17
|
end
|
|
10
18
|
|
|
11
|
-
def self.find(resource_type)
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
def self.find(resource_type, namespace: nil)
|
|
20
|
+
return find_namespaced(resource_type, namespace) if namespace.present?
|
|
21
|
+
|
|
22
|
+
find_flat(resource_type, namespace)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.find_namespaced(resource_type, namespace)
|
|
26
|
+
namespaced_class = build_resource_class_name(resource_type, namespace)
|
|
27
|
+
namespaced_class.constantize
|
|
14
28
|
rescue NameError
|
|
15
|
-
raise MissingResourceClass,
|
|
29
|
+
raise MissingResourceClass.new(resource_type, namespace:) unless JSONAPI.configuration.namespace_fallback
|
|
30
|
+
|
|
31
|
+
find_flat(resource_type, namespace)
|
|
16
32
|
end
|
|
17
33
|
|
|
18
|
-
def self.
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
def self.find_flat(resource_type, namespace)
|
|
35
|
+
flat_class = build_resource_class_name(resource_type, nil)
|
|
36
|
+
flat_class.constantize
|
|
37
|
+
rescue NameError
|
|
38
|
+
raise MissingResourceClass.new(resource_type, namespace:)
|
|
39
|
+
end
|
|
21
40
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
find(resource_type)
|
|
26
|
-
rescue MissingResourceClass
|
|
27
|
-
# For STI subclasses, fall back to base class resource
|
|
28
|
-
raise unless model_class.respond_to?(:base_class) && model_class.base_class != model_class
|
|
41
|
+
def self.build_resource_class_name(resource_type, namespace)
|
|
42
|
+
base = "#{resource_type.singularize.classify}Resource"
|
|
43
|
+
return base unless namespace.present?
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
"#{namespace.to_s.camelize}::#{base}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.find_for_model(model_class, namespace: nil)
|
|
49
|
+
return ActiveStorageBlobResource if active_storage_blob?(model_class)
|
|
50
|
+
|
|
51
|
+
find_resource_for_model(model_class, namespace)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.find_resource_for_model(model_class, namespace)
|
|
55
|
+
effective_namespace = namespace || extract_namespace_from_model(model_class)
|
|
56
|
+
resource_type = model_class.name.demodulize.underscore.pluralize
|
|
57
|
+
|
|
58
|
+
find(resource_type, namespace: effective_namespace)
|
|
59
|
+
rescue MissingResourceClass
|
|
60
|
+
find_base_class_resource(model_class, effective_namespace)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.find_base_class_resource(model_class, effective_namespace)
|
|
64
|
+
raise MissingResourceClass, model_class.name unless sti_subclass?(model_class)
|
|
65
|
+
|
|
66
|
+
base_resource_type = model_class.base_class.name.demodulize.underscore.pluralize
|
|
67
|
+
base_namespace = extract_namespace_from_model(model_class.base_class) || effective_namespace
|
|
68
|
+
find(base_resource_type, namespace: base_namespace)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.active_storage_blob?(model_class)
|
|
72
|
+
defined?(::ActiveStorage) && model_class == ::ActiveStorage::Blob
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.sti_subclass?(model_class)
|
|
76
|
+
model_class.respond_to?(:base_class) && model_class.base_class != model_class
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.extract_namespace_from_model(model_class)
|
|
80
|
+
parts = model_class.name.deconstantize
|
|
81
|
+
parts.presence&.underscore
|
|
33
82
|
end
|
|
34
83
|
end
|
|
35
84
|
end
|
data/lib/json_api/routing.rb
CHANGED
|
@@ -4,32 +4,39 @@ module JSONAPI
|
|
|
4
4
|
module Routing
|
|
5
5
|
def jsonapi_resources(resource, controller: nil, defaults: {}, sti: false, **options, &)
|
|
6
6
|
resource_name = resource.to_s
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
defaults = defaults.merge(format: :jsonapi, resource_type: resource_name)
|
|
7
|
+
namespace = extract_namespace_from_scope
|
|
8
|
+
controller ||= detect_controller(resource_name, namespace)
|
|
9
|
+
defaults = build_jsonapi_defaults(defaults, resource_name, namespace)
|
|
11
10
|
options[:only] = :index if sti
|
|
12
11
|
|
|
12
|
+
JSONAPI::ResourceLoader.find(resource_name, namespace:)
|
|
13
13
|
define_resource_routes(resource, controller, defaults, options, &)
|
|
14
|
-
define_sti_routes(resource, resource_name, defaults, sti)
|
|
14
|
+
define_sti_routes(resource, resource_name, defaults, sti, namespace)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_jsonapi_defaults(defaults, resource_name, namespace)
|
|
18
|
+
defaults.merge(format: :jsonapi, resource_type: resource_name, jsonapi_namespace: namespace)
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
private
|
|
18
22
|
|
|
19
|
-
def
|
|
20
|
-
|
|
23
|
+
def extract_namespace_from_scope
|
|
24
|
+
@scope[:module]&.to_s.presence
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def detect_controller(resource_name, namespace = nil)
|
|
28
|
+
potential_controller_name = build_controller_name(resource_name, namespace)
|
|
21
29
|
potential_controller_name.constantize
|
|
22
30
|
nil
|
|
23
31
|
rescue NameError
|
|
24
32
|
"json_api/resources"
|
|
25
33
|
end
|
|
26
34
|
|
|
27
|
-
def build_controller_name(resource_name)
|
|
28
|
-
scoped_module = @scope[:module]
|
|
35
|
+
def build_controller_name(resource_name, namespace = nil)
|
|
29
36
|
base_name = "#{resource_name.pluralize.camelize}Controller"
|
|
30
|
-
return base_name unless
|
|
37
|
+
return base_name unless namespace
|
|
31
38
|
|
|
32
|
-
"#{
|
|
39
|
+
"#{namespace.to_s.camelize}::#{base_name}"
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
def define_resource_routes(resource, controller, defaults, options, &block)
|
|
@@ -47,15 +54,13 @@ module JSONAPI
|
|
|
47
54
|
end
|
|
48
55
|
end
|
|
49
56
|
|
|
50
|
-
def define_sti_routes(resource, resource_name, defaults, sti)
|
|
57
|
+
def define_sti_routes(resource, resource_name, defaults, sti, namespace = nil)
|
|
51
58
|
return unless sti
|
|
52
59
|
|
|
53
60
|
if sti.is_a?(Array)
|
|
54
|
-
define_explicit_sti_routes(sti,
|
|
55
|
-
defaults,)
|
|
61
|
+
define_explicit_sti_routes(sti, defaults)
|
|
56
62
|
else
|
|
57
|
-
define_auto_sti_routes(resource, resource_name,
|
|
58
|
-
defaults,)
|
|
63
|
+
define_auto_sti_routes(resource, resource_name, defaults, namespace)
|
|
59
64
|
end
|
|
60
65
|
end
|
|
61
66
|
|
|
@@ -63,13 +68,13 @@ module JSONAPI
|
|
|
63
68
|
sti_resources.each { |sub_resource_name| jsonapi_resources(sub_resource_name, defaults:) }
|
|
64
69
|
end
|
|
65
70
|
|
|
66
|
-
def define_auto_sti_routes(resource, resource_name, defaults)
|
|
67
|
-
resource_class = JSONAPI::ResourceLoader.find(resource_name)
|
|
71
|
+
def define_auto_sti_routes(resource, resource_name, defaults, namespace = nil)
|
|
72
|
+
resource_class = JSONAPI::ResourceLoader.find(resource_name, namespace:)
|
|
68
73
|
model_class = resource_class.model_class
|
|
69
74
|
return unless model_class.respond_to?(:descendants)
|
|
70
75
|
|
|
71
76
|
model_class.descendants.each do |subclass|
|
|
72
|
-
sub_resource_name = subclass.name.underscore.pluralize.to_sym
|
|
77
|
+
sub_resource_name = subclass.name.demodulize.underscore.pluralize.to_sym
|
|
73
78
|
next if sub_resource_name == resource.to_sym
|
|
74
79
|
|
|
75
80
|
jsonapi_resources(sub_resource_name, defaults:)
|
|
@@ -27,18 +27,35 @@ module JSONAPI
|
|
|
27
27
|
raise ArgumentError, "Missing type or id in relationship data" unless type && id
|
|
28
28
|
|
|
29
29
|
is_polymorphic = polymorphic_association?(definition, relationship_name)
|
|
30
|
-
validate_relationship_type!(type, association) unless is_polymorphic
|
|
30
|
+
validate_relationship_type!(type, association, definition) unless is_polymorphic
|
|
31
31
|
|
|
32
32
|
find_related_record(type, id, association, is_polymorphic)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def validate_relationship_type!(type, association)
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
def validate_relationship_type!(type, association, definition = nil)
|
|
36
|
+
# Get expected type using the same format as the incoming type
|
|
37
|
+
format = resolve_type_format_from_incoming(type, definition)
|
|
38
|
+
expected_type = TypeConversion.model_type_name(association.klass, format:)
|
|
39
|
+
|
|
40
|
+
# Also check flat type for backwards compatibility
|
|
41
|
+
expected_flat = TypeConversion.model_type_name(association.klass, format: :flat)
|
|
42
|
+
|
|
43
|
+
return if type == expected_type || type == expected_flat
|
|
38
44
|
|
|
39
45
|
raise ArgumentError, "Invalid relationship type: expected #{expected_type}, got #{type}"
|
|
40
46
|
end
|
|
41
47
|
|
|
48
|
+
def resolve_type_format_from_incoming(type, definition)
|
|
49
|
+
# Detect format from incoming type string
|
|
50
|
+
if type.include?("/")
|
|
51
|
+
:prefixed
|
|
52
|
+
elsif definition.respond_to?(:type_format) && definition.type_format
|
|
53
|
+
definition.type_format
|
|
54
|
+
else
|
|
55
|
+
JSONAPI.configuration.namespace_type_format
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
42
59
|
def find_related_record(type, id, association, is_polymorphic)
|
|
43
60
|
related_model_class = resolve_related_model_class(type, association, is_polymorphic)
|
|
44
61
|
related_model_class.find(id)
|
|
@@ -4,18 +4,52 @@ module JSONAPI
|
|
|
4
4
|
module TypeConversion
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def type_to_class_name(type)
|
|
8
|
-
type.to_s
|
|
7
|
+
def type_to_class_name(type, namespace: nil)
|
|
8
|
+
type_str = type.to_s
|
|
9
|
+
return prefixed_type_to_class(type_str) if type_str.include?("/")
|
|
10
|
+
return "#{namespace.to_s.camelize}::#{type_str.singularize.classify}" if namespace.present?
|
|
11
|
+
|
|
12
|
+
type_str.singularize.classify
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prefixed_type_to_class(type_str)
|
|
16
|
+
parts = type_str.split("/")
|
|
17
|
+
"#{parts[0..-2].join("/").camelize}::#{parts.last.singularize.classify}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def model_type_name(model_class, format: nil)
|
|
21
|
+
format ||= JSONAPI.configuration.namespace_type_format
|
|
22
|
+
|
|
23
|
+
full_name = model_class.name.underscore.pluralize
|
|
24
|
+
|
|
25
|
+
format_type_name(full_name, format)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resource_type_name(definition_class, format: nil)
|
|
29
|
+
format ||= resolve_type_format(definition_class)
|
|
30
|
+
|
|
31
|
+
full_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
|
|
32
|
+
|
|
33
|
+
format_type_name(full_name, format)
|
|
9
34
|
end
|
|
10
35
|
|
|
11
|
-
def
|
|
12
|
-
|
|
36
|
+
def format_type_name(full_name, format)
|
|
37
|
+
case format
|
|
38
|
+
when :prefixed
|
|
39
|
+
full_name
|
|
40
|
+
when :underscore
|
|
41
|
+
full_name.tr("/", "_")
|
|
42
|
+
else # :flat
|
|
43
|
+
full_name.split("/").last
|
|
44
|
+
end
|
|
13
45
|
end
|
|
14
46
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
47
|
+
def resolve_type_format(definition_class)
|
|
48
|
+
if definition_class.respond_to?(:type_format) && definition_class.type_format
|
|
49
|
+
definition_class.type_format
|
|
50
|
+
else
|
|
51
|
+
JSONAPI.configuration.namespace_type_format
|
|
52
|
+
end
|
|
19
53
|
end
|
|
20
54
|
end
|
|
21
55
|
end
|
data/lib/json_api/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jpie
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Emil Kampp
|
|
@@ -90,13 +90,6 @@ files:
|
|
|
90
90
|
- lib/json_api/controllers/concerns/relationships/serialization.rb
|
|
91
91
|
- lib/json_api/controllers/concerns/relationships/sorting.rb
|
|
92
92
|
- lib/json_api/controllers/concerns/relationships/updating.rb
|
|
93
|
-
- lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb
|
|
94
|
-
- lib/json_api/controllers/concerns/relationships_controller/events.rb
|
|
95
|
-
- lib/json_api/controllers/concerns/relationships_controller/removal.rb
|
|
96
|
-
- lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb
|
|
97
|
-
- lib/json_api/controllers/concerns/relationships_controller/serialization.rb
|
|
98
|
-
- lib/json_api/controllers/concerns/relationships_controller/sorting.rb
|
|
99
|
-
- lib/json_api/controllers/concerns/relationships_controller/updating.rb
|
|
100
93
|
- lib/json_api/controllers/concerns/resource_actions.rb
|
|
101
94
|
- lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb
|
|
102
95
|
- lib/json_api/controllers/concerns/resource_actions/field_validation.rb
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module ActiveStorageRemoval
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def remove_active_storage_relationship(relationship_data)
|
|
11
|
-
reflection = @resource.class.reflect_on_attachment(@relationship_name)
|
|
12
|
-
if reflection&.macro == :has_many_attached
|
|
13
|
-
remove_many_active_storage_attachments(relationship_data)
|
|
14
|
-
else
|
|
15
|
-
remove_one_active_storage_attachment(relationship_data)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def remove_many_active_storage_attachments(relationship_data)
|
|
20
|
-
raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
|
|
21
|
-
return if relationship_data.empty?
|
|
22
|
-
|
|
23
|
-
attachment_proxy = @resource.public_send(@relationship_name)
|
|
24
|
-
return unless attachment_proxy.attached?
|
|
25
|
-
|
|
26
|
-
blob_ids = extract_blob_ids(relationship_data)
|
|
27
|
-
attachments = attachment_proxy.attachments.where(blob_id: blob_ids)
|
|
28
|
-
attachments.each(&:purge)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def remove_one_active_storage_attachment(relationship_data)
|
|
32
|
-
validate_single_removal_identifier!(relationship_data)
|
|
33
|
-
return if relationship_data.nil?
|
|
34
|
-
|
|
35
|
-
attachment_proxy = @resource.public_send(@relationship_name)
|
|
36
|
-
return unless attachment_proxy.attached?
|
|
37
|
-
|
|
38
|
-
blob_id = extract_single_blob_id(relationship_data)
|
|
39
|
-
attachment = attachment_proxy.attachments.find_by(blob_id:)
|
|
40
|
-
attachment&.purge
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def extract_blob_ids(relationship_data)
|
|
44
|
-
relationship_data.map do |identifier|
|
|
45
|
-
extract_blob_id_from_identifier(identifier)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def extract_single_blob_id(relationship_data)
|
|
50
|
-
extract_blob_id_from_identifier(relationship_data)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def extract_blob_id_from_identifier(identifier)
|
|
54
|
-
type = RelationshipHelpers.extract_type_from_identifier(identifier)
|
|
55
|
-
id = RelationshipHelpers.extract_id_from_identifier(identifier)
|
|
56
|
-
validate_blob_type!(type)
|
|
57
|
-
id.to_i
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def validate_blob_type!(type)
|
|
61
|
-
return if self.class.active_storage_blob_type?(type)
|
|
62
|
-
|
|
63
|
-
raise ArgumentError, "Expected active_storage_blobs type, got #{type}"
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module Events
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def emit_relationship_event(action, relationship_data)
|
|
11
|
-
resource_type_name = params[:resource_type] || @resource_name.pluralize
|
|
12
|
-
|
|
13
|
-
JSONAPI::Instrumentation.relationship_event(
|
|
14
|
-
action:,
|
|
15
|
-
resource_type: resource_type_name,
|
|
16
|
-
resource_id: @resource.id,
|
|
17
|
-
relationship_name: @relationship_name.to_s,
|
|
18
|
-
related_ids: extract_related_ids(relationship_data),
|
|
19
|
-
related_type: extract_related_type(relationship_data),
|
|
20
|
-
)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def extract_related_ids(relationship_data)
|
|
24
|
-
return nil if relationship_data.nil?
|
|
25
|
-
|
|
26
|
-
if relationship_data.is_a?(Array)
|
|
27
|
-
relationship_data.filter_map { |item| item[:id] }
|
|
28
|
-
else
|
|
29
|
-
[relationship_data[:id]].compact
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def extract_related_type(relationship_data)
|
|
34
|
-
return nil if relationship_data.nil?
|
|
35
|
-
|
|
36
|
-
if relationship_data.is_a?(Array)
|
|
37
|
-
relationship_data.first&.dig(:type)
|
|
38
|
-
else
|
|
39
|
-
relationship_data[:type]
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "active_storage_removal"
|
|
4
|
-
|
|
5
|
-
module JSONAPI
|
|
6
|
-
module RelationshipsController
|
|
7
|
-
module Removal
|
|
8
|
-
extend ActiveSupport::Concern
|
|
9
|
-
include ActiveStorageRemoval
|
|
10
|
-
|
|
11
|
-
private
|
|
12
|
-
|
|
13
|
-
def remove_relationship(relationship_data)
|
|
14
|
-
if active_storage_attachment?(@relationship_name, @resource.class)
|
|
15
|
-
return remove_active_storage_relationship(relationship_data)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
remove_association_relationship(relationship_data)
|
|
19
|
-
rescue ActiveRecord::NotNullViolation, ActiveRecord::RecordInvalid => e
|
|
20
|
-
raise ArgumentError, "Cannot remove relationship: #{e.message}"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def remove_association_relationship(relationship_data)
|
|
24
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
25
|
-
raise ArgumentError, "Association not found: #{@relationship_name}" unless association
|
|
26
|
-
|
|
27
|
-
ensure_relationship_writable!(association)
|
|
28
|
-
dispatch_relationship_removal(association, relationship_data)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def dispatch_relationship_removal(association, relationship_data)
|
|
32
|
-
if association.collection?
|
|
33
|
-
remove_from_many_relationship(association, relationship_data)
|
|
34
|
-
else
|
|
35
|
-
remove_from_one_relationship(association, relationship_data)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def remove_from_many_relationship(association, relationship_data)
|
|
40
|
-
raise ArgumentError, "Expected array for to-many relationship removal" unless relationship_data.is_a?(Array)
|
|
41
|
-
return if relationship_data.empty?
|
|
42
|
-
|
|
43
|
-
remove_from_collection(association, relationship_data)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def remove_from_collection(association, relationship_data)
|
|
47
|
-
collection_ids = @resource.public_send(@relationship_name).pluck(:id)
|
|
48
|
-
foreign_key = association.foreign_key
|
|
49
|
-
|
|
50
|
-
ActiveRecord::Base.transaction do
|
|
51
|
-
relationship_data.each do |identifier|
|
|
52
|
-
remove_resource_from_collection(identifier, association, collection_ids, foreign_key)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def remove_resource_from_collection(identifier, association, collection_ids, foreign_key)
|
|
58
|
-
resource = RelationshipHelpers.resolve_and_find_related_resource(
|
|
59
|
-
identifier,
|
|
60
|
-
association:,
|
|
61
|
-
resource_class: @resource_class,
|
|
62
|
-
relationship_name: @relationship_name,
|
|
63
|
-
)
|
|
64
|
-
return unless collection_ids.include?(resource.id)
|
|
65
|
-
|
|
66
|
-
resource.update!(foreign_key => nil)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def remove_from_one_relationship(association, relationship_data)
|
|
70
|
-
validate_single_removal_identifier!(relationship_data)
|
|
71
|
-
return if relationship_data.nil?
|
|
72
|
-
|
|
73
|
-
remove_single_association(association, relationship_data)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def validate_single_removal_identifier!(relationship_data)
|
|
77
|
-
return unless relationship_data.is_a?(Array)
|
|
78
|
-
|
|
79
|
-
raise ArgumentError, "Expected single resource identifier for to-one relationship"
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def remove_single_association(association, relationship_data)
|
|
83
|
-
related_resource = find_related_resource(relationship_data, association)
|
|
84
|
-
current_resource = @resource.public_send(@relationship_name)
|
|
85
|
-
return unless current_resource == related_resource
|
|
86
|
-
|
|
87
|
-
@resource.public_send("#{@relationship_name}=", nil)
|
|
88
|
-
@resource.save!
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module ResponseHelpers
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def build_show_response
|
|
11
|
-
response_data = { data: serialize_relationship_data, links: serialize_relationship_links }
|
|
12
|
-
meta = serialize_relationship_meta
|
|
13
|
-
response_data[:meta] = meta if meta.present?
|
|
14
|
-
response_data
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def save_and_render_relationship(relationship_data)
|
|
18
|
-
if @resource.save
|
|
19
|
-
emit_relationship_event(:updated, relationship_data)
|
|
20
|
-
render_relationship_response
|
|
21
|
-
else
|
|
22
|
-
render_validation_errors(@resource)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def render_relationship_response
|
|
27
|
-
render json: {
|
|
28
|
-
data: serialize_relationship_data,
|
|
29
|
-
links: serialize_relationship_links,
|
|
30
|
-
meta: serialize_relationship_meta,
|
|
31
|
-
}.compact, status: :ok
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def finalize_relationship_removal(relationship_data)
|
|
35
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
36
|
-
return emit_and_respond_no_content(relationship_data) unless association && !association.collection?
|
|
37
|
-
|
|
38
|
-
save_to_one_removal(relationship_data)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def save_to_one_removal(relationship_data)
|
|
42
|
-
if @resource.save
|
|
43
|
-
emit_and_respond_no_content(relationship_data)
|
|
44
|
-
else
|
|
45
|
-
render_validation_errors(@resource)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def emit_and_respond_no_content(relationship_data)
|
|
50
|
-
emit_relationship_event(:removed, relationship_data)
|
|
51
|
-
head :no_content
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module Serialization
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def serialize_relationship_data
|
|
11
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
12
|
-
return nil unless association
|
|
13
|
-
|
|
14
|
-
related = fetch_related_data(association)
|
|
15
|
-
serialize_related(related, association)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def fetch_related_data(association)
|
|
19
|
-
related = @resource.public_send(@relationship_name)
|
|
20
|
-
return related unless association.collection? && related.respond_to?(:order)
|
|
21
|
-
|
|
22
|
-
apply_sorting_to_relationship(related, association)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def serialize_related(related, association)
|
|
26
|
-
return serialize_collection_relationship(related, association) if association.collection?
|
|
27
|
-
|
|
28
|
-
serialize_single_relationship(related, association) if related
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def serialize_collection_relationship(related, association)
|
|
32
|
-
return [] if related.nil?
|
|
33
|
-
|
|
34
|
-
related.map { |r| serialize_resource_identifier(r, association) }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def serialize_single_relationship(related, association)
|
|
38
|
-
serialize_resource_identifier(related, association)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def serialize_resource_identifier(resource_instance, association)
|
|
42
|
-
RelationshipHelpers.serialize_resource_identifier(
|
|
43
|
-
resource_instance,
|
|
44
|
-
association:,
|
|
45
|
-
resource_class: @resource_class,
|
|
46
|
-
)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def serialize_relationship_links
|
|
50
|
-
{
|
|
51
|
-
self: relationship_self_url,
|
|
52
|
-
related: relationship_related_url,
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def relationship_self_url
|
|
57
|
-
"/#{params[:resource_type]}/#{params[:id]}/relationships/#{params[:relationship_name]}"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def relationship_related_url
|
|
61
|
-
"/#{params[:resource_type]}/#{params[:id]}/#{params[:relationship_name]}"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def serialize_relationship_meta
|
|
65
|
-
relationship_def = find_relationship_definition
|
|
66
|
-
return nil unless relationship_def
|
|
67
|
-
|
|
68
|
-
relationship_def[:meta]
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module Sorting
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def apply_sorting_to_relationship(related, association)
|
|
11
|
-
sorts = parse_sort_param
|
|
12
|
-
return related if sorts.empty?
|
|
13
|
-
|
|
14
|
-
related_model = association.klass
|
|
15
|
-
db_sorts, virtual_sorts = partition_sorts_by_type(sorts, related_model)
|
|
16
|
-
|
|
17
|
-
related = apply_db_sorts(related, db_sorts)
|
|
18
|
-
apply_virtual_sorts_if_needed(related, virtual_sorts, related_model)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def partition_sorts_by_type(sorts, related_model)
|
|
22
|
-
sorts.partition do |sort_field|
|
|
23
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
24
|
-
related_model.column_names.include?(field.to_s)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def apply_db_sorts(related, db_sorts)
|
|
29
|
-
db_sorts.each do |sort_field|
|
|
30
|
-
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
31
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
32
|
-
related = related.order(field => direction)
|
|
33
|
-
end
|
|
34
|
-
related
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def apply_virtual_sorts_if_needed(related, virtual_sorts, related_model)
|
|
38
|
-
return related unless virtual_sorts.any?
|
|
39
|
-
|
|
40
|
-
resource_class = ResourceLoader.find_for_model(related_model)
|
|
41
|
-
sort_records_by_virtual_attributes(related.to_a, virtual_sorts, resource_class)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def sort_records_by_virtual_attributes(records, virtual_sorts, resource_class)
|
|
45
|
-
records.sort do |a, b|
|
|
46
|
-
compare_records(a, b, virtual_sorts, resource_class)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def compare_records(record_a, record_b, virtual_sorts, resource_class)
|
|
51
|
-
virtual_sorts.each do |sort_field|
|
|
52
|
-
comparison = compare_by_field(record_a, record_b, sort_field, resource_class)
|
|
53
|
-
return comparison unless comparison.zero?
|
|
54
|
-
end
|
|
55
|
-
0
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def compare_by_field(record_a, record_b, sort_field, resource_class)
|
|
59
|
-
direction = RelationshipHelpers.extract_sort_direction(sort_field)
|
|
60
|
-
field = RelationshipHelpers.extract_sort_field_name(sort_field)
|
|
61
|
-
|
|
62
|
-
value_a = get_virtual_value(record_a, field, resource_class)
|
|
63
|
-
value_b = get_virtual_value(record_b, field, resource_class)
|
|
64
|
-
|
|
65
|
-
comparison = compare_values(value_a, value_b)
|
|
66
|
-
direction == :desc ? -comparison : comparison
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def get_virtual_value(record, field, resource_class)
|
|
70
|
-
resource_instance = resource_class.new(record, {})
|
|
71
|
-
field_sym = field.to_sym
|
|
72
|
-
return resource_instance.public_send(field_sym) if resource_instance.respond_to?(field_sym, false)
|
|
73
|
-
|
|
74
|
-
nil
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def compare_values(value_a, value_b)
|
|
78
|
-
return 0 if value_a.nil? && value_b.nil?
|
|
79
|
-
return -1 if value_a.nil?
|
|
80
|
-
return 1 if value_b.nil?
|
|
81
|
-
|
|
82
|
-
value_a <=> value_b
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def validate_sort_param
|
|
86
|
-
sorts = parse_sort_param
|
|
87
|
-
return if sorts.empty?
|
|
88
|
-
|
|
89
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
90
|
-
return unless association&.collection?
|
|
91
|
-
|
|
92
|
-
validate_sort_fields_for_association(sorts, association)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def validate_sort_fields_for_association(sorts, association)
|
|
96
|
-
resource_class = ResourceLoader.find_for_model(association.klass)
|
|
97
|
-
valid_fields = valid_sort_fields_for_resource(resource_class, association.klass)
|
|
98
|
-
invalid_fields = invalid_sort_fields_for_columns(sorts, valid_fields)
|
|
99
|
-
return if invalid_fields.empty?
|
|
100
|
-
|
|
101
|
-
render_invalid_sort_fields(invalid_fields)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def render_invalid_sort_fields(invalid_fields)
|
|
105
|
-
render_parameter_errors(
|
|
106
|
-
invalid_fields,
|
|
107
|
-
title: "Invalid Sort Field",
|
|
108
|
-
detail_proc: ->(field) { "Invalid sort field requested: #{field}" },
|
|
109
|
-
source_proc: ->(_field) { { parameter: "sort" } },
|
|
110
|
-
)
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module JSONAPI
|
|
4
|
-
module RelationshipsController
|
|
5
|
-
module Updating
|
|
6
|
-
extend ActiveSupport::Concern
|
|
7
|
-
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
def update_relationship(relationship_data)
|
|
11
|
-
association = @resource.class.reflect_on_association(@relationship_name)
|
|
12
|
-
raise ArgumentError, "Association not found: #{@relationship_name}" unless association
|
|
13
|
-
|
|
14
|
-
ensure_relationship_writable!(association)
|
|
15
|
-
apply_relationship_update(association, relationship_data)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def apply_relationship_update(association, relationship_data)
|
|
19
|
-
if association.collection?
|
|
20
|
-
update_to_many_relationship(association, relationship_data)
|
|
21
|
-
else
|
|
22
|
-
update_to_one_relationship(association, relationship_data)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def update_to_many_relationship(association, relationship_data)
|
|
27
|
-
if relationship_data.nil? || empty_array?(relationship_data)
|
|
28
|
-
@resource.public_send("#{@relationship_name}=", [])
|
|
29
|
-
return
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
raise ArgumentError, "Expected array for to-many relationship" unless relationship_data.is_a?(Array)
|
|
33
|
-
|
|
34
|
-
related_resources = resolve_related_resources(relationship_data, association)
|
|
35
|
-
@resource.public_send("#{@relationship_name}=", related_resources)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def empty_array?(data)
|
|
39
|
-
data.is_a?(Array) && data.empty?
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def resolve_related_resources(relationship_data, association)
|
|
43
|
-
relationship_data.map { |identifier| find_related_resource(identifier, association) }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def update_to_one_relationship(association, relationship_data)
|
|
47
|
-
if relationship_data.nil?
|
|
48
|
-
@resource.public_send("#{@relationship_name}=", nil)
|
|
49
|
-
return
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
validate_single_identifier!(relationship_data)
|
|
53
|
-
related_resource = find_related_resource(relationship_data, association)
|
|
54
|
-
@resource.public_send("#{@relationship_name}=", related_resource)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def validate_single_identifier!(relationship_data)
|
|
58
|
-
return unless relationship_data.is_a?(Array)
|
|
59
|
-
|
|
60
|
-
raise ArgumentError, "Expected single resource identifier for to-one relationship"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def find_related_resource(identifier, association)
|
|
64
|
-
RelationshipHelpers.resolve_and_find_related_resource(
|
|
65
|
-
identifier,
|
|
66
|
-
association:,
|
|
67
|
-
resource_class: @resource_class,
|
|
68
|
-
relationship_name: @relationship_name,
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|