Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 10 additions & 1 deletion pkg/channels/channel_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
125 changes: 125 additions & 0 deletions pkg/newclient/update_request.go
Original file line number Diff line number Diff line change
@@ -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())
}
99 changes: 99 additions & 0 deletions pkg/newclient/update_request_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading