jpie 3.10.1 → 4.0.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +69 -8
  4. data/lib/json_api/active_storage/detection.rb +19 -1
  5. data/lib/json_api/configuration.rb +1 -2
  6. data/lib/json_api/controllers/concerns/relationships/serialization.rb +4 -3
  7. data/lib/json_api/controllers/concerns/relationships/sorting.rb +1 -1
  8. data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +25 -0
  9. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +11 -3
  10. data/lib/json_api/controllers/concerns/resource_actions.rb +9 -1
  11. data/lib/json_api/controllers/relationships_controller.rb +7 -0
  12. data/lib/json_api/railtie.rb +2 -2
  13. data/lib/json_api/resources/concerns/attributes_dsl.rb +44 -10
  14. data/lib/json_api/resources/concerns/relationships_dsl.rb +15 -0
  15. data/lib/json_api/resources/resource_loader.rb +50 -21
  16. data/lib/json_api/routing.rb +46 -14
  17. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +1 -1
  18. data/lib/json_api/serialization/concerns/attributes_serialization.rb +15 -10
  19. data/lib/json_api/serialization/concerns/include_filtering.rb +9 -1
  20. data/lib/json_api/serialization/concerns/includes_serialization.rb +50 -35
  21. data/lib/json_api/serialization/concerns/meta_serialization.rb +1 -2
  22. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +1 -1
  23. data/lib/json_api/serialization/deserializer.rb +17 -1
  24. data/lib/json_api/serialization/serializer.rb +8 -0
  25. data/lib/json_api/support/relationship_helpers.rb +5 -1
  26. data/lib/json_api/support/resource_identifier.rb +1 -3
  27. data/lib/json_api/support/type_conversion.rb +22 -4
  28. data/lib/json_api/version.rb +1 -1
  29. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aee1da20dd3c81baafa8bcb78801d128f2b59f76be85b12216f96bf35b822344
4
- data.tar.gz: 7de107c0fd9ea459ad0f7015d7b0a6877e88462f81b9900cdf022d14788a8118
3
+ metadata.gz: fd3d3cf0f731b0a5d47d2e0ae3b4fc405d7b49ae775da39baf04a3f862b06d3b
4
+ data.tar.gz: 2d305cd5e41bb20cc5ef36fdf0321407425e503fee2a7424f40035cb9fda0059
5
5
  SHA512:
