Skip to content

Commit bc0da6b

Browse files
committed
feat: add InsertField transformation support
- Introduced InsertFieldTransformation to insert or overwrite JSON fields with literals and metadata placeholders. - Updated TransformationSpec to include "insertField" as a valid transformation type. - Implemented validation logic for InsertField transformation configuration, ensuring required fields and valid JSON formats. - Added unit tests for InsertField transformation creation, validation, and error handling scenarios. - Updated CRDs to reflect the new InsertField transformation type in documentation.
1 parent 74ef552 commit bc0da6b

11 files changed

Lines changed: 534 additions & 3 deletions

api/v1/dataflow_types.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1342,7 +1342,7 @@ type SASLConfig struct {
13421342
// TransformationSpec defines a transformation to apply (type + config).
13431343
// +kubebuilder:pruning:PreserveUnknownFields
13441344
type TransformationSpec struct {
1345-
// Type of transformation: timestamp, flatten, filter, mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap, replaceField, headersToPayload, structFlatten, extractField, hoistField, cast, timezone
1345+
// Type of transformation: timestamp, flatten, filter, mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap, replaceField, headersToPayload, structFlatten, extractField, hoistField, cast, timezone, insertField
13461346
Type string `json:"type"`
13471347

13481348
// Config holds transformation configuration. Structure depends on type.
@@ -1436,6 +1436,11 @@ func (t *TransformationSpec) GetTimezoneConfig() (*TimezoneTransformation, error
14361436
return getTypedConfig[TimezoneTransformation](t.Config)
14371437
}
14381438

1439+
// GetInsertFieldConfig returns InsertField transformation config.
1440+
func (t *TransformationSpec) GetInsertFieldConfig() (*InsertFieldTransformation, error) {
1441+
return getTypedConfig[InsertFieldTransformation](t.Config)
1442+
}
1443+
14391444
// TimestampTransformation adds a timestamp field
14401445
type TimestampTransformation struct {
14411446
// FieldName is the name of the timestamp field (default: created_at)
@@ -1615,6 +1620,16 @@ type TimezoneTransformation struct {
16151620
Format string `json:"format,omitempty"`
16161621
}
16171622

1623+
// InsertFieldTransformation inserts or overwrites JSON fields with literals,
1624+
// metadata placeholders (${metadata.topic|partition|offset|timestamp}), ${now}, or json:<raw>.
1625+
// Distinct from timestamp (single now field) and headersToPayload (headers → body only).
1626+
// Non-JSON payloads are passed through. Missing metadata keys yield an empty string.
1627+
type InsertFieldTransformation struct {
1628+
// Fields maps JSONPath → value. Required, non-empty.
1629+
// Values: literal string, "${metadata.<key>}", "${now}", or "json:<raw JSON>".
1630+
Fields map[string]string `json:"fields"`
1631+
}
1632+
16181633
// DataFlowStatus defines the observed state of DataFlow
16191634
type DataFlowStatus struct {
16201635
// Phase represents the current phase of the data flow

api/v1/dataflow_validation.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,6 +1168,30 @@ func validateTransformations(transformations []TransformationSpec, f *field.Path
11681168
} else {
11691169
all = append(all, field.Required(idx.Child("config"), "timezone transformation configuration is required"))
11701170
}
1171+
case transformtypes.InsertField:
1172+
if hasConfig {
1173+
var cfg InsertFieldTransformation
1174+
if err := json.Unmarshal(t.Config.Raw, &cfg); err != nil {
1175+
all = append(all, field.Invalid(idx.Child("config"), string(t.Config.Raw), "invalid insertField config: "+err.Error()))
1176+
} else if len(cfg.Fields) == 0 {
1177+
all = append(all, field.Required(idx.Child("config", "fields"), "fields is required and must be non-empty"))
1178+
} else {
1179+
for path, value := range cfg.Fields {
1180+
if normalizeJSONPathField(path) == "" {
1181+
all = append(all, field.Invalid(idx.Child("config", "fields"), path, "fields keys must be non-empty JSONPaths"))
1182+
continue
1183+
}
1184+
if strings.HasPrefix(value, "json:") {
1185+
raw := strings.TrimPrefix(value, "json:")
1186+
if !json.Valid([]byte(raw)) {
1187+
all = append(all, field.Invalid(idx.Child("config", "fields").Key(path), value, "json: value must be valid JSON"))
1188+
}
1189+
}
1190+
}
1191+
}
1192+
} else {
1193+
all = append(all, field.Required(idx.Child("config"), "insertField transformation configuration is required"))
1194+
}
11711195
}
11721196
}
11731197
return all

api/v1/dataflow_validation_transformations_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,3 +741,112 @@ func TestValidateTransformations_Timezone(t *testing.T) {
741741
}
742742
})
743743
}
744+
745+
func TestValidateTransformations_InsertField(t *testing.T) {
746+
baseSpec := DataFlowSpec{
747+
Source: SourceSpec{
748+
Type: "kafka",
749+
Config: mustRawConfigForValidation(KafkaSourceSpec{Brokers: []string{"broker:9092"}, Topic: "src"}),
750+
},
751+
Sink: SinkSpec{
752+
Type: "kafka",
753+
Config: mustRawConfigForValidation(KafkaSinkSpec{Brokers: []string{"broker:9092"}, Topic: "dst"}),
754+
},
755+
}
756+
757+
t.Run("valid", func(t *testing.T) {
758+
spec := baseSpec
759+
spec.Transformations = []TransformationSpec{{
760+
Type: "insertField",
761+
Config: mustRawConfigForValidation(InsertFieldTransformation{
762+
Fields: map[string]string{
763+
"pipeline": "orders-cdc",
764+
"source_topic": "${metadata.topic}",
765+
"ingested_at": "${now}",
766+
"flags.reprocessed": "json:false",
767+
},
768+
}),
769+
}}
770+
errs := ValidateDataFlowSpec(&spec)
771+
if len(errs) != 0 {
772+
t.Fatalf("expected no validation errors, got %v", errs)
773+
}
774+
})
775+
776+
t.Run("valid with JSONPath prefix", func(t *testing.T) {
777+
spec := baseSpec
778+
spec.Transformations = []TransformationSpec{{
779+
Type: "insertField",
780+
Config: mustRawConfigForValidation(InsertFieldTransformation{
781+
Fields: map[string]string{
782+
"$.pipeline": "orders-cdc",
783+
},
784+
}),
785+
}}
786+
errs := ValidateDataFlowSpec(&spec)
787+
if len(errs) != 0 {
788+
t.Fatalf("expected no validation errors, got %v", errs)
789+
}
790+
})
791+
792+
t.Run("missing config", func(t *testing.T) {
793+
spec := baseSpec
794+
spec.Transformations = []TransformationSpec{{Type: "insertField"}}
795+
errs := ValidateDataFlowSpec(&spec)
796+
if len(errs) == 0 {
797+
t.Fatal("expected validation error for missing config")
798+
}
799+
if !strings.Contains(errs.ToAggregate().Error(), "insertField transformation configuration is required") {
800+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
801+
}
802+
})
803+
804+
t.Run("empty fields", func(t *testing.T) {
805+
spec := baseSpec
806+
spec.Transformations = []TransformationSpec{{
807+
Type: "insertField",
808+
Config: mustRawConfigForValidation(InsertFieldTransformation{}),
809+
}}
810+
errs := ValidateDataFlowSpec(&spec)
811+
if len(errs) == 0 {
812+
t.Fatal("expected validation error for empty fields")
813+
}
814+
if !strings.Contains(errs.ToAggregate().Error(), "fields is required") {
815+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
816+
}
817+
})
818+
819+
t.Run("invalid json value", func(t *testing.T) {
820+
spec := baseSpec
821+
spec.Transformations = []TransformationSpec{{
822+
Type: "insertField",
823+
Config: mustRawConfigForValidation(InsertFieldTransformation{
824+
Fields: map[string]string{"bad": "json:{not"},
825+
}),
826+
}}
827+
errs := ValidateDataFlowSpec(&spec)
828+
if len(errs) == 0 {
829+
t.Fatal("expected validation error for invalid json value")
830+
}
831+
if !strings.Contains(errs.ToAggregate().Error(), "valid JSON") {
832+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
833+
}
834+
})
835+
836+
t.Run("empty path key", func(t *testing.T) {
837+
spec := baseSpec
838+
spec.Transformations = []TransformationSpec{{
839+
Type: "insertField",
840+
Config: mustRawConfigForValidation(InsertFieldTransformation{
841+
Fields: map[string]string{"$": "x"},
842+
}),
843+
}}
844+
errs := ValidateDataFlowSpec(&spec)
845+
if len(errs) == 0 {
846+
t.Fatal("expected validation error for empty path")
847+
}
848+
if !strings.Contains(errs.ToAggregate().Error(), "non-empty JSONPaths") {
849+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
850+
}
851+
})
852+
}

