Skip to content

Commit bc635de

Browse files
committed
fix(openapiv2): prevent nested required fields hoisting to parent schema
When generating OpenAPI schemas for request bodies with path parameters extracted from nested messages (e.g., {thing.name}), nested required field names were incorrectly added to the parent body schemas required array instead of remaining in the nested objects required array. For example, with UpdateFooRequest containing a required Foo field, and Foo having required name and value fields, when using path parameter {thing.name}, the generated body schema incorrectly had: required: ["value", "thing"] Instead of: required: ["thing"] with thing.required: ["value"] This fix adds logic to distinguish between field-level and nested-level required markers when path parameters are present. It ensures only the field name itself appears in the parents required array if the field is marked REQUIRED, while nested field names remain in the nested schemas required array.
1 parent 2ff75ac commit bc635de

File tree

2 files changed

+255
-5
lines changed

2 files changed

+255
-5
lines changed

protoc-gen-openapiv2/internal/genopenapi/template.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -613,11 +613,38 @@ func renderMessageAsDefinition(msg *descriptor.Message, reg *descriptor.Registry
613613
}
614614

615615
if fieldSchema.Required != nil {
616-
schema.Required = getUniqueFields(schema.Required, fieldSchema.Required)
617-
schema.Required = append(schema.Required, fieldSchema.Required...)
618-
// To avoid populating both the field schema require and message schema require, unset the field schema require.
619-
// See issue #2635.
620-
fieldSchema.Required = nil
616+
// Only hoist required fields to parent if there are no path params inside this field.
617+
if len(subPathParams) == 0 {
618+
schema.Required = getUniqueFields(schema.Required, fieldSchema.Required)
619+
schema.Required = append(schema.Required, fieldSchema.Required...)
620+
// To avoid populating both the field schema require and message schema require, unset the field schema require.
621+
// See issue #2635.
622+
fieldSchema.Required = nil
623+
} else {
624+
// When there are path params, we need to separate field-level required from nested required.
625+
// The field name itself (if required) should be in parent's required, but nested field names
626+
// should stay in the nested schema's required.
627+
fieldName := f.GetName()
628+
if reg.GetUseJSONNamesForFields() {
629+
fieldName = f.GetJsonName()
630+
}
631+
// Check if the field name is in the fieldSchema.Required (it would be if the field is marked REQUIRED)
632+
var nestedRequired []string
633+
fieldIsRequired := false
634+
for _, req := range fieldSchema.Required {
635+
if req == fieldName {
636+
fieldIsRequired = true
637+
} else {
638+
nestedRequired = append(nestedRequired, req)
639+
}
640+
}
641+
// Add the field name to parent's required if the field itself is required
642+
if fieldIsRequired && find(schema.Required, fieldName) == -1 {
643+
schema.Required = append(schema.Required, fieldName)
644+
}
645+
// Keep only the nested required fields in the field schema
646+
fieldSchema.Required = nestedRequired
647+
}
621648
}
622649

