jpie 0.4.5 → 1.0.1

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 (141) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +26 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +76 -107
  6. data/.travis.yml +7 -0
  7. data/Gemfile +23 -0
  8. data/Gemfile.lock +321 -0
  9. data/README.md +1508 -136
  10. data/Rakefile +3 -14
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/jpie.gemspec +21 -38
  14. data/kiln/app/resources/user_message_resource.rb +4 -0
  15. data/lib/jpie.rb +3 -25
  16. data/lib/json_api/active_storage/deserialization.rb +116 -0
  17. data/lib/json_api/active_storage/detection.rb +69 -0
  18. data/lib/json_api/active_storage/serialization.rb +34 -0
  19. data/lib/json_api/configuration.rb +57 -0
  20. data/lib/json_api/controllers/base_controller.rb +26 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  23. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  24. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  25. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  26. data/lib/json_api/controllers/concerns/controller_helpers.rb +19 -0
  27. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  28. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  29. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  30. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  31. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  32. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  33. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  34. data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
  35. data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
  36. data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
  37. data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
  38. data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
  39. data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
  40. data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
  41. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  42. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  43. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  44. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  45. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  46. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  47. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  48. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  49. data/lib/json_api/controllers/concerns/resource_actions.rb +106 -0
  50. data/lib/json_api/controllers/relationships_controller.rb +108 -0
  51. data/lib/json_api/controllers/resources_controller.rb +6 -0
  52. data/lib/json_api/errors/parameter_not_allowed.rb +19 -0
  53. data/lib/json_api/railtie.rb +112 -0
  54. data/lib/json_api/resources/active_storage_blob_resource.rb +19 -0
  55. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  56. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  57. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  58. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  59. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  60. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  61. data/lib/json_api/resources/resource.rb +32 -0
  62. data/lib/json_api/resources/resource_loader.rb +35 -0
  63. data/lib/json_api/routing.rb +81 -0
  64. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  65. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  66. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  67. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  68. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  69. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  70. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  71. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  72. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  73. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  74. data/lib/json_api/serialization/deserializer.rb +26 -0
  75. data/lib/json_api/serialization/serializer.rb +77 -0
  76. data/lib/json_api/support/active_storage_support.rb +82 -0
  77. data/lib/json_api/support/collection_query.rb +50 -0
  78. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  79. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  80. data/lib/json_api/support/concerns/pagination.rb +30 -0
  81. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  82. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  83. data/lib/json_api/support/concerns/sorting.rb +88 -0
  84. data/lib/json_api/support/instrumentation.rb +43 -0
  85. data/lib/json_api/support/param_helpers.rb +54 -0
  86. data/lib/json_api/support/relationship_guard.rb +16 -0
  87. data/lib/json_api/support/relationship_helpers.rb +76 -0
  88. data/lib/json_api/support/resource_identifier.rb +87 -0
  89. data/lib/json_api/support/responders.rb +100 -0
  90. data/lib/json_api/support/response_helpers.rb +10 -0
  91. data/lib/json_api/support/sort_parsing.rb +21 -0
  92. data/lib/json_api/support/type_conversion.rb +21 -0
  93. data/lib/json_api/testing/test_helper.rb +76 -0
  94. data/lib/json_api/testing.rb +3 -0
  95. data/lib/{jpie → json_api}/version.rb +2 -2
  96. data/lib/json_api.rb +50 -0
  97. data/lib/rubocop/cop/custom/hash_value_omission.rb +53 -0
  98. metadata +100 -169
  99. data/.cursor/rules/dependencies.mdc +0 -19
  100. data/.cursor/rules/examples.mdc +0 -16
  101. data/.cursor/rules/git.mdc +0 -14
  102. data/.cursor/rules/project_structure.mdc +0 -30
  103. data/.cursor/rules/publish_gem.mdc +0 -73
  104. data/.cursor/rules/security.mdc +0 -14
  105. data/.cursor/rules/style.mdc +0 -15
  106. data/.cursor/rules/testing.mdc +0 -16
  107. data/.overcommit.yml +0 -35
  108. data/CHANGELOG.md +0 -164
  109. data/LICENSE.txt +0 -21
  110. data/PUBLISHING.md +0 -111
  111. data/examples/basic_example.md +0 -146
  112. data/examples/including_related_resources.md +0 -491
  113. data/examples/pagination.md +0 -303
  114. data/examples/relationships.md +0 -114
  115. data/examples/resource_attribute_configuration.md +0 -147
  116. data/examples/resource_meta_configuration.md +0 -244
  117. data/examples/rspec_testing.md +0 -130
  118. data/examples/single_table_inheritance.md +0 -160
  119. data/lib/jpie/configuration.rb +0 -12
  120. data/lib/jpie/controller/crud_actions.rb +0 -141
  121. data/lib/jpie/controller/error_handling/handler_setup.rb +0 -124
  122. data/lib/jpie/controller/error_handling/handlers.rb +0 -109
  123. data/lib/jpie/controller/error_handling.rb +0 -23
  124. data/lib/jpie/controller/json_api_validation.rb +0 -193
  125. data/lib/jpie/controller/parameter_parsing.rb +0 -78
  126. data/lib/jpie/controller/related_actions.rb +0 -45
  127. data/lib/jpie/controller/relationship_actions.rb +0 -291
  128. data/lib/jpie/controller/relationship_validation.rb +0 -117
  129. data/lib/jpie/controller/rendering.rb +0 -154
  130. data/lib/jpie/controller.rb +0 -45
  131. data/lib/jpie/deserializer.rb +0 -110
  132. data/lib/jpie/errors.rb +0 -117
  133. data/lib/jpie/generators/resource_generator.rb +0 -116
  134. data/lib/jpie/generators/templates/resource.rb.erb +0 -31
  135. data/lib/jpie/railtie.rb +0 -42
  136. data/lib/jpie/resource/attributable.rb +0 -112
  137. data/lib/jpie/resource/inferrable.rb +0 -43
  138. data/lib/jpie/resource/sortable.rb +0 -93
  139. data/lib/jpie/resource.rb +0 -147
  140. data/lib/jpie/routing.rb +0 -59
  141. data/lib/jpie/serializer.rb +0 -205
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_api/support/relationship_guard"
4
+ require_relative "concerns/relationships/serialization"
5
+ require_relative "concerns/relationships/updating"
6
+ require_relative "concerns/relationships/removal"
7
+ require_relative "concerns/relationships/sorting"
8
+ require_relative "concerns/relationships/events"
9
+ require_relative "concerns/relationships/response_helpers"
10
+
11
+ module JSONAPI
12
+ class RelationshipsController < BaseController
13
+ include Relationships::Serialization
14
+ include Relationships::Updating
15
+ include Relationships::Removal
16
+ include Relationships::Sorting
17
+ include Relationships::Events
18
+ include Relationships::ResponseHelpers
19
+
20
+ skip_before_action :validate_resource_type!, only: %i[update destroy]
21
+ skip_before_action :validate_resource_id!, only: %i[update destroy]
22
+
23
+ before_action :set_resource_name
24
+ before_action :set_resource_class
25
+ before_action :set_resource
26
+ before_action :set_relationship_name
27
+ before_action :validate_relationship_exists
28
+ before_action :validate_sort_param, only: [:show]
29
+ skip_before_action :validate_resource_type!, :validate_resource_id!
30
+
31
+ def show
32
+ authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
33
+ render json: build_show_response, status: :ok
34
+ end
35
+
36
+ def update
37
+ authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
38
+ relationship_data = parse_relationship_data
39
+ update_relationship(relationship_data)
40
+ save_and_render_relationship(relationship_data)
41
+ rescue ArgumentError => e
42
+ render_invalid_relationship_error(e)
43
+ rescue JSONAPI::Exceptions::ParameterNotAllowed => e
44
+ render_parameter_not_allowed_error(e)
45
+ end
46
+
47
+ def destroy
48
+ authorize_resource_action!(@resource, action: :update, context: { relationship: @relationship_name })
49
+ relationship_data = parse_relationship_data
50
+ return head :no_content if relationship_data.nil?
51
+
52
+ remove_relationship(relationship_data)
53
+ finalize_relationship_removal(relationship_data)
54
+ rescue ArgumentError => e
55
+ render_invalid_relationship_error(e)
56
+ rescue JSONAPI::Exceptions::ParameterNotAllowed => e
57
+ render_parameter_not_allowed_error(e)
58
+ end
59
+
60
+ private
61
+
62
+ def set_relationship_name
63
+ @relationship_name = params[:relationship_name].to_sym
64
+ end
65
+
66
+ def validate_relationship_exists
67
+ return if find_relationship_definition
68
+
69
+ render_jsonapi_error(
70
+ status: 404,
71
+ title: "Relationship Not Found",
72
+ detail: "Relationship '#{@relationship_name}' not found on #{@resource_name}",
73
+ )
74
+ end
75
+
76
+ def find_relationship_definition
77
+ RelationshipHelpers.find_relationship_definition(@resource_class, @relationship_name)
78
+ end
79
+
80
+ def parse_relationship_data
81
+ raw_data = params[:data]
82
+ return nil if raw_data.nil?
83
+ return [] if raw_data.is_a?(Array) && raw_data.empty?
84
+ return raw_data.map { |item| ParamHelpers.deep_symbolize_params(item) } if raw_data.is_a?(Array)
85
+
86
+ ParamHelpers.deep_symbolize_params(raw_data)
87
+ end
88
+
89
+ def polymorphic_association?
90
+ RelationshipHelpers.polymorphic_association?(@resource_class, @relationship_name)
91
+ end
92
+
93
+ def ensure_relationship_writable!(association)
94
+ return if active_storage_writable?(association)
95
+
96
+ relationship_def = find_relationship_definition
97
+ readonly = relationship_def && (relationship_def[:options] || {})[:readonly] == true
98
+
99
+ JSONAPI::RelationshipGuard.ensure_writable!(association, @relationship_name, readonly:)
100
+ end
101
+
102
+ def active_storage_writable?(association)
103
+ self.class.respond_to?(:active_storage_attachment?) &&
104
+ active_storage_attachment?(@relationship_name, @resource.class) &&
105
+ association.nil?
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class ResourcesController < BaseController
5
+ end
6
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Errors
5
+ class ParameterNotAllowed < JSONAPI::Error
6
+ attr_reader :params
7
+
8
+ def initialize(params = [])
9
+ @params = params
10
+ super("Parameter not allowed: #{Array(params).join(", ")}")
11
+ end
12
+ end
13
+ end
14
+
15
+ # Backward compatibility alias
16
+ module Exceptions
17
+ ParameterNotAllowed = Errors::ParameterNotAllowed
18
+ end
19
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module JSONAPI
6
+ class Railtie < Rails::Railtie
7
+ config.before_initialize do
8
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
9
+ inflect.acronym "JSON"
10
+ inflect.acronym "API"
11
+ # Ensure json_api converts to JSONAPI
12
+ end
13
+ end
14
+
15
+ initializer "json_api.mime_type" do |_app|
16
+ Mime::Type.register "application/vnd.api+json", :jsonapi
17
+ end
18
+
19
+ initializer "json_api.routes" do |_app|
20
+ require "json_api/routing"
21
+ ActionDispatch::Routing::Mapper.include JSONAPI::Routing
22
+ end
23
+
24
+ # Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
25
+ # Controllers and resources are autoloaded via Zeitwerk
26
+
27
+ initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
28
+ ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
29
+ ActiveSupport::JSON.decode(raw_post)
30
+ rescue JSON::ParserError => e
31
+ raise ActionDispatch::Http::Parameters::ParseError, e.message
32
+ end
33
+ end
34
+
35
+ initializer "json_api.controllers.include_base", before: :add_routing_paths do |_app|
36
+ mixin = lambda do |base|
37
+ next unless JSONAPI.configuration.base_controller_overridden?
38
+
39
+ # Use class name comparison instead of object equality to avoid timing issues
40
+ # when ApplicationController loads before JSONAPIController
41
+ expected_class_name = JSONAPI.configuration.instance_variable_get(:@base_controller_class)
42
+ next unless base.name == expected_class_name
43
+
44
+ base.include(JSONAPI::ControllerHelpers)
45
+ base.include(JSONAPI::ResourceActions)
46
+ end
47
+
48
+ ActiveSupport.on_load(:action_controller_base, &mixin)
49
+ ActiveSupport.on_load(:action_controller_api, &mixin)
50
+ end
51
+
52
+ # Inject JSON:API concerns into the configured base controller class only when it's been overridden
53
+ # This allows empty controllers inheriting from ApplicationController to work automatically
54
+ # We don't mix into ActionController::API by default to avoid Rails filtering action methods
55
+ # (Rails treats public methods on abstract base classes as internal methods)
56
+ #
57
+ # We use after_initialize because:
58
+ # 1. App controllers (e.g., JSONAPIController) must be autoloaded first
59
+ # 2. Rails 8 freezes autoload paths during initialization, and config.to_prepare runs
60
+ # before app controllers are available, causing FrozenError or NameError
61
+ # 3. We register with reloader.to_prepare for code reloading in development
62
+ config.after_initialize do |app|
63
+ # Register for code reloading in development
64
+ app.reloader.to_prepare do
65
+ Railtie.setup_base_controllers
66
+ end
67
+
68
+ # Run once immediately after initialization
69
+ # In eager_load environments (production, test with config.eager_load = true),
70
+ # controllers are already loaded. In development, this triggers autoloading
71
+ # which is now safe since initialization is complete.
72
+ Railtie.setup_base_controllers
73
+ end
74
+
75
+ class << self
76
+ def setup_base_controllers
77
+ return unless JSONAPI.configuration.base_controller_overridden?
78
+
79
+ base_controller_class = resolve_base_controller_class
80
+ return unless base_controller_class
81
+
82
+ rebuild_controllers_if_needed(base_controller_class)
83
+ include_jsonapi_concerns(base_controller_class)
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_base_controller_class
89
+ class_name = JSONAPI.configuration.instance_variable_get(:@base_controller_class)
90
+ class_name.constantize
91
+ rescue NameError
92
+ # Controller not yet loaded - this can happen in test environments
93
+ # where eager_load is false and autoloading hasn't run yet.
94
+ nil
95
+ end
96
+
97
+ def rebuild_controllers_if_needed(base_controller_class)
98
+ return if JSONAPI::BaseController.superclass == base_controller_class
99
+
100
+ JSONAPI.send(:remove_const, :BaseController)
101
+ load "json_api/controllers/base_controller.rb"
102
+ JSONAPI.send(:remove_const, :RelationshipsController)
103
+ load "json_api/controllers/relationships_controller.rb"
104
+ end
105
+
106
+ def include_jsonapi_concerns(base_controller_class)
107
+ base_controller_class.include(JSONAPI::ControllerHelpers)
108
+ base_controller_class.include(JSONAPI::ResourceActions)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class ActiveStorageBlobResource < Resource
5
+ attributes :filename, :content_type, :byte_size, :checksum, :url
6
+
7
+ def self.model_class
8
+ ::ActiveStorage::Blob
9
+ end
10
+
11
+ def url
12
+ return nil unless resource.persisted?
13
+
14
+ Rails.application.routes.url_helpers.rails_blob_path(resource, only_path: true)
15
+ rescue StandardError
16
+ "/rails/active_storage/blobs/#{resource.signed_id}/#{resource.filename}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module AttributesDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def attributes(*attrs)
10
+ @attributes ||= []
11
+ @attributes.concat(attrs.map(&:to_sym))
12
+ @attributes.uniq!
13
+ end
14
+
15
+ def creatable_fields(*fields)
16
+ @creatable_fields ||= []
17
+ @creatable_fields.concat(fields.map(&:to_sym))
18
+ @creatable_fields.uniq!
19
+ end
20
+
21
+ def updatable_fields(*fields)
22
+ @updatable_fields ||= []
23
+ @updatable_fields.concat(fields.map(&:to_sym))
24
+ @updatable_fields.uniq!
25
+ end
26
+ end
27
+
28
+ module FieldResolution
29
+ def permitted_attributes
30
+ declared_attributes = instance_variable_defined?(:@attributes)
31
+ attrs = @attributes || []
32
+ attrs = superclass.permitted_attributes + attrs if should_inherit_attributes?(declared_attributes)
33
+ attrs.uniq
34
+ end
35
+
36
+ def permitted_creatable_fields
37
+ resolve_field_list(:@creatable_fields, :permitted_creatable_fields)
38
+ end
39
+
40
+ def permitted_updatable_fields
41
+ resolve_field_list(:@updatable_fields, :permitted_updatable_fields)
42
+ end
43
+
44
+ def resolve_field_list(ivar, method)
45
+ return (instance_variable_get(ivar) || []).uniq if instance_variable_defined?(ivar)
46
+ return superclass.public_send(method).uniq if inherits_field?(ivar, method)
47
+
48
+ permitted_attributes.uniq
49
+ end
50
+
51
+ def inherits_field?(ivar, method)
52
+ superclass != JSONAPI::Resource &&
53
+ superclass.respond_to?(method) &&
54
+ superclass.instance_variable_defined?(ivar)
55
+ end
56
+
57
+ def should_inherit_attributes?(declared_attributes)
58
+ !declared_attributes &&
59
+ superclass != JSONAPI::Resource &&
60
+ superclass.respond_to?(:permitted_attributes)
61
+ end
62
+ end
63
+
64
+ included do
65
+ extend FieldResolution
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module FiltersDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def filters(*filter_names)
10
+ @filters ||= []
11
+ @filters.concat(filter_names.map(&:to_sym))
12
+ @filters.uniq!
13
+ end
14
+
15
+ def permitted_filters_through
16
+ relationship_names
17
+ end
18
+
19
+ def permitted_filters
20
+ declared_filters = instance_variable_defined?(:@filters)
21
+ filter_list = @filters || []
22
+ if !declared_filters &&
23
+ superclass != JSONAPI::Resource &&
24
+ superclass.respond_to?(:permitted_filters)
25
+ filter_list = superclass.permitted_filters + filter_list
26
+ end
27
+ filter_list.uniq
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module MetaDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def meta(hash = nil, &block)
10
+ @meta = hash || block
11
+ end
12
+
13
+ def resource_meta
14
+ if instance_variable_defined?(:@meta)
15
+ @meta
16
+ elsif superclass != JSONAPI::Resource && superclass.respond_to?(:resource_meta)
17
+ superclass.resource_meta
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module ModelClassHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def resource_for_model(model_class)
10
+ resource_const = "#{model_class.name}Resource"
11
+ resource_const.safe_constantize if resource_const.respond_to?(:safe_constantize) || defined?(ActiveSupport)
12
+ rescue NameError
13
+ nil
14
+ end
15
+
16
+ def model_class
17
+ name.sub(/Resource$/, "").classify.constantize
18
+ end
19
+
20
+ def safe_model_class
21
+ return nil unless respond_to?(:name) && name
22
+ return nil unless defined?(ActiveSupport)
23
+
24
+ name.sub(/Resource$/, "").classify.safe_constantize
25
+ rescue NoMethodError
26
+ nil
27
+ end
28
+
29
+ def reflection_model_class
30
+ model_class
31
+ rescue StandardError
32
+ safe_model_class
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module RelationshipsDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def has_one(name, meta: nil, **options)
10
+ @relationships ||= []
11
+ detect_polymorphic(name, options)
12
+ @relationships << { name: name.to_sym, type: :has_one, meta:, options: }
13
+ end
14
+
15
+ def has_many(name, meta: nil, **options)
16
+ @relationships ||= []
17
+ validate_append_only_options!(options)
18
+ detect_polymorphic(name, options)
19
+ @relationships << { name: name.to_sym, type: :has_many, meta:, options: }
20
+ end
21
+
22
+ def belongs_to(name, meta: nil, **options)
23
+ @relationships ||= []
24
+ detect_polymorphic(name, options)
25
+ @relationships << { name: name.to_sym, type: :belongs_to, meta:, options: }
26
+ end
27
+
28
+ def relationship_definitions
29
+ declared_relationships = instance_variable_defined?(:@relationships)
30
+ rels = @relationships || []
31
+ rels = superclass.relationship_definitions + rels if should_inherit_relationships?(declared_relationships)
32
+ rels.uniq { |r| r[:name] }
33
+ end
34
+
35
+ def relationship_names
36
+ relationship_definitions.map { |r| r[:name] }
37
+ end
38
+ end
39
+
40
+ module RelationshipHelperMethods
41
+ def validate_append_only_options!(options)
42
+ if options[:append_only] && options[:purge_on_nil] == true
43
+ raise ArgumentError, "Cannot use append_only: true with purge_on_nil: true"
44
+ end
45
+
46
+ options[:purge_on_nil] = false if options[:append_only] && !options.key?(:purge_on_nil)
47
+ end
48
+
49
+ def detect_polymorphic(name, options)
50
+ return if options.key?(:polymorphic)
51
+
52
+ model_klass = reflection_model_class
53
+ return unless model_klass.respond_to?(:reflect_on_association)
54
+
55
+ reflection = model_klass.reflect_on_association(name)
56
+ options[:polymorphic] = reflection&.polymorphic?
57
+ end
58
+
59
+ def should_inherit_relationships?(declared_relationships)
60
+ !declared_relationships &&
61
+ superclass != JSONAPI::Resource &&
62
+ superclass.respond_to?(:relationship_definitions)
63
+ end
64
+ end
65
+
66
+ included do
67
+ extend RelationshipHelperMethods
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Resources
5
+ module SortableFieldsDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def sortable_fields(*field_names)
10
+ @sortable_fields ||= []
11
+ @sortable_fields.concat(field_names.map(&:to_sym))
12
+ @sortable_fields.uniq!
13
+ end
14
+
15
+ def permitted_sortable_fields
16
+ sort_fields = @sortable_fields || []
17
+ sort_fields = inherited_sort_only_fields + sort_fields if should_inherit_sortable_fields?
18
+ (permitted_attributes + sort_fields).uniq
19
+ end
20
+
21
+ def should_inherit_sortable_fields?
22
+ !instance_variable_defined?(:@sortable_fields) &&
23
+ !instance_variable_defined?(:@attributes) &&
24
+ superclass != JSONAPI::Resource &&
25
+ superclass.respond_to?(:permitted_sortable_fields)
26
+ end
27
+
28
+ def inherited_sort_only_fields
29
+ parent_sort_fields = superclass.permitted_sortable_fields
30
+ parent_attributes = superclass.permitted_attributes
31
+ parent_sort_fields - parent_attributes
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/attributes_dsl"
4
+ require_relative "concerns/filters_dsl"
5
+ require_relative "concerns/sortable_fields_dsl"
6
+ require_relative "concerns/relationships_dsl"
7
+ require_relative "concerns/meta_dsl"
8
+ require_relative "concerns/model_class_helpers"
9
+
10
+ module JSONAPI
11
+ class Resource
12
+ include Resources::AttributesDsl
13
+ include Resources::FiltersDsl
14
+ include Resources::SortableFieldsDsl
15
+ include Resources::RelationshipsDsl
16
+ include Resources::MetaDsl
17
+ include Resources::ModelClassHelpers
18
+
19
+ def initialize(record = nil, context = {})
20
+ @record = record
21
+ @context = context
22
+ @transformed_params = {}
23
+ end
24
+
25
+ attr_reader :record
26
+ alias resource record
27
+
28
+ def transformed_params
29
+ @transformed_params || {}
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class ResourceLoader
5
+ class MissingResourceClass < JSONAPI::Error
6
+ def initialize(resource_type)
7
+ super("Resource class for '#{resource_type}' not found. Define #{resource_type.singularize.classify}Resource < JSONAPI::Resource")
8
+ end
9
+ end
10
+
11
+ def self.find(resource_type)
12
+ resource_class_name = "#{resource_type.singularize.classify}Resource"
13
+ resource_class_name.constantize
14
+ rescue NameError
15
+ raise MissingResourceClass, resource_type
16
+ end
17
+
18
+ def self.find_for_model(model_class)
19
+ # Handle ActiveStorage::Blob specially
20
+ return ActiveStorageBlobResource if defined?(::ActiveStorage) && model_class == ::ActiveStorage::Blob
21
+
22
+ # For STI subclasses, try the specific subclass resource first
23
+ resource_type = model_class.name.underscore.pluralize
24
+ begin
25
+ find(resource_type)
26
+ rescue MissingResourceClass
27
+ # For STI subclasses, fall back to base class resource
28
+ raise unless model_class.respond_to?(:base_class) && model_class.base_class != model_class
29
+
30
+ base_resource_type = model_class.base_class.name.underscore.pluralize
31
+ find(base_resource_type)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Routing
5
+ def jsonapi_resources(resource, controller: nil, defaults: {}, sti: false, **options, &)
6
+ resource_name = resource.to_s
7
+ controller = detect_controller(resource_name) if controller.nil?
8
+
9
+ JSONAPI::ResourceLoader.find(resource_name)
10
+ defaults = defaults.merge(format: :jsonapi, resource_type: resource_name)
11
+ options[:only] = :index if sti
12
+
13
+ define_resource_routes(resource, controller, defaults, options, &)
14
+ define_sti_routes(resource, resource_name, defaults, sti)
15
+ end
16
+
17
+ private
18
+
19
+ def detect_controller(resource_name)
20
+ potential_controller_name = build_controller_name(resource_name)
21
+ potential_controller_name.constantize
22
+ nil
23
+ rescue NameError
24
+ "json_api/resources"
25
+ end
26
+
27
+ def build_controller_name(resource_name)
28
+ scoped_module = @scope[:module]
29
+ base_name = "#{resource_name.pluralize.camelize}Controller"
30
+ return base_name unless scoped_module
31
+
32
+ "#{scoped_module.to_s.camelize}::#{base_name}"
33
+ end
34
+
35
+ def define_resource_routes(resource, controller, defaults, options, &block)
36
+ resources(resource, controller:, defaults:, **options) do
37
+ define_relationship_routes
38
+ instance_eval(&block) if block
39
+ end
40
+ end
41
+
42
+ def define_relationship_routes
43
+ member do
44
+ get "relationships/:relationship_name", to: "json_api/relationships#show", as: :relationship
45
+ patch "relationships/:relationship_name", to: "json_api/relationships#update"
46
+ delete "relationships/:relationship_name", to: "json_api/relationships#destroy"
47
+ end
48
+ end
49
+
50
+ def define_sti_routes(resource, resource_name, defaults, sti)
51
+ return unless sti
52
+
53
+ if sti.is_a?(Array)
54
+ define_explicit_sti_routes(sti,
55
+ defaults,)
56
+ else
57
+ define_auto_sti_routes(resource, resource_name,
58
+ defaults,)
59
+ end
60
+ end
61
+
62
+ def define_explicit_sti_routes(sti_resources, defaults)
63
+ sti_resources.each { |sub_resource_name| jsonapi_resources(sub_resource_name, defaults:) }
64
+ end
65
+
66
+ def define_auto_sti_routes(resource, resource_name, defaults)
67
+ resource_class = JSONAPI::ResourceLoader.find(resource_name)
68
+ model_class = resource_class.model_class
69
+ return unless model_class.respond_to?(:descendants)
70
+
71
+ model_class.descendants.each do |subclass|
72
+ sub_resource_name = subclass.name.underscore.pluralize.to_sym
73
+ next if sub_resource_name == resource.to_sym
74
+
75
+ jsonapi_resources(sub_resource_name, defaults:)
76
+ end
77
+ rescue NameError, JSONAPI::ResourceLoader::MissingResourceClass
78
+ nil
79
+ end
80
+ end
81
+ end