Skip to content

About

Protobuf power on Rails

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

35 Commits

Folders and files

Repository files navigation

protobufable: Protocol Buffers as the schema of record for a Rails API

protobufable makes generated protobuf message classes the shape of a Rails application's data, on the way in and on the way out. A JSON request body is decoded straight into the message its action declares, so a wrong type is a 400 before the action runs and the action reads a typed object instead of params. A jsonb column holds a message whose shape is declared in a .proto. Errors take their type and status from a protobuf enum. Responses are encoded by the schema's own encoder.

Installing the gem switches on only the proto_json and protobuf renderers and the request body parser, which leaves a request alone unless its action declares protobuf_body. Every other piece is a concern you include into the classes that want it, and it acts on those classes only; each section below starts from that setup. The gem's only hard dependencies are google-protobuf and Rails; the integrations with problem, protovalidate, alba and connect_rpc_rails are separate requires, and none of those gems is a dependency.

class WidgetsController < ApplicationController
  include Protobufable::RequestParseable

  protobuf_body Api::CreateWidgetRequest
  def create
    widget = Widget.create!(name: message.name)
    render proto_json: Api::CreateWidgetResponse.new(widget: WidgetResource.new(widget).as_protobuf)
  end
end
$ curl -sS -X POST -H 'Content-Type: application/json' -d '{"name": 42}' https://api.example.com/v1/widgets
HTTP/1.1 400 Bad Request

Features

Request bodies

protobuf_body declares the message an action accepts. The JSON body is decoded into it before the action runs, so a wrong type or an unknown field is a 400 and the action reads a typed message instead of params. See docs/request-bodies.md.

class WidgetsController < ApplicationController
  include Protobufable::RequestParseable

  protobuf_body Api::CreateWidgetRequest
  def create
    Widget.create!(name: message.name, tags: message.tags.to_a)
  end
end

Responses

render proto_json: and render protobuf: encode a message with the schema's own encoder, in any controller. An Alba resource bound to a message fails at load time when the two disagree, rather than silently sending an empty field. See docs/responses.md.

require "protobufable/alba_binding"

class WidgetResource < ApplicationResource
  include Protobufable::AlbaBinding

  with_protobuf Api::Widget do   # raises at load time unless it matches Api::Widget
    attributes :id, :name
  end
end

render proto_json: Api::GetWidgetResponse.new(widget: WidgetResource.new(widget).as_protobuf)

Messages in a column

Protobufable::JsonType keeps a message in a json or jsonb column, declared per attribute. It is an ActiveModel type, not a serialize coder, so dirty tracking and casting behave. For a text or binary column, Protobufable::JsonCoder and Protobufable::BinaryCoder are coders for serialize. See docs/columns.md.

class Widget < ApplicationRecord
  attribute :settings, Protobufable::JsonType.new(Internal::WidgetSettings),
    default: -> { Internal::WidgetSettings.new }
end

Errors

An error catalogue in the schema, built on problem: type and status come from an enum value's annotation, so two errors needing different statuses are different types, and a typo in the catalogue fails on boot. See docs/errors.md.

enum ProblemType {
  PROBLEM_TYPE_UNSPECIFIED = 0;
  PROBLEM_TYPE_NOT_FOUND = 1 [(problem_type) = {uri: "not-found", http_status: 404}];
}
require "protobufable/problem"

class ApiError < StandardError
  include Protobufable::Problem::Typeable

  def self.problem_type_enum = "myapp.api.ProblemType"
  def self.problem_type_annotation = "myapp.api.problem_type"
end

class NotFound < ApiError
  problem_type :PROBLEM_TYPE_NOT_FOUND
end

class ApplicationController < ActionController::API
  include Problem::Rescuable   # from problem; renders them as application/problem+json
end

raise NotFound.new(detail: "no widget w_123")   # 404, "type": "not-found"

Value validation

buf.validate rules run on request bodies and on stored columns, each opted into separately. A request breaking a rule is a 400 before the action runs. See docs/validation.md, which also covers reporting the failed fields with field paths in the spelling the caller sent.

message CreateWidgetRequest {
  string name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 100}];
}
require "protobufable/protovalidate"

class WidgetsController < ApplicationController
  include Protobufable::RequestValidatable   # brings RequestParseable

  protobuf_body Api::CreateWidgetRequest
  def create = ...
end

class ApplicationRecord < ActiveRecord::Base
  include Protobufable::ColumnValidatable
end

Connect RPC

A problem raised in a Connect RPC goes out as a Connect error carrying the same document, so a caller dispatches on the same type over REST and Connect. See docs/connect.md.

require "protobufable/connect"

class WidgetsController < ActionController::API
  include ConnectRpcRails::Controller
  include Protobufable::Connect::ProblemRescuable   # after ConnectRpcRails::Controller
end
{"code": "not_found", "message": "no widget w_123",
 "details": [{"type": "protobufable.ProblemDetails", "value": "..."}]}

Typed

RBS signatures ship with the gem, and the suite type checks under Steep.

Requirements

  • Ruby 3.4 or later
  • Rails 7.1 or later
  • google-protobuf 4.26 or later

Optional dependencies are not installed for you. Add the ones for the features you use:

Installation

bundle add protobufable

Rails wires itself up through a railtie, which registers the renderers, installs the request body parser, and lists a broken validation rule as a 400 in rescue_responses. Outside Rails, call Protobufable.install! at boot.

Caveats

  • protovalidate is a prerelease (0.1.0.beta3 at the time of writing).
  • Editing a .proto does not hot-reload. google-protobuf raises on a duplicate descriptor definition, so the development loop is to restart on regeneration.

Caveats specific to a feature are on its page, such as vendoring the shipped .proto files in docs/errors.md.

Development

bundle install
bundle exec rake      # specs, then rbs and steep
bundle exec yard      # docs
hk check --all        # rubocop, actionlint, zizmor
mise run protoc       # regenerate the spec fixtures

See also

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/protobufable.

License

The gem is available as open source under the terms of the MIT License. Copyright (c) 2026 Sorah Fukumori.

About

Protobuf power on Rails

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages