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
4 changes: 4 additions & 0 deletions internal/config/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions internal/config/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
10 changes: 10 additions & 0 deletions internal/config/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions internal/configtypes/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"`

Expand Down
2 changes: 1 addition & 1 deletion internal/configtypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions internal/tools/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
83 changes: 83 additions & 0 deletions internal/tools/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading