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

  1. Multiple Fields: Transform multiple fields in one operation
  2. Custom Regex: Add regex-based transformations
  3. Nested Paths: Support nested field paths like user.profile.name
  4. Chained Operations: Apply multiple transformations in sequence