diff --git a/protoc-gen-openapiv2/internal/genopenapi/generator_test.go b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go index 1255d7ae5cb..18e755f266e 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/generator_test.go +++ b/protoc-gen-openapiv2/internal/genopenapi/generator_test.go @@ -2075,3 +2075,135 @@ func TestIssue5684_UnusedMethodsNotInOpenAPI(t *testing.T) { t.Errorf("expected exactly 1 path, got %d paths", len(paths)) } } + +// TestGenerateMergeFilesWithBodyAndPathParams tests that OpenAPI generation +// doesn't panic when merging files where a service uses body:"*" with path parameters. +// This reproduces the bug from https://github.com/grpc-ecosystem/grpc-gateway/issues/6274 +func TestGenerateMergeFilesWithBodyAndPathParams(t *testing.T) { + t.Parallel() + + // First proto file: contains only message definitions, with swagger option + // This file will be the merge target since it has the swagger option + const messagesProto = ` + proto_file: { + name: "example/v1/messages.proto" + package: "example.v1" + message_type: { + name: "Item" + field: { + name: "id" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "id" + } + field: { + name: "name" + number: 2 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "name" + } + } + message_type: { + name: "UpdateItemRequest" + field: { + name: "id" + number: 1 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "id" + } + field: { + name: "name" + number: 2 + label: LABEL_OPTIONAL + type: TYPE_STRING + json_name: "name" + } + } + options: { + go_package: "example/v1;examplev1" + [grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger]: { + info: { + title: "Test API" + version: "1.0" + } + } + } + syntax: "proto3" + }` + + // Second proto file: contains the service that references messages from first file + // This file does NOT have the swagger option, so it won't be the merge target + const serviceProto = ` + proto_file: { + name: "example/v1/service.proto" + package: "example.v1" + dependency: "example/v1/messages.proto" + service: { + name: "ItemService" + method: { + name: "UpdateItem" + input_type: ".example.v1.UpdateItemRequest" + output_type: ".example.v1.Item" + options: { + [google.api.http]: { + put: "/v1/items/{id}" + body: "*" + } + } + } + } + options: { + go_package: "example/v1;examplev1" + } + syntax: "proto3" + }` + + var msgReq, svcReq pluginpb.CodeGeneratorRequest + if err := prototext.Unmarshal([]byte(messagesProto), &msgReq); err != nil { + t.Fatalf("failed to unmarshal messages proto: %s", err) + } + if err := prototext.Unmarshal([]byte(serviceProto), &svcReq); err != nil { + t.Fatalf("failed to unmarshal service proto: %s", err) + } + + // Combine into a single request with both files to generate + req := &pluginpb.CodeGeneratorRequest{ + ProtoFile: append(msgReq.ProtoFile, svcReq.ProtoFile...), + FileToGenerate: []string{"example/v1/messages.proto", "example/v1/service.proto"}, + } + + formats := [...]genopenapi.Format{ + genopenapi.FormatJSON, + genopenapi.FormatYAML, + } + + for _, format := range formats { + format := format + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + // This should not panic - the bug causes panic with + // "failed to resolve method FQN: '.example.v1.ItemService.UpdateItem'" + resp := requireGenerate(t, req, format, false, true) + if len(resp) != 1 { + t.Fatalf("invalid count, expected: 1, actual: %d", len(resp)) + } + + content := resp[0].GetContent() + t.Log(content) + + // Verify the path exists in output + if !strings.Contains(content, "/v1/items/{id}") { + t.Error("expected /v1/items/{id} path in output") + } + + // Verify the body definition was created (this is what triggers the bug) + if !strings.Contains(content, "ItemServiceUpdateItemBody") { + t.Error("expected ItemServiceUpdateItemBody definition in output") + } + }) + } +} diff --git a/protoc-gen-openapiv2/internal/genopenapi/template.go b/protoc-gen-openapiv2/internal/genopenapi/template.go index a1635f5dce2..224f60659aa 100644 --- a/protoc-gen-openapiv2/internal/genopenapi/template.go +++ b/protoc-gen-openapiv2/internal/genopenapi/template.go @@ -1569,7 +1569,14 @@ func renderServices(services []*descriptor.Service, paths *openapiPathsObject, r if meth.Name != nil { methFQN, ok := fullyQualifiedNameToOpenAPIName(meth.FQMN(), reg) if !ok { - panic(fmt.Errorf("failed to resolve method FQN: '%s'", meth.FQMN())) + // Fallback: use FQN naming (strip leading dot) if method FQN not found in mapping. + // This can happen when files are merged and method FQNs are not properly registered. + fqmn := meth.FQMN() + if len(fqmn) > 0 && fqmn[0] == '.' { + methFQN = fqmn[1:] + } else { + methFQN = fqmn + } } defName := methFQN + "Body" schema.Ref = fmt.Sprintf("#/definitions/%s", defName) @@ -2078,6 +2085,15 @@ func operationForMethod(httpMethod string) func(*openapiPathItemObject) *openapi // This function is called with a param which contains the entire definition of a method. func applyTemplate(p param) (*openapiSwaggerObject, error) { + // Clear the naming cache for this registry at the start of each file processing. + // This is necessary because when multiple files are processed, the cache may contain + // a filtered mapping from a previous file that doesn't include all names needed + // for the current file (e.g., method FQNs from the current file's services). + // The cache will be rebuilt with the appropriate filtered names later in this function. + registriesSeenMutex.Lock() + delete(registriesSeen, p.reg) + registriesSeenMutex.Unlock() + // Create the basic template object. This is the object that everything is // defined off of. s := openapiSwaggerObject{