diff --git a/internal/config/format.go b/internal/config/format.go index 787fe60a9..8cf949003 100644 --- a/internal/config/format.go +++ b/internal/config/format.go @@ -15,6 +15,10 @@ func ValidatePublicationData(data []byte, format string) error { if !tools.IsValidJSON(data) { return errors.New("data is not valid JSON") } + case configtypes.PublicationDataFormatJSONObject: + if !tools.IsValidJSONObject(data) { + return errors.New("data is not valid JSON object") + } case configtypes.PublicationDataFormatBinary: // Binary format allows empty data, no validation needed default: diff --git a/internal/config/format_test.go b/internal/config/format_test.go index aeb7081de..325adc903 100644 --- a/internal/config/format_test.go +++ b/internal/config/format_test.go @@ -69,6 +69,61 @@ func TestValidatePublicationData(t *testing.T) { wantErr: true, errMsg: "data is not valid JSON", }, + // JSON object format tests + { + name: "json_object format - valid json object", + data: []byte(`{"key": "value"}`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: false, + }, + { + name: "json_object format - valid json object with whitespace", + data: []byte(` {"key": "value"} `), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: false, + }, + { + name: "json_object format - json array rejected", + data: []byte(`[1, 2, 3]`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, + { + name: "json_object format - json string rejected", + data: []byte(`"string"`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, + { + name: "json_object format - json null rejected", + data: []byte(`null`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, + { + name: "json_object format - json number rejected", + data: []byte(`42`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, + { + name: "json_object format - invalid json", + data: []byte(`not valid json`), + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, + { + name: "json_object format - empty data", + data: []byte{}, + format: configtypes.PublicationDataFormatJSONObject, + wantErr: true, + errMsg: "data is not valid JSON object", + }, // Binary format tests { name: "binary format - binary data allowed", diff --git a/internal/config/validate.go b/internal/config/validate.go index 260b3825a..7120520fd 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -48,7 +48,7 @@ func (c Config) Validate() error { return fmt.Errorf("in channel.history_meta_ttl: %v", err) } - if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatBinary}, c.Channel.PublicationDataFormat) { + if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.Channel.PublicationDataFormat) { return fmt.Errorf("unknown channel.publication_data_format: \"%s\"", c.Channel.PublicationDataFormat) } @@ -235,7 +235,7 @@ func validateChannelOptions(c configtypes.ChannelOptions, globalHistoryMetaTTL c return fmt.Errorf("unknown recovery mode: \"%s\"", c.ForceRecoveryMode) } - if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatBinary}, c.PublicationDataFormat) { + if !slices.Contains([]string{"", configtypes.PublicationDataFormatJSON, configtypes.PublicationDataFormatJSONObject, configtypes.PublicationDataFormatBinary}, c.PublicationDataFormat) { return fmt.Errorf("unknown publication_data_format: \"%s\"", c.PublicationDataFormat) } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index eb947426e..8b6d85e6f 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -24,6 +24,11 @@ func TestValidatePublicationDataFormat(t *testing.T) { format: configtypes.PublicationDataFormatJSON, wantErr: false, }, + { + name: "json_object is valid", + format: configtypes.PublicationDataFormatJSONObject, + wantErr: false, + }, { name: "binary is valid", format: configtypes.PublicationDataFormatBinary, @@ -72,6 +77,11 @@ func TestValidatePublicationDataFormatInNamespace(t *testing.T) { format: "json", wantErr: false, }, + { + name: "json_object in namespace", + format: "json_object", + wantErr: false, + }, { name: "binary in namespace", format: "binary", diff --git a/internal/configtypes/namespace.go b/internal/configtypes/namespace.go index 2710ab366..aa0b5214e 100644 --- a/internal/configtypes/namespace.go +++ b/internal/configtypes/namespace.go @@ -10,6 +10,8 @@ import ( const ( // PublicationDataFormatJSON indicates that publication data must be valid JSON. PublicationDataFormatJSON = "json" + // PublicationDataFormatJSONObject indicates that publication data must be valid JSON and specifically a JSON object. + PublicationDataFormatJSONObject = "json_object" // PublicationDataFormatBinary indicates that publication data is binary and empty data is allowed. PublicationDataFormatBinary = "binary" ) @@ -157,6 +159,7 @@ type ChannelOptions struct { // PublicationDataFormat defines the format validation for publication data. // If empty (default) - current behavior is used, reject empty data. // If "json" - validate that data is valid JSON, return bad request if not. + // If "json_object" - validate that data is valid JSON and specifically a JSON object, return bad request if not. // If "binary" - allow empty data to be published. PublicationDataFormat string `mapstructure:"publication_data_format" json:"publication_data_format" envconfig:"publication_data_format" yaml:"publication_data_format" toml:"publication_data_format"` diff --git a/internal/configtypes/types.go b/internal/configtypes/types.go index 8b5321976..e21a6f563 100644 --- a/internal/configtypes/types.go +++ b/internal/configtypes/types.go @@ -486,7 +486,7 @@ type Channel struct { HistoryMetaTTL Duration `mapstructure:"history_meta_ttl" json:"history_meta_ttl" envconfig:"history_meta_ttl" default:"720h" yaml:"history_meta_ttl" toml:"history_meta_ttl"` // PublicationDataFormat is a global publication data format for all channels. Can be overridden in channel namespace. - // Empty string means default behavior (reject empty data). Possible values: "", "json", "binary". + // Empty string means default behavior (reject empty data). Possible values: "", "json", "json_object", "binary". PublicationDataFormat string `mapstructure:"publication_data_format" json:"publication_data_format" envconfig:"publication_data_format" default:"" yaml:"publication_data_format" toml:"publication_data_format"` // MaxLength is a maximum length of a channel name. This is a global option for all channels. diff --git a/internal/tools/json.go b/internal/tools/json.go index d2249e1e3..dfbcf3440 100644 --- a/internal/tools/json.go +++ b/internal/tools/json.go @@ -6,3 +6,25 @@ import "encoding/json" func IsValidJSON(data []byte) bool { return json.Valid(data) } + +// IsValidJSONObject checks if the given byte slice is valid JSON and specifically a JSON object. +func IsValidJSONObject(data []byte) bool { + if !IsValidJSON(data) { + return false + } + // Find first non-whitespace character + start := 0 + for start < len(data) && isWhitespace(data[start]) { + start++ + } + // Find last non-whitespace character + end := len(data) - 1 + for end >= start && isWhitespace(data[end]) { + end-- + } + return start <= end && data[start] == '{' && data[end] == '}' +} + +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/internal/tools/json_test.go b/internal/tools/json_test.go index b45b5ba3d..2a2492b28 100644 --- a/internal/tools/json_test.go +++ b/internal/tools/json_test.go @@ -96,3 +96,86 @@ func TestIsValidJSON(t *testing.T) { }) } } + +func TestIsValidJSONObject(t *testing.T) { + tests := []struct { + name string + input []byte + expected bool + }{ + { + name: "empty data", + input: []byte{}, + expected: false, + }, + { + name: "valid json object", + input: []byte(`{"key": "value"}`), + expected: true, + }, + { + name: "valid empty json object", + input: []byte(`{}`), + expected: true, + }, + { + name: "valid json object with whitespace", + input: []byte(` {"key": "value"} `), + expected: true, + }, + { + name: "valid json object with newlines", + input: []byte("{\n \"key\": \"value\"\n}"), + expected: true, + }, + { + name: "valid json array - should be false", + input: []byte(`[1, 2, 3]`), + expected: false, + }, + { + name: "valid json string - should be false", + input: []byte(`"hello"`), + expected: false, + }, + { + name: "valid json number - should be false", + input: []byte(`123`), + expected: false, + }, + { + name: "valid json boolean - should be false", + input: []byte(`true`), + expected: false, + }, + { + name: "valid json null - should be false", + input: []byte(`null`), + expected: false, + }, + { + name: "invalid json - missing closing brace", + input: []byte(`{"key": "value"`), + expected: false, + }, + { + name: "invalid json - plain text", + input: []byte(`hello world`), + expected: false, + }, + { + name: "invalid json - trailing comma", + input: []byte(`{"key": "value",}`), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsValidJSONObject(tt.input) + if got != tt.expected { + t.Errorf("IsValidJSONObject() = %v, want %v", got, tt.expected) + } + }) + } +}