Repository: danielgtaylor/huma
Pull request #314 (2024-03-14 16:04:10)

Nested struct visibility
This pull request enables `readOnly`, `writeOnly` and `deprecated` struct tags on nested structs.

Resolves issue: #313


Patch:
diff --git a/validate.go b/validate.go
index 6addce4..35bf852 100644
--- a/validate.go
+++ b/validate.go
@@ -538,6 +538,13 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode,
 
 	for _, k := range s.propertyNames {
 		v := s.Properties[k]
+
+		// Schemas are generated such that the read/write-only properties are set
+		// alongside the `$ref`, if it is present (i.e. for objects). If not,
+		// then the read/write-only properties are set directly on the schema and
+		// the `for` loop never runs.
+		readOnly := v.ReadOnly
+		writeOnly := v.WriteOnly
 		for v.Ref != "" {
 			v = r.SchemaFromRef(v.Ref)
 		}
@@ -547,7 +554,7 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode,
 		// TODO: should we make this configurable?
 
 		// Be stricter for responses, enabling validation of the server if desired.
-		if mode == ModeReadFromServer && v.WriteOnly && m[k] != nil && !reflect.ValueOf(m[k]).IsZero() {
+		if mode == ModeReadFromServer && writeOnly && m[k] != nil && !reflect.ValueOf(m[k]).IsZero() {
 			res.Add(path, m[k], "write only property is non-zero")
 			continue
 		}
@@ -556,8 +563,8 @@ func handleMapString(r Registry, s *Schema, path *PathBuffer, mode ValidateMode,
 			if !s.requiredMap[k] {
 				continue
 			}
-			if (mode == ModeWriteToServer && v.ReadOnly) ||
-				(mode == ModeReadFromServer && v.WriteOnly) {
+			if (mode == ModeWriteToServer && readOnly) ||
+				(mode == ModeReadFromServer && writeOnly) {
 				// These are not required for the current mode.
 				continue
 			}
@@ -619,6 +626,13 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m
 
 	for _, k := range s.propertyNames {
 		v := s.Properties[k]
+
+		// Schemas are generated such that the read/write-only properties are set
+		// alongside the `$ref`, if it is present (i.e. for objects). If not,
+		// then the read/write-only properties are set directly on the schema and
+		// the `for` loop never runs.
+		readOnly := v.ReadOnly
+		writeOnly := v.WriteOnly
 		for v.Ref != "" {
 			v = r.SchemaFromRef(v.Ref)
 		}
@@ -628,7 +642,7 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m
 		// TODO: should we make this configurable?
 
 		// Be stricter for responses, enabling validation of the server if desired.
-		if mode == ModeReadFromServer && v.WriteOnly && m[k] != nil && !reflect.ValueOf(m[k]).IsZero() {
+		if mode == ModeReadFromServer && writeOnly && m[k] != nil && !reflect.ValueOf(m[k]).IsZero() {
 			res.Add(path, m[k], "write only property is non-zero")
 			continue
 		}
@@ -637,8 +651,8 @@ func handleMapAny(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, m
 			if !s.requiredMap[k] {
 				continue
 			}
-			if (mode == ModeWriteToServer && v.ReadOnly) ||
-				(mode == ModeReadFromServer && v.WriteOnly) {
+			if (mode == ModeWriteToServer && readOnly) ||
+				(mode == ModeReadFromServer && writeOnly) {
 				// These are not required for the current mode.
 				continue
 			}


Test patch:
diff --git a/huma_test.go b/huma_test.go
index ed12655..79bc34a 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -287,6 +287,28 @@ func TestFeatures(t *testing.T) {
 			// Headers: map[string]string{"Content-Type": "application/json"},
 			Body: `{"name":"foo"}`,
 		},
+		{
+			Name: "request-body-nested-struct-readOnly",
+			Register: func(t *testing.T, api huma.API) {
+				type NestedStruct struct {
+					Foo struct {
+						Bar string `json:"bar"`
+					} `json:"foo" readOnly:"true"`
+					Value string `json:"value"`
+				}
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/body",
+				}, func(ctx context.Context, input *struct {
+					Body *NestedStruct
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method: http.MethodPost,
+			URL:    "/body",
+			Body:   `{"value":"test"}`,
+		},
 		{
 			Name: "request-body-defaults",
 			Register: func(t *testing.T, api huma.API) {
diff --git a/schema_test.go b/schema_test.go
index 85c67d1..be618db 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -297,6 +297,25 @@ func TestSchema(t *testing.T) {
 				"required": ["value"]
 			}`,
 		},
+		{
+			name: "field-readonly-struct",
+			input: struct {
+				Value struct {
+					Foo string `json:"foo"`
+				} `json:"value" readOnly:"true"`
+			}{},
+			expected: `{
+				"type": "object",
+				"properties": {
+					"value": {
+						"$ref": "#/components/schemas/ValueStruct",
+						"readOnly": true
+					}
+				},
+				"additionalProperties": false,
+				"required": ["value"]
+			}`,
+		},
 		{
 			name: "field-default-string",
 			input: struct {