respondo-grpc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RespondoGrpc
6
+ # Converts between the Hash built by ResponseBuilder and the
7
+ # respondo.v1.Response protobuf message — in both directions.
8
+ module ProtoMapper
9
+ PAGINATION_KEYS = %i[current_page per_page total_pages total_count next_page prev_page].freeze
10
+
11
+ module_function
12
+
13
+ # @param hash [Hash] output of ResponseBuilder#build
14
+ # @return [Respondo::V1::Response]
15
+ def to_proto(hash)
16
+ response = ::Respondo::V1::Response.new(success: hash[:success] ? true : false,
17
+ message: hash[:message].to_s)
18
+
19
+ if hash[:payload]
20
+ response.payload = ::Google::Protobuf::Any.pack(hash[:payload])
21
+ elsif !hash[:data].nil?
22
+ response.data = ::Google::Protobuf::Value.from_ruby(jsonable(hash[:data]))
23
+ end
24
+
25
+ (hash[:errors] || {}).each do |field, messages|
26
+ response.errors << ::Respondo::V1::FieldError.new(field: field.to_s,
27
+ messages: Array(messages).map(&:to_s))
28
+ end
29
+
30
+ response.meta = meta_to_proto(hash[:meta] || {})
31
+ response
32
+ end
33
+
34
+ # @param response [Respondo::V1::Response]
35
+ # @return [Hash] { success:, message:, data:, payload:, errors:, meta: }
36
+ # `data` comes back as plain Ruby (String keys; every number is a Float,
37
+ # because google.protobuf.Value has a single number type). `payload` stays a
38
+ # Google::Protobuf::Any — call `.unpack(MyMessage)` on it.
39
+ def to_h(response)
40
+ hash = { success: response.success, message: response.message }
41
+ hash[:data] = response.data.to_ruby(true) if response.has_data?
42
+ hash[:payload] = response.payload if response.has_payload?
43
+
44
+ errors = response.errors.each_with_object({}) do |error, memo|
45
+ memo[error.field.to_sym] = error.messages.to_a
46
+ end
47
+ hash[:errors] = errors unless errors.empty?
48
+ hash[:meta] = meta_to_h(response.meta) if response.has_meta?
49
+ hash
50
+ end
51
+
52
+ # Wire bytes of the message — what goes into the trailing metadata.
53
+ def encode(response)
54
+ ::Respondo::V1::Response.encode(response)
55
+ end
56
+
57
+ def decode(bytes)
58
+ ::Respondo::V1::Response.decode(bytes)
59
+ end
60
+
61
+ def meta_to_proto(meta)
62
+ meta = meta.dup
63
+ attrs = {}
64
+
65
+ attrs[:request_id] = meta.delete(:request_id).to_s if meta.key?(:request_id)
66
+ attrs[:timestamp] = meta.delete(:timestamp).to_s if meta.key?(:timestamp)
67
+ attrs[:code] = meta.delete(:code).to_i if meta.key?(:code)
68
+ attrs[:status] = meta.delete(:status).to_s if meta.key?(:status)
69
+ attrs[:grpc_code] = meta.delete(:grpc_code).to_i if meta.key?(:grpc_code)
70
+ attrs[:error_code] = meta.delete(:error_code).to_s if meta.key?(:error_code)
71
+
72
+ pagination = meta.delete(:pagination)
73
+ attrs[:pagination] = pagination_to_proto(pagination) if pagination.is_a?(Hash)
74
+
75
+ proto = ::Respondo::V1::Meta.new(**attrs)
76
+ proto.extra = ::Google::Protobuf::Struct.from_hash(jsonable(meta)) unless meta.empty?
77
+ proto
78
+ end
79
+
80
+ def meta_to_h(meta)
81
+ hash = {}
82
+ hash[:request_id] = meta.request_id unless meta.request_id.empty?
83
+ hash[:timestamp] = meta.timestamp unless meta.timestamp.empty?
84
+ hash[:code] = meta.code unless meta.code.zero?
85
+ hash[:status] = meta.status unless meta.status.empty?
86
+ hash[:grpc_code] = meta.grpc_code unless meta.grpc_code.zero?
87
+ hash[:error_code] = meta.error_code unless meta.error_code.empty?
88
+ hash[:pagination] = pagination_to_h(meta.pagination) if meta.has_pagination?
89
+ hash[:extra] = meta.extra.to_h if meta.has_extra?
90
+ hash
91
+ end
92
+
93
+ def pagination_to_proto(pagination)
94
+ values = PAGINATION_KEYS.each_with_object({}) do |key, memo|
95
+ value = pagination[key] || pagination[key.to_s]
96
+ memo[key] = value.to_i unless value.nil?
97
+ end
98
+ ::Respondo::V1::Pagination.new(**values)
99
+ end
100
+
101
+ def pagination_to_h(pagination)
102
+ hash = {
103
+ current_page: pagination.current_page,
104
+ per_page: pagination.per_page,
105
+ total_pages: pagination.total_pages,
106
+ total_count: pagination.total_count
107
+ }
108
+ hash[:next_page] = pagination.next_page if pagination.has_next_page?
109
+ hash[:prev_page] = pagination.prev_page if pagination.has_prev_page?
110
+ hash
111
+ end
112
+
113
+ # google.protobuf.Value accepts only nil, true/false, Numeric, String, Array
114
+ # and Hash (String keys). Normalize anything else that can show up in a
115
+ # payload: Symbols, Times, Dates, BigDecimals, ...
116
+ def jsonable(object)
117
+ case object
118
+ when nil, true, false, String, Integer, Float then object
119
+ when Hash then object.each_with_object({}) { |(key, value), memo| memo[key.to_s] = jsonable(value) }
120
+ when Array then object.map { |item| jsonable(item) }
121
+ when Symbol then object.to_s
122
+ when Time then object.utc.iso8601
123
+ else
124
+ if object.respond_to?(:iso8601) then object.iso8601
125
+ elsif defined?(::BigDecimal) && object.is_a?(::BigDecimal) then object.to_f
126
+ elsif object.respond_to?(:to_h) then jsonable(object.to_h)
127
+ else object.to_s
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RespondoGrpc
6
+ # Builds the standardized response as a plain Hash. ProtoMapper converts it
7
+ # into the respondo.v1.Response protobuf message.
8
+ #
9
+ # {
10
+ # success: Boolean,
11
+ # message: String,
12
+ # data: Object | Array | nil, # JSON-like payload
13
+ # payload: <protobuf message>, # only when a protobuf message was passed as data
14
+ # errors: { field: ["msg", ...] }, # only when present
15
+ # meta: { request_id:, timestamp:, ...extra, pagination:, code:, status:, grpc_code:, error_code: }
16
+ # }
17
+ #
18
+ # A protobuf message passed as `data` is kept as-is (and later packed into a
19
+ # google.protobuf.Any) instead of being flattened into a Hash, so clients can
20
+ # unpack it into a real class.
21
+ class ResponseBuilder
22
+ # @param success [Boolean]
23
+ # @param data [Object] anything — serialized automatically
24
+ # @param message [String]
25
+ # @param meta [Hash] caller-supplied extra meta (merged in)
26
+ # @param errors [Hash] field-level errors, e.g. { email: ["is invalid"] }
27
+ # @param pagination [Hash, nil] e.g. { current_page: 1, per_page: 25, total_pages: 4,
28
+ # total_count: 100, next_page: 2, prev_page: nil }
29
+ # @param call [Object] the gRPC call (for the request id in its metadata)
30
+ def initialize(success:, data: nil, message: nil, meta: {}, errors: nil,
31
+ pagination: nil, call: nil)
32
+ @success = success
33
+ @raw_data = data
34
+ @message = message
35
+ @extra_meta = meta || {}
36
+ @errors = errors
37
+ @pagination = pagination
38
+ @call = call
39
+ end
40
+
41
+ # @return [Hash] the complete response payload
42
+ def build
43
+ payload = { success: @success, message: resolve_message }
44
+
45
+ if typed_payload?(@raw_data)
46
+ payload[:data] = nil
47
+ payload[:payload] = @raw_data
48
+ else
49
+ payload[:data] = Serializer.call(@raw_data)
50
+ end
51
+
52
+ payload[:errors] = @errors if @errors.is_a?(Hash) && !@errors.empty?
53
+ payload[:meta] = build_meta
54
+ payload
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_message
60
+ return @message if @message && !@message.empty?
61
+
62
+ @success ? RespondoGrpc.config.default_success_message : RespondoGrpc.config.default_error_message
63
+ end
64
+
65
+ def typed_payload?(object)
66
+ defined?(::Google::Protobuf::MessageExts) && object.is_a?(::Google::Protobuf::MessageExts)
67
+ end
68
+
69
+ def build_meta
70
+ config = RespondoGrpc.config
71
+ meta = {}
72
+
73
+ # 1. Request ID first (opt-in)
74
+ id = request_id
75
+ meta[:request_id] = id if config.include_request_id && id
76
+
77
+ # 2. Timestamp second
78
+ meta[:timestamp] = current_timestamp
79
+
80
+ # 3. Global defaults (lowest priority), then caller-supplied meta
81
+ meta.merge!(config.default_meta)
82
+ meta.merge!(@extra_meta)
83
+
84
+ # 4. Pagination — plain hash from the caller
85
+ meta[:pagination] = @pagination if @pagination.is_a?(Hash) && !@pagination.empty?
86
+
87
+ # 5. Re-pin the status fields to the very end
88
+ %i[code status grpc_code error_code].each do |key|
89
+ meta[key] = meta.delete(key) if meta.key?(key)
90
+ end
91
+
92
+ meta
93
+ end
94
+
95
+ # The id the client sent in its request metadata (x-request-id by default).
96
+ def request_id
97
+ return nil unless @call.respond_to?(:metadata)
98
+
99
+ metadata = @call.metadata
100
+ return nil unless metadata.respond_to?(:[])
101
+
102
+ key = RespondoGrpc.config.request_id_key.to_s
103
+ metadata[key] || metadata[key.to_sym]
104
+ end
105
+
106
+ def current_timestamp
107
+ if Time.respond_to?(:current)
108
+ Time.current.utc.iso8601
109
+ else
110
+ Time.now.utc.iso8601
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RespondoGrpc
4
+ # Responsible for converting any Ruby object into a plain Hash / Array / primitive.
5
+ # (ProtoMapper later turns the result into a google.protobuf.Value.)
6
+ #
7
+ # Priority order:
8
+ # 1. Custom serializer from RespondoGrpc.config (if set)
9
+ # 2. ActiveRecord::Relation
10
+ # 3. Arrays — each element is serialized recursively
11
+ # 4. ActiveRecord::Base
12
+ # 5. ActiveModel::Errors
13
+ # 6. Exceptions
14
+ # 7. Hashes — values are serialized recursively
15
+ # 8. Objects responding to #as_json or #to_h
16
+ # 9. Primitives — returned as-is
17
+ module Serializer
18
+ module_function
19
+
20
+ # @param object [Object] anything — AR model, collection, error, hash, array, primitive
21
+ # @return [Hash, Array, String, Numeric, nil]
22
+ def call(object)
23
+ return nil if object.nil?
24
+
25
+ # 1. Custom serializer wins
26
+ return RespondoGrpc.config.serializer.call(object) if RespondoGrpc.config.serializer
27
+
28
+ # 2. ActiveRecord::Relation (lazy collection)
29
+ return object.map { |record| serialize_record(record) } if ar_relation?(object)
30
+
31
+ # 3. Array — recurse
32
+ return object.map { |item| call(item) } if object.is_a?(Array)
33
+
34
+ # 4. ActiveRecord model instance
35
+ return serialize_record(object) if ar_model?(object)
36
+
37
+ # 5. ActiveModel::Errors object
38
+ return serialize_errors(object) if active_model_errors?(object)
39
+
40
+ # 6. Exception / StandardError
41
+ return { message: object.message } if object.is_a?(Exception)
42
+
43
+ # 7. Hash — recursively serialize values
44
+ return object.transform_values { |value| call(value) } if object.is_a?(Hash)
45
+
46
+ # 8. Responds to #as_json (ActiveSupport), then #to_h
47
+ return object.as_json if object.respond_to?(:as_json)
48
+ return object.to_h if object.respond_to?(:to_h)
49
+
50
+ # 9. Primitive — return as-is
51
+ object
52
+ end
53
+
54
+ def ar_relation?(object)
55
+ defined?(ActiveRecord::Relation) && object.is_a?(ActiveRecord::Relation)
56
+ end
57
+
58
+ def ar_model?(object)
59
+ defined?(ActiveRecord::Base) && object.is_a?(ActiveRecord::Base)
60
+ end
61
+
62
+ def active_model_errors?(object)
63
+ defined?(ActiveModel::Errors) && object.is_a?(ActiveModel::Errors)
64
+ end
65
+
66
+ def serialize_record(record)
67
+ if record.respond_to?(:as_json)
68
+ record.as_json
69
+ elsif record.respond_to?(:to_h)
70
+ record.to_h
71
+ else
72
+ record
73
+ end
74
+ end
75
+
76
+ def serialize_errors(errors)
77
+ if errors.respond_to?(:to_hash)
78
+ errors.to_hash
79
+ elsif errors.respond_to?(:messages)
80
+ errors.messages
81
+ else
82
+ {}
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RespondoGrpc
4
+ # Mixed into a gRPC service class to answer every rpc with the respondo.v1.Response
5
+ # envelope, using expressive, status-aware helpers.
6
+ #
7
+ # class UserService < Example::Users::V1::UserService::Service
8
+ # include RespondoGrpc::ServiceHelpers
9
+ #
10
+ # def get_user(request, call)
11
+ # user = User.find_by(id: request.id)
12
+ # return render_grpc_not_found(message: "User not found", call: call) unless user
13
+ #
14
+ # render_grpc_ok(data: user, message: "User fetched", call: call)
15
+ # end
16
+ # end
17
+ #
18
+ # Success helpers RETURN the Response message — make the call the last
19
+ # expression of the rpc method.
20
+ #
21
+ # Error helpers RAISE GRPC::BadStatus with the mapped gRPC status code (NOT_FOUND,
22
+ # INVALID_ARGUMENT, ...) and the envelope in the trailing metadata, unless
23
+ # config.always_ok is true, in which case they return the envelope as a normal
24
+ # reply. Writing `return render_grpc_not_found(...)` works in both modes.
25
+ #
26
+ # Every helper is prefixed (render_grpc_*) so this module can sit next to the
27
+ # respondo (JSON) and respondo-graphql helpers without name clashes.
28
+ #
29
+ # Success helpers (2xx) — accept data:, message:, meta:, pagination:, call:
30
+ # render_grpc_ok, render_grpc_created, render_grpc_accepted, render_grpc_non_authoritative,
31
+ # render_grpc_partial_content, render_grpc_multi_status, render_grpc_already_reported,
32
+ # render_grpc_im_used
33
+ #
34
+ # Success helpers (2xx) without a payload — accept message:, meta:, pagination:, call:
35
+ # render_grpc_no_content, render_grpc_reset_content
36
+ #
37
+ # Error helpers (4xx / 5xx) — accept message:, errors:, meta:, call:
38
+ # render_grpc_bad_request, render_grpc_unauthorized, render_grpc_payment_required,
39
+ # render_grpc_forbidden, render_grpc_not_found, render_grpc_method_not_allowed,
40
+ # render_grpc_not_acceptable, render_grpc_proxy_auth_required, render_grpc_request_timeout,
41
+ # render_grpc_conflict, render_grpc_gone, render_grpc_length_required,
42
+ # render_grpc_precondition_failed, render_grpc_payload_too_large, render_grpc_uri_too_long,
43
+ # render_grpc_unsupported_media_type, render_grpc_range_not_satisfiable,
44
+ # render_grpc_expectation_failed, render_grpc_im_a_teapot, render_grpc_misdirected_request,
45
+ # render_grpc_unprocessable, render_grpc_locked, render_grpc_failed_dependency,
46
+ # render_grpc_too_early, render_grpc_upgrade_required, render_grpc_precondition_required,
47
+ # render_grpc_too_many_requests, render_grpc_request_header_fields_too_large,
48
+ # render_grpc_unavailable_for_legal_reasons, render_grpc_server_error,
49
+ # render_grpc_not_implemented, render_grpc_bad_gateway, render_grpc_service_unavailable,
50
+ # render_grpc_gateway_timeout, render_grpc_http_version_not_supported,
51
+ # render_grpc_variant_also_negotiates, render_grpc_insufficient_storage,
52
+ # render_grpc_loop_detected, render_grpc_not_extended,
53
+ # render_grpc_network_authentication_required
54
+ #
55
+ # 1xx (informational) and 3xx (redirect) helpers are intentionally not
56
+ # provided: they are HTTP transport concepts with no meaning in gRPC.
57
+ module ServiceHelpers
58
+ # name => { code: HTTP-style code, status: status name, message: default message, data: takes data? }
59
+ SUCCESS_HELPERS = {
60
+ ok: { code: 200, status: :ok, message: nil, data: true },
61
+ created: { code: 201, status: :created, message: "Created successfully", data: true },
62
+ accepted: { code: 202, status: :accepted, message: "Request accepted and is being processed", data: true },
63
+ non_authoritative: { code: 203, status: :non_authoritative_information, message: "Non-authoritative information", data: true },
64
+ no_content: { code: 204, status: :no_content, message: "Deleted successfully", data: false },
65
+ reset_content: { code: 205, status: :reset_content, message: "Reset content", data: false },
66
+ partial_content: { code: 206, status: :partial_content, message: "Partial content returned", data: true },
67
+ multi_status: { code: 207, status: :multi_status, message: "Multi-status response", data: true },
68
+ already_reported: { code: 208, status: :already_reported, message: "Already reported", data: true },
69
+ im_used: { code: 226, status: :im_used, message: "IM used", data: true }
70
+ }.freeze
71
+
72
+ # name => { code: HTTP-style code, status: status name, message: default message }
73
+ ERROR_HELPERS = {
74
+ bad_request: { code: 400, status: :bad_request, message: "Bad request" },
75
+ unauthorized: { code: 401, status: :unauthorized, message: "Unauthorized" },
76
+ payment_required: { code: 402, status: :payment_required, message: "Payment required to access this resource" },
77
+ forbidden: { code: 403, status: :forbidden, message: "You do not have permission to perform this action" },
78
+ not_found: { code: 404, status: :not_found, message: "Resource not found" },
79
+ method_not_allowed: { code: 405, status: :method_not_allowed, message: "Method not allowed" },
80
+ not_acceptable: { code: 406, status: :not_acceptable, message: "Requested format not acceptable" },
81
+ proxy_auth_required: { code: 407, status: :proxy_authentication_required, message: "Proxy authentication required" },
82
+ request_timeout: { code: 408, status: :request_timeout, message: "Request timed out" },
83
+ conflict: { code: 409, status: :conflict, message: "Resource conflict" },
84
+ gone: { code: 410, status: :gone, message: "Resource no longer available" },
85
+ length_required: { code: 411, status: :length_required, message: "Content length required" },
86
+ precondition_failed: { code: 412, status: :precondition_failed, message: "Precondition failed" },
87
+ payload_too_large: { code: 413, status: :payload_too_large, message: "Payload too large" },
88
+ uri_too_long: { code: 414, status: :uri_too_long, message: "URI too long" },
89
+ unsupported_media_type: { code: 415, status: :unsupported_media_type, message: "Unsupported media type" },
90
+ range_not_satisfiable: { code: 416, status: :range_not_satisfiable, message: "Range not satisfiable" },
91
+ expectation_failed: { code: 417, status: :expectation_failed, message: "Expectation failed" },
92
+ im_a_teapot: { code: 418, status: :im_a_teapot, message: "I'm a teapot" },
93
+ misdirected_request: { code: 421, status: :misdirected_request, message: "Misdirected request" },
94
+ unprocessable: { code: 422, status: :unprocessable_content, message: "Validation failed" },
95
+ locked: { code: 423, status: :locked, message: "Resource is locked" },
96
+ failed_dependency: { code: 424, status: :failed_dependency, message: "Failed dependency" },
97
+ too_early: { code: 425, status: :too_early, message: "Too early" },
98
+ upgrade_required: { code: 426, status: :upgrade_required, message: "Upgrade required" },
99
+ precondition_required: { code: 428, status: :precondition_required, message: "Precondition required" },
100
+ too_many_requests: { code: 429, status: :too_many_requests, message: "Too many requests. Please slow down." },
101
+ request_header_fields_too_large: { code: 431, status: :request_header_fields_too_large, message: "Request header fields too large" },
102
+ unavailable_for_legal_reasons: { code: 451, status: :unavailable_for_legal_reasons, message: "Unavailable for legal reasons" },
103
+ server_error: { code: 500, status: :internal_server_error, message: "An unexpected error occurred" },
104
+ not_implemented: { code: 501, status: :not_implemented, message: "This feature is not yet implemented" },
105
+ bad_gateway: { code: 502, status: :bad_gateway, message: "Bad gateway — upstream service error" },
106
+ service_unavailable: { code: 503, status: :service_unavailable, message: "Service temporarily unavailable" },
107
+ gateway_timeout: { code: 504, status: :gateway_timeout, message: "Gateway timeout — upstream service did not respond" },
108
+ http_version_not_supported: { code: 505, status: :http_version_not_supported, message: "Version not supported" },
109
+ variant_also_negotiates: { code: 506, status: :variant_also_negotiates, message: "Variant also negotiates" },
110
+ insufficient_storage: { code: 507, status: :insufficient_storage, message: "Insufficient storage" },
111
+ loop_detected: { code: 508, status: :loop_detected, message: "Loop detected" },
112
+ not_extended: { code: 510, status: :not_extended, message: "Not extended" },
113
+ network_authentication_required: { code: 511, status: :network_authentication_required, message: "Network authentication required" }
114
+ }.freeze
115
+
116
+ # status name => HTTP-style code, so render_grpc_error(status: :not_found) resolves to 404
117
+ HTTP_CODE_BY_STATUS = ERROR_HELPERS.each_value.with_object({}) do |spec, memo|
118
+ memo[spec[:status]] = spec[:code]
119
+ end.freeze
120
+
121
+ SUCCESS_HELPERS.each do |name, spec|
122
+ default_message = spec[:message]
123
+
124
+ if spec[:data]
125
+ # e.g. render_grpc_ok(data: @user, message: "User fetched", meta: {}, pagination: nil, call: call)
126
+ define_method(:"render_grpc_#{name}") do |data: nil, message: default_message, meta: {}, pagination: nil, call: nil|
127
+ render_grpc_success(data: data, message: message, meta: meta, pagination: pagination,
128
+ code: spec[:code], status: spec[:status], call: call)
129
+ end
130
+ else
131
+ # e.g. render_grpc_no_content(message: "Deleted", meta: {}, pagination: nil, call: call)
132
+ define_method(:"render_grpc_#{name}") do |message: default_message, meta: {}, pagination: nil, call: nil|
133
+ render_grpc_success(data: nil, message: message, meta: meta, pagination: pagination,
134
+ code: spec[:code], status: spec[:status], call: call)
135
+ end
136
+ end
137
+ end
138
+
139
+ ERROR_HELPERS.each do |name, spec|
140
+ default_message = spec[:message]
141
+
142
+ # e.g. render_grpc_not_found(message: "User not found", errors: nil, meta: {}, call: call)
143
+ define_method(:"render_grpc_#{name}") do |message: default_message, errors: nil, meta: {}, call: nil|
144
+ render_grpc_error(message: message, errors: errors, meta: meta,
145
+ code: spec[:code], status: spec[:status], call: call)
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ # =========================================================================
152
+ # Core DSL — every helper delegates to these two
153
+ # =========================================================================
154
+
155
+ # Build a successful envelope.
156
+ #
157
+ # @param data [Object] payload — AR model, collection, Hash, Array, protobuf message, nil
158
+ # @param message [String] human-readable description
159
+ # @param meta [Hash] extra fields merged into meta
160
+ # @param pagination [Hash, nil] pagination hash built by the caller
161
+ # @param code [Integer, nil] HTTP-style code recorded in meta (200, 201, ...)
162
+ # @param status [Symbol] status name recorded in meta
163
+ # @param call [Object] the rpc's call argument (for the request id)
164
+ # @return [Respondo::V1::Response]
165
+ def render_grpc_success(data: nil, message: nil, meta: {}, code: nil, pagination: nil, status: :ok, call: nil)
166
+ merged_meta = code ? meta.merge(code: code, status: status.to_s) : meta
167
+
168
+ hash = ResponseBuilder.new(
169
+ success: true,
170
+ data: data,
171
+ message: message,
172
+ meta: merged_meta,
173
+ pagination: pagination,
174
+ call: call
175
+ ).build
176
+
177
+ ProtoMapper.to_proto(hash)
178
+ end
179
+
180
+ # Build a failed envelope and finish the call with the mapped gRPC status.
181
+ #
182
+ # @param message [String] human-readable error description
183
+ # @param errors [Hash, ActiveModel::Errors] field-level errors
184
+ # @param code [Integer, String, nil] HTTP-style code (Integer) or machine-readable code (String)
185
+ # @param meta [Hash] extra fields merged into meta
186
+ # @param status [Symbol, Integer] status name (default :unprocessable_content) or HTTP-style code
187
+ # @param call [Object] the rpc's call argument (for the request id)
188
+ # @raise [GRPC::BadStatus] unless config.always_ok, in which case the envelope is returned
189
+ # @return [Respondo::V1::Response]
190
+ def render_grpc_error(message: nil, errors: nil, code: nil, meta: {}, status: :unprocessable_content, call: nil)
191
+ http_code = respondo_grpc_http_code(code, status)
192
+ grpc_code = StatusMap.grpc_code_for(http_code)
193
+
194
+ status_meta = { code: http_code, status: status.to_s, grpc_code: grpc_code }
195
+ status_meta[:error_code] = code if code.is_a?(String) && !code.empty?
196
+
197
+ hash = ResponseBuilder.new(
198
+ success: false,
199
+ data: nil,
200
+ message: message,
201
+ meta: meta.merge(status_meta),
202
+ errors: extract_grpc_errors(errors),
203
+ call: call
204
+ ).build
205
+
206
+ response = ProtoMapper.to_proto(hash)
207
+ return response if RespondoGrpc.config.always_ok
208
+
209
+ raise RespondoGrpc.bad_status(grpc_code, hash[:message], RespondoGrpc::METADATA_KEY => ProtoMapper.encode(response))
210
+ end
211
+
212
+ # An Integer code wins, then an Integer status, then the status name's
213
+ # HTTP-style code; 500 when nothing matches.
214
+ def respondo_grpc_http_code(code, status)
215
+ return code if code.is_a?(Integer)
216
+ return status if status.is_a?(Integer)
217
+
218
+ HTTP_CODE_BY_STATUS.fetch(status.to_sym, 500)
219
+ end
220
+
221
+ # Normalize errors into a plain Hash regardless of source type.
222
+ def extract_grpc_errors(errors)
223
+ return nil if errors.nil?
224
+ return errors.to_hash if defined?(ActiveModel::Errors) && errors.is_a?(ActiveModel::Errors)
225
+ return errors if errors.is_a?(Hash)
226
+ return { base: errors } if errors.is_a?(Array)
227
+ return { base: [errors] } if errors.is_a?(String)
228
+
229
+ nil
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RespondoGrpc
4
+ # Short names: `render_ok`, `render_not_found`, ... Each one simply calls its
5
+ # render_grpc_* twin.
6
+ #
7
+ # Opt-in per service. The names are the same ones the respondo (JSON) gem uses
8
+ # in controllers, so they are never mixed in automatically:
9
+ #
10
+ # class UserService < Example::Users::V1::UserService::Service
11
+ # include RespondoGrpc::ShortHelpers
12
+ #
13
+ # def get_user(request, call)
14
+ # render_ok(data: User.find(request.id), call: call) # same as render_grpc_ok
15
+ # end
16
+ # end
17
+ module ShortHelpers
18
+ def self.included(base)
19
+ base.include(ServiceHelpers)
20
+ return unless defined?(::Respondo::ControllerHelpers) && base.ancestors.include?(::Respondo::ControllerHelpers)
21
+
22
+ warn "[respondo-grpc] #{base} includes RespondoGrpc::ShortHelpers while respondo's JSON helpers " \
23
+ "are available: render_ok & co. now answer in gRPC in this class. " \
24
+ "Use render_grpc_* everywhere if it also needs JSON."
25
+ end
26
+
27
+ (ServiceHelpers::SUCCESS_HELPERS.keys + ServiceHelpers::ERROR_HELPERS.keys).each do |name|
28
+ define_method(:"render_#{name}") do |**options|
29
+ public_send(:"render_grpc_#{name}", **options)
30
+ end
31
+ end
32
+ end
33
+ end