jpie 0.4.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +35 -110
  5. data/.travis.yml +7 -0
  6. data/Gemfile +21 -0
  7. data/Gemfile.lock +312 -0
  8. data/README.md +2072 -140
  9. data/Rakefile +3 -14
  10. data/bin/console +15 -0
  11. data/bin/setup +8 -0
  12. data/jpie.gemspec +18 -35
  13. data/kiln/app/resources/user_message_resource.rb +2 -0
  14. data/lib/jpie.rb +3 -28
  15. data/lib/json_api/active_storage/deserialization.rb +106 -0
  16. data/lib/json_api/active_storage/detection.rb +74 -0
  17. data/lib/json_api/active_storage/serialization.rb +32 -0
  18. data/lib/json_api/configuration.rb +58 -0
  19. data/lib/json_api/controllers/base_controller.rb +26 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers.rb +223 -0
  21. data/lib/json_api/controllers/concerns/resource_actions.rb +657 -0
  22. data/lib/json_api/controllers/relationships_controller.rb +504 -0
  23. data/lib/json_api/controllers/resources_controller.rb +6 -0
  24. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  25. data/lib/json_api/railtie.rb +75 -0
  26. data/lib/json_api/resources/active_storage_blob_resource.rb +11 -0
  27. data/lib/json_api/resources/resource.rb +238 -0
  28. data/lib/json_api/resources/resource_loader.rb +35 -0
  29. data/lib/json_api/routing.rb +72 -0
  30. data/lib/json_api/serialization/deserializer.rb +362 -0
  31. data/lib/json_api/serialization/serializer.rb +320 -0
  32. data/lib/json_api/support/active_storage_support.rb +85 -0
  33. data/lib/json_api/support/collection_query.rb +406 -0
  34. data/lib/json_api/support/instrumentation.rb +42 -0
  35. data/lib/json_api/support/param_helpers.rb +51 -0
  36. data/lib/json_api/support/relationship_guard.rb +16 -0
  37. data/lib/json_api/support/relationship_helpers.rb +74 -0
  38. data/lib/json_api/support/resource_identifier.rb +87 -0
  39. data/lib/json_api/support/responders.rb +100 -0
  40. data/lib/json_api/support/response_helpers.rb +10 -0
  41. data/lib/json_api/support/sort_parsing.rb +21 -0
  42. data/lib/json_api/support/type_conversion.rb +21 -0
  43. data/lib/json_api/testing/test_helper.rb +76 -0
  44. data/lib/json_api/testing.rb +3 -0
  45. data/lib/{jpie → json_api}/version.rb +2 -2
  46. data/lib/json_api.rb +50 -0
  47. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  48. metadata +50 -167
  49. data/.cursor/rules/dependencies.mdc +0 -19
  50. data/.cursor/rules/examples.mdc +0 -16
  51. data/.cursor/rules/git.mdc +0 -14
  52. data/.cursor/rules/project_structure.mdc +0 -30
  53. data/.cursor/rules/security.mdc +0 -14
  54. data/.cursor/rules/style.mdc +0 -15
  55. data/.cursor/rules/testing.mdc +0 -16
  56. data/.overcommit.yml +0 -35
  57. data/CHANGELOG.md +0 -164
  58. data/LICENSE.txt +0 -21
  59. data/examples/basic_example.md +0 -146
  60. data/examples/including_related_resources.md +0 -491
  61. data/examples/pagination.md +0 -303
  62. data/examples/relationships.md +0 -114
  63. data/examples/resource_attribute_configuration.md +0 -147
  64. data/examples/resource_meta_configuration.md +0 -244
  65. data/examples/single_table_inheritance.md +0 -160
  66. data/lib/jpie/configuration.rb +0 -12
  67. data/lib/jpie/controller/crud_actions.rb +0 -141
  68. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  69. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  70. data/lib/jpie/controller/error_handling.rb +0 -23
  71. data/lib/jpie/controller/json_api_validation.rb +0 -193
  72. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  73. data/lib/jpie/controller/related_actions.rb +0 -45
  74. data/lib/jpie/controller/relationship_actions.rb +0 -291
  75. data/lib/jpie/controller/relationship_validation.rb +0 -117
  76. data/lib/jpie/controller/rendering.rb +0 -154
  77. data/lib/jpie/controller.rb +0 -45
  78. data/lib/jpie/deserializer.rb +0 -110
  79. data/lib/jpie/errors.rb +0 -117
  80. data/lib/jpie/generators/resource_generator.rb +0 -116
  81. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  82. data/lib/jpie/railtie.rb +0 -42
  83. data/lib/jpie/resource/attributable.rb +0 -112
  84. data/lib/jpie/resource/inferrable.rb +0 -43
  85. data/lib/jpie/resource/sortable.rb +0 -93
  86. data/lib/jpie/resource.rb +0 -147
  87. data/lib/jpie/routing.rb +0 -59
  88. data/lib/jpie/rspec.rb +0 -71
  89. data/lib/jpie/serializer.rb +0 -205
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class Serializer
5
+ include ActiveStorageSupport
6
+
7
+ # Class method for generating the JSON:API object
8
+ def self.jsonapi_object
9
+ obj = { version: JSONAPI.configuration.jsonapi_version }
10
+ obj[:meta] = JSONAPI.configuration.jsonapi_meta if JSONAPI.configuration.jsonapi_meta
11
+ obj
12
+ end
13
+
14
+ def initialize(record, definition: nil, base_definition: nil)
15
+ @record = record
16
+ @definition = definition || ResourceLoader.find_for_model(record.class)
17
+ @base_definition = base_definition
18
+ @sti_subclass = nil # Cache for sti_subclass? check
19
+ end
20
+
21
+ def to_hash(include: [], fields: {}, document_meta: nil)
22
+ {
23
+ jsonapi: jsonapi_object,
24
+ data: serialize_record(fields),
25
+ included: serialize_included(include, fields),
26
+ meta: document_meta
27
+ }.compact
28
+ end
29
+
30
+ def serialize_record(fields = {})
31
+ {
32
+ type: record_type,
33
+ id: record_id,
34
+ attributes: serialize_attributes(fields),
35
+ relationships: serialize_relationships,
36
+ links: serialize_links,
37
+ meta: record_meta
38
+ }.compact
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :record, :definition
44
+
45
+ def base_definition
46
+ @base_definition ||= definition
47
+ end
48
+
49
+ def record_type
50
+ if definition.name.end_with?("Resource")
51
+ RelationshipHelpers.resource_type_name(definition)
52
+ else
53
+ record.class.name.underscore.pluralize
54
+ end
55
+ end
56
+
57
+ def record_id
58
+ record.id.to_s
59
+ end
60
+
61
+ def serialize_attributes(fields = {})
62
+ type_fields = extract_type_fields(fields)
63
+ return {} if type_fields.empty? && fields.any?
64
+
65
+ attributes = build_attributes_hash
66
+ return attributes if type_fields.empty?
67
+
68
+ attributes.slice(*type_fields.map(&:to_sym))
69
+ end
70
+
71
+ def extract_type_fields(fields)
72
+ fields[record_type.to_sym] || []
73
+ end
74
+
75
+ def build_attributes_hash
76
+ permitted_attrs = definition.permitted_attributes.map(&:to_sym)
77
+ attributes = {}
78
+ definition_instance = definition.new(record, {})
79
+
80
+ permitted_attrs.each do |attr_sym|
81
+ attributes[attr_sym] = get_attribute_value(definition_instance, attr_sym)
82
+ end
83
+
84
+ attributes.compact
85
+ end
86
+
87
+ def get_attribute_value(definition_instance, attr_sym)
88
+ return definition_instance.public_send(attr_sym) if definition_instance.respond_to?(attr_sym, false)
89
+
90
+ attr_name = attr_sym.to_s
91
+ return record.attributes[attr_name] if model_has_attribute?(attr_name)
92
+
93
+ nil
94
+ end
95
+
96
+ def model_has_attribute?(attr_name)
97
+ record.respond_to?(:attributes) &&
98
+ record.attributes.is_a?(Hash) &&
99
+ record.attributes.key?(attr_name)
100
+ end
101
+
102
+ def serialize_relationships
103
+ relationships = {}
104
+ relationship_definitions = definition.relationship_definitions
105
+
106
+ relationship_definitions.each do |rel_def|
107
+ serialize_relationship(rel_def, relationships)
108
+ end
109
+
110
+ relationships
111
+ end
112
+
113
+ def serialize_relationship(rel_def, relationships)
114
+ association_name = rel_def[:name]
115
+
116
+ if active_storage_attachment?(association_name, record.class)
117
+ return serialize_active_storage_relationship_wrapper(rel_def, relationships, association_name)
118
+ end
119
+
120
+ serialize_regular_relationship(rel_def, relationships, association_name)
121
+ end
122
+
123
+ def serialize_active_storage_relationship_wrapper(rel_def, relationships, association_name)
124
+ relationships[association_name] = {
125
+ data: serialize_active_storage_relationship(association_name, record),
126
+ meta: rel_def[:meta]
127
+ }.compact
128
+ end
129
+
130
+ def serialize_regular_relationship(rel_def, relationships, association_name)
131
+ association = record.class.reflect_on_association(association_name)
132
+ return unless association
133
+
134
+ relationships[association_name] = {
135
+ data: serialize_relationship_data(association),
136
+ meta: rel_def[:meta]
137
+ }.compact
138
+ end
139
+
140
+ def serialize_relationship_data(association)
141
+ related = record.public_send(association.name)
142
+
143
+ if association.collection?
144
+ serialize_collection_relationship(related, association)
145
+ elsif related
146
+ serialize_single_relationship(related, association)
147
+ end
148
+ end
149
+
150
+ def serialize_collection_relationship(related, association)
151
+ return [] if related.nil?
152
+
153
+ related.map { |r| serialize_identifier_for_related(r, association) }
154
+ end
155
+
156
+ def serialize_single_relationship(related, association)
157
+ serialize_identifier_for_related(related, association)
158
+ end
159
+
160
+ def serialize_identifier_for_related(related_record, association)
161
+ base_def_for_related, use_instance_class = determine_sti_definition(related_record)
162
+
163
+ RelationshipHelpers.serialize_resource_identifier(
164
+ related_record,
165
+ association:,
166
+ resource_class: definition,
167
+ use_instance_class:,
168
+ base_resource_class: base_def_for_related
169
+ )
170
+ end
171
+
172
+ def determine_sti_definition(_related_record)
173
+ [nil, true]
174
+ end
175
+
176
+ def serialize_links
177
+ links = { self: "/#{record_type}/#{record_id}" }
178
+ add_active_storage_download_link(links) if active_storage_blob?
179
+ links
180
+ end
181
+
182
+ def active_storage_blob?
183
+ defined?(::ActiveStorage) && record.is_a?(::ActiveStorage::Blob)
184
+ end
185
+
186
+ def add_active_storage_download_link(links)
187
+ links[:download] = rails_blob_path || fallback_blob_path
188
+ rescue StandardError
189
+ links[:download] = fallback_blob_path
190
+ end
191
+
192
+ def rails_blob_path
193
+ Rails.application.routes.url_helpers.rails_blob_path(record, only_path: true)
194
+ end
195
+
196
+ def fallback_blob_path
197
+ "/rails/active_storage/blobs/#{record.signed_id}/#{record.filename}"
198
+ end
199
+
200
+ def serialize_included(includes, fields = {})
201
+ return [] if includes.empty?
202
+
203
+ included_records = []
204
+ processed = Set.new
205
+
206
+ includes.each do |include_path|
207
+ serialize_include_path(record, include_path, fields, included_records, processed)
208
+ end
209
+
210
+ included_records
211
+ end
212
+
213
+ def serialize_include_path(current_record, include_path, fields, included_records, processed)
214
+ path_parts = include_path.split(".")
215
+ association_name = path_parts.first.to_sym
216
+
217
+ return unless valid_include_association?(current_record, association_name)
218
+
219
+ related_array = get_related_records(current_record, association_name)
220
+ related_array.each do |related_record|
221
+ serialize_and_process_record(related_record, fields, included_records, processed)
222
+ serialize_nested_path(related_record, path_parts, fields, included_records, processed)
223
+ end
224
+ end
225
+
226
+ def valid_include_association?(current_record, association_name)
227
+ current_definition = ResourceLoader.find_for_model(current_record.class)
228
+ relationship_def = RelationshipHelpers.find_relationship_definition(current_definition, association_name)
229
+ return false unless relationship_def
230
+
231
+ # Check for ActiveStorage attachments
232
+ return true if self.class.active_storage_attachment?(association_name, current_record.class)
233
+
234
+ association = current_record.class.reflect_on_association(association_name)
235
+ association.present?
236
+ end
237
+
238
+ def get_related_records(current_record, association_name)
239
+ # Handle ActiveStorage attachments specially
240
+ if self.class.active_storage_attachment?(association_name, current_record.class)
241
+ attachment = current_record.public_send(association_name)
242
+ return [] unless attachment.respond_to?(:attached?) && attachment.attached?
243
+ return attachment.blobs.to_a if attachment.is_a?(::ActiveStorage::Attached::Many)
244
+
245
+ return [attachment.blob].compact
246
+
247
+ end
248
+
249
+ related = current_record.public_send(association_name)
250
+ Array(related)
251
+ end
252
+
253
+ def serialize_and_process_record(related_record, fields, included_records, processed)
254
+ record_key = build_record_key(related_record)
255
+ return if processed.include?(record_key)
256
+
257
+ serializer = self.class.new(related_record)
258
+ included_records << serializer.serialize_record(fields)
259
+ processed.add(record_key)
260
+ end
261
+
262
+ def build_record_key(related_record)
263
+ "#{related_record.class.name}-#{related_record.id}"
264
+ end
265
+
266
+ def serialize_nested_path(related_record, path_parts, fields, included_records, processed)
267
+ return unless path_parts.length > 1
268
+
269
+ nested_path = path_parts[1..].join(".")
270
+ serialize_include_path(related_record, nested_path, fields, included_records, processed)
271
+ end
272
+
273
+ def jsonapi_object
274
+ self.class.jsonapi_object
275
+ end
276
+
277
+ def record_meta
278
+ custom_meta = custom_meta_from_definition
279
+ default_meta = build_default_meta
280
+
281
+ # Merge defaults with custom meta (custom meta can override defaults)
282
+ return unless default_meta.any? || custom_meta.any?
283
+
284
+ default_meta.merge(custom_meta)
285
+ end
286
+
287
+ def custom_meta_from_definition
288
+ meta = nil
289
+
290
+ # Try instance method on Resource class first (dynamic, has access to model via resource)
291
+ if definition.method_defined?(:meta)
292
+ # Directly instantiate the resource class - all resources inherit from ApplicationResource
293
+ # which has initialize(resource = nil, context = {}) and attr_reader :resource
294
+ instance = definition.new(@record, {})
295
+ meta = instance.meta
296
+ # Fall back to class method (static)
297
+ elsif (class_meta = definition.resource_meta)
298
+ meta = class_meta.is_a?(Proc) ? class_meta.call(record) : class_meta
299
+ end
300
+
301
+ # Convert meta to hash if it's not nil
302
+ meta = meta.to_h if meta.respond_to?(:to_h) && !meta.is_a?(Hash)
303
+ meta || {}
304
+ end
305
+
306
+ def build_default_meta
307
+ default_timestamp_meta
308
+ end
309
+
310
+ def default_timestamp_meta
311
+ meta = {}
312
+
313
+ meta[:created_at] = record.created_at.iso8601 if record.respond_to?(:created_at) && record.created_at.present?
314
+
315
+ meta[:updated_at] = record.updated_at.iso8601 if record.respond_to?(:updated_at) && record.updated_at.present?
316
+
317
+ meta
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module JSONAPI
6
+ module ActiveStorageSupport
7
+ extend ActiveSupport::Concern
8
+
9
+ module ClassMethods
10
+ def active_storage_attachment?(association_name, model_class)
11
+ ActiveStorage::Detection.attachment?(association_name, model_class)
12
+ end
13
+
14
+ def active_storage_blob_type?(type)
15
+ ActiveStorage::Detection.blob_type?(type)
16
+ end
17
+ end
18
+
19
+ def active_storage_attachment?(association_name, model_class = nil)
20
+ return self.class.active_storage_attachment?(association_name, model_class) if model_class
21
+
22
+ resolved_model_class = if respond_to?(:model_class, true)
23
+ send(:model_class)
24
+ elsif respond_to?(:resource_model_class, true)
25
+ send(:resource_model_class)
26
+ elsif respond_to?(:resource, true) && (res = send(:resource)) && res.respond_to?(:class)
27
+ res.class
28
+ else
29
+ raise NotImplementedError,
30
+ "Must implement resource_model_class or provide model_class parameter"
31
+ end
32
+ self.class.active_storage_attachment?(association_name, resolved_model_class)
33
+ end
34
+
35
+ def extract_active_storage_params_from_hash(params_hash, model_class)
36
+ ActiveStorage::Deserialization.extract_params_from_hash(params_hash, model_class)
37
+ end
38
+
39
+ def attach_active_storage_files(record, attachment_params, resource_class: nil)
40
+ ActiveStorage::Deserialization.attach_files(record, attachment_params, definition: resource_class)
41
+ end
42
+
43
+ def purge_on_nil_enabled?(attachment_name, resource_class)
44
+ ActiveStorage::Deserialization.purge_on_nil_enabled?(attachment_name, resource_class)
45
+ end
46
+
47
+ def append_only_enabled?(attachment_name, resource_class)
48
+ ActiveStorage::Deserialization.append_only_enabled?(attachment_name, resource_class)
49
+ end
50
+
51
+ def find_relationship_definition(attachment_name, resource_class)
52
+ ActiveStorage::Deserialization.find_relationship_definition(attachment_name, resource_class)
53
+ end
54
+
55
+ def filter_active_storage_from_includes(includes_hash, current_model_class)
56
+ ActiveStorage::Detection.filter_from_includes(includes_hash, current_model_class)
57
+ end
58
+
59
+ def filter_polymorphic_from_includes(includes_hash, current_model_class)
60
+ ActiveStorage::Detection.filter_polymorphic_from_includes(includes_hash, current_model_class)
61
+ end
62
+
63
+ def serialize_active_storage_relationship(attachment_name, record)
64
+ ActiveStorage::Serialization.serialize_relationship(attachment_name, record)
65
+ end
66
+
67
+ def serialize_blob_identifier(blob)
68
+ ActiveStorage::Serialization.serialize_blob_identifier(blob)
69
+ end
70
+
71
+ def process_active_storage_attachment(attrs, association_name, id_or_ids, singular:)
72
+ ActiveStorage::Deserialization.process_attachment(attrs, association_name, id_or_ids, singular:)
73
+ end
74
+
75
+ def find_blob_by_signed_id(signed_id)
76
+ ActiveStorage::Deserialization.find_blob_by_signed_id(signed_id)
77
+ end
78
+
79
+ private
80
+
81
+ def resource_model_class
82
+ raise NotImplementedError, "Must implement resource_model_class or override active_storage_attachment?"
83
+ end
84
+ end
85
+ end