diff --git a/pkg/channels/channel_service.go b/pkg/channels/channel_service.go index adfb499a..c211c908 100644 --- a/pkg/channels/channel_service.go +++ b/pkg/channels/channel_service.go @@ -146,6 +146,13 @@ func Update(client newclient.Client, channel *Channel) (*Channel, error) { return newclient.Update[Channel](client, template, channel.SpaceID, channel.ID, channel) } +// UpdateChannel modifies a channel from a request that supports explicit +// field clearing. +func UpdateChannel(client newclient.Client, request *newclient.UpdateRequest[Channel]) (*Channel, error) { + channel := request.Resource() + return newclient.Update[Channel](client, template, channel.SpaceID, channel.ID, request) +} + // DeleteById deletes the channel based on the ID provided as input. func DeleteByID(client newclient.Client, spaceID string, ID string) error { return newclient.DeleteByID(client, template, spaceID, ID) @@ -157,6 +164,8 @@ func GetAll(client newclient.Client, spaceID string) ([]*Channel, error) { } // GetByProjectID returns all channels in given project. -func GetByProjectID(client newclient.Client, spaceID string, channelsQuery QueryByProjectID) (*resources.Resources[*Channel], error) { +func GetByProjectID( + client newclient.Client, spaceID string, channelsQuery QueryByProjectID, +) (*resources.Resources[*Channel], error) { return newclient.GetByQuery[Channel](client, templateV2, spaceID, channelsQuery) } diff --git a/pkg/newclient/update_request.go b/pkg/newclient/update_request.go new file mode 100644 index 00000000..f873c28e --- /dev/null +++ b/pkg/newclient/update_request.go @@ -0,0 +1,125 @@ +package newclient + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "sync" +) + +// UpdateRequest wraps a resource for a PUT. Marshals the resource normally by +// default; use Clear to force a field to be sent as its zero value over the wire +type UpdateRequest[T any] struct { + resource *T + cleared map[string]struct{} +} + +func NewUpdateRequest[T any](resource *T) *UpdateRequest[T] { + return &UpdateRequest[T]{resource: resource} +} + +// Clear forces jsonName to be sent as its zero value, overwriting any current +// value on the resource. Only use when wiping a field server-side; for normal +// updates, mutate the resource and don't call Clear. +func (r *UpdateRequest[T]) Clear(jsonName string) *UpdateRequest[T] { + if r.cleared == nil { + r.cleared = make(map[string]struct{}) + } + r.cleared[jsonName] = struct{}{} + return r +} + +// Resource returns the wrapped resource. +func (r *UpdateRequest[T]) Resource() *T { return r.resource } + +func (r *UpdateRequest[T]) MarshalJSON() ([]byte, error) { + raw, err := json.Marshal(r.resource) + if err != nil { + return nil, fmt.Errorf("update request: marshal resource: %w", err) + } + if len(r.cleared) == 0 { + return raw, nil + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, fmt.Errorf("update request: decode for cleared fields: %w", err) + } + + typ := reflect.TypeOf((*T)(nil)).Elem() + index := indexFields(typ) + for name := range r.cleared { + field, ok := index[name] + if !ok { + return nil, fmt.Errorf("update request: no JSON field %q on %s", name, typ.Name()) + } + empty, err := zeroJSON(field.Type) + if err != nil { + return nil, fmt.Errorf("update request: zero value for %q: %w", name, err) + } + obj[name] = empty + } + return json.Marshal(obj) +} + +type fieldIndex map[string]reflect.StructField + +var fieldIndexCache sync.Map + +func indexFields(t reflect.Type) fieldIndex { + if cached, ok := fieldIndexCache.Load(t); ok { + return cached.(fieldIndex) + } + idx := fieldIndex{} + addFields(t, idx) + fieldIndexCache.Store(t, idx) + return idx +} + +// addFields follows encoding/json's flattening: descend into anonymous fields, +// outer names shadow embedded ones. +func addFields(t reflect.Type, idx fieldIndex) { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Anonymous { + addFields(f.Type, idx) + continue + } + if !f.IsExported() { + continue + } + tag := f.Tag.Get("json") + if tag == "-" { + continue + } + name := strings.SplitN(tag, ",", 2)[0] + if name == "" { + name = f.Name + } + if _, exists := idx[name]; !exists { + idx[name] = f + } + } +} + +// zeroJSON encodes t's zero value. Nil slices/maps are upgraded to []/{} so +// Clear on a collection emits an empty container, not null. +func zeroJSON(t reflect.Type) (json.RawMessage, error) { + var v reflect.Value + switch t.Kind() { + case reflect.Slice: + v = reflect.MakeSlice(t, 0, 0) + case reflect.Map: + v = reflect.MakeMap(t) + default: + v = reflect.New(t).Elem() + } + return json.Marshal(v.Interface()) +} diff --git a/pkg/newclient/update_request_test.go b/pkg/newclient/update_request_test.go new file mode 100644 index 00000000..1d86fa06 --- /dev/null +++ b/pkg/newclient/update_request_test.go @@ -0,0 +1,99 @@ +package newclient + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type fakeBase struct { + ID string `json:"Id,omitempty"` + Links map[string]string `json:"Links,omitempty"` +} + +type fakeChannel struct { + Name string `json:"Name,omitempty"` + Description string `json:"Description,omitempty"` + IsDefault bool `json:"IsDefault"` + CustomFieldDefinitions []fakeCustomField `json:"CustomFieldDefinitions,omitempty"` + Tags []string `json:"Tags,omitempty"` + fakeBase +} + +type fakeCustomField struct { + FieldName string `json:"FieldName"` +} + +func TestUpdateRequest_DefaultMatchesPlainMarshal(t *testing.T) { + c := &fakeChannel{Name: "n", fakeBase: fakeBase{ID: "Channels-1"}} + + got, err := json.Marshal(NewUpdateRequest(c)) + require.NoError(t, err) + want, err := json.Marshal(c) + require.NoError(t, err) + require.JSONEq(t, string(want), string(got)) +} + +func TestUpdateRequest_ClearEmitsEmptySliceWhenNil(t *testing.T) { + c := &fakeChannel{Name: "n"} + + got, err := json.Marshal(NewUpdateRequest(c).Clear("CustomFieldDefinitions")) + require.NoError(t, err) + require.Contains(t, string(got), `"CustomFieldDefinitions":[]`) +} + +func TestUpdateRequest_ClearOverwritesNonEmptySlice(t *testing.T) { + c := &fakeChannel{ + Name: "n", + CustomFieldDefinitions: []fakeCustomField{{FieldName: "x"}}, + } + + got, err := json.Marshal(NewUpdateRequest(c).Clear("CustomFieldDefinitions")) + require.NoError(t, err) + require.Contains(t, string(got), `"CustomFieldDefinitions":[]`) + require.NotContains(t, string(got), `"FieldName":"x"`) +} + +func TestUpdateRequest_ClearEmitsZeroForPrimitive(t *testing.T) { + c := &fakeChannel{Name: "n", Description: "still here"} + + got, err := json.Marshal(NewUpdateRequest(c).Clear("Description")) + require.NoError(t, err) + require.Contains(t, string(got), `"Description":""`) +} + +func TestUpdateRequest_ClearWorksOnEmbeddedField(t *testing.T) { + c := &fakeChannel{Name: "n"} + + got, err := json.Marshal(NewUpdateRequest(c).Clear("Id")) + require.NoError(t, err) + require.Contains(t, string(got), `"Id":""`) +} + +func TestUpdateRequest_ClearUnknownFieldErrorsAtMarshal(t *testing.T) { + c := &fakeChannel{Name: "n"} + + _, err := json.Marshal(NewUpdateRequest(c).Clear("NotAField")) + require.Error(t, err) + require.Contains(t, err.Error(), "NotAField") +} + +func TestUpdateRequest_MultipleClearsCompose(t *testing.T) { + c := &fakeChannel{Name: "n", Description: "x"} + + got, err := json.Marshal(NewUpdateRequest(c). + Clear("CustomFieldDefinitions"). + Clear("Tags"). + Clear("Description")) + require.NoError(t, err) + require.Contains(t, string(got), `"CustomFieldDefinitions":[]`) + require.Contains(t, string(got), `"Tags":[]`) + require.Contains(t, string(got), `"Description":""`) +} + +func TestUpdateRequest_ResourceReturnsWrappedPointer(t *testing.T) { + c := &fakeChannel{Name: "n"} + req := NewUpdateRequest(c) + require.Same(t, c, req.Resource()) +}