jpie 0.3.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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Resource
5
+ module Attributable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_attributes, :_relationships, :_meta_attributes
10
+ self._attributes = []
11
+ self._relationships = {}
12
+ self._meta_attributes = []
13
+ end
14
+
15
+ class_methods do
16
+ def attribute(name, options = {}, &)
17
+ name = name.to_sym
18
+ _attributes << name unless _attributes.include?(name)
19
+ define_attribute_method(name, options, &)
20
+ end
21
+
22
+ def attributes(*names)
23
+ names.each { attribute(it) }
24
+ end
25
+
26
+ def meta_attribute(name, options = {}, &)
27
+ name = name.to_sym
28
+ _meta_attributes << name unless _meta_attributes.include?(name)
29
+ define_attribute_method(name, options, &)
30
+ end
31
+
32
+ def meta_attributes(*names)
33
+ names.each { meta_attribute(it) }
34
+ end
35
+
36
+ # More concise aliases for modern Rails style
37
+ alias_method :meta, :meta_attribute
38
+ alias_method :metas, :meta_attributes
39
+
40
+ def relationship(name, options = {})
41
+ name = name.to_sym
42
+ _relationships[name] = options
43
+
44
+ # Check if method is already defined (public or private) to allow custom implementations
45
+ return if method_defined?(name) || private_method_defined?(name)
46
+
47
+ define_method(name) do
48
+ attr_name = options[:attr] || name
49
+ @object.public_send(attr_name)
50
+ end
51
+ end
52
+
53
+ def has_many(name, options = {})
54
+ name = name.to_sym
55
+ resource_class_name = options[:resource] || infer_resource_class_name(name)
56
+ relationship(name, { resource: resource_class_name }.merge(options))
57
+ end
58
+
59
+ def has_one(name, options = {})
60
+ name = name.to_sym
61
+ resource_class_name = options[:resource] || infer_resource_class_name(name)
62
+ relationship(name, { resource: resource_class_name }.merge(options))
63
+ end
64
+
65
+ private
66
+
67
+ def define_attribute_method(name, options, &)
68
+ # If a block is provided, use it (existing behavior)
69
+ if block_given?
70
+ define_method(name) do
71
+ instance_exec(&)
72
+ end
73
+ # If options[:block] is provided, use it (existing behavior)
74
+ elsif options[:block]
75
+ define_method(name) do
76
+ instance_exec(&options[:block])
77
+ end
78
+ # If method is not already defined on the resource (public or private), define the default implementation
79
+ elsif !method_defined?(name) && !private_method_defined?(name)
80
+ define_method(name) do
81
+ attr_name = options[:attr] || name
82
+ @object.public_send(attr_name)
83
+ end
84
+ end
85
+ # If method is already defined, don't override it - let the custom method handle it
86
+ end
87
+
88
+ def infer_resource_class_name(relationship_name)
89
+ # Convert relationship name to resource class name
90
+ # :posts -> "PostResource"
91
+ # :user -> "UserResource"
92
+ singularized_name = relationship_name.to_s.singularize
93
+ "#{singularized_name.camelize}Resource"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Resource
5
+ module Inferrable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_type, :_model_class
10
+ end
11
+
12
+ class_methods do
13
+ def model(model_class = nil)
14
+ if model_class
15
+ self._model_class = model_class
16
+ else
17
+ _model_class || infer_model_class
18
+ end
19
+ end
20
+
21
+ def type(type_name = nil)
22
+ if type_name
23
+ self._type = type_name.to_s
24
+ else
25
+ _type || infer_type_name
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def infer_model_class
32
+ name.chomp('Resource').constantize
33
+ rescue NameError
34
+ nil
35
+ end
36
+
37
+ def infer_type_name
38
+ model&.model_name&.plural || name.chomp('Resource').underscore.pluralize
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Resource
5
+ module Sortable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_sortable_fields
10
+ self._sortable_fields = {}
11
+ end
12
+
13
+ class_methods do
14
+ # Define a sortable field with optional custom sorting logic
15
+ # Example:
16
+ # sortable_by :name
17
+ # sortable_by :created_at, :created_at_desc
18
+ # sortable_by :popularity do |direction|
19
+ # if direction == :asc
20
+ # model.order(:likes_count)
21
+ # else
22
+ # model.order(likes_count: :desc)
23
+ # end
24
+ # end
25
+ def sortable_by(field, column = nil, &block)
26
+ field = field.to_sym
27
+ _sortable_fields[field] = block || column || field
28
+ end
29
+
30
+ # More concise alias for modern Rails style
31
+ alias_method :sortable, :sortable_by
32
+
33
+ # Apply sorting to a query based on sort parameters
34
+ # sort_fields: array of sort field strings (e.g., ['name', '-created_at'])
35
+ def sort(query, sort_fields)
36
+ return query if sort_fields.blank?
37
+
38
+ sort_fields.each do |sort_field|
39
+ # Parse direction (- prefix means descending)
40
+ if sort_field.start_with?('-')
41
+ field = sort_field[1..].to_sym
42
+ direction = :desc
43
+ else
44
+ field = sort_field.to_sym
45
+ direction = :asc
46
+ end
47
+
48
+ # Check if field is sortable
49
+ unless sortable_field?(field)
50
+ raise JPie::Errors::BadRequestError.new(
51
+ detail: "Invalid sort field: #{field}. Sortable fields are: #{sortable_fields.join(', ')}"
52
+ )
53
+ end
54
+
55
+ # Apply sorting
56
+ query = apply_sort(query, field, direction)
57
+ end
58
+
59
+ query
60
+ end
61
+
62
+ # Get list of all sortable fields (attributes + explicitly defined sortable fields)
63
+ def sortable_fields
64
+ (_attributes + _sortable_fields.keys).uniq.map(&:to_s)
65
+ end
66
+
67
+ # Check if a field is sortable
68
+ def sortable_field?(field)
69
+ field = field.to_sym
70
+ _attributes.include?(field) || _sortable_fields.key?(field)
71
+ end
72
+
73
+ private
74
+
75
+ # Apply a single sort to the query
76
+ def apply_sort(query, field, direction)
77
+ sortable_config = _sortable_fields[field]
78
+
79
+ if sortable_config.is_a?(Proc)
80
+ # Custom sorting block
81
+ instance_exec(query, direction, &sortable_config)
82
+ elsif sortable_config.is_a?(Symbol)
83
+ # Custom column name
84
+ query.order(sortable_config => direction)
85
+ else
86
+ # Default sorting by attribute name
87
+ query.order(field => direction)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'resource/attributable'
4
+ require_relative 'resource/inferrable'
5
+ require_relative 'resource/sortable'
6
+ require_relative 'errors'
7
+
8
+ module JPie
9
+ class Resource
10
+ include ActiveSupport::Configurable
11
+ include Attributable
12
+ include Inferrable
13
+ include Sortable
14
+
15
+ class << self
16
+ def inherited(subclass)
17
+ super
18
+ subclass._attributes = _attributes.dup
19
+ subclass._relationships = _relationships.dup
20
+ subclass._meta_attributes = _meta_attributes.dup
21
+ subclass._sortable_fields = _sortable_fields.dup
22
+ end
23
+
24
+ # Default scope method that returns all records
25
+ # Override this in your resource classes to provide authorization scoping
26
+ # Example:
27
+ # def self.scope(context)
28
+ # current_user = context[:current_user]
29
+ # return model.none unless current_user
30
+ #
31
+ # if current_user.admin?
32
+ # model.all
33
+ # else
34
+ # model.where(user: current_user)
35
+ # end
36
+ # end
37
+ def scope(_context = {})
38
+ model.all
39
+ end
40
+ end
41
+
42
+ attr_reader :object, :context
43
+
44
+ def initialize(object, context = {})
45
+ @object = object
46
+ @context = context
47
+ end
48
+
49
+ delegate :id, to: :@object
50
+
51
+ delegate :type, to: :class
52
+
53
+ def attributes_hash
54
+ self.class._attributes.index_with do
55
+ send(it)
56
+ end
57
+ end
58
+
59
+ def meta_hash
60
+ # Start with meta attributes from the macro
61
+ base_meta = self.class._meta_attributes.index_with do
62
+ send(it)
63
+ end
64
+
65
+ # Check if the resource defines a custom meta method
66
+ if respond_to?(:meta, true) && method(:meta).owner != JPie::Resource
67
+ custom_meta = meta
68
+
69
+ # Validate that meta method returns a hash
70
+ unless custom_meta.is_a?(Hash)
71
+ raise JPie::Errors::ResourceError.new(
72
+ detail: "meta method must return a Hash, got #{custom_meta.class}"
73
+ )
74
+ end
75
+
76
+ # Merge custom meta with base meta (custom meta takes precedence)
77
+ base_meta.merge(custom_meta)
78
+ else
79
+ base_meta
80
+ end
81
+ end
82
+
83
+ protected
84
+
85
+ # Default meta method that returns the meta attributes
86
+ # This can be overridden in subclasses
87
+ def meta
88
+ self.class._meta_attributes.index_with do
89
+ send(it)
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def method_missing(method_name, *, &)
96
+ if @object.respond_to?(method_name)
97
+ @object.public_send(method_name, *, &)
98
+ else
99
+ super
100
+ end
101
+ end
102
+
103
+ def respond_to_missing?(method_name, include_private = false)
104
+ @object.respond_to?(method_name, include_private) || super
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ class Serializer
5
+ attr_reader :resource_class, :options
6
+
7
+ def initialize(resource_class, options = {})
8
+ @resource_class = resource_class
9
+ @options = options
10
+ end
11
+
12
+ def serialize(objects, context = {}, includes: [])
13
+ return { data: nil } if objects.nil?
14
+
15
+ resources = build_resources(objects, context)
16
+ result = serialize_data(objects, resources)
17
+ add_included_data(result, resources, includes, context) if should_include_data?(includes, result)
18
+ result
19
+ end
20
+
21
+ private
22
+
23
+ def build_resources(objects, context)
24
+ Array(objects).filter_map { |obj| obj ? resource_class.new(obj, context) : nil }
25
+ end
26
+
27
+ def serialize_data(objects, resources)
28
+ if objects.is_a?(Array) || objects.respond_to?(:each)
29
+ serialize_collection(resources)
30
+ else
31
+ resources.first ? serialize_single(resources.first) : { data: nil }
32
+ end
33
+ end
34
+
35
+ def should_include_data?(includes, result)
36
+ includes.any? && result[:data]
37
+ end
38
+
39
+ def add_included_data(result, resources, includes, context)
40
+ included_data = collect_included_data(resources, includes, context)
41
+ result[:included] = included_data
42
+ end
43
+
44
+ def serialize_single(resource)
45
+ {
46
+ data: serialize_resource_data(resource)
47
+ }
48
+ end
49
+
50
+ def serialize_collection(resources)
51
+ {
52
+ data: resources.map { serialize_resource_data(it) }
53
+ }
54
+ end
55
+
56
+ def serialize_resource_data(resource)
57
+ data = {
58
+ id: resource.id.to_s,
59
+ type: resource.type,
60
+ attributes: serialize_attributes(resource)
61
+ }
62
+
63
+ meta_data = serialize_meta(resource)
64
+ data[:meta] = meta_data if meta_data.any?
65
+
66
+ data.compact
67
+ end
68
+
69
+ def serialize_attributes(resource)
70
+ attributes = resource.attributes_hash
71
+ return {} if attributes.empty?
72
+
73
+ attributes.transform_keys { it.to_s.underscore }
74
+ .transform_values { serialize_value(it) }
75
+ end
76
+
77
+ def serialize_meta(resource)
78
+ meta_attributes = resource.meta_hash
79
+ return {} if meta_attributes.empty?
80
+
81
+ meta_attributes.transform_keys { it.to_s.underscore }
82
+ .transform_values { serialize_value(it) }
83
+ end
84
+
85
+ def serialize_value(value)
86
+ value.respond_to?(:iso8601) ? value.iso8601 : value
87
+ end
88
+
89
+ def collect_included_data(resources, includes, context)
90
+ processor = IncludeProcessor.new(self, context)
91
+ processor.process(resources, includes)
92
+ end
93
+
94
+ def parse_nested_includes(includes)
95
+ result = {}
96
+
97
+ includes.each do |include_path|
98
+ parts = include_path.split('.')
99
+ top_level = parts.first
100
+ nested_path = parts[1..].join('.') if parts.length > 1
101
+
102
+ result[top_level] ||= []
103
+ result[top_level] << nested_path if nested_path.present?
104
+ end
105
+
106
+ result
107
+ end
108
+
109
+ # Helper class to manage include processing state and reduce parameter passing
110
+ class IncludeProcessor
111
+ attr_reader :serializer, :context, :included, :processed_includes
112
+
113
+ def initialize(serializer, context)
114
+ @serializer = serializer
115
+ @context = context
116
+ @included = []
117
+ @processed_includes = {}
118
+ end
119
+
120
+ def process(resources, includes)
121
+ parsed_includes = serializer.send(:parse_nested_includes, includes)
122
+ parsed_includes.each do |include_name, nested_includes|
123
+ process_single_include(include_name, nested_includes, resources)
124
+ end
125
+ included
126
+ end
127
+
128
+ private
129
+
130
+ def process_single_include(include_name, nested_includes, resources)
131
+ include_sym = include_name.to_sym
132
+ relationship_options = serializer.resource_class._relationships[include_sym]
133
+ return unless relationship_options
134
+
135
+ resources.each do |resource|
136
+ process_resource_relationships(resource, include_sym, relationship_options, nested_includes)
137
+ end
138
+ end
139
+
140
+ def process_resource_relationships(resource, include_sym, relationship_options, nested_includes)
141
+ related_objects = resource.public_send(include_sym)
142
+ return unless related_objects
143
+
144
+ Array(related_objects).each do |related_object|
145
+ process_related_object(related_object, relationship_options, nested_includes)
146
+ end
147
+ end
148
+
149
+ def process_related_object(related_object, relationship_options, nested_includes)
150
+ related_resource_class = serializer.send(:determine_resource_class, related_object, relationship_options)
151
+ return unless related_resource_class
152
+
153
+ related_resource = related_resource_class.new(related_object, context)
154
+ resource_data = serializer.send(:serialize_resource_data, related_resource)
155
+
156
+ add_to_included_if_unique(resource_data)
157
+ process_nested_includes_for_resource(related_resource_class, related_resource, nested_includes)
158
+ end
159
+
160
+ def add_to_included_if_unique(resource_data)
161
+ key = [resource_data[:type], resource_data[:id]]
162
+ return if processed_includes[key]
163
+
164
+ processed_includes[key] = true
165
+ included << resource_data
166
+ end
167
+
168
+ def process_nested_includes_for_resource(related_resource_class, related_resource, nested_includes)
169
+ return unless nested_includes.any?
170
+
171
+ nested_serializer = JPie::Serializer.new(related_resource_class)
172
+ nested_included = nested_serializer.send(:collect_included_data, [related_resource], nested_includes, context)
173
+
174
+ nested_included.each do |nested_item|
175
+ add_to_included_if_unique(nested_item)
176
+ end
177
+ end
178
+ end
179
+
180
+ def determine_resource_class(object, relationship_options)
181
+ # First try the explicitly specified resource class
182
+ if relationship_options[:resource]
183
+ begin
184
+ return relationship_options[:resource].constantize
185
+ rescue NameError
186
+ # If the resource class doesn't exist, it might be a polymorphic relationship
187
+ # Fall through to polymorphic detection
188
+ end
189
+ end
190
+
191
+ # For polymorphic relationships, determine resource class from object class
192
+ if object&.class
193
+ resource_class_name = "#{object.class.name}Resource"
194
+ begin
195
+ return resource_class_name.constantize
196
+ rescue NameError
197
+ # Resource class doesn't exist for this object type
198
+ return nil
199
+ end
200
+ end
201
+
202
+ nil
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JPie
4
+ VERSION = '0.3.0'
5
+ end
data/lib/jpie.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+ require 'jpie/version'
6
+
7
+ module JPie
8
+ autoload :Resource, 'jpie/resource'
9
+ autoload :Serializer, 'jpie/serializer'
10
+ autoload :Deserializer, 'jpie/deserializer'
11
+ autoload :Controller, 'jpie/controller'
12
+ autoload :Configuration, 'jpie/configuration'
13
+ autoload :Errors, 'jpie/errors'
14
+
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+ end
24
+ end
25
+
26
+ require 'jpie/railtie' if defined?(Rails)