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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +201 -0
- data/README.md +200 -0
- data/lib/respondo/v1/response_pb.rb +23 -0
- data/lib/respondo-grpc.rb +5 -0
- data/lib/respondo_grpc/client_helpers.rb +54 -0
- data/lib/respondo_grpc/configuration.rb +58 -0
- data/lib/respondo_grpc/proto_mapper.rb +132 -0
- data/lib/respondo_grpc/response_builder.rb +114 -0
- data/lib/respondo_grpc/serializer.rb +86 -0
- data/lib/respondo_grpc/service_helpers.rb +232 -0
- data/lib/respondo_grpc/short_helpers.rb +33 -0
- data/lib/respondo_grpc/status_map.rb +110 -0
- data/lib/respondo_grpc/version.rb +5 -0
- data/lib/respondo_grpc.rb +74 -0
- data/proto/respondo/v1/response.proto +78 -0
- data/respondo-grpc.gemspec +45 -0
- metadata +159 -0
|
@@ -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
|