Skip to content

Commit

Permalink
use kubebuilder style instead of proto
Browse files Browse the repository at this point in the history
Signed-off-by: Shashank Ram <[email protected]>
  • Loading branch information
shashankram committed Apr 2, 2024
1 parent cf2d5f4 commit 06b5ea2
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 1,159 deletions.
10 changes: 3 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ install-deps: install-protoc
mkdir -p $(BINDIR)
GOBIN=$(BINDIR) go install github.com/golang/protobuf/protoc-gen-go

build-proto:
mkdir -p $(ROOTDIR)/protobuf/options
PATH=$(BINDIR):$(PATH) protoc -I=$(ROOTDIR)/protobuf/imports/ -I=$(ROOTDIR)/protobuf --go_out=$(OUTPUTDIR) $(ROOTDIR)/protobuf/options.proto
cp $(OUTPUTDIR)/github.com/solo-io/protoc-gen-openapi/protobuf/options/options.pb.go $(ROOTDIR)/protobuf/options

build: install-deps build-proto
build: install-deps
mkdir -p $(BINDIR)
go build -o $(BINDIR)/protoc-gen-openapi

Expand All @@ -32,6 +27,7 @@ gotest:
PROTOC_VERSION:=3.15.8
PROTOC_URL:=https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}
.PHONY: install-protoc
.SILENT: install-protoc
install-protoc:
mkdir -p $(BINDIR)
if [ $(shell ${BINDIR}/protoc --version | grep -c ${PROTOC_VERSION}) -ne 0 ]; then \
Expand All @@ -54,4 +50,4 @@ install-protoc:
fi