6
- metadata.gz: 6639e6a66ac7c37cbf50372b433f9b1be772d6e1bda28cd76edbfb4a15612c6307bb9412894e0c6eede135aa6e0de321c2726cf3eda493381f746153ff6dd4fc
7
- data.tar.gz: 1998a903147f3737344b172459656dbb2638525d0371aac16c6951a8c519334ee4ddae641a7a260d200cd41d1c4ea480202bfd476bb930a1f55a8165d1ce4a07
6
+ metadata.gz: beeb386f1743ca01b78a5532925d16115b66ca94ae00f31b1b94118c7b0bdf63367e00242593b78e62cd891c7e497cc00b722ee17d6fa37b724f2de5aa40d178
7
+ data.tar.gz: e6362ed9996bec7dedabf0891e9a50a4ff80f240829cf6f7e08decf4b4ddf5d29c139f6e71eb88de1a53648449c416907a81c7f73dee1134e49a4b8704ca5063
data/CHANGELOG.md CHANGED
@@ -7,6 +7,161 @@ at release time.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.0.0] - 2026-09-09
11
+
12
+ ### Changed
13
+
14
+ - **Breaking. A namespace serves only what it declares.** A namespaced request
15
+ used to fall back to the flat resource when the namespaced one was missing —
16
+ for the primary type, and for every `?include=` hop. On a public namespace
17
+ that published the internal resource's records and attributes to an
18
+ anonymous caller, and an STI record slipped to its base class's flat
19
+ resource even where the namespace carried a base resource. The fallback is
20
+ gone, and `config.namespace_fallback` with it. A namespaced request resolves
21
+ the namespaced resource alone. A route whose namespace carries no resource
22
+ fails at draw time — the app does not boot — because `jsonapi_resources`
23
+ resolves the resource while routes load. An include hop the namespace
24
+ cannot answer is a 400 at validation, before the record loads; a
25
+ polymorphic hop, which validation cannot judge, answers 400 (or 404 on a
26
+ relationships route) when a record's class resolves nothing at
27
+ serialization. A namespaced model still
28
+ reaches the resource in its own namespace, and an STI record answers
29
+ through the request namespace's resource for its own class, then its base
30
+ class. To keep serving a flat resource inside a namespace, declare it:
31
+ `class API::V1::WidgetResource < WidgetResource; end`. That line is the
32
+ audit trail. Release this as a major version.
33
+
34
+ The relationships routes obey the same rule: the linkage and related-record
35
+ scopes resolve through the request namespace's resource, where they used to
36
+ resolve with no namespace at all. Write-path linkage lookups (deserializing
37
+ a relationship's resource identifiers) still resolve without a namespace;
38
+ a bound write surface gates those through its declared field lists.
39
+ - Read the outer hash when neither it nor its `data` member names a `type`.
40
+ `JSONAPI::Deserializer.new(data: { attributes: { … } })` used to read the
41
+ nested member; it now reads the outer hash and finds no attributes. Such a
42
+ hash is neither a document nor a resource object, because JSON:API requires
43
+ `type` in every resource object. Name the `type` to keep the old result.
44
+ - Answer `{}` instead of raising `TypeError` for a document whose `data` member
45
+ holds an array. `dig(:data, :attributes)` raised on the array.
46
+ - Read a declared write list as the complete allow-list for relationships. jpie
47
+ gated a relationship only when the resource named it in `creatable_fields` or
48
+ `updatable_fields`. A relationship named in neither stayed writable, so a
49
+ resource that declared an allow-list of attributes still accepted any
50
+ relationship the client sent. The client re-pointed a row it did not own. A
51
+ resource that declares the list for an action now refuses a relationship the
52
+ list omits.
53
+
54
+ This refuses writes jpie used to accept. Check every resource that calls
55
+ `creatable_fields` or `updatable_fields` and names no relationship, and every
56
+ subclass of one, because a subclass that declares nothing reads its parent's
57
+ lists. A client that writes a relationship on such a resource now meets 403
58
+ Forbidden. Name the relationship in the list to keep the write.
59
+
60
+ Two shapes are unaffected. A resource that declares no list falls back to the
61
+ attribute list, which names no relationship, so failing closed there would
62
+ refuse every resource that never stated an opinion. An override of
63
+ `permitted_creatable_fields` or `permitted_updatable_fields` is not a
64
+ declaration either: an override that adjusts the inherited list, such as
65
+ `super - [:role]`, returns a plain array, the same as one that states a fresh
66
+ list, so jpie cannot tell the two apart. On both shapes, `readonly: true`
67
+ still stops a write.
68
+ - **Breaking. Cut the public API.** Hold a relationship route to the `only:`
69
+ and `except:` the caller gives `jsonapi_resources`. Each verb stands on a
70
+ member action: `GET` on `:show`, and `PATCH` and `DELETE` both on
71
+ `:update` — a relationship `DELETE` removes members from the relationship,
72
+ the controller authorizes it as an update, and the primary record survives,
73
+ so `:destroy` plays no part. Before this, `only:` cut the member routes and
74
+ left all three relationship routes whole, so a caller who omitted `:update`
75
+ still got `PATCH /:type/:id/relationships/:relationship_name`. The
76
+ relationships controller authorizes the parent record alone, so the omitted
77
+ member action did no work there and the write landed. The base route of an
78
+ `sti:` set carries `only: :index`, so it now draws no relationship route.
79
+ The `:relationship` path helper hangs on the first verb drawn, so it
80
+ survives without `:show`. Release this as a major version.
81
+
82
+ An STI subtype route that states no options of its own now inherits the
83
+ caller's `only:`/`except:` as well — auto-drawn subtypes and bare explicit
84
+ entries used to draw every route whatever the caller restricted. A hash
85
+ subtype entry keeps its own declaration.
86
+
87
+ Regenerate the OpenAPI document with the route audit: a consumer that
88
+ documents relationship routes carries phantom operations for every verb
89
+ this removes.
90
+
91
+ Audit every `jsonapi_resources` line before the bump: a declaration without
92
+ `:update` loses relationship `PATCH` and `DELETE`, and one without `:show`
93
+ loses relationship `GET`. Measured against kiln's route table, 60 of 112
94
+ declarations lose at least one relationship verb — most of them read-only
95
+ template, suggestion, and public-portal surfaces, which is the point of the
96
+ change. A caller who wants a relationship verb back must name its member
97
+ action.
98
+ - Skip strictly less with `skip_before_action :load_jsonapi_resource`: the
99
+ callback now loads the record alone, so a controller that skips it still
100
+ resolves the resource class and still validates `include`, `fields` and
101
+ `filter`. On a typed route a bad `include` answers 400 where it used to
102
+ raise over a nil resource class.
103
+ - Answer 404, not a NoMethodError 500, for a hand-drawn route whose resource
104
+ type resolves to no class — the same answer the relationships controller
105
+ already gives. `jsonapi_resources` resolves the resource at draw time, so
106
+ only a hand-drawn route reaches this.
107
+ - Validate `include`, `fields` and `filter` before the record loads. The loader
108
+ ran first and built its query from the client's `include`, so a path the gem
109
+ then rejected with 400 had already read the record and preloaded every hop.
110
+ A `show` with `include=posts.comments.invalid_association` ran three queries
111
+ and threw the rows away; it now runs none. On a public endpoint an anonymous
112
+ caller no longer reaches the database with an unchecked `include`.
113
+ - Answer 400, not 404, when a request carries both a bad `include` and a
114
+ missing id. The server must understand a request before it can report the
115
+ record absent, and JSON:API requires 400 for an include path it cannot
116
+ resolve. A 404 here tells the caller the record is missing when the server
117
+ never asked.
118
+
119
+ ### Fixed
120
+
121
+ - Read a declared write list through the whole ancestor chain. Only the
122
+ direct subclass of a declaring resource inherited its
123
+ `creatable_fields`/`updatable_fields`; a grandchild fell open to the
124
+ attribute fallback and wrote any relationship. Any ancestor's declaration
125
+ now counts, and resolution still reads through the superclass, so an
126
+ intermediate reader override keeps its say at every level.
127
+ - Read `attributes` and `relationships` from the resource object, not from a
128
+ `data` member nested inside it. The deserializer took the nested pair first,
129
+ and the controllers hand it the resource object, which the client writes. A
130
+ client nested a second `data` member and wrote fields that never appeared
131
+ where a host application's guard reads them. `type` now decides the shape:
132
+ JSON:API requires it in every resource object and names no `type` among the
133
+ members of a document. A hash that carries `type` is the resource object, and
134
+ the deserializer unwraps `data` only for a hash that carries no `type` over a
135
+ `data` member that carries one.
136
+
137
+ ## [3.11.0]
138
+
139
+ ### Changed
140
+
141
+ - Read a model-backed attribute with `has_attribute?` and `read_attribute`.
142
+ The serializer called `ActiveRecord#attributes` three times per attribute per
143
+ record, and each call builds a hash of every column. A record with a fat text
144
+ column paid for that column on all three.
145
+ - Drop the per-record objects in the include walk. Each visited record
146
+ allocated a struct, a hash copy of the path context, and an empty keyword
147
+ hash; the attachment verdict now lifts out of the loop. The relationship
148
+ names an include path asks for are computed once per path, not once per
149
+ record.
150
+ - Precompute each include path's walk once per request. The walk re-split and
151
+ re-joined the path strings at every level for every parent record.
152
+ - Look a relationship definition up through a name-keyed index. The linear
153
+ scan converted every entry's name on every lookup, once per record per hop.
154
+ - Memoize the ActiveStorage attachment verdict per class and name. The
155
+ reflection lookup allocated a string per call, per relationship per record.
156
+ - Resolve a model's resource class and type name through identity-keyed front
157
+ caches. The name-keyed caches allocated their key strings on every call.
158
+ - Serialize a record through one resource instance. The attribute reader and
159
+ the meta reader each built their own, so a resource's memoized reads ran
160
+ twice.
161
+ - Skip the respond_to? probe on a loaded singular association target. The
162
+ probe walked ActiveModel's attribute matcher and allocated a string per
163
+ record.
164
+
10
165
  ## [3.10.1]
11
166
 
12
167
  ### Fixed
data/README.md CHANGED
@@ -143,6 +143,42 @@ class UserResource < JSONAPI::Resource
143
143
  end
144
144
  ```
145
145
 
146
+ A declared list is the complete allow-list. It names relationships as well as
147
+ attributes, so the resource above lets a client write no relationship at all.
148
+ Name a relationship in the list to let a client write it:
149
+
150
+ ```ruby
151
+ class UserResource < JSONAPI::Resource
152
+ attributes :name, :email
153
+ has_many :teams
154
+
155
+ creatable_fields :name, :email, :teams
156
+ updatable_fields :name, :teams
157
+ end
158
+ ```
159
+
160
+ Name a relationship in one list and not the other to make it create-only or
161
+ update-only. A client that writes a relationship the action's list omits meets
162
+ 403 Forbidden. An omitted attribute is not an error: the deserializer drops it
163
+ and the request answers 200.
164
+
165
+ `creatable_fields` and `updatable_fields` are the only way to declare a list. A
166
+ subclass that declares nothing reads its parent's lists and closes the same
167
+ relationships.
168
+
169
+ A resource that declares no list for an action keeps every relationship
170
+ writable, because the fallback list is the attribute list, which names no
171
+ relationship. Mark a relationship `readonly: true` to stop a write there.
172
+
173
+ An override of `permitted_creatable_fields` or `permitted_updatable_fields` is
174
+ not a declaration, and it leaves every relationship writable. An override that
175
+ adjusts the inherited list — `super - [:role]` — returns a plain array, the same
176
+ as one that states a fresh list, so jpie cannot tell the two apart. Call the DSL
177
+ to close the list.
178
+
179
+ The gate never runs for an ActiveStorage attachment, so a `has_one` backed by
180
+ `has_one_attached` needs no entry in either list.
181
+
146
182
  ## Relationship Endpoints
147
183
 
148
184
  Manage relationship links independently of the parent resource. This will not delete the related resource itself, but only manage the relationship.
@@ -153,6 +189,15 @@ If the relationship is `User -(has_one)-> AccountUser -(belong_to)-> Account` an
153
189
  - `PATCH /users/:id/relationships/:relationship_name` - Replace relationship linkage
154
190
  - `DELETE /users/:id/relationships/:relationship_name` - Remove relationship linkage
155
191
 
192
+ Each verb stands on a member action, so `only:` and `except:` govern it. `GET` stands on `:show`. `PATCH` and `DELETE` both stand on `:update`, because a relationship `DELETE` removes members from the relationship — the controller authorizes it as an update, and the primary record survives — so `:destroy` plays no part. A declaration that omits the member action grants no relationship path for its verbs:
193
+
194
+ ```ruby
195
+ # Draws relationship GET alone: :update is omitted, so no PATCH and no DELETE.
196
+ jsonapi_resources :posts, only: %i[index show create destroy]
197
+ ```
198
+
199
+ The `:relationship` path helper hangs on the first verb drawn, so it survives a declaration that keeps a write verb and drops `:show`.
200
+
156
201
  ## Filtering
157
202
 
158
203
  Declare permitted filters in your resource class:
@@ -853,10 +898,6 @@ JSONAPI.configure do |config|
853
898
  # :same_namespace -> API::V1::VendorResource maps to API::V1::Vendor
854
899
  # :flat -> API::V1::VendorResource maps to Vendor
855
900
  config.namespace_model_mapping = :same_namespace
856
-
857
- # Fallback behavior when namespaced resource not found: true (default) or false
858
- # When true, if API::V1::VendorResource not found, try VendorResource
859
- config.namespace_fallback = true
860
901
  end
861
902
  ```
862
903
 
@@ -878,15 +919,35 @@ end
878
919
  This will:
879
920
 
880
921
  - Look for `API::V1::WidgetsController` (falls back to `JSONAPI::ResourcesController` if not found)
881
- - Load `API::V1::WidgetResource` (falls back to `WidgetResource` if `namespace_fallback: true`)
922
+ - Load `API::V1::WidgetResource`
882
923
  - Pass `jsonapi_namespace: "api/v1"` to the controller
883
924
 
884
925
  The request namespace also selects the resource class during serialization: for
885
926
  the attributes, the include filter, the links, and the relationship linkage.
886
927
  `GET /portal/posts` serializes with `Portal::PostResource` even when the `Post`
887
- model is flat. When the namespaced resource does not exist, the loader falls
888
- back to the model's own namespace, then to the flat resource. Set
889
- `config.namespace_fallback = false` to refuse the flat fallback.
928
+ model is flat.
929
+
930
+ A namespace serves only what it declares. A namespaced request never resolves a
931
+ flat resource: a route whose namespace carries no resource fails at draw time
932
+ (the app does not boot), and an `?include=` hop the namespace cannot answer is
933
+ a 400 at validation, before anything loads. For a namespaced model, both name
934
+ shapes answer — `Pub::ItemResource` and `Pub::DepotItemResource` for
935
+ `Depot::Item` — so the class a `MissingResourceClass` message names is always
936
+ a working declaration. To serve a flat resource inside a
937
+ namespace, declare it:
938
+
939
+ ```ruby
940
+ class API::V1::WidgetResource < WidgetResource; end
941
+ ```
942
+
943
+ That one line is the review trail: the namespace's public surface is exactly
944
+ the set of resource classes defined in it. Two shapes need no declaration — a
945
+ namespaced model still reaches the resource in its own namespace, and an STI
946
+ record answers through the request namespace's resource for its own class,
947
+ then for its base class, so one base resource covers every subclass.
948
+ ActiveStorage attachments answer through the blob resource and are exempt.
949
+ Polymorphic include hops cannot be judged at validation; each record's class
950
+ resolves at serialization and raises if the namespace cannot answer it.
890
951
 
891
952
  #### Namespaced Resources
892
953
 
@@ -1,17 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/map"
4
+
3
5
  module JSONAPI
4
6
  module ActiveStorage
5
7
  module Detection
8
+ # The reflection lookup allocates a string per call, and the serializer
9
+ # asks per relationship per record. The verdict is a function of the
10
+ # class object and the name, so memoize it. Keys are class objects: the
11
+ # railtie clears the cache on code reload so a reloaded class cannot be
12
+ # retained.
13
+ @attachment_cache = Concurrent::Map.new
14
+
6
15
  module_function
7
16
 
8
17
  def attachment?(association_name, model_class)
9
18
  return false unless defined?(::ActiveStorage)
10
19
 
11
- model_class.respond_to?(:reflect_on_attachment) &&
20
+ cache = @attachment_cache.compute_if_absent(model_class) { Concurrent::Map.new }
21
+ cached = cache[association_name.to_sym]
22
+ return cached unless cached.nil?
23
+
24
+ cache[association_name.to_sym] =
25
+ model_class.respond_to?(:reflect_on_attachment) &&
12
26
  model_class.reflect_on_attachment(association_name.to_sym).present?
13
27
  end
14
28
 
29
+ def reset_cache!
30
+ @attachment_cache.clear
31
+ end
32
+
15
33
  def blob_type?(type)
16
34
  return false unless defined?(::ActiveStorage)
17
35
 
@@ -4,7 +4,7 @@ module JSONAPI
4
4
  class Configuration
5
5
  attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
6
6
  :authorization_scope, :document_meta_resolver,
7
- :namespace_type_format, :namespace_model_mapping, :namespace_fallback,
7
+ :namespace_type_format, :namespace_model_mapping,
8
8
  :n1_detection_enabled,
9
9
  :query_tracking_enabled, :query_count_threshold, :slow_query_threshold_ms,
10
10
  :query_tracking_queries_cap,
@@ -87,7 +87,6 @@ module JSONAPI
87
87
  def set_namespace_defaults
88
88
  @namespace_type_format = :flat
89
89
  @namespace_model_mapping = :same_namespace
90
- @namespace_fallback = true
91
90
  end
92
91
  end
93
92
 
@@ -37,7 +37,8 @@ module JSONAPI
37
37
  return fetch_blank_polymorphic(association) if type_value.blank? || id_value.blank?
38
38
 
39
39
  related_model_class = type_value.constantize
40
- related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class)
40
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(related_model_class,
41
+ namespace: jsonapi_namespace,)
41
42
  base = apply_relationship_authorization(related_resource_class.records, related_model_class)
