diff --git a/autopatch/autopatch.go b/autopatch/autopatch.go index e04f2e71..77b58e24 100644 --- a/autopatch/autopatch.go +++ b/autopatch/autopatch.go @@ -234,12 +234,9 @@ func PatchResource(api huma.API, path *huma.PathItem) { // Get the schema from the PUT operation putSchema := put.RequestBody.Content["application/json"].Schema - if putSchema.Ref != "" { - putSchema = oapi.Components.Schemas.SchemaFromRef(putSchema.Ref) - } // Create an optional version of the PUT schema - optionalPutSchema := makeOptionalSchema(putSchema) + optionalPutSchema := makeOptionalSchema(oapi.Components.Schemas, putSchema, map[string]struct{}{}) // Manually register the operation so it shows up in the generated OpenAPI. op := &huma.Operation{ @@ -453,77 +450,97 @@ func PatchResource(api huma.API, path *huma.PathItem) { }) } -func makeOptionalSchema(s *huma.Schema) *huma.Schema { +func makeOptionalSchema(registry huma.Registry, s *huma.Schema, visitedRefs map[string]struct{}) *huma.Schema { if s == nil { return nil } + // If this schema has a ref, try to resolve it and leverage the referenced schema + if s.Ref != "" { + resolved := registry.SchemaFromRef(s.Ref) + if _, cycle := visitedRefs[s.Ref]; cycle || resolved == nil { + // Unresolvable or self-referential: leave the ref in place. + return &huma.Schema{Ref: s.Ref} + } + + visitedRefs[s.Ref] = struct{}{} + defer delete(visitedRefs, s.Ref) + s = resolved + } + optionalSchema := &huma.Schema{ - Type: s.Type, - Title: s.Title, - Description: s.Description, - Format: s.Format, - ContentEncoding: s.ContentEncoding, - Default: s.Default, - Examples: s.Examples, - AdditionalProperties: s.AdditionalProperties, - Enum: s.Enum, - Minimum: s.Minimum, - ExclusiveMinimum: s.ExclusiveMinimum, - Maximum: s.Maximum, - ExclusiveMaximum: s.ExclusiveMaximum, - MultipleOf: s.MultipleOf, - MinLength: s.MinLength, - MaxLength: s.MaxLength, - Pattern: s.Pattern, - PatternDescription: s.PatternDescription, - MinItems: s.MinItems, - MaxItems: s.MaxItems, - UniqueItems: s.UniqueItems, - MinProperties: s.MinProperties, - MaxProperties: s.MaxProperties, - ReadOnly: s.ReadOnly, - WriteOnly: s.WriteOnly, - Deprecated: s.Deprecated, - Extensions: s.Extensions, - DependentRequired: s.DependentRequired, - Discriminator: s.Discriminator, + Type: s.Type, + Title: s.Title, + Description: s.Description, + Format: s.Format, + ContentEncoding: s.ContentEncoding, + Default: s.Default, + Examples: s.Examples, + Enum: s.Enum, + Minimum: s.Minimum, + ExclusiveMinimum: s.ExclusiveMinimum, + Maximum: s.Maximum, + ExclusiveMaximum: s.ExclusiveMaximum, + MultipleOf: s.MultipleOf, + MinLength: s.MinLength, + MaxLength: s.MaxLength, + Pattern: s.Pattern, + PatternDescription: s.PatternDescription, + MinItems: s.MinItems, + MaxItems: s.MaxItems, + UniqueItems: s.UniqueItems, + MinProperties: s.MinProperties, + MaxProperties: s.MaxProperties, + ReadOnly: s.ReadOnly, + WriteOnly: s.WriteOnly, + Deprecated: s.Deprecated, + Extensions: s.Extensions, + DependentRequired: s.DependentRequired, + Discriminator: s.Discriminator, } if s.Items != nil { - optionalSchema.Items = makeOptionalSchema(s.Items) + optionalSchema.Items = makeOptionalSchema(registry, s.Items, visitedRefs) } if s.Properties != nil { optionalSchema.Properties = make(map[string]*huma.Schema) for k, v := range s.Properties { - optionalSchema.Properties[k] = makeOptionalSchema(v) + optionalSchema.Properties[k] = makeOptionalSchema(registry, v, visitedRefs) + } + } + + if s.AdditionalProperties != nil { + if additionalPropertiesSchema, ok := s.AdditionalProperties.(*huma.Schema); ok { + optionalSchema.AdditionalProperties = makeOptionalSchema(registry, additionalPropertiesSchema, visitedRefs) + } else { + optionalSchema.AdditionalProperties = s.AdditionalProperties } } if s.OneOf != nil { optionalSchema.OneOf = make([]*huma.Schema, len(s.OneOf)) for i, schema := range s.OneOf { - optionalSchema.OneOf[i] = makeOptionalSchema(schema) + optionalSchema.OneOf[i] = makeOptionalSchema(registry, schema, visitedRefs) } } if s.AnyOf != nil { optionalSchema.AnyOf = make([]*huma.Schema, len(s.AnyOf)) for i, schema := range s.AnyOf { - optionalSchema.AnyOf[i] = makeOptionalSchema(schema) + optionalSchema.AnyOf[i] = makeOptionalSchema(registry, schema, visitedRefs) } } if s.AllOf != nil { optionalSchema.AllOf = make([]*huma.Schema, len(s.AllOf)) for i, schema := range s.AllOf { - optionalSchema.AllOf[i] = makeOptionalSchema(schema) + optionalSchema.AllOf[i] = makeOptionalSchema(registry, schema, visitedRefs) } } if s.Not != nil { - optionalSchema.Not = makeOptionalSchema(s.Not) + optionalSchema.Not = makeOptionalSchema(registry, s.Not, visitedRefs) } // Make all properties optional diff --git a/autopatch/autopatch_test.go b/autopatch/autopatch_test.go index 1312f118..9fb13a79 100644 --- a/autopatch/autopatch_test.go +++ b/autopatch/autopatch_test.go @@ -297,6 +297,65 @@ func TestExplicitDisable(t *testing.T) { assert.Equal(t, http.StatusMethodNotAllowed, w.Code, w.Body.String()) } +func TestPatchRecursiveNode(t *testing.T) { + type Node struct { + Name string `json:"name"` + Children []*Node `json:"children,omitempty"` + } + + db := map[string]*Node{ + "root": {Name: "root", Children: []*Node{{Name: "child"}}}, + } + + _, api := humatest.New(t) + + huma.Register(api, huma.Operation{ + OperationID: "get-node", + Method: http.MethodGet, + Path: "/nodes/{id}", + Errors: []int{404}, + }, func(ctx context.Context, input *struct { + ID string `path:"id"` + }) (*struct { + Body *Node + }, error) { + node := db[input.ID] + if node == nil { + return nil, huma.Error404NotFound("Not found") + } + return &struct{ Body *Node }{Body: node}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "put-node", + Method: http.MethodPut, + Path: "/nodes/{id}", + Errors: []int{404}, + }, func(ctx context.Context, input *struct { + ID string `path:"id"` + Body Node + }) (*struct { + Body *Node + }, error) { + node := input.Body + db[input.ID] = &node + return &struct{ Body *Node }{Body: db[input.ID]}, nil + }) + + AutoPatch(api) + + path := api.OpenAPI().Paths["/nodes/{id}"] + require.NotNil(t, path) + require.NotNil(t, path.Patch) + + w := api.Patch("/nodes/root", + "Content-Type: application/merge-patch+json", + strings.NewReader(`{"name": "updated"}`), + ) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), `"name":"updated"`) +} + func TestDeprecatedPatch(t *testing.T) { _, api := humatest.New(t) @@ -416,6 +475,14 @@ func TestNullabilityExtension(t *testing.T) { assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } +func testRegistry() huma.Registry { + return huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) +} + +func testMakeOptionalSchema(registry huma.Registry, s *huma.Schema) *huma.Schema { + return makeOptionalSchema(registry, s, map[string]struct{}{}) +} + func TestMakeOptionalSchemaBasicProperties(t *testing.T) { originalSchema := &huma.Schema{ Type: "object", @@ -426,7 +493,7 @@ func TestMakeOptionalSchemaBasicProperties(t *testing.T) { Required: []string{"id", "name"}, } - optionalSchema := makeOptionalSchema(originalSchema) + optionalSchema := testMakeOptionalSchema(testRegistry(), originalSchema) assert.Equal(t, "object", optionalSchema.Type) assert.Contains(t, optionalSchema.Properties, "id") @@ -434,6 +501,21 @@ func TestMakeOptionalSchemaBasicProperties(t *testing.T) { assert.Empty(t, optionalSchema.Required) } +func TestMakeOptionalSchemaOneOf(t *testing.T) { + originalSchema := &huma.Schema{ + OneOf: []*huma.Schema{ + {Type: "string"}, + {Type: "number"}, + }, + } + + optionalSchema := testMakeOptionalSchema(testRegistry(), originalSchema) + + assert.Len(t, optionalSchema.OneOf, 2) + assert.Equal(t, "string", optionalSchema.OneOf[0].Type) + assert.Equal(t, "number", optionalSchema.OneOf[1].Type) +} + func TestMakeOptionalSchemaAnyOf(t *testing.T) { originalSchema := &huma.Schema{ AnyOf: []*huma.Schema{ @@ -442,7 +524,7 @@ func TestMakeOptionalSchemaAnyOf(t *testing.T) { }, } - optionalSchema := makeOptionalSchema(originalSchema) + optionalSchema := testMakeOptionalSchema(testRegistry(), originalSchema) assert.Len(t, optionalSchema.AnyOf, 2) assert.Equal(t, "string", optionalSchema.AnyOf[0].Type) @@ -459,7 +541,7 @@ func TestMakeOptionalSchemaAllOf(t *testing.T) { }, } - optionalSchema := makeOptionalSchema(originalSchema) + optionalSchema := testMakeOptionalSchema(testRegistry(), originalSchema) assert.Len(t, optionalSchema.AllOf, 2) assert.Equal(t, 1, *optionalSchema.AllOf[0].MinLength) @@ -473,14 +555,14 @@ func TestMakeOptionalSchemaNot(t *testing.T) { }, } - optionalSchema := makeOptionalSchema(originalSchema) + optionalSchema := testMakeOptionalSchema(testRegistry(), originalSchema) assert.NotNil(t, optionalSchema.Not) assert.Equal(t, "null", optionalSchema.Not.Type) } func TestMakeOptionalSchemaNilInput(t *testing.T) { - assert.Nil(t, makeOptionalSchema(nil)) + assert.Nil(t, testMakeOptionalSchema(testRegistry(), nil)) } func TestReplaceNulls(t *testing.T) { @@ -590,12 +672,217 @@ func TestMakeOptionalSchemaNestedSchemas(t *testing.T) { Required: []string{"nested"}, } - optionalNestedSchema := makeOptionalSchema(nestedSchema) + optionalNestedSchema := testMakeOptionalSchema(testRegistry(), nestedSchema) assert.Empty(t, optionalNestedSchema.Required) assert.Empty(t, optionalNestedSchema.Properties["nested"].Required) } +func TestMakeOptionalSchemaRefs(t *testing.T) { + const addressRef = "#/components/schemas/Address" + const tagRef = "#/components/schemas/Tag" + + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + registry.Map()["Address"] = &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "street": {Type: "string"}, + "city": {Type: "string"}, + }, + Required: []string{"street", "city"}, + } + registry.Map()["Tag"] = &huma.Schema{Type: "string"} + + t.Run("top-level ref", func(t *testing.T) { + optional := testMakeOptionalSchema(registry, &huma.Schema{Ref: addressRef}) + + assert.Empty(t, optional.Ref) + assert.Equal(t, "object", optional.Type) + assert.Empty(t, optional.Required) + assert.Len(t, optional.Properties, 2) + assert.Equal(t, "string", optional.Properties["street"].Type) + assert.Equal(t, "string", optional.Properties["city"].Type) + }) + + t.Run("property ref", func(t *testing.T) { + parent := &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "address": {Ref: addressRef}, + }, + Required: []string{"address"}, + } + + optional := testMakeOptionalSchema(registry, parent) + + assert.Empty(t, optional.Required) + address := optional.Properties["address"] + require.NotNil(t, address) + assert.Empty(t, address.Ref) + assert.Equal(t, "object", address.Type) + assert.Empty(t, address.Required) + }) + + t.Run("items ref", func(t *testing.T) { + parent := &huma.Schema{ + Type: "array", + Items: &huma.Schema{Ref: tagRef}, + } + + optional := testMakeOptionalSchema(registry, parent) + + require.NotNil(t, optional.Items) + assert.Empty(t, optional.Items.Ref) + assert.Equal(t, "string", optional.Items.Type) + }) + + t.Run("unresolvable external ref", func(t *testing.T) { + const externalRef = "https://example.com/schemas/Thing.json" + + optional := testMakeOptionalSchema(registry, &huma.Schema{Ref: externalRef}) + + require.NotNil(t, optional) + assert.Equal(t, externalRef, optional.Ref) + assert.Empty(t, optional.Type) + }) + + t.Run("recursive self reference", func(t *testing.T) { + const nodeRef = "#/components/schemas/Node" + + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + registry.Map()["Node"] = &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "name": {Type: "string"}, + "children": { + Type: "array", + Items: &huma.Schema{Ref: nodeRef}, + }, + }, + Required: []string{"name"}, + } + + optional := testMakeOptionalSchema(registry, &huma.Schema{Ref: nodeRef}) + + assert.Empty(t, optional.Ref) + assert.Equal(t, "object", optional.Type) + assert.Empty(t, optional.Required) + assert.Equal(t, "string", optional.Properties["name"].Type) + assert.Equal(t, "array", optional.Properties["children"].Type) + require.NotNil(t, optional.Properties["children"].Items) + assert.Equal(t, nodeRef, optional.Properties["children"].Items.Ref) + }) +} + +func TestMakeOptionalSchemaAdditionalProperties(t *testing.T) { + t.Run("boolean additionalProperties", func(t *testing.T) { + for _, val := range []bool{true, false} { + original := &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "name": {Type: "string"}, + }, + Required: []string{"name"}, + AdditionalProperties: val, + } + + optional := testMakeOptionalSchema(testRegistry(), original) + + assert.Equal(t, val, optional.AdditionalProperties) + assert.Empty(t, optional.Required) + } + }) + + t.Run("inline schema additionalProperties", func(t *testing.T) { + original := &huma.Schema{ + Type: "object", + AdditionalProperties: &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "value": {Type: "string"}, + }, + Required: []string{"value"}, + }, + } + + optional := testMakeOptionalSchema(testRegistry(), original) + + addl, ok := optional.AdditionalProperties.(*huma.Schema) + require.True(t, ok) + assert.Equal(t, "object", addl.Type) + assert.Empty(t, addl.Required) + assert.Equal(t, "string", addl.Properties["value"].Type) + }) + + t.Run("ref additionalProperties", func(t *testing.T) { + const tagRef = "#/components/schemas/Tag" + + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + registry.Map()["Tag"] = &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "label": {Type: "string"}, + }, + Required: []string{"label"}, + } + + original := &huma.Schema{ + Type: "object", + AdditionalProperties: &huma.Schema{Ref: tagRef}, + } + + optional := testMakeOptionalSchema(registry, original) + + addl, ok := optional.AdditionalProperties.(*huma.Schema) + require.True(t, ok) + assert.Empty(t, addl.Ref) + assert.Equal(t, "object", addl.Type) + assert.Empty(t, addl.Required) + assert.Equal(t, "string", addl.Properties["label"].Type) + }) + + t.Run("recursive ref additionalProperties", func(t *testing.T) { + const nodeRef = "#/components/schemas/Node" + + registry := huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer) + registry.Map()["Node"] = &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "name": {Type: "string"}, + "named": { + Type: "object", + AdditionalProperties: &huma.Schema{Ref: nodeRef}, + }, + }, + Required: []string{"name"}, + } + + optional := testMakeOptionalSchema(registry, &huma.Schema{Ref: nodeRef}) + + assert.Empty(t, optional.Required) + named := optional.Properties["named"] + require.NotNil(t, named) + addl, ok := named.AdditionalProperties.(*huma.Schema) + require.True(t, ok) + assert.Equal(t, nodeRef, addl.Ref) + }) + + t.Run("unresolvable ref additionalProperties", func(t *testing.T) { + const externalRef = "https://example.com/schemas/Thing.json" + + original := &huma.Schema{ + Type: "object", + AdditionalProperties: &huma.Schema{Ref: externalRef}, + } + + optional := testMakeOptionalSchema(testRegistry(), original) + + addl, ok := optional.AdditionalProperties.(*huma.Schema) + require.True(t, ok) + assert.Equal(t, externalRef, addl.Ref) + }) +} + type findRelativeResourcePathTest struct { requestPath string putPath string