-
Notifications
You must be signed in to change notification settings - Fork 73
Server: Creating Controllers
Once you have your generated server stubs, you can bind them to controllers in your Ruby application.
First, create a app/rpc
directory in your application. This path is configurable, but Gruf by default looks here for gRPC controllers. Note that Gruf (as of 2.15+) uses zeitwerk for managing the Gruf controller directory to allow for fast autoloading and reloading in dev. This means that you should adhere to Zeitwerk's file structure standards for creating your controllers.
Using our example from the Generating Stubs page, we've got a Demo::Jobs::Service
we want to bind to a new Demo::JobsController
, located at app/rpc/demo/jobs_controller.rb
:
module Demo
class JobsController < ::Gruf::Controllers::Base
bind ::Demo::Jobs::Service
##
# @return [Demo::GetJobResp] The job response
#
def get_job
job = ::Job.find(request.message.id)
Demo::GetJobResp.new(id: job.id)
rescue ActiveRecord::RecordNotFound => _e
fail!(:not_found, :job_not_found, "Failed to find Job with ID: #{request.message.id}")
rescue StandardError => e
set_debug_info(e.message, e.backtrace[0..4])
fail!(:internal, :internal, "ERROR: #{e.message}")
end
end
end
This creates a fully functional gRPC server method. Let's break it down a bit.
First off, you can see we're binding the new server stub to the new JobsController class. This class extends Gruf::Controllers::Base
, which is the base abstract controller that does a lot of the magic under the covers:
class JobsController < ::Gruf::Controllers::Base
bind ::Demo::Jobs::Service
From there, we define a get_job
method - using a Ruby-friendly underscore naming style to match the GetJob
method we defined in our protobuf.
def get_job
end
The method automatically has exposed a request
object - this is the Gruf::Controllers::Request
object that has a bunch of utility methods on it for helping you handle the incoming request. The most relevant here, of course, is the incoming RPC message, which we can get to via request.message
. As per our protobuf definition, we're looking for a id
attribute on the message by which we'll find our Job:
def get_job
job = ::Job.find(request.message.id)
end
Note here we're assuming we have a Job
ActiveRecord class that we're using to lookup the model. We recommend not exposing ActiveRecord models to controllers and using entity/repository and hexagonal architecture patterns, but for this example its fine. From there, we'll return in the method with our RPC response message:
def get_job
job = ::Job.find(request.message.id)
Demo::GetJobResp.new(id: job.id)
end
gRPC methods require a message object to always be returned, unless you are raising a GRPC::BadStatus
exception. Gruf will handle the exception raising (more on that in a second), so for our intents and purposes, we want to make sure we're always returning our required response object here.
This gives us a nice happy path for the GetJob call. But what happens if the job isn't found? We can handle that:
def get_job
job = ::Job.find(request.message.id)
Demo::GetJobResp.new(id: job.id)
rescue ActiveRecord::RecordNotFound => _e
fail!(:not_found, :job_not_found, "Failed to find Job with ID: #{request.message.id}")
rescue StandardError => e
set_debug_info(e.message, e.backtrace[0..4])
fail!(:internal, :internal, "ERROR: #{e.message}")
end
Note a new helper method: fail!
. This method takes in a few arguments: first, a symbol mapping to the GRPC Status code to fail with. The second argument is a custom argument as a "app error code" that can be used to allow calling services to react programmatically to different types of failures. Finally, the third is the message to return with the error.
Since gRPC is a request/response RPC protocol, you don't send the errors back into the response object. Instead, you send BadStatus codes back to the calling client, which takes that in as exceptions. gRPC, however, allows you to attach "trailing metadata" to a BadStatus response. Gruf uses this to serialize errors into that trailing metadata, allowing for clients to deserialize the error payload and get more detailed error information (rather than just a status code).
This error is normally serialized via JSON, but you can implement your own serialization method (such as serializing the error into protobuf) to standardize the error payloads your systems return. Just extend the Gruf::Serializers::Errors::Base
class, and set the serializer via the config.error_serializer=
attribute in an initializer. A good use case for this is creating an Error
protobuf message, and using a serializer that serializes the Error object into that message, so you can have consistent, contractual, language-agnostic errors across your various systems.
With the ability to attach more context to errors, gruf allows you to return custom field errors as well:
add_field_error(:email, :invalid_email, 'Email must be a valid email address!')
You can then check for the existence of errors on the current request:
fail!(:invalid_argument) if has_field_errors?
You can also set more debugging info in error responses, which can be useful for detailing distributed errors:
begin
DoBadThing.call
rescue StandardError => e
set_debug_info(e.message, e.backtrace)
end