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 +4 -4
- data/CHANGELOG.md +127 -0
- data/README.md +69 -8
- data/lib/json_api/configuration.rb +1 -2
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +4 -3
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +1 -1
- data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +25 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +11 -3
- data/lib/json_api/controllers/concerns/resource_actions.rb +9 -1
- data/lib/json_api/controllers/relationships_controller.rb +7 -0
- data/lib/json_api/resources/concerns/attributes_dsl.rb +44 -10
- data/lib/json_api/resources/resource_loader.rb +35 -25
- data/lib/json_api/routing.rb +46 -14
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +1 -1
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +1 -1
- data/lib/json_api/serialization/deserializer.rb +17 -1
- data/lib/json_api/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd3d3cf0f731b0a5d47d2e0ae3b4fc405d7b49ae775da39baf04a3f862b06d3b
|
|
4
|
+
data.tar.gz: 2d305cd5e41bb20cc5ef36fdf0321407425e503fee2a7424f40035cb9fda0059
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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.
|
|
888
|
-
|
|
889
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
|
|
58
|
+
if creatable.include?(name) || updatable.include?(name)
|
|
59
|
+
return (action == :create ? creatable : updatable).include?(name)
|
|
60
|
+
end
|
|
55
61
|
|
|
56
|
-
(action
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
92
|
+
"#{namespace}|::#{model_class.name}"
|
|
103
93
|
end
|
|
104
94
|
|
|
105
|
-
# The request namespace outranks the model's own namespace, which
|
|
106
|
-
#
|
|
107
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/json_api/routing.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
patch
|
|
63
|
-
|
|
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,
|
|
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,
|
|
116
|
+
define_auto_sti_routes(resource, defaults, namespace, restriction)
|
|
87
117
|
end
|
|
88
118
|
end
|
|
89
119
|
|
|
90
|
-
|
|
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,
|
|
102
|
-
resource_class = JSONAPI::ResourceLoader.find(
|
|
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
|
|
@@ -4,7 +4,7 @@ module JSONAPI
|
|
|
4
4
|
module Serialization
|
|
5
5
|
module RelationshipsDeserialization
|
|
6
6
|
def relationships
|
|
7
|
-
rels = @params
|
|
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
|
data/lib/json_api/version.rb
CHANGED