jpie 1.0.2 → 1.1.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: ecbe7000fb7dd1053c155ec2f9de3866bd3eb3138e4509dc5e8d226bcdbb054a
4
- data.tar.gz: 07e03b20414abbfe7fb849fd5dd99f5390633ff9498bad3d064b5ed3894973a2
3
+ metadata.gz: 4e425e58d806e51b4cb6ed58345acdc3169079011dca02fa8dfae18b57bbe70c
4
+ data.tar.gz: e0f9316d7a675048c5c63442e9f897452ffa62358d3ca73d8e8aa288122a623c
5
5
  SHA512:
6
- metadata.gz: dc63cd8358e5a678bdd69e230f4a5842cf11ff0268006b615d0c5de7570d8da0ec9f83f7da40d2fc8d0758629fae95dfdde13eb34ef2ae384f1b764a6ac99857
7
- data.tar.gz: ea527ed50fd384b5cd0e8f21a3cf1d5b23d7121f11b8ad712a5d44cf4adbdd1dfef56e8f2a50da3f34fe42698ef27ff538d59086c5575e70bc0ca9359b261a95
6
+ metadata.gz: 36c41c6de0027699b5534abe379930f67fc67698aa8016635a294b8ba5624ace0bedaeaebddfbf5708c3ad58696527b1e15f177ac84c36c272581cff123a52ec
7
+ data.tar.gz: c948938233596ef3345bc7186e8f78575f37c1057d9675850634ddb53c36506de7b26b89f14555fdca8659124ae8ec0b9908da39324f87cff90dd82aa2c8cd8d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (1.0.2)
4
+ jpie (1.1.0)
5
5
  actionpack (~> 8.0, >= 8.0.0)
6
6
  rails (~> 8.0, >= 8.0.0)
7
7
 
data/README.md CHANGED
@@ -14,6 +14,7 @@ A Rails 8+ gem that provides JSON:API compliant routing DSL and generic JSON:API
14
14
  - Configurable pagination options
15
15
  - Content negotiation with Accept and Content-Type headers
16
16
  - Support for polymorphic and STI relationships
17
+ - Namespace support for API versioning and modular organization
17
18
 
18
19
  ## Installation
19
20
 
@@ -679,6 +680,91 @@ This is useful when you need access to features available in `ActionController::
679
680
 
680
681
  **Note:** The configuration must be set before the gem's controllers are loaded. Set it in a Rails initializer that loads before `json_api` is required.
681
682
 
683
+ ### Namespace Support
684
+
685
+ The gem supports namespaced resources, controllers, and models for API versioning or modular organization.
686
+
687
+ #### Configuration Options
688
+
689
+ ```ruby
690
+ JSONAPI.configure do |config|
691
+ # Type format in JSON:API responses: :flat (default), :prefixed, or :underscore
692
+ # :flat -> "vendors" (namespace inferred from route context)
693
+ # :prefixed -> "api/v1/vendors"
694
+ # :underscore -> "api_v1_vendors"
695
+ config.namespace_type_format = :flat
696
+
697
+ # Model mapping: :same_namespace (default) or :flat
698
+ # :same_namespace -> API::V1::VendorResource maps to API::V1::Vendor
699
+ # :flat -> API::V1::VendorResource maps to Vendor
700
+ config.namespace_model_mapping = :same_namespace
701
+
702
+ # Fallback behavior when namespaced resource not found: true (default) or false
703
+ # When true, if API::V1::VendorResource not found, try VendorResource
704
+ config.namespace_fallback = true
705
+ end
706
+ ```
707
+
708
+ #### Namespaced Routes
709
+
710
+ Use Rails' standard `namespace` or `scope` with `module:` to define namespaced JSON:API resources:
711
+
712
+ ```ruby
713
+ # config/routes.rb
714
+ Rails.application.routes.draw do
715
+ namespace :api do
716
+ namespace :v1 do
717
+ jsonapi_resources :widgets
718
+ end
719
+ end
720
+ end
721
+ ```
722
+
723
+ This will:
724
+
725
+ - Look for `API::V1::WidgetsController` (falls back to `JSONAPI::ResourcesController` if not found)
726
+ - Load `API::V1::WidgetResource` (falls back to `WidgetResource` if `namespace_fallback: true`)
727
+ - Pass `jsonapi_namespace: "api/v1"` to the controller
728
+
729
+ #### Namespaced Resources
730
+
731
+ Define resources within namespaces that match your route structure:
732
+
733
+ ```ruby
734
+ # app/resources/api/v1/widget_resource.rb
735
+ module API
736
+ module V1
737
+ class WidgetResource < JSONAPI::Resource
738
+ attributes :name, :description
739
+ end
740
+ end
741
+ end
742
+ ```
743
+
744
+ #### Per-Resource Type Format
745
+
746
+ Override the global `namespace_type_format` for individual resources:
747
+
748
+ ```ruby
749
+ class API::V1::WidgetResource < JSONAPI::Resource
750
+ self.type_format = :prefixed # This resource will serialize as "api/v1/widgets"
751
+
752
+ attributes :name
753
+ end
754
+ ```
755
+
756
+ #### Explicit Model Class
757
+
758
+ Override the automatic model class resolution:
759
+
760
+ ```ruby
761
+ class API::V1::WidgetResource < JSONAPI::Resource
762
+ self.model_class = Widget # Use flat Widget model regardless of config
763
+
764
+ attributes :name
765
+ end
766
+ ```
767
+
682
768
  ## Authorization
683
769
 
684
770
  The gem provides two optional authorization hooks:
@@ -1182,8 +1268,7 @@ Content-Type: application/vnd.api+json
1182
1268
  "filename": "avatar.jpg",
1183
1269
  "content_type": "image/jpeg",
1184
1270
  "byte_size": 102400,
1185
- "checksum": "abc123...",
1186
- "url": "/rails/active_storage/blobs/.../avatar.jpg"
1271
+ "checksum": "abc123..."
1187
1272
  },
1188
1273
  "links": {
1189
1274
  "self": "/active_storage_blobs/1",
@@ -1193,7 +1278,7 @@ Content-Type: application/vnd.api+json
1193
1278
  }
1194
1279
  ```
