Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvement Autopatch (adding a body) #496

Merged
merged 3 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 94 additions & 16 deletions autopatch/autopatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ 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)

// Manually register the operation so it shows up in the generated OpenAPI.
op := &huma.Operation{
OperationID: "patch-" + name,
Expand All @@ -145,18 +154,10 @@ func PatchResource(api huma.API, path *huma.PathItem) {
Required: true,
Content: map[string]*huma.MediaType{
"application/merge-patch+json": {
Schema: &huma.Schema{
Type: huma.TypeObject,
Description: "JSON merge patch object, see PUT operation for schema. All fields are optional.",
AdditionalProperties: true,
},
Schema: optionalPutSchema,
},
"application/merge-patch+shorthand": {
Schema: &huma.Schema{
Type: huma.TypeObject,
Description: "Shorthand merge patch object, see PUT operation for schema. All fields are optional.",
AdditionalProperties: true,
},
Schema: optionalPutSchema,
},
"application/json-patch+json": {
Schema: jsonPatchSchema,
Expand All @@ -171,9 +172,7 @@ func PatchResource(api huma.API, path *huma.PathItem) {
}
oapi.AddOperation(op)

// Manually register the handler with the router. This bypasses the normal
// Huma API since this is easier and we are just calling the other pre-existing
// operations.
// Manually register the handler with the router.
adapter := api.Adapter()
adapter.Handle(op, func(ctx huma.Context) {
patchData, err := io.ReadAll(ctx.BodyReader())
Expand Down Expand Up @@ -208,9 +207,8 @@ func PatchResource(api huma.API, path *huma.PathItem) {
})

// Accept JSON for the patches.
// TODO: could we accept other stuff here...?
ctx.SetHeader("Accept", "application/json")
ctx.SetHeader("Accept-Encoding", "")
origReq.Header.Set("Accept", "application/json")
origReq.Header.Set("Accept-Encoding", "")

origWriter := httptest.NewRecorder()
adapter.ServeHTTP(origWriter, origReq)
Expand Down Expand Up @@ -323,3 +321,83 @@ func PatchResource(api huma.API, path *huma.PathItem) {
io.Copy(ctx.BodyWriter(), putWriter.Body)
})
}

func makeOptionalSchema(s *huma.Schema) *huma.Schema {
if s == nil {
return nil
}

optionalSchema := &huma.Schema{
Type: s.Type,
Nullable: true, // Make all fields nullable
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,
}

if s.Items != nil {
optionalSchema.Items = makeOptionalSchema(s.Items)
}

if s.Properties != nil {
optionalSchema.Properties = make(map[string]*huma.Schema)
for k, v := range s.Properties {
optionalSchema.Properties[k] = makeOptionalSchema(v)
}
}

if s.OneOf != nil {
optionalSchema.OneOf = make([]*huma.Schema, len(s.OneOf))
for i, schema := range s.OneOf {
optionalSchema.OneOf[i] = makeOptionalSchema(schema)
}
}

if s.AnyOf != nil {
optionalSchema.AnyOf = make([]*huma.Schema, len(s.AnyOf))
for i, schema := range s.AnyOf {
optionalSchema.AnyOf[i] = makeOptionalSchema(schema)
}
}

if s.AllOf != nil {
optionalSchema.AllOf = make([]*huma.Schema, len(s.AllOf))
for i, schema := range s.AllOf {
optionalSchema.AllOf[i] = makeOptionalSchema(schema)
}
}

if s.Not != nil {
optionalSchema.Not = makeOptionalSchema(s.Not)
}

// Make all properties optional
optionalSchema.Required = nil

return optionalSchema
}
67 changes: 67 additions & 0 deletions autopatch/autopatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,70 @@ func TestDeprecatedPatch(t *testing.T) {

assert.True(t, api.OpenAPI().Paths["/things/{thing-id}"].Patch.Deprecated)
}
func TestAutoPatchOptionalSchema(t *testing.T) {
_, api := humatest.New(t)

type TestModel struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Optional *string `json:"optional,omitempty"`
}

huma.Register(api, huma.Operation{
OperationID: "get-test",
Method: http.MethodGet,
Path: "/test/{id}",
}, func(ctx context.Context, input *struct {
ID string `path:"id"`
}) (*struct {
Body *TestModel
}, error) {
return &struct{ Body *TestModel }{&TestModel{}}, nil
})

huma.Register(api, huma.Operation{
OperationID: "put-test",
Method: http.MethodPut,
Path: "/test/{id}",
}, func(ctx context.Context, input *struct {
ID string `path:"id"`
Body TestModel `json:"body"`
}) (*struct {
Body *TestModel
}, error) {
return &struct{ Body *TestModel }{&TestModel{}}, nil
})

AutoPatch(api)

// Check if PATCH operation was generated
patchOp := api.OpenAPI().Paths["/test/{id}"].Patch
assert.NotNil(t, patchOp, "PATCH operation should be generated")

// Check if the generated PATCH operation has the correct schema
patchSchema := patchOp.RequestBody.Content["application/merge-patch+json"].Schema
assert.NotNil(t, patchSchema, "PATCH schema should be present")

// Verify that all fields in the schema are optional
assert.Empty(t, patchSchema.Required, "All fields should be optional in PATCH schema")

// Check if all fields from the original schema are present
properties := patchSchema.Properties
assert.Contains(t, properties, "id")
assert.Contains(t, properties, "name")
assert.Contains(t, properties, "age")
assert.Contains(t, properties, "optional")

// Verify that all fields are nullable
for _, prop := range properties {
assert.True(t, prop.Nullable, "All fields should be nullable in PATCH schema")
}

// Test the generated PATCH operation
w := api.Patch("/test/123",
"Content-Type: application/merge-patch+json",
strings.NewReader(`{"name": "New Name", "age": 30}`),
)
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
}