jpie 3.8.2 → 3.9.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: 4d78efd83c17a8d1e0b9c7e13966c083de82d95d92463dd5b945a4478d981c30
4
- data.tar.gz: 71594c1b5687a4d20268d79b4e83e3760d399f3467029b8f0787329b6cf599fa
3
+ metadata.gz: 4f3af561d7afa748d2a1095a6b3c83fe8ab42b12a6fb88655f67c023e72779ba
4
+ data.tar.gz: ea3c315885d7b2ae18f328bb6659c796e64e82b62b87ae479fd7d3faa0ef4f09
5
5
  SHA512:
6
- metadata.gz: e1bdbd01c0c181b59be183a8d72decb7d3e13f254c92cd477285f69a909953d4bfe58f6dff6001ddbc437036888e30b6ab9e921675fd65a70003206f714f37ac
7
- data.tar.gz: 6bdadef777e82f021a67b820e77e61a7c2051dbb5cddc037447b0c42c26c3142cc738f8a6aa864ef26b2cfb19a3a0f5e6f6a42afd5ec483b1049bd15d24acf52
6
+ metadata.gz: edd52e432cec91efbd1487ebb8ad340fbe560879415ae6af400290b68e5c5bfa3db6f7afd967077bbf6a5509992329b412f865af88db19f80accadb8903f44e5
7
+ data.tar.gz: 1f9854c359c421410f19645ae03659137059e92f8bc88a1b52b3e79ac2906abe5747c859a46ed02a312ba8ab37e7f661fbce1c16e42a2a29e73e5446122889b1
@@ -42,6 +42,12 @@ Gather facts before concluding. Prefer:
42
42
 
43
43
  Do not rely on assumptions. Evidence supports or refutes hypotheses.
44
44
 
45
+ **Bind the error to the incident by identity, not by proximity.** An error in the same account, the same minute, the same job class, or the same feature area is NOT evidence that it caused the reported failure. Busy systems fire several unrelated errors in one window. Prove the link with a record identity that both sides share — the job arguments, the record GID, the message id, the request id. Sentry event details carry `job_arguments` and `extra`; open them and read the ids. If no shared id ties the error to the records the user named, you have a co-occurring error, not the cause.
46
+
47
+ Search the failure's exact time window for EVERY error, not just the ones that match your first idea. Then pick the one whose arguments name the user's records. A narrow search that starts from a suspected culprit returns that culprit and hides the real one.
48
+
49
+ **When the user's evidence contradicts your diagnosis, re-run the search.** Do not restate the theory in better words. Their report of what actually happened outranks your reading of the code. Treat the contradiction as a failed hypothesis and go back to evidence gathering.
50
+
45
51
  ## Methodology and verification
46
52
 
47
53
  - **5 Whys:** State the problem; ask "why?" repeatedly until you reach a cause you can fix. **Verify:** Would fixing this prevent the issue? If not, keep investigating.
@@ -55,6 +61,10 @@ Do not rely on assumptions. Evidence supports or refutes hypotheses.
55
61
  | "Let me try this quick fix" | You do not understand the cause |
56
62
  | "Maybe if I add a guard here" | Guessing, not debugging |
57
63
  | "Let me try a few things" | Random changes waste time |
64
+ | "This error is in the same account and the same minute, so it is the one" | Co-occurrence is not causation; match a record id before you believe it |
65
+ | "The related error is in the same job class / feature, close enough" | Same area is not same incident; open the event's arguments and match the ids |
66
+ | "The user says it still fails, let me explain why my fix is right" | Their evidence beat your theory; re-run the search instead of defending |
67
+ | "I found an error that fits my theory, stop searching" | Search the whole time window; a narrow search only confirms what you assumed |
58
68
 
59
69
  When you notice these, stop and run through problem definition, evidence, and one hypothesis at a time.
60
70
 
@@ -20,6 +20,7 @@ Do not recommend changing code just to pass specs, or changing specs just to mak
20
20
  - Review recent changes (git log, options, caller expectations).
