Skip to content

Commit cd9db15

Browse files
protoc-gen-openapiv3: add annotations
Fixes #6674
1 parent ad6b437 commit cd9db15

16 files changed

Lines changed: 3974 additions & 14 deletions
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package genopenapi
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/grpc-ecosystem/grpc-gateway/v2/internal/descriptor"
7+
"github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv3/options"
8+
"google.golang.org/protobuf/proto"
9+
)
10+
11+
// Annotation lookups. Each returns (nil, false) when the extension is not
12+
// present. A returned annotation's non-empty sub-fields replace defaults the
13+
// generator would otherwise derive from proto comments or proto types.
14+
15+
func fileDocumentAnnotation(file *descriptor.File) (*options.Document, bool) {
16+
if file.Options == nil || !proto.HasExtension(file.Options, options.E_Openapiv3Document) {
17+
return nil, false
18+
}
19+
d, ok := proto.GetExtension(file.Options, options.E_Openapiv3Document).(*options.Document)
20+
if !ok || d == nil {
21+
return nil, false
22+
}
23+
return d, true
24+
}
25+
26+
func methodOperationAnnotation(m *descriptor.Method) (*options.Operation, bool) {
27+
if m.Options == nil || !proto.HasExtension(m.Options, options.E_Openapiv3Operation) {
28+
return nil, false
29+
}
30+
op, ok := proto.GetExtension(m.Options, options.E_Openapiv3Operation).(*options.Operation)
31+
if !ok || op == nil {
32+
return nil, false
33+
}
34+
return op, true
35+
}
36+
37+
func messageSchemaAnnotation(msg *descriptor.Message) (*options.Schema, bool) {
38+
if msg.Options == nil || !proto.HasExtension(msg.Options, options.E_Openapiv3Schema) {
39+
return nil, false
40+
}
41+
s, ok := proto.GetExtension(msg.Options, options.E_Openapiv3Schema).(*options.Schema)
42+
if !ok || s == nil {
43+
return nil, false
44+
}
45+
return s, true
46+
}
47+
48+
func fieldSchemaAnnotation(field *descriptor.Field) (*options.Schema, bool) {
49+
if field.Options == nil || !proto.HasExtension(field.Options, options.E_Openapiv3Field) {
50+
return nil, false
51+
}
52+
s, ok := proto.GetExtension(field.Options, options.E_Openapiv3Field).(*options.Schema)
53+
if !ok || s == nil {
54+
return nil, false
55+
}
56+
return s, true
57+
}
58+
59+
// applyDocumentOverride applies file-level Document overrides onto the
60+
// generated OpenAPI document. Non-empty fields replace defaults; empty fields
61+
// leave the current value untouched. Returns an error if the annotation is
62+
// invalid (e.g. License sets both `identifier` and `url`, which the OpenAPI
63+
// 3.1.0 spec declares mutually exclusive).
64+
func applyDocumentOverride(doc *Document, d *options.Document) error {
65+
if d == nil {
66+
return nil
67+
}
68+
if info := d.GetInfo(); info != nil {
69+
if v := info.GetTitle(); v != "" {
70+
doc.Info.Title = v
71+
}
72+
if v := info.GetSummary(); v != "" {
73+
doc.Info.Summary = v
74+
}
75+
if v := info.GetDescription(); v != "" {
76+
doc.Info.Description = v
77+
}
78+
if v := info.GetTermsOfService(); v != "" {
79+
doc.Info.TermsOfService = v
80+
}
81+
if v := info.GetVersion(); v != "" {
82+
doc.Info.Version = v
83+
}
84+
if c := info.GetContact(); c != nil {
85+
doc.Info.Contact = &Contact{
86+
Name: c.GetName(),
87+
URL: c.GetUrl(),
88+
Email: c.GetEmail(),
89+
}
90+
}
91+
if l := info.GetLicense(); l != nil {
92+
if l.GetName() == "" {
93+
return fmt.Errorf("openapiv3 license: name is required")
94+
}
95+
if l.GetIdentifier() != "" && l.GetUrl() != "" {
96+
return fmt.Errorf("openapiv3 license: identifier and url are mutually exclusive, set only one")
97+
}
98+
doc.Info.License = &License{
99+
Name: l.GetName(),
100+
Identifier: l.GetIdentifier(),
101+
URL: l.GetUrl(),
102+
}
103+
}
104+
}
105+
for _, s := range d.GetServers() {
106+
doc.Servers = append(doc.Servers, &Server{
107+
URL: s.GetUrl(),
108+
Description: s.GetDescription(),
109+
})
110+
}
111+
if ed := d.GetExternalDocs(); ed != nil {
112+
doc.ExternalDocs = &ExternalDocs{
113+
Description: ed.GetDescription(),
114+
URL: ed.GetUrl(),
115+
}
116+
}
117+
for _, t := range d.GetTags() {
118+
tag := &Tag{
119+
Name: t.GetName(),
120+
Description: t.GetDescription(),
121+
}
122+
if ed := t.GetExternalDocs(); ed != nil {
123+
tag.ExternalDocs = &ExternalDocs{
124+
Description: ed.GetDescription(),
125+
URL: ed.GetUrl(),
126+
}
127+
}
128+
doc.Tags = append(doc.Tags, tag)
129+
}
130+
return nil
131+
}
132+
133+
// applyOperationOverride applies method-level Operation overrides onto the
134+
// generated operation. Annotation values replace comment-derived summary and
135+
// description; a non-empty tag list replaces the default (the service name);
136+
// the deprecated flag is ORed with the proto cascade, so proto-level
137+
// deprecation cannot be revoked by annotation.
138+
func applyOperationOverride(op *Operation, o *options.Operation) {
139+
if o == nil {
140+
return
141+
}
142+
if tags := o.GetTags(); len(tags) > 0 {
143+
op.Tags = tags
144+
}
145+
if v := o.GetSummary(); v != "" {
146+
op.Summary = v
147+
}
148+
if v := o.GetDescription(); v != "" {
149+
op.Description = v
150+
}
151+
if v := o.GetOperationId(); v != "" {
152+
op.OperationID = v
153+
}
154+
if ed := o.GetExternalDocs(); ed != nil {
155+
op.ExternalDocs = &ExternalDocs{
156+
Description: ed.GetDescription(),
157+
URL: ed.GetUrl(),
158+
}
159+
}
160+
if o.GetDeprecated() {
161+
op.Deprecated = true
162+
}
163+
}
164+
165+
// applySchemaTitle applies the title field of a Schema annotation onto a
166+
// schema body. Used for both message-level and field-level annotations, and
167+
// (for fields) only when an allOf wrapper is present — a bare $ref cannot
168+
// carry a title sibling in OpenAPI 3.1.0.
169+
func applySchemaTitle(s *Schema, o *options.Schema) {
170+
if o == nil {
171+
return
172+
}
173+
if v := o.GetTitle(); v != "" {
174+
s.Title = v
175+
}
176+
}
177+
178+
// applyMessageSchemaOverride applies a message-level Schema annotation onto
179+
// a component schema.
180+
func applyMessageSchemaOverride(s *Schema, o *options.Schema) {
181+
if o == nil {
182+
return
183+
}
184+
applySchemaTitle(s, o)
185+
if v := o.GetDescription(); v != "" {
186+
s.Description = v
187+
}
188+
}
189+
190+
// annotationNeedsSchemaBody reports whether a field annotation sets anything
191+
// that cannot be expressed as a $ref sibling in OpenAPI 3.1.0. Currently
192+
// that's just `title`: `description` can sit as a sibling directly. Used by
193+
// propertySchema to decide when a referenced field needs an allOf wrapper.
194+
func annotationNeedsSchemaBody(o *options.Schema) bool {
195+
if o == nil {
196+
return false
197+
}
198+
return o.GetTitle() != ""
199+
}