42
43
  record = base.find(id_value)
43
44
 
@@ -52,14 +53,14 @@ module JSONAPI
52
53
 
53
54
  def fetch_polymorphic_has_many_through_resource_records(association)
54
55
  association_instance = @resource.association(@relationship_name)
55
- related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
56
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass, namespace: jsonapi_namespace)
56
57
  base = apply_relationship_authorization(related_resource_class.records, association.klass)
57
58
  association_instance.scope.merge(base)
58
59
  end
59
60
 
60
61
  def fetch_non_polymorphic_related_through_resource_records(association)
61
62
  association_instance = @resource.association(@relationship_name)
62
- related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass)
63
+ related_resource_class = JSONAPI::ResourceLoader.find_for_model(association.klass, namespace: jsonapi_namespace)
63
64
  base = apply_relationship_authorization(related_resource_class.records, association.klass)
64
65
  scope = association_instance.scope.merge(base)
65
66
 
@@ -13,7 +13,7 @@ module JSONAPI
13
13
  sorts = parse_sort_param
14
14
  return related if sorts.empty?
15
15
 
16
- resource_class = ResourceLoader.find_for_model(association.klass)
16
+ resource_class = ResourceLoader.find_for_model(association.klass, namespace: jsonapi_namespace)
17
17
  Support::Sort.new(sort_params: sorts, resource_class:).apply(related)
18
18
  end
19
19
  end
@@ -30,13 +30,38 @@ module JSONAPI
30
30
 
31
31
  assoc = current.reflect_on_association(name.to_sym)
32
32
  return false unless assoc
