From 6e44effc82f8f678f8f69be51a89eba699ecd1c8 Mon Sep 17 00:00:00 2001 From: bufdev <4228796+bufdev@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:28:56 +0100 Subject: [PATCH] Convert Request and ResponseWriter to interfaces (#10) --- README.md | 8 +- env.go | 8 +- handler.go | 16 +- .../protoc-gen-protogen-simple/main.go | 6 +- .../protoc-gen-protoreflect-simple/main.go | 6 +- internal/examples/protoc-gen-simple/main.go | 6 +- protoplugin.go | 57 ++-- protoplugin_test.go | 12 +- request.go | 173 +++++++------ response_writer.go | 244 +++++++++++------- 10 files changed, 302 insertions(+), 234 deletions(-) diff --git a/README.md b/README.md index da6c350..4ad0004 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ func main() { func handle( _ context.Context, - _ *protoplugin.HandlerEnv, - responseWriter *protoplugin.ResponseWriter, - request *protoplugin.Request, + _ protoplugin.PluginEnv, + responseWriter protoplugin.ResponseWriter, + request protoplugin.Request, ) error { // Set the flag indicating that we support proto3 optionals. We don't even use them in this // plugin, but protoc will error if it encounters a proto3 file with an optional but the @@ -193,7 +193,7 @@ Errors or warnings will also be produced if: a duplicate name is plugin authoring issue, and here at Buf, we've seen a lot of plugins have this issue! - Any file path is not cleaned. -By default, these are errors, however if `WithLenientResponseValidation` is set, these will be warnings. +By default, these are errors, however if `WithLenientValidation` is set, these will be warnings. ## What this library is not diff --git a/env.go b/env.go index b2edcec..7a601c1 100644 --- a/env.go +++ b/env.go @@ -16,7 +16,7 @@ package protoplugin import "io" -// Env represents an environment for a plugin to run within. +// Env represents an environment. // // This wraps items like args, environment variables, and stdio. // @@ -37,13 +37,13 @@ type Env struct { Stderr io.Writer } -// HandlerEnv represents an environment that a Handler is run within. +// PluginEnv represents an environment that a plugin is run within. // -// This provides the environment variables and stderr to a Handler. A Handler should not have +// This provides the environment variables and stderr. A plugin implementation should not have // access to stdin, stdout, or the args, as these are controlled by the plugin framework. // // When calling Main, this uses the values os.Environ and os.Stderr. -type HandlerEnv struct { +type PluginEnv struct { // Environment are the environment variables. Environ []string // Stderr is the stderr for the plugin. diff --git a/handler.go b/handler.go index e2d86f4..3efa555 100644 --- a/handler.go +++ b/handler.go @@ -44,21 +44,21 @@ type Handler interface { // (for example, a missing option), this error should be added to the response via SetError. Handle( ctx context.Context, - handlerEnv *HandlerEnv, - responseWriter *ResponseWriter, - request *Request, + pluginEnv PluginEnv, + responseWriter ResponseWriter, + request Request, ) error } // HandlerFunc is a function that implements Handler. -type HandlerFunc func(context.Context, *HandlerEnv, *ResponseWriter, *Request) error +type HandlerFunc func(context.Context, PluginEnv, ResponseWriter, Request) error // Handle implements Handler. func (h HandlerFunc) Handle( ctx context.Context, - handlerEnv *HandlerEnv, - responseWriter *ResponseWriter, - request *Request, + pluginEnv PluginEnv, + responseWriter ResponseWriter, + request Request, ) error { - return h(ctx, handlerEnv, responseWriter, request) + return h(ctx, pluginEnv, responseWriter, request) } diff --git a/internal/examples/protoc-gen-protogen-simple/main.go b/internal/examples/protoc-gen-protogen-simple/main.go index d810ec4..77bbb3d 100644 --- a/internal/examples/protoc-gen-protogen-simple/main.go +++ b/internal/examples/protoc-gen-protogen-simple/main.go @@ -36,9 +36,9 @@ func main() { func handle( _ context.Context, - _ *protoplugin.HandlerEnv, - responseWriter *protoplugin.ResponseWriter, - request *protoplugin.Request, + _ protoplugin.PluginEnv, + responseWriter protoplugin.ResponseWriter, + request protoplugin.Request, ) error { plugin, err := protogen.Options{}.New(request.CodeGeneratorRequest()) if err != nil { diff --git a/internal/examples/protoc-gen-protoreflect-simple/main.go b/internal/examples/protoc-gen-protoreflect-simple/main.go index 7ddab87..ba8d739 100644 --- a/internal/examples/protoc-gen-protoreflect-simple/main.go +++ b/internal/examples/protoc-gen-protoreflect-simple/main.go @@ -38,9 +38,9 @@ func main() { func handle( _ context.Context, - _ *protoplugin.HandlerEnv, - responseWriter *protoplugin.ResponseWriter, - request *protoplugin.Request, + _ protoplugin.PluginEnv, + responseWriter protoplugin.ResponseWriter, + request protoplugin.Request, ) error { // Set the flag indicating that we support proto3 optionals. We don't even use them in this // plugin, but protoc will error if it encounters a proto3 file with an optional but the diff --git a/internal/examples/protoc-gen-simple/main.go b/internal/examples/protoc-gen-simple/main.go index eb7d36f..f05d410 100644 --- a/internal/examples/protoc-gen-simple/main.go +++ b/internal/examples/protoc-gen-simple/main.go @@ -34,9 +34,9 @@ func main() { func handle( _ context.Context, - _ *protoplugin.HandlerEnv, - responseWriter *protoplugin.ResponseWriter, - request *protoplugin.Request, + _ protoplugin.PluginEnv, + responseWriter protoplugin.ResponseWriter, + request protoplugin.Request, ) error { // Set the flag indicating that we support proto3 optionals. We don't even use them in this // plugin, but protoc will error if it encounters a proto3 file with an optional but the diff --git a/protoplugin.go b/protoplugin.go index be58a94..77c627f 100644 --- a/protoplugin.go +++ b/protoplugin.go @@ -30,7 +30,7 @@ import ( var ( // osEnv is the os-based Env used in Main. - osEnv = &Env{ + osEnv = Env{ Args: os.Args[1:], Environ: os.Environ(), Stdin: os.Stdin, @@ -77,7 +77,7 @@ func Main(handler Handler, options ...MainOption) { // when writing plugin tests, or if you want to use your own custom logic for main functions. func Run( ctx context.Context, - env *Env, + env Env, handler Handler, options ...RunOption, ) error { @@ -114,8 +114,8 @@ func WithVersion(version string) RunOption { }) } -// WithLenientResponseValidation returns a new GenerateOption that says handle non-critical issues with response -// construction as warnings that will be handled by the given warning handler. +// WithLenientValidation returns a new RunOption that says handle non-critical issues +// as warnings that will be handled by the given warning handler. // // This allows the following issues to result in warnings instead of errors: // @@ -137,11 +137,11 @@ func WithVersion(version string) RunOption { // // The default is to error on these issues. // -// Implementers of lenientResponseValidationErrorFunc can assume that errors passed will be non-nil and have non-empty +// Implementers of lenientValidationErrorFunc can assume that errors passed will be non-nil and have non-empty // values for err.Error(). -func WithLenientResponseValidation(lenientResponseValidateErrorFunc func(error)) RunOption { +func WithLenientValidation(lenientValidateErrorFunc func(error)) RunOption { return optsFunc(func(opts *opts) { - opts.lenientResponseValidateErrorFunc = lenientResponseValidateErrorFunc + opts.lenientValidateErrorFunc = lenientValidateErrorFunc }) } @@ -149,7 +149,7 @@ func WithLenientResponseValidation(lenientResponseValidateErrorFunc func(error)) func run( ctx context.Context, - env *Env, + env Env, handler Handler, opts *opts, ) error { @@ -173,16 +173,23 @@ func run( if err := proto.Unmarshal(input, codeGeneratorRequest); err != nil { return err } - codeGeneratorResponse, err := generate( + request, err := NewRequest(codeGeneratorRequest) + if err != nil { + return err + } + responseWriter := NewResponseWriter(ResponseWriterWithLenientValidation(opts.lenientValidateErrorFunc)) + if err := handler.Handle( ctx, - &HandlerEnv{ + PluginEnv{ Environ: env.Environ, Stderr: env.Stderr, }, - codeGeneratorRequest, - handler, - opts, - ) + responseWriter, + request, + ); err != nil { + return err + } + codeGeneratorResponse, err := responseWriter.ToCodeGeneratorResponse() if err != nil { return err } @@ -194,24 +201,6 @@ func run( return err } -func generate( - ctx context.Context, - handlerEnv *HandlerEnv, - codeGeneratorRequest *pluginpb.CodeGeneratorRequest, - handler Handler, - opts *opts, -) (*pluginpb.CodeGeneratorResponse, error) { - request, err := newRequest(codeGeneratorRequest) - if err != nil { - return nil, err - } - responseWriter := newResponseWriter(opts.lenientResponseValidateErrorFunc) - if err := handler.Handle(ctx, handlerEnv, responseWriter, request); err != nil { - return nil, err - } - return responseWriter.toCodeGeneratorResponse() -} - // withCancelInterruptSignal returns a context that is cancelled if interrupt signals are sent. func withCancelInterruptSignal(ctx context.Context) (context.Context, context.CancelFunc) { interruptSignalC, closer := newInterruptSignalChannel() @@ -237,8 +226,8 @@ func newInterruptSignalChannel() (<-chan os.Signal, func()) { } type opts struct { - version string - lenientResponseValidateErrorFunc func(error) + version string + lenientValidateErrorFunc func(error) } func newOpts() *opts { diff --git a/protoplugin_test.go b/protoplugin_test.go index 596c4d2..aab6965 100644 --- a/protoplugin_test.go +++ b/protoplugin_test.go @@ -47,9 +47,9 @@ func TestBasic(t *testing.T) { HandlerFunc( func( ctx context.Context, - handlerEnv *HandlerEnv, - responseWriter *ResponseWriter, - request *Request, + pluginEnv PluginEnv, + responseWriter ResponseWriter, + request Request, ) error { for _, fileDescriptorProto := range request.FileDescriptorProtosToGenerate() { topLevelMessageNames := make([]string, len(fileDescriptorProto.GetMessageType())) @@ -77,14 +77,14 @@ func TestWithVersionOption(t *testing.T) { stdout := bytes.NewBuffer(nil) err := Run( context.Background(), - &Env{ + Env{ Args: args, Environ: nil, Stdin: iotest.ErrReader(io.EOF), Stdout: stdout, Stderr: io.Discard, }, - HandlerFunc(func(ctx context.Context, _ *HandlerEnv, _ *ResponseWriter, _ *Request) error { return nil }), + HandlerFunc(func(ctx context.Context, _ PluginEnv, _ ResponseWriter, _ Request) error { return nil }), runOptions..., ) return stdout.String(), err @@ -129,7 +129,7 @@ func testBasic( err = Run( ctx, - &Env{ + Env{ Args: nil, Environ: nil, Stdin: stdin, diff --git a/request.go b/request.go index 2fc3721..a96a9ad 100644 --- a/request.go +++ b/request.go @@ -27,7 +27,89 @@ import ( ) // Request wraps a CodeGeneratorRequest. -type Request struct { +// +// Request contains a private method to ensure that it is not constructed outside this package, to +// enable us to modify the Request interface in the future without breaking compatibility. +type Request interface { + // Parameter returns the value of the parameter field on the CodeGeneratorRequest. + Parameter() string + // FileDescriptorsToGenerate returns the FileDescriptors for the files specified by the + // file_to_generate field on the CodeGeneratorRequest. + // + // The caller can assume that all FileDescriptors have a valid path as the name field. + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, + // and have `.proto` as the file extension. + FileDescriptorsToGenerate() ([]protoreflect.FileDescriptor, error) + // AllFiles returns the a Files registry for all files in the CodeGeneratorRequest. + // + // This matches with the proto_file field on the CodeGeneratorRequest, with the FileDescriptorProtos + // from the source_file_descriptors field used for the files in file_to_geneate if WithSourceRetentionOptions + // is specified. + // + // The caller can assume that all FileDescriptors have a valid path as the name field. + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, + // and have `.proto` as the file extension. + AllFiles() (*protoregistry.Files, error) + // FileDescriptorProtosToGenerate returns the FileDescriptors for the files specified by the + // file_to_generate field. + // + // The caller can assume that all FileDescriptorProtoss have a valid path as the name field. + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, + // and have `.proto` as the file extension. + FileDescriptorProtosToGenerate() []*descriptorpb.FileDescriptorProto + // AllFileDescriptorProtos returns the FileDescriptorProtos for all files in the CodeGeneratorRequest. + // + // This matches with the proto_file field on the CodeGeneratorRequest, with the FileDescriptorProtos + // from the source_file_descriptors field used for the files in file_to_geneate if WithSourceRetentionOptions + // is specified. + // + // The caller can assume that all FileDescriptorProtoss have a valid path as the name field. + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, + // and have `.proto` as the file extension. + AllFileDescriptorProtos() []*descriptorpb.FileDescriptorProto + // CompilerVersion returns the specified compiler_version on the CodeGeneratorRequest. + // + // If the compiler_version field was not present, nil is returned. + // + // The caller can assume that the major, minor, and patch values are non-negative. + CompilerVersion() *CompilerVersion + // CodeGeneratorRequest returns the raw underlying CodeGeneratorRequest. + // + // The returned CodeGeneratorRequest is a not copy - do not modify it! If you would + // like to modify the CodeGeneratorRequest, use proto.Clone to create a copy. + CodeGeneratorRequest() *pluginpb.CodeGeneratorRequest + // WithSourceRetentionOptions will return a copy of the Request that will result in all + // methods returning descriptors with source-retention options retained on files to generate. + // + // By default, only runtime-retention options are included on files to generate. Note that + // source-retention options are always included on files not in file_to_generate. + // + // An error will be returned if the underlying CodeGeneratorRequest did not have source_file_descriptors populated. + WithSourceRetentionOptions() (Request, error) + + isRequest() +} + +// NewRequest returns a new Request for the CodeGeneratorRequest. +// +// The CodeGeneratorRequest will be validated as part of construction. +func NewRequest(codeGeneratorRequest *pluginpb.CodeGeneratorRequest) (Request, error) { + if err := validateCodeGeneratorRequest(codeGeneratorRequest); err != nil { + return nil, err + } + request := &request{ + codeGeneratorRequest: codeGeneratorRequest, + } + request.getFilesToGenerateMap = + sync.OnceValue(request.getFilesToGenerateMapUncached) + request.getSourceFileDescriptorNameToFileDescriptorProtoMap = + sync.OnceValue(request.getSourceFileDescriptorNameToFileDescriptorProtoMapUncached) + return request, nil +} + +// *** PRIVATE *** + +type request struct { codeGeneratorRequest *pluginpb.CodeGeneratorRequest getFilesToGenerateMap func() map[string]struct{} @@ -36,18 +118,11 @@ type Request struct { sourceRetentionOptions bool } -// Parameter returns the value of the parameter field on the CodeGeneratorRequest. -func (r *Request) Parameter() string { +func (r *request) Parameter() string { return r.codeGeneratorRequest.GetParameter() } -// FileDescriptorsToGenerate returns the FileDescriptors for the files specified by the -// file_to_generate field on the CodeGeneratorRequest. -// -// The caller can assume that all FileDescriptors have a valid path as the name field. -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, -// and have `.proto` as the file extension. -func (r *Request) FileDescriptorsToGenerate() ([]protoreflect.FileDescriptor, error) { +func (r *request) FileDescriptorsToGenerate() ([]protoreflect.FileDescriptor, error) { files, err := r.AllFiles() if err != nil { return nil, err @@ -63,26 +138,11 @@ func (r *Request) FileDescriptorsToGenerate() ([]protoreflect.FileDescriptor, er return fileDescriptors, nil } -// AllFiles returns the a Files registry for all files in the CodeGeneratorRequest. -// -// This matches with the proto_file field on the CodeGeneratorRequest, with the FileDescriptorProtos -// from the source_file_descriptors field used for the files in file_to_geneate if WithSourceRetentionOptions -// is specified. -// -// The caller can assume that all FileDescriptors have a valid path as the name field. -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, -// and have `.proto` as the file extension. -func (r *Request) AllFiles() (*protoregistry.Files, error) { +func (r *request) AllFiles() (*protoregistry.Files, error) { return protodesc.NewFiles(&descriptorpb.FileDescriptorSet{File: r.AllFileDescriptorProtos()}) } -// FileDescriptorProtosToGenerate returns the FileDescriptors for the files specified by the -// file_to_generate field. -// -// The caller can assume that all FileDescriptorProtoss have a valid path as the name field. -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, -// and have `.proto` as the file extension. -func (r *Request) FileDescriptorProtosToGenerate() []*descriptorpb.FileDescriptorProto { +func (r *request) FileDescriptorProtosToGenerate() []*descriptorpb.FileDescriptorProto { // If we want source-retention options, source_file_descriptors is all we need. // // We have validated that source_file_descriptors is populated via WithSourceRetentionOptions. @@ -100,16 +160,7 @@ func (r *Request) FileDescriptorProtosToGenerate() []*descriptorpb.FileDescripto return fileDescriptorProtos } -// AllFileDescriptorProtos returns the FileDescriptorProtos for all files in the CodeGeneratorRequest. -// -// This matches with the proto_file field on the CodeGeneratorRequest, with the FileDescriptorProtos -// from the source_file_descriptors field used for the files in file_to_geneate if WithSourceRetentionOptions -// is specified. -// -// The caller can assume that all FileDescriptorProtoss have a valid path as the name field. -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, do not jump context, -// and have `.proto` as the file extension. -func (r *Request) AllFileDescriptorProtos() []*descriptorpb.FileDescriptorProto { +func (r *request) AllFileDescriptorProtos() []*descriptorpb.FileDescriptorProto { // If we do not want source-retention options, proto_file is all we need. if !r.sourceRetentionOptions { return slices.Clone(r.codeGeneratorRequest.GetProtoFile()) @@ -131,12 +182,7 @@ func (r *Request) AllFileDescriptorProtos() []*descriptorpb.FileDescriptorProto return fileDescriptorProtos } -// CompilerVersion returns the specified compiler_version on the CodeGeneratorRequest. -// -// If the compiler_version field was not present, nil is returned. -// -// The caller can assume that the major, minor, and patch values are non-negative. -func (r *Request) CompilerVersion() *CompilerVersion { +func (r *request) CompilerVersion() *CompilerVersion { if version := r.codeGeneratorRequest.GetCompilerVersion(); version != nil { return &CompilerVersion{ Major: int(version.GetMajor()), @@ -148,26 +194,15 @@ func (r *Request) CompilerVersion() *CompilerVersion { return nil } -// CodeGeneratorRequest returns the raw underlying CodeGeneratorRequest. -// -// The returned CodeGeneratorRequest is a not copy - do not modify it! If you would -// like to modify the CodeGeneratorRequest, use proto.Clone to create a copy. -func (r *Request) CodeGeneratorRequest() *pluginpb.CodeGeneratorRequest { +func (r *request) CodeGeneratorRequest() *pluginpb.CodeGeneratorRequest { return r.codeGeneratorRequest } -// WithSourceRetentionOptions will return a copy of the Request that will result in all -// methods returning descriptors with source-retention options retained on files to generate. -// -// By default, only runtime-retention options are included on files to generate. Note that -// source-retention options are always included on files not in file_to_generate. -// -// An error will be returned if the underlying CodeGeneratorRequest did not have source_file_descriptors populated. -func (r *Request) WithSourceRetentionOptions() (*Request, error) { +func (r *request) WithSourceRetentionOptions() (Request, error) { if err := r.validateSourceFileDescriptorsPresent(); err != nil { return nil, err } - return &Request{ + return &request{ codeGeneratorRequest: r.codeGeneratorRequest, getFilesToGenerateMap: r.getFilesToGenerateMap, getSourceFileDescriptorNameToFileDescriptorProtoMap: r.getSourceFileDescriptorNameToFileDescriptorProtoMap, @@ -175,23 +210,7 @@ func (r *Request) WithSourceRetentionOptions() (*Request, error) { }, nil } -// *** PRIVATE *** - -func newRequest(codeGeneratorRequest *pluginpb.CodeGeneratorRequest) (*Request, error) { - if err := validateCodeGeneratorRequest(codeGeneratorRequest); err != nil { - return nil, err - } - request := &Request{ - codeGeneratorRequest: codeGeneratorRequest, - } - request.getFilesToGenerateMap = - sync.OnceValue(request.getFilesToGenerateMapUncached) - request.getSourceFileDescriptorNameToFileDescriptorProtoMap = - sync.OnceValue(request.getSourceFileDescriptorNameToFileDescriptorProtoMapUncached) - return request, nil -} - -func (r *Request) validateSourceFileDescriptorsPresent() error { +func (r *request) validateSourceFileDescriptorsPresent() error { if len(r.codeGeneratorRequest.GetSourceFileDescriptors()) == 0 && len(r.codeGeneratorRequest.GetProtoFile()) > 0 { return errors.New("source_file_descriptors not set on CodeGeneratorRequest but source-retention options requested - you likely need to upgrade your protobuf compiler") @@ -199,7 +218,7 @@ func (r *Request) validateSourceFileDescriptorsPresent() error { return nil } -func (r *Request) getFilesToGenerateMapUncached() map[string]struct{} { +func (r *request) getFilesToGenerateMapUncached() map[string]struct{} { filesToGenerateMap := make( map[string]struct{}, len(r.codeGeneratorRequest.GetFileToGenerate()), @@ -210,7 +229,7 @@ func (r *Request) getFilesToGenerateMapUncached() map[string]struct{} { return filesToGenerateMap } -func (r *Request) getSourceFileDescriptorNameToFileDescriptorProtoMapUncached() map[string]*descriptorpb.FileDescriptorProto { +func (r *request) getSourceFileDescriptorNameToFileDescriptorProtoMapUncached() map[string]*descriptorpb.FileDescriptorProto { sourceFileDescriptorNameToFileDescriptorProtoMap := make( map[string]*descriptorpb.FileDescriptorProto, len(r.codeGeneratorRequest.GetSourceFileDescriptors()), @@ -220,3 +239,5 @@ func (r *Request) getSourceFileDescriptorNameToFileDescriptorProtoMapUncached() } return sourceFileDescriptorNameToFileDescriptorProtoMap } + +func (*request) isRequest() {} diff --git a/response_writer.go b/response_writer.go index 6f16f6e..eba6852 100644 --- a/response_writer.go +++ b/response_writer.go @@ -24,25 +24,146 @@ import ( ) // ResponseWriter is used by implementations of Handler to construct CodeGeneratorResponses. -type ResponseWriter struct { - codeGeneratorResponse *pluginpb.CodeGeneratorResponse - written bool +// +// ResponseWriter contains a private method to ensure that it is not constructed outside this package, to +// enable us to modify the ResponseWriter interface in the future without breaking compatibility. +type ResponseWriter interface { + // AddFile adds the file with the given content to the response. + // + // This takes care of the most common case of adding a CodeGeneratorResponse.File with content. If you need add a + // CodeGeneratorResponse.File with insertion points or any other feature, use AddCodeGeneratorResponseFiles. + // + // The plugin will exit with a non-zero exit code if the name is an invalid path. + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, and do not jump context. + // + // If a file with the same name was already added, or the file name is not cleaned, a warning will be produced. + AddFile(name string, content string) + // SetError sets the error message on the response. + // + // If there is an error with the actual input .proto files that results in your plugin's business logic not being able to be executed + // (for example, a missing option), this error should be added to the response via SetError. If there is a system error, the + // Handler should return error, which will result in the plugin exiting with a non-zero exit code. + // + // If there is an existing error message already added, it will be overwritten. + // Note that empty error messages will be ignored (ie it will be as if no error was set). + SetError(message string) + // SetFeatureProto3Optional sets the FEATURE_PROTO3_OPTIONAL feature on the response. + // + // This function should be preferred over SetSupportedFeatures. Use SetSupportedFeatures only if you need low-level access. + SetFeatureProto3Optional() + // SetFeatureSupportsEditions sets the FEATURE_SUPPORTS_EDITIONS feature on the response along + // with the given min and max editions. + // + // This function should be preferred over calling SetSupportedFeatures, SetMinimumEdition, and SetMaximumEdition separately. + // Use SetSupportedFeatures, SetMinimumEdition, and SetMaximumEdition only if you need low-level access. + // + // The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. + SetFeatureSupportsEditions(minimumEdition descriptorpb.Edition, maximumEdition descriptorpb.Edition) + // AddCodeGeneratorResponseFiles adds the CodeGeneratorResponse.Files to the response. + // + // See the documentation on CodeGeneratorResponse.File for the exact semantics. + // + // If you are just adding file content, use the simpler AddFile. This function is for lower-level access. + // + // The plugin will exit with a non-zero exit code if any of the following are true: + // + // - The CodeGeneratorResponse_File is nil. + // - The name is an invalid path. + // + // Paths are considered valid if they are non-empty, relative, use '/' as the path separator, and do not jump context. + // + // If a file with the same name was already added, or the file name is not cleaned, a warning will be produced. + AddCodeGeneratorResponseFiles(files ...*pluginpb.CodeGeneratorResponse_File) + // SetSupportedFeatures the given features on the response. + // + // You likely want to use the specific feature functions instead of this function. + // This function is for lower-level access. + // + // If there are existing features already added, they will be overwritten. + // + // If the features are not represented in the known CodeGeneratorResponse.Features, + // the plugin will exit with a non-zero exit code. + SetSupportedFeatures(supportedFeatures uint64) + // SetMinimumEdition sets the minimum edition. + // + // If you want to specify that you are supporting editions, it is likely easier to use + // SetFeatureSupportsEditions. This function is for those callers needing to have lower-level access. + // + // The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. + SetMinimumEdition(minimumEdition int32) + // SetMaximumEdition sets the maximum edition. + // + // If you want to specify that you are supporting editions, it is likely easier to use + // SetFeatureSupportsEditions. This function is for those callers needing to have lower-level access. + // + // The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. + SetMaximumEdition(maximumEdition int32) + // ToCodeGeneratorResponse creates a CodeGeneratorResponse from the values written to the ResponseWriter. + // + // Most users of this library will not need to call this function. This function is only used if you are + // invoking Handlers outside of Main or Run. + // + // This function can be called exactly once. Future calls to this function will result in an error. + ToCodeGeneratorResponse() (*pluginpb.CodeGeneratorResponse, error) - lenientResponseValidateErrorFunc func(error) + isResponseWriter() +} - lock sync.RWMutex +// NewResponseWriter returns a new ResponseWriter. +func NewResponseWriter(options ...ResponseWriterOption) ResponseWriter { + responseWriter := &responseWriter{ + codeGeneratorResponse: &pluginpb.CodeGeneratorResponse{}, + } + for _, option := range options { + option(responseWriter) + } + return responseWriter } -// AddFile adds the file with the given content to the response. +// ResponseWriterOption is an option for a new ResponseWriter. +type ResponseWriterOption func(*responseWriter) + +// ResponseWriterWithLenientValidation returns a new ResponseWriterOption that says handle non-critical CodeGeneratorResponse +// validation issues as warnings that will be handled by the given warning handler. // -// This takes care of the most common case of adding a CodeGeneratorResponse.File with content. If you need add a -// CodeGeneratorResponse.File with insertion points or any other feature, use AddCodeGeneratorResponseFiles. +// This allows the following issues to result in warnings instead of errors: // -// The plugin will exit with a non-zero exit code if the name is an invalid path. -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, and do not jump context. +// - Duplicate file names for files without insertion points. If the same file name is used two or more times for +// files without insertion points, the first occurrence of the file will be used and subsequent occurrences will +// be dropped. +// - File names that are not equal to filepath.ToSlash(filepath.Clean(name)). The file name will be modified +// with this normalization. // -// If a file with the same name was already added, or the file name is not cleaned, a warning will be produced. -func (r *ResponseWriter) AddFile(name string, content string) { +// These issues result in CodeGeneratorResponses that are not properly formed per the CodeGeneratorResponse +// spec, however both protoc and buf have been resilient to these issues for years. There are numerous plugins +// out in the wild that have these issues, and protoplugin should be able to function as a proxy to these +// plugins without error. +// +// Most users of protoplugin should not use this option, this should only be used for plugins that proxy to other +// plugins. If you are authoring a standalone plugin, you should instead make sure your responses are completely correct. +// +// The default is to error on these issues. +// +// Implementers of lenientValidateErrorFunc can assume that errors passed will be non-nil and have non-empty +// values for err.Error(). +func ResponseWriterWithLenientValidation(lenientValidateErrorFunc func(error)) ResponseWriterOption { + return func(responseWriter *responseWriter) { + responseWriter.lenientValidateErrorFunc = lenientValidateErrorFunc + } +} + +// *** PRIVATE *** + +type responseWriter struct { + codeGeneratorResponse *pluginpb.CodeGeneratorResponse + written bool + + lenientValidateErrorFunc func(error) + + lock sync.RWMutex +} + +func (r *responseWriter) AddFile(name string, content string) { r.AddCodeGeneratorResponseFiles( &pluginpb.CodeGeneratorResponse_File{ Name: proto.String(name), @@ -51,15 +172,7 @@ func (r *ResponseWriter) AddFile(name string, content string) { ) } -// SetError sets the error message on the response. -// -// If there is an error with the actual input .proto files that results in your plugin's business logic not being able to be executed -// (for example, a missing option), this error should be added to the response via SetError. If there is a system error, the -// Handler should return error, which will result in the plugin exiting with a non-zero exit code. -// -// If there is an existing error message already added, it will be overwritten. -// Note that empty error messages will be ignored (ie it will be as if no error was set). -func (r *ResponseWriter) SetError(message string) { +func (r *responseWriter) SetError(message string) { r.lock.Lock() defer r.lock.Unlock() @@ -72,60 +185,24 @@ func (r *ResponseWriter) SetError(message string) { r.codeGeneratorResponse.Error = proto.String(message) } -// SetFeatureProto3Optional sets the FEATURE_PROTO3_OPTIONAL feature on the response. -// -// This function should be preferred over SetSupportedFeatures. Use SetSupportedFeatures only if you need low-level access. -func (r *ResponseWriter) SetFeatureProto3Optional() { +func (r *responseWriter) SetFeatureProto3Optional() { r.addSupportedFeatures(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)) } -// SetFeatureSupportsEditions sets the FEATURE_SUPPORTS_EDITIONS feature on the response along -// with the given min and max editions. -// -// This function should be preferred over calling SetSupportedFeatures, SetMinimumEdition, and SetMaximumEdition separately. -// Use SetSupportedFeatures, SetMinimumEdition, and SetMaximumEdition only if you need low-level access. -// -// The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. -func (r *ResponseWriter) SetFeatureSupportsEditions( - minimumEdition descriptorpb.Edition, - maximumEdition descriptorpb.Edition, -) { +func (r *responseWriter) SetFeatureSupportsEditions(minimumEdition descriptorpb.Edition, maximumEdition descriptorpb.Edition) { r.addSupportedFeatures(uint64(pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS)) r.SetMinimumEdition(int32(minimumEdition)) r.SetMaximumEdition(int32(maximumEdition)) } -// AddCodeGeneratorResponseFiles adds the CodeGeneratorResponse.Files to the response. -// -// See the documentation on CodeGeneratorResponse.File for the exact semantics. -// -// If you are just adding file content, use the simpler AddFile. This function is for lower-level access. -// -// The plugin will exit with a non-zero exit code if any of the following are true: -// -// - The CodeGeneratorResponse_File is nil. -// - The name is an invalid path. -// -// Paths are considered valid if they are non-empty, relative, use '/' as the path separator, and do not jump context. -// -// If a file with the same name was already added, or the file name is not cleaned, a warning will be produced. -func (r *ResponseWriter) AddCodeGeneratorResponseFiles(files ...*pluginpb.CodeGeneratorResponse_File) { +func (r *responseWriter) AddCodeGeneratorResponseFiles(files ...*pluginpb.CodeGeneratorResponse_File) { r.lock.Lock() defer r.lock.Unlock() r.codeGeneratorResponse.File = append(r.codeGeneratorResponse.GetFile(), files...) } -// SetSupportedFeatures the given features on the response. -// -// You likely want to use the specific feature functions instead of this function. -// This function is for lower-level access. -// -// If there are existing features already added, they will be overwritten. -// -// If the features are not represented in the known CodeGeneratorResponse.Features, -// the plugin will exit with a non-zero exit code. -func (r *ResponseWriter) SetSupportedFeatures(supportedFeatures uint64) { +func (r *responseWriter) SetSupportedFeatures(supportedFeatures uint64) { r.lock.Lock() defer r.lock.Unlock() @@ -136,49 +213,21 @@ func (r *ResponseWriter) SetSupportedFeatures(supportedFeatures uint64) { } } -// SetMinimumEdition sets the minimum edition. -// -// If you want to specify that you are supporting editions, it is likely easier to use -// SetFeatureSupportsEditions. This function is for those callers needing to have lower-level access. -// -// The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. -func (r *ResponseWriter) SetMinimumEdition(minimumEdition int32) { +func (r *responseWriter) SetMinimumEdition(minimumEdition int32) { r.lock.Lock() defer r.lock.Unlock() r.codeGeneratorResponse.MinimumEdition = proto.Int32(minimumEdition) } -// SetMaximumEdition sets the maximum edition. -// -// If you want to specify that you are supporting editions, it is likely easier to use -// SetFeatureSupportsEditions. This function is for those callers needing to have lower-level access. -// -// The plugin will exit with a non-zero exit code if the minimum edition is greater than the maximum edition. -func (r *ResponseWriter) SetMaximumEdition(maximumEdition int32) { +func (r *responseWriter) SetMaximumEdition(maximumEdition int32) { r.lock.Lock() defer r.lock.Unlock() r.codeGeneratorResponse.MaximumEdition = proto.Int32(maximumEdition) } -// *** PRIVATE *** - -func newResponseWriter(lenientResponseValidateErrorFunc func(error)) *ResponseWriter { - return &ResponseWriter{ - codeGeneratorResponse: &pluginpb.CodeGeneratorResponse{}, - lenientResponseValidateErrorFunc: lenientResponseValidateErrorFunc, - } -} - -func (r *ResponseWriter) addSupportedFeatures(supportedFeatures uint64) { - r.lock.Lock() - defer r.lock.Unlock() - - r.codeGeneratorResponse.SupportedFeatures = proto.Uint64(r.codeGeneratorResponse.GetSupportedFeatures() | supportedFeatures) -} - -func (r *ResponseWriter) toCodeGeneratorResponse() (*pluginpb.CodeGeneratorResponse, error) { +func (r *responseWriter) ToCodeGeneratorResponse() (*pluginpb.CodeGeneratorResponse, error) { r.lock.RLock() defer r.lock.RUnlock() @@ -191,8 +240,17 @@ func (r *ResponseWriter) toCodeGeneratorResponse() (*pluginpb.CodeGeneratorRespo } r.written = true - if err := validateAndNormalizeCodeGeneratorResponse(r.codeGeneratorResponse, r.lenientResponseValidateErrorFunc); err != nil { + if err := validateAndNormalizeCodeGeneratorResponse(r.codeGeneratorResponse, r.lenientValidateErrorFunc); err != nil { return nil, err } return r.codeGeneratorResponse, nil } + +func (r *responseWriter) addSupportedFeatures(supportedFeatures uint64) { + r.lock.Lock() + defer r.lock.Unlock() + + r.codeGeneratorResponse.SupportedFeatures = proto.Uint64(r.codeGeneratorResponse.GetSupportedFeatures() | supportedFeatures) +} + +func (*responseWriter) isResponseWriter() {}