21
21
  - Gather evidence (params, include, serializer path, input data).
22
22
  - Trace backwards from the failure to where correct behaviour diverges.
23
+ - Bind each error you find to the reported incident by a shared record id (job arguments, record GID, message id, request id) — same account, same minute, or same feature is proximity, not proof. Search the whole failure window, not only what matches your first idea.
23
24
 
24
25
  ### Phase 2: Pattern analysis
25
26
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (3.8.2)
4
+ jpie (3.9.0)
5
5
  actionpack (~> 8.1, >= 8.1.0)
6
6
  pg_query (>= 4)
7
7
  prosopite (>= 1)
data/README.md CHANGED
@@ -875,6 +875,13 @@ This will:
875
875
  - Load `API::V1::WidgetResource` (falls back to `WidgetResource` if `namespace_fallback: true`)
876
876
  - Pass `jsonapi_namespace: "api/v1"` to the controller
877
877
 
878
+ The request namespace also selects the resource class during serialization: for
879
+ the attributes, the include filter, the links, and the relationship linkage.
880
+ `GET /portal/posts` serializes with `Portal::PostResource` even when the `Post`
881
+ model is flat. When the namespaced resource does not exist, the loader falls
882
+ back to the model's own namespace, then to the flat resource. Set
883
+ `config.namespace_fallback = false` to refuse the flat fallback.
884
+
878
885
  #### Namespaced Resources
879
886
 
880
887
  Define resources within namespaces that match your route structure:
@@ -147,7 +147,7 @@ module JSONAPI
147
147
  return if records.empty?
148
148
 
149
149
  klass = records.first.class
150
- resource_scope = ResourceLoader.find_for_model(klass).records
150
+ resource_scope = ResourceLoader.find_for_model(klass, namespace: jsonapi_namespace).records
151
151
  preload_values = resource_scope.preload_values + resource_scope.includes_values
152
152
  return if preload_values.empty?
153
153
 
@@ -9,11 +9,9 @@ module JSONAPI
9
9
  def serialize_resource(resource)
10
10
  includes = parse_include_param
11
11
  cache = build_include_filter_cache([resource], includes)
12
- JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache).to_hash(
13
- include: includes,
14
- fields: parse_fields_param,
15
- document_meta: jsonapi_document_meta,
16
- )
12
+ serializer = JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache,
13
+ namespace: jsonapi_namespace,)
14
+ serializer.to_hash(include: includes, fields: parse_fields_param, document_meta: jsonapi_document_meta)
17
15
  end
18
16
 
19
17
  def serialize_collection(resources)
@@ -48,7 +46,8 @@ module JSONAPI
48
46
  end
49
47
 
50
48
  def serialize_single(resource, includes, fields, include_context = nil, cache = nil)
51
- JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache)
49
+ JSONAPI::Serializer.new(resource, authorization_context: self, include_filter_cache: cache,
50
+ namespace: jsonapi_namespace,)
52
51
  .to_hash(include: includes, fields:, document_meta: nil, include_context:)
53
52
  end
54
53
 
@@ -56,7 +55,8 @@ module JSONAPI
56
55
  # every included record gets its scope verdict in one query per class,
57
56
  # and the serializers answer their per-visit checks from memory.
58
57
  def build_include_filter_cache(resources, includes)
59
- cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self)
58
+ cache = JSONAPI::Serialization::IncludeFilterCache.new(authorization_context: self,
59
+ namespace: jsonapi_namespace,)
60
60
  includes.each do |path|
61
61
  current = resources
62
62
  path.split(".").each do |part|
@@ -59,20 +59,42 @@ module JSONAPI
59
59
  def self.find_for_model(model_class, namespace: nil)
60
60
  return ActiveStorageBlobResource if active_storage_blob?(model_class)
61
61
 
62
- # Key format "::ModelName" is public-ish: applications pre-populate
63
- # @model_cache with these keys to pin STI subclass resources.
64
- key = namespace ? "#{namespace}|::#{model_class.name}" : "::#{model_class.name}"
62
+ key = model_cache_key(model_class, namespace)
65
63
  cached = @model_cache[key]