clean:
@rm -fr $(OUTPUTDIR) $(BINDIR)/protoc-gen-openapi
@rm -rf $(OUTPUTDIR)
58 changes: 38 additions & 20 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,68 +31,86 @@ func TestOpenAPIGeneration(t *testing.T) {
id string
perPackage bool
genOpts string
inputFiles map[string][]string
wantFiles []string
}{
{
name: "Per Package Generation",
id: "test1",
perPackage: true,
genOpts: "",
wantFiles: []string{"testpkg.json", "testpkg2.json"},
inputFiles: map[string][]string{
"testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"},
"testpkg2": {"./testdata/testpkg2/test3.proto"},
},
wantFiles: []string{"testpkg.json", "testpkg2.json"},
},
{
name: "Single File Generation",
id: "test2",
perPackage: false,
genOpts: "single_file=true",
wantFiles: []string{"openapiv3.json"},
inputFiles: map[string][]string{
"testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"},
"testpkg2": {"./testdata/testpkg2/test3.proto"},
},
wantFiles: []string{"openapiv3.json"},
},
{
name: "Use $ref in the output",
id: "test3",
perPackage: false,
genOpts: "single_file=true,use_ref=true",
wantFiles: []string{"testRef/openapiv3.json"},
inputFiles: map[string][]string{
"testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"},
"testpkg2": {"./testdata/testpkg2/test3.proto"},
},
wantFiles: []string{"testRef/openapiv3.json"},
},
{
name: "Use yaml, proto_oneof, int_native, and multiline_description",
name: "Use yaml, proto_oneof, int_native, validation rules, and multiline_description",
id: "test4",
perPackage: false,
genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true",
wantFiles: []string{"test4/openapiv3.yaml"},
inputFiles: map[string][]string{
"testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"},
"testpkg2": {"./testdata/testpkg2/test3.proto"},
},
wantFiles: []string{"test4/openapiv3.yaml"},
},
{
name: "Test validation rules",
id: "test5",
perPackage: false,
genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true",
inputFiles: map[string][]string{
"test5": {"./testdata/test5/rules.proto"},
},
wantFiles: []string{"test5/openapiv3.yaml"},
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
tempDir, err := os.MkdirTemp("", "openapi-temp")
if err != nil {
t.Fatal(err)
if len(tc.inputFiles) == 0 {
t.Fatalf("inputFiles must be set for test case %s", tc.name)
}
defer os.RemoveAll(tempDir)

// we assume that the package name is the same as the name of the folder containing the proto files.
packages := make(map[string][]string)
err = filepath.Walk("testdata", func(path string, info os.FileInfo, err error) error {
if strings.HasSuffix(path, ".proto") {
dir := filepath.Dir(path)
packages[dir] = append(packages[dir], path)
}
return nil
})
tempDir, err := os.MkdirTemp("", "openapi-temp")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)

if tc.perPackage {
for _, files := range packages {
for _, files := range tc.inputFiles {
args := []string{"-Itestdata", "--openapi_out=" + tc.genOpts + ":" + tempDir}
args = append(args, files...)
protocOpenAPI(t, args)
}
} else {
args := []string{"-Itestdata", "--openapi_out=" + tc.genOpts + ":" + tempDir}
for _, files := range packages {
for _, files := range tc.inputFiles {
args = append(args, files...)
}
protocOpenAPI(t, args)
Expand Down
70 changes: 47 additions & 23 deletions openapiGenerator.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,14 @@ import (
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"

"github.com/solo-io/protoc-gen-openapi/pkg/markers"
"github.com/solo-io/protoc-gen-openapi/pkg/protomodel"
"github.com/solo-io/protoc-gen-openapi/protobuf/options"
)

const (
validationMarker = "+kubebuilder:validation:"

hideMarker = "$hide_from_docs"
)

// Some special types with predefined schemas.
Expand Down Expand Up @@ -405,32 +411,34 @@ func (g *openapiGenerator) generateMessageSchema(message *protomodel.MessageDesc
}
o := openapi3.NewObjectSchema()
o.Description = g.generateDescription(message)
msgRules := g.validationRules(message)
markers.ApplyToSchema(o, msgRules)

oneOfFields := make(map[int32][]string)
for _, field := range message.Fields {
repeated := field.IsRepeated()
fieldName := g.fieldName(field)

opts, ok := proto.GetExtension(field.GetOptions(), options.E_Options).(*options.FieldOptions)
if ok && opts != nil {
fieldDesc := g.generateDescription(field)
repeated := field.IsRepeated()

switch {
case opts.GetTypeObject():
schema := specialSoloTypes["google.protobuf.Struct"]
schema.Description = fieldDesc
o.WithProperty(fieldName, getSchemaIfRepeated(&schema, repeated))
continue

case opts.GetTypeValue():
schema := specialSoloTypes["google.protobuf.Value"]
schema.Description = fieldDesc
o.WithProperty(fieldName, getSchemaIfRepeated(&schema, repeated))
continue
}
fieldDesc := g.generateDescription(field)
fieldRules := g.validationRules(field)

schemaType := markers.ParseType(fieldRules)
switch schemaType {
case markers.TypeObject:
schema := specialSoloTypes["google.protobuf.Struct"]
schema.Description = fieldDesc
markers.ApplyToSchema(&schema, fieldRules)
o.WithProperty(fieldName, getSchemaIfRepeated(&schema, repeated))
continue
case markers.TypeValue:
schema := specialSoloTypes["google.protobuf.Value"]
schema.Description = fieldDesc
markers.ApplyToSchema(&schema, fieldRules)
o.WithProperty(fieldName, getSchemaIfRepeated(&schema, repeated))
continue
}

sr := g.fieldTypeRef(field)
markers.ApplyToSchema(sr.Value, fieldRules)
o.WithProperty(fieldName, sr.Value)

// If the field is a oneof, we need to add the oneof property to the schema
Expand Down Expand Up @@ -604,12 +612,22 @@ func (g *openapiGenerator) generateMultiLineDescription(desc protomodel.CoreDesc
if !g.descriptionConfiguration.IncludeDescriptionInSchema {
return ""
}
comments, _ := g.parseComments(desc)
return comments
}

func (g *openapiGenerator) validationRules(desc protomodel.CoreDesc) []string {
_, validationRules := g.parseComments(desc)
return validationRules
}

func (g *openapiGenerator) parseComments(desc protomodel.CoreDesc) (comments string, validationRules []string) {
c := strings.TrimSpace(desc.Location().GetLeadingComments())
blocks := strings.Split(c, "\n\n")

var sb strings.Builder
for i, block := range blocks {
if strings.HasPrefix(strings.TrimSpace(block), "$hide_from_docs") {
if strings.HasPrefix(strings.TrimSpace(block), hideMarker) {
continue
}
if i > 0 {
Expand All @@ -621,7 +639,12 @@ func (g *openapiGenerator) generateMultiLineDescription(desc protomodel.CoreDesc
if i > 0 {
blockSb.WriteString("\n")
}
if strings.HasPrefix(strings.TrimSpace(line), "$hide_from_docs") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, hideMarker) {
continue
}
if strings.HasPrefix(l, validationMarker) {
validationRules = append(validationRules, l)
continue
}
if len(line) > 0 && line[0] == ' ' {
Expand All @@ -634,7 +657,8 @@ func (g *openapiGenerator) generateMultiLineDescription(desc protomodel.CoreDesc
sb.WriteString(block)
}

return sb.String()
comments = strings.TrimSpace(sb.String())
return
}

func (g *openapiGenerator) fieldType(field *protomodel.FieldDescriptor) *openapi3.Schema {
Expand Down
112 changes: 112 additions & 0 deletions pkg/markers/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package markers

import (
"fmt"
"log"
"strings"

"github.com/getkin/kin-openapi/openapi3"
)

const (
validationKey = "+kubebuilder:validation:"
validationsHeader = "x-kubernetes-validations"

ruleDelimiter = ";;"
)

const (
TypeObject = "object"
TypeValue = "value"
)

var (
typeKey = validationKey + "Type="
xValidationKey = validationKey + "XValidation:"
)

type ValidationRule struct {
Rule string `json:"rule"`
Message string `json:"message,omitempty"`
MessageMessageExpression string `json:"messageExpression,omitempty"`
}

func ParseType(rules []string) string {
for _, rule := range rules {
if strings.HasPrefix(rule, typeKey) {
return strings.TrimPrefix(rule, typeKey)
}
}
return ""
}

func ApplyToSchema(o *openapi3.Schema, rules []string) {
for _, rule := range rules {
err := applyRule(o, rule)
if err != nil {
log.Panicf("error applying rule: %v", err)
}
}
}

func applyRule(o *openapi3.Schema, rule string) error {
rule = strings.TrimSpace(rule)

switch {
case strings.HasPrefix(rule, xValidationKey):
rule, err := parseXValidationRule(strings.TrimPrefix(rule, xValidationKey))
if err != nil {
return err
}
applyXValidationRule(o, rule)

case strings.HasPrefix(rule, typeKey):
// ignore, already handled in ParseType

default:
return fmt.Errorf("unsupported validation rule: %s", rule)
}

return nil
}

func parseXValidationRule(ruleStr string) (ValidationRule, error) {
parts := strings.Split(ruleStr, ruleDelimiter)
var rule ValidationRule
for _, part := range parts {
part = strings.TrimSpace(part)
switch {
case strings.HasPrefix(part, "rule="):
rule.Rule = extractQuoted(strings.TrimPrefix(part, "rule="))
case strings.HasPrefix(part, "message="):
rule.Message = extractQuoted(strings.TrimPrefix(part, "message="))
case strings.HasPrefix(part, "messageExpression="):
rule.MessageMessageExpression = extractQuoted(strings.TrimPrefix(part, "messageExpression="))
}
}
if rule.Rule == "" {
return rule, fmt.Errorf("missing 'rule' field in rule: %s", ruleStr)
}
return rule, nil
}

func extractQuoted(s string) string {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
log.Panicf("validation rule fields must be quoted; got: %s", s)
return ""
}

func applyXValidationRule(o *openapi3.Schema, rule ValidationRule) {
if o.ExtensionProps.Extensions == nil {
o.ExtensionProps = openapi3.ExtensionProps{
Extensions: map[string]interface{}{
validationsHeader: []ValidationRule{},
},
}
} else if o.ExtensionProps.Extensions[validationsHeader] == nil {
o.ExtensionProps.Extensions[validationsHeader] = []ValidationRule{}
}
o.ExtensionProps.Extensions[validationsHeader] = append(o.ExtensionProps.Extensions[validationsHeader].([]ValidationRule), rule)
}
Loading

0 comments on commit 06b5ea2

Please sign in to comment.