diff --git a/docs/docs/mapping/customizing_openapi_output.md b/docs/docs/mapping/customizing_openapi_output.md index beada2384dd..f3d4ba52269 100644 --- a/docs/docs/mapping/customizing_openapi_output.md +++ b/docs/docs/mapping/customizing_openapi_output.md @@ -847,5 +847,32 @@ or with `protoc`: protoc --openapiv2_out=. --openapiv2_opt=ignore_comments=true ./path/to/file.proto ``` +### Preserve RPC Path Order + +By default, generated Swagger files emit paths found in proto files in alphabetical order. If you would like to +preserve the order of emitted paths to mirror the path order found in proto files, you can use the `preserve_rpc_order` option. If set to `true`, this option will ensure path ordering is preserved for Swagger files with both json and yaml formats. + +This option will also ensure path ordering is preserved in the following scenarios: + +1. When using additional bindings, paths will preserve their ordering within an RPC. +2. When using multiple services, paths will preserve their ordering between RPCs in the whole protobuf file. +3. When merging protobuf files, paths will preserve their ordering depending on the order of files specified on the command line. + +`preserve_rpc_order` can be passed via the `protoc` CLI: + +```sh +protoc --openapiv2_out=. --openapiv2_opt=preserve_rpc_order=true ./path/to/file.proto +``` + +Or, with `buf` in `buf.gen.yaml`: + +```yaml +version: v1 +plugins: + - name: openapiv2 + out: . + opt: + - preserve_rpc_order=true +``` {% endraw %} diff --git a/internal/descriptor/registry.go b/internal/descriptor/registry.go index 9cd14cc7e9d..ecc950d2127 100644 --- a/internal/descriptor/registry.go +++ b/internal/descriptor/registry.go @@ -146,6 +146,10 @@ type Registry struct { // allowPatchFeature determines whether to use PATCH feature involving update masks (using google.protobuf.FieldMask). allowPatchFeature bool + + // preserveRPCOrder, if true, will ensure the order of paths emitted in openapi swagger files mirror + // the order of RPC methods found in proto files. If false, emitted paths will be ordered alphabetically. + preserveRPCOrder bool } type repeatedFieldSeparator struct { @@ -811,3 +815,13 @@ func (r *Registry) SetAllowPatchFeature(allow bool) { func (r *Registry) GetAllowPatchFeature() bool { return r.allowPatchFeature } + +// SetPreserveRPCOrder sets preserveRPCOrder +func (r *Registry) SetPreserveRPCOrder(preserve bool) { + r.preserveRPCOrder = preserve +} + +// IsPreserveRPCOrder returns preserveRPCOrder +func (r *Registry) IsPreserveRPCOrder() bool { + return r.preserveRPCOrder +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/generator.go b/protoc-gen-openapiv2/internal/genopenapi/generator.go index 3848be8c9d2..b5d171387b2 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/generator.go +++ b/protoc-gen-openapiv2/internal/genopenapi/generator.go @@ -7,6 +7,7 @@ import ( "fmt" "path/filepath" "reflect" + "sort" "strings" "github.com/grpc-ecosystem/grpc-gateway/v2/internal/descriptor" @@ -19,6 +20,7 @@ import ( "google.golang.org/protobuf/types/descriptorpb" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/pluginpb" + "gopkg.in/yaml.v3" ) var errNoTargetService = errors.New("no target service defined in the file") @@ -59,12 +61,10 @@ func mergeTargetFile(targets []*wrapper, mergeFileName string) *wrapper { for k, v := range f.swagger.Definitions { mergedTarget.swagger.Definitions[k] = v } - for k, v := range f.swagger.Paths { - mergedTarget.swagger.Paths[k] = v - } for k, v := range f.swagger.SecurityDefinitions { mergedTarget.swagger.SecurityDefinitions[k] = v } + copy(mergedTarget.swagger.Paths, f.swagger.Paths) mergedTarget.swagger.Security = append(mergedTarget.swagger.Security, f.swagger.Security...) } } @@ -112,6 +112,58 @@ func (so openapiSwaggerObject) MarshalYAML() (interface{}, error) { }, nil } +// Custom json marshaller for openapiPathsObject. Ensures +// openapiPathsObject is marshalled into expected format in generated +// swagger.json. +func (po openapiPathsObject) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + + buf.WriteString("{") + for i, pd := range po { + if i != 0 { + buf.WriteString(",") + } + // marshal key + key, err := json.Marshal(pd.Path) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteString(":") + // marshal value + val, err := json.Marshal(pd.PathItemObject) + if err != nil { + return nil, err + } + buf.Write(val) + } + + buf.WriteString("}") + return buf.Bytes(), nil +} + +// Custom yaml marshaller for openapiPathsObject. Ensures +// openapiPathsObject is marshalled into expected format in generated +// swagger.yaml. +func (po openapiPathsObject) MarshalYAML() (interface{}, error) { + var pathObjectNode yaml.Node + pathObjectNode.Kind = yaml.MappingNode + + for _, pathData := range po { + var pathNode, pathItemObjectNode yaml.Node + + pathNode.SetString(pathData.Path) + b, err := yaml.Marshal(pathData.PathItemObject) + if err != nil { + return nil, err + } + pathItemObjectNode.SetString(string(b)) + pathObjectNode.Content = append(pathObjectNode.Content, &pathNode, &pathItemObjectNode) + } + + return pathObjectNode, nil +} + func (so openapiInfoObject) MarshalJSON() ([]byte, error) { type alias openapiInfoObject return extensionMarshalJSON(alias(so), so.extensions) @@ -341,6 +393,9 @@ func (g *generator) Generate(targets []*descriptor.File) ([]*descriptor.Response if g.reg.IsAllowMerge() { targetOpenAPI := mergeTargetFile(openapis, g.reg.GetMergeFileName()) + if !g.reg.IsPreserveRPCOrder() { + targetOpenAPI.swagger.sortPathsAlphabetically() + } f, err := encodeOpenAPI(targetOpenAPI, g.format) if err != nil { return nil, fmt.Errorf("failed to encode OpenAPI for %s: %w", g.reg.GetMergeFileName(), err) @@ -351,6 +406,9 @@ func (g *generator) Generate(targets []*descriptor.File) ([]*descriptor.Response } } else { for _, file := range openapis { + if !g.reg.IsPreserveRPCOrder() { + file.swagger.sortPathsAlphabetically() + } f, err := encodeOpenAPI(file, g.format) if err != nil { return nil, fmt.Errorf("failed to encode OpenAPI for %s: %w", file.fileName, err) @@ -364,6 +422,12 @@ func (g *generator) Generate(targets []*descriptor.File) ([]*descriptor.Response return files, nil } +func (so openapiSwaggerObject) sortPathsAlphabetically() { + sort.Slice(so.Paths, func(i, j int) bool { + return so.Paths[i].Path < so.Paths[j].Path + }) +} + // AddErrorDefs Adds google.rpc.Status and google.protobuf.Any // to registry (used for error-related API responses) func AddErrorDefs(reg *descriptor.Registry) error { diff --git a/protoc-gen-openapiv2/internal/genopenapi/generator_test.go b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go index b63915ae10a..1b83e6c1ce0 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/generator_test.go +++ b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go @@ -1,6 +1,8 @@ package genopenapi_test import ( + "reflect" + "sort" "strings" "testing" @@ -30,7 +32,7 @@ func TestGenerate_YAML(t *testing.T) { }, } - resp := requireGenerate(t, req, genopenapi.FormatYAML) + resp := requireGenerate(t, req, genopenapi.FormatYAML, false, false) if len(resp) != 1 { t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) } @@ -102,7 +104,7 @@ func TestGenerateExtension(t *testing.T) { t.Run(string(format), func(t *testing.T) { t.Parallel() - resp := requireGenerate(t, &req, format) + resp := requireGenerate(t, &req, format, false, false) if len(resp) != 1 { t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) } @@ -122,10 +124,14 @@ func requireGenerate( tb testing.TB, req *pluginpb.CodeGeneratorRequest, format genopenapi.Format, + preserveRPCOrder bool, + allowMerge bool, ) []*descriptor.ResponseFile { tb.Helper() reg := descriptor.NewRegistry() + reg.SetPreserveRPCOrder(preserveRPCOrder) + reg.SetAllowMerge(allowMerge) if err := reg.Load(req); err != nil { tb.Fatalf("failed to load request: %s", err) @@ -147,7 +153,7 @@ func requireGenerate( switch { case err != nil: tb.Fatalf("failed to generate targets: %s", err) - case len(resp) != len(targets): + case len(resp) != len(targets) && !allowMerge: tb.Fatalf("invalid count, expected: %d, actual: %d", len(targets), len(resp)) } @@ -242,7 +248,7 @@ func TestGeneratedYAMLIndent(t *testing.T) { t.Fatalf("failed to marshall yaml: %s", err) } - resp := requireGenerate(t, &req, genopenapi.FormatYAML) + resp := requireGenerate(t, &req, genopenapi.FormatYAML, false, false) if len(resp) != 1 { t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) } @@ -255,3 +261,1438 @@ func TestGeneratedYAMLIndent(t *testing.T) { t.Fatalf("got invalid yaml: %s", err) } } + +func TestGenerateRPCOrderPreserved(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, true, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/b/first", "/a/second", "/c/third"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } + +} + +func TestGenerateRPCOrderNotPreserved(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, false, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/a/second", "/b/first", "/c/third"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } + +} + +func TestGenerateRPCOrderPreservedMultipleServices(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestServiceOne" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/d/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/e/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + service: { + name: "TestServiceTwo" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/g/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, true, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/d/first", "/e/second", "/c/third", "/b/first", "/a/second", "/g/third"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderNotPreservedMultipleServices(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestServiceOne" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/d/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/e/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + service: { + name: "TestServiceTwo" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/g/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, false, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/d/first", "/e/second", "/c/third", "/b/first", "/a/second", "/g/third"} + sort.Strings(expectedPaths) + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderPreservedMergeFiles(t *testing.T) { + t.Parallel() + + const in1 = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/cpath" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/bpath" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/apath" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + const in2 = ` + file_to_generate: "exampleproto/v2/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v2/example.proto" + package: "example.v2" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/f/fpath" + } + } + } + method: { + name: "Test2" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/e/epath" + } + } + } + method: { + name: "Test3" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/d/dpath" + } + } + } + } + options: { + go_package: "exampleproto/v2;exampleproto" + } + }` + + var req1, req2 pluginpb.CodeGeneratorRequest + + if err := prototext.Unmarshal([]byte(in1), &req1); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + if err := prototext.Unmarshal([]byte(in2), &req2); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + req1.ProtoFile = append(req1.ProtoFile, req2.ProtoFile...) + req1.FileToGenerate = append(req1.FileToGenerate, req2.FileToGenerate...) + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req1, format, true, true) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/c/cpath", "/b/bpath", "/a/apath", "/f/fpath", "/e/epath", "/d/dpath"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderNotPreservedMergeFiles(t *testing.T) { + t.Parallel() + + const in1 = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/cpath" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/bpath" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/apath" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + const in2 = ` + file_to_generate: "exampleproto/v2/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v2/example.proto" + package: "example.v2" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/f/fpath" + } + } + } + method: { + name: "Test2" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/e/epath" + } + } + } + method: { + name: "Test3" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/d/dpath" + } + } + } + } + options: { + go_package: "exampleproto/v2;exampleproto" + } + }` + + var req1, req2 pluginpb.CodeGeneratorRequest + + if err := prototext.Unmarshal([]byte(in1), &req1); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + if err := prototext.Unmarshal([]byte(in2), &req2); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + req1.ProtoFile = append(req1.ProtoFile, req2.ProtoFile...) + req1.FileToGenerate = append(req1.FileToGenerate, req2.FileToGenerate...) + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req1, format, false, true) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/c/cpath", "/b/bpath", "/a/apath", "/f/fpath", "/e/epath", "/d/dpath"} + sort.Strings(expectedPaths) + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderPreservedAdditionalBindings(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + additional_bindings { + get: "/a/additional" + } + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + additional_bindings { + get: "/z/zAdditional" + } + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + additional_bindings { + get: "/b/bAdditional" + } + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, true, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/b/first", "/a/additional", "/a/second", "/z/zAdditional", "/c/third", "/b/bAdditional"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderNotPreservedAdditionalBindings(t *testing.T) { + t.Parallel() + + const in = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + additional_bindings { + get: "/a/additional" + } + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + additional_bindings { + get: "/z/zAdditional" + } + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + additional_bindings { + get: "/b/bAdditional" + } + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + var req pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(in), &req); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req, format, false, false) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/b/first", "/a/additional", "/a/second", "/z/zAdditional", "/c/third", "/b/bAdditional"} + sort.Strings(expectedPaths) + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderPreservedMergeFilesAdditionalBindingsMultipleServices(t *testing.T) { + t.Parallel() + + const in1 = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestServiceOne" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/d/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/e/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + service: { + name: "TestServiceTwo" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/g/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + const in2 = ` + file_to_generate: "exampleproto/v2/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v2/example.proto" + package: "example.v2" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/b/bpath" + additional_bindings { + get: "/a/additional" + } + } + } + } + method: { + name: "Test2" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/a/apath" + additional_bindings { + get: "/z/zAdditional" + } + } + } + } + method: { + name: "Test3" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/c/cpath" + additional_bindings { + get: "/b/bAdditional" + } + } + } + } + } + options: { + go_package: "exampleproto/v2;exampleproto" + } + }` + + var req1, req2 pluginpb.CodeGeneratorRequest + + if err := prototext.Unmarshal([]byte(in1), &req1); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + if err := prototext.Unmarshal([]byte(in2), &req2); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + req1.ProtoFile = append(req1.ProtoFile, req2.ProtoFile...) + req1.FileToGenerate = append(req1.FileToGenerate, req2.FileToGenerate...) + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req1, format, true, true) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/d/first", "/e/second", "/c/third", + "/b/first", "/a/second", "/g/third", "/b/bpath", "/a/additional", + "/a/apath", "/z/zAdditional", "/c/cpath", "/b/bAdditional"} + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +func TestGenerateRPCOrderNotPreservedMergeFilesAdditionalBindingsMultipleServices(t *testing.T) { + t.Parallel() + + const in1 = ` + file_to_generate: "exampleproto/v1/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v1/example.proto" + package: "example.v1" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestServiceOne" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/d/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/e/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/c/third" + } + } + } + } + service: { + name: "TestServiceTwo" + method: { + name: "Test1" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/b/first" + } + } + } + method: { + name: "Test2" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/a/second" + } + } + } + method: { + name: "Test3" + input_type: ".example.v1.Foo" + output_type: ".example.v1.Foo" + options: { + [google.api.http]: { + get: "/g/third" + } + } + } + } + options: { + go_package: "exampleproto/v1;exampleproto" + } + }` + + const in2 = ` + file_to_generate: "exampleproto/v2/example.proto" + parameter: "output_format=yaml,allow_delete_body=true" + proto_file: { + name: "exampleproto/v2/example.proto" + package: "example.v2" + message_type: { + name: "Foo" + field: { + name: "bar" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "bar" + } + } + service: { + name: "TestService" + method: { + name: "Test1" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/b/bpath" + additional_bindings { + get: "/a/additional" + } + } + } + } + method: { + name: "Test2" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/a/apath" + additional_bindings { + get: "/z/zAdditional" + } + } + } + } + method: { + name: "Test3" + input_type: ".example.v2.Foo" + output_type: ".example.v2.Foo" + options: { + [google.api.http]: { + get: "/c/cpath" + additional_bindings { + get: "/b/bAdditional" + } + } + } + } + } + options: { + go_package: "exampleproto/v2;exampleproto" + } + }` + + var req1, req2 pluginpb.CodeGeneratorRequest + + if err := prototext.Unmarshal([]byte(in1), &req1); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + if err := prototext.Unmarshal([]byte(in2), &req2); err != nil { + t.Fatalf("failed to marshall yaml: %s", err) + } + + req1.ProtoFile = append(req1.ProtoFile, req2.ProtoFile...) + req1.FileToGenerate = append(req1.FileToGenerate, req2.FileToGenerate...) + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + resp := requireGenerate(t, &req1, format, false, true) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + + t.Log(content) + + contentsSlice := strings.Fields(content) + expectedPaths := []string{"/d/first", "/e/second", "/c/third", + "/b/first", "/a/second", "/g/third", "/b/bpath", "/a/additional", + "/a/apath", "/z/zAdditional", "/c/cpath", "/b/bAdditional"} + sort.Strings(expectedPaths) + + foundPaths := []string{} + for _, contentValue := range contentsSlice { + findExpectedPaths(&foundPaths, expectedPaths, contentValue) + } + + if allPresent := reflect.DeepEqual(foundPaths, expectedPaths); !allPresent { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, expectedPaths) + } + }) + } +} + +// Tries to find expected paths from a provided substring and store them in the foundPaths +// slice. +func findExpectedPaths(foundPaths *[]string, expectedPaths []string, potentialPath string) { + seenPaths := map[string]struct{}{} + + for _, path := range expectedPaths { + _, pathAlreadySeen := seenPaths[path] + if strings.Contains(potentialPath, path) && !pathAlreadySeen { + *foundPaths = append(*foundPaths, path) + seenPaths[path] = struct{}{} + } + } +} + +func TestFindExpectedPaths(t *testing.T) { + t.Parallel() + + testCases := [...]struct { + testName string + requiredPaths []string + potentialPath string + expectedPathsFound []string + }{ + { + testName: "One potential path present", + requiredPaths: []string{"/d/first", "/e/second", "/c/third", "/b/first"}, + potentialPath: "[{\"path: \"/d/first\"", + expectedPathsFound: []string{"/d/first"}, + }, + { + testName: "No potential Paths present", + requiredPaths: []string{"/d/first", "/e/second", "/c/third", "/b/first"}, + potentialPath: "[{\"path: \"/z/zpath\"", + expectedPathsFound: []string{}, + }, + { + testName: "Multiple potential paths present", + requiredPaths: []string{"/d/first", "/e/second", "/c/third", "/b/first", "/d/first"}, + potentialPath: "[{\"path: \"/d/first\"someData\"/c/third\"someData\"/b/third\"", + expectedPathsFound: []string{"/d/first", "/c/third"}, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.testName), func(t *testing.T) { + t.Parallel() + + foundPaths := []string{} + findExpectedPaths(&foundPaths, tc.requiredPaths, tc.potentialPath) + if correctPathsFound := reflect.DeepEqual(foundPaths, tc.expectedPathsFound); !correctPathsFound { + t.Fatalf("Found paths differed from expected paths. Got: %#v, want %#v", foundPaths, tc.expectedPathsFound) + } + }) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/template.go b/protoc-gen-openapiv2/internal/genopenapi/template.go index 01ef460af51..3515dedcb8e 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/template.go +++ b/protoc-gen-openapiv2/internal/genopenapi/template.go @@ -1109,7 +1109,7 @@ func renderServiceTags(services []*descriptor.Service, reg *descriptor.Registry) return tags } -func renderServices(services []*descriptor.Service, paths openapiPathsObject, reg *descriptor.Registry, requestResponseRefs, customRefs refMap, msgs []*descriptor.Message) error { +func renderServices(services []*descriptor.Service, paths *openapiPathsObject, reg *descriptor.Registry, requestResponseRefs, customRefs refMap, msgs []*descriptor.Message) error { // Correctness of svcIdx and methIdx depends on 'services' containing the services in the same order as the 'file.Service' array. svcBaseIdx := 0 var lastFile *descriptor.File = nil @@ -1330,7 +1330,9 @@ func renderServices(services []*descriptor.Service, paths openapiPathsObject, re parameters = append(parameters, queryParams...) path := partsToOpenAPIPath(parts, pathParamNames) - pathItemObject, ok := paths[path] + + pathItemObject, ok := getPathItemObject(*paths, path) + if !ok { pathItemObject = openapiPathItemObject{} } else { @@ -1362,14 +1364,15 @@ func renderServices(services []*descriptor.Service, paths openapiPathsObject, re newPathCount += 1 newPathElement = firstPathParameter.Name + pathParamUniqueSuffixDeliminator + strconv.Itoa(newPathCount) newPath = strings.ReplaceAll(path, "{"+firstPathParameter.Name+"}", "{"+newPathElement+"}") - if newPathItemObject, ok := paths[newPath]; ok { + + if newPathItemObject, ok := getPathItemObject(*paths, newPath); ok { existingOperationObject = operationFunc(&newPathItemObject) } else { existingOperationObject = nil } } // update the pathItemObject we are adding to with the new path - pathItemObject = paths[newPath] + pathItemObject, _ = getPathItemObject(*paths, newPath) firstPathParameter.Name = newPathElement path = newPath parameters[firstParamIndex] = *firstPathParameter @@ -1652,7 +1655,8 @@ func renderServices(services []*descriptor.Service, paths openapiPathsObject, re case "OPTIONS": pathItemObject.Options = operationObject } - paths[path] = pathItemObject + + updatePaths(paths, path, pathItemObject) } } } @@ -1661,6 +1665,33 @@ func renderServices(services []*descriptor.Service, paths openapiPathsObject, re return nil } +// Returns the openapiPathItemObject associated with a path. If path is not present, returns +// empty openapiPathItemObject and false. +func getPathItemObject(paths openapiPathsObject, path string) (openapiPathItemObject, bool) { + for _, pathData := range paths { + if pathData.Path == path { + return pathData.PathItemObject, true + } + } + + return openapiPathItemObject{}, false +} + +// If a path already exists in openapiPathsObject, updates that path's openapiPathItemObject. If not, +// appends a new path and openapiPathItemObject to the openapiPathsObject. +func updatePaths(paths *openapiPathsObject, path string, pathItemObject openapiPathItemObject) { + for i, p := range *paths { + if p.Path == path { + (*paths)[i].PathItemObject = pathItemObject + return + } + } + *paths = append(*paths, pathData{ + Path: path, + PathItemObject: pathItemObject, + }) +} + func mergeDescription(schema openapiSchemaObject) string { desc := schema.Description if schema.Title != "" { // join title because title of parameter object will be ignored @@ -1699,7 +1730,7 @@ func applyTemplate(p param) (*openapiSwaggerObject, error) { Swagger: "2.0", Consumes: []string{"application/json"}, Produces: []string{"application/json"}, - Paths: make(openapiPathsObject), + Paths: openapiPathsObject{}, Definitions: make(openapiDefinitionsObject), Info: openapiInfoObject{ Title: *p.File.Name, @@ -1711,7 +1742,7 @@ func applyTemplate(p param) (*openapiSwaggerObject, error) { // and create entries for all of them. // Also adds custom user specified references to second map. requestResponseRefs, customRefs := refMap{}, refMap{} - if err := renderServices(p.Services, s.Paths, p.reg, requestResponseRefs, customRefs, p.Messages); err != nil { + if err := renderServices(p.Services, &s.Paths, p.reg, requestResponseRefs, customRefs, p.Messages); err != nil { panic(err) } @@ -1920,20 +1951,20 @@ func applyTemplate(p param) (*openapiSwaggerObject, error) { if spb.Responses != nil { for _, verbs := range s.Paths { var maps []openapiResponsesObject - if verbs.Delete != nil { - maps = append(maps, verbs.Delete.Responses) + if verbs.PathItemObject.Delete != nil { + maps = append(maps, verbs.PathItemObject.Delete.Responses) } - if verbs.Get != nil { - maps = append(maps, verbs.Get.Responses) + if verbs.PathItemObject.Get != nil { + maps = append(maps, verbs.PathItemObject.Get.Responses) } - if verbs.Post != nil { - maps = append(maps, verbs.Post.Responses) + if verbs.PathItemObject.Post != nil { + maps = append(maps, verbs.PathItemObject.Post.Responses) } - if verbs.Put != nil { - maps = append(maps, verbs.Put.Responses) + if verbs.PathItemObject.Put != nil { + maps = append(maps, verbs.PathItemObject.Put.Responses) } - if verbs.Patch != nil { - maps = append(maps, verbs.Patch.Responses) + if verbs.PathItemObject.Patch != nil { + maps = append(maps, verbs.PathItemObject.Patch.Responses) } for k, v := range spb.Responses { diff --git a/protoc-gen-openapiv2/internal/genopenapi/template_test.go b/protoc-gen-openapiv2/internal/genopenapi/template_test.go index 1d91bc21e33..80c4e48d214 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/template_test.go +++ b/protoc-gen-openapiv2/internal/genopenapi/template_test.go @@ -1658,10 +1658,10 @@ func TestApplyTemplateMultiService(t *testing.T) { // Check that the two services have unique operation IDs even though they // have the same method name. - if want, is := "ExampleService_Example", result.Paths["/v1/echo"].Get.OperationID; !reflect.DeepEqual(is, want) { + if want, is := "ExampleService_Example", result.getPathItemObject("/v1/echo").Get.OperationID; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).Paths[0].Get.OperationID = %s want to be %s", file, is, want) } - if want, is := "OtherService_Example", result.Paths["/v1/ping"].Get.OperationID; !reflect.DeepEqual(is, want) { + if want, is := "OtherService_Example", result.getPathItemObject("/v1/ping").Get.OperationID; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).Paths[0].Get.OperationID = %s want to be %s", file, is, want) } @@ -1869,13 +1869,14 @@ func TestApplyTemplateOverrideWithOperation(t *testing.T) { t.Errorf("applyTemplate(%#v) failed with %v; want success", *file, err) return } - if want, is := "MyExample", result.Paths["/v1/echo"].Get.OperationID; !reflect.DeepEqual(is, want) { + + if want, is := "MyExample", result.getPathItemObject("/v1/echo").Get.OperationID; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).Paths[0].Get.OperationID = %s want to be %s", *file, is, want) } - if want, is := []string{"application/xml"}, result.Paths["/v1/echo"].Get.Consumes; !reflect.DeepEqual(is, want) { + if want, is := []string{"application/xml"}, result.getPathItemObject("/v1/echo").Get.Consumes; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).Paths[0].Get.Consumes = %s want to be %s", *file, is, want) } - if want, is := []string{"application/json", "application/xml"}, result.Paths["/v1/echo"].Get.Produces; !reflect.DeepEqual(is, want) { + if want, is := []string{"application/json", "application/xml"}, result.getPathItemObject("/v1/echo").Get.Produces; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).Paths[0].Get.Produces = %s want to be %s", *file, is, want) } @@ -2095,8 +2096,8 @@ func TestApplyTemplateExtensions(t *testing.T) { var operation *openapiOperationObject var response openapiResponseObject for _, v := range result.Paths { - operation = v.Get - response = v.Get.Responses["200"] + operation = v.PathItemObject.Get + response = v.PathItemObject.Get.Responses["200"] } if want, is, name := []extension{ {key: "x-op-foo", value: json.RawMessage("\"baz\"")}, @@ -2267,7 +2268,7 @@ func TestApplyTemplateHeaders(t *testing.T) { var response openapiResponseObject for _, v := range result.Paths { - response = v.Get.Responses["200"] + response = v.PathItemObject.Get.Responses["200"] } if want, is, name := []openapiHeadersObject{ { @@ -3013,17 +3014,17 @@ func TestApplyTemplateRequestWithClientStreaming(t *testing.T) { if want, got, name := 3, len(result.Definitions), "len(Definitions)"; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %d want to be %d", file, name, got, want) } - if _, ok := result.Paths["/v1/echo"].Post.Responses["200"]; !ok { - t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.Paths["/v1/echo"].Post.Responses["200"]`) + if _, ok := result.getPathItemObject("/v1/echo").Post.Responses["200"]; !ok { + t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.getPathItemObject("/v1/echo").Post.Responses["200"]`) } else { - if want, got, name := "A successful response.(streaming responses)", result.Paths["/v1/echo"].Post.Responses["200"].Description, `result.Paths["/v1/echo"].Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { + if want, got, name := "A successful response.(streaming responses)", result.getPathItemObject("/v1/echo").Post.Responses["200"].Description, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - streamExampleExampleMessage := result.Paths["/v1/echo"].Post.Responses["200"].Schema - if want, got, name := "object", streamExampleExampleMessage.Type, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { + streamExampleExampleMessage := result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema + if want, got, name := "object", streamExampleExampleMessage.Type, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - if want, got, name := "Stream result of exampleExampleMessage", streamExampleExampleMessage.Title, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { + if want, got, name := "Stream result of exampleExampleMessage", streamExampleExampleMessage.Title, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } streamExampleExampleMessageProperties := *(streamExampleExampleMessage.Properties) @@ -3197,17 +3198,17 @@ func TestApplyTemplateRequestWithServerStreamingAndNoStandardErrors(t *testing.T if want, got, name := 1, len(result.Definitions), "len(Definitions)"; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %d want to be %d", file, name, got, want) } - if _, ok := result.Paths["/v1/echo"].Post.Responses["200"]; !ok { - t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.Paths["/v1/echo"].Post.Responses["200"]`) + if _, ok := result.getPathItemObject("/v1/echo").Post.Responses["200"]; !ok { + t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.getPathItemObject("/v1/echo").Post.Responses["200"]`) } else { - if want, got, name := "A successful response.(streaming responses)", result.Paths["/v1/echo"].Post.Responses["200"].Description, `result.Paths["/v1/echo"].Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { + if want, got, name := "A successful response.(streaming responses)", result.getPathItemObject("/v1/echo").Post.Responses["200"].Description, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - streamExampleExampleMessage := result.Paths["/v1/echo"].Post.Responses["200"].Schema - if want, got, name := "object", streamExampleExampleMessage.Type, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { + streamExampleExampleMessage := result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema + if want, got, name := "object", streamExampleExampleMessage.Type, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - if want, got, name := "Stream result of exampleExampleMessage", streamExampleExampleMessage.Title, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { + if want, got, name := "Stream result of exampleExampleMessage", streamExampleExampleMessage.Title, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } streamExampleExampleMessageProperties := *(streamExampleExampleMessage.Properties) @@ -3553,17 +3554,17 @@ func TestApplyTemplateRequestWithBodyQueryParameters(t *testing.T) { return } - if _, ok := result.Paths["/v1/{parent}/books"].Post.Responses["200"]; !ok { - t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", tt.args.file, `result.Paths["/v1/{parent}/books"].Post.Responses["200"]`) + if _, ok := result.getPathItemObject("/v1/{parent}/books").Post.Responses["200"]; !ok { + t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", tt.args.file, `result.getPathItemObject("/v1/{parent}/books").Post.Responses["200"]`) } else { - if want, got, name := 3, len(result.Paths["/v1/{parent}/books"].Post.Parameters), `len(result.Paths["/v1/{parent}/books"].Post.Parameters)`; !reflect.DeepEqual(got, want) { + if want, got, name := 3, len(result.getPathItemObject("/v1/{parent}/books").Post.Parameters), `len(result.getPathItemObject("/v1/{parent}/books").Post.Parameters)`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %d want to be %d", tt.args.file, name, got, want) } for i, want := range tt.want { - p := result.Paths["/v1/{parent}/books"].Post.Parameters[i] - if got, name := (paramOut{p.Name, p.In, p.Required}), `result.Paths["/v1/{parent}/books"].Post.Parameters[0]`; !reflect.DeepEqual(got, want) { + p := result.getPathItemObject("/v1/{parent}/books").Post.Parameters[i] + if got, name := (paramOut{p.Name, p.In, p.Required}), `result.getPathItemObject("/v1/{parent}/books").Post.Parameters[0]`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %v want to be %v", tt.args.file, name, got, want) } } @@ -6241,7 +6242,7 @@ func TestTemplateWithoutErrorDefinition(t *testing.T) { return } - defRsp, ok := result.Paths["/v1/echo"].Post.Responses["default"] + defRsp, ok := result.getPathItemObject("/v1/echo").Post.Responses["default"] if !ok { return } @@ -6497,7 +6498,7 @@ func TestSingleServiceTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("Results path length differed, got %d want %d", got, want) } - firstOpGet := result.Paths["/v1/{name}"].Get + firstOpGet := result.getPathItemObject("/v1/{name}").Get if got, want := firstOpGet.OperationID, "Service1_GetFoo"; got != want { t.Fatalf("First operation GET id differed, got %s want %s", got, want) } @@ -6514,7 +6515,7 @@ func TestSingleServiceTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("First operation GET second param 'in' differed, got %s want %s", got, want) } - firstOpDelete := result.Paths["/v1/{name}"].Delete + firstOpDelete := result.getPathItemObject("/v1/{name}").Delete if got, want := firstOpDelete.OperationID, "Service1_DeleteFoo"; got != want { t.Fatalf("First operation id DELETE differed, got %s want %s", got, want) } @@ -6531,7 +6532,7 @@ func TestSingleServiceTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("First operation DELETE second param 'in' differed, got %s want %s", got, want) } - secondOpGet := result.Paths["/v1/{name"+pathParamUniqueSuffixDeliminator+"1}"].Get + secondOpGet := result.getPathItemObject("/v1/{name" + pathParamUniqueSuffixDeliminator + "1}").Get if got, want := secondOpGet.OperationID, "Service1_GetBar"; got != want { t.Fatalf("Second operation id GET differed, got %s want %s", got, want) } @@ -6548,7 +6549,7 @@ func TestSingleServiceTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("Second operation GET second param 'in' differed, got %s want %s", got, want) } - secondOpDelete := result.Paths["/v1/{name"+pathParamUniqueSuffixDeliminator+"1}"].Delete + secondOpDelete := result.getPathItemObject("/v1/{name" + pathParamUniqueSuffixDeliminator + "1}").Delete if got, want := secondOpDelete.OperationID, "Service1_DeleteBar"; got != want { t.Fatalf("Second operation id differed, got %s want %s", got, want) } @@ -6740,7 +6741,7 @@ func TestSingleServiceTemplateWithDuplicateInAllSupportedHttp1Operations(t *test t.Fatalf("Results path length differed, got %d want %d", got, want) } - firstOpMethod := getOperation(result.Paths["/v1/{name}"], method) + firstOpMethod := getOperation(result.getPathItemObject("/v1/{name}"), method) if got, want := firstOpMethod.OperationID, "Service1_"+method+"Foo"; got != want { t.Fatalf("First operation %s id differed, got %s want %s", method, got, want) } @@ -6757,7 +6758,7 @@ func TestSingleServiceTemplateWithDuplicateInAllSupportedHttp1Operations(t *test t.Fatalf("First operation %s second param 'in' differed, got %s want %s", method, got, want) } - secondOpMethod := getOperation(result.Paths["/v1/{name"+pathParamUniqueSuffixDeliminator+"1}"], method) + secondOpMethod := getOperation(result.getPathItemObject("/v1/{name"+pathParamUniqueSuffixDeliminator+"1}"), method) if got, want := secondOpMethod.OperationID, "Service1_"+method+"Bar"; got != want { t.Fatalf("Second operation id %s differed, got %s want %s", method, got, want) } @@ -7179,7 +7180,7 @@ func TestTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("Results path length differed, got %d want %d", got, want) } - firstOp := result.Paths["/v1/{name}/{role}"].Get + firstOp := result.getPathItemObject("/v1/{name}/{role}").Get if got, want := firstOp.OperationID, "Service1_Method1"; got != want { t.Fatalf("First operation id differed, got %s want %s", got, want) } @@ -7202,7 +7203,7 @@ func TestTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("First operation third param 'in' differed, got %s want %s", got, want) } - secondOp := result.Paths["/v1/{name"+pathParamUniqueSuffixDeliminator+"1}/{role}"].Get + secondOp := result.getPathItemObject("/v1/{name" + pathParamUniqueSuffixDeliminator + "1}/{role}").Get if got, want := secondOp.OperationID, "Service1_Method2"; got != want { t.Fatalf("Second operation id differed, got %s want %s", got, want) } @@ -7225,7 +7226,7 @@ func TestTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("Second operation third param 'in' differed, got %s want %s", got, want) } - thirdOp := result.Paths["/v1/{name}/roles"].Get + thirdOp := result.getPathItemObject("/v1/{name}/roles").Get if got, want := thirdOp.OperationID, "Service2_Method3"; got != want { t.Fatalf("Third operation id differed, got %s want %s", got, want) } @@ -7242,7 +7243,7 @@ func TestTemplateWithDuplicateHttp1Operations(t *testing.T) { t.Fatalf("Third operation second param 'in' differed, got %s want %s", got, want) } - forthOp := result.Paths["/v1/{name"+pathParamUniqueSuffixDeliminator+"2}/{role}"].Get + forthOp := result.getPathItemObject("/v1/{name" + pathParamUniqueSuffixDeliminator + "2}/{role}").Get if got, want := forthOp.OperationID, "Service2_Method4"; got != want { t.Fatalf("Fourth operation id differed, got %s want %s", got, want) } @@ -7551,7 +7552,7 @@ func TestRenderServicesParameterDescriptionNoFieldBody(t *testing.T) { t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err) } - got := result.Paths["/v1/projects/someotherpath"].Post.Parameters[0].Description + got := result.getPathItemObject("/v1/projects/someotherpath").Post.Parameters[0].Description want := "aMessage description" if got != want { @@ -7701,7 +7702,7 @@ func TestRenderServicesWithBodyFieldNameInCamelCase(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/v1/users/{userObject.name}"].Post + var operation = *result.getPathItemObject("/v1/users/{userObject.name}").Post if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -7911,7 +7912,7 @@ func TestRenderServicesWithBodyFieldHasFieldMask(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/v1/users/{userObject.name}"].Patch + var operation = *result.getPathItemObject("/v1/users/{userObject.name}").Patch if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -8063,7 +8064,7 @@ func TestRenderServicesWithColonInPath(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/my/{overrideField}:foo"].Post + var operation = *result.getPathItemObject("/my/{overrideField}:foo").Post if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -8210,7 +8211,7 @@ func TestRenderServicesWithDoubleColonInPath(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/my/{field}:foo:bar"].Post + var operation = *result.getPathItemObject("/my/{field}:foo:bar").Post if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -8357,7 +8358,7 @@ func TestRenderServicesWithColonLastInPath(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/my/{field}:"].Post + var operation = *result.getPathItemObject("/my/{field}:").Post if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -8504,7 +8505,7 @@ func TestRenderServicesWithColonInSegment(t *testing.T) { t.Fatalf("Wrong results path, got %s want %s", got, want) } - var operation = *result.Paths["/my/{field}"].Post + var operation = *result.getPathItemObject("/my/{field}").Post if got, want := len(operation.Parameters), 2; got != want { t.Fatalf("Parameters length differed, got %d want %d", got, want) } @@ -8751,7 +8752,7 @@ func TestRenderServiceWithHeaderParameters(t *testing.T) { t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err) } - params := result.Paths["/v1/echo"].Get.Parameters + params := result.getPathItemObject("/v1/echo").Get.Parameters if !reflect.DeepEqual(params, test.parameters) { t.Errorf("expected %+v, got %+v", test.parameters, params) @@ -8763,13 +8764,501 @@ func TestRenderServiceWithHeaderParameters(t *testing.T) { func GetPaths(req *openapiSwaggerObject) []string { paths := make([]string, len(req.Paths)) i := 0 - for k := range req.Paths { - paths[i] = k + for _, k := range req.Paths { + paths[i] = k.Path i++ } return paths } +func TestRenderServicesOpenapiPathsOrderPreserved(t *testing.T) { + reqDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyRequest"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + + resDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyResponse"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + meth1 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod1"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + meth2 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod2"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + + svc := &descriptorpb.ServiceDescriptorProto{ + Name: proto.String("MyService"), + Method: []*descriptorpb.MethodDescriptorProto{meth1, meth2}, + } + reqMsg := &descriptor.Message{ + DescriptorProto: reqDesc, + } + resMsg := &descriptor.Message{ + DescriptorProto: resDesc, + } + reqField := &descriptor.Field{ + Message: reqMsg, + FieldDescriptorProto: reqMsg.GetField()[0], + } + resField := &descriptor.Field{ + Message: resMsg, + FieldDescriptorProto: resMsg.GetField()[0], + } + reqField.JsonName = proto.String("field") + resField.JsonName = proto.String("field") + reqMsg.Fields = []*descriptor.Field{reqField} + resMsg.Fields = []*descriptor.Field{resField} + + file := descriptor.File{ + FileDescriptorProto: &descriptorpb.FileDescriptorProto{ + SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, + Package: proto.String("example"), + Name: proto.String(",my_service.proto"), + MessageType: []*descriptorpb.DescriptorProto{reqDesc, resDesc}, + Service: []*descriptorpb.ServiceDescriptorProto{svc}, + Options: &descriptorpb.FileOptions{ + GoPackage: proto.String("github.com/grpc-ecosystem/grpc-gateway/runtime/internal/examplepb;example"), + }, + }, + GoPkg: descriptor.GoPackage{ + Path: "example.com/path/to/example/example.pb", + Name: "example_pb", + }, + Messages: []*descriptor.Message{reqMsg, resMsg}, + Services: []*descriptor.Service{ + { + ServiceDescriptorProto: svc, + Methods: []*descriptor.Method{ + { + MethodDescriptorProto: meth1, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/c/cpath", + }, + }, + }, + }, { + MethodDescriptorProto: meth2, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/b/bpath", + }, + }, + }, + }, + }, + }, + }, + } + reg := descriptor.NewRegistry() + reg.SetPreserveRPCOrder(true) + err := reg.Load(&pluginpb.CodeGeneratorRequest{ProtoFile: []*descriptorpb.FileDescriptorProto{file.FileDescriptorProto}}) + if err != nil { + t.Fatalf("failed to reg.Load(): %v", err) + } + result, err := applyTemplate(param{File: crossLinkFixture(&file), reg: reg}) + if err != nil { + t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err) + } + + paths := result.Paths + + firstRPCPath := file.Services[0].Methods[0].Bindings[0].PathTmpl.Template + secondRPCPath := file.Services[0].Methods[1].Bindings[0].PathTmpl.Template + for i, pathData := range paths { + switch i { + case 0: + if got, want := pathData.Path, firstRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 1: + if got, want := pathData.Path, secondRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + } + } +} + +func TestRenderServicesOpenapiPathsOrderPreservedMultipleServices(t *testing.T) { + reqDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyRequest"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + + resDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyResponse"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + meth1 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod1"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + meth2 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod2"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + meth3 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod3"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + meth4 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod4"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + + svc1 := &descriptorpb.ServiceDescriptorProto{ + Name: proto.String("MyServiceOne"), + Method: []*descriptorpb.MethodDescriptorProto{meth1, meth2}, + } + svc2 := &descriptorpb.ServiceDescriptorProto{ + Name: proto.String("MyServiceTwo"), + Method: []*descriptorpb.MethodDescriptorProto{meth3, meth4}, + } + reqMsg := &descriptor.Message{ + DescriptorProto: reqDesc, + } + resMsg := &descriptor.Message{ + DescriptorProto: resDesc, + } + reqField := &descriptor.Field{ + Message: reqMsg, + FieldDescriptorProto: reqMsg.GetField()[0], + } + resField := &descriptor.Field{ + Message: resMsg, + FieldDescriptorProto: resMsg.GetField()[0], + } + reqField.JsonName = proto.String("field") + resField.JsonName = proto.String("field") + reqMsg.Fields = []*descriptor.Field{reqField} + resMsg.Fields = []*descriptor.Field{resField} + + file := descriptor.File{ + FileDescriptorProto: &descriptorpb.FileDescriptorProto{ + SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, + Package: proto.String("example"), + Name: proto.String(",my_service.proto"), + MessageType: []*descriptorpb.DescriptorProto{reqDesc, resDesc}, + Service: []*descriptorpb.ServiceDescriptorProto{svc1, svc2}, + Options: &descriptorpb.FileOptions{ + GoPackage: proto.String("github.com/grpc-ecosystem/grpc-gateway/runtime/internal/examplepb;example"), + }, + }, + GoPkg: descriptor.GoPackage{ + Path: "example.com/path/to/example/example.pb", + Name: "example_pb", + }, + Messages: []*descriptor.Message{reqMsg, resMsg}, + Services: []*descriptor.Service{ + { + ServiceDescriptorProto: svc1, + Methods: []*descriptor.Method{ + { + MethodDescriptorProto: meth1, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/g/gpath", + }, + }, + }, + }, { + MethodDescriptorProto: meth2, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/f/fpath", + }, + }, + }, + }, + }, + }, { + ServiceDescriptorProto: svc1, + Methods: []*descriptor.Method{ + { + MethodDescriptorProto: meth3, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/c/cpath", + }, + }, + }, + }, { + MethodDescriptorProto: meth4, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/b/bpath", + }, + }, + }, + }, + }, + }, + }, + } + reg := descriptor.NewRegistry() + reg.SetPreserveRPCOrder(true) + reg.SetUseJSONNamesForFields(true) + err := reg.Load(&pluginpb.CodeGeneratorRequest{ProtoFile: []*descriptorpb.FileDescriptorProto{file.FileDescriptorProto}}) + if err != nil { + t.Fatalf("failed to reg.Load(): %v", err) + } + result, err := applyTemplate(param{File: crossLinkFixture(&file), reg: reg}) + if err != nil { + t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err) + } + + paths := result.Paths + + firstRPCPath := file.Services[0].Methods[0].Bindings[0].PathTmpl.Template + secondRPCPath := file.Services[0].Methods[1].Bindings[0].PathTmpl.Template + thirdRPCPath := file.Services[1].Methods[0].Bindings[0].PathTmpl.Template + fourthRPCPath := file.Services[1].Methods[1].Bindings[0].PathTmpl.Template + for i, pathData := range paths { + switch i { + case 0: + if got, want := pathData.Path, firstRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 1: + if got, want := pathData.Path, secondRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 2: + if got, want := pathData.Path, thirdRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 3: + if got, want := pathData.Path, fourthRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + } + } +} + +func TestRenderServicesOpenapiPathsOrderPreservedAdditionalBindings(t *testing.T) { + reqDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyRequest"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + + resDesc := &descriptorpb.DescriptorProto{ + Name: proto.String("MyResponse"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("field"), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + Number: proto.Int32(1), + }, + }, + } + meth1 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod1"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + meth2 := &descriptorpb.MethodDescriptorProto{ + Name: proto.String("MyMethod2"), + InputType: proto.String("MyRequest"), + OutputType: proto.String("MyResponse"), + } + + svc := &descriptorpb.ServiceDescriptorProto{ + Name: proto.String("MyService"), + Method: []*descriptorpb.MethodDescriptorProto{meth1, meth2}, + } + reqMsg := &descriptor.Message{ + DescriptorProto: reqDesc, + } + resMsg := &descriptor.Message{ + DescriptorProto: resDesc, + } + reqField := &descriptor.Field{ + Message: reqMsg, + FieldDescriptorProto: reqMsg.GetField()[0], + } + resField := &descriptor.Field{ + Message: resMsg, + FieldDescriptorProto: resMsg.GetField()[0], + } + reqField.JsonName = proto.String("field") + resField.JsonName = proto.String("field") + reqMsg.Fields = []*descriptor.Field{reqField} + resMsg.Fields = []*descriptor.Field{resField} + + file := descriptor.File{ + FileDescriptorProto: &descriptorpb.FileDescriptorProto{ + SourceCodeInfo: &descriptorpb.SourceCodeInfo{}, + Package: proto.String("example"), + Name: proto.String(",my_service.proto"), + MessageType: []*descriptorpb.DescriptorProto{reqDesc, resDesc}, + Service: []*descriptorpb.ServiceDescriptorProto{svc}, + Options: &descriptorpb.FileOptions{ + GoPackage: proto.String("github.com/grpc-ecosystem/grpc-gateway/runtime/internal/examplepb;example"), + }, + }, + GoPkg: descriptor.GoPackage{ + Path: "example.com/path/to/example/example.pb", + Name: "example_pb", + }, + Messages: []*descriptor.Message{reqMsg, resMsg}, + Services: []*descriptor.Service{ + { + ServiceDescriptorProto: svc, + Methods: []*descriptor.Method{ + { + MethodDescriptorProto: meth1, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/c/cpath", + }, + }, { + HTTPMethod: "GET", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/additionalbinding", + }, + }, + }, + }, { + MethodDescriptorProto: meth2, + RequestType: reqMsg, + ResponseType: resMsg, + Bindings: []*descriptor.Binding{ + { + HTTPMethod: "POST", + PathTmpl: httprule.Template{ + Version: 1, + OpCodes: []int{0, 0}, + Template: "/b/bpath", + }, + }, + }, + }, + }, + }, + }, + } + reg := descriptor.NewRegistry() + reg.SetPreserveRPCOrder(true) + reg.SetUseJSONNamesForFields(true) + err := reg.Load(&pluginpb.CodeGeneratorRequest{ProtoFile: []*descriptorpb.FileDescriptorProto{file.FileDescriptorProto}}) + if err != nil { + t.Fatalf("failed to reg.Load(): %v", err) + } + result, err := applyTemplate(param{File: crossLinkFixture(&file), reg: reg}) + if err != nil { + t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err) + } + + paths := result.Paths + if err != nil { + t.Fatalf("failed to obtain extension paths: %v", err) + } + + firstRPCPath := file.Services[0].Methods[0].Bindings[0].PathTmpl.Template + firstRPCPathAdditionalBinding := file.Services[0].Methods[0].Bindings[1].PathTmpl.Template + secondRPCPath := file.Services[0].Methods[1].Bindings[0].PathTmpl.Template + for i, pathData := range paths { + switch i { + case 0: + if got, want := pathData.Path, firstRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 1: + if got, want := pathData.Path, firstRPCPathAdditionalBinding; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + case 2: + if got, want := pathData.Path, secondRPCPath; got != want { + t.Fatalf("RPC path order not preserved, got %s want %s", got, want) + } + } + } +} + func TestArrayMessageItemsType(t *testing.T) { msgDesc := &descriptorpb.DescriptorProto{ @@ -9152,8 +9641,9 @@ func TestQueryParameterType(t *testing.T) { }, }, } - expect := openapiPathsObject{ - "/v1/echo": openapiPathItemObject{ + expect := openapiPathsObject{{ + Path: "/v1/echo", + PathItemObject: openapiPathItemObject{ Get: &openapiOperationObject{ Parameters: openapiParametersObject{ { @@ -9165,7 +9655,8 @@ func TestQueryParameterType(t *testing.T) { }, }, }, - } + }} + reg := descriptor.NewRegistry() reg.SetUseJSONNamesForFields(false) if err := AddErrorDefs(reg); err != nil { @@ -9199,7 +9690,8 @@ func TestQueryParameterType(t *testing.T) { if want, is, name := []string{"application/json"}, result.Produces, "Produces"; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, is, want) } - if want, is, name := expect["/v1/echo"].Get.Parameters, result.Paths["/v1/echo"].Get.Parameters, "Produces"; !reflect.DeepEqual(is, want) { + + if want, is, name := expect[0].PathItemObject.Get.Parameters, result.getPathItemObject("/v1/echo").Get.Parameters, "Produces"; !reflect.DeepEqual(is, want) { t.Errorf("applyTemplate(%#v).%s = %v want to be %v", file, name, is, want) } @@ -9305,20 +9797,21 @@ func TestApplyTemplateRequestWithServerStreamingHttpBody(t *testing.T) { if want, got, name := 3, len(result.Definitions), "len(Definitions)"; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %d want to be %d", file, name, got, want) } - if _, ok := result.Paths["/v1/echo"].Post.Responses["200"]; !ok { - t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.Paths["/v1/echo"].Post.Responses["200"]`) + + if _, ok := result.getPathItemObject("/v1/echo").Post.Responses["200"]; !ok { + t.Errorf("applyTemplate(%#v).%s = expected 200 response to be defined", file, `result.getPathItemObject("/v1/echo").Post.Responses["200"]`) } else { - if want, got, name := "A successful response.(streaming responses)", result.Paths["/v1/echo"].Post.Responses["200"].Description, `result.Paths["/v1/echo"].Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { + if want, got, name := "A successful response.(streaming responses)", result.getPathItemObject("/v1/echo").Post.Responses["200"].Description, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Description`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - streamExampleExampleMessage := result.Paths["/v1/echo"].Post.Responses["200"].Schema - if want, got, name := "string", streamExampleExampleMessage.Type, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { + streamExampleExampleMessage := result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema + if want, got, name := "string", streamExampleExampleMessage.Type, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Type`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - if want, got, name := "binary", streamExampleExampleMessage.Format, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Format`; !reflect.DeepEqual(got, want) { + if want, got, name := "binary", streamExampleExampleMessage.Format, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Format`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } - if want, got, name := "Free form byte stream", streamExampleExampleMessage.Title, `result.Paths["/v1/echo"].Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { + if want, got, name := "Free form byte stream", streamExampleExampleMessage.Title, `result.getPathItemObject("/v1/echo").Post.Responses["200"].Schema.Title`; !reflect.DeepEqual(got, want) { t.Errorf("applyTemplate(%#v).%s = %s want to be %s", file, name, got, want) } if len(*streamExampleExampleMessage.Properties) != 0 { @@ -9332,3 +9825,273 @@ func TestApplyTemplateRequestWithServerStreamingHttpBody(t *testing.T) { t.Errorf("got: %s", fmt.Sprint(result)) } } + +// Returns the openapiPathItemObject associated with a path. +func (so openapiSwaggerObject) getPathItemObject(path string) openapiPathItemObject { + for _, pathData := range so.Paths { + if pathData.Path == path { + return pathData.PathItemObject + } + } + + return openapiPathItemObject{} +} + +func TestGetPathItemObjectSwaggerObjectMethod(t *testing.T) { + testCases := [...]struct { + testName string + swaggerObject openapiSwaggerObject + path string + expectedPathItemObject openapiPathItemObject + }{ + { + testName: "Path present in swagger object", + swaggerObject: openapiSwaggerObject{Paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}}, + path: "a/path", + expectedPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }, { + testName: "Path not present in swaggerObject", + swaggerObject: openapiSwaggerObject{Paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}}, + path: "b/path", + expectedPathItemObject: openapiPathItemObject{}, + }, { + testName: "Path present in swaggerPathsObject with multiple paths", + swaggerObject: openapiSwaggerObject{Paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }, { + Path: "another/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "Another testful description", + }, + }, + }}}, + path: "another/path", + expectedPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "Another testful description", + }, + }, + }, { + testName: "Path not present in swaggerObject with no paths", + swaggerObject: openapiSwaggerObject{}, + path: "b/path", + expectedPathItemObject: openapiPathItemObject{}, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.testName), func(t *testing.T) { + actualPathItemObject := tc.swaggerObject.getPathItemObject(tc.path) + if isEqual := reflect.DeepEqual(actualPathItemObject, tc.expectedPathItemObject); !isEqual { + t.Fatalf("Got pathItemObject: %#v, want pathItemObject: %#v", actualPathItemObject, tc.expectedPathItemObject) + } + }) + } +} + +func TestGetPathItemObjectFunction(t *testing.T) { + testCases := [...]struct { + testName string + paths openapiPathsObject + path string + expectedPathItemObject openapiPathItemObject + expectedIsPathPresent bool + }{ + { + testName: "Path present in openapiPathsObject", + paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}, + path: "a/path", + expectedPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + expectedIsPathPresent: true, + }, { + testName: "Path not present in openapiPathsObject", + paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}, + path: "b/path", + expectedPathItemObject: openapiPathItemObject{}, + expectedIsPathPresent: false, + }, { + testName: "Path present in openapiPathsObject with multiple paths", + paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }, { + Path: "another/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "Another testful description", + }, + }, + }}, + path: "another/path", + expectedPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "Another testful description", + }, + }, + expectedIsPathPresent: true, + }, { + testName: "Path not present in empty openapiPathsObject", + paths: openapiPathsObject{}, + path: "b/path", + expectedPathItemObject: openapiPathItemObject{}, + expectedIsPathPresent: false, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.testName), func(t *testing.T) { + actualPathItemObject, actualIsPathPresent := getPathItemObject(tc.paths, tc.path) + if isEqual := reflect.DeepEqual(actualPathItemObject, tc.expectedPathItemObject); !isEqual { + t.Fatalf("Got pathItemObject: %#v, want pathItemObject: %#v", actualPathItemObject, tc.expectedPathItemObject) + } + if actualIsPathPresent != tc.expectedIsPathPresent { + t.Fatalf("Got isPathPresent bool: %t, want isPathPresent bool: %t", actualIsPathPresent, tc.expectedIsPathPresent) + } + }) + } +} + +func TestUpdatePaths(t *testing.T) { + testCases := [...]struct { + testName string + paths openapiPathsObject + pathToUpdate string + newPathItemObject openapiPathItemObject + expectedUpdatedPaths openapiPathsObject + }{ + { + testName: "Path present in openapiPathsObject, pathItemObject updated.", + paths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}, + pathToUpdate: "a/path", + newPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A newly updated testful description", + }, + }, + expectedUpdatedPaths: openapiPathsObject{{ + Path: "a/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A newly updated testful description", + }, + }, + }}, + }, { + testName: "Path not present in openapiPathsObject, new path data appended.", + paths: openapiPathsObject{{ + Path: "c/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }}, + pathToUpdate: "b/path", + newPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A new testful description to add", + }, + }, + expectedUpdatedPaths: openapiPathsObject{{ + Path: "c/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A testful description", + }, + }, + }, { + Path: "b/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A new testful description to add", + }, + }, + }}, + }, { + testName: "No paths present in openapiPathsObject, new path data appended.", + paths: openapiPathsObject{}, + pathToUpdate: "b/path", + newPathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A new testful description to add", + }, + }, + expectedUpdatedPaths: openapiPathsObject{{ + Path: "b/path", + PathItemObject: openapiPathItemObject{ + Get: &openapiOperationObject{ + Description: "A new testful description to add", + }, + }, + }}, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(string(tc.testName), func(t *testing.T) { + updatePaths(&tc.paths, tc.pathToUpdate, tc.newPathItemObject) + if pathsCorrectlyUpdated := reflect.DeepEqual(tc.paths, tc.expectedUpdatedPaths); !pathsCorrectlyUpdated { + t.Fatalf("Paths not correctly updated. Want %#v, got %#v", tc.expectedUpdatedPaths, tc.paths) + } + }) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/types.go b/protoc-gen-openapiv2/internal/genopenapi/types.go index b73fcdfb14b..c4eaa62e696 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/types.go +++ b/protoc-gen-openapiv2/internal/genopenapi/types.go @@ -103,7 +103,12 @@ type openapiScopesObject map[string]string type openapiSecurityRequirementObject map[string][]string // http://swagger.io/specification/#pathsObject -type openapiPathsObject map[string]openapiPathItemObject +type openapiPathsObject []pathData + +type pathData struct { + Path string + PathItemObject openapiPathItemObject +} // http://swagger.io/specification/#pathItemObject type openapiPathItemObject struct { diff --git a/protoc-gen-openapiv2/main.go b/protoc-gen-openapiv2/main.go index e7fa7f74b37..43f3d7fffd5 100644 --- a/protoc-gen-openapiv2/main.go +++ b/protoc-gen-openapiv2/main.go @@ -45,6 +45,7 @@ var ( disableDefaultResponses = flag.Bool("disable_default_responses", false, "if set, disables generation of default responses. Useful if you have to support custom response codes that are not 200.") useAllOfForRefs = flag.Bool("use_allof_for_refs", false, "if set, will use allOf as container for $ref to preserve same-level properties.") allowPatchFeature = flag.Bool("allow_patch_feature", true, "whether to hide update_mask fields in PATCH requests from the generated swagger file.") + preserveRPCOrder = flag.Bool("preserve_rpc_order", false, "if true, will ensure the order of paths emitted in openapi swagger files mirror the order of RPC methods found in proto files. If false, emitted paths will be ordered alphabetically.") ) // Variables set by goreleaser at build time @@ -143,6 +144,7 @@ func main() { reg.SetDisableDefaultResponses(*disableDefaultResponses) reg.SetUseAllOfForRefs(*useAllOfForRefs) reg.SetAllowPatchFeature(*allowPatchFeature) + reg.SetPreserveRPCOrder(*preserveRPCOrder) if err := reg.SetRepeatedPathParamSeparator(*repeatedPathParamSeparator); err != nil { emitError(err) return