Simple Transformer Component
A complete example of a data transformation component.
Overview
This component takes input data and transforms it according to configurable rules. It demonstrates:
- Basic component structure
- Settings configuration via
SettingsHandler - Input/output ports
- Data transformation
- A dedicated error port using the SDK error contract
Complete Implementation
package transformer
import (
"context"
"fmt"
"strings"
"github.com/tiny-systems/module/api/v1alpha1"
"github.com/tiny-systems/module/module"
"github.com/tiny-systems/module/registry"
)
const (
ComponentName = "transformer"
InPort = "in"
OutPort = "out"
ErrorPort = "error"
)
// Settings for the transformer
type Settings struct {
Operation string `json:"operation" required:"true" title:"Operation" enum:"uppercase,lowercase,trim,prefix,suffix" default:"uppercase" description:"Transformation operation to apply"`
Prefix string `json:"prefix,omitempty" title:"Prefix" description:"Prefix to add (when operation is 'prefix')"`
Suffix string `json:"suffix,omitempty" title:"Suffix" description:"Suffix to add (when operation is 'suffix')"`
FieldPath string `json:"fieldPath" required:"true" title:"Field Path" default:"text" description:"Key of the field to transform"`
}
type Context any
// InMessage - what the component receives
type InMessage struct {
Context Context `json:"context,omitempty" configurable:"true" title:"Context" description:"Arbitrary message passed through unchanged"`
Data map[string]any `json:"data" required:"true" configurable:"true" title:"Data" description:"Input object containing the field to transform"`
}
// OutMessage - what the component produces
type OutMessage struct {
Context Context `json:"context,omitempty" title:"Context"`
OriginalData map[string]any `json:"originalData" title:"Original Data"`
TransformedData map[string]any `json:"transformedData" title:"Transformed Data"`
Operation string `json:"operation" title:"Operation Applied"`
}
// Component struct
type Component struct {
settings Settings
}
// Instance creates a new component instance with defaults
func (c *Component) Instance() module.Component {
return &Component{
settings: Settings{
Operation: "uppercase",
FieldPath: "text",
},
}
}
// GetInfo returns component metadata
func (c *Component) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: ComponentName,
Description: "Transformer",
Info: "Transforms one string field of the input object using a configurable operation, then emits the original and transformed objects on Out.",
Tags: []string{"transform", "text", "string"},
}
}
// OnSettings receives settings from the SettingsPort (SettingsHandler capability).
// The runner dispatches settings here — Handle never sees the settings port.
func (c *Component) OnSettings(_ context.Context, msg any) error {
settings, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings: %T", msg)
}
c.settings = settings
return nil
}
// Ports returns the port definitions
func (c *Component) Ports() []module.Port {
return []module.Port{
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: c.settings,
},
{
Name: InPort,
Label: "In",
Position: module.Left,
Configuration: InMessage{},
},
{
Name: OutPort,
Label: "Out",
Position: module.Right,
Source: true, // Source: true marks an OUTPUT port
Configuration: new(OutMessage),
},
{
Name: ErrorPort,
Label: "Error",
Position: module.Bottom,
Source: true,
Configuration: module.ErrorMessage{}, // canonical {context, error, retryable}
},
}
}
// Handle processes incoming messages and returns a module.Result
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
if port != InPort {
return module.Fail(fmt.Errorf("unknown port: %s", port))
}
in, ok := msg.(InMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message: %T", msg))
}
return c.transform(ctx, output, in)
}
func (c *Component) transform(ctx context.Context, output module.Handler, in InMessage) module.Result {
// Clone the data before modification
transformedData := make(map[string]any, len(in.Data))
for k, v := range in.Data {
transformedData[k] = v
}
// Get the field to transform
fieldValue, ok := transformedData[c.settings.FieldPath]
if !ok {
// A missing field won't appear on retry — mark it permanent.
err := module.Permanent(fmt.Errorf("field %q not found in data", c.settings.FieldPath))
return output(ctx, ErrorPort, module.NewError(in.Context, err))
}
text, ok := fieldValue.(string)
if !ok {
err := module.Permanent(fmt.Errorf("field %q is not a string", c.settings.FieldPath))
return output(ctx, ErrorPort, module.NewError(in.Context, err))
}
// Apply transformation
var transformed string
switch c.settings.Operation {
case "uppercase":
transformed = strings.ToUpper(text)
case "lowercase":
transformed = strings.ToLower(text)
case "trim":
transformed = strings.TrimSpace(text)
case "prefix":
transformed = c.settings.Prefix + text
case "suffix":
transformed = text + c.settings.Suffix
default:
transformed = text
}
transformedData[c.settings.FieldPath] = transformed
// Emit output and RETURN the handler's Result — do not discard it
return output(ctx, OutPort, OutMessage{
Context: in.Context,
OriginalData: in.Data,
TransformedData: transformedData,
Operation: c.settings.Operation,
})
}
// Compile-time interface checks
var (
_ module.Component = (*Component)(nil)
_ module.SettingsHandler = (*Component)(nil)
)
func init() {
registry.Register((&Component{}).Instance())
}
Usage Example
Node Configuration
Settings live in the node's _settings port configuration; input data is mapped on the incoming edge with expressions:
# _settings port configuration (JSON, written by the editor / build_flow)
{
"operation": "uppercase",
"fieldPath": "message"
}
# Edge mapping into the `in` port
{
"data": {
"message": "{{$.body}}",
"timestamp": "{{now()}}"
}
}
Input Data
{
"data": {
"message": "hello world",
"timestamp": 1705312200
}
}
Output Data
{
"originalData": {
"message": "hello world",
"timestamp": 1705312200
},
"transformedData": {
"message": "HELLO WORLD",
"timestamp": 1705312200
},
"operation": "uppercase"
}
Key Patterns Demonstrated
1. Enum Settings
Operation string `json:"operation" required:"true" title:"Operation" enum:"uppercase,lowercase,trim,prefix,suffix" default:"uppercase"`
The enum tag creates a dropdown in the UI. Note the whole tag stays on one line — a raw string tag split across lines is invalid Go.
2. SettingsHandler Instead of a Port Switch
Implementing OnSettings means the runner delivers settings before business messages and Handle stays focused on data ports.
3. Canonical Error Port
Emit module.ErrorMessage built with module.NewError — the {context, error, retryable} shape the retry component and the platform understand:
err := module.Permanent(fmt.Errorf("field %q is not a string", c.settings.FieldPath))
return output(ctx, ErrorPort, module.NewError(in.Context, err))
Mark transient failures with module.Retryable(err); unmarked errors are never retried.
4. Data Cloning
Always clone input data before modification:
transformedData := make(map[string]any, len(in.Data))
for k, v := range in.Data {
transformedData[k] = v
}
Testing
A test handler is a module.Handler and returns module.Result:
func TestTransformer(t *testing.T) {
comp := (&Component{}).Instance().(*Component)
// Initialize settings through the capability method
if err := comp.OnSettings(context.Background(), Settings{
Operation: "uppercase",
FieldPath: "text",
}); err != nil {
t.Fatal(err)
}
var result OutMessage
handler := func(ctx context.Context, port string, msg any) module.Result {
if port == OutPort {
result = msg.(OutMessage)
}
return module.Ok(nil)
}
res := comp.Handle(context.Background(), handler, InPort, InMessage{
Data: map[string]any{"text": "hello"},
})
if err := res.Err(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.TransformedData["text"] != "HELLO" {
t.Errorf("expected HELLO, got %v", result.TransformedData["text"])
}
}
Extension Ideas
- Multiple Fields: Transform multiple fields in one operation
- Custom Regex: Add regex-based transformations
- Nested Paths: Support nested field paths like
user.profile.name - Chained Operations: Apply multiple transformations in sequence