66
64
  return cached if cached
67
65
 
68
- effective_namespace = namespace || extract_namespace_from_model(model_class)
69
- candidates = model_candidates(model_class, effective_namespace)
70
-
71
- klass = resolve(candidates) ||
72
- raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
66
+ model_namespace = extract_namespace_from_model(model_class)
67
+ klass = resolve(find_for_model_candidates(model_class, namespace, model_namespace)) ||
68
+ raise(MissingResourceClass.new(model_class.name, namespace: namespace || model_namespace))
73
69
  @model_cache[key] = klass
74
70
  end
75
71
 
72
+ def self.find_for_model_candidates(model_class, namespace, model_namespace)
73
+ return model_candidates(model_class, model_namespace) unless namespace
74
+
75
+ request_namespace_candidates(model_class, namespace, model_namespace)
76
+ end
77
+
78
+ # Key format "::ModelName" is public-ish: applications pre-populate
79
+ # @model_cache with these keys to pin STI subclass resources.
80
+ def self.model_cache_key(model_class, namespace)
81
+ return "::#{model_class.name}" unless namespace
82
+
83
+ "#{namespace}|::#{model_class.name}|#{JSONAPI.configuration.namespace_fallback ? 1 : 0}"
84
+ end
85
+
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.
89
+ 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)]
92
+ candidates |= model_candidates(model_class, model_namespace)
93
+ return candidates if JSONAPI.configuration.namespace_fallback
94
+
95
+ candidates - [build_resource_class_name(resource_type, nil)]
96
+ end
97
+
76
98
  def self.build_resource_class_name(resource_type, namespace)
77
99
  base = "#{resource_type.singularize.classify}Resource"
78
100
  return base unless namespace.present?
@@ -61,7 +61,7 @@ module JSONAPI
61
61
  end
62
62
 
63
63
  def valid_include_association?(current_record, association_name)
64
- current_definition = ResourceLoader.find_for_model(current_record.class)
64
+ current_definition = ResourceLoader.find_for_model(current_record.class, namespace:)
65
65
  relationship_def = RelationshipHelpers.find_relationship_definition(current_definition, association_name)
66
66
  return false unless relationship_def
67
67
  return true if self.class.active_storage_attachment?(association_name, current_record.class)
@@ -91,7 +91,7 @@ module JSONAPI
91
91
  end
92
92
 
93
93
  def build_scoped_relation(related_klass, association)
94
- related_base_scope = ResourceLoader.find_for_model(related_klass).records
94
+ related_base_scope = ResourceLoader.find_for_model(related_klass, namespace:).records
95
95
  association.scope.merge(apply_include_authorization(related_base_scope, related_klass))
96
96
  end
97
97
 
@@ -119,12 +119,19 @@ module JSONAPI
119
119
  return if ctx.processed.include?(build_record_key(related_record))
120
120
 
121
121
  requested = include_paths_to_relationship_names(ctx.all_includes, path_to_record)
122
- serializer = self.class.new(related_record, parent_record:, association_name:,
123
- authorization_context:, include_filter_cache:,)
122
+ serializer = child_serializer(related_record, parent_record:, association_name:)
124
123
  ctx.included_records << serializer.serialize_record(ctx.fields, requested_relationships: requested)
125
124
  ctx.processed.add(build_record_key(related_record))
126
125
  end
127
126
 
127
+ # An included record serializes with the same request context as its
128
+ # parent: the authorization context, the filter cache, and the namespace
129
+ # that selects the resource class.
130
+ def child_serializer(related_record, parent_record:, association_name:)
131
+ self.class.new(related_record, parent_record:, association_name:,
132
+ authorization_context:, include_filter_cache:, namespace:,)
133
+ end
134
+
128
135
  def build_record_key(related_record)
129
136
  "#{related_record.class.name}-#{related_record.id}"
130
137
  end
@@ -43,7 +43,7 @@ module JSONAPI
43
43
  end
44
44
 
45
45
  def variant_options_for_parent_association
46
- parent_definition = ResourceLoader.find_for_model(parent_record.class)
46
+ parent_definition = ResourceLoader.find_for_model(parent_record.class, namespace:)
47
47
  rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
48
48
  rel_def&.dig(:options, :variant)
49
49
  end
@@ -61,26 +61,29 @@ module JSONAPI
61
61
 
62
62
  def process_to_many_relationship(attrs, association_name, param_name, data)
63
63
  ids = data.map { |r| extract_id(r) }
64
- types = data.map { |r| extract_type(r) }
65
64
 
66
- if types.any? && self.class.active_storage_blob_type?(types.first)
65
+ # An attachment must take the attachment path whether or not the client sends a
66
+ # `type`. JSON:API requires a resource identifier to carry `type` and `id`, so
67
+ # routing on the association itself keeps the spec-compliant payload working
68
+ # instead of falling through to the `<rel>_ids=` setter, which an attachment lacks.
69
+ if active_storage_attachment?(association_name)
67
70
  process_active_storage_attachment(attrs, association_name, ids, singular: false)
68
71
  return
69
72
  end
70
73
 
74
+ types = data.map { |r| extract_type(r) }
71
75
  validate_relationship_type(association_name, types.first) unless polymorphic_association?(association_name)
72
76
  attrs["#{param_name.singularize}_ids"] = ids
73
77
  end
74
78
 
75
79
  def process_to_one_relationship(attrs, association_name, param_name, data)
76
80
  id = extract_id(data)
77
- type = extract_type(data)
78
81
 
79
- if self.class.active_storage_blob_type?(type)
82
+ if active_storage_attachment?(association_name)
80
83
  return process_active_storage_attachment(attrs, association_name, id, singular: true)
81
84
  end
82
85
 
83
- process_regular_to_one_relationship(attrs, association_name, param_name, id, type)
86
+ process_regular_to_one_relationship(attrs, association_name, param_name, id, extract_type(data))
84
87
  end
85
88
 
86
89
  def process_regular_to_one_relationship(attrs, association_name, param_name, id, type)
@@ -45,14 +45,19 @@ module JSONAPI
45
45
  relationships[association_name] = result
46
46
  end
47
47
 
48
+ # Linkage carries a type and an id, so reading the association directly
49
+ # leaks the identity of a record the related endpoint denies — the same
50
+ # bypass the `included` section closes, with a narrower payload. Both
51
+ # sections resolve through one authorized read, so they always describe
52
+ # the same set, and the request-scoped filter cache means the second
53
+ # reader vets nothing the first already vetted.
48
54
  def serialize_relationship_data(association)
49
- related = record.public_send(association.name)
55
+ related = get_association_records(record, association.name)
50
56
 
51
- if association.collection?
52
- serialize_collection_relationship(related, association)
53
- elsif related
54
- serialize_single_relationship(related, association)
55
- end
57
+ return serialize_collection_relationship(related, association) if association.collection?
58
+
59
+ first = related.first
60
+ serialize_single_relationship(first, association) if first
56
61
  end
57
62
 
58
63
  def serialize_collection_relationship(related, association)
@@ -74,6 +79,7 @@ module JSONAPI
74
79
  resource_class: definition,
75
80
  use_instance_class:,
76
81
  base_resource_class: base_def_for_related,
82
+ namespace:,
77
83
  )
78
84
  end
79
85
 
@@ -15,8 +15,9 @@ module JSONAPI
15
15
  Entry = Struct.new(:base_scope, :authorized_scope, :narrowed, :where_clause_empty, :where_hash,
16
16
  keyword_init: true,)
17
17
 
18
- def initialize(authorization_context: nil)
18
+ def initialize(authorization_context: nil, namespace: nil)
19
19
  @authorization_context = authorization_context
20
+ @namespace = namespace
20
21
  @entries = {}
21
22
  @verdicts = {}
22
23
  end