api/v1/zz_generated.deepcopy.go

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/dataflow.dataflow.io_dataflowcrons.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,7 @@ spec:
12681268
description: 'Type of transformation: timestamp, flatten, filter,
12691269
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap,
12701270
replaceField, headersToPayload, structFlatten, extractField,
1271-
hoistField, cast, timezone'
1271+
hoistField, cast, timezone, insertField'
12721272
type: string
12731273
required:
12741274
- type

config/crd/bases/dataflow.dataflow.io_dataflows.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,7 @@ spec:
12531253
description: 'Type of transformation: timestamp, flatten, filter,
12541254
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap,
12551255
replaceField, headersToPayload, structFlatten, extractField,
1256-
hoistField, cast, timezone'
1256+
hoistField, cast, timezone, insertField'
12571257
type: string
12581258
required:
12591259
- type

internal/transformers/factory.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ var transformerRegistry = map[string]transformerEntry{
6464
transformtypes.Timezone: {create: createTransformer[v1.TimezoneTransformation](transformtypes.Timezone, func(cfg *v1.TimezoneTransformation) Transformer {
6565
return NewTimezoneTransformer(cfg)
6666
})},
67+
transformtypes.InsertField: {create: createTransformer[v1.InsertFieldTransformation](transformtypes.InsertField, func(cfg *v1.InsertFieldTransformation) Transformer {
68+
return NewInsertFieldTransformer(cfg)
69+
})},
6770
}
6871

6972
// createTransformer returns a factory function that unmarshals raw config into T and calls newFn.

internal/transformers/factory_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,31 @@ func TestCreateTransformer_Timezone(t *testing.T) {
419419
})
420420
}
421421

422+
func TestCreateTransformer_InsertField(t *testing.T) {
423+
runCreateTransformerTests(t, []transformerTestCase{
424+
{
425+
name: "valid insertField transformation",
426+
transformation: &v1.TransformationSpec{
427+
Type: transformtypes.InsertField,
428+
Config: mustConfig(v1.InsertFieldTransformation{Fields: map[string]string{
429+
"pipeline": "orders-cdc",
430+
"source_topic": "${metadata.topic}",
431+
"ingested_at": "${now}",
432+
"flags.active": "json:true",
433+
}}),
434+
},
435+
},
436+
{
437+
name: "insertField without config",
438+
transformation: &v1.TransformationSpec{
439+
Type: transformtypes.InsertField,
440+
},
441+
wantErr: true,
442+
errContains: "insertField transformation configuration is required",
443+
},
444+
})
445+
}
446+
422447
func TestCreateTransformer_UnsupportedType(t *testing.T) {
423448
transformation := &v1.TransformationSpec{
424449
Type: "unsupported",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package transformers
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"strings"
24+
"time"
25+
26+
v1 "github.com/dataflow-operator/dataflow/api/v1"
27+
"github.com/dataflow-operator/dataflow/internal/types"
28+
"github.com/tidwall/sjson"
29+
)
30+
31+
const (
32+
insertFieldNowPlaceholder = "${now}"
33+
insertFieldMetadataPrefix = "${metadata."
34+
insertFieldMetadataSuffix = "}"
35+
insertFieldJSONPrefix = "json:"
36+
)
37+
38+
// InsertFieldTransformer inserts or overwrites JSON fields with literals and placeholders.
39+
type InsertFieldTransformer struct {
40+
config *v1.InsertFieldTransformation
41+
}
42+
43+
// NewInsertFieldTransformer creates a new insertField transformer.
44+
func NewInsertFieldTransformer(config *v1.InsertFieldTransformation) *InsertFieldTransformer {
45+
return &InsertFieldTransformer{config: config}
46+
}
47+
48+
// Transform inserts configured fields into the JSON payload.
49+
// Non-JSON payloads are passed through unchanged. Metadata is preserved.
50+
// Missing metadata keys resolve to an empty string. Invalid json: values return an error.
51+
func (t *InsertFieldTransformer) Transform(ctx context.Context, message *types.Message) ([]*types.Message, error) {
52+
if _, ok := tryUnmarshalJSON(message); !ok {
53+
return []*types.Message{message}, nil
54+
}
55+
56+
jsonStr := string(message.Data)
57+
for path, rawValue := range t.config.Fields {
58+
normalized := normalizeFieldPath(strings.TrimSpace(path))
59+
if normalized == "" {
60+
continue
61+
}
62+
63+
value, asRaw, err := resolveInsertFieldValue(rawValue, message.Metadata)
64+
if err != nil {
65+
return nil, fmt.Errorf("insertField %q: %w", path, err)
66+
}
67+
68+
if asRaw {
69+
jsonStr, err = sjson.SetRaw(jsonStr, normalized, value.(string))
70+
} else {
71+
jsonStr, err = sjson.Set(jsonStr, normalized, value)
72+
}
73+
if err != nil {
74+
return nil, fmt.Errorf("insertField set %q: %w", path, err)
75+
}
76+
}
77+
78+
return []*types.Message{newMessageFrom(message, []byte(jsonStr))}, nil
79+
}
80+
81+
// resolveInsertFieldValue resolves a configured field value.
82+
// Returns asRaw=true when the value should be written via sjson.SetRaw (json: prefix).
83+
func resolveInsertFieldValue(raw string, meta map[string]interface{}) (value interface{}, asRaw bool, err error) {
84+
switch {
85+
case raw == insertFieldNowPlaceholder:
86+
return time.Now().Format(time.RFC3339), false, nil
87+
case strings.HasPrefix(raw, insertFieldMetadataPrefix) && strings.HasSuffix(raw, insertFieldMetadataSuffix):
88+
key := raw[len(insertFieldMetadataPrefix) : len(raw)-len(insertFieldMetadataSuffix)]
89+
return formatInsertFieldMetadata(meta, key), false, nil
90+
case strings.HasPrefix(raw, insertFieldJSONPrefix):
91+
jsonRaw := raw[len(insertFieldJSONPrefix):]
92+
if !json.Valid([]byte(jsonRaw)) {
93+
return nil, false, fmt.Errorf("invalid json value %q", jsonRaw)
94+
}
95+
return jsonRaw, true, nil
96+
default:
97+
return raw, false, nil
98+
}
99+
}
100+
101+
func formatInsertFieldMetadata(meta map[string]interface{}, key string) string {
102+
if meta == nil || key == "" {
103+
return ""
104+
}
105+
v, ok := meta[key]
106+
if !ok || v == nil {
107+
return ""
108+
}
109+
switch t := v.(type) {
110+
case string:
111+
return t
112+
case []byte:
113+
return string(t)
114+
case time.Time:
115+
return t.UTC().Format(time.RFC3339Nano)
116+
default:
117+
return fmt.Sprint(t)
118+
}
119+
}

0 commit comments

Comments
 (0)