Skip to content
Merged
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
25 changes: 25 additions & 0 deletions bindings/zeebe/command/activate_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ type activateJobsPayload struct {
RequestTimeout *metadata.Duration `json:"requestTimeout,omitempty"`
}

// UnmarshalJSON implements json.Unmarshaler to provide field-specific error messages for
// duration fields. Both timeout and requestTimeout accept either a Go duration string
// (e.g. "30s", "5m", "1h30m") or a plain integer representing nanoseconds.
func (p *activateJobsPayload) UnmarshalJSON(data []byte) error {
type alias activateJobsPayload
shadow := struct {
*alias
Timeout *json.RawMessage `json:"timeout,omitempty"`
RequestTimeout *json.RawMessage `json:"requestTimeout,omitempty"`
}{alias: (*alias)(p)}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}

var err error
if p.Timeout, err = parseOptionalDuration(shadow.Timeout, "timeout"); err != nil {
return err
}
if p.RequestTimeout, err = parseOptionalDuration(shadow.RequestTimeout, "requestTimeout"); err != nil {
return err
}

return nil
}

func (z *ZeebeCommand) activateJobs(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
var payload activateJobsPayload
err := json.Unmarshal(req.Data, &payload)
Expand Down
69 changes: 69 additions & 0 deletions bindings/zeebe/command/activate_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,75 @@ func (cmd3 *mockActivateJobsCommandStep3) Send(context.Context) ([]entities.Job,
return []entities.Job{}, nil
}

func TestActivateJobsPayloadUnmarshal(t *testing.T) {
t.Run("invalid timeout produces field-scoped error", func(t *testing.T) {
raw := `{"jobType":"worker","maxJobsToActivate":10,"timeout":"PT30S"}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
assert.Contains(t, err.Error(), "PT30S")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("invalid requestTimeout produces field-scoped error", func(t *testing.T) {
raw := `{"jobType":"worker","maxJobsToActivate":10,"requestTimeout":"2minutes"}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "requestTimeout")
assert.Contains(t, err.Error(), "2minutes")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("valid Go duration strings succeed", func(t *testing.T) {
raw := `{"jobType":"worker","maxJobsToActivate":10,"timeout":"30s","requestTimeout":"5m"}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.Timeout)
assert.Equal(t, 30*time.Second, p.Timeout.Duration)
require.NotNil(t, p.RequestTimeout)
assert.Equal(t, 5*time.Minute, p.RequestTimeout.Duration)
})

t.Run("numeric nanosecond timeout still parses correctly", func(t *testing.T) {
// Regression guard: numeric (nanosecond) values were accepted before and must remain valid.
raw := `{"jobType":"worker","maxJobsToActivate":10,"timeout":30000000000}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.Timeout)
assert.Equal(t, 30*time.Second, p.Timeout.Duration)
})

t.Run("numeric nanosecond requestTimeout still parses correctly", func(t *testing.T) {
// Regression guard: numeric (nanosecond) values were accepted before and must remain valid.
raw := `{"jobType":"worker","maxJobsToActivate":10,"requestTimeout":300000000000}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.RequestTimeout)
assert.Equal(t, 5*time.Minute, p.RequestTimeout.Duration)
})

t.Run("null timeout leaves pointer nil", func(t *testing.T) {
raw := `{"jobType":"worker","maxJobsToActivate":10,"timeout":null}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
assert.Nil(t, p.Timeout)
})

t.Run("null requestTimeout leaves pointer nil", func(t *testing.T) {
raw := `{"jobType":"worker","maxJobsToActivate":10,"requestTimeout":null}`
var p activateJobsPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
assert.Nil(t, p.RequestTimeout)
})
}

func TestActivateJobs(t *testing.T) {
testLogger := logger.NewLogger("test")

Expand Down
21 changes: 21 additions & 0 deletions bindings/zeebe/command/create_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ type createInstancePayload struct {
RequestTimeout *metadata.Duration `json:"requestTimeout,omitempty"`
}

// UnmarshalJSON implements json.Unmarshaler to provide a field-specific error message for
// the requestTimeout duration field. The requestTimeout field accepts either a Go duration string
// (e.g. "30s", "5m", "1h30m") or a plain integer representing nanoseconds.
func (p *createInstancePayload) UnmarshalJSON(data []byte) error {
type alias createInstancePayload
shadow := struct {
*alias
RequestTimeout *json.RawMessage `json:"requestTimeout,omitempty"`
}{alias: (*alias)(p)}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}

var err error
if p.RequestTimeout, err = parseOptionalDuration(shadow.RequestTimeout, "requestTimeout"); err != nil {
return err
}

return nil
}

func (z *ZeebeCommand) createInstance(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
var payload createInstancePayload
err := json.Unmarshal(req.Data, &payload)
Expand Down
40 changes: 40 additions & 0 deletions bindings/zeebe/command/create_instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"context"
"encoding/json"
"testing"
"time"

"github.com/camunda/zeebe/clients/go/v8/pkg/commands"
"github.com/camunda/zeebe/clients/go/v8/pkg/pb"
Expand Down Expand Up @@ -98,6 +99,45 @@ func (cmd3 *mockCreateInstanceCommandStep3) Send(context.Context) (*pb.CreatePro
return &pb.CreateProcessInstanceResponse{}, nil
}

func TestCreateInstancePayloadUnmarshal(t *testing.T) {
t.Run("invalid requestTimeout produces field-scoped error", func(t *testing.T) {
raw := `{"bpmnProcessId":"order-process","requestTimeout":"PT5M"}`
var p createInstancePayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "requestTimeout")
assert.Contains(t, err.Error(), "PT5M")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("valid Go duration string succeeds", func(t *testing.T) {
raw := `{"bpmnProcessId":"order-process","requestTimeout":"5m"}`
var p createInstancePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.RequestTimeout)
assert.Equal(t, 5*time.Minute, p.RequestTimeout.Duration)
})

t.Run("numeric nanosecond requestTimeout still parses correctly", func(t *testing.T) {
// Regression guard: numeric (nanosecond) values were accepted before and must remain valid.
raw := `{"bpmnProcessId":"order-process","requestTimeout":300000000000}`
var p createInstancePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.RequestTimeout)
assert.Equal(t, 5*time.Minute, p.RequestTimeout.Duration)
})

t.Run("null requestTimeout leaves pointer nil", func(t *testing.T) {
raw := `{"bpmnProcessId":"order-process","requestTimeout":null}`
var p createInstancePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
assert.Nil(t, p.RequestTimeout)
})
}

func TestCreateInstance(t *testing.T) {
testLogger := logger.NewLogger("test")

Expand Down
38 changes: 38 additions & 0 deletions bindings/zeebe/command/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Copyright 2021 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package command

import (
"encoding/json"
"fmt"

"github.com/dapr/kit/metadata"
)

// errInvalidDurationFormat is the shared message used by every command binding so that an
// invalid duration value is reported consistently, naming the offending field and the value.
const errInvalidDurationFormat = "invalid value %s for field '%s' (expected a Go duration string, e.g. \"30s\", \"5m\", \"1h30m\", or a plain integer nanoseconds value): %w"

// parseOptionalDuration decodes an optional duration field that was captured as raw JSON so the
// caller can surface a field-scoped error. It returns (nil, nil) when the field is absent.
func parseOptionalDuration(raw *json.RawMessage, field string) (*metadata.Duration, error) {
if raw == nil {
return nil, nil
}
var d metadata.Duration
if err := d.UnmarshalJSON(*raw); err != nil {
return nil, fmt.Errorf(errInvalidDurationFormat, string(*raw), field, err)
}
return &d, nil
}
21 changes: 21 additions & 0 deletions bindings/zeebe/command/fail_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ type failJobPayload struct {
Variables interface{} `json:"variables"`
}

// UnmarshalJSON implements json.Unmarshaler to provide a field-specific error message for
// the retryBackOff duration field. The retryBackOff field accepts either a Go duration string
// (e.g. "30s", "5m", "1h30m") or a plain integer representing nanoseconds.
func (p *failJobPayload) UnmarshalJSON(data []byte) error {
type alias failJobPayload
shadow := struct {
*alias
RetryBackOff *json.RawMessage `json:"retryBackOff,omitempty"`
}{alias: (*alias)(p)}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}

var err error
if p.RetryBackOff, err = parseOptionalDuration(shadow.RetryBackOff, "retryBackOff"); err != nil {
return err
}

return nil
}

func (z *ZeebeCommand) failJob(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
var payload failJobPayload
err := json.Unmarshal(req.Data, &payload)
Expand Down
40 changes: 40 additions & 0 deletions bindings/zeebe/command/fail_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"context"
"encoding/json"
"testing"
"time"

"github.com/camunda/zeebe/clients/go/v8/pkg/commands"
"github.com/camunda/zeebe/clients/go/v8/pkg/pb"
Expand Down Expand Up @@ -82,6 +83,45 @@ func (cmd3 *mockFailJobCommandStep3) Send(context.Context) (*pb.FailJobResponse,
return &pb.FailJobResponse{}, nil
}

func TestFailJobPayloadUnmarshal(t *testing.T) {
t.Run("invalid retryBackOff produces field-scoped error", func(t *testing.T) {
raw := `{"jobKey":1,"retries":3,"retryBackOff":"PT10S"}`
var p failJobPayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "retryBackOff")
assert.Contains(t, err.Error(), "PT10S")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("valid Go duration string succeeds", func(t *testing.T) {
raw := `{"jobKey":1,"retries":3,"retryBackOff":"10s"}`
var p failJobPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.RetryBackOff)
assert.Equal(t, 10*time.Second, p.RetryBackOff.Duration)
})

t.Run("numeric nanosecond retryBackOff still parses correctly", func(t *testing.T) {
// Regression guard: numeric (nanosecond) values were accepted before and must remain valid.
raw := `{"jobKey":1,"retries":3,"retryBackOff":30000000000}`
var p failJobPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.RetryBackOff)
assert.Equal(t, 30*time.Second, p.RetryBackOff.Duration)
})

t.Run("null retryBackOff leaves pointer nil", func(t *testing.T) {
raw := `{"jobKey":1,"retries":3,"retryBackOff":null}`
var p failJobPayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
assert.Nil(t, p.RetryBackOff)
})
}

func TestFailJob(t *testing.T) {
testLogger := logger.NewLogger("test")

Expand Down
22 changes: 22 additions & 0 deletions bindings/zeebe/command/publish_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ type publishMessagePayload struct {
Variables interface{} `json:"variables"`
}

// UnmarshalJSON implements json.Unmarshaler to provide field-specific error messages for
// duration fields. The timeToLive field accepts either a Go duration string (e.g. "30s", "5m", "1h30m")
// or a plain integer representing nanoseconds.
func (p *publishMessagePayload) UnmarshalJSON(data []byte) error {
// Use a shadow struct with raw JSON for the duration field so we can provide better errors.
type alias publishMessagePayload
shadow := struct {
*alias
TimeToLive *json.RawMessage `json:"timeToLive,omitempty"`
}{alias: (*alias)(p)}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}

var err error
if p.TimeToLive, err = parseOptionalDuration(shadow.TimeToLive, "timeToLive"); err != nil {
return err
}

return nil
}

func (z *ZeebeCommand) publishMessage(ctx context.Context, req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
var payload publishMessagePayload
err := json.Unmarshal(req.Data, &payload)
Expand Down
50 changes: 50 additions & 0 deletions bindings/zeebe/command/publish_message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,56 @@ func (cmd3 *mockPublishMessageCommandStep3) Send(context.Context) (*pb.PublishMe
return &pb.PublishMessageResponse{}, nil
}

func TestPublishMessagePayloadUnmarshal(t *testing.T) {
t.Run("invalid timeToLive produces field-scoped error", func(t *testing.T) {
// ISO-8601 durations are not valid; Go duration strings like "1m30s" are required.
raw := `{"messageName":"msg","timeToLive":"PT1M"}`
var p publishMessagePayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "timeToLive")
assert.Contains(t, err.Error(), "PT1M")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("unknown unit in timeToLive produces field-scoped error", func(t *testing.T) {
raw := `{"messageName":"msg","timeToLive":"1minute"}`
var p publishMessagePayload
err := p.UnmarshalJSON([]byte(raw))
require.Error(t, err)
assert.Contains(t, err.Error(), "timeToLive")
assert.Contains(t, err.Error(), "1minute")
assert.Contains(t, err.Error(), "duration string")
})

t.Run("valid Go duration string succeeds", func(t *testing.T) {
raw := `{"messageName":"msg","timeToLive":"1m30s"}`
var p publishMessagePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.TimeToLive)
assert.Equal(t, 90*time.Second, p.TimeToLive.Duration)
})

t.Run("numeric nanosecond timeToLive still parses correctly", func(t *testing.T) {
// Regression guard: numeric (nanosecond) values were accepted before and must remain valid.
raw := `{"messageName":"msg","timeToLive":30000000000}`
var p publishMessagePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
require.NotNil(t, p.TimeToLive)
assert.Equal(t, 30*time.Second, p.TimeToLive.Duration)
})

t.Run("null timeToLive leaves pointer nil", func(t *testing.T) {
raw := `{"messageName":"msg","timeToLive":null}`
var p publishMessagePayload
err := p.UnmarshalJSON([]byte(raw))
require.NoError(t, err)
assert.Nil(t, p.TimeToLive)
})
}

func TestPublishMessage(t *testing.T) {
testLogger := logger.NewLogger("test")

Expand Down
Loading