clients, 300Mb-500Mbps traf c • Dashboard: https://grafana.rubykaigi.net/ • Hashtag: #rubykaigiNOC fi Claude just joined our team to constantly monitor and remedy the things…
writing user-facing Rails • after years of "what is this person even doing for work", my previous job included • Login, session management, an OAuth authorisation server, authorisation, and so on • Hiring: SaaS Control Plane engineers, Security engineers • Authentication, authorization, billing, contract management, etc…
the rst time in a while • Split out of the monolith, anticipating going multi-product • Also an exploration of our baseline development style from now on, infra included • OpenAPI stands in the way • Our existing monolith Rails app shares its schema with the frontend and mobile apps via OpenAPI fi • Even if an LLM writes it for you, nobody wants to review this
admin - shopItem description: | Updates the display SKU of a shop's item. Used to link the storefront SKU for items whose warehouse code differs from the storefront SKU. operationId: updateShopItemForAdmin security: - Bearer: [] parameters: - in: path name: shop_id schema: type: integer required: true description: Shop ID - in: path name: id schema: type: integer
description: | SKU shown on the storefront. Not updated when omitted. An empty string or null unlinks it. example: "RK-2026-0001" responses: "200": description: ok content: application/json: schema: $ref: "../../../components/ShopItemForAdmin.yaml#/components/schemas/ShopItemForAdmin" "400": description: Validation error content: application/json: schema: $ref: "../../../components/ValidationError.yaml#/components/schemas/ValidationError" "404": description: Resource not found content: application/json: schema: Nobody wants to review this, right?
the display SKU of a shop's item. // Used to link the storefront SKU for items whose warehouse code differs from the storefront SKU. rpc UpdateShopItem(UpdateShopItemRequest) returns (ShopItemForAdmin) { option (google.api.http) = { patch: "/admin/shops/{shop_id}/items/{id}" body: "body" }; option (gnostic.openapi.v3.operation) = {tags: ["admin", "shopItem"]}; } } message UpdateShopItemRequest { int64 shop_id = 1 [(buf.validate.field).int64.gt = 0]; // Shop ID int64 id = 2 [(buf.validate.field).int64.gt = 0]; // Item ID Body body = 3 [(buf.validate.field).required = true]; message Body { // SKU shown on the storefront. // Not updated when omitted. An empty string unlinks it. optional string display_sku = 1 [(buf.validate.field).string = { max_len: 32
shop's item. // Used to link the storefront SKU for items whose warehouse code differs from the storefront SKU. rpc UpdateShopItem(UpdateShopItemRequest) returns (ShopItemForAdmin) { option (google.api.http) = { patch: "/admin/shops/{shop_id}/items/{id}" body: "body" }; option (gnostic.openapi.v3.operation) = {tags: ["admin", "shopItem"]}; } } The same API in Protobuf message UpdateShopItemRequest { int64 shop_id = 1 [(buf.validate.field).int64.gt = 0]; // Shop ID int64 id = 2 [(buf.validate.field).int64.gt = 0]; // Item ID Body body = 3 [(buf.validate.field).required = true]; message Body { // SKU shown on the storefront. // Not updated when omitted. An empty string unlinks it. optional string display_sku = 1 [(buf.validate.field).string = { max_len: 32 pattern: "^([A-Z0-9-]+)?$" }]; } }
project is an IAM platform where a system I want to write robustly, so I want types. However I want Rails too, as our team is used to & for exibility. • It has plenty of an ordinary web app's side as well, so writing it all in Rust doesn't t • Considered OpenAPI, Protobuf, Smithy and more, then decided to give Protobuf a go • I've liked Protobuf for a long time • Used it for ISUCON10 too: its portal was a Protobuf-enabled Rails app But this time, I’d like it to be more integrated with Rails. fi fi fi fl • https://github.com/isucon/portal https://github.com/isucon/isucon10- nal
we could get by with OpenAPI • though it's painful to both write and read • A fair number of people generate it through tests, right? • RSwag, Typelizer, and so on • …Isn't the reason you want to generate it that writing it is painful?
a protocol, it's better to reach some agreement with the API consumer side • Then discussing over a hand-written schema works best • But OpenAPI/Swagger YAML is hard for humans to read and write • With LLM-assisted development, schema de nitions matter even more • Handy for conversations like "an API with this schema would be nice, I want something like this” fi fi • In the end, I gave up on auto-generated schema and made Protobuf- rst
• Today's talk is about that Rails app • Excluding con g, Rails APIs and kwargs-ish ones, apparently only around 50 • About 350 for Rails APIs fi • About 300 constructions of Data classes and protobuf messages • 180 message types, 78 Data classes, 0 Structs • LOC 17071, 968 methods
implementing all of topics covered today on sponsorships.rubykaigi.org (vibe coded) • No time for a detailed demo, though… • Code excerpts sometimes omit part of module names 0w0.co/kor26
language and its serialisation format • Made by Google, Open-sourced in 2008. • Around long before gRPC (2015) • De ne messages in .proto, generate code, and use it • The binary representation is small. Notably, eld names aren't in fi fi fi fi it; it uses the tag numbers from the schema de nition instead
also call external executables to generate additional code • Such as actual API client/server codes like gRPC fi • rbs_out, nally merged recently, can output RBS too! • Though Tapioca's Protobuf support is more powerful
id = 1; string name = 2; // Display name, such as "Ruby" or "Platinum". int32 rank = 3; optional string summary = 4; // One line describing the plan. int32 capacity = 8; optional google.protobuf.Timestamp closes_at = 14; }
protobuf is a typed data format • so Serialization/Deserialization is sort of its main job • Who has used serialize? The one that turns things into YAML or JSON • serialize :…, coder: JSON • Ever been frustrated by not having types?
or JSON? may be a better deal since you can use SQL functions and such # binary column serialize :settings, coder: Protobufable::BinaryCoder.new(Rk::FallbackOptions) # json column attribute :settings, Protobufable::JsonType.new(Rk::FallbackOptions), default: -> { Rk::FallbackOptions.new }
handy enough for internal use, ActiveRecord models included, but Protobuf is a data interchange format. • Let's use it for standard REST APIs implemented in Rails • Protobuf isn’t gRPC exclusive!
a frontend codebase that established OpenAPI based work ow, our Rails app has to accept OpenAPIshaped API requests. • I didn’t think it’s ef cient to change everything at the same time, thus I explored how we can provide openapi.yml from Protobuf- rst app. • There are multiple protoc plugins to emit OpenAPI schema from fl fi fi fi Proto les.
OpenAPI v3 support fi fi fi fl • Protobuf’s option is very powerful feature • You can attach annotation data to les, messages and elds • json_name mentioned earlier is one of the built-in options • gnostic uses options to inject OpenAPI-speci c data
which didn't match our existing API design • For that part, we patch protoc's output and use the result as the nal openapi.yml • And nally, the generated schema is veri ed for correctness in request specs fi fi fi fi • https://github.com/ahx/openapi_ rst
Protobuf, sharing between systems comes with a lot of hassle • Typical approaches • monorepo: has monorepo-speci c pains, but seems handy and easy fi • package (gem): releases and versioning are a hassle • submodule: pulling and updating are both a pain
had a schema repo sitting in between of frontend and backend repositories • And we still have. • Getting the schema reviewed and merged rst, then hitting rework on the backend side and waiting for review again: that kind of hassle tends to happen • Having to nish the merge on the schema side and update the submodule… before the backend side can merge is tough • Ideally, the frontend and backend of the same system seem happier in the fi fi same repo
Rails app, we established a mechanism that sends pull requests to the dependent repos when openapi.yml or proto is updated • Hint: Obtain GitHub Apps installation tokens for crossing repos in GitHub Actions with ID tokens • https://github.com/sorah/sts-cat allows safely exchanges ID tokens for installation tokens!
so we can tell what to copy • Whether to commit or not seems to be a matter of style • Commit: works right after clone, so fewer steps to get it running. Also usable to decide what to copy to other repos • Don't commit: diffs bloat fi fl • CI work ow runs all generate tasks and ensure there’s no diff
linguist-vendored marks les as vendored, • linguist-generated marks les as generated • They're collapsed by default in PR diffs, so set them when doing fi fi this kind of thing
repo itself, we plan to lean towards copying into the schema repo and each repo • There's also the Buf Schema Registry from Buf, so that might be an option once protos pile up
can interop with OpenAPI-expecting API clients. • However, just generating a openapi.yml is no different from merely having an openapi.yml • We need to properly use generated Protobuf message classes in the actual API implementation
body into the protobuf message class class ThingsController < … speci ed for the action and expose via include Protobufable::RequestParseable message helper protobuf_body Rk::PatchThingRequest • Also copied into params[*], so logging def update & lter_attributes just work p message #=> #<Rk::PatchThingRequest> # ... • Annotation is saved to a controller ivar. end end • ActionDispatch::Request is monkey fi fi patched to hijack params_parser.
serializer, but making it emit Protobuf instead of JSON • Use Alba's attribute declarations and such inside the with_protobuf helper • Error when a nonexistent eld is speci ed • Error when a eld is missing • Nesting with one, many DSL just works as usual fi fi fi • You use as_protobuf, instead of as_json and to_json.
in the RFC 9457 Problem Details format and provides a concern for Exception to de ne error types • Not Protobuf-aware by itself class Error < StandardError include Problem::Detailable end class NotAllowed < Error type "forbidden" status 403 title "not allowed" end fi class ApplicationController < … include Problem::Rescuable def index = raise NotAllowed end
to the Problem gem to give API consumers an error schema •The app's proto de nitions hold errors as an enum, and enum value options set type and status •This allows API consumers to understand error types in advance, e.g. help generating error classes at client side. •Also this bene ts the API schema review, because reviewers can take a look at added error types at the same time. •And for security reasons, APIs can choose the same error type across multiple error types to hide a actual reason. But internal logs can tell them apart by the enum value. fi fi fi fi Since we ended up adding Connect RPC in the end anyway, the theory that google.rpc.Status everywhere from the start would've been ne, well, isn't entirely wrong…
the Ruby side just point at an enum value module Errors class ApiError < StandardError include Protobufable::Problem::Typeable def self.problem_type_enum = 'rubykaigi.sponsor_app.ProblemType' def self.problem_type_annotation = 'rubykaigi.sponsor_app.problem_type' end class NotFound < ApiError problem_type :PROBLEM_TYPE_NOT_FOUND # type and status is loaded from Protobuf enum descriptor end end raise Errors::NotFound.new(detail: "No conference has the slug #{slug.inspect}.") # => {type: "https://sponsorships.rubykaigi.org/problems/not-found", status: 404}
Array due to crafted requests • Every Rails developer must have seen this kind of error • Validating parameters seriously in Rails is a hassle, or rather, it happens because it's a hassle def update @item.update!(name: params[:item][:name].strip) end # {"item": null} # {"item": {"name": 1}} # {"item": []} => NoMethodError: undefined method '[]' for nil => NoMethodError: undefined method 'strip' for an instance of Integer => TypeError: no implicit conversion of Symbol into Integer
of *.proto schema of an extension, and runtime validator • De ne validation rules on messages and elds as options • Rules are attached to the schema, so not only API producers but API consumers can use it too • Complex rules can be written in CEL fi fi • CEL: a rule language made by Google
a CEL expression • Runtimes that evaluate built-in-only rules fast without CEL are starting to appear, so those seem preferable in general message Condition { option (buf.validate.message).cel = { id: "fallback_option.condition.not_empty" message: "a condition must name plans or a booth request" expression: "size(this.plans) > 0 || has(this.booth_request)" }; } repeated string plans = 1 [(buf.validate.field).repeated = { unique: true items: {string: {min_len: 1, max_len: 100}} }]; optional bool booth_request = 2;
shim) • This approach is a copy of protovalidate-py, but -py has ended up carrying a Rust implementation inside • That's the move to the fast runtime that skips CEL where CEL isn't needed, mentioned earlier
with Protovalidate on its own and returns errors • Behaviour is customisable by rescue_from-ing InvalidMessage • By default, 400 through config.action_dispatch.rescue_responses
render status: :bad_request, json: {errors: e.violations.map(&:to_s)} end protobuf_body Condition def create # Requests reach here guaranteed to satisfy validation rule end end # {} => 400 {"errors": ["a condition must name plans or a booth request [fallback_option.condition.not_empty]”]} # {"plans": ["Gold", "Gold", ""]} # => 400 {"errors": [ # "plans: repeated value must contain unique items [repeated.unique]", # "plans[2]: must be at least 1 characters [string.min_len]”]}
of moments where RPC would do instead of REST • With Connect RPC or gRPC, you can use plenty of excellent client libraries and their code generators • I want ActionController, but it doesn't have to be REST • With only what the grpc gem provides, there are none of the handy features, before_action included • If we could keep the ActionController feel and reuse assets like concerns that REST APIs use too… that'd be great!
• Generates client and server stub code from the proto service de nition • Exchanges Protobuf binaries over HTTP/2 • Has a wide variety of built-in features, streaming APIs included • HTTP/2-based, but carrying the status in an HTTP Trailer is a distinctive trait, or rather its only awkward part • There are various reasons, like the status might change while generating the response • Isn't most of so-called load balancer gRPC support about this? [citation needed] fi • FIXME: restore monospace: service
overkill; I get the protocol, but simple is better” • So pain is not just in Ruby • Simpler than gRPC-Web • Simpli ed down to being as easy as using a regular REST API with curl fi Although connect-go library provides simple gRPC protocol implementation
POST some JSON. • RPCs declared side-effect free can be called with GET too curl -H 'Content-Type: application/json' -H 'Connect-Protocol-Version: 1' \ -d '{"conferenceSlug": "rubykaigi2048"}' \ http://localhost:3000/rubykaigi.sponsor_app.AdminService/GetConference curl 'http://localhost:3000/rubykaigi.sponsor_app.AdminService/GetConference' \ '?connect=v1&encoding=json&message=%7B%22conferenceSlug%22%3A%22rubykaigi2048%22%7D'
should be about 1 server 1 service • because code generation adds that many client classes • The whole Rails app is 1 service, but rpcs can be scattered across controllers # config/routes.rb connect_service 'rubykaigi.sponsor_app.AdminService' do rpc 'ListConferences' => 'api/admin/conferences' rpc 'GetConference' => 'api/admin/conferences' rpc 'UpdateConference' => 'api/admin/conferences' rpc 'GetPlan' => 'api/admin/plans' end
gives you the request as a protobuf message. • Protovalidate is supported too! class Api::Admin::ConferencesController < Api::Admin::ApplicationController def update_conference conference = Conference.find_by!(slug: message.conference_slug) save_input!(conference, connect_request.input) Rk::AdminServiceUpdateConferenceResponse.new( conference: admin_conference_message(conference) ) end end
Protobufable and ConnectRpcRails bring typed request params • But "within this action, the message helper returns this" can't be expressed in RBS or RBI…. • It's hard to express that message is this type in the call stack under def index • # self as ... & ... or m = message #: ... is messy because same type name is repeatedly shown • Helper methods can be called from multiple actions, so they can be ignored fi in the rst place. But we can’t even narrow down only within an action
inferred as Foo|Bar, not Foo # so we need annotation, repetitive! msg = message #: Foo end # Clean but can’t put this in RBI/RBS so Tapioca cannot help protobuf_body Bar def neh #: self as TheController & ProtobufBody[Bar] message # => Bar end end
is a C++ binding, and schemas are managed by registering messages in a process-global descriptor pool • Code generated by ruby_out also just has a binary string written in it, feeds that to google-protobuf, pulls Protobuf message classes out of the native extension and stuffs them into constants • This is what doesn't allow hot reload…… • Putting up with it using watchexec
running protoc by hand. It’s a hard way • Use buf instead • It manages schema dependencies too • well known types, common types; • schemas used by other Protobuf-related libraries; • and even your internal ones (with appropriate registry setup)
out-of-the-box • such as Timestamp, Duration and Any • Whilst these aren’t a primitive type, but Protobuf language bindings often have type converters into each language's corresponding types • Though Google::Protobuf::Timestamp conversion is not AS::TimeWithZone aware…
to camelCase without option • Some WKTs have a JSON representation too • Timestamp: RFC 3339 • Duration: “90s” • Also handy for parsing JSON from outside while type-checking it AdminPlan.encode_json(plan) #=> ‘{“name":"Ruby", # "closesAt":"2026-10-15T10:00:00Z"}' AdminPlan.encode_json( plan, preserve_proto_fieldnames: true) #=> ‘{"name":"Ruby", "closes_at":"2026-10-15T10:00:00Z"}' AdminPlan.decode_json('{"name": 1}') #=> Google::Protobuf::ParseError: Error parsing JSON # @1:10: Expected string
converted to JSON in your API or for storing data, you may not be able to safely rename elds or enums at all. fi fi • Acceptable as long as you don't rename elds
ne or Struct.new? I do. • Data class vs Protobuf message • Data: can take any objects. Immutable. But no inline typing support. • Protobuf: take only supported types, but assigning a wrong type errors immediately. • It's pretty handy as a dataclass replacement • and Protobuf wins once SerDe is required. • Types are nice, but properly de ned keys alone are already a win. A nonexistent fi fi attribute properly raises NoMethodError……
values become 0 or "", right? • A. Actually there's optional these days • but default values are less error prone than nil, which is better • even with optional, it returns the default value and only tells you whether the value is set • # Hash: nil when the key is missing hash[:summary] #=> nil # Protobuf: gets default value even with optional plan = Rk::SponsorApp::AdminPlan.new plan.summary #=> "" plan.has_summary? #=> false * Protobuf Editions, the version after proto3, is out and makes the optional equivalent the default (2023 onwards), but toolchain support may not be great yet)
• Something like Google's API style guide • It comes from the company where Protocol Buffers and gRPC originated, so it's easy to take as a reference • It's full of guidelines that are useful as plain API design theory, more than about how to use Protobuf • And https://github.com/googleapis has lots of .proto les for public APIs fi like Google Cloud, worth a look too.
use as a schema de nition language • The Protobufable gem brings Protobuf types everywhere, from ActiveRecord json columns to REST API requests/responses, errors and validation • With gnostic and the like, interop with OpenAPI clients is possible too • We could also implement Connect RPC by making use of ActionController and the Rails ecosystem • Acknowledgements: this work includes plenty of ideas from my colleague @draftcode fi • AD: We’re hiring! Come visit RubyMusicMixin tonight 8pm. • Questions? Either JA/EN ok
use as a schema de nition language • The Protobufable gem brings Protobuf types everywhere, from ActiveRecord json columns to REST API requests/responses, errors and validation • With gnostic and the like, interop with OpenAPI clients is possible too • We could also implement Connect RPC by making use of ActionController and the Rails ecosystem • Acknowledgements: this work includes plenty of ideas from my colleague @draftcode fi • AD: We’re hiring! Come visit RubyMusicMixin tonight 8pm. • Questions? Either JA/EN ok