From 2754376b12e33d037bd2d84a67cf3ac109a41b50 Mon Sep 17 00:00:00 2001 From: Rasmus Lisborg Date: Thu, 14 May 2026 20:47:59 +0100 Subject: [PATCH 1/2] fix: handle boolean false for UrlPattern and UrlPrefix in ContentTypeOptions The Contentstack API returns false (boolean) for string fields that are unset, rather than omitting them or returning an empty string. This caused json.Unmarshal to fail with 'cannot unmarshal bool into string'. Introduce FlexString, a string type with a custom UnmarshalJSON that accepts both JSON strings and booleans (treating false as empty string). Co-Authored-By: Claude Sonnet 4.6 --- management/content_type.go | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/management/content_type.go b/management/content_type.go index 7811d45..37705c6 100644 --- a/management/content_type.go +++ b/management/content_type.go @@ -37,13 +37,32 @@ type ContentTypeInput struct { } type ContentTypeOptions struct { - Title string `json:"title"` - Publishable bool `json:"bool"` - IsPage bool `json:"is_page"` - Singleton bool `json:"singleton"` - SubTitle []string `json:"sub_title"` - UrlPattern string `json:"url_pattern"` - UrlPrefix string `json:"url_prefix"` + Title string `json:"title"` + Publishable bool `json:"bool"` + IsPage bool `json:"is_page"` + Singleton bool `json:"singleton"` + SubTitle []string `json:"sub_title"` + UrlPattern FlexString `json:"url_pattern"` + UrlPrefix FlexString `json:"url_prefix"` +} + +// FlexString unmarshals a JSON value that is either a string or a boolean. +// The Contentstack API returns false (boolean) for string fields that are unset, +// rather than omitting them or returning an empty string. +type FlexString string + +func (f *FlexString) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + *f = FlexString(s) + return nil + } + var b bool + if err := json.Unmarshal(data, &b); err == nil { + *f = "" + return nil + } + return fmt.Errorf("FlexString: cannot unmarshal %s into string or bool", data) } func (si *StackInstance) ContentTypeCreate(ctx context.Context, input ContentTypeInput) (*ContentType, error) { From 4551502d6c2ccc4f5463a61851e2cceb78afa5d5 Mon Sep 17 00:00:00 2001 From: Rasmus Lisborg Date: Fri, 15 May 2026 09:22:23 +0100 Subject: [PATCH 2/2] test: add tests for FlexString and ContentTypeOptions unmarshalling Co-Authored-By: Claude Sonnet 4.6 --- management/content_type_test.go | 124 ++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 management/content_type_test.go diff --git a/management/content_type_test.go b/management/content_type_test.go new file mode 100644 index 0000000..6a1b579 --- /dev/null +++ b/management/content_type_test.go @@ -0,0 +1,124 @@ +package management + +import ( + "encoding/json" + "testing" +) + +func TestFlexString_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want FlexString + wantErr bool + }{ + { + name: "string value", + input: `"/:title"`, + want: "/:title", + }, + { + name: "empty string", + input: `""`, + want: "", + }, + { + name: "boolean false treated as empty string", + input: `false`, + want: "", + }, + { + name: "boolean true treated as empty string", + input: `true`, + want: "", + }, + { + name: "number is invalid", + input: `42`, + wantErr: true, + }, + { + // JSON null unmarshals into bool as false in Go, so null is treated + // as empty string rather than an error. + name: "null treated as empty string", + input: `null`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f FlexString + err := json.Unmarshal([]byte(tt.input), &f) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && f != tt.want { + t.Errorf("UnmarshalJSON() = %q, want %q", f, tt.want) + } + }) + } +} + +func TestContentTypeOptions_UnmarshalJSON(t *testing.T) { + t.Run("url_pattern and url_prefix as strings", func(t *testing.T) { + input := `{ + "title": "My Type", + "is_page": true, + "singleton": false, + "url_pattern": "/:title", + "url_prefix": "/" + }` + var opts ContentTypeOptions + if err := json.Unmarshal([]byte(input), &opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.UrlPattern != "/:title" { + t.Errorf("UrlPattern = %q, want %q", opts.UrlPattern, "/:title") + } + if opts.UrlPrefix != "/" { + t.Errorf("UrlPrefix = %q, want %q", opts.UrlPrefix, "/") + } + }) + + t.Run("url_pattern and url_prefix as boolean false (unset)", func(t *testing.T) { + input := `{ + "title": "My Type", + "is_page": false, + "singleton": false, + "url_pattern": false, + "url_prefix": false + }` + var opts ContentTypeOptions + if err := json.Unmarshal([]byte(input), &opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.UrlPattern != "" { + t.Errorf("UrlPattern = %q, want empty string", opts.UrlPattern) + } + if opts.UrlPrefix != "" { + t.Errorf("UrlPrefix = %q, want empty string", opts.UrlPrefix) + } + }) + + t.Run("singleton content type without url fields", func(t *testing.T) { + input := `{ + "title": "Header", + "is_page": false, + "singleton": true, + "url_pattern": false, + "url_prefix": false + }` + var opts ContentTypeOptions + if err := json.Unmarshal([]byte(input), &opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.Singleton { + t.Error("Singleton = false, want true") + } + if opts.UrlPattern != "" { + t.Errorf("UrlPattern = %q, want empty string", opts.UrlPattern) + } + }) +}