From 2ffca17a34fbcd8271cb68c711b98dfadf9d958a Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Sun, 15 Mar 2026 12:14:02 -0400 Subject: [PATCH] feat(openapi): Add flatten_oneofs plugin option --- .../tests/oneof/openapi_flatten_oneofs.yaml | 100 ++++++++++++++++++ cmd/protoc-gen-openapi/generator/generator.go | 12 ++- cmd/protoc-gen-openapi/main.go | 1 + cmd/protoc-gen-openapi/plugin_test.go | 40 +++++++ 4 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 cmd/protoc-gen-openapi/examples/tests/oneof/openapi_flatten_oneofs.yaml diff --git a/cmd/protoc-gen-openapi/examples/tests/oneof/openapi_flatten_oneofs.yaml b/cmd/protoc-gen-openapi/examples/tests/oneof/openapi_flatten_oneofs.yaml new file mode 100644 index 00000000..e1dd9a5f --- /dev/null +++ b/cmd/protoc-gen-openapi/examples/tests/oneof/openapi_flatten_oneofs.yaml @@ -0,0 +1,100 @@ +# Generated with protoc-gen-openapi +# https://github.com/fern-api/protoc-gen-openapi/tree/master/cmd/protoc-gen-openapi + +openapi: 3.0.3 +info: + title: OneOfService API + description: Service definition + version: 0.0.1 +paths: + /v1/payments: + post: + tags: + - OneOfService + operationId: OneOfService_CreatePayment + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OneOfMessage' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/OneOfMessage' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + x-fern-sdk-group-name: OneOfService + x-fern-sdk-method-name: CreatePayment +components: + schemas: + EmptyOneOf: + type: object + properties: {} + x-fern-type-name: EmptyOneOf + source: tests/oneof/message.proto + GoogleProtobufAny: + type: object + properties: + '@type': + type: string + description: The type of the serialized message. + additionalProperties: true + description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message. + x-fern-encoding: + proto: + type: google.protobuf.Any + OneOfMessage: + type: object + properties: + id: + type: string + description: Identifier for the message + x-fern-encoding: + proto: + type: google.protobuf.StringValue + credit_card: + type: string + x-fern-encoding: + proto: + type: google.protobuf.StringValue + bank_transfer: + type: string + x-fern-encoding: + proto: + type: google.protobuf.StringValue + digital_wallet: + type: string + x-fern-encoding: + proto: + type: google.protobuf.StringValue + authentication_config: + $ref: '#/components/schemas/EmptyOneOf' + description: Message demonstrating oneof field usage + x-fern-type-name: OneOfMessage + source: tests/oneof/message.proto + Status: + type: object + properties: + code: + type: integer + description: The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. + format: int32 + message: + type: string + description: A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. + details: + type: array + items: + $ref: '#/components/schemas/GoogleProtobufAny' + description: A list of messages that carry the error details. There is a common set of message types for APIs to use. + description: 'The `Status` type defines a logical error model that is suitable for different programming environments, including REST APIs and RPC APIs. It is used by [gRPC](https://github.com/grpc). Each `Status` message contains three pieces of data: error code, error message, and error details. You can find out more about this error model and how to work with it in the [API Design Guide](https://cloud.google.com/apis/design/errors).' +tags: + - name: OneOfService diff --git a/cmd/protoc-gen-openapi/generator/generator.go b/cmd/protoc-gen-openapi/generator/generator.go index 5b62c58c..ab3327b5 100644 --- a/cmd/protoc-gen-openapi/generator/generator.go +++ b/cmd/protoc-gen-openapi/generator/generator.go @@ -47,6 +47,7 @@ type Configuration struct { DefaultResponse *bool OutputMode *string SourceRoot *string + FlattenOneofs *bool } const ( @@ -894,10 +895,10 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m var required []string for _, field := range message.Fields { - // Skip fields that are part of an explicit oneOf. + // Skip fields that are part of an explicit oneOf (unless flattening is enabled). // Proto3 optional fields create synthetic oneofs that should be // treated as regular optional fields, not as oneOf variants. - if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() { + if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() && !*g.conf.FlattenOneofs { continue } @@ -969,8 +970,11 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m Required: required, } - // Add oneOf fields to the schema - g.addOneOfFieldsToSchema(d, message.Oneofs, schema, schemaName, filename) + // Add oneOf fields to the schema (unless flattening is enabled, + // in which case they were already added as regular properties). + if !*g.conf.FlattenOneofs { + g.addOneOfFieldsToSchema(d, message.Oneofs, schema, schemaName, filename) + } // Merge any `Schema` annotations with the current extSchema := proto.GetExtension(message.Desc.Options(), v3.E_Schema) diff --git a/cmd/protoc-gen-openapi/main.go b/cmd/protoc-gen-openapi/main.go index c97975d4..e42dca91 100644 --- a/cmd/protoc-gen-openapi/main.go +++ b/cmd/protoc-gen-openapi/main.go @@ -39,6 +39,7 @@ func main() { DefaultResponse: flags.Bool("default_response", true, `add default response. If "true", automatically adds a default response to operations which use the google.rpc.Status message. Useful if you use envoy or grpc-gateway to transcode as they use this type for their default error responses.`), OutputMode: flags.String("output_mode", "merged", `output generation mode. By default, a single openapi.yaml is generated at the out folder. Use "source_relative' to generate a separate '[inputfile].openapi.yaml' next to each '[inputfile].proto'.`), SourceRoot: flags.String("source_root", "", `root directory of the source Protobuf files. This is used to add source information to the openapi.yaml file.`), + FlattenOneofs: flags.Bool("flatten_oneofs", false, `flatten oneof fields as regular properties on the parent message instead of generating a oneOf union`), } opts := protogen.Options{ diff --git a/cmd/protoc-gen-openapi/plugin_test.go b/cmd/protoc-gen-openapi/plugin_test.go index 21f8b5d4..19507e8b 100644 --- a/cmd/protoc-gen-openapi/plugin_test.go +++ b/cmd/protoc-gen-openapi/plugin_test.go @@ -270,6 +270,46 @@ func TestOpenAPIStringEnums(t *testing.T) { } } +func TestOpenAPIFlattenOneofs(t *testing.T) { + // Set PATH to include the protoc-gen-openapi plugin + os.Setenv("PATH", "../../:"+os.Getenv("PATH")) + + for _, tt := range openapiTests { + fixture := path.Join(tt.path, "openapi_flatten_oneofs.yaml") + if _, err := os.Stat(fixture); errors.Is(err, os.ErrNotExist) { + if !GENERATE_FIXTURES { + continue + } + } + t.Run(tt.name, func(t *testing.T) { + err := exec.Command("protoc", + "-I", "../../", + "-I", "../../third_party", + "-I", "examples", + path.Join(tt.path, tt.protofile), + "--plugin=protoc-gen-openapi=./protoc-gen-openapi", + "--openapi_out=naming=proto,flatten_oneofs=true:.").Run() + if err != nil { + t.Fatalf("protoc failed: %+v", err) + } + if GENERATE_FIXTURES { + err := CopyFixture(TEMP_FILE, fixture) + if err != nil { + t.Fatalf("Can't generate fixture: %+v", err) + } + } else { + // Verify that the generated spec matches our expected version. + err = exec.Command("diff", TEMP_FILE, fixture).Run() + if err != nil { + t.Fatalf("Diff failed: %+v", err) + } + } + // if the test succeeded, clean up + os.Remove(TEMP_FILE) + }) + } +} + func TestOpenAPIDefaultResponse(t *testing.T) { // Set PATH to include the protoc-gen-openapi plugin os.Setenv("PATH", "../../:"+os.Getenv("PATH"))