@@ -53,7 +54,7 @@ module JSONAPI
53
54
  end
54
55
 
55
56
  def build_entry(klass)
56
- base_scope = ResourceLoader.find_for_model(klass).records
57
+ base_scope = ResourceLoader.find_for_model(klass, namespace: @namespace).records
57
58
  authorized_scope = apply_authorization(base_scope, klass)
58
59
  Entry.new(
59
60
  base_scope: base_scope,
@@ -35,10 +35,16 @@ module JSONAPI
35
35
  @jsonapi_object = nil
36
36
  end
37
37
 
38
+ # namespace is the request's JSON:API namespace. A namespaced endpoint over
39
+ # a flat model (a public portal that re-exposes an internal model) resolves
40
+ # to its own resource only when the namespace reaches this far: resolution
41
+ # by model alone derives the namespace from the model, so a flat model can
42
+ # never reach a namespaced resource.
38
43
  def initialize(record, definition: nil, base_definition: nil, parent_record: nil, association_name: nil,
39
- authorization_context: nil, include_filter_cache: nil)
44
+ authorization_context: nil, include_filter_cache: nil, namespace: nil)
40
45
  @record = record
41
- @definition = definition || ResourceLoader.find_for_model(record.class)
46
+ @namespace = namespace
47
+ @definition = definition || ResourceLoader.find_for_model(record.class, namespace:)
42
48
  @base_definition = base_definition
43
49
  @parent_record = parent_record
44
50
  @association_name = association_name
@@ -75,12 +81,12 @@ module JSONAPI
75
81
 
76
82
  private
77
83
 
78
- attr_reader :record, :definition, :parent_record, :association_name, :authorization_context
84
+ attr_reader :record, :definition, :parent_record, :association_name, :authorization_context, :namespace
79
85
 
80
86
  # Shared per request when the controller passes one in; a standalone
81
87
  # serializer builds its own, which still dedupes within its own walk.
82
88
  def include_filter_cache
83
- @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context: authorization_context)
89
+ @include_filter_cache ||= Serialization::IncludeFilterCache.new(authorization_context:, namespace:)
84
90
  end
85
91
 
86
92
  def base_definition
@@ -20,7 +20,8 @@ module JSONAPI
20
20
  # Delegate to ResourceIdentifier
21
21
  # rubocop:disable Lint/UnusedMethodArgument
22
22
  def serialize_resource_identifier(
23
- record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil
23
+ record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil,
24
+ namespace: nil
24
25
  )
25
26
  # rubocop:enable Lint/UnusedMethodArgument
26
27
  ResourceIdentifier.serialize_identifier(
@@ -28,6 +29,7 @@ module JSONAPI
28
29
  association:,
29
30
  definition: resource_class,
30
31
  use_instance_class:,
32
+ namespace:,
31
33
  )
32
34
  end
33
35
 
@@ -4,10 +4,10 @@ module JSONAPI
4
4
  module ResourceIdentifier
5
5
  module_function
6
6
 
7
- def serialize_identifier(record, association:, definition:, use_instance_class: false)
7
+ def serialize_identifier(record, association:, definition:, use_instance_class: false, namespace: nil)
8
8
  model_class = determine_model_class(record, association:, definition:,
9
9
  use_instance_class:,)
10
- related_definition = JSONAPI::ResourceLoader.find_for_model(model_class)
10
+ related_definition = JSONAPI::ResourceLoader.find_for_model(model_class, namespace:)
11
11
  related_type = TypeConversion.resource_type_name(related_definition)
12
12
 
13
13
  { type: related_type, id: record.id.to_s }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "3.8.2"
4
+ VERSION = "3.9.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.8.2
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -222,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
222
  - !ruby/object:Gem::Version
223
223
  version: '0'
224
224
  requirements: []
225
- rubygems_version: 3.6.9
225
+ rubygems_version: 3.7.2
226
226
  specification_version: 4
227
227
  summary: JSON:API compliant Rails gem for producing and consuming JSON:API resources
228
228
  test_files: []