Skip to content

Server: Creating Controllers

Tom Arbesser edited this page Sep 18, 2022 · 8 revisions

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.

Custom Field Errors

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?

Setting Debug Info

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

Next: Running Gruf and Command Line Options