Skip to content

Commit

Permalink
protoc-gen-openapi: Support oneOf fields
Browse files Browse the repository at this point in the history
While this falls back to previous behavior for messages having more
than one 'oneof', the overwhelming majority of our cases have a
single field. This lets us optimize documentation generation for
semantics where possible
  • Loading branch information
coxley committed Feb 5, 2025
1 parent ad271d5 commit 432b3ad
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 13 deletions.
64 changes: 64 additions & 0 deletions cmd/protoc-gen-openapi/examples/tests/oneof/message.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2020 Google LLC.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

syntax = "proto3";

package tests.oneof.message.v1;

import "google/api/annotations.proto";
import "openapiv3/annotations.proto";

option go_package = "github.com/google/gnostic/apps/protoc-gen-openapi/examples/tests/oneof/message/v1;message";

service Messaging {
rpc SendMessage(Message) returns(Message) {
option(google.api.http) = {
post: "/v1/messages/{message_id}"
body: "*"
};
}
}

message Message {
string message_id = 1;
string text = 2;

oneof sender {
// Email address of the sender
string email = 3 [
(openapi.v3.property) = {
type: 'string',
format: 'email'
}
];

// Full name of the sender
string name = 4;
}

Double double = 5;
}

// Double demonstrates the generated output for a message with more than one `oneof`
// group
message Double {
oneof foo {
bool bar = 1;
}

oneof baz {
bool qux = 2;
}
}
101 changes: 101 additions & 0 deletions cmd/protoc-gen-openapi/examples/tests/oneof/openapi_oneof.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Generated with protoc-gen-openapi
# https://github.com/google/gnostic/tree/master/cmd/protoc-gen-openapi

openapi: 3.0.3
info:
title: Messaging API
version: 0.0.1
paths:
/v1/messages/{messageId}:
post:
tags:
- Messaging
operationId: Messaging_SendMessage
parameters:
- name: messageId
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
components:
schemas:
Double:
type: object
properties:
bar:
type: boolean
qux:
type: boolean
description: |-
Double demonstrates the generated output for a message with more than one `oneof`
group
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.
Message:
type: object
allOf:
- type: object
properties:
messageId:
type: string
text:
type: string
double:
$ref: '#/components/schemas/Double'
- oneOf:
- title: email
type: object
properties:
email:
type: string
description: Email address of the sender
format: email
- title: name
type: object
properties:
name:
type: string
description: Full name of the sender
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: Messaging
97 changes: 84 additions & 13 deletions cmd/protoc-gen-openapi/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Configuration struct {
CircularDepth *int
DefaultResponse *bool
OutputMode *string
GenerateOneOfs *bool
}

const (
Expand All @@ -53,8 +54,10 @@ const (
// In order to dynamically add google.rpc.Status responses we need
// to know the message descriptors for google.rpc.Status as well
// as google.protobuf.Any.
var statusProtoDesc = (&status_pb.Status{}).ProtoReflect().Descriptor()
var anyProtoDesc = (&any_pb.Any{}).ProtoReflect().Descriptor()
var (
statusProtoDesc = (&status_pb.Status{}).ProtoReflect().Descriptor()
anyProtoDesc = (&any_pb.Any{}).ProtoReflect().Descriptor()
)

// OpenAPIv3Generator holds internal state needed to generate an OpenAPIv3 document for a transcoded Protocol Buffer service.
type OpenAPIv3Generator struct {
Expand Down Expand Up @@ -296,7 +299,6 @@ func (g *OpenAPIv3Generator) _buildQueryParamsV3(field *protogen.Field, depths m
if field.Desc.IsMap() {
// Map types are not allowed in query parameteres
return parameters

} else if field.Desc.Kind() == protoreflect.MessageKind {
typeName := g.reflect.fullMessageTypeName(field.Desc.Message())

Expand Down Expand Up @@ -588,7 +590,9 @@ func (g *OpenAPIv3Generator) buildOperationV3(
Description: "Default error response",
Content: wk.NewApplicationJsonMediaType(&v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Reference{
Reference: &v3.Reference{XRef: "#/components/schemas/" + statusSchemaName}}}),
Reference: &v3.Reference{XRef: "#/components/schemas/" + statusSchemaName},
},
}),
},
},
},
Expand Down Expand Up @@ -621,7 +625,6 @@ func (g *OpenAPIv3Generator) buildOperationV3(
if bodyField == "*" {
// Pass the entire request message as the request body.
requestSchema = g.reflect.schemaOrReferenceForMessage(inputMessage.Desc)

} else {
// If body refers to a message field, use that type.
for _, field := range inputMessage.Fields {
Expand Down Expand Up @@ -819,6 +822,17 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m
AdditionalProperties: make([]*v3.NamedSchemaOrReference, 0),
}

// There's not a nice way to handle oneof fields if there are more than one
// group in a single message.
//
// If there are more than one in the message, fallback to the previous
// behavior. That'll treat each oneof field as normal.
handleOneOf := len(message.Oneofs) == 1 && *g.conf.GenerateOneOfs
var oneOfs []*v3.SchemaOrReference
if handleOneOf {
oneOfs = make([]*v3.SchemaOrReference, 0, len(message.Oneofs[0].Fields))
}

var required []string
for _, field := range message.Fields {
// Get the field description from the comments.
Expand Down Expand Up @@ -873,22 +887,79 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m
}
}

definitionProperties.AdditionalProperties = append(
definitionProperties.AdditionalProperties,
&v3.NamedSchemaOrReference{
Name: g.reflect.formatFieldName(field.Desc),
Value: fieldSchema,
},
)
namedSchema := &v3.NamedSchemaOrReference{
Name: g.reflect.formatFieldName(field.Desc),
Value: fieldSchema,
}
if !handleOneOf || field.Oneof == nil {
definitionProperties.AdditionalProperties = append(
definitionProperties.AdditionalProperties,
namedSchema,
)
} else {
oneOfs = append(oneOfs, &v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Schema{
Schema: &v3.Schema{
Title: g.reflect.formatFieldName(field.Desc),
Type: "object",
Properties: &v3.Properties{
AdditionalProperties: []*v3.NamedSchemaOrReference{namedSchema},
},
},
},
})
}
}