33
+ # A polymorphic hop has no single target class, so no static check can
34
+ # judge it here; each record's class resolves at serialization.
33
35
  break if assoc.polymorphic?
34
36
 
35
37
  current = assoc.klass
38
+ return false unless include_hop_resolvable?(current)
36
39
  end
37
40
  true
38
41
  end
39
42
 
43
+ # A namespaced request serves an include only where the namespace carries a
44
+ # resource to answer it. A hop that resolves nothing is a 400 here, not a
45
+ # MissingResourceClass halfway through serialization.
46
+ def include_hop_resolvable?(klass)
47
+ JSONAPI::ResourceLoader.find_for_model(klass, namespace: jsonapi_namespace)
48
+ true
49
+ rescue JSONAPI::ResourceLoader::MissingResourceClass
50
+ false
51
+ end
52
+
53
+ # Validation cannot judge a polymorphic hop, so the namespace's answer
54
+ # only arrives when a record's class resolves at serialization. Render
55
+ # the refusal validation would have given, never a raw 500.
56
+ def render_unanswerable_include(error)
57
+ render_parameter_errors(
58
+ [error.message],
59
+ title: "Invalid Include Path",
60
+ detail_proc: ->(m) { m },
61
+ source_proc: ->(_) { { parameter: "include" } },
62
+ )
63
+ end
64
+
40
65
  def render_include_errors(invalid)
41
66
  render_parameter_errors(
42
67
  invalid,
@@ -7,13 +7,21 @@ module JSONAPI
7
7
 
8
8
  private
9
9
 
10
- def load_jsonapi_resource
10
+ # jsonapi_resources resolves the resource at draw time, so a typed route with no
11
+ # resource class is a hand-drawn misconfiguration. Answer it 404, the way the
12
+ # relationships controller's set_resource_class does — a nil class here surfaced
13
+ # later as a NoMethodError 500 in whichever action read it first.
14
+ def load_jsonapi_resource_class
11
15
  @resource_name = params[:resource_type]&.singularize
12
16
  @jsonapi_namespace = params[:jsonapi_namespace].presence
13
17
  load_resource_and_model_class
14
- load_resource_record
15
- rescue JSONAPI::ResourceLoader::MissingResourceClass
18
+ rescue JSONAPI::ResourceLoader::MissingResourceClass => e
16
19
  @resource_class = nil
20
+ render_resource_not_found_error(e.message)
21
+ end
22
+
23
+ def load_jsonapi_resource
24
+ load_resource_record
17
25
  rescue ActiveRecord::RecordNotFound
18
26
  render_record_not_found
19
27
  end
@@ -24,10 +24,14 @@ module JSONAPI
24
24
  include ResourceLoading
25
25
 
26
26
  included do
27
- before_action :load_jsonapi_resource
27
+ before_action :load_jsonapi_resource_class
28
+ # A query parameter shapes the query the loader builds, so judge every one of them
29
+ # before the loader reads the database. The body members stay after the load,
30
+ # because a conflict with the addressed record needs that record.
28
31
  before_action :validate_fields_param, only: %i[index show]
29
32
  before_action :validate_filter_param, only: [:index]
30
33
  before_action :validate_include_param, only: %i[index show]
34
+ before_action :load_jsonapi_resource
31
35
  before_action :validate_resource_type!, only: %i[create update]
32
36
  before_action :validate_resource_id!, only: [:update]
33
37
  end
@@ -39,11 +43,15 @@ module JSONAPI
39
43
  @total_count = query.total_count
40
44
  @pagination_applied = query.pagination_applied
41
45
  render json: serialize_collection(query.scope), status: :ok
46
+ rescue JSONAPI::ResourceLoader::MissingResourceClass => e
47
+ render_unanswerable_include(e)
42
48
  end
43
49
 
44
50
  def show
45
51
  authorize_resource_action!(@resource, action: :show)
46
52
  render json: serialize_resource(@resource), status: :ok
53
+ rescue JSONAPI::ResourceLoader::MissingResourceClass => e
54
+ render_unanswerable_include(e)
47
55
  end
48
56
 
49
57
  def create
@@ -20,6 +20,13 @@ module JSONAPI
20
20
  skip_before_action :validate_resource_type!, only: %i[update destroy]
21
21
  skip_before_action :validate_resource_id!, only: %i[update destroy]
22
22
 
23
+ # A polymorphic or namespaced relationship can only learn at serialization
24
+ # that the request namespace carries no resource for the record's class.
25
+ # Answer 404, the same as set_resource_class does for the primary type.
26
+ rescue_from JSONAPI::ResourceLoader::MissingResourceClass do |error|
27
+ render_resource_not_found_error(error.message)
28
+ end
29
+
23
30
  before_action :set_resource_name
24
31
  before_action :set_resource_class
25
32
  before_action :set_resource
@@ -105,8 +105,8 @@ module JSONAPI
105
105
  # that pre-populate ResourceLoader caches in their own to_prepare blocks
106
106
  # run after this and re-apply their entries.
107
107
  app.reloader.to_prepare do
108
- JSONAPI::ResourceLoader.reset_cache!
109
- JSONAPI::TypeConversion.reset_cache!
108
+ [JSONAPI::ResourceLoader, JSONAPI::TypeConversion,
109
+ JSONAPI::ActiveStorage::Detection,].each(&:reset_cache!)
110
110
  Railtie.setup_base_controllers
111
111
  end
112
112
 
@@ -42,18 +42,44 @@ module JSONAPI
42
42
  end
43
43
 
44
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.
45
+ # A declared list is the complete allow-list. A resource that declares the action's
46
+ # field list names every field a client may write, and the deserializer refuses a
47
+ # relationship the list omits. Naming a relationship in one list and not the other
48
+ # still makes it create-only or update-only, the same way it restricts attributes.
49
+ #
50
+ # A resource that declares no list for the action keeps the older behavior, and only
51
+ # `readonly: true` stops the write. The fallback list is the attribute list, which
52
+ # names no relationship, so failing closed there would refuse every resource that
53
+ # never stated an opinion.
50
54
  def relationship_field_writable?(name, action)
51
55
  name = name.to_sym
52
56
  creatable = permitted_creatable_fields.map(&:to_sym)
53
57
  updatable = permitted_updatable_fields.map(&:to_sym)
54
- return true unless creatable.include?(name) || updatable.include?(name)
58
+ if creatable.include?(name) || updatable.include?(name)
59
+ return (action == :create ? creatable : updatable).include?(name)
60
+ end
55
61
 
56
- (action == :create ? creatable : updatable).include?(name)
62
+ !field_list_declared?(action)
63
+ end
64
+
65
+ # Whether the resource declares the action's field list, rather than falling back to
66
+ # the attribute list. The DSL records a declaration in the instance variable, and a
67
+ # subclass reads its parent's. Those are the two signals `resolve_field_list` treats
68
+ # as declared, and this asks the same question of the same two.
69
+ #
70
+ # An override of the reader is not a signal. An override that adjusts the inherited
71
+ # list — `super - [:draft] + [:owners]` — returns a plain array, the same as one that
72
+ # states a fresh list, so the two cannot be told apart. Reading an override as a
73
+ # declaration would refuse every relationship an adjusting resource does not name,
74
+ # and such a resource names attributes only. Call the DSL to close the list.
75
+ def field_list_declared?(action)
76
+ ivar, method = if action == :create
77
+ %i[@creatable_fields permitted_creatable_fields]
78
+ else
79
+ %i[@updatable_fields permitted_updatable_fields]
80
+ end
81
+
82
+ instance_variable_defined?(ivar) || inherits_field?(ivar, method)
57
83
  end
58
84
 
59
85
  def resolve_field_list(ivar, method)
@@ -63,10 +89,18 @@ module JSONAPI
63
89
  permitted_attributes.uniq
64
90
  end
65
91
 
92
+ # Any ancestor's declaration counts. Reading the immediate parent alone let a
93
+ # grandchild of a declaring resource fall open to the attribute fallback. The
94
+ # resolution still reads through `superclass.public_send`, so an intermediate
95
+ # reader override — the adjuster shape — keeps its say at every level.
66
96
  def inherits_field?(ivar, method)
67
- superclass != JSONAPI::Resource &&
68
- superclass.respond_to?(method) &&
69
- superclass.instance_variable_defined?(ivar)
97
+ klass = superclass
98
+ while klass != JSONAPI::Resource && klass.respond_to?(method)
99
+ return true if klass.instance_variable_defined?(ivar)
100
+
101
+ klass = klass.superclass
102
+ end
103
+ false
70
104
  end
71
105
 
72
106
  def should_inherit_attributes?(declared_attributes)
@@ -31,6 +31,7 @@ module JSONAPI
31
31
 
32
32
  def reset_relationship_definitions!
33
33
  remove_instance_variable(:@relationship_definitions) if defined?(@relationship_definitions)
34
+ remove_instance_variable(:@relationship_definition_index) if defined?(@relationship_definition_index)
34
35
  end
35
36
 
36
37
  def relationship_names
@@ -39,6 +40,20 @@ module JSONAPI
39
40
  end
40
41
 
41
42
  module RelationshipHelperMethods
43
+ # Name-keyed lookup over relationship_definitions. The serializer asks
44
+ # for one definition by name once per record per hop; a linear scan
45
+ # with a to_s per entry allocated thousands of strings per request.
46
+ # Both spellings key the same entry, so a lookup converts nothing.
47
+ def relationship_definition_index
48
+ return @relationship_definition_index if defined?(@relationship_definition_index)
49
+
50
+ index = relationship_definitions.each_with_object({}) do |r, h|
51
+ h[r[:name]] = r
52
+ h[r[:name].to_s] = r
53
+ end
54
+ @relationship_definition_index = index.freeze
55
+ end
56
+
42
57
  def register_relationship(name:, type:, meta:, options:)
43
58
  @relationships ||= []
44
59
  @relationships << { name: name.to_sym, type:, meta:, options: }
@@ -25,14 +25,21 @@ module JSONAPI
25
25
  # caches on code reload.
26
26
  @find_cache = Concurrent::Map.new
27
27
  @model_cache = Concurrent::Map.new
28
+ # Identity front cache for find_for_model: the string cache key allocated
29
+ # on every call, and the serializer calls per record and per identifier.
30
+ # Nested by fallback flag and namespace so a configuration flip cannot
31
+ # serve a stale class. Values come through the string-keyed path, so an
32
+ # application that pre-populates @model_cache keeps its pins.
33
+ @model_identity_cache = Concurrent::Map.new
28
34
 
29
35
  def self.reset_cache!
30
36
  @find_cache.clear
31
37
  @model_cache.clear
38
+ @model_identity_cache.clear
32
39
  end
33
40
 
34
41
  def self.find(resource_type, namespace: nil)
35
- key = "#{namespace}|#{resource_type}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
42
+ key = "#{namespace}|#{resource_type}"
36
43
  cached = @find_cache[key]
37
44
  return cached if cached
38
45
 
@@ -41,24 +48,26 @@ module JSONAPI
41
48
  @find_cache[key] = klass
42
49
  end
43
50
 
51
+ # A namespaced request resolves inside its namespace alone — "widgets" under
52
+ # "api/v1" names API::V1::WidgetResource and nothing else. A flat request
53
+ # names the flat resource. Nothing falls back across the boundary, so a
54
+ # namespace serves only what it declares.
44
55
  def self.find_candidates(resource_type, namespace)
45
- candidates = []
46
-
47
- # Namespaced resource, e.g. "widgets" with namespace "api/v1" → API::V1::WidgetResource
48
- candidates << build_resource_class_name(resource_type, namespace) if namespace
49
-
50
- # Flat resource, e.g. "widgets" → WidgetResource
51
- if !namespace || JSONAPI.configuration.namespace_fallback
52
- candidates << build_resource_class_name(resource_type,
53
- nil,)
54
- end
55
-
56
- candidates
56
+ [build_resource_class_name(resource_type, namespace.presence)]
57
57
  end
58
58
 
59
59
  def self.find_for_model(model_class, namespace: nil)
60
60
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
61
61
 
62
+ bucket = model_identity_bucket(namespace)
63
+ bucket[model_class] || (bucket[model_class] = find_for_model_uncached(model_class, namespace))
64
+ end
65
+
66
+ def self.model_identity_bucket(namespace)
67
+ @model_identity_cache.compute_if_absent(namespace) { Concurrent::Map.new }
68
+ end
69
+
70
+ def self.find_for_model_uncached(model_class, namespace)
62
71
  key = model_cache_key(model_class, namespace)
63
72
  cached = @model_cache[key]
64
73
  return cached if cached
@@ -80,19 +89,39 @@ module JSONAPI
80
89
  def self.model_cache_key(model_class, namespace)
81
90
  return "::#{model_class.name}" unless namespace
82
91
 
83
- "#{namespace}|::#{model_class.name}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
92
+ "#{namespace}|::#{model_class.name}"
84
93
  end
85
94
 
86
- # The request namespace outranks the model's own namespace, which stays a
87
- # fallback so a namespaced model still reaches its own resource. The flat
88
- # name comes last, and only while the configuration allows the fallback.
95
+ # The request namespace outranks the model's own namespace, which a
96
+ # namespaced model keeps so it still reaches its own resource. An STI
97
+ # record answers through the request namespace's resource for its own
98
+ # class, then for its base class, so one base resource covers every
99
+ # subclass. No flat name is a candidate: a flat resource is another
100
+ # namespace's surface, and resolving it here is what published internal
101
+ # fields on public portals.
89
102
  def self.request_namespace_candidates(model_class, namespace, model_namespace)
90
- resource_type = model_class.name.demodulize.underscore.pluralize
91
- candidates = [build_resource_class_name(resource_type, namespace)]
103
+ candidates = sti_resource_names(model_class, namespace)
92
104
  candidates |= model_candidates(model_class, model_namespace)
93
- return candidates if JSONAPI.configuration.namespace_fallback
105
+ candidates - sti_resource_names(model_class, nil)
106
+ end
107
+
108
+ # Every name shape a class answers to, in one namespace: the demodulized
109
+ # name and, for a namespaced model, the flat-combined name `find` builds
110
+ # from the route type — so `Pub::DepotItemResource`, the class the
111
+ # MissingResourceClass message names, is a candidate, and the flat
112
+ # `DepotItemResource` is subtracted with the rest.
113
+ def self.sti_resource_names(model_class, namespace)
114
+ classes = [model_class]
115
+ classes << model_class.base_class if sti_subclass?(model_class)
116
+ classes.flat_map { class_resource_names(it, namespace) }
117
+ end
118
+
119
+ def self.class_resource_names(klass, namespace)
120
+ names = [build_resource_class_name(klass.name.demodulize.underscore.pluralize, namespace)]
121
+ return names unless klass.name.include?("::")
94
122
 
95
- candidates - [build_resource_class_name(resource_type, nil)]
123
+ combined = "#{klass.name.gsub("::", "")}Resource"
124
+ names << (namespace ? "#{namespace.to_s.camelize}::#{combined}" : combined)
96
125
  end
97
126
 
98
127
  def self.build_resource_class_name(resource_type, namespace)
@@ -2,17 +2,29 @@
2
2
 
3
3
  module JSONAPI
4
4
  module Routing
5
+ # The member action each relationship route stands on. A relationship
6
+ # route reads or writes through the parent record: GET stands on :show,
7
+ # and PATCH and DELETE both stand on :update, because a relationship
8
+ # DELETE removes members from the relationship — the controller
9
+ # authorizes it as an update, and the primary record survives — so
10
+ # :destroy plays no part.
11
+ RELATIONSHIP_ACTIONS = { show: :show, update: :update, destroy: :update }.freeze
12
+
5
13
  def jsonapi_resources(resource, controller: nil, defaults: {}, sti: false, **options, &)
6
14
  resource_name = resource.to_s
7
15
  namespace = extract_namespace_from_scope
8
16
  controller ||= detect_controller(resource_name, namespace)
9
17
  defaults = build_jsonapi_defaults(defaults, resource_name, namespace)
18
+ # The caller's restriction, before the STI base overwrite: a subtype route
19
+ # that states no options of its own inherits it, so `only:` governs the
20
+ # whole set rather than the base alone.
21
+ restriction = options.slice(:only, :except)
10
22
  options[:only] = :index if sti
11
23
  options = without_form_actions(options)
12
24
 
13
25
  JSONAPI::ResourceLoader.find(resource_name, namespace:)
14
26
  define_resource_routes(resource, controller, defaults, options, &)
15
- define_sti_routes(resource, resource_name, defaults, sti, namespace)
27
+ define_sti_routes(resource, defaults, sti, namespace, restriction)
16
28
  end
17
29
 
18
30
  def build_jsonapi_defaults(defaults, resource_name, namespace)
@@ -50,17 +62,35 @@ module JSONAPI
50
62
  end
51
63
 
52
64
  def define_resource_routes(resource, controller, defaults, options, &block)
65
+ actions = permitted_relationship_actions(options)
53
66
  resources(resource, controller:, defaults:, **options) do
54
- define_relationship_routes
67
+ define_relationship_routes(actions)
55
68
  instance_eval(&block) if block
56
69
  end
57
70
  end
58
71
 
59
- def define_relationship_routes
72
+ # The relationships controller authorizes the parent record, never the
73
+ # member action the caller left out. So a caller who omitted `:update` still
74
+ # got the relationship PATCH, a write path they never asked for.
75
+ def permitted_relationship_actions(options)
76
+ named = options.key?(:only) ? Array(options[:only]).map(&:to_sym) : RELATIONSHIP_ACTIONS.values
77
+ kept = named - Array(options[:except]).map(&:to_sym)
78
+ RELATIONSHIP_ACTIONS.select { |_action, member| kept.include?(member) }.keys
79
+ end
80
+
81
+ # The :relationship helper hangs on the first verb drawn, so it survives
82
+ # a declaration that keeps a write verb and drops :show.
83
+ def define_relationship_routes(actions)
84
+ return if actions.empty?
85
+
60
86
  member do
61
- get "relationships/:relationship_name", to: relationships_endpoint(:show), as: :relationship
62
- patch "relationships/:relationship_name", to: relationships_endpoint(:update)
63
- delete "relationships/:relationship_name", to: relationships_endpoint(:destroy)
87
+ path = "relationships/:relationship_name"
88
+ { get: :show, patch: :update, delete: :destroy }.each do |verb, action|
89
+ next unless actions.include?(action)
90
+
91
+ name = (:relationship if action == actions.first)
92
+ public_send(verb, path, to: relationships_endpoint(action), as: name)
93
+ end
64
94
  end
65
95
  end
66
96
 
@@ -77,29 +107,31 @@ module JSONAPI
77
107
  end
78
108
  end
79
109
 
80
- def define_sti_routes(resource, resource_name, defaults, sti, namespace = nil)
110
+ def define_sti_routes(resource, defaults, sti, namespace, restriction)
81
111
  return unless sti
82
112
 
83
113
  if sti.is_a?(Array)
84
- define_explicit_sti_routes(sti, defaults)
114
+ define_explicit_sti_routes(sti, defaults, restriction)
85
115
  else
86
- define_auto_sti_routes(resource, resource_name, defaults, namespace)
116
+ define_auto_sti_routes(resource, defaults, namespace, restriction)
87
117
  end
88
118
  end
89
119
 
90
- def define_explicit_sti_routes(sti_resources, defaults)
120
+ # A subtype entry that states its own options is a declaration and keeps it.
121
+ # A bare entry declares nothing, so it inherits the caller's restriction.
122
+ def define_explicit_sti_routes(sti_resources, defaults, restriction)
91
123
  sti_resources.each do |entry|
92
124
  case entry
93
125
  in Hash
94
126
  entry.each { |sub_resource_name, options| jsonapi_resources(sub_resource_name, defaults:, **options) }
95
127
  else
96
- jsonapi_resources(entry, defaults:)
128
+ jsonapi_resources(entry, defaults:, **restriction)
97
129
  end
98
130
  end
99
131
  end
100
132
 
101
- def define_auto_sti_routes(resource, resource_name, defaults, namespace = nil)
102
- resource_class = JSONAPI::ResourceLoader.find(resource_name, namespace:)
133
+ def define_auto_sti_routes(resource, defaults, namespace, restriction)
134
+ resource_class = JSONAPI::ResourceLoader.find(resource.to_s, namespace:)
103
135
  model_class = resource_class.model_class
104
136
  return unless model_class.respond_to?(:descendants)
105
137
 
@@ -107,7 +139,7 @@ module JSONAPI
107
139
  sub_resource_name = subclass.name.demodulize.underscore.pluralize.to_sym
108
140
  next if sub_resource_name == resource.to_sym
109
141
 
110
- jsonapi_resources(sub_resource_name, defaults:)
142
+ jsonapi_resources(sub_resource_name, defaults:, **restriction)
111
143
  rescue NameError, JSONAPI::ResourceLoader::MissingResourceClass
112
144
  next
113
145
  end
@@ -10,7 +10,7 @@ module JSONAPI
10
10
  end
11
11
 
12
12
  def extract_attributes_from_params
13
- @params.dig(:data, :attributes) || @params[:attributes] || {}
13
+ @params[:attributes] || {}
14
14
  end
15
15
 
16
16
  def permitted_attributes_for_action
@@ -20,9 +20,8 @@ module JSONAPI
20
20
  end
21
21
 
22
22
  def build_attributes_hash
23
- permitted_attrs = definition.permitted_attributes.map(&:to_sym)
23
+ permitted_attrs = definition.permitted_attributes
24
24
  attributes = {}
25
- definition_instance = definition.new(record, {})
26
25
 
27
26
  permitted_attrs.each do |attr_sym|
28
27
  attributes[attr_sym] = get_attribute_value(definition_instance, attr_sym)
@@ -34,16 +33,22 @@ module JSONAPI
34
33
  def get_attribute_value(definition_instance, attr_sym)
35
34
  return definition_instance.public_send(attr_sym) if definition_instance.respond_to?(attr_sym, false)
36
35
 
37
- attr_name = attr_sym.to_s
38
- return record.attributes[attr_name] if model_has_attribute?(attr_name)
39
-
40
- nil
36
+ read_model_attribute(attr_sym.to_s)
41
37
  end
42
38
 
43
- def model_has_attribute?(attr_name)
44
- record.respond_to?(:attributes) &&
45
- record.attributes.is_a?(Hash) &&
46
- record.attributes.key?(attr_name)
39
+ # ActiveRecord#attributes builds a hash of every column on every call,
40
+ # and this reader called it three times per attribute per record: twice
41
+ # to ask whether the column exists, once to read it. A record with a fat
42
+ # text column paid for that column on all three. has_attribute? and
43
+ # read_attribute answer from the attribute set itself, one attribute at a
44
+ # time. A model that is not ActiveRecord keeps the hash route.
45
+ def read_model_attribute(attr_name)
46
+ if record.respond_to?(:has_attribute?) && record.respond_to?(:read_attribute)
47
+ return record.has_attribute?(attr_name) ? record.read_attribute(attr_name) : nil
48
+ end
49
+
50
+ attributes = record.attributes if record.respond_to?(:attributes)
51
+ attributes.is_a?(Hash) ? attributes[attr_name] : nil
47
52
  end
48
53
  end
49
54
  end
@@ -10,7 +10,15 @@ module JSONAPI
10
10
  # denies. The request-scoped cache memoizes the scopes and the per-id
11
11
  # verdicts, so each record is vetted at most once per request.
12
12
  def filter_loaded_records(association, related_klass)
13
- loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
13
+ target = association.target
14
+ # A collection target is an Array; a singular target is the record or
15
+ # nil. A respond_to?(:to_a) probe on a record walks ActiveModel's
16
+ # attribute matcher and allocates a string per probe.
17
+ loaded_array = if target.is_a?(Array)
18
+ target
19
+ else
20
+ target.nil? ? [] : [target]
21
+ end
14
22
  return [] if loaded_array.empty?
15
23
 
16
24
  include_filter_cache.filter(loaded_array, related_klass)
@@ -4,10 +4,19 @@ require_relative "include_filtering"
4
4
 
5
5
  module JSONAPI
6
6
  module Serialization
7
- IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, keyword_init: true)
8
- PathContext = Struct.new(:path_parts, :path_to_related, :association_name, :current_record, keyword_init: true)
9
- IncludeIteration = Struct.new(:related_record, :path_parts, :path_to_related, :association_name, :current_record,
10
- keyword_init: true,)
7
+ IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, :requested, :walks,
8
+ keyword_init: true,) do
9
+ # Per-request memos: the relationship names each include path asks for,
10
+ # and the precomputed walk for each path. They default lazily, so a
11
+ # context built by hand (a caller outside this gem) works without
12
+ # naming them.
13
+ def requested = self[:requested] ||= {}
14
+
15
+ def walks = self[:walks] ||= {}
16
+ end
17
+ # Precomputed shape of one include path: association symbols per depth and
18
+ # the dotted prefix string that names each depth from the root.
19
+ IncludePathWalk = Struct.new(:names, :prefixes, keyword_init: true)
11
20
 
12
21
  module IncludesSerialization
13
22
  include IncludePathHelpers
@@ -19,7 +28,7 @@ module JSONAPI
19
28
 
20
29
  ctx = context || build_include_context(fields, all_includes)
21
30
  ctx.all_includes ||= all_includes
22
- all_includes.each { |path| serialize_include_path(record, path, ctx, path_from_root: "") }
31
+ all_includes.each { |path| serialize_include_path(record, path_walk_for(path, ctx), 0, ctx) }
23
32
  ctx.included_records
24
33
  end
25
34
 
@@ -29,34 +38,45 @@ module JSONAPI
29
38
 
30
39
  private
31
40
 
32
- def serialize_include_path(current_record, include_path, ctx, path_from_root: "")
33
- path_parts = include_path.split(".")
34
- association_name = path_parts.first.to_sym
41
+ # The split, the association symbols, and the prefix strings depend on
42
+ # the path alone, so compute them once per path instead of once per
43
+ # parent record. The old walk re-split and re-joined the path strings at
44
+ # every level for every record it passed.
45
+ def path_walk_for(path, ctx)
46
+ ctx.walks[path] ||= begin
47
+ names = path.split(".").map(&:to_sym)
48
+ prefixes = names.each_with_index.map { |_, i| names[0..i].join(".") }
49
+ IncludePathWalk.new(names: names, prefixes: prefixes)
50
+ end
51
+ end
52
+
53
+ def serialize_include_path(current_record, walk, depth, ctx)
54
+ association_name = walk.names[depth]
35
55
  return unless valid_include_association?(current_record, association_name)
36
56
 
37
- path_to_related = path_from_root.blank? ? path_parts.first : "#{path_from_root}.#{path_parts.first}"
38
- path_ctx = PathContext.new(path_parts:, path_to_related:, association_name:, current_record: current_record)
39
- process_related_records(get_related_records(current_record, association_name), path_ctx, ctx)
57
+ serialize_walk_level(current_record, get_related_records(current_record, association_name),
58
+ walk, depth, ctx,)
40
59
  end
41
60
 
42
- def process_related_records(related_array, path_ctx, ctx)
61
+ # The attachment verdict holds for the whole related array: it reads the
62
+ # association name and the parent class, which do not change inside the
63
+ # loop.
64
+ def serialize_walk_level(current_record, related_array, walk, depth, ctx)
65
+ attachment = self.class.active_storage_attachment?(walk.names[depth], current_record.class)
66
+ nested = depth + 1 < walk.names.size
67
+
43
68
  related_array.each do |related_record|
44
- iter = IncludeIteration.new(related_record: related_record, **path_ctx.to_h)
45
- serialize_related_record(iter, ctx)
69
+ serialize_walk_record(related_record, current_record, walk, depth, ctx, attachment: attachment)
70
+ serialize_include_path(related_record, walk, depth + 1, ctx) if nested
46
71
  end
47
72
  end
48
73
 
49
- def serialize_related_record(iter, ctx)
50
- parent_context = parent_context_for(iter.association_name, iter.current_record)
51
- serialize_and_process_record(iter.related_record, iter.path_to_related, ctx, **parent_context)
52
- serialize_nested_path(iter.related_record, iter.path_parts, iter.path_to_related, ctx)
53
- end
54
-
55
- def parent_context_for(association_name, current_record)
56
- if self.class.active_storage_attachment?(association_name, current_record.class)
57
- { parent_record: current_record, association_name: }
74
+ def serialize_walk_record(related_record, current_record, walk, depth, ctx, attachment:)
75
+ if attachment
76
+ serialize_and_process_record(related_record, walk.prefixes[depth], ctx,
77
+ parent_record: current_record, association_name: walk.names[depth],)
58
78
  else
59
- {}
79
+ serialize_and_process_record(related_record, walk.prefixes[depth], ctx)
60
80
  end
61
81
  end
62
82
 
@@ -122,12 +142,12 @@ module JSONAPI
122
142
  end
123
143
 
124
144
  def serialize_and_process_record(related_record, path_to_record, ctx, parent_record: nil, association_name: nil)
125
- return if ctx.processed.include?(build_record_key(related_record))
145
+ return unless ctx.processed.add?(build_record_key(related_record))
126
146
 
127
- requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
147
+ requested = ctx.requested[path_to_record] ||=
148
+ include_paths_to_relationship_names(ctx.all_includes, path_to_record)
128
149
  serializer = child_serializer(related_record, parent_record:, association_name:)
129
150
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
130
- ctx.processed.add(build_record_key(related_record))
131
151
  end
132
152
 
133
153
  # An included record serializes with the same request context as its
