Skip to content

Repository files navigation

protoc-utils

A Kotlin library for building protoc compiler plugins. It wraps the raw DescriptorProto types generated by protobuf-java with a richer API that surfaces source comments, accurate source locations, and type-safe extension option access — the three things every non-trivial plugin needs and which the raw protobuf API makes difficult.


Installation

Released artifacts are published to Maven Central.

Gradle (Kotlin DSL):

dependencies {
    implementation("com.engine:protoc-utils:<version>")
}

Maven:

<dependency>
    <groupId>com.engine</groupId>
    <artifactId>protoc-utils</artifactId>
    <version>VERSION</version>
</dependency>

The companion protoc-utils-recorder plugin is published as multi-platform native binaries under the same group; see recorder/README.md for how to consume those.


Why this exists

When protoc invokes a plugin it sends a CodeGeneratorRequest on stdin. The request contains FileDescriptorProto objects that describe every .proto file that was compiled. The raw protobuf-java API for these types is mechanical and low-level:

  • Comments are buried in SourceCodeInfo.Location records indexed by an opaque integer path. Correlating a descriptor with its comment requires walking that path manually.
  • Extension options (e.g. google.api.http) are only accessible if the right ExtensionRegistry was provided at parse time. If they weren't registered you get raw UnknownFields bytes with no typed API.
  • Every interesting piece of data (name, inputType, …) is detached from its source location, so producing good diagnostics or doc generation requires a lot of bookkeeping.

protoc-utils solves this by wrapping every descriptor type in a thin class that keeps value and location together and exposes a consistent comment API regardless of comment style.


Core concepts

SyntaxElement<T>

Every scalar field on a descriptor (name, field number, type name, …) is exposed as a SyntaxElement<T>. It pairs the typed value with its SourceCodeInfo.Location, which carries the leading, trailing, and leading-detached comments written in the source file.

val nameElement: SyntaxElement<String>? = service.name

// typed value
val name: String? = nameElement?.value                         // "ExampleService"

// source comments
val comment: String? = nameElement?.location?.leadingComments?.cleaned

Comment

Comment is a data class with two fields:

Field Description
raw Exactly what protoc put in SourceCodeInfo.Location.leading_comments
cleaned The comment text with fencing characters (//, /*, *, etc.) stripped

The cleaned form is what you almost always want when generating documentation or OpenAPI descriptions.

Extension options

Wrapper classes for *Options messages (MethodOptionsWrapper, ServiceOptionsWrapper, etc.) extend AbstractExtendableMessageWrapper, which exposes findExtension().


Traversing gRPC services and extracting google.api.http options

The following example assumes you have parsed a CodeGeneratorRequest using the recorder tool (see recorder/README.md) and placed the resulting code-generator-request.binpb on the test classpath.

Given a .proto file like this:

// Provides access to parent resources.
service ExampleService {
    // Fetches a parent by ID or by path parameter.
    rpc GetParent(GetParentRequest) returns (Parent) {
        option (google.api.http) = {
            post: "/parent"
            body: "*"
            additional_bindings: [{ get: "/example/{id}" }]
        };
    };

    rpc GetChild(GetChildRequest) returns (Child) {
        option (google.api.http) = {
            post: "/child"
            body: "*"
        };
    }
}

Parse the request and traverse it:

import com.engine.protoc.util.extensions.wrap
import com.google.api.AnnotationsProto
import com.google.protobuf.ExtensionRegistry
import com.google.protobuf.compiler.PluginProtos

// Register every extension your .proto files use.
// google.api.http lives in AnnotationsProto (proto-google-common-protos).
val registry = ExtensionRegistry.newInstance().apply {
    AnnotationsProto.registerAllExtensions(this)
}

val cgreq = checkNotNull(javaClass.getResourceAsStream("/code-generator-request.binpb"))
    .use { PluginProtos.CodeGeneratorRequest.parseFrom(it, registry) }
    .wrap()                                     // CodeGeneratorRequestWrapper

for (file in cgreq.sourceFileDescriptors) {
    for (service in file.services) {            // List<ServiceDescriptorProtoWrapper>
        // service-level comment and name
        val serviceComment = service.location?.leadingComments?.cleaned
        val serviceName    = service.name?.value

        println("service $serviceName // $serviceComment")

        for (method in service.methods) {       // List<MethodDescriptorProtoWrapper>
            val methodName    = method.name?.value
            val methodComment = method.location?.leadingComments?.cleaned
            val inputType     = method.inputType?.value     // e.g. ".GetParentRequest"
            val outputType    = method.outputType?.value    // e.g. ".Parent"

            // google.api.http option — null if the method has no HTTP binding
            val http = method.options?.findExtension(AnnotationsProto.http)

            println("  rpc $methodName($inputType) returns ($outputType)")
            println("    comment:  $methodComment")
            println("    http rule: $http")
        }
    }
}

Sample output for the proto above:

service ExampleService // Provides access to parent resources.
  rpc GetParent(.GetParentRequest) returns (.Parent)
    comment:  Fetches a parent by ID or by path parameter.
    http rule: post: "/parent"  body: "*"  additional_bindings { get: "/example/{id}" }
  rpc GetChild(.GetChildRequest) returns (.Child)
    comment:  null
    http rule: post: "/child"  body: "*"

Accessing additional HTTP bindings

google.api.HttpRule itself contains additionalBindings for methods that map to multiple HTTP routes:

val http = method.options?.findExtension(AnnotationsProto.http) ?: return

// primary binding
println("primary: ${http.post.ifEmpty { http.get }}")

// additional bindings (e.g. alternative URL patterns)
for (binding in http.additionalBindingsList) {
    println("  also: ${binding.get.ifEmpty { binding.post }}")
}

Reading extension options that were not registered at parse time

If an extension was not added to the ExtensionRegistry before parsing, it ends up in the message's unknownFields. The wrapper's findExtension returns null in that case, but you can still decode it:

import com.engine.protoc.util.extensions.findUnregisteredExtension

// returns null — not in registry
method.options?.findExtension(MyExtensions.myMethodOption)

// decodes from unknownFields instead
method.options?.proto?.findUnregisteredExtension(MyExtensions.myMethodOption)

Key wrapper types

Wrapper Wraps
CodeGeneratorRequestWrapper PluginProtos.CodeGeneratorRequest
FileDescriptorProtoWrapper DescriptorProtos.FileDescriptorProto
ServiceDescriptorProtoWrapper DescriptorProtos.ServiceDescriptorProto
MethodDescriptorProtoWrapper DescriptorProtos.MethodDescriptorProto
MethodOptionsWrapper DescriptorProtos.MethodOptions
ServiceOptionsWrapper DescriptorProtos.ServiceOptions
DescriptorProtoWrapper DescriptorProtos.DescriptorProto (message)
FieldDescriptorProtoWrapper DescriptorProtos.FieldDescriptorProto
EnumDescriptorProtoWrapper DescriptorProtos.EnumDescriptorProto
EnumValueDescriptorProtoWrapper DescriptorProtos.EnumValueDescriptorProto

All wrappers expose .location: LocationWrapper? for comment access. Wrappers for *Options messages additionally expose findExtension() for type-safe option retrieval.


Testing a plugin

See recorder/README.md for how to use the companion protoc-utils-recorder plugin to capture a CodeGeneratorRequest as a binary fixture, and how to load and replay it in unit tests without requiring a protoc installation at test time.

About

Library and utilities to support the construction of Protobuf Compiler Plugins

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages