Skip to content

Commit 432b3ad

Browse files
committed
protoc-gen-openapi: Support oneOf fields
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
1 parent ad271d5 commit 432b3ad

File tree

5 files changed

+289
-13
lines changed

5 files changed

+289
-13
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2020 Google LLC.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
syntax = "proto3";
17+
18+
package tests.oneof.message.v1;
19+
20+
import "google/api/annotations.proto";
21+
import "openapiv3/annotations.proto";
22+
23+
option go_package = "github.com/google/gnostic/apps/protoc-gen-openapi/examples/tests/oneof/message/v1;message";
24+
25+
service Messaging {
26+
rpc SendMessage(Message) returns(Message) {
27+
option(google.api.http) = {
28+
post: "/v1/messages/{message_id}"
29+
body: "*"
30+
};
31+
}
32+
}
33+
34+
message Message {
35+
string message_id = 1;
36+
string text = 2;
37+
38+
oneof sender {
39+
// Email address of the sender
40+
string email = 3 [
41+
(openapi.v3.property) = {
42+
type: 'string',
43+
format: 'email'
44+
}
45+
];
46+
47+
// Full name of the sender
48+
string name = 4;
49+
}
50+
51+
Double double = 5;
52+
}
53+
54+
// Double demonstrates the generated output for a message with more than one `oneof`
55+
// group
56+
message Double {
57+
oneof foo {
58+
bool bar = 1;
59+
}
60+
61+
oneof baz {
62+
bool qux = 2;
63+
}
64+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Generated with protoc-gen-openapi
2+
# https://github.com/google/gnostic/tree/master/cmd/protoc-gen-openapi
3+
4+
openapi: 3.0.3
5+
info:
6+
title: Messaging API
7+
version: 0.0.1
8+
paths:
9+
/v1/messages/{messageId}:
10+
post:
11+
tags:
12+
- Messaging
13+
operationId: Messaging_SendMessage
14+
parameters:
15+
- name: messageId
16+
in: path
17+
required: true
18+
schema:
19+
type: string
20+
requestBody:
21+
content:
22+
application/json:
23+
schema:
24+
$ref: '#/components/schemas/Message'
25+
required: true
26+
responses:
27+
"200":
28+
description: OK
29+
content:
30+
application/json:
31+
schema:
32+
$ref: '#/components/schemas/Message'
33+
default:
34+
description: Default error response
35+
content:
36+
application/json:
37+
schema:
38+
$ref: '#/components/schemas/Status'
39+
components:
40+
schemas:
41+
Double:
42+
type: object
43+
properties:
44+
bar:
45+
type: boolean
46+
qux:
47+
type: boolean
48+
description: |-
49+
Double demonstrates the generated output for a message with more than one `oneof`
50+
group
51+
GoogleProtobufAny:
52+
type: object
53+
properties:
54+
'@type':
55+
type: string
56+
description: The type of the serialized message.
57+
additionalProperties: true
58+
description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message.
59+
Message:
60+
type: object
61+
allOf:
62+
- type: object
63+
properties:
64+
messageId:
65+
type: string
66+
text:
67+
type: string
68+
double:
69+
$ref: '#/components/schemas/Double'
70+
- oneOf:
71+
- title: email
72+
type: object
73+
properties:
74+
email:
75+
type: string
76+
description: Email address of the sender
77+
format: email
78+
- title: name
79+
type: object
80+
properties:
81+
name:
82+
type: string
83+
description: Full name of the sender
84+
Status:
85+
type: object
86+
properties:
87+
code:
88+
type: integer
89+
description: The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
90+
format: int32
91+
message:
92+
type: string
93+
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.
94+
details:
95+
type: array
96+
items:
97+
$ref: '#/components/schemas/GoogleProtobufAny'
98+
description: A list of messages that carry the error details. There is a common set of message types for APIs to use.
99+
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).'
100+
tags:
101+
- name: Messaging

cmd/protoc-gen-openapi/generator/generator.go

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type Configuration struct {
4444
CircularDepth *int
4545
DefaultResponse *bool
4646
OutputMode *string
47+
GenerateOneOfs *bool
4748
}
4849

