the-active-rpc 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/Rakefile +8 -0
- data/lib/active_rpc/client_config.rb +30 -0
- data/lib/active_rpc/client_factory.rb +61 -0
- data/lib/active_rpc/configuration.rb +50 -0
- data/lib/active_rpc/model_extensions/attribute_dsl.rb +31 -0
- data/lib/active_rpc/model_extensions.rb +1456 -0
- data/lib/active_rpc/rpc/base_controller.rb +63 -0
- data/lib/active_rpc/rpc/concerns/includable.rb +68 -0
- data/lib/active_rpc/rpc/concerns/paginatable.rb +155 -0
- data/lib/active_rpc/rpc/concerns/query_builder.rb +178 -0
- data/lib/active_rpc/rpc/concerns/ransackable.rb +113 -0
- data/lib/active_rpc/rpc/concerns/request_processor.rb +191 -0
- data/lib/active_rpc/rpc/concerns/resource_controller.rb +281 -0
- data/lib/active_rpc/rpc/concerns/scopable.rb +92 -0
- data/lib/active_rpc/rpc/concerns/serializable.rb +30 -0
- data/lib/active_rpc/rpc/concerns/sortable.rb +71 -0
- data/lib/active_rpc/rpc/configuration.rb +7 -0
- data/lib/active_rpc/rpc/interceptors/locale_interceptor.rb +38 -0
- data/lib/active_rpc/rpc.rb +18 -0
- data/lib/active_rpc/version.rb +3 -0
- data/lib/active_rpc.rb +15 -0
- data/lib/generators/active_rpc/client_setup/client_setup_generator.rb +57 -0
- data/lib/generators/active_rpc/controller/gruf_controller_generator.rb +60 -0
- data/lib/generators/active_rpc/server_setup/server_setup_generator.rb +11 -0
- data/lib/the-active-rpc.rb +1 -0
- metadata +196 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module ActiveRpc
|
|
2
|
+
module Rpc
|
|
3
|
+
module Concerns
|
|
4
|
+
# The RequestProcessor concern provides methods for processing gRPC requests
|
|
5
|
+
# with standardized error handling and parameter validation.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# def get_user
|
|
9
|
+
# process_request do
|
|
10
|
+
# validate_required_params!(request.message, :id)
|
|
11
|
+
# user = find_record(User, request.message.id)
|
|
12
|
+
# user.to_rpc_response
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module RequestProcessor
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Process a request with a block, handling common errors
|
|
19
|
+
def process_request
|
|
20
|
+
ActiveRecord::Base.transaction do
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
24
|
+
fail!(:not_found, :record_not_found, e.message)
|
|
25
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
26
|
+
# For validation errors, we now return structured responses instead of failing
|
|
27
|
+
# This should be handled by the individual controller methods
|
|
28
|
+
raise e
|
|
29
|
+
rescue ArgumentError => e
|
|
30
|
+
fail!(:invalid_argument, :invalid_request, e.message)
|
|
31
|
+
rescue UncaughtThrowError => e
|
|
32
|
+
# Handle throw(:abort) from callbacks
|
|
33
|
+
if e.tag == :abort
|
|
34
|
+
fail!(:aborted, :operation_aborted, "Operation aborted")
|
|
35
|
+
else
|
|
36
|
+
# Re-raise if it's not an :abort throw
|
|
37
|
+
raise e
|
|
38
|
+
end
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Rails.logger.error("Error processing request: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
41
|
+
fail!(:internal, :unexpected_error, "An unexpected error occurred")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Extract parameters from a request payload
|
|
45
|
+
def extract_params(payload, *param_names)
|
|
46
|
+
# Step 1: Determine which fields to extract and get _fields metadata
|
|
47
|
+
if payload.respond_to?(:_fields) && !payload._fields.empty?
|
|
48
|
+
# Use _fields metadata (only top-level fields)
|
|
49
|
+
fields_to_extract = payload._fields
|
|
50
|
+
.reject { |path| path.include?('.') || path.include?('[') }
|
|
51
|
+
# Pass the full _fields metadata for nested conversion
|
|
52
|
+
fields_metadata = payload._fields
|
|
53
|
+
elsif param_names.empty?
|
|
54
|
+
# Extract all available fields from protobuf descriptor
|
|
55
|
+
if payload.class.respond_to?(:descriptor)
|
|
56
|
+
fields_to_extract = payload.class.descriptor.map(&:name)
|
|
57
|
+
else
|
|
58
|
+
fields_to_extract = []
|
|
59
|
+
end
|
|
60
|
+
fields_metadata = nil
|
|
61
|
+
else
|
|
62
|
+
# Extract only requested fields
|
|
63
|
+
fields_to_extract = param_names.map(&:to_s)
|
|
64
|
+
fields_metadata = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Step 2: Extract the fields
|
|
68
|
+
result = {}
|
|
69
|
+
fields_to_extract.each do |field_name|
|
|
70
|
+
field_sym = field_name.to_sym
|
|
71
|
+
if payload.respond_to?(field_sym)
|
|
72
|
+
field_value = payload.send(field_sym)
|
|
73
|
+
# Pass the field path and metadata for nested conversion
|
|
74
|
+
field_path = field_name
|
|
75
|
+
result[field_sym] = convert_protobuf_to_ruby(field_value, field_path, fields_metadata)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Convert protobuf objects to Ruby hashes/arrays recursively
|
|
85
|
+
# WITHOUT using to_h to preserve empty arrays and explicit default values
|
|
86
|
+
# Respects _fields metadata to only extract fields that were originally sent
|
|
87
|
+
def convert_protobuf_to_ruby(value, current_path = '', fields_metadata = nil)
|
|
88
|
+
case value
|
|
89
|
+
when Google::Protobuf::MessageExts
|
|
90
|
+
# Convert protobuf message to hash by extracting only tracked fields
|
|
91
|
+
result = {}
|
|
92
|
+
|
|
93
|
+
if fields_metadata
|
|
94
|
+
# Only extract fields that are in the _fields metadata
|
|
95
|
+
value.class.descriptor.each do |field|
|
|
96
|
+
field_name = field.name
|
|
97
|
+
field_path = current_path.empty? ? field_name : "#{current_path}.#{field_name}"
|
|
98
|
+
|
|
99
|
+
# Check if this field path exists in _fields metadata
|
|
100
|
+
# Match exact path or any child path (but not parent paths)
|
|
101
|
+
if fields_metadata.any? { |path| path == field_path || path.start_with?("#{field_path}.") || path.start_with?("#{field_path}[") }
|
|
102
|
+
field_value = value.send(field_name)
|
|
103
|
+
result[field_name] = convert_protobuf_to_ruby(field_value, field_path, fields_metadata)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
# Fallback: extract all fields when no metadata available
|
|
108
|
+
value.class.descriptor.each do |field|
|
|
109
|
+
field_name = field.name
|
|
110
|
+
field_value = value.send(field_name)
|
|
111
|
+
field_path = current_path.empty? ? field_name : "#{current_path}.#{field_name}"
|
|
112
|
+
result[field_name] = convert_protobuf_to_ruby(field_value, field_path, fields_metadata)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
result
|
|
117
|
+
when Google::Protobuf::Map
|
|
118
|
+
# Convert map fields while respecting _fields metadata for individual keys
|
|
119
|
+
allowed_entry_paths = if fields_metadata
|
|
120
|
+
fields_metadata.select { |path| path.start_with?("#{current_path}[") }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
value.each_with_object({}) do |(map_key, map_value), acc|
|
|
124
|
+
entry_path = "#{current_path}[#{map_key}]"
|
|
125
|
+
|
|
126
|
+
if allowed_entry_paths.present?
|
|
127
|
+
next unless allowed_entry_paths.any? { |path| path == entry_path || path.start_with?("#{entry_path}.") }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
acc[map_key] = convert_protobuf_to_ruby(map_value, entry_path, fields_metadata)
|
|
131
|
+
end
|
|
132
|
+
when Google::Protobuf::RepeatedField
|
|
133
|
+
# Convert repeated field to array and recursively convert elements
|
|
134
|
+
value.map.with_index do |item, index|
|
|
135
|
+
item_path = "#{current_path}[#{index}]"
|
|
136
|
+
convert_protobuf_to_ruby(item, item_path, fields_metadata)
|
|
137
|
+
end
|
|
138
|
+
when Array
|
|
139
|
+
# Convert array elements recursively
|
|
140
|
+
value.map.with_index do |item, index|
|
|
141
|
+
item_path = "#{current_path}[#{index}]"
|
|
142
|
+
convert_protobuf_to_ruby(item, item_path, fields_metadata)
|
|
143
|
+
end
|
|
144
|
+
when Hash
|
|
145
|
+
# Convert hash values recursively
|
|
146
|
+
value.transform_values { |v| convert_protobuf_to_ruby(v, current_path, fields_metadata) }
|
|
147
|
+
else
|
|
148
|
+
# Return primitive values as-is
|
|
149
|
+
value
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validate required parameters
|
|
154
|
+
def validate_required_params!(payload, *param_names)
|
|
155
|
+
missing = param_names.select { |name| !payload.respond_to?(name) || payload.send(name).nil? }
|
|
156
|
+
if missing.any?
|
|
157
|
+
raise ArgumentError, "Missing required parameters: #{missing.join(', ')}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Find a record or fail with not found
|
|
162
|
+
def find_record(model_class, id)
|
|
163
|
+
record = model_class.find_by(id: id)
|
|
164
|
+
unless record
|
|
165
|
+
fail!(:not_found, :record_not_found, "#{model_class.name} with ID #{id} not found")
|
|
166
|
+
end
|
|
167
|
+
record
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Create structured response metadata for validation errors
|
|
171
|
+
def create_validation_error_metadata(record)
|
|
172
|
+
# Share the Rails errors object directly - no conversion needed!
|
|
173
|
+
errors_map = {}
|
|
174
|
+
record.errors.to_hash.each do |field, messages|
|
|
175
|
+
errors_map[field.to_s] = Core::ErrorMessages.new(messages: messages)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
Core::ResponseMetadata.new(
|
|
179
|
+
success: false,
|
|
180
|
+
errors: errors_map
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Create successful response metadata
|
|
185
|
+
def create_success_metadata
|
|
186
|
+
Core::ResponseMetadata.new(success: true)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
module ActiveRpc
|
|
2
|
+
module Rpc
|
|
3
|
+
module Concerns
|
|
4
|
+
# The ResourceController concern provides high-level methods for common CRUD operations
|
|
5
|
+
# in gRPC controllers.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# def get_user
|
|
9
|
+
# get_resource(User, request.message, transformer: :to_rpc_response)
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# def list_users
|
|
13
|
+
# list_resources(User, request.message,
|
|
14
|
+
# response_class: ::Core::UserListResponse,
|
|
15
|
+
# transformer: :to_rpc_response
|
|
16
|
+
# )
|
|
17
|
+
# end
|
|
18
|
+
module ResourceController
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
include ActiveRpc::Rpc::Concerns::RequestProcessor
|
|
22
|
+
include ActiveRpc::Rpc::Concerns::QueryBuilder
|
|
23
|
+
|
|
24
|
+
# Get a single resource
|
|
25
|
+
def get_resource(model_class, request_payload, options = {})
|
|
26
|
+
process_request do
|
|
27
|
+
# Validate required parameters
|
|
28
|
+
validate_required_params!(request_payload, :id)
|
|
29
|
+
|
|
30
|
+
# Find the resource with includes if provided
|
|
31
|
+
includes = options[:includes] || []
|
|
32
|
+
resource = model_class.includes(includes).find(request_payload.id)
|
|
33
|
+
|
|
34
|
+
# Transform the resource
|
|
35
|
+
if options[:transformer].is_a?(Proc)
|
|
36
|
+
options[:transformer].call(resource)
|
|
37
|
+
elsif options[:transformer] == :to_rpc_response
|
|
38
|
+
resource.to_rpc_response
|
|
39
|
+
else
|
|
40
|
+
resource
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# List resources
|
|
46
|
+
def list_resources(model_class, request_payload, options = {})
|
|
47
|
+
process_request do
|
|
48
|
+
execute_query(model_class, request_payload, options)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create a resource
|
|
53
|
+
def create_resource(model_class, request_payload, options = {})
|
|
54
|
+
process_request do
|
|
55
|
+
# Validate required parameters
|
|
56
|
+
if options[:required_params].is_a?(Array)
|
|
57
|
+
validate_required_params!(request_payload, *options[:required_params])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Extract parameters
|
|
61
|
+
params = if options[:param_extractor].is_a?(Proc)
|
|
62
|
+
options[:param_extractor].call(request_payload)
|
|
63
|
+
else
|
|
64
|
+
extract_params(request_payload, *options[:param_names])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Coerce JSONB columns from JSON string to Hash (schema-aware, generic)
|
|
68
|
+
params = coerce_jsonb_params(model_class, params)
|
|
69
|
+
|
|
70
|
+
# Create the resource
|
|
71
|
+
resource = model_class.new(params.to_h.with_indifferent_access)
|
|
72
|
+
|
|
73
|
+
# Apply before save hook if provided
|
|
74
|
+
if options[:before_save].is_a?(Proc)
|
|
75
|
+
hook_result = options[:before_save].call(resource, request_payload)
|
|
76
|
+
# Check if the hook returned an error response
|
|
77
|
+
return hook_result if hook_result.respond_to?(:success) && hook_result.success == false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Save the resource
|
|
81
|
+
if resource.save
|
|
82
|
+
# Apply after save hook if provided
|
|
83
|
+
if options[:after_save].is_a?(Proc)
|
|
84
|
+
hook_result = options[:after_save].call(resource, request_payload)
|
|
85
|
+
# Check if the hook returned an error response
|
|
86
|
+
return hook_result if hook_result.respond_to?(:success) && hook_result.success == false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Transform the resource with success metadata
|
|
90
|
+
result = if options[:transformer].is_a?(Proc)
|
|
91
|
+
options[:transformer].call(resource)
|
|
92
|
+
elsif options[:transformer] == :to_rpc_response
|
|
93
|
+
resource.reload.to_rpc_response
|
|
94
|
+
else
|
|
95
|
+
resource.reload
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Ensure string fields in protobuf receive strings (stringify Hash/Array)
|
|
99
|
+
coerce_string_fields_in_response!(result)
|
|
100
|
+
|
|
101
|
+
# Add success metadata to the response
|
|
102
|
+
result._metadata = create_success_metadata if result.respond_to?(:_metadata=)
|
|
103
|
+
|
|
104
|
+
result
|
|
105
|
+
else
|
|
106
|
+
# Create response with validation error metadata
|
|
107
|
+
result = if options[:transformer].is_a?(Proc)
|
|
108
|
+
options[:transformer].call(resource)
|
|
109
|
+
elsif options[:transformer] == :to_rpc_response
|
|
110
|
+
resource.to_rpc_response
|
|
111
|
+
else
|
|
112
|
+
# Create a basic response object for the resource type
|
|
113
|
+
resource_class_name = resource.class.name
|
|
114
|
+
response_class = "Core::#{resource_class_name}Response".constantize
|
|
115
|
+
response_class.new
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Add validation error metadata to the response
|
|
119
|
+
result._metadata = create_validation_error_metadata(resource) if result.respond_to?(:_metadata=)
|
|
120
|
+
|
|
121
|
+
# Ensure string fields in protobuf receive strings (stringify Hash/Array)
|
|
122
|
+
coerce_string_fields_in_response!(result)
|
|
123
|
+
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Update a resource
|
|
130
|
+
def update_resource(model_class, request_payload, options = {})
|
|
131
|
+
process_request do
|
|
132
|
+
# Validate required parameters
|
|
133
|
+
validate_required_params!(request_payload, :id)
|
|
134
|
+
|
|
135
|
+
# Find the resource
|
|
136
|
+
resource = find_record(model_class, request_payload.id)
|
|
137
|
+
|
|
138
|
+
# Extract parameters
|
|
139
|
+
params = if options[:param_extractor].is_a?(Proc)
|
|
140
|
+
options[:param_extractor].call(request_payload)
|
|
141
|
+
else
|
|
142
|
+
extract_params(request_payload, *options[:param_names])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Coerce JSONB columns from JSON string to Hash (schema-aware, generic)
|
|
146
|
+
params = coerce_jsonb_params(model_class, params)
|
|
147
|
+
|
|
148
|
+
# Apply before save hook if provided
|
|
149
|
+
if options[:before_save].is_a?(Proc)
|
|
150
|
+
hook_result = options[:before_save].call(resource, request_payload)
|
|
151
|
+
# Check if the hook returned an error response
|
|
152
|
+
return hook_result if hook_result.respond_to?(:success) && hook_result.success == false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Update the resource
|
|
156
|
+
resource.assign_attributes(params.with_indifferent_access)
|
|
157
|
+
if resource.save
|
|
158
|
+
# Apply after save hook if provided
|
|
159
|
+
if options[:after_save].is_a?(Proc)
|
|
160
|
+
hook_result = options[:after_save].call(resource, request_payload)
|
|
161
|
+
# Check if the hook returned an error response
|
|
162
|
+
return hook_result if hook_result.respond_to?(:success) && hook_result.success == false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Transform the resource with success metadata
|
|
166
|
+
result = if options[:transformer].is_a?(Proc)
|
|
167
|
+
options[:transformer].call(resource)
|
|
168
|
+
elsif options[:transformer] == :to_rpc_response
|
|
169
|
+
resource.reload.to_rpc_response
|
|
170
|
+
else
|
|
171
|
+
resource.reload
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Ensure string fields in protobuf receive strings (stringify Hash/Array)
|
|
175
|
+
coerce_string_fields_in_response!(result)
|
|
176
|
+
|
|
177
|
+
# Add success metadata to the response
|
|
178
|
+
result._metadata = create_success_metadata if result.respond_to?(:_metadata=)
|
|
179
|
+
|
|
180
|
+
result
|
|
181
|
+
else
|
|
182
|
+
# Create response with validation error metadata
|
|
183
|
+
result = if options[:transformer].is_a?(Proc)
|
|
184
|
+
options[:transformer].call(resource)
|
|
185
|
+
elsif options[:transformer] == :to_rpc_response
|
|
186
|
+
resource.to_rpc_response
|
|
187
|
+
else
|
|
188
|
+
# Create a basic response object for the resource type
|
|
189
|
+
resource_class_name = resource.class.name
|
|
190
|
+
response_class = "Core::#{resource_class_name}Response".constantize
|
|
191
|
+
response_class.new
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Add validation error metadata to the response
|
|
195
|
+
result._metadata = create_validation_error_metadata(resource) if result.respond_to?(:_metadata=)
|
|
196
|
+
|
|
197
|
+
# Ensure string fields in protobuf receive strings (stringify Hash/Array)
|
|
198
|
+
coerce_string_fields_in_response!(result)
|
|
199
|
+
|
|
200
|
+
result
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Delete a resource
|
|
206
|
+
def delete_resource(model_class, request_payload, options = {})
|
|
207
|
+
process_request do
|
|
208
|
+
# Validate required parameters
|
|
209
|
+
validate_required_params!(request_payload, :id)
|
|
210
|
+
|
|
211
|
+
# Find the resource
|
|
212
|
+
resource = find_record(model_class, request_payload.id)
|
|
213
|
+
|
|
214
|
+
# Apply before destroy hook if provided
|
|
215
|
+
options[:before_destroy].call(resource, request_payload) if options[:before_destroy].is_a?(Proc)
|
|
216
|
+
|
|
217
|
+
# Destroy the resource
|
|
218
|
+
resource.destroy!
|
|
219
|
+
|
|
220
|
+
# Apply after destroy hook if provided
|
|
221
|
+
options[:after_destroy].call(resource, request_payload) if options[:after_destroy].is_a?(Proc)
|
|
222
|
+
|
|
223
|
+
# Return success response
|
|
224
|
+
options[:success_response] || { success: true }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
# Convert jsonb column params from JSON string to Hash, generically
|
|
231
|
+
def coerce_jsonb_params(model_class, params)
|
|
232
|
+
return params unless params.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
jsonb_columns = model_class.columns.select { |c| c.type == :jsonb }.map(&:name)
|
|
235
|
+
return params if jsonb_columns.empty?
|
|
236
|
+
|
|
237
|
+
params.transform_keys(&:to_sym).tap do |h|
|
|
238
|
+
jsonb_columns.each do |col|
|
|
239
|
+
key = col.to_sym
|
|
240
|
+
next unless h.key?(key) && h[key].is_a?(String)
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
parsed = JSON.parse(h[key])
|
|
244
|
+
h[key] = parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
245
|
+
rescue JSON::ParserError
|
|
246
|
+
# leave as-is
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Ensure any protobuf string fields receive strings, not Hash/Array (stringify if needed)
|
|
253
|
+
def coerce_string_fields_in_response!(message)
|
|
254
|
+
return message unless message.respond_to?(:class) && message.class.respond_to?(:descriptor)
|
|
255
|
+
|
|
256
|
+
message.class.descriptor.each do |field|
|
|
257
|
+
name = field.name
|
|
258
|
+
value = message.send(name) if message.respond_to?(name)
|
|
259
|
+
|
|
260
|
+
case field.type
|
|
261
|
+
when :string
|
|
262
|
+
message.send("#{name}=", value.to_json) if value.is_a?(Hash) || value.is_a?(Array)
|
|
263
|
+
when :message
|
|
264
|
+
if value.respond_to?(:class) && value.class.respond_to?(:descriptor)
|
|
265
|
+
coerce_string_fields_in_response!(value)
|
|
266
|
+
end
|
|
267
|
+
when :repeated
|
|
268
|
+
if value.is_a?(Array)
|
|
269
|
+
value.each do |v|
|
|
270
|
+
coerce_string_fields_in_response!(v) if v.respond_to?(:class) && v.class.respond_to?(:descriptor)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
message
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module ActiveRpc
|
|
2
|
+
module Rpc
|
|
3
|
+
module Concerns
|
|
4
|
+
# The Scopable concern provides methods for applying named scopes
|
|
5
|
+
# to ActiveRecord queries in gRPC controllers.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# def list_users
|
|
9
|
+
# process_request do
|
|
10
|
+
# base_query = User.all
|
|
11
|
+
# query = apply_scopes(base_query, request.message)
|
|
12
|
+
# # ...
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module Scopable
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Apply scopes to a query
|
|
19
|
+
def apply_scopes(query, params)
|
|
20
|
+
return query unless params.respond_to?(:scope) && params.scope.present?
|
|
21
|
+
|
|
22
|
+
# Handle both new JSON format and old individual parameters for backward compatibility
|
|
23
|
+
scope_hash = params.scope.to_h
|
|
24
|
+
scope_params = if scope_hash.key?('args')
|
|
25
|
+
# New format: JSON-serialized scope arguments
|
|
26
|
+
begin
|
|
27
|
+
JSON.parse(scope_hash['args'])
|
|
28
|
+
rescue JSON::ParserError => e
|
|
29
|
+
Rails.logger.error("[Scopable] Failed to parse scope args: #{e.message}")
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
# Old format: individual string parameters (backward compatibility)
|
|
34
|
+
scope_hash
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get available scopes
|
|
38
|
+
available_scopes = get_available_scopes(query.model)
|
|
39
|
+
|
|
40
|
+
# Apply each scope
|
|
41
|
+
scope_params.each do |scope_name, scope_value|
|
|
42
|
+
# Skip if the scope is not available and we have defined scopes
|
|
43
|
+
scope_available = available_scopes.empty? ||
|
|
44
|
+
available_scopes.key?(scope_name.to_sym) ||
|
|
45
|
+
available_scopes.key?(scope_name.to_s)
|
|
46
|
+
|
|
47
|
+
unless scope_available
|
|
48
|
+
Rails.logger.debug("[Scopable] Skipping scope #{scope_name} - not in available_scopes")
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless query.respond_to?(scope_name)
|
|
53
|
+
Rails.logger.debug("[Scopable] Skipping scope #{scope_name} - query doesn't respond to it")
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
# Handle regular scopes
|
|
59
|
+
method = query.method(scope_name)
|
|
60
|
+
if method.arity.abs <= 1
|
|
61
|
+
Rails.logger.debug("[Scopable] Applying scope: #{scope_name}(#{scope_value.class})")
|
|
62
|
+
query = scope_value.present? ? query.public_send(scope_name, scope_value) : query.public_send(scope_name)
|
|
63
|
+
else
|
|
64
|
+
Rails.logger.debug("[Scopable] Skipping scope #{scope_name} - arity too high: #{method.arity}")
|
|
65
|
+
end
|
|
66
|
+
rescue => e
|
|
67
|
+
Rails.logger.error("[Scopable] Error applying scope '#{scope_name}': #{e.message}")
|
|
68
|
+
# Continue with other scopes even if one fails
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
query
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Get available scopes for a model
|
|
78
|
+
def get_available_scopes(model_class)
|
|
79
|
+
if model_class.respond_to?(:active_rpc_config) && model_class.active_rpc_config
|
|
80
|
+
resource = model_class.active_rpc_config[:resource].to_s.underscore
|
|
81
|
+
scopes_method = "#{resource}_scopes"
|
|
82
|
+
|
|
83
|
+
return model_class.send(scopes_method) if model_class.respond_to?(scopes_method)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Default empty scopes if none are defined
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ActiveRpc
|
|
2
|
+
module Rpc
|
|
3
|
+
module Concerns
|
|
4
|
+
# The Serializable concern provides methods for transforming ActiveRecord models
|
|
5
|
+
# into gRPC response objects.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# def get_user
|
|
9
|
+
# process_request do
|
|
10
|
+
# user = find_record(User, request.message.id)
|
|
11
|
+
# serialize_record(user, serializer: UserSerializer)
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
module Serializable
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
# Transform a record using a serializer
|
|
18
|
+
def serialize_record(record, options = {})
|
|
19
|
+
serializer_class = options[:serializer] || "#{record.class.name}Serializer".constantize
|
|
20
|
+
serializer_class.new(record).to_h
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Transform a collection using a serializer
|
|
24
|
+
def serialize_collection(collection, options = {})
|
|
25
|
+
collection.map { |record| serialize_record(record, options) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module ActiveRpc
|
|
2
|
+
module Rpc
|
|
3
|
+
module Concerns
|
|
4
|
+
# The Sortable concern provides methods for applying sorting
|
|
5
|
+
# to ActiveRecord queries in gRPC controllers.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# def list_users
|
|
9
|
+
# process_request do
|
|
10
|
+
# base_query = User.all
|
|
11
|
+
# query = apply_sorting(base_query, request.message)
|
|
12
|
+
# # ...
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module Sortable
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Apply sorting to a query
|
|
19
|
+
def apply_sorting(query, params)
|
|
20
|
+
return query unless params.respond_to?(:sort) && params.sort.present?
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
# Parse the sort parameter
|
|
24
|
+
field, direction = params.sort.split(' ')
|
|
25
|
+
direction ||= 'asc'
|
|
26
|
+
|
|
27
|
+
# Sanitize the direction
|
|
28
|
+
direction = %w[asc desc].include?(direction.downcase) ? direction.downcase : 'asc'
|
|
29
|
+
|
|
30
|
+
# Get query configuration
|
|
31
|
+
query_config = get_query_config(query.model)
|
|
32
|
+
sortable_attrs = query_config[:sortable] || []
|
|
33
|
+
|
|
34
|
+
# Check if the field is sortable or if we have no defined sortable attributes
|
|
35
|
+
if sortable_attrs.empty? || sortable_attrs.include?(field.to_sym) || sortable_attrs.include?(field.to_s)
|
|
36
|
+
# Apply sorting
|
|
37
|
+
query.order("#{field} #{direction}")
|
|
38
|
+
else
|
|
39
|
+
Rails.logger.error("Invalid sort field: #{field}")
|
|
40
|
+
query
|
|
41
|
+
end
|
|
42
|
+
rescue => e
|
|
43
|
+
Rails.logger.error("Error applying sorting: #{e.message}")
|
|
44
|
+
# Return original query if sorting fails
|
|
45
|
+
query
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get query configuration for a model
|
|
50
|
+
def get_query_config(model_class)
|
|
51
|
+
if model_class.respond_to?(:active_rpc_config) && model_class.active_rpc_config
|
|
52
|
+
resource = model_class.active_rpc_config[:resource].to_s.underscore
|
|
53
|
+
query_config_method = "#{resource}_query_config"
|
|
54
|
+
|
|
55
|
+
if model_class.respond_to?(query_config_method)
|
|
56
|
+
return model_class.send(query_config_method)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Default configuration if none is defined
|
|
61
|
+
{
|
|
62
|
+
searchable: model_class.column_names,
|
|
63
|
+
filterable: model_class.column_names,
|
|
64
|
+
sortable: model_class.column_names,
|
|
65
|
+
includable: model_class.reflect_on_all_associations.map(&:name)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|