@@ -138,15 +158,10 @@ module JSONAPI
138
158
  authorization_context:, include_filter_cache:, namespace:,)
139
159
  end
140
160
 
161
+ # A two-slot array beats the string key: no interpolation, no copy of
162
+ # the class name, and the same class-plus-id identity.
141
163
  def build_record_key(related_record)
142
- "#{related_record.class.name}-#{related_record.id}"
143
- end
144
-
145
- def serialize_nested_path(related_record, path_parts, path_from_root, ctx)
146
- return unless path_parts.length > 1
147
-
148
- nested_path = path_parts[1..].join(".")
149
- serialize_include_path(related_record, nested_path, ctx, path_from_root: path_from_root)
164
+ [related_record.class, related_record.id]
150
165
  end
151
166
  end
152
167
  end
@@ -22,8 +22,7 @@ module JSONAPI
22
22
  def fetch_instance_meta
23
23
  return unless definition.method_defined?(:meta)
24
24
 
25
- instance = definition.new(@record, {})
26
- instance.meta
25
+ definition_instance.meta
27
26
  end
28
27
 
29
28
  def fetch_class_meta
@@ -4,7 +4,7 @@ module JSONAPI
4
4
  module Serialization
5
5
  module RelationshipsDeserialization
6
6
  def relationships
7
- rels = @params.dig(:data, :relationships) || @params[:relationships] || {}
7
+ rels = @params[:relationships] || {}
8
8
  rels = rels.to_h if rels.respond_to?(:to_h)
9
9
  rels.is_a?(Hash) ? rels : {}
10
10
  end
@@ -17,7 +17,7 @@ module JSONAPI
17
17
  include Serialization::DeserializationHelpers
18
18
 
19
19
  def initialize(params, model_class:, action: :create, definition: nil)
20
- @params = ParamHelpers.deep_symbolize_params(params)
20
+ @params = resource_object(ParamHelpers.deep_symbolize_params(params))
21
21
  @model_class = model_class
22
22
  # Prefer the resource the request is addressing (resolved by type/route). Resolving
23
23
  # from the model alone is ambiguous when several resources share one model
@@ -26,5 +26,21 @@ module JSONAPI
26
26
  @definition = definition || ResourceLoader.find_for_model(model_class)
27
27
  @action = action.to_sym
28
28
  end
29
+
30
+ private
31
+
32
+ # A caller passes either the whole document or the resource object inside it, and the
33
+ # controllers pass the resource object, which the client writes. `type` settles which one
34
+ # arrived: JSON:API requires it in every resource object and names no `type` among the
35
+ # members of a document. So a hash that carries `type` is the resource object, and only a
36
+ # hash that carries none, over a `data` member that carries one, is a document. Anything
37
+ # else is neither, and the outer hash wins. A reader that took a nested `data` first let a
38
+ # client hide an attribute or a relationship from a guard that reads the request body.
39
+ def resource_object(params)
40
+ return params if params.key?(:type)
41
+ return params unless params[:data].is_a?(Hash) && params[:data].key?(:type)
42
+
43
+ params[:data]
44
+ end
29
45
  end
30
46
  end
@@ -83,6 +83,14 @@ module JSONAPI
83
83
 
84
84
  attr_reader :record, :definition, :parent_record, :association_name, :authorization_context, :namespace
85
85
 
86
+ # One resource instance per serialized record, shared by the attribute
87
+ # reader and the meta reader. The two built separate instances, which
88
+ # doubled the instantiation and kept the resource's own memoized reads
89
+ # from being shared between an attribute and a meta entry.
90
+ def definition_instance
91
+ @definition_instance ||= definition.new(record)
92
+ end
93
+
86
94
  # Shared per request when the controller passes one in; a standalone
87
95
  # serializer builds its own, which still dedupes within its own walk.
88
96
  def include_filter_cache
@@ -63,7 +63,11 @@ module JSONAPI
63
63
  end
64
64
 
65
65
  def find_relationship_definition(definition, relationship_name)
66
- definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
66
+ if definition.respond_to?(:relationship_definition_index)
67
+ definition.relationship_definition_index[relationship_name]
68
+ else
69
+ definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
70
+ end
67
71
  end
68
72
  end
69
73
  end
@@ -73,9 +73,7 @@ module JSONAPI
73
73
  end
74
74
 
75
75
  def polymorphic_association?(definition, relationship_name)
76
- relationship_def = definition.relationship_definitions.find do |r|
77
- r[:name].to_s == relationship_name.to_s
78
- end
76
+ relationship_def = RelationshipHelpers.find_relationship_definition(definition, relationship_name)
79
77
  return false unless relationship_def
80
78
 
81
79
  relationship_def[:options][:polymorphic] == true
@@ -9,15 +9,26 @@ module JSONAPI
9
9
  # relationship identifier. Cache frozen results keyed by name and format.
10
10
  # The railtie clears the cache on code reload.
11
11
  @type_name_cache = Concurrent::Map.new
12
+ # Identity front cache: the name-keyed cache still allocated its key
13
+ # string on every call. Nested by kind and format so a lookup allocates
14
+ # nothing; values come through the name-keyed cache, and both clear
15
+ # together on code reload.
16
+ @class_name_cache = Concurrent::Map.new
12
17
 
13
18
  def self.reset_cache!
14
19
  @type_name_cache.clear
20
+ @class_name_cache.clear
15
21
  end
16
22
 
17
23
  def self.type_name_cache
18
24
  @type_name_cache
19
25
  end
20
26
 
27
+ def self.class_name_bucket(kind, format)
28
+ by_kind = @class_name_cache.compute_if_absent(kind) { Concurrent::Map.new }
29
+ by_kind.compute_if_absent(format) { Concurrent::Map.new }
30
+ end
31
+
21
32
  module_function
22
33
 
23
34
  def type_to_class_name(type, namespace: nil)
@@ -38,8 +49,14 @@ module JSONAPI
38
49
  name = model_class.name
39
50
  return format_type_name(name.underscore.pluralize, format) if name.nil?
40
51
 
41
- cache = TypeConversion.type_name_cache
42
- cache["m|#{name}|#{format}"] ||= format_type_name(name.underscore.pluralize, format).freeze
52
+ bucket = TypeConversion.class_name_bucket(:model, format)
53
+ bucket[model_class] ||= named_type_name("m|#{name}|#{format}", name.underscore.pluralize, format)
54
+ end
55
+
56
+ # The name-keyed cache stays the source of truth so a reloaded class
57
+ # resolves to the same frozen string.
58
+ def named_type_name(key, full_name, format)
59
+ TypeConversion.type_name_cache[key] ||= format_type_name(full_name, format).freeze
43
60
  end
44
61
 
45
62
  def resource_type_name(definition_class, format: nil)
@@ -47,8 +64,9 @@ module JSONAPI
47
64
  name = definition_class.name
48
65
  return format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format) if name.nil?
49
66
 
50
- cache = TypeConversion.type_name_cache
51
- cache["r|#{name}|#{format}"] ||= format_type_name(name.sub(/Resource$/, "").underscore.pluralize, format).freeze
67
+ bucket = TypeConversion.class_name_bucket(:resource, format)
68
+ bucket[definition_class] ||=
69
+ named_type_name("r|#{name}|#{format}", name.sub(/Resource$/, "").underscore.pluralize, format)
52
70
  end
53
71
 
54
72
  def format_type_name(full_name, format)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.10.1"
4
+ VERSION = "4.0.0"
5
5
  end
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.10.1
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp