├── changelog ├── v0.0.2 │ └── kin-openapi-v080.yaml ├── v0.3.0 │ └── go-124.yaml ├── v0.3.1 │ └── bump-kin-openapi.yaml ├── v0.4.0 │ └── bump-go.yaml ├── v0.1.1 │ └── add-uint64value.yaml ├── v0.0.1 │ └── kin-openapi-v080.yaml ├── v0.1.2 │ └── fix-test.yaml ├── v0.0.3 │ └── fix-googleprotobufValue-crd-validation.yaml ├── v0.1.0 │ └── support-empty-schema.yaml ├── v0.2.2 │ └── kubebuilder-markers.yaml ├── v0.2.3 │ └── disable-kube-markers-option.yaml ├── v0.2.4 │ ├── disableKubeMarkers-description.yaml │ └── disable-kube-markers_maintain-type-object.yaml ├── v0.2.5 │ └── allow_ignored_kube_markers.yaml ├── v0.2.1 │ └── oneof-fix.yaml └── v0.2.0 │ └── oneof-misc.yaml ├── tools.go ├── .github └── workflows │ └── test.yaml ├── testdata ├── testpkg │ ├── test2.proto │ ├── test1.proto │ └── test6.proto ├── golden │ ├── test7 │ │ └── openapiv3.yaml │ ├── test6 │ │ └── openapiv3.yaml │ ├── test10 │ │ └── openapiv3.yaml │ ├── test9 │ │ └── openapiv3.yaml │ ├── test5 │ │ └── openapiv3.yaml │ ├── test8 │ │ └── openapiv3.yaml │ ├── testpkg2.json │ ├── testpkg.json │ ├── test4 │ │ └── openapiv3.yaml │ ├── testRef │ │ └── openapiv3.json │ └── openapiv3.json ├── testpkg2 │ └── test3.proto ├── test6 │ └── markers.proto ├── test5 │ └── rules.proto ├── test10 │ └── markers.proto ├── test7 │ └── markers.proto ├── test8 │ └── markers.proto ├── test9 │ └── markers.proto └── struct.proto ├── .gitignore ├── go.mod ├── pkg ├── protomodel │ ├── locationDescriptor.go │ ├── serviceDescriptor.go │ ├── enumDescriptor.go │ ├── pathVector.go │ ├── packageDescriptor.go │ ├── messageDescriptor.go │ ├── frontMatter.go │ ├── baseDesc.go │ ├── fileDescriptor.go │ └── model.go ├── protocgen │ └── generate.go └── markers │ ├── register.go │ ├── validation.go │ └── constraints.go ├── Makefile ├── README.md ├── main.go ├── go.sum ├── integration_test.go ├── LICENSE └── openapiGenerator.go /changelog/v0.0.2/kin-openapi-v080.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: NON_USER_FACING 3 | description: adds `object` type for `google.protobuf.ListValue`. -------------------------------------------------------------------------------- /changelog/v0.3.0/go-124.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: BREAKING_CHANGE 3 | description: > 4 | Bump dependencies to support Go 1.24 5 | issueLink: https://github.com/solo-io/solo-projects/issues/7609 6 | resolvesIssue: false -------------------------------------------------------------------------------- /changelog/v0.3.1/bump-kin-openapi.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: DEPENDENCY_BUMP 3 | description: Update kin-openapi to v0.131.0 4 | dependencyOwner: kin-openapi 5 | dependencyRepo: kin-openapi 6 | dependencyTag: v0.131.0 7 | -------------------------------------------------------------------------------- /changelog/v0.4.0/bump-go.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: BREAKING_CHANGE 3 | description: > 4 | Bump dependencies to support Go 1.25 5 | issueLink: https://github.com/solo-io/solo-projects/issues/8681 6 | resolvesIssue: false 7 | -------------------------------------------------------------------------------- /changelog/v0.1.1/add-uint64value.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: FIX 3 | description: Support custom solo type `UInt64Value` and `Int64Value`. 4 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/10 5 | resolvesIssue: true 6 | -------------------------------------------------------------------------------- /changelog/v0.0.1/kin-openapi-v080.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: DEPENDENCY_BUMP 3 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/1 4 | dependencyOwner: getKin 5 | dependencyRepo: kin-openapi 6 | dependencyTag: v0.80.0 7 | -------------------------------------------------------------------------------- /changelog/v0.1.2/fix-test.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: FIX 3 | description: Fix bug in test pertaining to incorrect validation of expected files. 4 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/18 5 | resolvesIssue: true 6 | -------------------------------------------------------------------------------- /changelog/v0.0.3/fix-googleprotobufValue-crd-validation.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: NON_USER_FACING 3 | description: adds `object` type for `google.protobuf.ListValue`. 4 | issueLink: https://github.com/solo-io/gloo/issues/5936 5 | resolvesIssue: false -------------------------------------------------------------------------------- /changelog/v0.1.0/support-empty-schema.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: BREAKING_CHANGE 3 | description: Support injecting a set of message names to generate empty validation schema 4 | issueLink: https://github.com/solo-io/gloo/issues/4789 5 | resolvesIssue: false -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | // Explanation for tools pattern: 5 | // https://go.dev/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 6 | 7 | package tools 8 | 9 | import ( 10 | _ "github.com/golang/protobuf/protoc-gen-go" 11 | ) 12 | -------------------------------------------------------------------------------- /changelog/v0.2.2/kubebuilder-markers.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: NEW_FEATURE 3 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/22 4 | resolvesIssue: true 5 | description: | 6 | Adds support for additional kubebuilder markers to configure 7 | the generated OpenAPI schema. 8 | -------------------------------------------------------------------------------- /changelog/v0.2.3/disable-kube-markers-option.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: NEW_FEATURE 3 | issueLink: https://github.com/solo-io/gloo-mesh-enterprise/issues/17005 4 | resolvesIssue: false 5 | description: | 6 | Adds support for disabling kubebuilder markers and validations to omit them from the generated OpenAPI schema. 7 | -------------------------------------------------------------------------------- /changelog/v0.2.4/disableKubeMarkers-description.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: FIX 3 | issueLink: https://github.com/solo-io/gloo-mesh-enterprise/issues/17005 4 | resolvesIssue: false 5 | description: | 6 | Fixes the description of the disable_kube_markers option to correctly state which markers are respected and which are disabled. 7 | -------------------------------------------------------------------------------- /changelog/v0.2.5/allow_ignored_kube_markers.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: NEW_FEATURE 3 | issueLink: https://github.com/solo-io/gloo-mesh-enterprise/issues/18119 4 | resolvesIssue: false 5 | description: | 6 | Allows the user to define one or more kube markers to ignore. This is useful when using protos that contain 7 | unsupported kubebuilder decorators. -------------------------------------------------------------------------------- /changelog/v0.2.4/disable-kube-markers_maintain-type-object.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: FIX 3 | issueLink: https://github.com/solo-io/gloo-mesh-enterprise/issues/17005 4 | resolvesIssue: false 5 | description: | 6 | Ensures the `Type=object` marker is still respected when disabling kubebuilder markers and validations. This ensures recursive fields will still work. 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | go: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Go 1.22 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version: '1.22.x' 19 | 20 | - name: Run tests 21 | run: make build-and-test 22 | -------------------------------------------------------------------------------- /testdata/testpkg/test2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package testpkg; 4 | 5 | message Test2 { 6 | // field1 is a field 7 | int32 field1 = 1; 8 | 9 | // an array of strings 10 | repeated string str = 2; 11 | 12 | double field3 = 3; 13 | float field4 = 4; 14 | fixed32 field5 = 5; 15 | sfixed32 field6 = 6; 16 | sint32 field7 = 7; 17 | uint32 field8 = 8; 18 | int64 field9 = 9; 19 | uint64 field10 = 10; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # OSX Files 9 | .DS_Store 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # IDEA files 15 | *.idea/ 16 | .vscode 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | vendor*/ 21 | _output/ 22 | /solo-kit-gen 23 | **/doc-gen-test-* 24 | 25 | # Local binary produced by make build 26 | protoc-gen-openapi 27 | out/ 28 | _output/ 29 | -------------------------------------------------------------------------------- /testdata/testpkg/test1.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package testpkg; 4 | 5 | import "testpkg/test2.proto"; 6 | 7 | message Msg1 { 8 | int32 int32_field = 1; 9 | int64 int64_field = 2; 10 | string string_field = 3; 11 | bool bool_field = 4; 12 | double double_field = 5; 13 | float float_field = 6; 14 | testpkg.Test2 test2 = 7; 15 | } 16 | 17 | // Some days 18 | enum Days { 19 | // Urgh 20 | MONDAY = 0; 21 | TUESDAY = 1; 22 | // Humpday 23 | WEDNESDAY = 2; 24 | } 25 | -------------------------------------------------------------------------------- /changelog/v0.2.1/oneof-fix.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: FIX 3 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/20 4 | resolvesIssue: true 5 | description: | 6 | Adds support for the following: 7 | - encodes proto oneof to OpenAPI oneOf schema 8 | - multi-line descriptions for properties when using yaml=true 9 | - fixes uint32 schema to use min-max and not int32 format 10 | - adds proto for custom field options support 11 | - updates go protobuf packages to use new lib as per https://protobuf.dev/reference/go/faq/#versions 12 | - build changes for proto code generation 13 | -------------------------------------------------------------------------------- /changelog/v0.2.0/oneof-misc.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | - type: BREAKING_CHANGE 3 | issueLink: https://github.com/solo-io/protoc-gen-openapi/issues/20 4 | resolvesIssue: true 5 | description: | 6 | Adds support for the following: 7 | - encodes proto oneof to OpenAPI oneOf schema 8 | - multi-line descriptions for properties when using yaml=true 9 | - fixes uint32 schema to use min-max and not int32 format 10 | - adds proto for custom field options support 11 | - updates go protobuf packages to use new lib as per https://protobuf.dev/reference/go/faq/#versions 12 | - build changes for proto code generation 13 | -------------------------------------------------------------------------------- /testdata/testpkg/test6.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | // $title: My Title 4 | // $description: My Overview 5 | // $location: https://mysite.com/mypage.html 6 | // $weight: custom weight 7 | // $front_matter: foo: bar 8 | 9 | // Package-level comment 1 10 | package testpkg; 11 | 12 | // Some good words 13 | // Xsome Xbad xwords 14 | // `xsome xbad xwords` 15 | // some `xbad` words 16 | // some `xbas` xwords 17 | // some [bad](normallink) normal 18 | // some [bad][testpkg.TypeY] normal 19 | // ``` 20 | // doesntmatter 21 | // ``` 22 | // thismatters 23 | // this is fine 24 | message TypeX { 25 | } 26 | 27 | // Comment for this type 28 | message TypeY { 29 | 30 | } 31 | 32 | message TypeZ { 33 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/solo-io/protoc-gen-openapi 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/getkin/kin-openapi v0.131.0 7 | github.com/ghodss/yaml v1.0.0 8 | github.com/golang/protobuf v1.5.3 9 | google.golang.org/protobuf v1.31.0 10 | sigs.k8s.io/controller-tools v0.14.0 11 | ) 12 | 13 | require ( 14 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 15 | github.com/go-openapi/swag v0.23.0 // indirect 16 | github.com/josharian/intern v1.0.0 // indirect 17 | github.com/mailru/easyjson v0.7.7 // indirect 18 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 19 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 20 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 21 | github.com/perimeterx/marshmallow v1.1.5 // indirect 22 | golang.org/x/mod v0.21.0 // indirect 23 | golang.org/x/sync v0.8.0 // indirect 24 | golang.org/x/tools v0.26.0 // indirect 25 | gopkg.in/yaml.v2 v2.4.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | k8s.io/apimachinery v0.29.0 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /pkg/protomodel/locationDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "google.golang.org/protobuf/types/descriptorpb" 19 | ) 20 | 21 | type LocationDescriptor struct { 22 | *descriptorpb.SourceCodeInfo_Location 23 | File *FileDescriptor 24 | } 25 | 26 | func newLocationDescriptor(desc *descriptorpb.SourceCodeInfo_Location, file *FileDescriptor) LocationDescriptor { 27 | return LocationDescriptor{ 28 | SourceCodeInfo_Location: desc, 29 | File: file, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /testdata/golden/test7/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test7.Msg: 4 | description: This is a top-level message. 5 | properties: 6 | a: 7 | format: int32 8 | type: integer 9 | blist: 10 | items: 11 | type: string 12 | type: array 13 | nested: 14 | properties: 15 | a: 16 | type: string 17 | b: 18 | type: string 19 | c: 20 | type: string 21 | d: 22 | type: string 23 | defaultValue: 24 | type: string 25 | embedded: 26 | type: string 27 | intOrString: 28 | type: string 29 | schemaless: 30 | description: Schemaless field 31 | type: string 32 | required: 33 | - a 34 | - b 35 | type: object 36 | object: 37 | description: Should maintain valid Type marker and not enumerate subfields. 38 | type: object 39 | x-kubernetes-preserve-unknown-fields: true 40 | recursive: 41 | type: object 42 | x-kubernetes-preserve-unknown-fields: true 43 | val: 44 | x-kubernetes-preserve-unknown-fields: true 45 | type: object 46 | info: 47 | title: OpenAPI Spec for Solo APIs. 48 | version: "" 49 | openapi: 3.0.1 50 | paths: null 51 | -------------------------------------------------------------------------------- /testdata/testpkg2/test3.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | // $location: https://foo.com/bar.html 4 | 5 | package testpkg2; 6 | 7 | // Test3 is a message that I use for testing. 8 | message Test3 { 9 | // field1 is a field 10 | int32 field1 = 1; 11 | 12 | // an array of strings 13 | repeated string str = 2; 14 | 15 | double field3 = 3; 16 | float field4 = 4; 17 | fixed32 field5 = 5; 18 | sfixed32 field6 = 6; 19 | sint32 field7 = 7; 20 | uint32 field8 = 8; 21 | 22 | enum Nested { 23 | ONE = 0; 24 | TWO = 1; 25 | THREE = 2; 26 | } 27 | 28 | oneof OneofField { 29 | string oneoffield1 = 9; 30 | string oneoffield2 = 10; 31 | } 32 | 33 | message Test3Nested { 34 | string nested1 = 1; 35 | double nested2 = 2; 36 | } 37 | 38 | // first line of comment. 39 | // 40 | // second line of comment. 41 | map mapField = 11; 42 | 43 | reserved 12; 44 | 45 | string deprecate = 13 [deprecated=true]; 46 | 47 | Test3Nested test3Nested = 14; 48 | 49 | AEnum aEnum = 15; 50 | 51 | // messageoneof comment 52 | MessageOneOf messageOneOfField = 16; 53 | 54 | int64 field17 = 17; 55 | uint64 field18 = 18; 56 | } 57 | 58 | message Test3_Value { 59 | string field1 = 1; 60 | int32 field2 = 2; 61 | } 62 | 63 | enum AEnum { 64 | ONE = 0; 65 | TWO = 1; 66 | THREE = 2; 67 | } 68 | 69 | message MessageOneOf { 70 | oneof port { 71 | // Valid port number 72 | uint32 number = 1; 73 | // $hide_from_docs 74 | string name = 2; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /testdata/test6/markers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test6; 4 | 5 | // This is a top-level message. 6 | // 7 | // +kubebuilder:pruning:PreserveUnknownFields 8 | message Msg { 9 | // +kubebuilder:pruning:PreserveUnknownFields 10 | Nested nested = 1; 11 | 12 | // +kubebuilder:validation:Maximum=100 13 | // +kubebuilder:validation:Minimum=5 14 | // +kubebuilder:validation:ExclusiveMaximum=true 15 | // +kubebuilder:validation:ExclusiveMinimum=true 16 | // +kubebuilder:validation:MultipleOf=2 17 | int32 a = 2; 18 | 19 | // +kubebuilder:validation:MinItems=1 20 | // +kubebuilder:validation:MaxItems=5 21 | // +kubebuilder:validation:UniqueItems=true 22 | repeated string blist = 3; 23 | 24 | // This is a nested message. 25 | // 26 | // +kubebuilder:validation:MinProperties=1 27 | // +kubebuilder:validation:MaxProperties=2 28 | message Nested { 29 | // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_]*$" 30 | // +kubebuilder:validation:Required 31 | string a = 1; 32 | 33 | // +kubebuilder:validation:Enum=Allow;Forbid;Replace 34 | // +kubebuilder:validation:Required 35 | string b = 2; 36 | 37 | // +kubebuilder:validation:MaxLength=100 38 | // +kubebuilder:validation:MinLength=1 39 | string c = 3; 40 | 41 | // +kubebuilder:validation:Format=date-time 42 | string d = 4; 43 | 44 | // +kubebuilder:validation:XIntOrString 45 | string int_or_string = 5; 46 | 47 | // +kubebuilder:default=forty-two 48 | // +kubebuilder:example=forty-two 49 | string default_value = 6; 50 | 51 | // Schemaless field 52 | // 53 | // +kubebuilder:validation:Schemaless 54 | string schemaless = 7; 55 | 56 | // +kubebuilder:validation:EmbeddedResource 57 | // +kubebuilder:validation:Nullable 58 | string embedded = 8; 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /pkg/protomodel/serviceDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "google.golang.org/protobuf/types/descriptorpb" 19 | ) 20 | 21 | type ServiceDescriptor struct { 22 | baseDesc 23 | *descriptorpb.ServiceDescriptorProto 24 | Methods []*MethodDescriptor // Methods, if any 25 | } 26 | 27 | type MethodDescriptor struct { 28 | baseDesc 29 | *descriptorpb.MethodDescriptorProto 30 | Input *MessageDescriptor 31 | Output *MessageDescriptor 32 | } 33 | 34 | func newServiceDescriptor(desc *descriptorpb.ServiceDescriptorProto, file *FileDescriptor, path pathVector) *ServiceDescriptor { 35 | qualifiedName := []string{desc.GetName()} 36 | 37 | s := &ServiceDescriptor{ 38 | ServiceDescriptorProto: desc, 39 | baseDesc: newBaseDesc(file, path, qualifiedName), 40 | } 41 | 42 | for i, m := range desc.Method { 43 | nameCopy := make([]string, len(qualifiedName), len(qualifiedName)+1) 44 | copy(nameCopy, qualifiedName) 45 | nameCopy = append(nameCopy, m.GetName()) 46 | 47 | md := &MethodDescriptor{ 48 | MethodDescriptorProto: m, 49 | baseDesc: newBaseDesc(file, path.append(serviceMethodPath, i), nameCopy), 50 | } 51 | s.Methods = append(s.Methods, md) 52 | } 53 | 54 | return s 55 | } 56 | -------------------------------------------------------------------------------- /testdata/golden/test6/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test6.Msg: 4 | description: This is a top-level message. 5 | properties: 6 | a: 7 | exclusiveMaximum: true 8 | exclusiveMinimum: true 9 | format: int32 10 | maximum: 100 11 | minimum: 5 12 | multipleOf: 2 13 | type: integer 14 | blist: 15 | items: 16 | type: string 17 | maxItems: 5 18 | minItems: 1 19 | type: array 20 | uniqueItems: true 21 | nested: 22 | maxProperties: 2 23 | minProperties: 1 24 | properties: 25 | a: 26 | pattern: ^[a-zA-Z0-9_]*$ 27 | type: string 28 | b: 29 | enum: 30 | - Allow 31 | - Forbid 32 | - Replace 33 | type: string 34 | c: 35 | maxLength: 100 36 | minLength: 1 37 | type: string 38 | d: 39 | format: date-time 40 | type: string 41 | defaultValue: 42 | default: forty-two 43 | example: forty-two 44 | type: string 45 | embedded: 46 | nullable: true 47 | type: string 48 | x-kubernetes-embedded-resource: true 49 | intOrString: 50 | type: string 51 | x-kubernetes-int-or-string: true 52 | schemaless: 53 | description: Schemaless field 54 | required: 55 | - a 56 | - b 57 | type: object 58 | x-kubernetes-preserve-unknown-fields: true 59 | type: object 60 | x-kubernetes-preserve-unknown-fields: true 61 | info: 62 | title: OpenAPI Spec for Solo APIs. 63 | version: "" 64 | openapi: 3.0.1 65 | paths: null 66 | -------------------------------------------------------------------------------- /testdata/test5/rules.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test5; 4 | 5 | import "struct.proto"; 6 | 7 | // This is a top-level message. 8 | // 9 | // +kubebuilder:validation:XValidation:rule="self.msgNested.a > 0",message="must be a postive value" 10 | // +kubebuilder:validation:XValidation:rule="self.nonNested != ''",message="cannot be empty" 11 | // +kubebuilder:validation:XValidation:rule="self.msgNested.b != 'default'",messageExpression="'b got invalid value: ' + self.msgNested.x" 12 | message Msg { 13 | nested msg_nested = 1; 14 | 15 | // @exclude 16 | // this block comment 17 | string non_nested = 2; 18 | 19 | // +kubebuilder:validation:XValidation:rule="self.all(x, x.contains('a string'))",message="must contain 'a string'" 20 | repeated string list = 3; 21 | 22 | // Recursive object. 23 | // 24 | // +kubebuilder:validation:Type=object 25 | // +kubebuilder:validation:XValidation:rule="self.x = 1" 26 | repeated Recursive recursive_obj = 4; 27 | 28 | // Recursive value. 29 | // 30 | // +kubebuilder:validation:Type=value 31 | // +kubebuilder:validation:XValidation:rule="self.x = 1" 32 | repeated Recursive recursive_val = 5; 33 | 34 | // OneOf message. 35 | oneof oneof_field { 36 | string oa = 6; 37 | 38 | // +kubebuilder:validation:Type=value 39 | google.protobuf.Value ob = 7; 40 | 41 | // +kubebuilder:validation:Type=object 42 | google.protobuf.Struct oc = 8; 43 | } 44 | 45 | // This is a nested message. 46 | // 47 | // +kubebuilder:validation:XValidation:rule="self.a > 0",message="must be a postive value" 48 | // +kubebuilder:validation:XValidation:rule="self.b != 'default'",messageExpression="'b got invalid value: ' + self.x" 49 | message nested { 50 | int32 a = 1; 51 | string b = 2; 52 | } 53 | 54 | // @exclude this comment should not be included in the output schema 55 | message Recursive { 56 | Recursive r = 1; 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /pkg/protocgen/generate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 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 | package protocgen 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "os" 21 | 22 | "google.golang.org/protobuf/proto" 23 | "google.golang.org/protobuf/types/pluginpb" 24 | ) 25 | 26 | // GenerateFn is a function definition for encapsulating the ore logic of code generation. 27 | type GenerateFn func(req pluginpb.CodeGeneratorRequest) (*pluginpb.CodeGeneratorResponse, error) 28 | 29 | // Generate is a wrapper for a main function of a protoc generator plugin 30 | func Generate(fn GenerateFn) { 31 | data, err := io.ReadAll(os.Stdin) 32 | if err != nil { 33 | fatal("Unable to read input proto: %v\n", err) 34 | } 35 | 36 | var request pluginpb.CodeGeneratorRequest 37 | if err = proto.Unmarshal(data, &request); err != nil { 38 | fatal("Unable to parse input proto: %v\n", err) 39 | } 40 | 41 | response, err := fn(request) 42 | if err != nil { 43 | fatal("%v\n", err) 44 | } 45 | 46 | data, err = proto.Marshal(response) 47 | if err != nil { 48 | fatal("Unable to serialize output proto: %v\n", err) 49 | } 50 | 51 | _, err = os.Stdout.Write(data) 52 | if err != nil { 53 | fatal("Unable to write output proto: %v\n", err) 54 | } 55 | } 56 | 57 | func fatal(format string, args ...interface{}) { 58 | fmt.Fprintf(os.Stderr, "ERROR: "+format, args...) 59 | os.Exit(1) 60 | } 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: build run 3 | 4 | build-and-test: build gotest 5 | 6 | ROOTDIR := $(shell pwd) 7 | OUTPUTDIR = $(ROOTDIR)/_output 8 | BINDIR = $(OUTPUTDIR)/.bin 9 | 10 | .PHONY: install-deps 11 | install-deps: install-protoc 12 | mkdir -p $(BINDIR) 13 | GOBIN=$(BINDIR) go install github.com/golang/protobuf/protoc-gen-go 14 | 15 | build: install-deps 16 | mkdir -p $(BINDIR) 17 | go build -o $(BINDIR)/protoc-gen-openapi 18 | 19 | run: 20 | rm -fr $(OUTPUTDIR) 21 | mkdir -p $(OUTPUTDIR) 22 | protoc --plugin=./$(BINDIR)/protoc-gen-openapi --openapi_out=single_file=true,use_ref=true:$(OUTPUTDIR)/. -Itestdata testdata/testpkg/test1.proto testdata/testpkg/test2.proto testdata/testpkg/test6.proto testdata/testpkg2/test3.proto 23 | 24 | gotest: 25 | PATH=$(BINDIR):$(PATH) go test -v ./... 26 | 27 | PROTOC_VERSION:=3.15.8 28 | PROTOC_URL:=https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION} 29 | .PHONY: install-protoc 30 | .SILENT: install-protoc 31 | install-protoc: 32 | mkdir -p $(BINDIR) 33 | if [ $(shell ${BINDIR}/protoc --version | grep -c ${PROTOC_VERSION}) -ne 0 ]; then \ 34 | echo expected protoc version ${PROTOC_VERSION} already installed ;\ 35 | else \ 36 | if [ "$(shell uname)" = "Darwin" ]; then \ 37 | echo "downloading protoc for osx" ;\ 38 | wget $(PROTOC_URL)-osx-x86_64.zip -O $(BINDIR)/protoc-${PROTOC_VERSION}.zip ;\ 39 | elif [ "$(shell uname -m)" = "aarch64" ]; then \ 40 | echo "downloading protoc for linux aarch64" ;\ 41 | wget $(PROTOC_URL)-linux-aarch_64.zip -O $(BINDIR)/protoc-${PROTOC_VERSION}.zip ;\ 42 | else \ 43 | echo "downloading protoc for linux x86-64" ;\ 44 | wget $(PROTOC_URL)-linux-x86_64.zip -O $(BINDIR)/protoc-${PROTOC_VERSION}.zip ;\ 45 | fi ;\ 46 | unzip $(BINDIR)/protoc-${PROTOC_VERSION}.zip -d $(BINDIR)/protoc-${PROTOC_VERSION} ;\ 47 | mv $(BINDIR)/protoc-${PROTOC_VERSION}/bin/protoc $(BINDIR)/protoc ;\ 48 | chmod +x $(BINDIR)/protoc ;\ 49 | rm -rf $(BINDIR)/protoc-${PROTOC_VERSION} $(BINDIR)/protoc-${PROTOC_VERSION}.zip ;\ 50 | fi 51 | 52 | clean: 53 | @rm -rf $(OUTPUTDIR) 54 | -------------------------------------------------------------------------------- /pkg/protomodel/enumDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "google.golang.org/protobuf/types/descriptorpb" 19 | ) 20 | 21 | type EnumDescriptor struct { 22 | baseDesc 23 | *descriptorpb.EnumDescriptorProto 24 | Values []*EnumValueDescriptor // The values of this enum 25 | } 26 | 27 | type EnumValueDescriptor struct { 28 | baseDesc 29 | *descriptorpb.EnumValueDescriptorProto 30 | } 31 | 32 | func newEnumDescriptor(desc *descriptorpb.EnumDescriptorProto, parent *MessageDescriptor, file *FileDescriptor, path pathVector) *EnumDescriptor { 33 | var qualifiedName []string 34 | if parent == nil { 35 | qualifiedName = []string{desc.GetName()} 36 | } else { 37 | qualifiedName = make([]string, len(parent.QualifiedName()), len(parent.QualifiedName())+1) 38 | copy(qualifiedName, parent.QualifiedName()) 39 | qualifiedName = append(qualifiedName, desc.GetName()) 40 | } 41 | 42 | e := &EnumDescriptor{ 43 | EnumDescriptorProto: desc, 44 | baseDesc: newBaseDesc(file, path, qualifiedName), 45 | } 46 | 47 | e.Values = make([]*EnumValueDescriptor, 0, len(desc.Value)) 48 | for i, ev := range desc.Value { 49 | nameCopy := make([]string, len(qualifiedName), len(qualifiedName)+1) 50 | copy(nameCopy, qualifiedName) 51 | nameCopy = append(nameCopy, ev.GetName()) 52 | 53 | evd := &EnumValueDescriptor{ 54 | EnumValueDescriptorProto: ev, 55 | baseDesc: newBaseDesc(file, path.append(enumValuePath, i), nameCopy), 56 | } 57 | e.Values = append(e.Values, evd) 58 | } 59 | 60 | return e 61 | } 62 | -------------------------------------------------------------------------------- /pkg/protomodel/pathVector.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "strconv" 19 | ) 20 | 21 | // The SourceCodeInfo messages describes the location of elements of a parsed 22 | // .proto currentFile by way of a "path", which is a sequence of integers that 23 | // describe the route from a FileDescriptorProto to the relevant submessage. 24 | // The path alternates between a field number of a repeated field, and an index 25 | // into that repeated field. The constants below define the field numbers that 26 | // are used. 27 | // 28 | // See descriptor.proto for more information about this. 29 | const ( 30 | // tag numbers in FileDescriptorProto 31 | packagePath = 2 // package 32 | messagePath = 4 // message_type 33 | enumPath = 5 // enum_type 34 | servicePath = 6 // service 35 | 36 | // tag numbers in DescriptorProto 37 | messageFieldPath = 2 // field 38 | messageMessagePath = 3 // nested_type 39 | messageEnumPath = 4 // enum_type 40 | 41 | // tag numbers in EnumDescriptorProto 42 | enumValuePath = 2 // value 43 | 44 | // tag numbers in ServiceDescriptorProto 45 | serviceMethodPath = 2 // method 46 | ) 47 | 48 | // A vector of comma-separated integers which identify a particular entry in a 49 | // given's file location information 50 | type pathVector string 51 | 52 | func newPathVector(v int) pathVector { 53 | return pathVector(strconv.Itoa(v)) 54 | } 55 | 56 | func (pv pathVector) append(v ...int) pathVector { 57 | result := pv 58 | for _, val := range v { 59 | result += pathVector("," + strconv.Itoa(val)) 60 | } 61 | return result 62 | } 63 | -------------------------------------------------------------------------------- /pkg/protomodel/packageDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "google.golang.org/protobuf/types/descriptorpb" 22 | ) 23 | 24 | // PackageDescriptor describes a package, which is a composition of proto files. 25 | type PackageDescriptor struct { 26 | baseDesc 27 | Files []*FileDescriptor 28 | Name string 29 | } 30 | 31 | func newPackageDescriptor(name string, desc []*descriptorpb.FileDescriptorProto, perFile bool) *PackageDescriptor { 32 | p := &PackageDescriptor{ 33 | Name: name, 34 | } 35 | 36 | for _, fd := range desc { 37 | f := newFileDescriptor(fd, p) 38 | p.Files = append(p.Files, f) 39 | 40 | // The package's file is one that documents the package statement. 41 | // The first file to do this "wins". 42 | loc := f.find(newPathVector(packagePath)) 43 | if loc != nil { 44 | if p.loc == nil { 45 | if loc.GetLeadingComments() != "" || loc.GetTrailingComments() != "" { 46 | p.loc = loc 47 | p.file = f 48 | // Inherit only f's frontMatter, don't get title from one file 49 | } 50 | } else if !perFile { 51 | leading := loc.GetLeadingComments() 52 | trailing := loc.GetTrailingComments() 53 | if leading != "" || trailing != "" { 54 | _, _ = fmt.Fprintf(os.Stderr, "WARNING: package %v has a conflicting package comment in file %v.\n", 55 | name, f.GetName()) 56 | _, _ = fmt.Fprintf(os.Stderr, "Previous:\n%v\n%v\nCurrent:\n%v\n%v\n", p.loc.GetLeadingComments(), p.loc.GetTrailingComments(), leading, trailing) 57 | } 58 | } 59 | } 60 | } 61 | 62 | return p 63 | } 64 | -------------------------------------------------------------------------------- /pkg/markers/register.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "reflect" 5 | 6 | "sigs.k8s.io/controller-tools/pkg/markers" 7 | ) 8 | 9 | const ( 10 | TargetType markers.TargetType = markers.DescribesType 11 | TargetField markers.TargetType = markers.DescribesField 12 | ) 13 | 14 | type definitionWithHelp struct { 15 | *markers.Definition 16 | Help *markers.DefinitionHelp 17 | } 18 | 19 | type Registry struct { 20 | mRegistry *markers.Registry 21 | } 22 | 23 | func NewRegistry() (*Registry, error) { 24 | mReg := &markers.Registry{} 25 | r := &Registry{ 26 | mRegistry: mReg, 27 | } 28 | return r, Register(mReg) 29 | } 30 | 31 | func (d *definitionWithHelp) WithHelp(help *markers.DefinitionHelp) *definitionWithHelp { 32 | d.Help = help 33 | return d 34 | } 35 | 36 | func (d *definitionWithHelp) Register(reg *markers.Registry) error { 37 | if err := reg.Register(d.Definition); err != nil { 38 | return err 39 | } 40 | if d.Help != nil { 41 | reg.AddHelp(d.Definition, d.Help) 42 | } 43 | return nil 44 | } 45 | 46 | func must(def *markers.Definition, err error) *definitionWithHelp { 47 | return &definitionWithHelp{ 48 | Definition: markers.Must(def, err), 49 | } 50 | } 51 | 52 | // AllDefinitions contains all marker definitions for this package. 53 | var AllDefinitions []*definitionWithHelp 54 | 55 | type hasHelp interface { 56 | Help() *markers.DefinitionHelp 57 | } 58 | 59 | // mustMakeAllWithPrefix converts each object into a marker definition using 60 | // the object's type's with the prefix to form the marker name. 61 | func mustMakeAllWithPrefix(prefix string, target markers.TargetType, objs ...interface{}) []*definitionWithHelp { 62 | defs := make([]*definitionWithHelp, len(objs)) 63 | for i, obj := range objs { 64 | name := prefix + ":" + reflect.TypeOf(obj).Name() 65 | def, err := markers.MakeDefinition(name, target, obj) 66 | if err != nil { 67 | panic(err) 68 | } 69 | defs[i] = &definitionWithHelp{Definition: def} 70 | } 71 | 72 | return defs 73 | } 74 | 75 | // Register registers all definitions for CRD generation to the given registry. 76 | func Register(reg *markers.Registry) error { 77 | for _, def := range AllDefinitions { 78 | if err := def.Register(reg); err != nil { 79 | return err 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /testdata/golden/test10/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test10.Msg: 4 | description: This is a top-level message. 5 | properties: 6 | a: 7 | exclusiveMaximum: true 8 | exclusiveMinimum: true 9 | format: int32 10 | maximum: 100 11 | minimum: 5 12 | multipleOf: 2 13 | type: integer 14 | x-kubernetes-validations: 15 | - message: must not equal 27 16 | rule: self != 27 17 | blist: 18 | items: 19 | type: string 20 | maxItems: 5 21 | minItems: 1 22 | type: array 23 | uniqueItems: true 24 | nested: 25 | maxProperties: 2 26 | minProperties: 1 27 | properties: 28 | a: 29 | pattern: ^[a-zA-Z0-9_]*$ 30 | type: string 31 | b: 32 | enum: 33 | - Allow 34 | - Forbid 35 | - Replace 36 | type: string 37 | c: 38 | maxLength: 100 39 | minLength: 1 40 | type: string 41 | d: 42 | format: date-time 43 | type: string 44 | defaultValue: 45 | default: forty-two 46 | type: string 47 | embedded: 48 | nullable: true 49 | type: string 50 | x-kubernetes-embedded-resource: true 51 | intOrString: 52 | type: string 53 | x-kubernetes-int-or-string: true 54 | schemaless: 55 | description: Schemaless field 56 | type: object 57 | x-kubernetes-preserve-unknown-fields: true 58 | object: 59 | description: Should maintain valid Type marker and not enumerate subfields. 60 | type: object 61 | x-kubernetes-preserve-unknown-fields: true 62 | recursive: 63 | type: object 64 | x-kubernetes-preserve-unknown-fields: true 65 | val: 66 | x-kubernetes-preserve-unknown-fields: true 67 | type: object 68 | x-kubernetes-preserve-unknown-fields: true 69 | info: 70 | title: OpenAPI Spec for Solo APIs. 71 | version: "" 72 | openapi: 3.0.1 73 | paths: null 74 | -------------------------------------------------------------------------------- /testdata/golden/test9/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test9.Msg: 4 | description: This is a top-level message. 5 | properties: 6 | a: 7 | exclusiveMaximum: true 8 | exclusiveMinimum: true 9 | format: int32 10 | maximum: 100 11 | minimum: 5 12 | multipleOf: 2 13 | type: integer 14 | x-kubernetes-validations: 15 | - message: must not equal 27 16 | rule: self != 27 17 | blist: 18 | items: 19 | type: string 20 | maxItems: 5 21 | minItems: 1 22 | type: array 23 | uniqueItems: true 24 | nested: 25 | maxProperties: 2 26 | minProperties: 1 27 | properties: 28 | a: 29 | pattern: ^[a-zA-Z0-9_]*$ 30 | type: string 31 | b: 32 | enum: 33 | - Allow 34 | - Forbid 35 | - Replace 36 | type: string 37 | c: 38 | maxLength: 100 39 | minLength: 1 40 | type: string 41 | d: 42 | format: date-time 43 | type: string 44 | defaultValue: 45 | default: forty-two 46 | example: forty-two 47 | type: string 48 | embedded: 49 | nullable: true 50 | type: string 51 | x-kubernetes-embedded-resource: true 52 | intOrString: 53 | type: string 54 | x-kubernetes-int-or-string: true 55 | schemaless: 56 | description: Schemaless field 57 | type: object 58 | x-kubernetes-preserve-unknown-fields: true 59 | object: 60 | description: Should maintain valid Type marker and not enumerate subfields. 61 | type: object 62 | x-kubernetes-preserve-unknown-fields: true 63 | recursive: 64 | type: object 65 | x-kubernetes-preserve-unknown-fields: true 66 | val: 67 | x-kubernetes-preserve-unknown-fields: true 68 | type: object 69 | x-kubernetes-preserve-unknown-fields: true 70 | info: 71 | title: OpenAPI Spec for Solo APIs. 72 | version: "" 73 | openapi: 3.0.1 74 | paths: null 75 | -------------------------------------------------------------------------------- /testdata/golden/test5/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test5.Msg: 4 | description: This is a top-level message. 5 | oneOf: 6 | - not: 7 | anyOf: 8 | - required: 9 | - oa 10 | - required: 11 | - ob 12 | - required: 13 | - oc 14 | - required: 15 | - oa 16 | - required: 17 | - ob 18 | - required: 19 | - oc 20 | properties: 21 | list: 22 | items: 23 | type: string 24 | type: array 25 | x-kubernetes-validations: 26 | - message: must contain 'a string' 27 | rule: self.all(x, x.contains('a string')) 28 | msgNested: 29 | properties: 30 | a: 31 | format: int32 32 | type: integer 33 | b: 34 | type: string 35 | type: object 36 | x-kubernetes-validations: 37 | - message: must be a postive value 38 | rule: self.a > 0 39 | - messageExpression: '''b got invalid value: '' + self.x' 40 | rule: self.b != 'default' 41 | nonNested: 42 | type: string 43 | oa: 44 | type: string 45 | ob: 46 | x-kubernetes-preserve-unknown-fields: true 47 | oc: 48 | type: object 49 | x-kubernetes-preserve-unknown-fields: true 50 | recursiveObj: 51 | description: Recursive object. 52 | items: 53 | type: object 54 | x-kubernetes-preserve-unknown-fields: true 55 | type: array 56 | x-kubernetes-validations: 57 | - rule: self.x = 1 58 | recursiveVal: 59 | description: Recursive value. 60 | items: 61 | x-kubernetes-preserve-unknown-fields: true 62 | type: array 63 | x-kubernetes-validations: 64 | - rule: self.x = 1 65 | type: object 66 | x-kubernetes-validations: 67 | - message: must be a postive value 68 | rule: self.msgNested.a > 0 69 | - message: cannot be empty 70 | rule: self.nonNested != '' 71 | - messageExpression: '''b got invalid value: '' + self.msgNested.x' 72 | rule: self.msgNested.b != 'default' 73 | info: 74 | title: OpenAPI Spec for Solo APIs. 75 | version: "" 76 | openapi: 3.0.1 77 | paths: null 78 | -------------------------------------------------------------------------------- /testdata/golden/test8/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | test8.Msg: 4 | description: This is a top-level message. 5 | properties: 6 | a: 7 | exclusiveMaximum: true 8 | exclusiveMinimum: true 9 | format: int32 10 | maximum: 100 11 | minimum: 5 12 | multipleOf: 2 13 | type: integer 14 | x-kubernetes-validations: 15 | - message: must not equal 27 16 | rule: self != 27 17 | blist: 18 | items: 19 | type: string 20 | maxItems: 5 21 | minItems: 1 22 | type: array 23 | uniqueItems: true 24 | nested: 25 | maxProperties: 2 26 | minProperties: 1 27 | properties: 28 | a: 29 | pattern: ^[a-zA-Z0-9_]*$ 30 | type: string 31 | b: 32 | enum: 33 | - Allow 34 | - Forbid 35 | - Replace 36 | type: string 37 | c: 38 | maxLength: 100 39 | minLength: 1 40 | type: string 41 | d: 42 | format: date-time 43 | type: string 44 | defaultValue: 45 | default: forty-two 46 | example: forty-two 47 | type: string 48 | embedded: 49 | nullable: true 50 | type: string 51 | x-kubernetes-embedded-resource: true 52 | intOrString: 53 | type: string 54 | x-kubernetes-int-or-string: true 55 | schemaless: 56 | description: Schemaless field 57 | required: 58 | - a 59 | - b 60 | type: object 61 | x-kubernetes-preserve-unknown-fields: true 62 | object: 63 | description: Should maintain valid Type marker and not enumerate subfields. 64 | type: object 65 | x-kubernetes-preserve-unknown-fields: true 66 | recursive: 67 | type: object 68 | x-kubernetes-preserve-unknown-fields: true 69 | val: 70 | x-kubernetes-preserve-unknown-fields: true 71 | type: object 72 | x-kubernetes-preserve-unknown-fields: true 73 | info: 74 | title: OpenAPI Spec for Solo APIs. 75 | version: "" 76 | openapi: 3.0.1 77 | paths: null 78 | -------------------------------------------------------------------------------- /testdata/test10/markers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test10; 4 | 5 | import "struct.proto"; 6 | 7 | // This is a top-level message. 8 | // 9 | // +kubebuilder:pruning:PreserveUnknownFields 10 | message Msg { 11 | // +kubebuilder:pruning:PreserveUnknownFields 12 | Nested nested = 1; 13 | 14 | // +kubebuilder:validation:Maximum=100 15 | // +kubebuilder:validation:Minimum=5 16 | // +kubebuilder:validation:ExclusiveMaximum=true 17 | // +kubebuilder:validation:ExclusiveMinimum=true 18 | // +kubebuilder:validation:MultipleOf=2 19 | // +kubebuilder:validation:XValidation:rule="self != 27",message="must not equal 27" 20 | int32 a = 2; 21 | 22 | // +kubebuilder:validation:MinItems=1 23 | // +kubebuilder:validation:MaxItems=5 24 | // +kubebuilder:validation:UniqueItems=true 25 | repeated string blist = 3; 26 | 27 | // +kubebuilder:validation:Type=value 28 | google.protobuf.Value val = 4; 29 | 30 | // Should maintain valid Type marker and not enumerate subfields. 31 | // 32 | // +kubebuilder:validation:Type=object 33 | Nested2 object = 5; 34 | 35 | // +kubebuilder:validation:Type=object 36 | Recursive recursive = 6; 37 | 38 | // This is a nested message. 39 | // 40 | // +kubebuilder:validation:MinProperties=1 41 | // +kubebuilder:validation:MaxProperties=2 42 | message Nested { 43 | // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_]*$" 44 | // +kubebuilder:validation:Required 45 | string a = 1; 46 | 47 | // +kubebuilder:validation:Enum=Allow;Forbid;Replace 48 | // +kubebuilder:validation:Required 49 | string b = 2; 50 | 51 | // +kubebuilder:validation:MaxLength=100 52 | // +kubebuilder:validation:MinLength=1 53 | string c = 3; 54 | 55 | // +kubebuilder:validation:Format=date-time 56 | string d = 4; 57 | 58 | // +kubebuilder:validation:XIntOrString 59 | string int_or_string = 5; 60 | 61 | // +kubebuilder:default=forty-two 62 | // +kubebuilder:example=forty-two 63 | string default_value = 6; 64 | 65 | // Schemaless field 66 | // 67 | // +kubebuilder:validation:Schemaless 68 | string schemaless = 7; 69 | 70 | // +kubebuilder:validation:EmbeddedResource 71 | // +kubebuilder:validation:Nullable 72 | string embedded = 8; 73 | } 74 | 75 | message Nested2 { 76 | string a = 1; 77 | string b = 2; 78 | int32 c = 3; 79 | } 80 | 81 | message Recursive { 82 | Recursive r = 1; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /testdata/test7/markers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test7; 4 | 5 | import "struct.proto"; 6 | 7 | // This is a top-level message. 8 | // 9 | // +kubebuilder:pruning:PreserveUnknownFields 10 | message Msg { 11 | // +kubebuilder:pruning:PreserveUnknownFields 12 | Nested nested = 1; 13 | 14 | // +kubebuilder:validation:Maximum=100 15 | // +kubebuilder:validation:Minimum=5 16 | // +kubebuilder:validation:ExclusiveMaximum=true 17 | // +kubebuilder:validation:ExclusiveMinimum=true 18 | // +kubebuilder:validation:MultipleOf=2 19 | // +kubebuilder:validation:XValidation:rule="self != 27",message="must not equal 27" 20 | int32 a = 2; 21 | 22 | // +kubebuilder:validation:MinItems=1 23 | // +kubebuilder:validation:MaxItems=5 24 | // +kubebuilder:validation:UniqueItems=true 25 | repeated string blist = 3; 26 | 27 | // +kubebuilder:validation:Type=value 28 | google.protobuf.Value val = 4; 29 | 30 | // Should maintain valid Type marker and not enumerate subfields. 31 | // 32 | // +kubebuilder:validation:Type=object 33 | Nested2 object = 5; 34 | 35 | // +kubebuilder:validation:Type=object 36 | Recursive recursive = 6; 37 | 38 | // This is a nested message. 39 | // 40 | // +kubebuilder:validation:MinProperties=1 41 | // +kubebuilder:validation:MaxProperties=2 42 | message Nested { 43 | // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_]*$" 44 | // +kubebuilder:validation:Required 45 | string a = 1; 46 | 47 | // +kubebuilder:validation:Enum=Allow;Forbid;Replace 48 | // +kubebuilder:validation:Required 49 | string b = 2; 50 | 51 | // +kubebuilder:validation:MaxLength=100 52 | // +kubebuilder:validation:MinLength=1 53 | string c = 3; 54 | 55 | // +kubebuilder:validation:Format=date-time 56 | string d = 4; 57 | 58 | // +kubebuilder:validation:XIntOrString 59 | string int_or_string = 5; 60 | 61 | // +kubebuilder:default=forty-two 62 | // +kubebuilder:example=forty-two 63 | string default_value = 6; 64 | 65 | // Schemaless field 66 | // 67 | // +kubebuilder:validation:Schemaless 68 | string schemaless = 7; 69 | 70 | // +kubebuilder:validation:EmbeddedResource 71 | // +kubebuilder:validation:Nullable 72 | string embedded = 8; 73 | } 74 | 75 | message Nested2 { 76 | string a = 1; 77 | string b = 2; 78 | int32 c = 3; 79 | } 80 | 81 | message Recursive { 82 | Recursive r = 1; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /testdata/test8/markers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test8; 4 | 5 | import "struct.proto"; 6 | 7 | // This is a top-level message. 8 | // 9 | // +kubebuilder:pruning:PreserveUnknownFields 10 | message Msg { 11 | // +kubebuilder:pruning:PreserveUnknownFields 12 | Nested nested = 1; 13 | 14 | // +kubebuilder:validation:Maximum=100 15 | // +kubebuilder:validation:Minimum=5 16 | // +kubebuilder:validation:ExclusiveMaximum=true 17 | // +kubebuilder:validation:ExclusiveMinimum=true 18 | // +kubebuilder:validation:MultipleOf=2 19 | // +kubebuilder:validation:XValidation:rule="self != 27",message="must not equal 27" 20 | int32 a = 2; 21 | 22 | // +kubebuilder:validation:MinItems=1 23 | // +kubebuilder:validation:MaxItems=5 24 | // +kubebuilder:validation:UniqueItems=true 25 | repeated string blist = 3; 26 | 27 | // +kubebuilder:validation:Type=value 28 | google.protobuf.Value val = 4; 29 | 30 | // Should maintain valid Type marker and not enumerate subfields. 31 | // 32 | // +kubebuilder:validation:Type=object 33 | Nested2 object = 5; 34 | 35 | // +kubebuilder:validation:Type=object 36 | Recursive recursive = 6; 37 | 38 | // This is a nested message. 39 | // 40 | // +kubebuilder:validation:MinProperties=1 41 | // +kubebuilder:validation:MaxProperties=2 42 | message Nested { 43 | // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_]*$" 44 | // +kubebuilder:validation:Required 45 | string a = 1; 46 | 47 | // +kubebuilder:validation:Enum=Allow;Forbid;Replace 48 | // +kubebuilder:validation:Required 49 | string b = 2; 50 | 51 | // +kubebuilder:validation:MaxLength=100 52 | // +kubebuilder:validation:MinLength=1 53 | string c = 3; 54 | 55 | // +kubebuilder:validation:Format=date-time 56 | string d = 4; 57 | 58 | // +kubebuilder:validation:XIntOrString 59 | string int_or_string = 5; 60 | 61 | // +kubebuilder:default=forty-two 62 | // +kubebuilder:example=forty-two 63 | string default_value = 6; 64 | 65 | // Schemaless field 66 | // 67 | // +kubebuilder:validation:Schemaless 68 | string schemaless = 7; 69 | 70 | // +kubebuilder:validation:EmbeddedResource 71 | // +kubebuilder:validation:Nullable 72 | string embedded = 8; 73 | } 74 | 75 | message Nested2 { 76 | string a = 1; 77 | string b = 2; 78 | int32 c = 3; 79 | } 80 | 81 | message Recursive { 82 | Recursive r = 1; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /testdata/test9/markers.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package test9; 4 | 5 | import "struct.proto"; 6 | 7 | // This is a top-level message. 8 | // 9 | // +kubebuilder:pruning:PreserveUnknownFields 10 | message Msg { 11 | // +kubebuilder:pruning:PreserveUnknownFields 12 | Nested nested = 1; 13 | 14 | // +kubebuilder:validation:Maximum=100 15 | // +kubebuilder:validation:Minimum=5 16 | // +kubebuilder:validation:ExclusiveMaximum=true 17 | // +kubebuilder:validation:ExclusiveMinimum=true 18 | // +kubebuilder:validation:MultipleOf=2 19 | // +kubebuilder:validation:XValidation:rule="self != 27",message="must not equal 27" 20 | int32 a = 2; 21 | 22 | // +kubebuilder:validation:MinItems=1 23 | // +kubebuilder:validation:MaxItems=5 24 | // +kubebuilder:validation:UniqueItems=true 25 | repeated string blist = 3; 26 | 27 | // +kubebuilder:validation:Type=value 28 | google.protobuf.Value val = 4; 29 | 30 | // Should maintain valid Type marker and not enumerate subfields. 31 | // 32 | // +kubebuilder:validation:Type=object 33 | Nested2 object = 5; 34 | 35 | // +kubebuilder:validation:Type=object 36 | Recursive recursive = 6; 37 | 38 | // This is a nested message. 39 | // 40 | // +kubebuilder:validation:MinProperties=1 41 | // +kubebuilder:validation:MaxProperties=2 42 | message Nested { 43 | // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_]*$" 44 | // +kubebuilder:validation:Required 45 | string a = 1; 46 | 47 | // +kubebuilder:validation:Enum=Allow;Forbid;Replace 48 | // +kubebuilder:validation:Required 49 | string b = 2; 50 | 51 | // +kubebuilder:validation:MaxLength=100 52 | // +kubebuilder:validation:MinLength=1 53 | string c = 3; 54 | 55 | // +kubebuilder:validation:Format=date-time 56 | string d = 4; 57 | 58 | // +kubebuilder:validation:XIntOrString 59 | string int_or_string = 5; 60 | 61 | // +kubebuilder:default=forty-two 62 | // +kubebuilder:example=forty-two 63 | string default_value = 6; 64 | 65 | // Schemaless field 66 | // 67 | // +kubebuilder:validation:Schemaless 68 | string schemaless = 7; 69 | 70 | // +kubebuilder:validation:EmbeddedResource 71 | // +kubebuilder:validation:Nullable 72 | string embedded = 8; 73 | } 74 | 75 | message Nested2 { 76 | string a = 1; 77 | string b = 2; 78 | int32 c = 3; 79 | } 80 | 81 | message Recursive { 82 | Recursive r = 1; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /pkg/protomodel/messageDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "google.golang.org/protobuf/types/descriptorpb" 19 | ) 20 | 21 | type MessageDescriptor struct { 22 | baseDesc 23 | *descriptorpb.DescriptorProto 24 | Parent *MessageDescriptor // The containing message, if any 25 | Messages []*MessageDescriptor // Inner messages, if any 26 | Enums []*EnumDescriptor // Inner enums, if any 27 | Fields []*FieldDescriptor // Fields, if any 28 | } 29 | 30 | type FieldDescriptor struct { 31 | baseDesc 32 | *descriptorpb.FieldDescriptorProto 33 | FieldType CoreDesc // Type of data held by this field 34 | } 35 | 36 | func newMessageDescriptor(desc *descriptorpb.DescriptorProto, parent *MessageDescriptor, file *FileDescriptor, path pathVector) *MessageDescriptor { 37 | var qualifiedName []string 38 | if parent == nil { 39 | qualifiedName = []string{desc.GetName()} 40 | } else { 41 | qualifiedName = make([]string, len(parent.QualifiedName()), len(parent.QualifiedName())+1) 42 | copy(qualifiedName, parent.QualifiedName()) 43 | qualifiedName = append(qualifiedName, desc.GetName()) 44 | } 45 | 46 | m := &MessageDescriptor{ 47 | DescriptorProto: desc, 48 | Parent: parent, 49 | baseDesc: newBaseDesc(file, path, qualifiedName), 50 | } 51 | 52 | for i, f := range desc.Field { 53 | nameCopy := make([]string, len(qualifiedName), len(qualifiedName)+1) 54 | copy(nameCopy, qualifiedName) 55 | nameCopy = append(nameCopy, f.GetName()) 56 | 57 | fd := &FieldDescriptor{ 58 | FieldDescriptorProto: f, 59 | baseDesc: newBaseDesc(file, path.append(messageFieldPath, i), nameCopy), 60 | } 61 | 62 | m.Fields = append(m.Fields, fd) 63 | } 64 | 65 | for i, msg := range desc.NestedType { 66 | m.Messages = append(m.Messages, newMessageDescriptor(msg, m, file, path.append(messageMessagePath, i))) 67 | } 68 | 69 | for i, e := range desc.EnumType { 70 | m.Enums = append(m.Enums, newEnumDescriptor(e, m, file, path.append(messageEnumPath, i))) 71 | } 72 | 73 | return m 74 | } 75 | 76 | func (f *FieldDescriptor) IsRepeated() bool { 77 | return f.Label != nil && *f.Label == descriptorpb.FieldDescriptorProto_LABEL_REPEATED 78 | } 79 | -------------------------------------------------------------------------------- /pkg/protomodel/frontMatter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "strings" 21 | 22 | "google.golang.org/protobuf/types/descriptorpb" 23 | ) 24 | 25 | type FrontMatter struct { 26 | Title string 27 | Overview string 28 | Description string 29 | HomeLocation string 30 | Extra []string 31 | Location LocationDescriptor 32 | } 33 | 34 | const ( 35 | titleTag = "$title: " 36 | overviewTag = "$overview: " 37 | descriptionTag = "$description: " 38 | locationTag = "$location: " 39 | frontMatterTag = "$front_matter: " 40 | ) 41 | 42 | func checkSingle(name string, old string, line string, tag string) string { 43 | result := line[len(tag):] 44 | if old != "" { 45 | _, _ = fmt.Fprintf(os.Stderr, "%v has more than one %v: %v\n", name, tag, result) 46 | } 47 | return result 48 | } 49 | 50 | func extractFrontMatter(name string, loc *descriptorpb.SourceCodeInfo_Location, file *FileDescriptor) FrontMatter { 51 | title := "" 52 | overview := "" 53 | description := "" 54 | homeLocation := "" 55 | var extra []string 56 | 57 | for _, para := range loc.LeadingDetachedComments { 58 | lines := strings.Split(para, "\n") 59 | for _, l := range lines { 60 | l = strings.Trim(l, " ") 61 | 62 | if strings.HasPrefix(l, "$") { 63 | if strings.HasPrefix(l, titleTag) { 64 | title = checkSingle(name, title, l, titleTag) 65 | } else if strings.HasPrefix(l, overviewTag) { 66 | overview = checkSingle(name, overview, l, overviewTag) 67 | } else if strings.HasPrefix(l, descriptionTag) { 68 | description = checkSingle(name, description, l, descriptionTag) 69 | } else if strings.HasPrefix(l, locationTag) { 70 | homeLocation = checkSingle(name, homeLocation, l, locationTag) 71 | } else if strings.HasPrefix(l, frontMatterTag) { 72 | // old way to specify custom front-matter 73 | extra = append(extra, l[len(frontMatterTag):]) 74 | } else { 75 | extra = append(extra, l[1:]) 76 | } 77 | } 78 | } 79 | } 80 | 81 | return FrontMatter{ 82 | Title: title, 83 | Overview: overview, 84 | Description: description, 85 | HomeLocation: homeLocation, 86 | Extra: extra, 87 | Location: newLocationDescriptor(loc, file), 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pkg/protomodel/baseDesc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 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 | package protomodel 16 | 17 | import ( 18 | "strings" 19 | 20 | "google.golang.org/protobuf/types/descriptorpb" 21 | ) 22 | 23 | // CoreDesc is an interface abstracting the abilities shared by all descriptors 24 | type CoreDesc interface { 25 | PackageDesc() *PackageDescriptor 26 | FileDesc() *FileDescriptor 27 | QualifiedName() []string 28 | IsHidden() bool 29 | Class() string 30 | Location() LocationDescriptor 31 | } 32 | 33 | // The common data for every descriptor in the model. This implements the coreDesc interface. 34 | type baseDesc struct { 35 | loc *descriptorpb.SourceCodeInfo_Location 36 | hidden bool 37 | cl string 38 | file *FileDescriptor 39 | name []string 40 | } 41 | 42 | func newBaseDesc(file *FileDescriptor, path pathVector, qualifiedName []string) baseDesc { 43 | loc := file.find(path) 44 | cl := "" 45 | com := "" 46 | 47 | if loc != nil { 48 | var newCom string 49 | com = loc.GetLeadingComments() 50 | if com != "" { 51 | cl, newCom = getClass(com) 52 | if cl != "" { 53 | clone := *loc 54 | clone.LeadingComments = &newCom 55 | loc = &clone 56 | } 57 | } else { 58 | com = loc.GetTrailingComments() 59 | if com != "" { 60 | cl, newCom = getClass(com) 61 | if cl != "" { 62 | clone := *loc 63 | clone.TrailingComments = &newCom 64 | loc = &clone 65 | } 66 | } 67 | } 68 | } 69 | 70 | return baseDesc{ 71 | file: file, 72 | loc: loc, 73 | hidden: strings.Contains(com, "$hide_from_docs") || strings.Contains(com, "[#not-implemented-hide:]"), 74 | cl: cl, 75 | name: qualifiedName, 76 | } 77 | } 78 | 79 | const class = "$class: " 80 | 81 | func getClass(com string) (cl string, newCom string) { 82 | start := strings.Index(com, class) 83 | if start < 0 { 84 | return 85 | } 86 | 87 | name := start + len(class) 88 | end := strings.IndexAny(com[name:], " \t\n") + start + len(class) 89 | 90 | if end < 0 { 91 | newCom = com[:start] 92 | cl = com[name:] 93 | } else { 94 | newCom = com[:start] + com[end:] 95 | cl = com[name:end] 96 | } 97 | 98 | return 99 | } 100 | 101 | func (bd baseDesc) PackageDesc() *PackageDescriptor { 102 | return bd.file.Parent 103 | } 104 | 105 | func (bd baseDesc) FileDesc() *FileDescriptor { 106 | return bd.file 107 | } 108 | 109 | func (bd baseDesc) QualifiedName() []string { 110 | return bd.name 111 | } 112 | 113 | func (bd baseDesc) IsHidden() bool { 114 | return bd.hidden 115 | } 116 | 117 | func (bd baseDesc) Class() string { 118 | return bd.cl 119 | } 120 | 121 | func (bd baseDesc) Location() LocationDescriptor { 122 | return newLocationDescriptor(bd.loc, bd.file) 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## What's this for? 3 | 4 | `protoc-gen-openapi` is a plugin for the Google protocol buffer compiler to generate 5 | openAPI V3 spec for any given input protobuf. It runs as a `protoc-gen-` binary that the 6 | protobuf compiler infers from the `openapi_out` flag. 7 | 8 | ## Build `protoc-gen-openapi` 9 | 10 | `protoc-gen-openapi` is written in Go, so ensure that is installed on your system. You 11 | can follow the instructions on the [golang website](https://golang.org/doc/install) or 12 | on Debian or Ubuntu, you can install it from the package manager: 13 | 14 | ```bash 15 | sudo apt-get install -y golang 16 | ``` 17 | 18 | To build, run the following command from this project directory: 19 | 20 | ```bash 21 | make build 22 | ``` 23 | 24 | Then ensure the resulting `protoc-gen-openapi` binary is in your `PATH`. A recommended location 25 | is `$HOME/bin`: 26 | 27 | ```bash 28 | cp _output/.bin/protoc-gen-openapi $HOME/bin 29 | ``` 30 | 31 | Since the following is often in your `$HOME/.bashrc` file: 32 | 33 | ```bash 34 | export PATH=$HOME/bin:$PATH 35 | ``` 36 | 37 | ## Using protoc-gen-openapi 38 | 39 | --- 40 | **TIP** 41 | 42 | The -I option in protoc is useful when you need to specify proto paths for imports. 43 | 44 | --- 45 | 46 | Then to generate the OpenAPI spec of the protobuf defined by file.proto, run 47 | 48 | ```bash 49 | protoc --openapi_out=output_directory input_directory/file.proto 50 | ``` 51 | 52 | With that input, the output will be written to 53 | 54 | output_directory/file.json 55 | 56 | Other supported options are: 57 | * `per_file` 58 | * when set to `true`, the output is per proto file instead of per package. 59 | * `single_file` 60 | * when set to `true`, the output is a single file of all the input protos specified. 61 | * `use_ref` 62 | * when set to `true`, the output uses the `$ref` field in OpenAPI spec to reference other schemas. 63 | * `yaml` 64 | * when set to `true`, the output is in yaml file. 65 | * `include_description` 66 | * when set to `true`, the openapi schema will include descriptions, generated from the proto message comment. 67 | * `multiline_description` 68 | * when set to `true`, the openapi schema will include descriptions, generated from the proto message comment, that can span multiple lines. This can only be used with `yaml=true`. 69 | * `enum_as_int_or_string` 70 | * when set to `true`, the openapi schema will include `x-kubernetes-int-or-string` on enums. 71 | * `additional_empty_schemas` 72 | * a `+` separated list of message names (`core.solo.io.Status`), whose generated schema should be an empty object that accepts all values. 73 | * `proto_oneof` 74 | * when set to `true`, the openapi schema will include `oneOf` emulating the behavior of proto `oneof`. 75 | * `int_native` 76 | * when set to `true`, the native openapi schemas will be used for Integer types instead of Solo wrappers that add Kubernetes extension headers to the schema to treat int as strings. 77 | * `disable_kube_markers` 78 | * when set to `true`, kubebuilder markers and validations such as PreserveUnknownFields, MinItems, default, and all CEL rules will be omitted from the OpenAPI schema. The Type and Required markers will be maintained. 79 | * `ignored_kube_marker_substrings` 80 | * when set, this list of substrings will be used to identify kubebuilder markers to ignore. When multiple are 81 | supplied, this will function as a logical OR i.e. any rule which contains a provided substring will be ignored -------------------------------------------------------------------------------- /pkg/protomodel/fileDescriptor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "google.golang.org/protobuf/types/descriptorpb" 19 | ) 20 | 21 | type FileDescriptor struct { 22 | *descriptorpb.FileDescriptorProto 23 | Parent *PackageDescriptor 24 | AllMessages []*MessageDescriptor // All the messages defined in this file 25 | AllEnums []*EnumDescriptor // All the enums defined in this file 26 | Messages []*MessageDescriptor // Top-level messages defined in this file 27 | Enums []*EnumDescriptor // Top-level enums defined in this file 28 | Services []*ServiceDescriptor // All services defined in this file 29 | Dependencies []*FileDescriptor // Files imported by this file 30 | locations map[pathVector]*descriptorpb.SourceCodeInfo_Location // Provenance 31 | Matter FrontMatter // Title, overview, homeLocation, front_matter 32 | } 33 | 34 | func newFileDescriptor(desc *descriptorpb.FileDescriptorProto, parent *PackageDescriptor) *FileDescriptor { 35 | f := &FileDescriptor{ 36 | FileDescriptorProto: desc, 37 | locations: make(map[pathVector]*descriptorpb.SourceCodeInfo_Location, len(desc.GetSourceCodeInfo().GetLocation())), 38 | Parent: parent, 39 | } 40 | 41 | // put all the locations in a map for quick lookup 42 | for _, loc := range desc.GetSourceCodeInfo().GetLocation() { 43 | if len(loc.Path) > 0 { 44 | pv := newPathVector(int(loc.Path[0])) 45 | for _, v := range loc.Path[1:] { 46 | pv = pv.append(int(v)) 47 | } 48 | f.locations[pv] = loc 49 | } 50 | } 51 | 52 | path := newPathVector(messagePath) 53 | for i, md := range desc.MessageType { 54 | f.Messages = append(f.Messages, newMessageDescriptor(md, nil, f, path.append(i))) 55 | } 56 | 57 | path = newPathVector(enumPath) 58 | for i, e := range desc.EnumType { 59 | f.Enums = append(f.Enums, newEnumDescriptor(e, nil, f, path.append(i))) 60 | } 61 | 62 | path = newPathVector(servicePath) 63 | for i, s := range desc.Service { 64 | f.Services = append(f.Services, newServiceDescriptor(s, f, path.append(i))) 65 | } 66 | 67 | // Find title/overview/etc content in comments and store it explicitly. 68 | loc := f.find(newPathVector(packagePath)) 69 | if loc != nil && loc.LeadingDetachedComments != nil { 70 | f.Matter = extractFrontMatter(f.GetName(), loc, f) 71 | } 72 | 73 | // get the transitive close of all messages and enums 74 | f.aggregateMessages(f.Messages) 75 | f.aggregateEnums(f.Enums) 76 | 77 | return f 78 | } 79 | 80 | func (f *FileDescriptor) find(path pathVector) *descriptorpb.SourceCodeInfo_Location { 81 | loc := f.locations[path] 82 | return loc 83 | } 84 | 85 | func (f *FileDescriptor) aggregateMessages(messages []*MessageDescriptor) { 86 | f.AllMessages = append(f.AllMessages, messages...) 87 | for _, msg := range messages { 88 | f.aggregateMessages(msg.Messages) 89 | f.aggregateEnums(msg.Enums) 90 | } 91 | } 92 | 93 | func (f *FileDescriptor) aggregateEnums(enums []*EnumDescriptor) { 94 | f.AllEnums = append(f.AllEnums, enums...) 95 | } 96 | -------------------------------------------------------------------------------- /testdata/struct.proto: -------------------------------------------------------------------------------- 1 | // Protocol Buffers - Google's data interchange format 2 | // Copyright 2008 Google Inc. All rights reserved. 3 | // https://developers.google.com/protocol-buffers/ 4 | // 5 | // Redistribution and use in source and binary forms, with or without 6 | // modification, are permitted provided that the following conditions are 7 | // met: 8 | // 9 | // * Redistributions of source code must retain the above copyright 10 | // notice, this list of conditions and the following disclaimer. 11 | // * Redistributions in binary form must reproduce the above 12 | // copyright notice, this list of conditions and the following disclaimer 13 | // in the documentation and/or other materials provided with the 14 | // distribution. 15 | // * Neither the name of Google Inc. nor the names of its 16 | // contributors may be used to endorse or promote products derived from 17 | // this software without specific prior written permission. 18 | // 19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | syntax = "proto3"; 32 | 33 | package google.protobuf; 34 | 35 | option cc_enable_arenas = true; 36 | option go_package = "google.golang.org/protobuf/types/known/structpb"; 37 | option java_package = "com.google.protobuf"; 38 | option java_outer_classname = "StructProto"; 39 | option java_multiple_files = true; 40 | option objc_class_prefix = "GPB"; 41 | option csharp_namespace = "Google.Protobuf.WellKnownTypes"; 42 | 43 | // `Struct` represents a structured data value, consisting of fields 44 | // which map to dynamically typed values. In some languages, `Struct` 45 | // might be supported by a native representation. For example, in 46 | // scripting languages like JS a struct is represented as an 47 | // object. The details of that representation are described together 48 | // with the proto support for the language. 49 | // 50 | // The JSON representation for `Struct` is JSON object. 51 | message Struct { 52 | // Unordered map of dynamically typed values. 53 | map fields = 1; 54 | } 55 | 56 | // `Value` represents a dynamically typed value which can be either 57 | // null, a number, a string, a boolean, a recursive struct value, or a 58 | // list of values. A producer of value is expected to set one of these 59 | // variants. Absence of any variant indicates an error. 60 | // 61 | // The JSON representation for `Value` is JSON value. 62 | message Value { 63 | // The kind of value. 64 | oneof kind { 65 | // Represents a null value. 66 | NullValue null_value = 1; 67 | // Represents a double value. 68 | double number_value = 2; 69 | // Represents a string value. 70 | string string_value = 3; 71 | // Represents a boolean value. 72 | bool bool_value = 4; 73 | // Represents a structured value. 74 | Struct struct_value = 5; 75 | // Represents a repeated `Value`. 76 | ListValue list_value = 6; 77 | } 78 | } 79 | 80 | // `NullValue` is a singleton enumeration to represent the null value for the 81 | // `Value` type union. 82 | // 83 | // The JSON representation for `NullValue` is JSON `null`. 84 | enum NullValue { 85 | // Null value. 86 | NULL_VALUE = 0; 87 | } 88 | 89 | // `ListValue` is a wrapper around a repeated field of values. 90 | // 91 | // The JSON representation for `ListValue` is JSON array. 92 | message ListValue { 93 | // Repeated field of dynamically typed values. 94 | repeated Value values = 1; 95 | } -------------------------------------------------------------------------------- /testdata/golden/testpkg2.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "components": { 4 | "schemas": { 5 | "testpkg2.AEnum": { 6 | "enum": [ 7 | "ONE", 8 | "TWO", 9 | "THREE" 10 | ], 11 | "type": "string" 12 | }, 13 | "testpkg2.MessageOneOf": { 14 | "properties": { 15 | "name": { 16 | "type": "string" 17 | }, 18 | "number": { 19 | "description": "Valid port number", 20 | "maximum": 4294967295, 21 | "minimum": 0, 22 | "type": "integer" 23 | } 24 | }, 25 | "type": "object" 26 | }, 27 | "testpkg2.Test3": { 28 | "description": "Test3 is a message that I use for testing.", 29 | "properties": { 30 | "aEnum": { 31 | "enum": [ 32 | "ONE", 33 | "TWO", 34 | "THREE" 35 | ], 36 | "type": "string" 37 | }, 38 | "deprecate": { 39 | "type": "string" 40 | }, 41 | "field1": { 42 | "description": "field1 is a field", 43 | "format": "int32", 44 | "type": "integer" 45 | }, 46 | "field17": { 47 | "format": "int64", 48 | "type": "integer", 49 | "x-kubernetes-int-or-string": true 50 | }, 51 | "field18": { 52 | "format": "int64", 53 | "type": "integer", 54 | "x-kubernetes-int-or-string": true 55 | }, 56 | "field3": { 57 | "type": "number" 58 | }, 59 | "field4": { 60 | "type": "number" 61 | }, 62 | "field5": { 63 | "format": "int32", 64 | "type": "integer" 65 | }, 66 | "field6": { 67 | "format": "int32", 68 | "type": "integer" 69 | }, 70 | "field7": { 71 | "format": "int32", 72 | "type": "integer" 73 | }, 74 | "field8": { 75 | "maximum": 4294967295, 76 | "minimum": 0, 77 | "type": "integer" 78 | }, 79 | "mapField": { 80 | "additionalProperties": { 81 | "properties": { 82 | "field1": { 83 | "type": "string" 84 | }, 85 | "field2": { 86 | "format": "int32", 87 | "type": "integer" 88 | } 89 | }, 90 | "type": "object" 91 | }, 92 | "description": "first line of comment.", 93 | "type": "object" 94 | }, 95 | "messageOneOfField": { 96 | "description": "messageoneof comment", 97 | "properties": { 98 | "name": { 99 | "type": "string" 100 | }, 101 | "number": { 102 | "description": "Valid port number", 103 | "maximum": 4294967295, 104 | "minimum": 0, 105 | "type": "integer" 106 | } 107 | }, 108 | "type": "object" 109 | }, 110 | "oneoffield1": { 111 | "type": "string" 112 | }, 113 | "oneoffield2": { 114 | "type": "string" 115 | }, 116 | "str": { 117 | "description": "an array of strings", 118 | "items": { 119 | "type": "string" 120 | }, 121 | "type": "array" 122 | }, 123 | "test3Nested": { 124 | "properties": { 125 | "nested1": { 126 | "type": "string" 127 | }, 128 | "nested2": { 129 | "type": "number" 130 | } 131 | }, 132 | "type": "object" 133 | } 134 | }, 135 | "type": "object" 136 | }, 137 | "testpkg2.Test3_Value": { 138 | "properties": { 139 | "field1": { 140 | "type": "string" 141 | }, 142 | "field2": { 143 | "format": "int32", 144 | "type": "integer" 145 | } 146 | }, 147 | "type": "object" 148 | } 149 | } 150 | }, 151 | "info": { 152 | "title": "OpenAPI Spec for Solo APIs.", 153 | "version": "testpkg2" 154 | }, 155 | "paths": null 156 | } -------------------------------------------------------------------------------- /testdata/golden/testpkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "components": { 4 | "schemas": { 5 | "testpkg.Days": { 6 | "description": "Some days", 7 | "enum": [ 8 | "MONDAY", 9 | "TUESDAY", 10 | "WEDNESDAY" 11 | ], 12 | "type": "string" 13 | }, 14 | "testpkg.Msg1": { 15 | "properties": { 16 | "boolField": { 17 | "type": "boolean" 18 | }, 19 | "doubleField": { 20 | "type": "number" 21 | }, 22 | "floatField": { 23 | "type": "number" 24 | }, 25 | "int32Field": { 26 | "format": "int32", 27 | "type": "integer" 28 | }, 29 | "int64Field": { 30 | "format": "int64", 31 | "type": "integer", 32 | "x-kubernetes-int-or-string": true 33 | }, 34 | "stringField": { 35 | "type": "string" 36 | }, 37 | "test2": { 38 | "properties": { 39 | "field1": { 40 | "description": "field1 is a field", 41 | "format": "int32", 42 | "type": "integer" 43 | }, 44 | "field10": { 45 | "format": "int64", 46 | "type": "integer", 47 | "x-kubernetes-int-or-string": true 48 | }, 49 | "field3": { 50 | "type": "number" 51 | }, 52 | "field4": { 53 | "type": "number" 54 | }, 55 | "field5": { 56 | "format": "int32", 57 | "type": "integer" 58 | }, 59 | "field6": { 60 | "format": "int32", 61 | "type": "integer" 62 | }, 63 | "field7": { 64 | "format": "int32", 65 | "type": "integer" 66 | }, 67 | "field8": { 68 | "maximum": 4294967295, 69 | "minimum": 0, 70 | "type": "integer" 71 | }, 72 | "field9": { 73 | "format": "int64", 74 | "type": "integer", 75 | "x-kubernetes-int-or-string": true 76 | }, 77 | "str": { 78 | "description": "an array of strings", 79 | "items": { 80 | "type": "string" 81 | }, 82 | "type": "array" 83 | } 84 | }, 85 | "type": "object" 86 | } 87 | }, 88 | "type": "object" 89 | }, 90 | "testpkg.Test2": { 91 | "properties": { 92 | "field1": { 93 | "description": "field1 is a field", 94 | "format": "int32", 95 | "type": "integer" 96 | }, 97 | "field10": { 98 | "format": "int64", 99 | "type": "integer", 100 | "x-kubernetes-int-or-string": true 101 | }, 102 | "field3": { 103 | "type": "number" 104 | }, 105 | "field4": { 106 | "type": "number" 107 | }, 108 | "field5": { 109 | "format": "int32", 110 | "type": "integer" 111 | }, 112 | "field6": { 113 | "format": "int32", 114 | "type": "integer" 115 | }, 116 | "field7": { 117 | "format": "int32", 118 | "type": "integer" 119 | }, 120 | "field8": { 121 | "maximum": 4294967295, 122 | "minimum": 0, 123 | "type": "integer" 124 | }, 125 | "field9": { 126 | "format": "int64", 127 | "type": "integer", 128 | "x-kubernetes-int-or-string": true 129 | }, 130 | "str": { 131 | "description": "an array of strings", 132 | "items": { 133 | "type": "string" 134 | }, 135 | "type": "array" 136 | } 137 | }, 138 | "type": "object" 139 | }, 140 | "testpkg.TypeX": { 141 | "description": "Some good words Xsome Xbad xwords `xsome xbad xwords` some `xbad` words some `xbas` xwords some [bad](normallink) normal some [bad][testpkg.TypeY] normal ``` doesntmatter ``` thismatters this is fine", 142 | "type": "object" 143 | }, 144 | "testpkg.TypeY": { 145 | "description": "Comment for this type", 146 | "type": "object" 147 | }, 148 | "testpkg.TypeZ": { 149 | "type": "object" 150 | } 151 | } 152 | }, 153 | "info": { 154 | "title": "My Overview", 155 | "version": "testpkg" 156 | }, 157 | "paths": null 158 | } -------------------------------------------------------------------------------- /pkg/protomodel/model.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package protomodel 16 | 17 | import ( 18 | "strings" 19 | 20 | "google.golang.org/protobuf/types/descriptorpb" 21 | "google.golang.org/protobuf/types/pluginpb" 22 | ) 23 | 24 | // model represents a resolved in-memory version of all the input protos 25 | type Model struct { 26 | AllFilesByName map[string]*FileDescriptor 27 | AllDescByName map[string]CoreDesc 28 | Packages []*PackageDescriptor 29 | } 30 | 31 | func NewModel(request *pluginpb.CodeGeneratorRequest, perFile bool) *Model { 32 | m := &Model{ 33 | AllFilesByName: make(map[string]*FileDescriptor, len(request.ProtoFile)), 34 | } 35 | 36 | // organize files by package 37 | filesByPackage := map[string][]*descriptorpb.FileDescriptorProto{} 38 | for _, pf := range request.ProtoFile { 39 | pkg := packageName(pf) 40 | slice := filesByPackage[pkg] 41 | filesByPackage[pkg] = append(slice, pf) 42 | } 43 | 44 | // create all the package descriptors 45 | var allFiles []*FileDescriptor 46 | for pkg, files := range filesByPackage { 47 | p := newPackageDescriptor(pkg, files, perFile) 48 | m.Packages = append(m.Packages, p) 49 | 50 | for _, f := range p.Files { 51 | allFiles = append(allFiles, f) 52 | m.AllFilesByName[f.GetName()] = f 53 | } 54 | } 55 | 56 | // prepare a map of name to descriptor 57 | m.AllDescByName = createDescMap(allFiles) 58 | 59 | // resolve all type references to nice easily used pointers 60 | for _, f := range allFiles { 61 | resolveFieldTypes(f.Messages, m.AllDescByName) 62 | resolveMethodTypes(f.Services, m.AllDescByName) 63 | resolveDependencies(f, m.AllFilesByName) 64 | } 65 | 66 | return m 67 | } 68 | 69 | func packageName(f *descriptorpb.FileDescriptorProto) string { 70 | // Does the file have a package clause? 71 | if pkg := f.GetPackage(); pkg != "" { 72 | return pkg 73 | } 74 | 75 | // use the last path element of the name, with the last dotted suffix removed. 76 | 77 | // First, find the last element 78 | name := f.GetName() 79 | if i := strings.LastIndex(name, "/"); i >= 0 { 80 | name = name[i+1:] 81 | } 82 | 83 | // Now drop the suffix 84 | if i := strings.LastIndex(name, "."); i >= 0 { 85 | name = name[0:i] 86 | } 87 | 88 | return name 89 | } 90 | 91 | // createDescMap builds a map from qualified names to descriptors. 92 | // The key names for the map come from the input data, which puts a period at the beginning. 93 | func createDescMap(files []*FileDescriptor) map[string]CoreDesc { 94 | descMap := make(map[string]CoreDesc) 95 | for _, f := range files { 96 | // The names in this loop are defined by the proto world, not us, so the 97 | // package name may be empty. If so, the dotted package name of X will 98 | // be ".X"; otherwise it will be ".pkg.X". 99 | dottedPkg := "." + f.GetPackage() 100 | if dottedPkg != "." { 101 | dottedPkg += "." 102 | } 103 | 104 | for _, svc := range f.Services { 105 | descMap[dottedPkg+DottedName(svc)] = svc 106 | } 107 | 108 | recordEnums(f.Enums, descMap, dottedPkg) 109 | recordMessages(f.Messages, descMap, dottedPkg) 110 | recordServices(f.Services, descMap, dottedPkg) 111 | resolveFieldTypes(f.Messages, descMap) 112 | } 113 | 114 | return descMap 115 | } 116 | 117 | func recordMessages(messages []*MessageDescriptor, descMap map[string]CoreDesc, dottedPkg string) { 118 | for _, msg := range messages { 119 | descMap[dottedPkg+DottedName(msg)] = msg 120 | 121 | recordMessages(msg.Messages, descMap, dottedPkg) 122 | recordEnums(msg.Enums, descMap, dottedPkg) 123 | 124 | for _, f := range msg.Fields { 125 | descMap[dottedPkg+DottedName(f)] = f 126 | } 127 | } 128 | } 129 | 130 | func recordEnums(enums []*EnumDescriptor, descMap map[string]CoreDesc, dottedPkg string) { 131 | for _, e := range enums { 132 | descMap[dottedPkg+DottedName(e)] = e 133 | 134 | for _, v := range e.Values { 135 | descMap[dottedPkg+DottedName(v)] = v 136 | } 137 | } 138 | } 139 | 140 | func recordServices(services []*ServiceDescriptor, descMap map[string]CoreDesc, dottedPkg string) { 141 | for _, s := range services { 142 | descMap[dottedPkg+DottedName(s)] = s 143 | 144 | for _, m := range s.Methods { 145 | descMap[dottedPkg+DottedName(m)] = m 146 | } 147 | } 148 | } 149 | 150 | func resolveFieldTypes(messages []*MessageDescriptor, descMap map[string]CoreDesc) { 151 | for _, msg := range messages { 152 | for _, field := range msg.Fields { 153 | field.FieldType = descMap[field.GetTypeName()] 154 | } 155 | resolveFieldTypes(msg.Messages, descMap) 156 | } 157 | } 158 | 159 | func resolveMethodTypes(services []*ServiceDescriptor, descMap map[string]CoreDesc) { 160 | for _, svc := range services { 161 | for _, method := range svc.Methods { 162 | method.Input = descMap[method.GetInputType()].(*MessageDescriptor) 163 | method.Output = descMap[method.GetOutputType()].(*MessageDescriptor) 164 | } 165 | } 166 | } 167 | 168 | func resolveDependencies(file *FileDescriptor, filesByName map[string]*FileDescriptor) { 169 | for _, desc := range file.Dependency { 170 | dep := filesByName[desc] 171 | file.Dependencies = append(file.Dependencies, dep) 172 | } 173 | } 174 | 175 | // DottedName returns a dotted representation of the coreDesc's name 176 | func DottedName(o CoreDesc) string { 177 | return strings.Join(o.QualifiedName(), ".") 178 | } 179 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Istio Authors 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 | package main 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | 21 | "github.com/solo-io/protoc-gen-openapi/pkg/protocgen" 22 | "github.com/solo-io/protoc-gen-openapi/pkg/protomodel" 23 | 24 | "google.golang.org/protobuf/types/pluginpb" 25 | ) 26 | 27 | // Breaks the comma-separated list of key=value pairs 28 | // in the parameter string into an easy to use map. 29 | func extractParams(parameter string) map[string]string { 30 | m := make(map[string]string) 31 | for _, p := range strings.Split(parameter, ",") { 32 | if p == "" { 33 | continue 34 | } 35 | 36 | if i := strings.Index(p, "="); i < 0 { 37 | m[p] = "" 38 | } else { 39 | m[p[0:i]] = p[i+1:] 40 | } 41 | } 42 | 43 | return m 44 | } 45 | 46 | func generate(request pluginpb.CodeGeneratorRequest) (*pluginpb.CodeGeneratorResponse, error) { 47 | perFile := false 48 | singleFile := false 49 | yaml := false 50 | useRef := false 51 | includeDescription := true 52 | multilineDescription := false 53 | enumAsIntOrString := false 54 | protoOneof := false 55 | intNative := false 56 | disableKubeMarkers := false 57 | 58 | var messagesWithEmptySchema []string 59 | var ignoredKubeMarkerSubstrings []string 60 | 61 | p := extractParams(request.GetParameter()) 62 | for k, v := range p { 63 | if k == "per_file" { 64 | switch strings.ToLower(v) { 65 | case "true": 66 | perFile = true 67 | case "false": 68 | perFile = false 69 | default: 70 | return nil, fmt.Errorf("unknown value '%s' for per_file", v) 71 | } 72 | } else if k == "single_file" { 73 | switch strings.ToLower(v) { 74 | case "true": 75 | if perFile { 76 | return nil, fmt.Errorf("output is already to be generated per file, cannot output to a single file") 77 | } 78 | singleFile = true 79 | case "false": 80 | singleFile = false 81 | default: 82 | return nil, fmt.Errorf("unknown value '%s' for single_file", v) 83 | } 84 | } else if k == "yaml" { 85 | yaml = true 86 | } else if k == "use_ref" { 87 | switch strings.ToLower(v) { 88 | case "true": 89 | useRef = true 90 | case "false": 91 | useRef = false 92 | default: 93 | return nil, fmt.Errorf("unknown value '%s' for use_ref", v) 94 | } 95 | } else if k == "include_description" { 96 | switch strings.ToLower(v) { 97 | case "true": 98 | includeDescription = true 99 | case "false": 100 | includeDescription = false 101 | default: 102 | return nil, fmt.Errorf("unknown value '%s' for include_description", v) 103 | } 104 | } else if k == "multiline_description" { 105 | switch strings.ToLower(v) { 106 | case "true": 107 | multilineDescription = true 108 | case "false": 109 | multilineDescription = false 110 | default: 111 | return nil, fmt.Errorf("unknown value '%s' for multiline_description", v) 112 | } 113 | } else if k == "enum_as_int_or_string" { 114 | switch strings.ToLower(v) { 115 | case "true": 116 | enumAsIntOrString = true 117 | case "false": 118 | enumAsIntOrString = false 119 | default: 120 | return nil, fmt.Errorf("unknown value '%s' for enum_as_int_or_string", v) 121 | } 122 | } else if k == "proto_oneof" { 123 | switch strings.ToLower(v) { 124 | case "true": 125 | protoOneof = true 126 | case "false": 127 | protoOneof = false 128 | default: 129 | return nil, fmt.Errorf("unknown value '%s' for proto_oneof", v) 130 | } 131 | } else if k == "int_native" { 132 | switch strings.ToLower(v) { 133 | case "true": 134 | intNative = true 135 | case "false": 136 | intNative = false 137 | default: 138 | return nil, fmt.Errorf("unknown value '%s' for int_native", v) 139 | } 140 | } else if k == "additional_empty_schema" { 141 | messagesWithEmptySchema = strings.Split(v, "+") 142 | } else if k == "disable_kube_markers" { 143 | switch strings.ToLower(v) { 144 | case "true": 145 | disableKubeMarkers = true 146 | case "false": 147 | disableKubeMarkers = false 148 | default: 149 | return nil, fmt.Errorf("unknown value '%s' for disable_kube_markers", v) 150 | } 151 | } else if k == "ignored_kube_marker_substrings" { 152 | if len(v) > 0 { 153 | ignoredKubeMarkerSubstrings = strings.Split(v, "+") 154 | } 155 | } else { 156 | return nil, fmt.Errorf("unknown argument '%s' specified", k) 157 | } 158 | } 159 | 160 | if !yaml && multilineDescription { 161 | return nil, fmt.Errorf("multiline_description is only supported when yaml=true") 162 | } 163 | 164 | m := protomodel.NewModel(&request, perFile) 165 | 166 | filesToGen := make(map[*protomodel.FileDescriptor]bool) 167 | for _, fileName := range request.FileToGenerate { 168 | fd := m.AllFilesByName[fileName] 169 | if fd == nil { 170 | return nil, fmt.Errorf("unable to find %s", request.FileToGenerate) 171 | } 172 | filesToGen[fd] = true 173 | } 174 | 175 | descriptionConfiguration := &DescriptionConfiguration{ 176 | IncludeDescriptionInSchema: includeDescription, 177 | MultilineDescription: multilineDescription, 178 | } 179 | 180 | g := newOpenAPIGenerator( 181 | m, 182 | perFile, 183 | singleFile, 184 | yaml, 185 | useRef, 186 | descriptionConfiguration, 187 | enumAsIntOrString, 188 | messagesWithEmptySchema, 189 | protoOneof, 190 | intNative, 191 | disableKubeMarkers, 192 | ignoredKubeMarkerSubstrings, 193 | ) 194 | return g.generateOutput(filesToGen) 195 | } 196 | 197 | func main() { 198 | protocgen.Generate(generate) 199 | } 200 | -------------------------------------------------------------------------------- /pkg/markers/validation.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/getkin/kin-openapi/openapi3" 9 | "sigs.k8s.io/controller-tools/pkg/markers" 10 | ) 11 | 12 | const ( 13 | // Kubebuilder marker used in comments 14 | Kubebuilder = "+kubebuilder:" 15 | 16 | FieldRequired = "required" 17 | FieldOptional = "optional" 18 | ) 19 | 20 | var ( 21 | _ SchemaMarker = Maximum(0) 22 | _ SchemaMarker = Minimum(0) 23 | _ SchemaMarker = ExclusiveMaximum(false) 24 | _ SchemaMarker = ExclusiveMinimum(false) 25 | _ SchemaMarker = MultipleOf(0) 26 | _ SchemaMarker = MinProperties(0) 27 | _ SchemaMarker = MaxProperties(0) 28 | _ SchemaMarker = MaxLength(0) 29 | _ SchemaMarker = MinLength(0) 30 | _ SchemaMarker = Pattern("") 31 | _ SchemaMarker = MaxItems(0) 32 | _ SchemaMarker = MinItems(0) 33 | _ SchemaMarker = UniqueItems(false) 34 | _ SchemaMarker = Enum(nil) 35 | _ SchemaMarker = Format("") 36 | _ SchemaMarker = Type("") 37 | _ SchemaMarker = XPreserveUnknownFields{} 38 | _ SchemaMarker = XEmbeddedResource{} 39 | _ SchemaMarker = XIntOrString{} 40 | _ SchemaMarker = XValidation{} 41 | ) 42 | 43 | // ValidationMarkers lists all available markers that affect CRD schema generation, 44 | // except for the few that don't make sense as type-level markers (see FieldOnlyMarkers). 45 | // All markers start with `+kubebuilder:validation:`, and continue with their type name. 46 | // A copy is produced of all markers that describes types as well, for making types 47 | // reusable and writing complex validations on slice items. 48 | var ValidationMarkers = mustMakeAllWithPrefix("kubebuilder:validation", markers.DescribesField, 49 | // Numeric markers 50 | Maximum(0), 51 | Minimum(0), 52 | ExclusiveMaximum(false), 53 | ExclusiveMinimum(false), 54 | MultipleOf(0), 55 | MinProperties(0), 56 | MaxProperties(0), 57 | 58 | // string markers 59 | MaxLength(0), 60 | MinLength(0), 61 | Pattern(""), 62 | 63 | // Slice markers 64 | MaxItems(0), 65 | MinItems(0), 66 | UniqueItems(false), 67 | 68 | // General markers 69 | Enum(nil), 70 | Format(""), 71 | Type(""), 72 | XPreserveUnknownFields{}, 73 | XEmbeddedResource{}, 74 | XIntOrString{}, 75 | XValidation{}, 76 | ) 77 | 78 | // FieldOnlyMarkers list field-specific validation markers (i.e. those markers that don't make 79 | // sense on a type, and thus aren't in ValidationMarkers). 80 | var FieldOnlyMarkers = []*definitionWithHelp{ 81 | must(markers.MakeDefinition("kubebuilder:validation:Required", markers.DescribesField, Required{})). 82 | WithHelp(markers.SimpleHelp("CRD validation", "specifies that this field is required, if fields are optional by default.")), 83 | 84 | // must(markers.MakeDefinition("kubebuilder:validation:Optional", markers.DescribesField, struct{}{})). 85 | // WithHelp(markers.SimpleHelp("CRD validation", "specifies that this field is optional, if fields are required by default.")), 86 | 87 | must(markers.MakeDefinition("kubebuilder:validation:Nullable", markers.DescribesField, Nullable{})), 88 | 89 | must(markers.MakeAnyTypeDefinition("kubebuilder:default", markers.DescribesField, Default{})), 90 | 91 | must(markers.MakeAnyTypeDefinition("kubebuilder:example", markers.DescribesField, Example{})), 92 | 93 | must(markers.MakeDefinition("kubebuilder:validation:EmbeddedResource", markers.DescribesField, XEmbeddedResource{})), 94 | 95 | must(markers.MakeDefinition("kubebuilder:validation:Schemaless", markers.DescribesField, Schemaless{})), 96 | } 97 | 98 | // ValidationIshMarkers are field-and-type markers that don't fall under the 99 | // :validation: prefix, and/or don't have a name that directly matches their 100 | // type. 101 | var ValidationIshMarkers = []*definitionWithHelp{ 102 | must(markers.MakeDefinition("kubebuilder:pruning:PreserveUnknownFields", markers.DescribesField, XPreserveUnknownFields{})), 103 | must(markers.MakeDefinition("kubebuilder:pruning:PreserveUnknownFields", markers.DescribesType, XPreserveUnknownFields{})), 104 | } 105 | 106 | type SchemaMarker interface { 107 | ApplyToSchema(o *openapi3.Schema) 108 | } 109 | 110 | func init() { 111 | AllDefinitions = append(AllDefinitions, ValidationMarkers...) 112 | 113 | for _, def := range ValidationMarkers { 114 | newDef := *def.Definition 115 | // copy both parts so we don't change the definition 116 | typDef := definitionWithHelp{ 117 | Definition: &newDef, 118 | Help: def.Help, 119 | } 120 | typDef.Target = markers.DescribesType 121 | AllDefinitions = append(AllDefinitions, &typDef) 122 | } 123 | 124 | AllDefinitions = append(AllDefinitions, FieldOnlyMarkers...) 125 | AllDefinitions = append(AllDefinitions, ValidationIshMarkers...) 126 | } 127 | 128 | func (r *Registry) MustApplyRulesToSchema( 129 | rules []string, 130 | o *openapi3.Schema, 131 | target markers.TargetType, 132 | ) { 133 | err := r.ApplyRulesToSchema(rules, o, target) 134 | if err != nil { 135 | log.Panicf("error applying rules to schema: %s", err) 136 | } 137 | } 138 | 139 | func (r *Registry) ApplyRulesToSchema( 140 | rules []string, 141 | o *openapi3.Schema, 142 | target markers.TargetType, 143 | ) error { 144 | for _, rule := range rules { 145 | defn := r.mRegistry.Lookup(rule, target) 146 | if defn == nil { 147 | return fmt.Errorf("no definition found for rule: %s", rule) 148 | } 149 | val, err := defn.Parse(rule) 150 | if err != nil { 151 | return fmt.Errorf("error parsing rule: %s", err) 152 | } 153 | if s, ok := val.(SchemaMarker); ok { 154 | s.ApplyToSchema(o) 155 | } else { 156 | return fmt.Errorf("expected SchemaMarker, got %T", val) 157 | } 158 | } 159 | return nil 160 | } 161 | 162 | func (r *Registry) GetSchemaType( 163 | rules []string, 164 | target markers.TargetType, 165 | ) Type { 166 | for _, rule := range rules { 167 | defn := r.mRegistry.Lookup(rule, target) 168 | if defn == nil { 169 | log.Panicf("no definition found for rule: %s", rule) 170 | } 171 | val, err := defn.Parse(rule) 172 | if err != nil { 173 | log.Panicf("error parsing rule: %s", err) 174 | } 175 | if s, ok := val.(Type); ok { 176 | return s 177 | } 178 | } 179 | return "" 180 | } 181 | 182 | func (r *Registry) IsRequired( 183 | rules []string, 184 | ) bool { 185 | for _, rule := range rules { 186 | defn := r.mRegistry.Lookup(rule, markers.DescribesField) 187 | if defn == nil { 188 | log.Panicf("no definition found for rule: %s", rule) 189 | } 190 | if strings.HasPrefix(rule, "+kubebuilder:validation:Required") { 191 | return true 192 | } 193 | } 194 | return false 195 | } 196 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 4 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 5 | github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE= 6 | github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= 7 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 8 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 9 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 10 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 11 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 12 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 13 | github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= 14 | github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 15 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 16 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 17 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 18 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 20 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 21 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 22 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 23 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 24 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 28 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 29 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 30 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 31 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 32 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 33 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= 34 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= 35 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= 36 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= 37 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 38 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 39 | github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= 40 | github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 41 | github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= 42 | github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 46 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 47 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 48 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 | github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= 50 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 51 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 52 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 53 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 54 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 55 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 56 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 57 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 58 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 59 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 60 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 61 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 62 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 63 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 64 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 65 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 66 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 67 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 68 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 69 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 70 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 71 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 72 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 73 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 74 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 75 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 76 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 77 | k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= 78 | k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= 79 | sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= 80 | sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= 81 | -------------------------------------------------------------------------------- /testdata/golden/test4/openapiv3.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | testpkg.Days: 4 | description: Some days 5 | enum: 6 | - MONDAY 7 | - TUESDAY 8 | - WEDNESDAY 9 | type: string 10 | testpkg.Msg1: 11 | properties: 12 | boolField: 13 | type: boolean 14 | doubleField: 15 | type: number 16 | floatField: 17 | type: number 18 | int32Field: 19 | format: int32 20 | type: integer 21 | int64Field: 22 | format: int64 23 | type: integer 24 | stringField: 25 | type: string 26 | test2: 27 | properties: 28 | field1: 29 | description: field1 is a field 30 | format: int32 31 | type: integer 32 | field3: 33 | type: number 34 | field4: 35 | type: number 36 | field5: 37 | format: int32 38 | type: integer 39 | field6: 40 | format: int32 41 | type: integer 42 | field7: 43 | format: int32 44 | type: integer 45 | field8: 46 | maximum: 4294967295 47 | minimum: 0 48 | type: integer 49 | field9: 50 | format: int64 51 | type: integer 52 | field10: 53 | format: uint64 54 | minimum: 0 55 | type: integer 56 | str: 57 | description: an array of strings 58 | items: 59 | type: string 60 | type: array 61 | type: object 62 | type: object 63 | testpkg.Test2: 64 | properties: 65 | field1: 66 | description: field1 is a field 67 | format: int32 68 | type: integer 69 | field3: 70 | type: number 71 | field4: 72 | type: number 73 | field5: 74 | format: int32 75 | type: integer 76 | field6: 77 | format: int32 78 | type: integer 79 | field7: 80 | format: int32 81 | type: integer 82 | field8: 83 | maximum: 4294967295 84 | minimum: 0 85 | type: integer 86 | field9: 87 | format: int64 88 | type: integer 89 | field10: 90 | format: uint64 91 | minimum: 0 92 | type: integer 93 | str: 94 | description: an array of strings 95 | items: 96 | type: string 97 | type: array 98 | type: object 99 | testpkg.TypeX: 100 | description: |- 101 | Some good words 102 | Xsome Xbad xwords 103 | `xsome xbad xwords` 104 | some `xbad` words 105 | some `xbas` xwords 106 | some [bad](normallink) normal 107 | some [bad][testpkg.TypeY] normal 108 | ``` 109 | doesntmatter 110 | ``` 111 | thismatters 112 | this is fine 113 | type: object 114 | testpkg.TypeY: 115 | description: Comment for this type 116 | type: object 117 | testpkg.TypeZ: 118 | type: object 119 | testpkg2.AEnum: 120 | enum: 121 | - ONE 122 | - TWO 123 | - THREE 124 | type: string 125 | testpkg2.MessageOneOf: 126 | oneOf: 127 | - not: 128 | anyOf: 129 | - required: 130 | - number 131 | - required: 132 | - name 133 | - required: 134 | - number 135 | - required: 136 | - name 137 | properties: 138 | name: 139 | type: string 140 | number: 141 | description: Valid port number 142 | maximum: 4294967295 143 | minimum: 0 144 | type: integer 145 | type: object 146 | testpkg2.Test3: 147 | description: Test3 is a message that I use for testing. 148 | oneOf: 149 | - not: 150 | anyOf: 151 | - required: 152 | - oneoffield1 153 | - required: 154 | - oneoffield2 155 | - required: 156 | - oneoffield1 157 | - required: 158 | - oneoffield2 159 | properties: 160 | aEnum: 161 | enum: 162 | - ONE 163 | - TWO 164 | - THREE 165 | type: string 166 | deprecate: 167 | type: string 168 | field1: 169 | description: field1 is a field 170 | format: int32 171 | type: integer 172 | field3: 173 | type: number 174 | field4: 175 | type: number 176 | field5: 177 | format: int32 178 | type: integer 179 | field6: 180 | format: int32 181 | type: integer 182 | field7: 183 | format: int32 184 | type: integer 185 | field8: 186 | maximum: 4294967295 187 | minimum: 0 188 | type: integer 189 | field17: 190 | format: int64 191 | type: integer 192 | field18: 193 | format: uint64 194 | minimum: 0 195 | type: integer 196 | mapField: 197 | additionalProperties: 198 | properties: 199 | field1: 200 | type: string 201 | field2: 202 | format: int32 203 | type: integer 204 | type: object 205 | description: |- 206 | first line of comment. 207 | 208 | second line of comment. 209 | type: object 210 | messageOneOfField: 211 | description: messageoneof comment 212 | oneOf: 213 | - not: 214 | anyOf: 215 | - required: 216 | - number 217 | - required: 218 | - name 219 | - required: 220 | - number 221 | - required: 222 | - name 223 | properties: 224 | name: 225 | type: string 226 | number: 227 | description: Valid port number 228 | maximum: 4294967295 229 | minimum: 0 230 | type: integer 231 | type: object 232 | oneoffield1: 233 | type: string 234 | oneoffield2: 235 | type: string 236 | str: 237 | description: an array of strings 238 | items: 239 | type: string 240 | type: array 241 | test3Nested: 242 | properties: 243 | nested1: 244 | type: string 245 | nested2: 246 | type: number 247 | type: object 248 | type: object 249 | testpkg2.Test3_Value: 250 | properties: 251 | field1: 252 | type: string 253 | field2: 254 | format: int32 255 | type: integer 256 | type: object 257 | info: 258 | title: OpenAPI Spec for Solo APIs. 259 | version: "" 260 | openapi: 3.0.1 261 | paths: null 262 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Istio Authors 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 | package main 16 | 17 | import ( 18 | "bytes" 19 | "os" 20 | "os/exec" 21 | "path/filepath" 22 | "strings" 23 | "testing" 24 | ) 25 | 26 | const goldenDir = "testdata/golden/" 27 | 28 | func TestOpenAPIGeneration(t *testing.T) { 29 | testcases := []struct { 30 | name string 31 | id string 32 | perPackage bool 33 | genOpts string 34 | inputFiles map[string][]string 35 | protocArgs []string 36 | wantFiles []string 37 | }{ 38 | { 39 | name: "Per Package Generation", 40 | id: "test1", 41 | perPackage: true, 42 | genOpts: "", 43 | inputFiles: map[string][]string{ 44 | "testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"}, 45 | "testpkg2": {"./testdata/testpkg2/test3.proto"}, 46 | }, 47 | wantFiles: []string{"testpkg.json", "testpkg2.json"}, 48 | }, 49 | { 50 | name: "Single File Generation", 51 | id: "test2", 52 | perPackage: false, 53 | genOpts: "single_file=true", 54 | inputFiles: map[string][]string{ 55 | "testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"}, 56 | "testpkg2": {"./testdata/testpkg2/test3.proto"}, 57 | }, 58 | wantFiles: []string{"openapiv3.json"}, 59 | }, 60 | { 61 | name: "Use $ref in the output", 62 | id: "test3", 63 | perPackage: false, 64 | genOpts: "single_file=true,use_ref=true", 65 | inputFiles: map[string][]string{ 66 | "testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"}, 67 | "testpkg2": {"./testdata/testpkg2/test3.proto"}, 68 | }, 69 | wantFiles: []string{"testRef/openapiv3.json"}, 70 | }, 71 | { 72 | name: "Use yaml, proto_oneof, int_native, validation rules, and multiline_description", 73 | id: "test4", 74 | perPackage: false, 75 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true", 76 | inputFiles: map[string][]string{ 77 | "testpkg": {"./testdata/testpkg/test1.proto", "./testdata/testpkg/test2.proto", "./testdata/testpkg/test6.proto"}, 78 | "testpkg2": {"./testdata/testpkg2/test3.proto"}, 79 | }, 80 | wantFiles: []string{"test4/openapiv3.yaml"}, 81 | }, 82 | { 83 | name: "Test validation rules", 84 | id: "test5", 85 | perPackage: false, 86 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true", 87 | inputFiles: map[string][]string{ 88 | "test5": {"./testdata/test5/rules.proto"}, 89 | }, 90 | wantFiles: []string{"test5/openapiv3.yaml"}, 91 | }, 92 | { 93 | name: "Test kubebuilder markers", 94 | id: "test6", 95 | perPackage: false, 96 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true", 97 | inputFiles: map[string][]string{ 98 | "test6": {"./testdata/test6/markers.proto"}, 99 | }, 100 | wantFiles: []string{"test6/openapiv3.yaml"}, 101 | }, 102 | { 103 | name: "Test disable_kube_markers option", 104 | id: "test7", 105 | perPackage: false, 106 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true,disable_kube_markers=true", 107 | inputFiles: map[string][]string{ 108 | "test7": {"./testdata/test7/markers.proto"}, 109 | }, 110 | wantFiles: []string{"test7/openapiv3.yaml"}, 111 | }, 112 | { 113 | name: "Test no markers are ignored when ignored_kube_markers is zero length", 114 | id: "test8", 115 | perPackage: false, 116 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true,disable_kube_markers=false,ignored_kube_marker_substrings=", 117 | inputFiles: map[string][]string{ 118 | "test8": {"./testdata/test8/markers.proto"}, 119 | }, 120 | wantFiles: []string{"test8/openapiv3.yaml"}, 121 | }, 122 | { 123 | name: "Test ignored_kube_markers option ignores a single marker", 124 | id: "test9", 125 | perPackage: false, 126 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true,disable_kube_markers=false,ignored_kube_marker_substrings=Required", 127 | inputFiles: map[string][]string{ 128 | "test9": {"./testdata/test9/markers.proto"}, 129 | }, 130 | wantFiles: []string{"test9/openapiv3.yaml"}, 131 | }, 132 | { 133 | name: "Test ignored_kube_markers option ignores multiple markers", 134 | id: "test10", 135 | perPackage: false, 136 | genOpts: "yaml=true,single_file=true,proto_oneof=true,int_native=true,multiline_description=true,disable_kube_markers=false,ignored_kube_marker_substrings=Required+example", 137 | inputFiles: map[string][]string{ 138 | "test10": {"./testdata/test10/markers.proto"}, 139 | }, 140 | wantFiles: []string{"test10/openapiv3.yaml"}, 141 | }, 142 | } 143 | 144 | for _, tc := range testcases { 145 | t.Run(tc.name, func(t *testing.T) { 146 | if len(tc.inputFiles) == 0 { 147 | t.Fatalf("inputFiles must be set for test case %s", tc.name) 148 | } 149 | 150 | tempDir, err := os.MkdirTemp("", "openapi-temp") 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | defer os.RemoveAll(tempDir) 155 | 156 | if tc.perPackage { 157 | for _, files := range tc.inputFiles { 158 | args := []string{"-Itestdata", "--openapi_out=" + tc.genOpts + ":" + tempDir} 159 | args = append(args, files...) 160 | protocOpenAPI(t, args) 161 | } 162 | } else { 163 | args := []string{"-Itestdata", "--openapi_out=" + tc.genOpts + ":" + tempDir} 164 | for _, files := range tc.inputFiles { 165 | args = append(args, files...) 166 | } 167 | protocOpenAPI(t, args) 168 | } 169 | 170 | // get the golden file and compare with the generated files. 171 | for _, file := range tc.wantFiles { 172 | wantPath := goldenDir + file 173 | // we are looking for the same file name in the generated path 174 | genPath := filepath.Join(tempDir, filepath.Base(wantPath)) 175 | got, err := os.ReadFile(genPath) 176 | if err != nil { 177 | if os.IsNotExist(err) { 178 | t.Fatalf("expected generated file %v does not exist: %v", genPath, err) 179 | } else { 180 | t.Errorf("error reading the generated file: %v", err) 181 | } 182 | } 183 | 184 | want, err := os.ReadFile(wantPath) 185 | if err != nil { 186 | t.Errorf("error reading the golden file: %v", err) 187 | } 188 | 189 | if bytes.Equal(got, want) { 190 | continue 191 | } 192 | 193 | cmd := exec.Command("diff", "-u", wantPath, genPath) 194 | out, _ := cmd.CombinedOutput() 195 | t.Errorf("golden file differs: %v\n%v", filepath.Base(wantPath), string(out)) 196 | } 197 | }) 198 | } 199 | } 200 | 201 | func protocOpenAPI(t *testing.T, args []string) { 202 | cmd := exec.Command("protoc", "--plugin=protoc-gen-openapi="+os.Args[0]) 203 | cmd.Args = append(cmd.Args, args...) 204 | cmd.Env = append(os.Environ(), "RUN_AS_PROTOC_GEN_OPENAPI=1") 205 | out, err := cmd.CombinedOutput() 206 | if len(out) > 0 || err != nil { 207 | t.Log("RUNNING: ", strings.Join(cmd.Args, " ")) 208 | } 209 | if len(out) > 0 { 210 | t.Log(string(out)) 211 | } 212 | if err != nil { 213 | t.Fatalf("protoc: %v", err) 214 | } 215 | } 216 | 217 | func init() { 218 | // when "RUN_AS_PROTOC_GEN_OPENAPI" is set, we use the protoc-gen-openapi directly 219 | // for the test scenarios. 220 | if os.Getenv("RUN_AS_PROTOC_GEN_OPENAPI") != "" { 221 | main() 222 | os.Exit(0) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /testdata/golden/testRef/openapiv3.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "components": { 4 | "schemas": { 5 | "testpkg.Days": { 6 | "description": "Some days", 7 | "enum": [ 8 | "MONDAY", 9 | "TUESDAY", 10 | "WEDNESDAY" 11 | ], 12 | "type": "string" 13 | }, 14 | "testpkg.Msg1": { 15 | "properties": { 16 | "boolField": { 17 | "type": "boolean" 18 | }, 19 | "doubleField": { 20 | "type": "number" 21 | }, 22 | "floatField": { 23 | "type": "number" 24 | }, 25 | "int32Field": { 26 | "format": "int32", 27 | "type": "integer" 28 | }, 29 | "int64Field": { 30 | "format": "int64", 31 | "type": "integer", 32 | "x-kubernetes-int-or-string": true 33 | }, 34 | "stringField": { 35 | "type": "string" 36 | }, 37 | "test2": { 38 | "properties": { 39 | "field1": { 40 | "description": "field1 is a field", 41 | "format": "int32", 42 | "type": "integer" 43 | }, 44 | "field10": { 45 | "format": "int64", 46 | "type": "integer", 47 | "x-kubernetes-int-or-string": true 48 | }, 49 | "field3": { 50 | "type": "number" 51 | }, 52 | "field4": { 53 | "type": "number" 54 | }, 55 | "field5": { 56 | "format": "int32", 57 | "type": "integer" 58 | }, 59 | "field6": { 60 | "format": "int32", 61 | "type": "integer" 62 | }, 63 | "field7": { 64 | "format": "int32", 65 | "type": "integer" 66 | }, 67 | "field8": { 68 | "maximum": 4294967295, 69 | "minimum": 0, 70 | "type": "integer" 71 | }, 72 | "field9": { 73 | "format": "int64", 74 | "type": "integer", 75 | "x-kubernetes-int-or-string": true 76 | }, 77 | "str": { 78 | "description": "an array of strings", 79 | "items": { 80 | "type": "string" 81 | }, 82 | "type": "array" 83 | } 84 | }, 85 | "type": "object" 86 | } 87 | }, 88 | "type": "object" 89 | }, 90 | "testpkg.Test2": { 91 | "properties": { 92 | "field1": { 93 | "description": "field1 is a field", 94 | "format": "int32", 95 | "type": "integer" 96 | }, 97 | "field10": { 98 | "format": "int64", 99 | "type": "integer", 100 | "x-kubernetes-int-or-string": true 101 | }, 102 | "field3": { 103 | "type": "number" 104 | }, 105 | "field4": { 106 | "type": "number" 107 | }, 108 | "field5": { 109 | "format": "int32", 110 | "type": "integer" 111 | }, 112 | "field6": { 113 | "format": "int32", 114 | "type": "integer" 115 | }, 116 | "field7": { 117 | "format": "int32", 118 | "type": "integer" 119 | }, 120 | "field8": { 121 | "maximum": 4294967295, 122 | "minimum": 0, 123 | "type": "integer" 124 | }, 125 | "field9": { 126 | "format": "int64", 127 | "type": "integer", 128 | "x-kubernetes-int-or-string": true 129 | }, 130 | "str": { 131 | "description": "an array of strings", 132 | "items": { 133 | "type": "string" 134 | }, 135 | "type": "array" 136 | } 137 | }, 138 | "type": "object" 139 | }, 140 | "testpkg.TypeX": { 141 | "description": "Some good words Xsome Xbad xwords `xsome xbad xwords` some `xbad` words some `xbas` xwords some [bad](normallink) normal some [bad][testpkg.TypeY] normal ``` doesntmatter ``` thismatters this is fine", 142 | "type": "object" 143 | }, 144 | "testpkg.TypeY": { 145 | "description": "Comment for this type", 146 | "type": "object" 147 | }, 148 | "testpkg.TypeZ": { 149 | "type": "object" 150 | }, 151 | "testpkg2.AEnum": { 152 | "enum": [ 153 | "ONE", 154 | "TWO", 155 | "THREE" 156 | ], 157 | "type": "string" 158 | }, 159 | "testpkg2.MessageOneOf": { 160 | "properties": { 161 | "name": { 162 | "type": "string" 163 | }, 164 | "number": { 165 | "description": "Valid port number", 166 | "maximum": 4294967295, 167 | "minimum": 0, 168 | "type": "integer" 169 | } 170 | }, 171 | "type": "object" 172 | }, 173 | "testpkg2.Test3": { 174 | "description": "Test3 is a message that I use for testing.", 175 | "properties": { 176 | "aEnum": { 177 | "enum": [ 178 | "ONE", 179 | "TWO", 180 | "THREE" 181 | ], 182 | "type": "string" 183 | }, 184 | "deprecate": { 185 | "type": "string" 186 | }, 187 | "field1": { 188 | "description": "field1 is a field", 189 | "format": "int32", 190 | "type": "integer" 191 | }, 192 | "field17": { 193 | "format": "int64", 194 | "type": "integer", 195 | "x-kubernetes-int-or-string": true 196 | }, 197 | "field18": { 198 | "format": "int64", 199 | "type": "integer", 200 | "x-kubernetes-int-or-string": true 201 | }, 202 | "field3": { 203 | "type": "number" 204 | }, 205 | "field4": { 206 | "type": "number" 207 | }, 208 | "field5": { 209 | "format": "int32", 210 | "type": "integer" 211 | }, 212 | "field6": { 213 | "format": "int32", 214 | "type": "integer" 215 | }, 216 | "field7": { 217 | "format": "int32", 218 | "type": "integer" 219 | }, 220 | "field8": { 221 | "maximum": 4294967295, 222 | "minimum": 0, 223 | "type": "integer" 224 | }, 225 | "mapField": { 226 | "additionalProperties": { 227 | "$ref": "#/components/schemas/testpkg2.Test3_Value" 228 | }, 229 | "description": "first line of comment.", 230 | "type": "object" 231 | }, 232 | "messageOneOfField": { 233 | "description": "messageoneof comment", 234 | "properties": { 235 | "name": { 236 | "type": "string" 237 | }, 238 | "number": { 239 | "description": "Valid port number", 240 | "maximum": 4294967295, 241 | "minimum": 0, 242 | "type": "integer" 243 | } 244 | }, 245 | "type": "object" 246 | }, 247 | "oneoffield1": { 248 | "type": "string" 249 | }, 250 | "oneoffield2": { 251 | "type": "string" 252 | }, 253 | "str": { 254 | "description": "an array of strings", 255 | "items": { 256 | "type": "string" 257 | }, 258 | "type": "array" 259 | }, 260 | "test3Nested": { 261 | "properties": { 262 | "nested1": { 263 | "type": "string" 264 | }, 265 | "nested2": { 266 | "type": "number" 267 | } 268 | }, 269 | "type": "object" 270 | } 271 | }, 272 | "type": "object" 273 | }, 274 | "testpkg2.Test3_Value": { 275 | "properties": { 276 | "field1": { 277 | "type": "string" 278 | }, 279 | "field2": { 280 | "format": "int32", 281 | "type": "integer" 282 | } 283 | }, 284 | "type": "object" 285 | } 286 | } 287 | }, 288 | "info": { 289 | "title": "OpenAPI Spec for Solo APIs.", 290 | "version": "" 291 | }, 292 | "paths": null 293 | } -------------------------------------------------------------------------------- /testdata/golden/openapiv3.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "components": { 4 | "schemas": { 5 | "testpkg.Days": { 6 | "description": "Some days", 7 | "enum": [ 8 | "MONDAY", 9 | "TUESDAY", 10 | "WEDNESDAY" 11 | ], 12 | "type": "string" 13 | }, 14 | "testpkg.Msg1": { 15 | "properties": { 16 | "boolField": { 17 | "type": "boolean" 18 | }, 19 | "doubleField": { 20 | "type": "number" 21 | }, 22 | "floatField": { 23 | "type": "number" 24 | }, 25 | "int32Field": { 26 | "format": "int32", 27 | "type": "integer" 28 | }, 29 | "int64Field": { 30 | "format": "int64", 31 | "type": "integer", 32 | "x-kubernetes-int-or-string": true 33 | }, 34 | "stringField": { 35 | "type": "string" 36 | }, 37 | "test2": { 38 | "properties": { 39 | "field1": { 40 | "description": "field1 is a field", 41 | "format": "int32", 42 | "type": "integer" 43 | }, 44 | "field10": { 45 | "format": "int64", 46 | "type": "integer", 47 | "x-kubernetes-int-or-string": true 48 | }, 49 | "field3": { 50 | "type": "number" 51 | }, 52 | "field4": { 53 | "type": "number" 54 | }, 55 | "field5": { 56 | "format": "int32", 57 | "type": "integer" 58 | }, 59 | "field6": { 60 | "format": "int32", 61 | "type": "integer" 62 | }, 63 | "field7": { 64 | "format": "int32", 65 | "type": "integer" 66 | }, 67 | "field8": { 68 | "maximum": 4294967295, 69 | "minimum": 0, 70 | "type": "integer" 71 | }, 72 | "field9": { 73 | "format": "int64", 74 | "type": "integer", 75 | "x-kubernetes-int-or-string": true 76 | }, 77 | "str": { 78 | "description": "an array of strings", 79 | "items": { 80 | "type": "string" 81 | }, 82 | "type": "array" 83 | } 84 | }, 85 | "type": "object" 86 | } 87 | }, 88 | "type": "object" 89 | }, 90 | "testpkg.Test2": { 91 | "properties": { 92 | "field1": { 93 | "description": "field1 is a field", 94 | "format": "int32", 95 | "type": "integer" 96 | }, 97 | "field10": { 98 | "format": "int64", 99 | "type": "integer", 100 | "x-kubernetes-int-or-string": true 101 | }, 102 | "field3": { 103 | "type": "number" 104 | }, 105 | "field4": { 106 | "type": "number" 107 | }, 108 | "field5": { 109 | "format": "int32", 110 | "type": "integer" 111 | }, 112 | "field6": { 113 | "format": "int32", 114 | "type": "integer" 115 | }, 116 | "field7": { 117 | "format": "int32", 118 | "type": "integer" 119 | }, 120 | "field8": { 121 | "maximum": 4294967295, 122 | "minimum": 0, 123 | "type": "integer" 124 | }, 125 | "field9": { 126 | "format": "int64", 127 | "type": "integer", 128 | "x-kubernetes-int-or-string": true 129 | }, 130 | "str": { 131 | "description": "an array of strings", 132 | "items": { 133 | "type": "string" 134 | }, 135 | "type": "array" 136 | } 137 | }, 138 | "type": "object" 139 | }, 140 | "testpkg.TypeX": { 141 | "description": "Some good words Xsome Xbad xwords `xsome xbad xwords` some `xbad` words some `xbas` xwords some [bad](normallink) normal some [bad][testpkg.TypeY] normal ``` doesntmatter ``` thismatters this is fine", 142 | "type": "object" 143 | }, 144 | "testpkg.TypeY": { 145 | "description": "Comment for this type", 146 | "type": "object" 147 | }, 148 | "testpkg.TypeZ": { 149 | "type": "object" 150 | }, 151 | "testpkg2.AEnum": { 152 | "enum": [ 153 | "ONE", 154 | "TWO", 155 | "THREE" 156 | ], 157 | "type": "string" 158 | }, 159 | "testpkg2.MessageOneOf": { 160 | "properties": { 161 | "name": { 162 | "type": "string" 163 | }, 164 | "number": { 165 | "description": "Valid port number", 166 | "maximum": 4294967295, 167 | "minimum": 0, 168 | "type": "integer" 169 | } 170 | }, 171 | "type": "object" 172 | }, 173 | "testpkg2.Test3": { 174 | "description": "Test3 is a message that I use for testing.", 175 | "properties": { 176 | "aEnum": { 177 | "enum": [ 178 | "ONE", 179 | "TWO", 180 | "THREE" 181 | ], 182 | "type": "string" 183 | }, 184 | "deprecate": { 185 | "type": "string" 186 | }, 187 | "field1": { 188 | "description": "field1 is a field", 189 | "format": "int32", 190 | "type": "integer" 191 | }, 192 | "field17": { 193 | "format": "int64", 194 | "type": "integer", 195 | "x-kubernetes-int-or-string": true 196 | }, 197 | "field18": { 198 | "format": "int64", 199 | "type": "integer", 200 | "x-kubernetes-int-or-string": true 201 | }, 202 | "field3": { 203 | "type": "number" 204 | }, 205 | "field4": { 206 | "type": "number" 207 | }, 208 | "field5": { 209 | "format": "int32", 210 | "type": "integer" 211 | }, 212 | "field6": { 213 | "format": "int32", 214 | "type": "integer" 215 | }, 216 | "field7": { 217 | "format": "int32", 218 | "type": "integer" 219 | }, 220 | "field8": { 221 | "maximum": 4294967295, 222 | "minimum": 0, 223 | "type": "integer" 224 | }, 225 | "mapField": { 226 | "additionalProperties": { 227 | "properties": { 228 | "field1": { 229 | "type": "string" 230 | }, 231 | "field2": { 232 | "format": "int32", 233 | "type": "integer" 234 | } 235 | }, 236 | "type": "object" 237 | }, 238 | "description": "first line of comment.", 239 | "type": "object" 240 | }, 241 | "messageOneOfField": { 242 | "description": "messageoneof comment", 243 | "properties": { 244 | "name": { 245 | "type": "string" 246 | }, 247 | "number": { 248 | "description": "Valid port number", 249 | "maximum": 4294967295, 250 | "minimum": 0, 251 | "type": "integer" 252 | } 253 | }, 254 | "type": "object" 255 | }, 256 | "oneoffield1": { 257 | "type": "string" 258 | }, 259 | "oneoffield2": { 260 | "type": "string" 261 | }, 262 | "str": { 263 | "description": "an array of strings", 264 | "items": { 265 | "type": "string" 266 | }, 267 | "type": "array" 268 | }, 269 | "test3Nested": { 270 | "properties": { 271 | "nested1": { 272 | "type": "string" 273 | }, 274 | "nested2": { 275 | "type": "number" 276 | } 277 | }, 278 | "type": "object" 279 | } 280 | }, 281 | "type": "object" 282 | }, 283 | "testpkg2.Test3_Value": { 284 | "properties": { 285 | "field1": { 286 | "type": "string" 287 | }, 288 | "field2": { 289 | "format": "int32", 290 | "type": "integer" 291 | } 292 | }, 293 | "type": "object" 294 | } 295 | } 296 | }, 297 | "info": { 298 | "title": "OpenAPI Spec for Solo APIs.", 299 | "version": "" 300 | }, 301 | "paths": null 302 | } -------------------------------------------------------------------------------- /pkg/markers/constraints.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "log" 5 | "math" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | ) 9 | 10 | // Maximum specifies the maximum numeric value that this field can have. 11 | type Maximum float64 12 | 13 | func (m Maximum) Value() float64 { 14 | return float64(m) 15 | } 16 | 17 | func (m Maximum) ApplyToSchema(o *openapi3.Schema) { 18 | if !hasNumericType(o) { 19 | log.Panicf("Maximum constraint applied to non-numeric type %s", o.Type) 20 | } 21 | o.WithMax(m.Value()) 22 | } 23 | 24 | // Minimum specifies the minimum numeric value that this field can have. Negative numbers are supported. 25 | type Minimum float64 26 | 27 | func (m Minimum) Value() float64 { 28 | return float64(m) 29 | } 30 | 31 | func (m Minimum) ApplyToSchema(o *openapi3.Schema) { 32 | if !hasNumericType(o) { 33 | log.Panicf("must apply Minimum to a numeric type, got %s", o.Type) 34 | } 35 | o.WithMin(m.Value()) 36 | } 37 | 38 | // ExclusiveMinimum indicates that the minimum is "up to" but not including that value. 39 | type ExclusiveMinimum bool 40 | 41 | func (m ExclusiveMinimum) ApplyToSchema(o *openapi3.Schema) { 42 | if !hasNumericType(o) { 43 | log.Panicf("must apply ExclusiveMinimum to a numeric type, got %s", o.Type) 44 | } 45 | o.WithExclusiveMin(bool(m)) 46 | } 47 | 48 | // ExclusiveMaximum indicates that the maximum is "up to" but not including that value. 49 | type ExclusiveMaximum bool 50 | 51 | func (m ExclusiveMaximum) ApplyToSchema(o *openapi3.Schema) { 52 | if !hasNumericType(o) { 53 | log.Panicf("must apply ExclusiveMaximum to a numeric type, got %s", o.Type) 54 | } 55 | o.WithExclusiveMax(bool(m)) 56 | } 57 | 58 | // MultipleOf specifies that this field must have a numeric value that's a multiple of this one. 59 | type MultipleOf float64 60 | 61 | func (m MultipleOf) Value() float64 { 62 | return float64(m) 63 | } 64 | 65 | func (m MultipleOf) ApplyToSchema(o *openapi3.Schema) { 66 | if !hasNumericType(o) { 67 | log.Panicf("must apply MultipleOf to a numeric type, got %s", o.Type) 68 | } 69 | if o.Type.Is(openapi3.TypeInteger) && !isIntegral(m.Value()) { 70 | log.Panicf("cannot apply non-integral MultipleOf validation (%v) to integer value", m.Value()) 71 | } 72 | val := m.Value() 73 | o.MultipleOf = &val 74 | } 75 | 76 | // MaxProperties restricts the number of keys in an object 77 | type MaxProperties int 78 | 79 | func (m MaxProperties) ApplyToSchema(o *openapi3.Schema) { 80 | if !o.Type.Is(openapi3.TypeObject) { 81 | log.Panicf("must apply MaxProperties to an object, got %s", o.Type) 82 | } 83 | o.WithMaxProperties(int64(m)) 84 | } 85 | 86 | // MinProperties restricts the number of keys in an object 87 | type MinProperties int 88 | 89 | func (m MinProperties) ApplyToSchema(o *openapi3.Schema) { 90 | if !o.Type.Is(openapi3.TypeObject) { 91 | log.Panicf("must apply MinProperties to an object, got %s", o.Type) 92 | } 93 | o.WithMinProperties(int64(m)) 94 | } 95 | 96 | // MaxLength specifies the maximum length for this string. 97 | type MaxLength int 98 | 99 | func (m MaxLength) ApplyToSchema(o *openapi3.Schema) { 100 | if !o.Type.Is(openapi3.TypeString) { 101 | log.Panicf("must apply MaxLength to a string, got %s", o.Type) 102 | } 103 | o.WithMaxLength(int64(m)) 104 | } 105 | 106 | // MinLength specifies the minimum length for this string. 107 | type MinLength int 108 | 109 | func (m MinLength) ApplyToSchema(o *openapi3.Schema) { 110 | if !o.Type.Is(openapi3.TypeString) { 111 | log.Panicf("must apply MinLength to a string, got %s", o.Type) 112 | } 113 | o.WithMinLength(int64(m)) 114 | } 115 | 116 | // Pattern specifies that this string must match the given regular expression. 117 | type Pattern string 118 | 119 | func (m Pattern) ApplyToSchema(o *openapi3.Schema) { 120 | if !o.Type.Is(openapi3.TypeString) { 121 | log.Panicf("must apply Pattern to a string, got %s", o.Type) 122 | } 123 | o.WithPattern(string(m)) 124 | } 125 | 126 | // MaxItems specifies the maximum length for this list. 127 | type MaxItems int 128 | 129 | func (m MaxItems) ApplyToSchema(o *openapi3.Schema) { 130 | if !o.Type.Is(openapi3.TypeArray) { 131 | log.Panicf("must apply MaxItems to an array, got %s", o.Type) 132 | } 133 | o.WithMaxItems(int64(m)) 134 | } 135 | 136 | // MinItems specifies the minimum length for this list. 137 | type MinItems int 138 | 139 | func (m MinItems) ApplyToSchema(o *openapi3.Schema) { 140 | if !o.Type.Is(openapi3.TypeArray) { 141 | log.Panicf("must apply MinItems to an array, got %s", o.Type) 142 | } 143 | o.WithMinItems(int64(m)) 144 | } 145 | 146 | // UniqueItems specifies that all items in this list must be unique. 147 | type UniqueItems bool 148 | 149 | func (m UniqueItems) ApplyToSchema(o *openapi3.Schema) { 150 | if !o.Type.Is(openapi3.TypeArray) { 151 | log.Panicf("must apply UniqueItems to an array, got %s", o.Type) 152 | } 153 | o.UniqueItems = bool(m) 154 | } 155 | 156 | // Enum specifies that this (scalar) field is restricted to the *exact* values specified here. 157 | type Enum []interface{} 158 | 159 | func (m Enum) ApplyToSchema(o *openapi3.Schema) { 160 | o.WithEnum(m...) 161 | } 162 | 163 | // Format specifies additional "complex" formatting for this field. 164 | // 165 | // For example, a date-time field would be marked as "type: string" and 166 | // "format: date-time". 167 | type Format string 168 | 169 | func (m Format) ApplyToSchema(o *openapi3.Schema) { 170 | o.WithFormat(string(m)) 171 | } 172 | 173 | // Type is a marker that specifies the type of a field in the schema 174 | // It only supports the following types: 175 | // - object: an opaque object 176 | // - value: an opauqe value 177 | type Type string 178 | 179 | const ( 180 | TypeObject Type = "object" 181 | TypeValue Type = "value" 182 | ) 183 | 184 | func (m Type) ApplyToSchema(o *openapi3.Schema) { 185 | if o.Type == nil { 186 | return 187 | } 188 | // object and value types are special cased in the generator 189 | if o.Type != nil && (o.Type.Is(openapi3.TypeObject) || !o.Type.Is(string(TypeValue))) { 190 | return 191 | } 192 | o.Type = &openapi3.Types{string(m)} 193 | } 194 | 195 | // PreserveUnknownFields stops the apiserver from pruning fields which are not specified. 196 | // 197 | // By default the apiserver drops unknown fields from the request payload 198 | // during the decoding step. This marker stops the API server from doing so. 199 | // It affects fields recursively, but switches back to normal pruning behaviour 200 | // if nested properties or additionalProperties are specified in the schema. 201 | // This can either be true or undefined. False 202 | // is forbidden. 203 | // 204 | // Note: The kubebuilder:validation:XPreserveUnknownFields variant is deprecated 205 | // in favor of the kubebuilder:pruning:PreserveUnknownFields variant. They function 206 | // identically. 207 | type XPreserveUnknownFields struct{} 208 | 209 | func (m XPreserveUnknownFields) ApplyToSchema(o *openapi3.Schema) { 210 | if o.Extensions == nil { 211 | o.Extensions = map[string]interface{}{} 212 | } 213 | o.Extensions["x-kubernetes-preserve-unknown-fields"] = true 214 | } 215 | 216 | // EmbeddedResource marks a fields as an embedded resource with apiVersion, kind and metadata fields. 217 | // 218 | // An embedded resource is a value that has apiVersion, kind and metadata fields. 219 | // They are validated implicitly according to the semantics of the currently 220 | // running apiserver. It is not necessary to add any additional schema for these 221 | // field, yet it is possible. This can be combined with PreserveUnknownFields. 222 | type XEmbeddedResource struct{} 223 | 224 | func (m XEmbeddedResource) ApplyToSchema(o *openapi3.Schema) { 225 | if o.Extensions == nil { 226 | o.Extensions = map[string]interface{}{} 227 | } 228 | o.Extensions["x-kubernetes-embedded-resource"] = true 229 | } 230 | 231 | // IntOrString marks a fields as an IntOrString. 232 | // 233 | // This is required when applying patterns or other validations to an IntOrString 234 | // field. Knwon information about the type is applied during the collapse phase 235 | // and as such is not normally available during marker application. 236 | type XIntOrString struct{} 237 | 238 | func (m XIntOrString) ApplyToSchema(o *openapi3.Schema) { 239 | if o.Extensions == nil { 240 | o.Extensions = map[string]interface{}{} 241 | } 242 | o.Extensions["x-kubernetes-int-or-string"] = true 243 | } 244 | 245 | // XValidation marks a field as requiring a value for which a given 246 | // expression evaluates to true. 247 | // 248 | // This marker may be repeated to specify multiple expressions, all of 249 | // which must evaluate to true. 250 | type XValidation struct { 251 | Rule string `json:"rule"` 252 | Message string `marker:",optional" json:"message,omitempty"` 253 | MessageExpression string `marker:",optional" json:"messageExpression,omitempty"` 254 | } 255 | 256 | func (x XValidation) ApplyToSchema(o *openapi3.Schema) { 257 | const validationsHeader = "x-kubernetes-validations" 258 | if o.Extensions == nil { 259 | o.Extensions = map[string]interface{}{ 260 | validationsHeader: []XValidation{}, 261 | } 262 | } else if o.Extensions[validationsHeader] == nil { 263 | o.Extensions[validationsHeader] = []XValidation{} 264 | } 265 | o.Extensions[validationsHeader] = append(o.Extensions[validationsHeader].([]XValidation), x) 266 | } 267 | 268 | // Nullable marks this field as allowing the "null" value. 269 | // 270 | // This is often not necessary, but may be helpful with custom serialization. 271 | type Nullable struct{} 272 | 273 | func (m Nullable) ApplyToSchema(o *openapi3.Schema) { 274 | o.WithNullable() 275 | } 276 | 277 | // Default sets the default value for this field. 278 | // 279 | // A default value will be accepted as any value valid for the 280 | // field. Formatting for common types include: boolean: `true`, string: 281 | // `Cluster`, numerical: `1.24`, array: `{1,2}`, object: `{policy: 282 | // "delete"}`). Defaults should be defined in pruned form, and only best-effort 283 | // validation will be performed. Full validation of a default requires 284 | // submission of the containing CRD to an apiserver. 285 | type Default struct { 286 | Value interface{} 287 | } 288 | 289 | func (m Default) ApplyToSchema(o *openapi3.Schema) { 290 | o.WithDefault(m.Value) 291 | } 292 | 293 | // Example sets the example value for this field. 294 | // 295 | // An example value will be accepted as any value valid for the 296 | // field. Formatting for common types include: boolean: `true`, string: 297 | // `Cluster`, numerical: `1.24`, array: `{1,2}`, object: `{policy: 298 | // "delete"}`). Examples should be defined in pruned form, and only best-effort 299 | // validation will be performed. Full validation of an example requires 300 | // submission of the containing CRD to an apiserver. 301 | type Example struct { 302 | Value interface{} 303 | } 304 | 305 | func (m Example) ApplyToSchema(o *openapi3.Schema) { 306 | o.Example = m.Value 307 | } 308 | 309 | // Schemaless marks a field as being a schemaless object. 310 | // 311 | // Schemaless objects are not introspected, so you must provide 312 | // any type and validation information yourself. One use for this 313 | // tag is for embedding fields that hold JSONSchema typed objects. 314 | // Because this field disables all type checking, it is recommended 315 | // to be used only as a last resort. 316 | type Schemaless struct{} 317 | 318 | func (m Schemaless) ApplyToSchema(o *openapi3.Schema) { 319 | // only preserve the description 320 | desc := o.Description 321 | nilSchema := openapi3.NewSchema() 322 | *o = *nilSchema 323 | o.Description = desc 324 | } 325 | 326 | // Required marks a field as required. 327 | type Required struct{} 328 | 329 | func (m Required) ApplyToSchema(o *openapi3.Schema) { 330 | // nothing to do, it is applied on the top level message containing the required field 331 | } 332 | 333 | func hasNumericType(o *openapi3.Schema) bool { 334 | return o.Type.Is(openapi3.TypeInteger) || o.Type.Is(openapi3.TypeNumber) 335 | } 336 | 337 | func isIntegral(value float64) bool { 338 | return value == math.Trunc(value) && !math.IsNaN(value) && !math.IsInf(value, 0) 339 | } 340 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "{}" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2016-2023 Solo.io Authors 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /openapiGenerator.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Istio Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this currentFile 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 | package main 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | "log" 22 | "math" 23 | "os" 24 | "path" 25 | "regexp" 26 | "strings" 27 | 28 | "github.com/getkin/kin-openapi/openapi3" 29 | "github.com/ghodss/yaml" 30 | "google.golang.org/protobuf/proto" 31 | "google.golang.org/protobuf/types/descriptorpb" 32 | "google.golang.org/protobuf/types/pluginpb" 33 | kubemarkers "sigs.k8s.io/controller-tools/pkg/markers" 34 | 35 | "github.com/solo-io/protoc-gen-openapi/pkg/markers" 36 | "github.com/solo-io/protoc-gen-openapi/pkg/protomodel" 37 | ) 38 | 39 | var descriptionExclusionMarkers = []string{"$hide_from_docs", "$hide", "@exclude"} 40 | 41 | // Some special types with predefined schemas. 42 | // This is to catch cases where solo apis contain recursive definitions 43 | // Normally these would result in stack-overflow errors when generating the open api schema 44 | // The imperfect solution, is to just generate an empty object for these types 45 | var specialSoloTypes = map[string]openapi3.Schema{ 46 | "core.solo.io.Metadata": { 47 | Type: &openapi3.Types{openapi3.TypeObject}, 48 | }, 49 | "google.protobuf.ListValue": *openapi3.NewArraySchema().WithItems(openapi3.NewObjectSchema()), 50 | "google.protobuf.Struct": { 51 | Type: &openapi3.Types{openapi3.TypeObject}, 52 | Properties: make(map[string]*openapi3.SchemaRef), 53 | Extensions: map[string]interface{}{ 54 | "x-kubernetes-preserve-unknown-fields": true, 55 | }, 56 | }, 57 | "google.protobuf.Any": { 58 | Type: &openapi3.Types{openapi3.TypeObject}, 59 | Properties: make(map[string]*openapi3.SchemaRef), 60 | Extensions: map[string]interface{}{ 61 | "x-kubernetes-preserve-unknown-fields": true, 62 | }, 63 | }, 64 | "google.protobuf.Value": { 65 | Properties: make(map[string]*openapi3.SchemaRef), 66 | Extensions: map[string]interface{}{ 67 | "x-kubernetes-preserve-unknown-fields": true, 68 | }, 69 | }, 70 | "google.protobuf.BoolValue": *openapi3.NewBoolSchema().WithNullable(), 71 | "google.protobuf.StringValue": *openapi3.NewStringSchema().WithNullable(), 72 | "google.protobuf.DoubleValue": *openapi3.NewFloat64Schema().WithNullable(), 73 | "google.protobuf.Int32Value": *openapi3.NewIntegerSchema().WithNullable().WithMin(math.MinInt32).WithMax(math.MaxInt32), 74 | "google.protobuf.Int64Value": *openapi3.NewIntegerSchema().WithNullable().WithMin(math.MinInt64).WithMax(math.MaxInt64), 75 | "google.protobuf.UInt32Value": *openapi3.NewIntegerSchema().WithNullable().WithMin(0).WithMax(math.MaxUint32), 76 | "google.protobuf.UInt64Value": *openapi3.NewIntegerSchema().WithNullable().WithMin(0).WithMax(math.MaxUint64), 77 | "google.protobuf.FloatValue": *openapi3.NewFloat64Schema().WithNullable(), 78 | "google.protobuf.Duration": *openapi3.NewStringSchema(), 79 | "google.protobuf.Empty": *openapi3.NewObjectSchema().WithMaxProperties(0), 80 | "google.protobuf.Timestamp": *openapi3.NewStringSchema().WithFormat("date-time"), 81 | } 82 | 83 | type openapiGenerator struct { 84 | buffer bytes.Buffer 85 | model *protomodel.Model 86 | perFile bool 87 | singleFile bool 88 | yaml bool 89 | useRef bool 90 | 91 | // transient state as individual files are processed 92 | currentPackage *protomodel.PackageDescriptor 93 | currentFrontMatterProvider *protomodel.FileDescriptor 94 | 95 | messages map[string]*protomodel.MessageDescriptor 96 | 97 | // @solo.io customizations to limit length of generated descriptions 98 | descriptionConfiguration *DescriptionConfiguration 99 | 100 | // @solo.io customization to support enum validation schemas with int or string values 101 | // we need to support this since some controllers marshal enums as integers and others as strings 102 | enumAsIntOrString bool 103 | 104 | // @solo.io customizations to define schemas for certain messages 105 | customSchemasByMessageName map[string]openapi3.Schema 106 | 107 | // If set to true, OpenAPI schema will include schema to emulate behavior of protobuf oneof fields 108 | protoOneof bool 109 | 110 | // If set to true, native OpenAPI integer scehmas will be used for integer types instead of Solo wrappers 111 | // that add Kubernetes extension headers to the schema to treat int as strings. 112 | intNative bool 113 | 114 | markerRegistry *markers.Registry 115 | 116 | // If set to true, kubebuilder markers and validations such as PreserveUnknownFields, MinItems, default, and all CEL rules will be omitted from the OpenAPI schema. 117 | // The Type and Required markers will be maintained. 118 | disableKubeMarkers bool 119 | 120 | // when set, this list of substrings will be used to identify kubebuilder markers to ignore. When multiple are 121 | // supplied, this will function as a logical OR i.e. any rule which contains a provided substring will be ignored 122 | ignoredKubeMarkerSubstrings []string 123 | } 124 | 125 | type DescriptionConfiguration struct { 126 | // Whether or not to include a description in the generated open api schema 127 | IncludeDescriptionInSchema bool 128 | 129 | // Whether or not the description for properties should be allowed to span multiple lines 130 | MultilineDescription bool 131 | } 132 | 133 | func newOpenAPIGenerator( 134 | model *protomodel.Model, 135 | perFile bool, 136 | singleFile bool, 137 | yaml bool, 138 | useRef bool, 139 | descriptionConfiguration *DescriptionConfiguration, 140 | enumAsIntOrString bool, 141 | messagesWithEmptySchema []string, 142 | protoOneof bool, 143 | intNative bool, 144 | disableKubeMarkers bool, 145 | ignoredKubeMarkers []string, 146 | ) *openapiGenerator { 147 | mRegistry, err := markers.NewRegistry() 148 | if err != nil { 149 | log.Panicf("error initializing marker registry: %v", err) 150 | } 151 | return &openapiGenerator{ 152 | model: model, 153 | perFile: perFile, 154 | singleFile: singleFile, 155 | yaml: yaml, 156 | useRef: useRef, 157 | descriptionConfiguration: descriptionConfiguration, 158 | enumAsIntOrString: enumAsIntOrString, 159 | customSchemasByMessageName: buildCustomSchemasByMessageName(messagesWithEmptySchema), 160 | protoOneof: protoOneof, 161 | intNative: intNative, 162 | markerRegistry: mRegistry, 163 | disableKubeMarkers: disableKubeMarkers, 164 | ignoredKubeMarkerSubstrings: ignoredKubeMarkers, 165 | } 166 | } 167 | 168 | // buildCustomSchemasByMessageName name returns a mapping of message name to a pre-defined openapi schema 169 | // It includes: 170 | // 1. `specialSoloTypes`, a set of pre-defined schemas 171 | // 2. `messagesWithEmptySchema`, a list of messages that are injected at runtime that should contain 172 | // and empty schema which accepts and preserves all fields 173 | func buildCustomSchemasByMessageName(messagesWithEmptySchema []string) map[string]openapi3.Schema { 174 | schemasByMessageName := make(map[string]openapi3.Schema) 175 | 176 | // Initialize the hard-coded values 177 | for name, schema := range specialSoloTypes { 178 | schemasByMessageName[name] = schema 179 | } 180 | 181 | // Add the messages that were injected at runtime 182 | for _, messageName := range messagesWithEmptySchema { 183 | emptyMessage := openapi3.Schema{ 184 | Type: &openapi3.Types{openapi3.TypeObject}, 185 | Properties: make(map[string]*openapi3.SchemaRef), 186 | Extensions: map[string]interface{}{ 187 | "x-kubernetes-preserve-unknown-fields": true, 188 | }, 189 | } 190 | schemasByMessageName[messageName] = emptyMessage 191 | } 192 | 193 | return schemasByMessageName 194 | } 195 | 196 | func (g *openapiGenerator) generateOutput(filesToGen map[*protomodel.FileDescriptor]bool) (*pluginpb.CodeGeneratorResponse, error) { 197 | response := pluginpb.CodeGeneratorResponse{} 198 | 199 | if g.singleFile { 200 | g.generateSingleFileOutput(filesToGen, &response) 201 | } else { 202 | for _, pkg := range g.model.Packages { 203 | g.currentPackage = pkg 204 | 205 | // anything to output for this package? 206 | count := 0 207 | for _, file := range pkg.Files { 208 | if _, ok := filesToGen[file]; ok { 209 | count++ 210 | } 211 | } 212 | 213 | if count > 0 { 214 | if g.perFile { 215 | g.generatePerFileOutput(filesToGen, pkg, &response) 216 | } else { 217 | g.generatePerPackageOutput(filesToGen, pkg, &response) 218 | } 219 | } 220 | } 221 | } 222 | 223 | return &response, nil 224 | } 225 | 226 | func (g *openapiGenerator) getFileContents(file *protomodel.FileDescriptor, 227 | messages map[string]*protomodel.MessageDescriptor, 228 | enums map[string]*protomodel.EnumDescriptor, 229 | services map[string]*protomodel.ServiceDescriptor, 230 | ) { 231 | for _, m := range file.AllMessages { 232 | messages[g.relativeName(m)] = m 233 | } 234 | 235 | for _, e := range file.AllEnums { 236 | enums[g.relativeName(e)] = e 237 | } 238 | 239 | for _, s := range file.Services { 240 | services[g.relativeName(s)] = s 241 | } 242 | } 243 | 244 | func (g *openapiGenerator) generatePerFileOutput(filesToGen map[*protomodel.FileDescriptor]bool, pkg *protomodel.PackageDescriptor, 245 | response *pluginpb.CodeGeneratorResponse, 246 | ) { 247 | for _, file := range pkg.Files { 248 | if _, ok := filesToGen[file]; ok { 249 | g.currentFrontMatterProvider = file 250 | messages := make(map[string]*protomodel.MessageDescriptor) 251 | enums := make(map[string]*protomodel.EnumDescriptor) 252 | services := make(map[string]*protomodel.ServiceDescriptor) 253 | 254 | g.getFileContents(file, messages, enums, services) 255 | filename := path.Base(file.GetName()) 256 | extension := path.Ext(filename) 257 | name := filename[0 : len(filename)-len(extension)] 258 | 259 | rf := g.generateFile(name, file, messages, enums, services) 260 | response.File = append(response.File, &rf) 261 | } 262 | } 263 | } 264 | 265 | func (g *openapiGenerator) generateSingleFileOutput(filesToGen map[*protomodel.FileDescriptor]bool, response *pluginpb.CodeGeneratorResponse) { 266 | messages := make(map[string]*protomodel.MessageDescriptor) 267 | enums := make(map[string]*protomodel.EnumDescriptor) 268 | services := make(map[string]*protomodel.ServiceDescriptor) 269 | 270 | for file, ok := range filesToGen { 271 | if ok { 272 | g.getFileContents(file, messages, enums, services) 273 | } 274 | } 275 | 276 | rf := g.generateFile("openapiv3", &protomodel.FileDescriptor{}, messages, enums, services) 277 | response.File = []*pluginpb.CodeGeneratorResponse_File{&rf} 278 | } 279 | 280 | func (g *openapiGenerator) generatePerPackageOutput(filesToGen map[*protomodel.FileDescriptor]bool, pkg *protomodel.PackageDescriptor, 281 | response *pluginpb.CodeGeneratorResponse, 282 | ) { 283 | // We need to produce a file for this package. 284 | 285 | // Decide which types need to be included in the generated file. 286 | // This will be all the types in the fileToGen input files, along with any 287 | // dependent types which are located in packages that don't have 288 | // a known location on the web. 289 | messages := make(map[string]*protomodel.MessageDescriptor) 290 | enums := make(map[string]*protomodel.EnumDescriptor) 291 | services := make(map[string]*protomodel.ServiceDescriptor) 292 | 293 | g.currentFrontMatterProvider = pkg.FileDesc() 294 | 295 | for _, file := range pkg.Files { 296 | if _, ok := filesToGen[file]; ok { 297 | g.getFileContents(file, messages, enums, services) 298 | } 299 | } 300 | 301 | rf := g.generateFile(pkg.Name, pkg.FileDesc(), messages, enums, services) 302 | response.File = append(response.File, &rf) 303 | } 304 | 305 | // Generate an OpenAPI spec for a collection of cross-linked files. 306 | func (g *openapiGenerator) generateFile(name string, 307 | pkg *protomodel.FileDescriptor, 308 | messages map[string]*protomodel.MessageDescriptor, 309 | enums map[string]*protomodel.EnumDescriptor, 310 | _ map[string]*protomodel.ServiceDescriptor, 311 | ) pluginpb.CodeGeneratorResponse_File { 312 | g.messages = messages 313 | 314 | allSchemas := make(map[string]*openapi3.SchemaRef) 315 | 316 | for _, message := range messages { 317 | // we generate the top-level messages here and the nested messages are generated 318 | // inside each top-level message. 319 | if message.Parent == nil { 320 | g.generateMessage(message, allSchemas) 321 | } 322 | } 323 | 324 | for _, enum := range enums { 325 | // when there is no parent to the enum. 326 | if len(enum.QualifiedName()) == 1 { 327 | g.generateEnum(enum, allSchemas) 328 | } 329 | } 330 | 331 | var version string 332 | var description string 333 | // only get the API version when generate per package or per file, 334 | // as we cannot guarantee all protos in the input are the same version. 335 | if !g.singleFile { 336 | if g.currentFrontMatterProvider != nil && g.currentFrontMatterProvider.Matter.Description != "" { 337 | description = g.currentFrontMatterProvider.Matter.Description 338 | } else if pd := g.generateDescription(g.currentPackage); pd != "" { 339 | description = pd 340 | } else { 341 | description = "OpenAPI Spec for Solo APIs." 342 | } 343 | // derive the API version from the package name 344 | // which is a convention for Istio APIs. 345 | var p string 346 | if pkg != nil { 347 | p = pkg.GetPackage() 348 | } else { 349 | p = name 350 | } 351 | s := strings.Split(p, ".") 352 | version = s[len(s)-1] 353 | } else { 354 | description = "OpenAPI Spec for Solo APIs." 355 | } 356 | 357 | c := openapi3.NewComponents() 358 | c.Schemas = allSchemas 359 | // add the openapi object required by the spec. 360 | o := openapi3.T{ 361 | OpenAPI: "3.0.1", 362 | Info: &openapi3.Info{ 363 | Title: description, 364 | Version: version, 365 | }, 366 | Components: &c, 367 | } 368 | 369 | g.buffer.Reset() 370 | var filename *string 371 | if g.yaml { 372 | b, err := yaml.Marshal(o) 373 | if err != nil { 374 | fmt.Fprintf(os.Stderr, "unable to marshall the output of %v to yaml", name) 375 | } 376 | filename = proto.String(name + ".yaml") 377 | g.buffer.Write(b) 378 | } else { 379 | b, err := json.MarshalIndent(o, "", " ") 380 | if err != nil { 381 | fmt.Fprintf(os.Stderr, "unable to marshall the output of %v to json", name) 382 | } 383 | filename = proto.String(name + ".json") 384 | g.buffer.Write(b) 385 | } 386 | 387 | return pluginpb.CodeGeneratorResponse_File{ 388 | Name: filename, 389 | Content: proto.String(g.buffer.String()), 390 | } 391 | } 392 | 393 | func (g *openapiGenerator) generateMessage(message *protomodel.MessageDescriptor, allSchemas map[string]*openapi3.SchemaRef) { 394 | if o := g.generateMessageSchema(message); o != nil { 395 | allSchemas[g.absoluteName(message)] = o.NewRef() 396 | } 397 | } 398 | 399 | func (g *openapiGenerator) generateSoloMessageSchema(message *protomodel.MessageDescriptor, customSchema *openapi3.Schema) *openapi3.Schema { 400 | o := customSchema 401 | o.Description = g.generateDescription(message) 402 | 403 | return o 404 | } 405 | 406 | func (g *openapiGenerator) generateSoloInt64Schema() *openapi3.Schema { 407 | schema := openapi3.NewInt64Schema() 408 | schema.Extensions = map[string]interface{}{ 409 | "x-kubernetes-int-or-string": true, 410 | } 411 | 412 | return schema 413 | } 414 | 415 | func (g *openapiGenerator) generateMessageSchema(message *protomodel.MessageDescriptor) *openapi3.Schema { 416 | // skip MapEntry message because we handle map using the map's repeated field. 417 | if message.GetOptions().GetMapEntry() { 418 | return nil 419 | } 420 | o := openapi3.NewObjectSchema() 421 | o.Description = g.generateDescription(message) 422 | msgRules := g.validationRules(message) 423 | g.mustApplyRulesToSchema(msgRules, o, markers.TargetType) 424 | 425 | oneOfFields := make(map[int32][]string) 426 | var requiredFields []string 427 | for _, field := range message.Fields { 428 | repeated := field.IsRepeated() 429 | fieldName := g.fieldName(field) 430 | fieldDesc := g.generateDescription(field) 431 | fieldRules := g.validationRules(field) 432 | 433 | // If the field is a oneof, we need to add the oneof property to the schema 434 | if field.OneofIndex != nil { 435 | idx := *field.OneofIndex 436 | oneOfFields[idx] = append(oneOfFields[idx], fieldName) 437 | } 438 | 439 | if g.markerRegistry.IsRequired(fieldRules) { 440 | requiredFields = append(requiredFields, fieldName) 441 | } 442 | 443 | schemaType := g.markerRegistry.GetSchemaType(fieldRules, markers.TargetField) 444 | if schemaType != "" { 445 | tmp := getSoloSchemaForMarkerType(schemaType) 446 | schema := getSchemaIfRepeated(&tmp, repeated) 447 | schema.Description = fieldDesc 448 | g.mustApplyRulesToSchema(fieldRules, schema, markers.TargetField) 449 | o.WithProperty(fieldName, schema) 450 | continue 451 | } 452 | 453 | sr := g.fieldTypeRef(field) 454 | g.mustApplyRulesToSchema(fieldRules, sr.Value, markers.TargetField) 455 | o.WithProperty(fieldName, sr.Value) 456 | } 457 | 458 | if len(requiredFields) > 0 { 459 | o.Required = requiredFields 460 | } 461 | 462 | if g.protoOneof { 463 | // Add protobuf oneof schema for this message 464 | oneOfs := make([][]*openapi3.Schema, len(oneOfFields)) 465 | for idx := range oneOfFields { 466 | // oneOfSchemas is a collection (not and required schemas) that should be assigned to the schemas's oneOf field 467 | oneOfSchemas := newProtoOneOfSchema(oneOfFields[idx]...) 468 | oneOfs[idx] = append(oneOfs[idx], oneOfSchemas...) 469 | } 470 | 471 | switch len(oneOfs) { 472 | case 0: 473 | // no oneof fields 474 | case 1: 475 | o.OneOf = getSchemaRefs(oneOfs[0]...) 476 | default: 477 | // Wrap collected OneOf refs with AllOf schema 478 | for _, schemas := range oneOfs { 479 | oneOfRef := openapi3.NewOneOfSchema(schemas...) 480 | o.AllOf = append(o.AllOf, oneOfRef.NewRef()) 481 | } 482 | } 483 | } 484 | 485 | return o 486 | } 487 | 488 | func getSoloSchemaForMarkerType(t markers.Type) openapi3.Schema { 489 | switch t { 490 | case markers.TypeObject: 491 | return specialSoloTypes["google.protobuf.Struct"] 492 | case markers.TypeValue: 493 | return specialSoloTypes["google.protobuf.Value"] 494 | default: 495 | log.Panicf("unexpected schema type %v", t) 496 | return openapi3.Schema{} 497 | } 498 | } 499 | 500 | func getSchemaRefs(schemas ...*openapi3.Schema) openapi3.SchemaRefs { 501 | var refs openapi3.SchemaRefs 502 | for _, schema := range schemas { 503 | refs = append(refs, schema.NewRef()) 504 | } 505 | return refs 506 | } 507 | 508 | func getSchemaIfRepeated(schema *openapi3.Schema, repeated bool) *openapi3.Schema { 509 | if repeated { 510 | schema = openapi3.NewArraySchema().WithItems(schema) 511 | } 512 | return schema 513 | } 514 | 515 | // newProtoOneOfSchema returns a schema that can be used to represent a collection of fields 516 | // that must be encoded as a oneOf in OpenAPI. 517 | // For e.g., if the fields x and y are a part of a proto oneof, then they can be represented as 518 | // follows, such that only one of x or y is required and specifying neither is also acceptable. 519 | // 520 | // { 521 | // "not": { 522 | // "anyOf": [ 523 | // { 524 | // "required": [ 525 | // "x" 526 | // ] 527 | // }, 528 | // { 529 | // "required": [ 530 | // "y" 531 | // ] 532 | // } 533 | // ] 534 | // } 535 | // }, 536 | // { 537 | // "required": [ 538 | // "x" 539 | // ] 540 | // }, 541 | // { 542 | // "required": [ 543 | // "y" 544 | // ] 545 | // } 546 | func newProtoOneOfSchema(fields ...string) []*openapi3.Schema { 547 | fieldSchemas := make([]*openapi3.Schema, len(fields)) 548 | for i, field := range fields { 549 | schema := openapi3.NewSchema() 550 | schema.Required = []string{field} 551 | fieldSchemas[i] = schema 552 | } 553 | // convert fieldSchema to a oneOf schema 554 | anyOfSchema := openapi3.NewAnyOfSchema(fieldSchemas...) 555 | notAnyOfSchema := openapi3.NewSchema() 556 | notAnyOfSchema.Not = anyOfSchema.NewRef() 557 | 558 | allOneOfSchemas := make([]*openapi3.Schema, len(fieldSchemas)+1) 559 | allOneOfSchemas[0] = notAnyOfSchema 560 | for i, fieldSchema := range fieldSchemas { 561 | allOneOfSchemas[i+1] = fieldSchema 562 | } 563 | return allOneOfSchemas 564 | } 565 | 566 | func (g *openapiGenerator) generateEnum(enum *protomodel.EnumDescriptor, allSchemas map[string]*openapi3.SchemaRef) { 567 | o := g.generateEnumSchema(enum) 568 | allSchemas[g.absoluteName(enum)] = o.NewRef() 569 | } 570 | 571 | func (g *openapiGenerator) generateEnumSchema(enum *protomodel.EnumDescriptor) *openapi3.Schema { 572 | /** 573 | The out of the box solution created an enum like: 574 | enum: 575 | - - option_a 576 | - option_b 577 | - option_c 578 | 579 | Instead, what we want is: 580 | enum: 581 | - option_a 582 | - option_b 583 | - option_c 584 | */ 585 | o := openapi3.NewStringSchema() 586 | o.Description = g.generateDescription(enum) 587 | 588 | // If the schema should be int or string, mark it as such 589 | if g.enumAsIntOrString { 590 | o.Extensions = map[string]interface{}{ 591 | "x-kubernetes-int-or-string": true, 592 | } 593 | return o 594 | } 595 | 596 | // otherwise, return define the expected string values 597 | values := enum.GetValue() 598 | for _, v := range values { 599 | o.Enum = append(o.Enum, v.GetName()) 600 | } 601 | o.Type = &openapi3.Types{openapi3.TypeString} 602 | 603 | return o 604 | } 605 | 606 | func (g *openapiGenerator) absoluteName(desc protomodel.CoreDesc) string { 607 | typeName := protomodel.DottedName(desc) 608 | return desc.PackageDesc().Name + "." + typeName 609 | } 610 | 611 | // converts the first section of the leading comment or the description of the proto 612 | // to a single line of description. 613 | func (g *openapiGenerator) generateDescription(desc protomodel.CoreDesc) string { 614 | if g.descriptionConfiguration.MultilineDescription { 615 | return g.generateMultiLineDescription(desc) 616 | } 617 | 618 | if !g.descriptionConfiguration.IncludeDescriptionInSchema { 619 | return "" 620 | } 621 | 622 | c := strings.TrimSpace(desc.Location().GetLeadingComments()) 623 | t := strings.Split(c, "\n\n")[0] 624 | // omit the comment that starts with `$`. 625 | if strings.HasPrefix(t, "$") { 626 | return "" 627 | } 628 | 629 | return strings.Join(strings.Fields(t), " ") 630 | } 631 | 632 | func (g *openapiGenerator) generateMultiLineDescription(desc protomodel.CoreDesc) string { 633 | if !g.descriptionConfiguration.IncludeDescriptionInSchema { 634 | return "" 635 | } 636 | comments, _ := g.parseComments(desc) 637 | return comments 638 | } 639 | 640 | func (g *openapiGenerator) mustApplyRulesToSchema( 641 | rules []string, 642 | o *openapi3.Schema, 643 | target kubemarkers.TargetType, 644 | ) { 645 | if g.disableKubeMarkers { 646 | return 647 | } 648 | g.markerRegistry.MustApplyRulesToSchema(rules, o, target) 649 | } 650 | 651 | func (g *openapiGenerator) validationRules(desc protomodel.CoreDesc) []string { 652 | _, validationRules := g.parseComments(desc) 653 | return validationRules 654 | } 655 | 656 | func (g *openapiGenerator) parseComments(desc protomodel.CoreDesc) (comments string, validationRules []string) { 657 | c := strings.TrimSpace(desc.Location().GetLeadingComments()) 658 | blocks := strings.Split(c, "\n\n") 659 | 660 | var ignoredKubeMarkersRegexp *regexp.Regexp 661 | if len(g.ignoredKubeMarkerSubstrings) > 0 { 662 | ignoredKubeMarkersRegexp = regexp.MustCompile( 663 | fmt.Sprintf("(?:%s)", strings.Join(g.ignoredKubeMarkerSubstrings, "|")), 664 | ) 665 | } 666 | 667 | var sb strings.Builder 668 | for i, block := range blocks { 669 | if shouldNotRenderDesc(strings.TrimSpace(block)) { 670 | continue 671 | } 672 | if i > 0 { 673 | sb.WriteString("\n\n") 674 | } 675 | var blockSb strings.Builder 676 | lines := strings.Split(block, "\n") 677 | for i, line := range lines { 678 | if i > 0 { 679 | blockSb.WriteString("\n") 680 | } 681 | l := strings.TrimSpace(line) 682 | if shouldNotRenderDesc(l) { 683 | continue 684 | } 685 | 686 | if strings.HasPrefix(l, markers.Kubebuilder) { 687 | if isIgnoredKubeMarker(ignoredKubeMarkersRegexp, l) { 688 | continue 689 | } 690 | 691 | validationRules = append(validationRules, l) 692 | continue 693 | } 694 | if len(line) > 0 && line[0] == ' ' { 695 | line = line[1:] 696 | } 697 | blockSb.WriteString(strings.TrimRight(line, " ")) 698 | } 699 | 700 | block = blockSb.String() 701 | sb.WriteString(block) 702 | } 703 | 704 | comments = strings.TrimSpace(sb.String()) 705 | return 706 | } 707 | 708 | func shouldNotRenderDesc(desc string) bool { 709 | desc = strings.TrimSpace(desc) 710 | for _, marker := range descriptionExclusionMarkers { 711 | if strings.HasPrefix(desc, marker) { 712 | return true 713 | } 714 | } 715 | return false 716 | } 717 | 718 | func (g *openapiGenerator) fieldType(field *protomodel.FieldDescriptor) *openapi3.Schema { 719 | var schema *openapi3.Schema 720 | var isMap bool 721 | switch *field.Type { 722 | case descriptorpb.FieldDescriptorProto_TYPE_FLOAT, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE: 723 | schema = openapi3.NewFloat64Schema() 724 | 725 | case descriptorpb.FieldDescriptorProto_TYPE_INT32, descriptorpb.FieldDescriptorProto_TYPE_SINT32, descriptorpb.FieldDescriptorProto_TYPE_SFIXED32: 726 | schema = openapi3.NewInt32Schema() 727 | 728 | case descriptorpb.FieldDescriptorProto_TYPE_INT64, descriptorpb.FieldDescriptorProto_TYPE_SINT64, 729 | descriptorpb.FieldDescriptorProto_TYPE_SFIXED64, descriptorpb.FieldDescriptorProto_TYPE_FIXED64: 730 | if g.intNative { 731 | schema = openapi3.NewInt64Schema() 732 | } else { 733 | schema = g.generateSoloInt64Schema() 734 | } 735 | 736 | case descriptorpb.FieldDescriptorProto_TYPE_FIXED32: 737 | schema = openapi3.NewInt32Schema() 738 | 739 | case descriptorpb.FieldDescriptorProto_TYPE_UINT32: 740 | schema = openapi3.NewIntegerSchema().WithMin(0).WithMax(math.MaxUint32) 741 | 742 | case descriptorpb.FieldDescriptorProto_TYPE_UINT64: 743 | if g.intNative { 744 | // we don't set the max here beacause it is too large to be represented without scientific notation 745 | // in YAML format 746 | schema = openapi3.NewIntegerSchema().WithMin(0).WithFormat("uint64") 747 | } else { 748 | schema = g.generateSoloInt64Schema() 749 | } 750 | 751 | case descriptorpb.FieldDescriptorProto_TYPE_BOOL: 752 | schema = openapi3.NewBoolSchema() 753 | 754 | case descriptorpb.FieldDescriptorProto_TYPE_STRING: 755 | schema = openapi3.NewStringSchema() 756 | 757 | case descriptorpb.FieldDescriptorProto_TYPE_MESSAGE: 758 | msg := field.FieldType.(*protomodel.MessageDescriptor) 759 | if soloSchema, ok := g.customSchemasByMessageName[g.absoluteName(msg)]; ok { 760 | // Allow for defining special Solo types 761 | schema = g.generateSoloMessageSchema(msg, &soloSchema) 762 | } else if msg.GetOptions().GetMapEntry() { 763 | isMap = true 764 | sr := g.fieldTypeRef(msg.Fields[1]) 765 | if g.useRef && sr.Ref != "" { 766 | schema = openapi3.NewObjectSchema() 767 | // in `$ref`, the value of the schema is not in the output. 768 | sr.Value = nil 769 | schema.AdditionalProperties = openapi3.AdditionalProperties{Schema: sr} 770 | } else { 771 | schema = openapi3.NewObjectSchema().WithAdditionalProperties(sr.Value) 772 | } 773 | } else { 774 | schema = g.generateMessageSchema(msg) 775 | } 776 | 777 | case descriptorpb.FieldDescriptorProto_TYPE_BYTES: 778 | schema = openapi3.NewBytesSchema() 779 | 780 | case descriptorpb.FieldDescriptorProto_TYPE_ENUM: 781 | enum := field.FieldType.(*protomodel.EnumDescriptor) 782 | schema = g.generateEnumSchema(enum) 783 | } 784 | 785 | if field.IsRepeated() && !isMap { 786 | schema = openapi3.NewArraySchema().WithItems(schema) 787 | } 788 | 789 | if schema != nil { 790 | schema.Description = g.generateDescription(field) 791 | } 792 | 793 | return schema 794 | } 795 | 796 | // fieldTypeRef generates the `$ref` in addition to the schema for a field. 797 | func (g *openapiGenerator) fieldTypeRef(field *protomodel.FieldDescriptor) *openapi3.SchemaRef { 798 | s := g.fieldType(field) 799 | var ref string 800 | if *field.Type == descriptorpb.FieldDescriptorProto_TYPE_MESSAGE { 801 | msg := field.FieldType.(*protomodel.MessageDescriptor) 802 | // only generate `$ref` for top level messages. 803 | if _, ok := g.messages[g.relativeName(field.FieldType)]; ok && msg.Parent == nil { 804 | ref = fmt.Sprintf("#/components/schemas/%v", g.absoluteName(field.FieldType)) 805 | } 806 | } 807 | return openapi3.NewSchemaRef(ref, s) 808 | } 809 | 810 | func (g *openapiGenerator) fieldName(field *protomodel.FieldDescriptor) string { 811 | return field.GetJsonName() 812 | } 813 | 814 | func (g *openapiGenerator) relativeName(desc protomodel.CoreDesc) string { 815 | typeName := protomodel.DottedName(desc) 816 | if desc.PackageDesc() == g.currentPackage { 817 | return typeName 818 | } 819 | 820 | return desc.PackageDesc().Name + "." + typeName 821 | } 822 | 823 | func isIgnoredKubeMarker(regexp *regexp.Regexp, l string) bool { 824 | if regexp == nil { 825 | return false 826 | } 827 | 828 | return regexp.MatchString(l) 829 | } 830 | --------------------------------------------------------------------------------