623650
if reg.GetUseAllOfForRefs() {

protoc-gen-openapiv2/internal/genopenapi/template_test.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11784,3 +11784,226 @@ func Test_updateSwaggerObjectFromFieldBehavior(t *testing.T) {
1178411784
})
1178511785
}
1178611786
}
11787+
11788+
// TestNestedRequiredFieldsNotHoisted tests the bug where nested required fields
11789+
// are incorrectly hoisted to the parent body schema's required array when path
11790+
// parameters reference nested fields
11791+
func TestNestedRequiredFieldsNotHoisted(t *testing.T) {
11792+
fieldBehaviorRequired := []annotations.FieldBehavior{annotations.FieldBehavior_REQUIRED}
11793+
requiredFieldOptions := new(descriptorpb.FieldOptions)
11794+
proto.SetExtension(requiredFieldOptions, annotations.E_FieldBehavior, fieldBehaviorRequired)
11795+
11796+
// Define the nested message (Foo) with REQUIRED fields
11797+
fooDesc := &descriptorpb.DescriptorProto{
11798+
Name: proto.String("Foo"),
11799+
Field: []*descriptorpb.FieldDescriptorProto{
11800+
{
11801+
Name: proto.String("name"),
11802+
Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(),
11803+
Number: proto.Int32(1),
11804+
Options: requiredFieldOptions, // name is REQUIRED
11805+
},
11806+
{
11807+
Name: proto.String("value"),
11808+
Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(),
11809+
Number: proto.Int32(2),
11810+
Options: requiredFieldOptions, // value is REQUIRED
11811+
},
11812+
},
11813+
}
11814+
11815+
// Define the request message (UpdateFooRequest)
11816+
updateFooReqDesc := &descriptorpb.DescriptorProto{
11817+
Name: proto.String("UpdateFooRequest"),
11818+
Field: []*descriptorpb.FieldDescriptorProto{
11819+
{
11820+
Name: proto.String("thing"),
11821+
Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(),
11822+
TypeName: proto.String(".test.Foo"),
11823+
Number: proto.Int32(1),
11824+
Options: requiredFieldOptions, // thing is REQUIRED
11825+
},
11826+
{
11827+
Name: proto.String("update_mask"),
11828+
Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), // Simplified - normally FieldMask
11829+
Number: proto.Int32(2),
11830+
},
11831+
},
11832+
}
11833+
11834+
fooMsg := &descriptor.Message{
11835+
DescriptorProto: fooDesc,
11836+
}
11837+
11838+
updateFooReqMsg := &descriptor.Message{
11839+
DescriptorProto: updateFooReqDesc,
11840+
}
11841+
11842+
nameField := &descriptor.Field{
11843+
Message: fooMsg,
11844+
FieldDescriptorProto: fooMsg.GetField()[0],
11845+
}
11846+
valueField := &descriptor.Field{
11847+
Message: fooMsg,
11848+
FieldDescriptorProto: fooMsg.GetField()[1],
11849+
}
11850+
fooMsg.Fields = []*descriptor.Field{nameField, valueField}
11851+
11852+
thingField := &descriptor.Field{
11853+
Message: updateFooReqMsg,
11854+
FieldMessage: fooMsg,
11855+
FieldDescriptorProto: updateFooReqMsg.GetField()[0],
11856+
}
11857+
updateMaskField := &descriptor.Field{
11858+
Message: updateFooReqMsg,
11859+
FieldDescriptorProto: updateFooReqMsg.GetField()[1],
11860+
}
11861+
updateFooReqMsg.Fields = []*descriptor.Field{thingField, updateMaskField}
11862+
11863+
meth := &descriptorpb.MethodDescriptorProto{
11864+
Name: proto.String("UpdateFoo"),
11865+
InputType: proto.String("UpdateFooRequest"),
11866+
OutputType: proto.String("Foo"),
11867+
}
11868+
11869+
svc := &descriptorpb.ServiceDescriptorProto{
11870+
Name: proto.String("FooService"),
11871+
Method: []*descriptorpb.MethodDescriptorProto{meth},
11872+
}
11873+
11874+
file := descriptor.File{
11875+
FileDescriptorProto: &descriptorpb.FileDescriptorProto{
11876+
SourceCodeInfo: &descriptorpb.SourceCodeInfo{},
11877+
Name: proto.String("foo.proto"),
11878+
Package: proto.String("test"),
11879+
MessageType: []*descriptorpb.DescriptorProto{fooDesc, updateFooReqDesc},
11880+
Service: []*descriptorpb.ServiceDescriptorProto{svc},
11881+
Options: &descriptorpb.FileOptions{
11882+
GoPackage: proto.String("github.com/example/test;test"),
11883+
},
11884+
},
11885+
GoPkg: descriptor.GoPackage{
11886+
Path: "example.com/path/to/test/test.pb",
11887+
Name: "test_pb",
11888+
},
11889+
Messages: []*descriptor.Message{fooMsg, updateFooReqMsg},
11890+
Services: []*descriptor.Service{
11891+
{
11892+
ServiceDescriptorProto: svc,
11893+
Methods: []*descriptor.Method{
11894+
{
11895+
MethodDescriptorProto: meth,
11896+
RequestType: updateFooReqMsg,
11897+
ResponseType: fooMsg,
11898+
Bindings: []*descriptor.Binding{
11899+
{
11900+
HTTPMethod: "PATCH",
11901+
PathTmpl: httprule.Template{
11902+
Version: 1,
11903+
OpCodes: []int{0, 0},
11904+
Template: "/api/v1/{thing.name}",
11905+
},
11906+
PathParams: []descriptor.Parameter{
11907+
{
11908+
FieldPath: descriptor.FieldPath([]descriptor.FieldPathComponent{
11909+
{
11910+
Name: "thing",
11911+
},
11912+
{
11913+
Name: "name",
11914+
},
11915+
}),
11916+
Target: nameField,
11917+
},
11918+
},
11919+
Body: &descriptor.Body{
11920+
FieldPath: []descriptor.FieldPathComponent{}, // body: "*"
11921+
},
11922+
},
11923+
},
11924+
},
11925+
},
11926+
},
11927+
},
11928+
}
11929+
11930+
reg := descriptor.NewRegistry()
11931+
fileCL := crossLinkFixture(&file)
11932+
err := reg.Load(reqFromFile(fileCL))
11933+
if err != nil {
11934+
t.Errorf("reg.Load(%#v) failed with %v; want success", file, err)
11935+
return
11936+
}
11937+
11938+
result, err := applyTemplate(param{File: fileCL, reg: reg})
11939+
if err != nil {
11940+
t.Fatalf("applyTemplate(%#v) failed with %v; want success", file, err)
11941+
}
11942+
11943+
paths := GetPaths(result)
11944+
if got, want := len(paths), 1; got != want {
11945+
t.Fatalf("Results path length differed, got %d want %d", got, want)
11946+
}
11947+
if got, want := paths[0], "/api/v1/{thing.name}"; got != want {
11948+
t.Fatalf("Wrong results path, got %s want %s", got, want)
11949+
}
11950+
11951+
operation := *result.getPathItemObject("/api/v1/{thing.name}").Patch
11952+
if len(operation.Parameters) < 2 {
11953+
t.Fatalf("Expected at least 2 parameters, got %d", len(operation.Parameters))
11954+
}
11955+
11956+
if got, want := operation.Parameters[0].Name, "thing.name"; got != want {
11957+
t.Fatalf("Wrong parameter name 0, got %s want %s", got, want)
11958+
}
11959+
if got, want := operation.Parameters[0].In, "path"; got != want {
11960+
t.Fatalf("Wrong parameter location 0, got %s want %s", got, want)
11961+
}
11962+
11963+
if got, want := operation.Parameters[1].Name, "body"; got != want {
11964+
t.Fatalf("Wrong parameter name 1, got %s want %s", got, want)
11965+
}
11966+
if got, want := operation.Parameters[1].In, "body"; got != want {
11967+
t.Fatalf("Wrong parameter location 1, got %s want %s", got, want)
11968+
}
11969+
11970+
bodySchemaRef := operation.Parameters[1].Schema.schemaCore.Ref
11971+
if bodySchemaRef == "" {
11972+
t.Fatal("Body schema reference is empty")
11973+
}
11974+
11975+
defName := strings.TrimPrefix(bodySchemaRef, "#/definitions/")
11976+
definition, found := result.Definitions[defName]
11977+
if !found {
11978+
t.Fatalf("expecting definition to contain %s", defName)
11979+
}
11980+
11981+
// Verify that nested required fields are NOT hoisted to parent level
11982+
correctRequiredFields := []string{"thing"}
11983+
if got, want := definition.Required, correctRequiredFields; !reflect.DeepEqual(got, want) {
11984+
t.Errorf("Nested required fields were incorrectly hoisted to parent level.\n"+
11985+
"Body definition required fields:\n"+
11986+
" got = %v\n"+
11987+
" want = %v (only top-level field names)\n"+
11988+
"Nested field 'value' should be in 'thing' property's required array, not parent's.",
11989+
got, want)
11990+
}
11991+
11992+
var thingKV *keyVal
11993+
if definition.Properties != nil {
11994+
for i := range *definition.Properties {
11995+
if (*definition.Properties)[i].Key == "thing" {
11996+
thingKV = &(*definition.Properties)[i]
11997+
break
11998+
}
11999+
}
12000+
}
12001+
12002+
if thingKV == nil {
12003+
t.Fatal("'thing' property not found in body definition")
12004+
}
12005+
12006+
if _, ok := thingKV.Value.(openapiSchemaObject); !ok {
12007+
t.Fatal("'thing' property value is not an openapiSchemaObject")
12008+
}
12009+
}

0 commit comments

Comments
 (0)