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.
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.
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.Locationrecords 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 rightExtensionRegistrywas provided at parse time. If they weren't registered you get rawUnknownFieldsbytes 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.
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?.cleanedComment 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.
Wrapper classes for *Options messages (MethodOptionsWrapper, ServiceOptionsWrapper, etc.) extend AbstractExtendableMessageWrapper, which exposes findExtension().
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: "*"
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 }}")
}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)| 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.
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.