4950
const (
@@ -53,8 +54,10 @@ const (
5354
// In order to dynamically add google.rpc.Status responses we need
5455
// to know the message descriptors for google.rpc.Status as well
5556
// as google.protobuf.Any.
56-
var statusProtoDesc = (&status_pb.Status{}).ProtoReflect().Descriptor()
57-
var anyProtoDesc = (&any_pb.Any{}).ProtoReflect().Descriptor()
57+
var (
58+
statusProtoDesc = (&status_pb.Status{}).ProtoReflect().Descriptor()
59+
anyProtoDesc = (&any_pb.Any{}).ProtoReflect().Descriptor()
60+
)
5861

5962
// OpenAPIv3Generator holds internal state needed to generate an OpenAPIv3 document for a transcoded Protocol Buffer service.
6063
type OpenAPIv3Generator struct {
@@ -296,7 +299,6 @@ func (g *OpenAPIv3Generator) _buildQueryParamsV3(field *protogen.Field, depths m
296299
if field.Desc.IsMap() {
297300
// Map types are not allowed in query parameteres
298301
return parameters
299-
300302
} else if field.Desc.Kind() == protoreflect.MessageKind {
301303
typeName := g.reflect.fullMessageTypeName(field.Desc.Message())
302304

@@ -588,7 +590,9 @@ func (g *OpenAPIv3Generator) buildOperationV3(
588590
Description: "Default error response",
589591
Content: wk.NewApplicationJsonMediaType(&v3.SchemaOrReference{
590592
Oneof: &v3.SchemaOrReference_Reference{
591-
Reference: &v3.Reference{XRef: "#/components/schemas/" + statusSchemaName}}}),
593+
Reference: &v3.Reference{XRef: "#/components/schemas/" + statusSchemaName},
594+
},
595+
}),
592596
},
593597
},
594598
},
@@ -621,7 +625,6 @@ func (g *OpenAPIv3Generator) buildOperationV3(
621625
if bodyField == "*" {
622626
// Pass the entire request message as the request body.
623627
requestSchema = g.reflect.schemaOrReferenceForMessage(inputMessage.Desc)
624-
625628
} else {
626629
// If body refers to a message field, use that type.
627630
for _, field := range inputMessage.Fields {
@@ -819,6 +822,17 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m
819822
AdditionalProperties: make([]*v3.NamedSchemaOrReference, 0),
820823
}
821824

825+
// There's not a nice way to handle oneof fields if there are more than one
826+
// group in a single message.
827+
//
828+
// If there are more than one in the message, fallback to the previous
829+
// behavior. That'll treat each oneof field as normal.
830+
handleOneOf := len(message.Oneofs) == 1 && *g.conf.GenerateOneOfs
831+
var oneOfs []*v3.SchemaOrReference
832+
if handleOneOf {
833+
oneOfs = make([]*v3.SchemaOrReference, 0, len(message.Oneofs[0].Fields))
834+
}
835+
822836
var required []string
823837
for _, field := range message.Fields {
824838
// Get the field description from the comments.
@@ -873,22 +887,79 @@ func (g *OpenAPIv3Generator) addSchemasForMessagesToDocumentV3(d *v3.Document, m
873887
}
874888
}
875889

876-
definitionProperties.AdditionalProperties = append(
877-
definitionProperties.AdditionalProperties,
878-
&v3.NamedSchemaOrReference{
879-
Name: g.reflect.formatFieldName(field.Desc),
880-
Value: fieldSchema,
881-
},
882-
)
890+
namedSchema := &v3.NamedSchemaOrReference{
891+
Name: g.reflect.formatFieldName(field.Desc),
892+
Value: fieldSchema,
893+
}
894+
if !handleOneOf || field.Oneof == nil {
895+
definitionProperties.AdditionalProperties = append(
896+
definitionProperties.AdditionalProperties,
897+
namedSchema,
898+
)
899+
} else {
900+
oneOfs = append(oneOfs, &v3.SchemaOrReference{
901+
Oneof: &v3.SchemaOrReference_Schema{
902+
Schema: &v3.Schema{
903+
Title: g.reflect.formatFieldName(field.Desc),
904+
Type: "object",
905+
Properties: &v3.Properties{
906+
AdditionalProperties: []*v3.NamedSchemaOrReference{namedSchema},
907+
},
908+
},
909+
},
910+
})
911+
}
883912
}
884913

885914
schema := &v3.Schema{
886915
Type: "object",
887916
Description: messageDescription,
888-
Properties: definitionProperties,
889917
Required: required,
890918
}
891919

920+
if !handleOneOf {
921+
schema.Properties = definitionProperties
922+
} else {
923+
// Combine normal fields and the oneOf clause together. For example:
924+
//
925+
// Identifier:
926+
// type: object
927+
// allOf:
928+
// - type: object
929+
// properties:
930+
// normal_field:
931+
// type: string
932+
// - oneOf:
933+
// - title: email
934+
// type: object
935+
// properties:
936+
// email:
937+
// type: string
938+
// - title: phone_number
939+
// type: object
940+
// properties:
941+
// phone_number:
942+
// type: string
943+
schema.AllOf = []*v3.SchemaOrReference{}
944+
if len(definitionProperties.AdditionalProperties) > 0 {
945+
schema.AllOf = append(schema.AllOf, &v3.SchemaOrReference{
946+
Oneof: &v3.SchemaOrReference_Schema{
947+
Schema: &v3.Schema{
948+
Type: "object",
949+
Properties: definitionProperties,
950+
},
951+
},
952+
})
953+
}
954+
schema.AllOf = append(schema.AllOf, &v3.SchemaOrReference{
955+
Oneof: &v3.SchemaOrReference_Schema{
956+
Schema: &v3.Schema{
957+
OneOf: oneOfs,
958+
},
959+
},
960+
})
961+
}
962+
892963
// Merge any `Schema` annotations with the current
893964
extSchema := proto.GetExtension(message.Desc.Options(), v3.E_Schema)
894965
if extSchema != nil {

cmd/protoc-gen-openapi/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func main() {
3838
CircularDepth: flags.Int("depth", 2, "depth of recursion for circular messages"),
3939
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.`),
4040
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'.`),
41+
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."),
4142
}
4243

4344
opts := protogen.Options{

cmd/protoc-gen-openapi/plugin_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var openapiTests = []struct {
4343
{name: "OpenAPIv3 Annotations", path: "examples/tests/openapiv3annotations/", protofile: "message.proto"},
4444
{name: "AllOf Wrap Message", path: "examples/tests/allofwrap/", protofile: "message.proto"},
4545
{name: "Additional Bindings", path: "examples/tests/additional_bindings/", protofile: "message.proto"},
46+
{name: "OneOf Fields", path: "examples/tests/oneof/", protofile: "message.proto"},
4647
}
4748

4849
// Set this to true to generate/overwrite the fixtures. Make sure you set it back
@@ -291,3 +292,41 @@ func TestOpenAPIDefaultResponse(t *testing.T) {
291292
})
292293
}
293294
}
295+
296+
func TestOpenAPIOneOfs(t *testing.T) {
297+
for _, tt := range openapiTests {
298+
fixture := path.Join(tt.path, "openapi_oneof.yaml")
299+
if _, err := os.Stat(fixture); errors.Is(err, os.ErrNotExist) {
300+
if !GENERATE_FIXTURES {
301+
continue
302+
}
303+
}
304+
t.Run(tt.name, func(t *testing.T) {
305+
// Run protoc and the protoc-gen-openapi plugin to generate an OpenAPI spec
306+
// with protobuf oneof support
307+
err := exec.Command("protoc",
308+
"-I", "../../",
309+
"-I", "../../third_party",
310+
"-I", "examples",
311+
path.Join(tt.path, tt.protofile),
312+
"--openapi_out=oneof=1:.").Run()
313+
if err != nil {
314+
t.Fatalf("protoc failed: %+v", err)
315+
}
316+
if GENERATE_FIXTURES {
317+
err := CopyFixture(TEMP_FILE, fixture)
318+
if err != nil {
319+
t.Fatalf("Can't generate fixture: %+v", err)
320+
}
321+
} else {
322+
// Verify that the generated spec matches our expected version.
323+
err = exec.Command("diff", TEMP_FILE, fixture).Run()
324+
if err != nil {
325+
t.Fatalf("Diff failed: %+v", err)
326+
}
327+
}
328+
// if the test succeeded, clean up
329+
os.Remove(TEMP_FILE)
330+
})
331+
}
332+
}

0 commit comments

Comments
 (0)