schema := &v3.Schema{
Type: "object",
Description: messageDescription,
Properties: definitionProperties,
Required: required,
}

if !handleOneOf {
schema.Properties = definitionProperties
} else {
// Combine normal fields and the oneOf clause together. For example:
//
// Identifier:
// type: object
// allOf:
// - type: object
// properties:
// normal_field:
// type: string
// - oneOf:
// - title: email
// type: object
// properties:
// email:
// type: string
// - title: phone_number
// type: object
// properties:
// phone_number:
// type: string
schema.AllOf = []*v3.SchemaOrReference{}
if len(definitionProperties.AdditionalProperties) > 0 {
schema.AllOf = append(schema.AllOf, &v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Schema{
Schema: &v3.Schema{
Type: "object",
Properties: definitionProperties,
},
},
})
}
schema.AllOf = append(schema.AllOf, &v3.SchemaOrReference{
Oneof: &v3.SchemaOrReference_Schema{
Schema: &v3.Schema{
OneOf: oneOfs,
},
},
})
}

// Merge any `Schema` annotations with the current
extSchema := proto.GetExtension(message.Desc.Options(), v3.E_Schema)
if extSchema != nil {
Expand Down
1 change: 1 addition & 0 deletions cmd/protoc-gen-openapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func main() {
CircularDepth: flags.Int("depth", 2, "depth of recursion for circular messages"),
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'.`),
GenerateOneOfs: flags.Bool("oneof", false, "Generate 'oneOf' sections for messages with 'oneof' fields. Note: Falls backs to default behavior for messages with multiple 'oneof' blocks."),
}

opts := protogen.Options{
Expand Down
39 changes: 39 additions & 0 deletions cmd/protoc-gen-openapi/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var openapiTests = []struct {
{name: "OpenAPIv3 Annotations", path: "examples/tests/openapiv3annotations/", protofile: "message.proto"},
{name: "AllOf Wrap Message", path: "examples/tests/allofwrap/", protofile: "message.proto"},
{name: "Additional Bindings", path: "examples/tests/additional_bindings/", protofile: "message.proto"},
{name: "OneOf Fields", path: "examples/tests/oneof/", protofile: "message.proto"},
}

// Set this to true to generate/overwrite the fixtures. Make sure you set it back
Expand Down Expand Up @@ -291,3 +292,41 @@ func TestOpenAPIDefaultResponse(t *testing.T) {
})
}
}

func TestOpenAPIOneOfs(t *testing.T) {
for _, tt := range openapiTests {
fixture := path.Join(tt.path, "openapi_oneof.yaml")
if _, err := os.Stat(fixture); errors.Is(err, os.ErrNotExist) {
if !GENERATE_FIXTURES {
continue
}
}
t.Run(tt.name, func(t *testing.T) {
// Run protoc and the protoc-gen-openapi plugin to generate an OpenAPI spec
// with protobuf oneof support
err := exec.Command("protoc",
"-I", "../../",
"-I", "../../third_party",
"-I", "examples",
path.Join(tt.path, tt.protofile),
"--openapi_out=oneof=1:.").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)
})
}
}

0 comments on commit 432b3ad

Please sign in to comment.