1195
1280
 
1196
- The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`, `checksum`, and `url` attributes, plus a download link.
1281
+ The built-in `JSONAPI::ActiveStorageBlobResource` provides `filename`, `content_type`, `byte_size`, and `checksum` attributes. The download URL is available via the `links.download` property.
1197
1282
 
1198
1283
  ### Attaching Files
1199
1284
 
@@ -3,7 +3,8 @@
3
3
  module JSONAPI
4
4
  class Configuration
5
5
  attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
6
- :authorization_scope, :document_meta_resolver
6
+ :authorization_scope, :document_meta_resolver,
7
+ :namespace_type_format, :namespace_model_mapping, :namespace_fallback
7
8
 
8
9
  def initialize
9
10
  @default_page_size = 25
@@ -13,6 +14,9 @@ module JSONAPI
13
14
  @authorization_scope = nil
14
15
  @document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
15
16
  @base_controller_class = "ActionController::API"
17
+ @namespace_type_format = :flat
18
+ @namespace_model_mapping = :same_namespace
19
+ @namespace_fallback = true
16
20
  end
17
21
 
18
22
  def base_controller_class=(value)
@@ -6,7 +6,7 @@ module JSONAPI
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- attr_reader :resource_class, :model_class
9
+ attr_reader :resource_class, :model_class, :jsonapi_namespace
10
10
  end
11
11
 
12
12
  protected
@@ -15,8 +15,13 @@ module JSONAPI
15
15
  @resource_name = params[:resource_type].to_s.singularize
16
16
  end
17
17
 
18
+ def set_jsonapi_namespace
19
+ @jsonapi_namespace = params[:jsonapi_namespace].presence
20
+ end
21
+
18
22
  def set_resource_class
19
- @resource_class = JSONAPI::ResourceLoader.find(params[:resource_type])
23
+ namespace = params[:jsonapi_namespace].presence
24
+ @resource_class = JSONAPI::ResourceLoader.find(params[:resource_type], namespace:)
20
25
  @model_class = @resource_class.model_class
21
26
  rescue JSONAPI::ResourceLoader::MissingResourceClass => e
22
27
  render_resource_not_found_error(e.message)
@@ -9,7 +9,8 @@ module JSONAPI
9
9
 
10
10
  def load_jsonapi_resource
11
11
  @resource_name = params[:resource_type]&.singularize
12
- @resource_class = JSONAPI::ResourceLoader.find(@resource_name) if @resource_name
12
+ @jsonapi_namespace = params[:jsonapi_namespace].presence
13
+ @resource_class = JSONAPI::ResourceLoader.find(@resource_name, namespace: @jsonapi_namespace) if @resource_name
13
14
  @model_class = @resource_class.model_class if @resource_class
14
15
  @resource = @model_class.find(params[:id]) if params[:id] && @model_class
15
16
  rescue JSONAPI::ResourceLoader::MissingResourceClass
@@ -5,23 +5,42 @@ module JSONAPI
5
5
  module ModelClassHelpers
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ module NamespaceResolution
9
+ def resolve_model_class_name
10
+ resource_name = name.sub(/Resource$/, "")
11
+ flat_namespace_mapping? ? resource_name.demodulize : resource_name
12
+ end
13
+
14
+ def flat_namespace_mapping?
15
+ JSONAPI.configuration.namespace_model_mapping == :flat
16
+ end
17
+
18
+ def resource_namespace
19
+ name.deconstantize.presence&.underscore
20
+ end
21
+ end
22
+
8
23
  class_methods do
24
+ include NamespaceResolution
25
+
26
+ attr_writer :model_class
27
+
9
28
  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)
29
+ "#{model_class.name}Resource".safe_constantize
12
30
  rescue NameError
13
31
  nil
14
32
  end
15
33
 
16
34
  def model_class
17
- name.sub(/Resource$/, "").classify.constantize
35
+ return @model_class if defined?(@model_class) && @model_class
36
+
37
+ resolve_model_class_name.constantize
18
38
  end
19
39
 
20
40
  def safe_model_class
21
- return nil unless respond_to?(:name) && name
22
- return nil unless defined?(ActiveSupport)
41
+ return nil unless respond_to?(:name) && name && defined?(ActiveSupport)
23
42
 
24
- name.sub(/Resource$/, "").classify.safe_constantize
43
+ resolve_model_class_name&.safe_constantize
25
44
  rescue NoMethodError
26
45
  nil
27
46
  end
@@ -16,6 +16,15 @@ module JSONAPI
16
16
  include Resources::MetaDsl
17
17
  include Resources::ModelClassHelpers
18
18
 
19
+ class << self
20
+ attr_accessor :type_format
21
+
22
+ def inherited(subclass)
23
+ super
24
+ subclass.type_format = type_format
25
+ end
26
+ end
27
+
19
28
  def initialize(record = nil, context = {})
20
29
  @record = record
21
30
  @context = context
@@ -3,33 +3,82 @@
3
3
  module JSONAPI
4
4
  class ResourceLoader
5
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")
6
+ def initialize(resource_type, namespace: nil)
7
+ class_hint = build_class_hint(resource_type, namespace)
8
+ super("Resource class for '#{resource_type}' not found. Define #{class_hint} < JSONAPI::Resource")
9
+ end
10
+
11
+ private
12
+
13
+ def build_class_hint(resource_type, namespace)
14
+ base = "#{resource_type.singularize.classify}Resource"
15
+ namespace.present? ? "#{namespace.to_s.camelize}::#{base}" : base
8
16
  end
9
17
  end
10
18
 
11
- def self.find(resource_type)
12
- resource_class_name = "#{resource_type.singularize.classify}Resource"
13
- resource_class_name.constantize
19
+ def self.find(resource_type, namespace: nil)
20
+ return find_namespaced(resource_type, namespace) if namespace.present?
21
+
22
+ find_flat(resource_type, namespace)
23
+ end
24
+
25
+ def self.find_namespaced(resource_type, namespace)
26
+ namespaced_class = build_resource_class_name(resource_type, namespace)
27
+ namespaced_class.constantize
14
28
  rescue NameError
15
- raise MissingResourceClass, resource_type
29
+ raise MissingResourceClass.new(resource_type, namespace:) unless JSONAPI.configuration.namespace_fallback
30
+
31
+ find_flat(resource_type, namespace)
16
32
  end
17
33
 
18
- def self.find_for_model(model_class)
19
- # Handle ActiveStorage::Blob specially
20
- return ActiveStorageBlobResource if defined?(::ActiveStorage) && model_class == ::ActiveStorage::Blob
34
+ def self.find_flat(resource_type, namespace)
35
+ flat_class = build_resource_class_name(resource_type, nil)
36
+ flat_class.constantize
37
+ rescue NameError
38
+ raise MissingResourceClass.new(resource_type, namespace:)
39
+ end
21
40
 
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
41
+ def self.build_resource_class_name(resource_type, namespace)
42
+ base = "#{resource_type.singularize.classify}Resource"
43
+ return base unless namespace.present?
29
44
 
30
- base_resource_type = model_class.base_class.name.underscore.pluralize
31
- find(base_resource_type)
32
- end
45
+ "#{namespace.to_s.camelize}::#{base}"
46
+ end
47
+
48
+ def self.find_for_model(model_class, namespace: nil)
49
+ return ActiveStorageBlobResource if active_storage_blob?(model_class)
50
+
51
+ find_resource_for_model(model_class, namespace)
52
+ end
53
+
54
+ def self.find_resource_for_model(model_class, namespace)
55
+ effective_namespace = namespace || extract_namespace_from_model(model_class)
56
+ resource_type = model_class.name.demodulize.underscore.pluralize
57
+
58
+ find(resource_type, namespace: effective_namespace)
59
+ rescue MissingResourceClass
60
+ find_base_class_resource(model_class, effective_namespace)
61
+ end
62
+
63
+ def self.find_base_class_resource(model_class, effective_namespace)
64
+ raise MissingResourceClass, model_class.name unless sti_subclass?(model_class)
65
+
66
+ base_resource_type = model_class.base_class.name.demodulize.underscore.pluralize
67
+ base_namespace = extract_namespace_from_model(model_class.base_class) || effective_namespace
68
+ find(base_resource_type, namespace: base_namespace)
69
+ end
70
+
71
+ def self.active_storage_blob?(model_class)
72
+ defined?(::ActiveStorage) && model_class == ::ActiveStorage::Blob
73
+ end
74
+
75
+ def self.sti_subclass?(model_class)
76
+ model_class.respond_to?(:base_class) && model_class.base_class != model_class
77
+ end
78
+
79
+ def self.extract_namespace_from_model(model_class)
80
+ parts = model_class.name.deconstantize
81
+ parts.presence&.underscore
33
82
  end
34
83
  end
35
84
  end
@@ -4,32 +4,39 @@ module JSONAPI
4
4
  module Routing
5
5
  def jsonapi_resources(resource, controller: nil, defaults: {}, sti: false, **options, &)
6
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)
7
+ namespace = extract_namespace_from_scope
8
+ controller ||= detect_controller(resource_name, namespace)
9
+ defaults = build_jsonapi_defaults(defaults, resource_name, namespace)
11
10
  options[:only] = :index if sti
12
11
 
12
+ JSONAPI::ResourceLoader.find(resource_name, namespace:)
13
13
  define_resource_routes(resource, controller, defaults, options, &)
14
- define_sti_routes(resource, resource_name, defaults, sti)
14
+ define_sti_routes(resource, resource_name, defaults, sti, namespace)
15
+ end
16
+
17
+ def build_jsonapi_defaults(defaults, resource_name, namespace)
18
+ defaults.merge(format: :jsonapi, resource_type: resource_name, jsonapi_namespace: namespace)
15
19
  end
16
20
 
17
21
  private
18
22
 
19
- def detect_controller(resource_name)
20
- potential_controller_name = build_controller_name(resource_name)
23
+ def extract_namespace_from_scope
24
+ @scope[:module]&.to_s.presence
25
+ end
26
+
27
+ def detect_controller(resource_name, namespace = nil)
28
+ potential_controller_name = build_controller_name(resource_name, namespace)
21
29
  potential_controller_name.constantize
22
30
  nil
23
31
  rescue NameError
24
32
  "json_api/resources"
25
33
  end
26
34
 
27
- def build_controller_name(resource_name)
28
- scoped_module = @scope[:module]
35
+ def build_controller_name(resource_name, namespace = nil)
29
36
  base_name = "#{resource_name.pluralize.camelize}Controller"
30
- return base_name unless scoped_module
37
+ return base_name unless namespace
31
38
 
32
- "#{scoped_module.to_s.camelize}::#{base_name}"
39
+ "#{namespace.to_s.camelize}::#{base_name}"
33
40
  end
34
41
 
35
42
  def define_resource_routes(resource, controller, defaults, options, &block)
@@ -47,15 +54,13 @@ module JSONAPI
47
54
  end
48
55
  end
49
56
 
50
- def define_sti_routes(resource, resource_name, defaults, sti)
57
+ def define_sti_routes(resource, resource_name, defaults, sti, namespace = nil)
51
58
  return unless sti
52
59
 
53
60
  if sti.is_a?(Array)
54
- define_explicit_sti_routes(sti,
55
- defaults,)
61
+ define_explicit_sti_routes(sti, defaults)
56
62
  else
57
- define_auto_sti_routes(resource, resource_name,
58
- defaults,)
63
+ define_auto_sti_routes(resource, resource_name, defaults, namespace)
59
64
  end
60
65
  end
61
66
 
@@ -63,13 +68,13 @@ module JSONAPI
63
68
  sti_resources.each { |sub_resource_name| jsonapi_resources(sub_resource_name, defaults:) }
64
69
  end
65
70
 
66
- def define_auto_sti_routes(resource, resource_name, defaults)
67
- resource_class = JSONAPI::ResourceLoader.find(resource_name)
71
+ def define_auto_sti_routes(resource, resource_name, defaults, namespace = nil)
72
+ resource_class = JSONAPI::ResourceLoader.find(resource_name, namespace:)
68
73
  model_class = resource_class.model_class
69
74
  return unless model_class.respond_to?(:descendants)
70
75
 
71
76
  model_class.descendants.each do |subclass|
72
- sub_resource_name = subclass.name.underscore.pluralize.to_sym
77
+ sub_resource_name = subclass.name.demodulize.underscore.pluralize.to_sym
73
78
  next if sub_resource_name == resource.to_sym
74
79
 
75
80
  jsonapi_resources(sub_resource_name, defaults:)
@@ -27,18 +27,35 @@ module JSONAPI
27
27
  raise ArgumentError, "Missing type or id in relationship data" unless type && id
28
28
 
29
29
  is_polymorphic = polymorphic_association?(definition, relationship_name)
30
- validate_relationship_type!(type, association) unless is_polymorphic
30
+ validate_relationship_type!(type, association, definition) unless is_polymorphic
31
31
 
32
32
  find_related_record(type, id, association, is_polymorphic)
33
33
  end
34
34
 
35
- def validate_relationship_type!(type, association)
36
- expected_type = TypeConversion.model_type_name(association.klass)
37
- return if type == expected_type
35
+ def validate_relationship_type!(type, association, definition = nil)
36
+ # Get expected type using the same format as the incoming type
37
+ format = resolve_type_format_from_incoming(type, definition)
38
+ expected_type = TypeConversion.model_type_name(association.klass, format:)
39
+
40
+ # Also check flat type for backwards compatibility
41
+ expected_flat = TypeConversion.model_type_name(association.klass, format: :flat)
42
+
43
+ return if type == expected_type || type == expected_flat
38
44
 
39
45
  raise ArgumentError, "Invalid relationship type: expected #{expected_type}, got #{type}"
40
46
  end
41
47
 
48
+ def resolve_type_format_from_incoming(type, definition)
49
+ # Detect format from incoming type string
50
+ if type.include?("/")
51
+ :prefixed
52
+ elsif definition.respond_to?(:type_format) && definition.type_format
53
+ definition.type_format
54
+ else
55
+ JSONAPI.configuration.namespace_type_format
56
+ end
57
+ end
58
+
42
59
  def find_related_record(type, id, association, is_polymorphic)
43
60
  related_model_class = resolve_related_model_class(type, association, is_polymorphic)
44
61
  related_model_class.find(id)
@@ -4,18 +4,52 @@ module JSONAPI
4
4
  module TypeConversion
5
5
  module_function
6
6
 
7
- def type_to_class_name(type)
8
- type.to_s.singularize.classify
7
+ def type_to_class_name(type, namespace: nil)
8
+ type_str = type.to_s
9
+ return prefixed_type_to_class(type_str) if type_str.include?("/")
10
+ return "#{namespace.to_s.camelize}::#{type_str.singularize.classify}" if namespace.present?
11
+
12
+ type_str.singularize.classify
13
+ end
14
+
15
+ def prefixed_type_to_class(type_str)
16
+ parts = type_str.split("/")
17
+ "#{parts[0..-2].join("/").camelize}::#{parts.last.singularize.classify}"
18
+ end
19
+
20
+ def model_type_name(model_class, format: nil)
21
+ format ||= JSONAPI.configuration.namespace_type_format
22
+
23
+ full_name = model_class.name.underscore.pluralize
24
+
25
+ format_type_name(full_name, format)
26
+ end
27
+
28
+ def resource_type_name(definition_class, format: nil)
29
+ format ||= resolve_type_format(definition_class)
30
+
31
+ full_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
32
+
33
+ format_type_name(full_name, format)
9
34
  end
10
35
 
11
- def model_type_name(model_class)
12
- model_class.name.underscore.pluralize
36
+ def format_type_name(full_name, format)
37
+ case format
38
+ when :prefixed
39
+ full_name
40
+ when :underscore
41
+ full_name.tr("/", "_")
42
+ else # :flat
43
+ full_name.split("/").last
44
+ end
13
45
  end
14
46
 
15
- def resource_type_name(definition_class)
16
- type_name = definition_class.name.sub(/Resource$/, "").underscore.pluralize
17
- # Remove namespace prefix if present (e.g., "json_api/active_storage_blobs" -> "active_storage_blobs")
18
- type_name.split("/").last
47
+ def resolve_type_format(definition_class)
48
+ if definition_class.respond_to?(:type_format) && definition_class.type_format
49
+ definition_class.type_format
50
+ else
51
+ JSONAPI.configuration.namespace_type_format
52
+ end
19
53
  end
20
54
  end
21
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.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: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp