jpie 3.11.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cc59c786e140082618fccd88462e9ea9e3c98d97aa6184752d9d06b0c99d8eb
4
- data.tar.gz: 4e212599caf45aad70ba6f286a2be35e5d61df0aee6c62e23f3177f827e7bb83
3
+ metadata.gz: fd3d3cf0f731b0a5d47d2e0ae3b4fc405d7b49ae775da39baf04a3f862b06d3b
4
+ data.tar.gz: 2d305cd5e41bb20cc5ef36fdf0321407425e503fee2a7424f40035cb9fda0059
5
5
  SHA512:
6
- metadata.gz: 2360e7d99ce16c2e9a9b485920811cd56f69e6281cd2dfd5b565be44a23c6cea9f9b85f8bd23caabe45b8e8976ff67e650d43bdc3a49ca9de1aab75151dc33db
7
- data.tar.gz: 29f4244ee319f9a68403f4aeda3f8a497b843c5b67e0cd975704bc5e3f9ac0c4041da86fc008c618ca4552c08b3f167eb6794ae36adc7ad4b14095ec6e3bc7b9
6
+ metadata.gz: beeb386f1743ca01b78a5532925d16115b66ca94ae00f31b1b94118c7b0bdf63367e00242593b78e62cd891c7e497cc00b722ee17d6fa37b724f2de5aa40d178
7
+ data.tar.gz: e6362ed9996bec7dedabf0891e9a50a4ff80f240829cf6f7e08decf4b4ddf5d29c139f6e71eb88de1a53648449c416907a81c7f73dee1134e49a4b8704ca5063
data/CHANGELOG.md CHANGED
@@ -7,6 +7,133 @@ 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
+
10
137
  ## [3.11.0]
11
138
 
12
139
  ### Changed
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
 
@@ -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
@@ -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)
@@ -39,7 +39,7 @@ module JSONAPI
39
39
  end
40
40
 
41
41
  def self.find(resource_type, namespace: nil)
42
- key = "#{namespace}|#{resource_type}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
42
+ key = "#{namespace}|#{resource_type}"
43
43
  cached = @find_cache[key]
44
44
  return cached if cached
45
45
 
@@ -48,19 +48,12 @@ module JSONAPI
48
48
  @find_cache[key] = klass
49
49
  end
50
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.
51
55
  def self.find_candidates(resource_type, namespace)
52
- candidates = []
53
-
54
- # Namespaced resource, e.g. "widgets" with namespace "api/v1" → API::V1::WidgetResource
55
- candidates << build_resource_class_name(resource_type, namespace) if namespace
56
-
57
- # Flat resource, e.g. "widgets" → WidgetResource
58
- if !namespace || JSONAPI.configuration.namespace_fallback
59
- candidates << build_resource_class_name(resource_type,
60
- nil,)
61
- end
62
-
63
- candidates
56
+ [build_resource_class_name(resource_type, namespace.presence)]
64
57
  end
65
58
 
66
59
  def self.find_for_model(model_class, namespace: nil)
@@ -71,10 +64,7 @@ module JSONAPI
71
64
  end
72
65
 
73
66
  def self.model_identity_bucket(namespace)
74
- fallback = @model_identity_cache.compute_if_absent(JSONAPI.configuration.namespace_fallback ? 1 : 0) do
75
- Concurrent::Map.new
76
- end
77
- fallback.compute_if_absent(namespace) { Concurrent::Map.new }
67
+ @model_identity_cache.compute_if_absent(namespace) { Concurrent::Map.new }
78
68
  end
79
69
 
80
70
  def self.find_for_model_uncached(model_class, namespace)
@@ -99,19 +89,39 @@ module JSONAPI
99
89
  def self.model_cache_key(model_class, namespace)
100
90
  return "::#{model_class.name}" unless namespace
101
91
 
102
- "#{namespace}|::#{model_class.name}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
92
+ "#{namespace}|::#{model_class.name}"
103
93
  end
104
94
 
105
- # The request namespace outranks the model's own namespace, which stays a
106
- # fallback so a namespaced model still reaches its own resource. The flat
107
- # 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.
108
102
  def self.request_namespace_candidates(model_class, namespace, model_namespace)
109
- resource_type = model_class.name.demodulize.underscore.pluralize
110
- candidates = [build_resource_class_name(resource_type, namespace)]
103
+ candidates = sti_resource_names(model_class, namespace)
111
104
  candidates |= model_candidates(model_class, model_namespace)
112
- 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?("::")
113
122
 
114
- candidates - [build_resource_class_name(resource_type, nil)]
123
+ combined = "#{klass.name.gsub("::", "")}Resource"
124
+ names << (namespace ? "#{namespace.to_s.camelize}::#{combined}" : combined)
115
125
  end
116
126
 
117
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.11.0"
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.11.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp