jpie 3.1.3 → 3.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +14 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +0 -114
  5. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +0 -18
  6. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +2 -19
  7. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +4 -3
  8. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +0 -24
  9. data/lib/json_api/controllers/concerns/resource_actions.rb +4 -18
  10. data/lib/json_api/controllers/relationships_controller.rb +8 -2
  11. data/lib/json_api/railtie.rb +10 -1
  12. data/lib/json_api/resources/concerns/attributes_dsl.rb +15 -0
  13. data/lib/json_api/resources/resource.rb +0 -4
  14. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +2 -3
  15. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +3 -11
  16. data/lib/json_api/serialization/concerns/relationship_processing.rb +2 -2
  17. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +0 -4
  18. data/lib/json_api/serialization/deserializer.rb +6 -2
  19. data/lib/json_api/support/param_helpers.rb +12 -0
  20. data/lib/json_api/support/relationship_guard.rb +6 -1
  21. data/lib/json_api/support/relationship_helpers.rb +0 -4
  22. data/lib/json_api/support/resource_identifier.rb +0 -4
  23. data/lib/json_api/version.rb +1 -1
  24. data/lib/json_api.rb +0 -2
  25. metadata +1 -6
  26. data/lib/json_api/controllers/concerns/resource_actions/sideposting.rb +0 -97
  27. data/lib/json_api/controllers/concerns/resource_actions/sideposting_primary_first.rb +0 -63
  28. data/lib/json_api/sideposting/lid_resolver.rb +0 -72
  29. data/lib/json_api/sideposting/order.rb +0 -11
  30. data/lib/json_api/sideposting/processor.rb +0 -118
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd99f20db06f6a541724cee1eb5c96adc2045933bc29dfff0ab55e1eaf03753c
4
- data.tar.gz: 54b68ed6a85b0091bbafdde4c87fd5bfb18d8d35df1afc9c3233a6c0c6b68976
3
+ metadata.gz: ac6d32dde9d9be0163f1f3c48a9c65770f4465176a403efdb5f0ba42f18bb4f5
4
+ data.tar.gz: 4ac43aa7aaf9194497910650cc1180800952806a7cb5ebf1ecf590decda4aaa0
5
5
  SHA512:
6
- metadata.gz: 4c319de1c2020500399d8ec1fe78392f3b2c36e147edda8405100a38bba428c4aa8229f2da61be1ea109bfd616c563e4907d9be4ad364438070d5c872e340544
7
- data.tar.gz: ea1bd9693e915dc19a9b96258d201aba8af05a4ebb9778a2fc6868336bf8cbc32276cd841c85ffedb0a7da8e8e16b21fc8e7798e46db95487c86a036bdcc5173
6
+ metadata.gz: ec7aff7ee080d6acca4bcf19cd3d72f7084c3bcb492df1d6e5c4b674711575450bd03e558267889d46cc9c436255dbc3013fc1b5bfa3df074503fdb39f342737
7
+ data.tar.gz: 6a4c2811e9df0bad8bbecc8b6a62ae5a0f9e44912d0035fad7c22a6569b383e24b574dbb35fe2cc3c9402b8157079af4f0addbef91e685e997539200c9b317b5
data/.rubocop.yml CHANGED
@@ -92,6 +92,20 @@ Style/HashSyntax:
92
92
  Lint/UnusedBlockArgument:
93
93
  Exclude:
94
94
  - "spec/jsonapi/authorization_spec.rb"
95
+ # Security PoC specs configure authorization handlers via keyword lambdas
96
+ # that intentionally ignore some of the required keyword arguments.
97
+ - "spec/requests/regression/*_spec.rb"
98
+
99
+ # Security PoC regression specs read as a single sequential narrative
100
+ # (sanity check -> attack -> assert), so a handful of expectations per example
101
+ # is clearer than splitting them across examples.
102
+ RSpec/MultipleExpectations:
103
+ Exclude:
104
+ - "spec/requests/regression/*_spec.rb"
105
+
106
+ RSpec/ExampleLength:
107
+ Exclude:
108
+ - "spec/requests/regression/*_spec.rb"
95
109
 
96
110
  # RSpec - file-specific exclusions
97
111
  RSpec/DescribedClass:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (3.1.2)
4
+ jpie (3.1.5)
5
5
  actionpack (~> 8.1, >= 8.1.0)
6
6
  pg_query (>= 4)
7
7
  prosopite (>= 1)
data/README.md CHANGED
@@ -15,7 +15,6 @@ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API
15
15
  - Content negotiation with Accept and Content-Type headers
16
16
  - Support for polymorphic and STI relationships
17
17
  - Namespace support for API versioning and modular organization
18
- - Sideposting: create primary and related resources in one request using `included` and local identifiers (`lid`)
19
18
 
20
19
  ## Installation
21
20
 
@@ -1192,119 +1191,6 @@ The gem validates relationship data:
1192
1191
  - Invalid polymorphic type (class doesn't exist) returns `400 Bad Request`
1193
1192
  - Attempting to unset linkage that cannot be nullified (e.g., foreign key has NOT NULL constraint) returns `400 Bad Request`
1194
1193
 
1195
- ## Sideposting
1196
-
1197
- Sideposting lets you create a primary resource and related resources that do not yet exist in a single POST request. The client sends the primary resource in `data` and full resource objects in `included`. Relationships on the primary can reference included resources by **local identifier** (`lid`) instead of `id`. By default the server creates the **primary** first, then creates included resources and updates the primary with resolved relationship ids (**primary-first**). You can optionally use **included-first** (create included resources first, then the primary) via a controller macro.
1198
-
1199
- This follows the JSON:API 1.1 [Local Identity](https://jsonapi.org/format/1.1/#document-resource-object-identification) convention: `lid` is a client-chosen temporary identifier for the request only; it is not stored.
1200
-
1201
- ### Sidepost order
1202
-
1203
- Controllers use **primary-first** by default: the primary resource is saved first (without lid-based relationships), then included resources are created, then the primary is updated with the resolved relationship ids. This supports flows such as signup where the user must exist before the account links to them.
1204
-
1205
- To use the legacy **included-first** order (create included resources first, then the primary), set the macro in your controller:
1206
-
1207
- ```ruby
1208
- class SomeController < JSONAPI::ResourcesController
1209
- sidepost_order :included_first
1210
- end
1211
- ```
1212
-
1213
- Valid values are `:primary_first` (default) and `:included_first`. Any other value raises `ArgumentError` at class load time.
1214
-
1215
- ### Custom create strategy
1216
-
1217
- For included resources, the default creation is `model_class.new(params).save`. You can override this per resource type by defining a class method **`sidepost_create`** on the resource class. It receives `params:` (the deserialized model attributes hash) and `controller:` and must return a persisted record or `nil`. If it returns a record, that record is used and the default create path is skipped; if it returns `nil`, the default create runs.
1218
-
1219
- Example: use a service to create the related resource instead of a bare `new`/`save`:
1220
-
1221
- ```ruby
1222
- class AccountResource < JSONAPI::Resource
1223
- def self.sidepost_create(params:, controller:)
1224
- CreateAccountService.call(
1225
- name: params["name"],
1226
- domain: params["domain"],
1227
- creator: Current.user
1228
- ).account
1229
- end
1230
- end
1231
- ```
1232
-
1233
- Use **primary-first** (default) plus `sidepost_create` on the related resource when the primary must exist first and the related resource needs custom creation logic.
1234
-
1235
- ### Request format
1236
-
1237
- - **`data`**: The primary resource. In `relationships`, use `type` and `lid` (no `id`) to reference an item in `included`.
1238
- - **`included`**: Array of full resource objects. Each can have `type`, `lid`, and `attributes` (and optionally `relationships` with further `lid` references). Order of creation is the order of `included`; a later item may reference an earlier item’s `lid`.
1239
-
1240
- Example: create a user and a new profile in one request:
1241
-
1242
- ```http
1243
- POST /users HTTP/1.1
1244
- Content-Type: application/vnd.api+json
1245
- Accept: application/vnd.api+json
1246
-
1247
- {
1248
- "data": {
1249
- "type": "users",
1250
- "attributes": {
1251
- "name": "Jane Doe",
1252
- "email": "jane@example.com"
1253
- },
1254
- "relationships": {
1255
- "profile": {
1256
- "data": { "type": "customer_profiles", "lid": "temp-profile" }
1257
- }
1258
- }
1259
- },
1260
- "included": [
1261
- {
1262
- "type": "customer_profiles",
1263
- "lid": "temp-profile",
1264
- "attributes": {
1265
- "company_name": "Acme Inc",
1266
- "industry": "Tech"
1267
- }
1268
- }
1269
- ]
1270
- }
1271
- ```
1272
-
1273
- With the default **primary-first** order, the server creates the user first, then creates the `customer_profiles` resource and assigns it an `id`, then updates the user's `profile` relationship to point to that new profile. The `lid` `"temp-profile"` is only used to match the relationship to the included object.
1274
-
1275
- ### Response
1276
-
1277
- On success the response is `201 Created` with the primary resource in `data` and the created related resources in `included`, each with a real `id`:
1278
-
1279
- ```json
1280
- {
1281
- "jsonapi": { "version": "1.1" },
1282
- "data": {
1283
- "type": "users",
1284
- "id": "123",
1285
- "attributes": { "name": "Jane Doe", "email": "jane@example.com" },
1286
- "relationships": {
1287
- "profile": { "data": { "type": "customer_profiles", "id": "456" } }
1288
- }
1289
- },
1290
- "included": [
1291
- {
1292
- "type": "customer_profiles",
1293
- "id": "456",
1294
- "attributes": { "company_name": "Acme Inc", "industry": "Tech" }
1295
- }
1296
- ]
1297
- }
1298
- ```
1299
-
1300
- ### Validation and rollback
1301
-
1302
- Creation runs in a single transaction. If any included resource fails validation, the entire request is rolled back: no primary or included resources are created. The response is `422 Unprocessable Entity` with errors that point to the failing included resource (e.g. `source.pointer` to the relevant `included` index or attribute).
1303
-
1304
- ### When sideposting is used
1305
-
1306
- Sideposting is applied when the create request includes a top-level `included` array. If `included` is present, the controller uses the configured **sidepost order** (primary-first by default, or included-first if `sidepost_order :included_first` is set). With primary-first, it creates the primary resource first (without lid-based relationships), creates included resources, resolves `lid` references to new `id`s, updates the primary with those relationships, then returns the primary and `included` in the response. With included-first, it creates included resources first, resolves `lid`s, then creates the primary and returns it with `included`.
1307
-
1308
1194
  ## Relationship Endpoint Details
1309
1195
 
1310
1196
  The gem provides dedicated endpoints for managing relationships independently of the main resource:
@@ -43,24 +43,6 @@ module JSONAPI
43
43
  render json: { errors: }, status: :unprocessable_content
44
44
  end
45
45
 
46
- def render_sidepost_validation_errors(sidepost_error)
47
- errors = sidepost_validation_errors(sidepost_error)
48
- render json: { errors: }, status: :unprocessable_content
49
- end
50
-
51
- def sidepost_validation_errors(sidepost_error)
52
- index = sidepost_error.included_index
53
- sidepost_error.record.errors.map do |error|
54
- pointer = "/included/#{index}/attributes/#{error.attribute}"
55
- {
56
- status: "422",
57
- title: "Validation Error",
58
- detail: error.full_message,
59
- source: { pointer: pointer },
60
- }
61
- end
62
- end
63
-
64
46
  def render_parameter_not_allowed_error(error)
65
47
  error_params = error.respond_to?(:params) ? error.params : []
66
48
  detail = error_params.present? ? error_params.join(", ") : "Relationship or attribute is read-only"
@@ -8,15 +8,7 @@ module JSONAPI
8
8
  private
9
9
 
10
10
  def prepare_create_params(sti_class)
11
- hash = deserialize_params(:create, model_class: sti_class)
12
- attachments = extract_active_storage_params_from_hash(hash, sti_class)
13
- clean_params(hash, attachments)
14
- apply_sti_type(sti_class, hash)
15
- [hash, attachments]
16
- end
17
-
18
- def prepare_create_params_from_data(sti_class, data)
19
- hash = deserialize_params(:create, model_class: sti_class, data:)
11
+ hash = deserialize_params(:create, model_class: sti_class, definition: determine_sti_resource_class)
20
12
  attachments = extract_active_storage_params_from_hash(hash, sti_class)
21
13
  clean_params(hash, attachments)
22
14
  apply_sti_type(sti_class, hash)
@@ -24,7 +16,7 @@ module JSONAPI
24
16
  end
25
17
 
26
18
  def prepare_update_params
27
- hash = deserialize_params(:update)
19
+ hash = deserialize_params(:update, definition: @resource_class)
28
20
  attachments = extract_active_storage_params_from_hash(hash, model_class)
29
21
  clean_params(hash, attachments)
30
22
  [hash, attachments]
@@ -53,15 +45,6 @@ module JSONAPI
53
45
  render json: serialize_resource(resource), status: :created, location: resource_url(resource)
54
46
  end
55
47
 
56
- def save_created_with_sidepost(resource, sideposted_records)
57
- raise JSONAPI::Sideposting::PrimaryValidationError, resource unless resource.save
58
-
59
- emit_resource_event(:created, resource)
60
- render json: serialize_resource_with_sidepost(resource, sideposted_records),
61
- status: :created,
62
- location: resource_url(resource)
63
- end
64
-
65
48
  def save_updated(params_hash, attachments)
66
49
  return render_validation_errors(@resource) unless @resource.update(params_hash)
67
50
 
@@ -28,7 +28,8 @@ module JSONAPI
28
28
  def load_resource_record
29
29
  return unless params[:id] && @resource_class
30
30
 
31
- scope = scope_with_includes(@resource_class.records)
31
+ authorized = apply_authorization_scope(@resource_class.records, action: action_name.to_sym)
32
+ scope = scope_with_includes(authorized)
32
33
  @resource = scope.find(params[:id])
33
34
  end
34
35
 
@@ -51,10 +52,10 @@ module JSONAPI
51
52
  JSONAPI::ParamHelpers.deep_symbolize_params(data)
52
53
  end
53
54
 
54
- def deserialize_params(action = :update, model_class: nil, data: nil)
55
+ def deserialize_params(action = :update, model_class: nil, data: nil, definition: nil)
55
56
  target = model_class || self.model_class
56
57
  payload = data.nil? ? raw_jsonapi_data : symbolize_data(data)
57
- JSONAPI::Deserializer.new(payload, model_class: target, action:).to_model_attributes
58
+ JSONAPI::Deserializer.new(payload, model_class: target, action:, definition:).to_model_attributes
58
59
  end
59
60
 
60
61
  def resource_url(resource)
@@ -14,19 +14,6 @@ module JSONAPI
14
14
  )
15
15
  end
16
16
 
17
- def serialize_resource_with_sidepost(resource, sideposted_records)
18
- result = serialize_resource(resource)
19
- return result if sideposted_records.empty?
20
-
21
- fields = parse_fields_param
22
- sideposted_objects = sideposted_records.map do |rec|
23
- JSONAPI::Serializer.new(rec).to_hash(include: [], fields:, document_meta: nil)[:data]
24
- end
25
- all_included = (result[:included] || []) + sideposted_objects
26
- result[:included] = deduplicate_included(all_included)
27
- result
28
- end
29
-
30
17
  def serialize_collection(resources)
31
18
  includes = parse_include_param
32
19
  fields = parse_fields_param
@@ -72,17 +59,6 @@ module JSONAPI
72
59
  processed.add(key)
73
60
  end
74
61
 
75
- def deduplicate_included(included_array)
76
- processed = Set.new
77
- included_array.each_with_object([]) do |inc, out|
78
- key = "#{inc[:type]}-#{inc[:id]}"
79
- next if processed.include?(key)
80
-
81
- out << inc
82
- processed.add(key)
83
- end
84
- end
85
-
86
62
  def build_collection_response(data, all_included)
87
63
  result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
88
64
  result[:included] = all_included if all_included.any?
@@ -9,7 +9,6 @@ require_relative "resource_actions/pagination"
9
9
  require_relative "resource_actions/type_validation"
10
10
  require_relative "resource_actions/crud_helpers"
11
11
  require_relative "resource_actions/resource_loading"
12
- require_relative "resource_actions/sideposting"
13
12
 
14
13
  module JSONAPI
15
14
  module ResourceActions
@@ -23,7 +22,6 @@ module JSONAPI
23
22
  include TypeValidation
24
23
  include CrudHelpers
25
24
  include ResourceLoading
26
- include Sideposting
27
25
 
28
26
  included do
29
27
  before_action :load_jsonapi_resource
@@ -49,28 +47,16 @@ module JSONAPI
49
47
  end
50
48
 
51
49
  def create
52
- perform_create
50
+ @resource = build_resource_for_create
51
+ authorize_resource_action!(@resource, action: :create)
52
+ attach_active_storage_files(@resource, @create_attachments, resource_class: determine_sti_resource_class)
53
+ save_created(@resource)
53
54
  rescue ArgumentError => e
54
55
  render_create_error(e)
55
- rescue JSONAPI::Sideposting::ValidationError => e
56
- render_sidepost_validation_errors(e)
57
- rescue JSONAPI::Sideposting::PrimaryValidationError => e
58
- render_validation_errors(e.record)
59
56
  rescue JSONAPI::Errors::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
60
57
  handle_create_exception(e)
61
58
  end
62
59
 
63
- def perform_create
64
- if sidepost_request?
65
- create_with_sidepost
66
- else
67
- @resource = build_resource_for_create
68
- authorize_resource_action!(@resource, action: :create)
69
- attach_active_storage_files(@resource, @create_attachments, resource_class: determine_sti_resource_class)
70
- save_created(@resource)
71
- end
72
- end
73
-
74
60
  def build_resource_for_create
75
61
  sti_class = determine_sti_class
76
62
  params_hash, @create_attachments = prepare_create_params(sti_class)
@@ -78,9 +78,11 @@ module JSONAPI
78
78
  raw_data = params[:data]
79
79
  return nil if raw_data.nil?
80
80
  return [] if raw_data.is_a?(Array) && raw_data.empty?
81
- return raw_data.map { |item| ParamHelpers.deep_symbolize_params(item) } if raw_data.is_a?(Array)
81
+ if raw_data.is_a?(Array)
82
+ return raw_data.map { |item| ParamHelpers.deep_symbolize_params(ParamHelpers.ensure_object!(item, "data")) }
83
+ end
82
84
 
83
- ParamHelpers.deep_symbolize_params(raw_data)
85
+ ParamHelpers.deep_symbolize_params(ParamHelpers.ensure_object!(raw_data, "data"))
84
86
  end
85
87
 
86
88
  def polymorphic_association?
@@ -90,6 +92,10 @@ module JSONAPI
90
92
  def ensure_relationship_writable!(association)
91
93
  return if active_storage_writable?(association)
92
94
 
95
+ unless @resource_class.relationship_field_writable?(@relationship_name, :update)
96
+ raise JSONAPI::AuthorizationError, "Relationship '#{@relationship_name}' is not updatable"
97
+ end
98
+
93
99
  relationship_def = find_relationship_definition
94
100
  readonly = relationship_def && (relationship_def[:options] || {})[:readonly] == true
95
101
 
@@ -57,7 +57,16 @@ module JSONAPI
57
57
 
58
58
  initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
59
59
  ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
60
- ActiveSupport::JSON.decode(raw_post)
60
+ data = ActiveSupport::JSON.decode(raw_post)
61
+ # A JSON:API document must be a single object. A top-level non-object body (a bare
62
+ # array/scalar) would otherwise leave `request.parameters` a non-Hash, and Rails' own
63
+ # Parameters#parameters then raises NoMethodError (`merge`/`deep_transform_keys!`) -> an
64
+ # unhandled 500. Reject it as a parse error so Rails renders a clean 400.
65
+ unless data.is_a?(Hash)
66
+ raise ActionDispatch::Http::Parameters::ParseError, "JSON:API request body must be an object"
67
+ end
68
+
69
+ data
61
70
  rescue JSON::ParserError => e
62
71
  raise ActionDispatch::Http::Parameters::ParseError, e.message
63
72
  end
@@ -41,6 +41,21 @@ module JSONAPI
41
41
  resolve_field_list(:@updatable_fields, :permitted_updatable_fields)
42
42
  end
43
43
 
44
+ # Whether a relationship may be written for the given action (:create or :update).
45
+ # A relationship is gated by the creatable_fields/updatable_fields allow-list only when
46
+ # the resource opted it in by naming it in one of those lists; relationships named in
47
+ # neither keep the default (readonly-only) behavior. This lets a resource make a
48
+ # relationship create-only — name it in creatable_fields, omit it from updatable_fields —
49
+ # the same way it restricts attributes.
50
+ def relationship_field_writable?(name, action)
51
+ name = name.to_sym
52
+ creatable = permitted_creatable_fields.map(&:to_sym)
53
+ updatable = permitted_updatable_fields.map(&:to_sym)
54
+ return true unless creatable.include?(name) || updatable.include?(name)
55
+
56
+ (action == :create ? creatable : updatable).include?(name)
57
+ end
58
+
44
59
  def resolve_field_list(ivar, method)
45
60
  return (instance_variable_get(ivar) || []).uniq if instance_variable_defined?(ivar)
46
61
  return superclass.public_send(method).uniq if inherits_field?(ivar, method)
@@ -23,10 +23,6 @@ module JSONAPI
23
23
  super
24
24
  subclass.type_format = type_format
25
25
  end
26
-
27
- def sidepost_create(*)
28
- nil
29
- end
30
26
  end
31
27
 
32
28
  def initialize(record = nil, context = {})
@@ -4,10 +4,9 @@ module JSONAPI
4
4
  module Serialization
5
5
  module AttributesDeserialization
6
6
  def attributes
7
- attrs = extract_attributes_from_params
8
- attrs = attrs.transform_keys(&:to_sym) if attrs.respond_to?(:transform_keys)
7
+ attrs = ParamHelpers.ensure_object!(extract_attributes_from_params, "data.attributes")
9
8
  permitted_attrs = permitted_attributes_for_action
10
- attrs.slice(*permitted_attrs)
9
+ attrs.transform_keys(&:to_sym).slice(*permitted_attrs)
11
10
  end
12
11
 
13
12
  def extract_attributes_from_params
@@ -52,11 +52,6 @@ module JSONAPI
52
52
 
53
53
  def extract_id(resource_identifier)
54
54
  id = RelationshipHelpers.extract_id_from_identifier(resource_identifier)
55
- if id.nil? && lid?(resource_identifier)
56
- raise ArgumentError,
57
- "lid references must be resolved before deserialization " \
58
- "(include related resources in top-level included)"
59
- end
60
55
  raise ArgumentError, "Missing id in relationship data" unless id
61
56
 
62
57
  id
@@ -99,17 +94,13 @@ module JSONAPI
99
94
  def valid_resource_identifier?(identifier)
100
95
  return false unless identifier.is_a?(Hash)
101
96
 
102
- has_type?(identifier) && (has_id?(identifier) || lid?(identifier))
97
+ has_type?(identifier) && has_id?(identifier)
103
98
  end
104
99
 
105
100
  def has_id?(identifier)
106
101
  identifier[:id].present?
107
102
  end
108
103
 
109
- def lid?(identifier)
110
- identifier[:lid].to_s.present?
111
- end
112
-
113
104
  def has_type?(identifier)
114
105
  identifier[:type]
115
106
  end
@@ -129,7 +120,8 @@ module JSONAPI
129
120
 
130
121
  association = @model_class.reflect_on_association(association_name.to_sym)
131
122
  readonly = relationship_options_for(association_name)[:readonly] == true
132
- JSONAPI::RelationshipGuard.ensure_writable!(association, association_name, readonly:) if association
123
+ field_writable = @definition.relationship_field_writable?(association_name, @action)
124
+ JSONAPI::RelationshipGuard.ensure_writable!(association, association_name, readonly:, field_writable:)
133
125
  end
134
126
 
135
127
  def relationship_options_for(association_name)
@@ -21,7 +21,7 @@ module JSONAPI
21
21
  private
22
22
 
23
23
  def normalize_relationship_value(value)
24
- value.is_a?(Hash) ? value : value.to_h
24
+ ParamHelpers.ensure_object!(value, "relationship")
25
25
  end
26
26
 
27
27
  def extract_data_from_value(value_hash)
@@ -48,7 +48,7 @@ module JSONAPI
48
48
  def validate_relationship_data_format!(data, association_name)
49
49
  return if valid_relationship_data?(data)
50
50
 
51
- raise ArgumentError, "Invalid relationship data for #{association_name}: missing type or (id or lid)"
51
+ raise ArgumentError, "Invalid relationship data for #{association_name}: missing type or id"
52
52
  end
53
53
 
54
54
  def process_relationship_data(attrs, association_name, param_name, data)
@@ -47,10 +47,6 @@ module JSONAPI
47
47
  RelationshipHelpers.extract_id_from_identifier(identifier)
48
48
  end
49
49
 
50
- def extract_lid_from_identifier(identifier)
51
- RelationshipHelpers.extract_lid_from_identifier(identifier)
52
- end
53
-
54
50
  def relationship_id(relationship_name)
55
51
  relationship_ids(relationship_name).first
56
52
  end
@@ -16,10 +16,14 @@ module JSONAPI
16
16
  include Serialization::RelationshipProcessing
17
17
  include Serialization::DeserializationHelpers
18
18
 
19
- def initialize(params, model_class:, action: :create)
19
+ def initialize(params, model_class:, action: :create, definition: nil)
20
20
  @params = ParamHelpers.deep_symbolize_params(params)
21
21
  @model_class = model_class
22
- @definition = ResourceLoader.find_for_model(model_class)
22
+ # Prefer the resource the request is addressing (resolved by type/route). Resolving
23
+ # from the model alone is ambiguous when several resources share one model
24
+ # (e.g. RestrictedPostResource and PostResource both back Post), which would gate
25
+ # relationships against the wrong resource's creatable_fields/updatable_fields.
26
+ @definition = definition || ResourceLoader.find_for_model(model_class)
23
27
  @action = action.to_sym
24
28
  end
25
29
  end
@@ -30,6 +30,18 @@ module JSONAPI
30
30
  end
31
31
  end
32
32
 
33
+ # Every JSON:API document member that carries attributes or resource identifiers must be an
34
+ # object. A malformed document can send a scalar/array in that slot; without this guard a
35
+ # later `.to_h`/`.slice`/`.dig` raises NoMethodError/TypeError -> an unhandled 500. Coerce
36
+ # a Parameters to a Hash, pass a Hash through, and reject anything else with an ArgumentError
37
+ # (which the create/update/relationship rescues render as a clean 400).
38
+ def ensure_object!(value, member)
39
+ return value if value.is_a?(Hash)
40
+ return value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
41
+
42
+ raise ArgumentError, "'#{member}' must be an object"
43
+ end
44
+
33
45
  def build_query_string(query_params)
34
46
  query_parts = []
35
47
  query_params.each do |key, value|
@@ -6,7 +6,12 @@ module JSONAPI
6
6
  module RelationshipGuard
7
7
  module_function
8
8
 
9
- def ensure_writable!(association, error_target, readonly: false)
9
+ def ensure_writable!(association, error_target, readonly: false, field_writable: true)
10
+ # A relationship excluded from the resource's creatable_fields/updatable_fields
11
+ # allow-list is not writable for this action, on either the main-body path or the
12
+ # dedicated endpoint. This gate is independent of the AR reflection.
13
+ raise JSONAPI::AuthorizationError, "Relationship '#{error_target}' is not writable" unless field_writable
14
+
10
15
  return unless association
11
16
  return unless readonly
12
17
 
@@ -35,10 +35,6 @@ module JSONAPI
35
35
  ResourceIdentifier.extract_id(identifier)
36
36
  end
37
37
 
38
- def extract_lid_from_identifier(identifier)
39
- ResourceIdentifier.extract_lid(identifier)
40
- end
41
-
42
38
  def extract_type_from_identifier(identifier)
43
39
  ResourceIdentifier.extract_type(identifier)
44
40
  end
@@ -17,10 +17,6 @@ module JSONAPI
17
17
  identifier[:id].to_s.presence
18
18
  end
19
19
 
20
- def extract_lid(identifier)
21
- identifier[:lid].to_s.presence
22
- end
23
-
24
20
  def extract_type(identifier)
25
21
  identifier[:type]
26
22
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.1.3"
4
+ VERSION = "3.1.6"
5
5
  end
data/lib/json_api.rb CHANGED
@@ -50,8 +50,6 @@ require "json_api/support/responders"
50
50
  require "json_api/support/instrumentation"
51
51
  require "json_api/serialization/serializer"
52
52
  require "json_api/serialization/deserializer"
53
- require "json_api/sideposting/lid_resolver"
54
- require "json_api/sideposting/processor"
55
53
  require "json_api/controllers/concerns/controller_helpers"
56
54
  require "json_api/controllers/concerns/resource_actions"
57
55
  require "json_api/controllers/base_controller"
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: 3.1.3
4
+ version: 3.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -135,8 +135,6 @@ files:
135
135
  - lib/json_api/controllers/concerns/resource_actions/pagination.rb
136
136
  - lib/json_api/controllers/concerns/resource_actions/resource_loading.rb
137
137
  - lib/json_api/controllers/concerns/resource_actions/serialization.rb
138
- - lib/json_api/controllers/concerns/resource_actions/sideposting.rb
139
- - lib/json_api/controllers/concerns/resource_actions/sideposting_primary_first.rb
140
138
  - lib/json_api/controllers/concerns/resource_actions/type_validation.rb
141
139
  - lib/json_api/controllers/relationships_controller.rb
142
140
  - lib/json_api/controllers/resources_controller.rb
@@ -168,9 +166,6 @@ files:
168
166
  - lib/json_api/serialization/deserializer.rb
169
167
  - lib/json_api/serialization/include_path_helpers.rb
170
168
  - lib/json_api/serialization/serializer.rb
171
- - lib/json_api/sideposting/lid_resolver.rb
172
- - lib/json_api/sideposting/order.rb
173
- - lib/json_api/sideposting/processor.rb
174
169
  - lib/json_api/support/active_storage_support.rb
175
170
  - lib/json_api/support/collection_query.rb
176
171
  - lib/json_api/support/concerns/condition_building.rb
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json_api/sideposting/order"
4
- require_relative "sideposting_primary_first"
5
-
6
- module JSONAPI
7
- module ResourceActions
8
- module Sideposting
9
- extend ActiveSupport::Concern
10
- include SidepostingPrimaryFirst
11
-
12
- class_methods do
13
- def sidepost_order(value)
14
- order = value.to_sym
15
- unless JSONAPI::Sideposting::Order::VALID_ORDERS.include?(order)
16
- raise ArgumentError, "sidepost_order must be one of #{JSONAPI::Sideposting::Order::VALID_ORDERS.join(", ")}"
17
- end
18
-
19
- @sidepost_order = order
20
- end
21
-
22
- def sidepost_order_value
23
- @sidepost_order || JSONAPI::Sideposting::Order::PRIMARY_FIRST
24
- end
25
-
26
- def included_first?
27
- sidepost_order_value == JSONAPI::Sideposting::Order::INCLUDED_FIRST
28
- end
29
- end
30
-
31
- def sidepost_request?
32
- params[:included].present?
33
- end
34
-
35
- def create_with_sidepost
36
- if self.class.included_first?
37
- create_with_sidepost_included_first
38
- else
39
- create_with_sidepost_primary_first
40
- end
41
- end
42
-
43
- def create_with_sidepost_included_first
44
- ActiveRecord::Base.transaction do
45
- processor = JSONAPI::Sideposting::Processor.new(included: params[:included], controller: self)
46
- processor.create_all!
47
- resolved_data = processor.lid_resolver.resolve(raw_jsonapi_data)
48
- resource = build_resource_from_resolved_data(resolved_data)
49
- authorize_resource_action!(resource, action: :create)
50
- attach_active_storage_files(resource, @create_attachments, resource_class: determine_sti_resource_class)
51
- save_created_with_sidepost(resource, processor.created_records)
52
- end
53
- end
54
-
55
- def extract_lid_relationships!(data)
56
- extracted = {}
57
- rels = data[:relationships] || {}
58
- rels.each do |key, val|
59
- next unless val.is_a?(Hash)
60
-
61
- d = val[:data] || val["data"]
62
- next unless contains_lid?(d)
63
-
64
- extracted[key] = val
65
- end
66
- extracted.each_key { |k| rels.delete(k) }
67
- extracted
68
- end
69
-
70
- def contains_lid?(rel_data)
71
- return false if rel_data.blank?
72
-
73
- if rel_data.is_a?(Array)
74
- rel_data.any? { |item| contains_lid?(item) }
75
- else
76
- (rel_data[:lid] || rel_data["lid"]).to_s.present?
77
- end
78
- end
79
-
80
- def apply_resolved_relationships!(resource, resolved_relationships)
81
- payload = { relationships: resolved_relationships }
82
- params_hash = JSONAPI::Deserializer.new(
83
- payload,
84
- model_class: resource.class,
85
- action: :update,
86
- ).to_model_attributes
87
- resource.update!(params_hash)
88
- end
89
-
90
- def build_resource_from_resolved_data(resolved_data)
91
- sti_class = determine_sti_class
92
- params_hash, @create_attachments = prepare_create_params_from_data(sti_class, resolved_data)
93
- sti_class.new(params_hash)
94
- end
95
- end
96
- end
97
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module ResourceActions
5
- module SidepostingPrimaryFirst
6
- extend ActiveSupport::Concern
7
-
8
- def create_with_sidepost_primary_first
9
- ActiveRecord::Base.transaction do
10
- data = raw_jsonapi_data.dup
11
- lid_relationships = extract_lid_relationships!(data)
12
- resource, lid_resolver = create_primary_and_lid_resolver(data)
13
- processor = run_sidepost_processor(lid_resolver)
14
- apply_lid_relationships_if_any(resource, lid_relationships, lid_resolver)
15
- render_created_with_sidepost(resource, processor)
16
- end
17
- end
18
-
19
- def create_primary_and_lid_resolver(data)
20
- resource = build_resource_from_resolved_data(data)
21
- authorize_resource_action!(resource, action: :create)
22
- attach_active_storage_files(resource, @create_attachments, resource_class: determine_sti_resource_class)
23
- raise JSONAPI::Sideposting::PrimaryValidationError, resource unless resource.save
24
-
25
- [resource, build_lid_resolver_with_primary(data, resource)]
26
- end
27
-
28
- def build_lid_resolver_with_primary(data, resource)
29
- lid_resolver = JSONAPI::Sideposting::LidResolver.new
30
- primary_lid = (data[:lid] || data["lid"]).to_s.presence
31
- if primary_lid
32
- type_name = RelationshipHelpers.resource_type_name(determine_sti_resource_class)
33
- lid_resolver.add(primary_lid, type: type_name, id: resource.id)
34
- end
35
- lid_resolver
36
- end
37
-
38
- def run_sidepost_processor(lid_resolver)
39
- processor = JSONAPI::Sideposting::Processor.new(
40
- included: params[:included],
41
- controller: self,
42
- lid_resolver: lid_resolver,
43
- )
44
- processor.create_all!
45
- processor
46
- end
47
-
48
- def apply_lid_relationships_if_any(resource, lid_relationships, lid_resolver)
49
- return if lid_relationships.blank?
50
-
51
- resolved = lid_resolver.resolve(lid_relationships)
52
- apply_resolved_relationships!(resource, resolved)
53
- end
54
-
55
- def render_created_with_sidepost(resource, processor)
56
- emit_resource_event(:created, resource)
57
- render json: serialize_resource_with_sidepost(resource, processor.created_records),
58
- status: :created,
59
- location: resource_url(resource)
60
- end
61
- end
62
- end
63
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module Sideposting
5
- class LidResolver
6
- def initialize
7
- @map = {}
8
- end
9
-
10
- def add(lid, type:, id:)
11
- raise ArgumentError, "lid is required" if lid.blank?
12
- raise ArgumentError, "type is required" if type.blank?
13
- raise ArgumentError, "id is required" if id.blank?
14
-
15
- @map[lid.to_s] = { type: type.to_s, id: id.to_s }
16
- self
17
- end
18
-
19
- def resolve(data)
20
- return data if data.nil?
21
-
22
- case data
23
- when Hash
24
- resolve_hash(data)
25
- when Array
26
- data.map { |item| resolve(item) }
27
- else
28
- data
29
- end
30
- end
31
-
32
- def [](lid)
33
- @map[lid.to_s]
34
- end
35
-
36
- private
37
-
38
- def resolve_hash(hash)
39
- result = {}
40
- hash.each do |key, value|
41
- result[key] = if resource_identifier_with_lid?(value)
42
- resolve_identifier(value)
43
- else
44
- resolve(value)
45
- end
46
- end
47
- result
48
- end
49
-
50
- def resource_identifier_with_lid?(value)
51
- return false unless value.is_a?(Hash)
52
-
53
- value = value.symbolize_keys if value.respond_to?(:symbolize_keys)
54
- type = value[:type] || value["type"]
55
- lid = value[:lid] || value["lid"]
56
- type.present? && lid.to_s.present?
57
- end
58
-
59
- def resolve_identifier(identifier)
60
- identifier = identifier.symbolize_keys if identifier.respond_to?(:symbolize_keys)
61
- lid = (identifier[:lid] || identifier["lid"]).to_s
62
- resolved = @map[lid]
63
- unless resolved
64
- raise ArgumentError,
65
- "Unknown lid '#{lid}' (ensure the resource is in the top-level included array)"
66
- end
67
-
68
- { type: resolved[:type], id: resolved[:id] }
69
- end
70
- end
71
- end
72
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module Sideposting
5
- module Order
6
- PRIMARY_FIRST = :primary_first
7
- INCLUDED_FIRST = :included_first
8
- VALID_ORDERS = [PRIMARY_FIRST, INCLUDED_FIRST].freeze
9
- end
10
- end
11
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json_api/sideposting/lid_resolver"
4
-
5
- module JSONAPI
6
- module Sideposting
7
- class ValidationError < JSONAPI::Error
8
- attr_reader :record, :included_index
9
-
10
- def initialize(record:, included_index:)
11
- @record = record
12
- @included_index = included_index
13
- super("Validation failed for included resource at index #{included_index}")
14
- end
15
- end
16
-
17
- class PrimaryValidationError < JSONAPI::Error
18
- attr_reader :record
19
-
20
- def initialize(record)
21
- @record = record
22
- super("Validation failed for primary resource")
23
- end
24
- end
25
-
26
- class Processor
27
- def initialize(included:, controller:, lid_resolver: nil)
28
- raise ArgumentError, "included is required" if included.blank?
29
- raise ArgumentError, "controller is required" if controller.nil?
30
-
31
- @included = Array(included).map { |item| symbolize_item(item) }
32
- @controller = controller
33
- @lid_resolver = lid_resolver || LidResolver.new
34
- @created_records = []
35
- end
36
-
37
- def create_all!
38
- @included.each_with_index do |item, index|
39
- record = create_one(item, index)
40
- @created_records << record
41
- lid = item[:lid].to_s.presence
42
- next unless lid
43
-
44
- resource_class = resource_class_for(item[:type])
45
- type_name = RelationshipHelpers.resource_type_name(resource_class)
46
- @lid_resolver.add(lid, type: type_name, id: record.id)
47
- end
48
- @created_records
49
- end
50
-
51
- attr_reader :lid_resolver, :created_records
52
-
53
- private
54
-
55
- def symbolize_item(item)
56
- item = item.to_unsafe_h if item.respond_to?(:to_unsafe_h)
57
- ParamHelpers.deep_symbolize_params(item.to_h)
58
- end
59
-
60
- def create_one(item, included_index)
61
- type = item[:type].to_s.presence
62
- raise ArgumentError, "Included resource is missing type" unless type
63
-
64
- resource_class = resource_class_for(type)
65
- model_class = resource_class.model_class
66
- params_hash, attachments = prepare_create_params_for_item(item, model_class, resource_class)
67
-
68
- custom = try_sidepost_create(resource_class, params_hash, included_index)
69
- return custom if custom
70
-
71
- create_record_default(model_class, params_hash, attachments, resource_class, included_index)
72
- end
73
-
74
- def try_sidepost_create(resource_class, params_hash, included_index)
75
- custom = resource_class.sidepost_create(params: params_hash, controller: @controller)
76
- return nil unless custom
77
-
78
- raise ValidationError.new(record: custom, included_index: included_index) unless custom.persisted?
79
-
80
- custom
81
- end
82
-
83
- def create_record_default(model_class, params_hash, attachments, resource_class, included_index)
84
- record = model_class.new(params_hash)
85
- @controller.send(:authorize_resource_action!, record, action: :create)
86
- @controller.send(:attach_active_storage_files, record, attachments, resource_class: resource_class)
87
- raise ValidationError.new(record: record, included_index: included_index) unless record.save
88
-
89
- record
90
- end
91
-
92
- def resource_class_for(type)
93
- namespace = @controller.instance_variable_get(:@jsonapi_namespace)
94
- ResourceLoader.find(type, namespace: namespace)
95
- end
96
-
97
- def prepare_create_params_for_item(item, model_class, _resource_class)
98
- hash = Deserializer.new(item, model_class: model_class, action: :create).to_model_attributes
99
- attachments = @controller.send(:extract_active_storage_params_from_hash, hash, model_class)
100
- attachments.each_key { |k| hash.delete(k.to_s) }
101
- hash.delete("type")
102
- hash.delete(:type)
103
- apply_sti_type(model_class, hash) if sti_base_with_type_column?(model_class)
104
- [hash, attachments]
105
- end
106
-
107
- def apply_sti_type(klass, params)
108
- return unless sti_base_with_type_column?(klass)
109
-
110
- params["type"] = klass.name
111
- end
112
-
113
- def sti_base_with_type_column?(klass)
114
- klass.respond_to?(:base_class) && klass.base_class == klass && klass.column_names.include?("type")
115
- end
116
- end
117
- end
118
- end