protoc-gen-openapiv3/internal/genopenapi/generator.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import (
1616
func Generate(reg *descriptor.Registry, files []*descriptor.File) ([]*pluginpb.CodeGeneratorResponse_File, error) {
1717
var out []*pluginpb.CodeGeneratorResponse_File
1818
for _, file := range files {
19-
doc, ok := generateFile(reg, file)
19+
doc, ok, err := generateFile(reg, file)
20+
if err != nil {
21+
return nil, fmt.Errorf("%s: %w", file.GetName(), err)
22+
}
2023
if !ok {
2124
// No HTTP annotations found, skip file.
2225
continue
@@ -36,13 +39,23 @@ func Generate(reg *descriptor.Registry, files []*descriptor.File) ([]*pluginpb.C
3639

3740
// generateFile builds a Document for a single proto file. The boolean return
3841
// is false when the file has no HTTP-bound operations to emit.
39-
func generateFile(reg *descriptor.Registry, file *descriptor.File) (*Document, bool) {
42+
func generateFile(reg *descriptor.Registry, file *descriptor.File) (*Document, bool, error) {
4043
name := file.GetName()
4144
title := strings.TrimSuffix(path.Base(name), path.Ext(name))
4245
doc := NewDocument(title, "1.0.0")
46+
if d, ok := fileDocumentAnnotation(file); ok {
47+
if err := applyDocumentOverride(doc, d); err != nil {
48+
return nil, false, err
49+
}
50+
}
4351
b := newSchemaBuilder(reg, doc)
4452

45-
seenTags := make(map[string]bool)
53+
// Prime seenTags with any document-level annotation tags so a service's
54+
// default tag does not clobber the annotation-provided description.
55+
seenTags := make(map[string]bool, len(doc.Tags))
56+
for _, t := range doc.Tags {
57+
seenTags[t.Name] = true
58+
}
4659

4760
for _, svc := range file.Services {
4861
if !isVisible(serviceVisibility(svc), reg) {
@@ -88,10 +101,10 @@ func generateFile(reg *descriptor.Registry, file *descriptor.File) (*Document, b
88101
}
89102

90103
if doc.Paths.Len() == 0 {
91-
return nil, false
104+
return nil, false, nil
92105
}
93106
if doc.Components.Empty() {
94107
doc.Components = nil
95108
}
96-
return doc, true
109+
return doc, true, nil
97110
}

0 commit comments

Comments
 (0)