Skip to content

Dynamic Schemas

Some components need schemas that change based on configuration or runtime conditions. TinySystems supports dynamic schema generation through the Ports() method.

When to Use Dynamic Schemas

  • Configurable Outputs: Router with dynamic route names
  • Data-Driven Ports: Ports generated from settings
  • Runtime Discovery: Schemas based on external data sources
  • Conditional Fields: Settings that change based on mode selection

Basic Dynamic Ports

Regenerate ports based on settings:

go
type Router struct {
    settings Settings
}

type Settings struct {
    Routes []string `json:"routes" title:"Route Names" minItems:"1"`
}

func (r *Router) Ports() []module.Port {
    ports := []module.Port{
        {
            Name:     v1alpha1.SettingsPort,
            Label:    "Settings",
            Source:   true,
            Position: module.PositionTop,
            Schema:   schema.FromGo(Settings{}),
        },
        {
            Name:     "input",
            Label:    "Input",
            Source:   true,
            Position: module.PositionLeft,
            Schema:   schema.FromGo(Message{}),
        },
        {
            Name:     "default",
            Label:    "Default",
            Source:   false,
            Position: module.PositionBottom,
            Schema:   schema.FromGo(Message{}),
        },
    }

    // Generate output ports from settings
    for _, route := range r.settings.Routes {
        ports = append(ports, module.Port{
            Name:     route,
            Label:    route,
            Source:   false,
            Position: module.PositionRight,
            Schema:   schema.FromGo(Message{}),
        })
    }

    return ports
}

Dynamic Control Schemas

Control schemas that reflect component state:

go
type Ticker struct {
    isRunning bool
    mu        sync.Mutex
}

type Control struct {
    Start bool `json:"start,omitempty" title:"Start" format:"button"`
    Stop  bool `json:"stop,omitempty" title:"Stop" format:"button"`
}

func (t *Ticker) getControl() Control {
    t.mu.Lock()
    defer t.mu.Unlock()

    return Control{
        Start: !t.isRunning,  // Show Start when stopped
        Stop:  t.isRunning,   // Show Stop when running
    }
}

func (t *Ticker) Ports() []module.Port {
    return []module.Port{
        {
            Name:     v1alpha1.ControlPort,
            Label:    "Control",
            Source:   true,
            Position: module.PositionTop,
            Schema:   schema.FromGo(t.getControl()),  // Dynamic!
        },
        // Other ports...
    }
}

Port Status Updates

Update port status dynamically:

go
type Server struct {
    settings    Settings
    currentPort int
    isListening bool
}

func (s *Server) Ports() []module.Port {
    return []module.Port{
        {
            Name:     "request",
            Label:    "Request",
            Source:   true,
            Position: module.PositionLeft,
            Schema:   schema.FromGo(Request{}),
            Status:   s.getRequestPortStatus(),
        },
        // Other ports...
    }
}

func (s *Server) getRequestPortStatus() module.Status {
    if s.isListening {
        return module.Status{
            Label:       "Listening",
            Description: fmt.Sprintf("Port %d", s.currentPort),
            State:       module.StateRunning,
        }
    }
    return module.Status{
        Label:       "Stopped",
        Description: "Not listening",
        State:       module.StateIdle,
    }
}

Triggering Schema Updates

After state changes, trigger UI refresh:

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) error {
    if port == v1alpha1.ControlPort {
        control := msg.(Control)

        if control.Start {
            c.isRunning = true

            // Trigger schema refresh - UI will call Ports() again
            output(context.Background(), v1alpha1.ReconcilePort, nil)

            return c.run(ctx, output)
        }

        if control.Stop {
            c.isRunning = false

            // Trigger schema refresh
            output(context.Background(), v1alpha1.ReconcilePort, nil)
        }
    }
    return nil
}

Conditional Settings

Settings that change based on mode:

go
type Settings struct {
    Mode       string `json:"mode" title:"Mode" enum:"simple,advanced"`
    SimpleOpt  string `json:"simpleOpt,omitempty" title:"Simple Option"`
    AdvancedA  string `json:"advancedA,omitempty" title:"Advanced Option A"`
    AdvancedB  string `json:"advancedB,omitempty" title:"Advanced Option B"`
}

type Component struct {
    settings Settings
}

func (c *Component) Ports() []module.Port {
    // Build settings schema based on current mode
    settingsSchema := c.buildSettingsSchema()

    return []module.Port{
        {
            Name:     v1alpha1.SettingsPort,
            Label:    "Settings",
            Source:   true,
            Position: module.PositionTop,
            Schema:   settingsSchema,
        },
        // Other ports...
    }
}

func (c *Component) buildSettingsSchema() schema.Schema {
    if c.settings.Mode == "advanced" {
        return schema.FromGo(struct {
            Mode      string `json:"mode" title:"Mode" enum:"simple,advanced"`
            AdvancedA string `json:"advancedA" title:"Advanced Option A" required:"true"`
            AdvancedB string `json:"advancedB" title:"Advanced Option B" required:"true"`
        }{})
    }

    return schema.FromGo(struct {
        Mode      string `json:"mode" title:"Mode" enum:"simple,advanced"`
        SimpleOpt string `json:"simpleOpt" title:"Simple Option"`
    }{})
}

Dynamic from External Data

Generate schema from external configuration:

go
type FieldConfig struct {
    Name     string `json:"name"`
    Type     string `json:"type"`
    Required bool   `json:"required"`
}

type Settings struct {
    Fields []FieldConfig `json:"fields" title:"Field Definitions"`
}

type DynamicForm struct {
    settings Settings
}

func (d *DynamicForm) Ports() []module.Port {
    return []module.Port{
        {
            Name:     v1alpha1.SettingsPort,
            Label:    "Settings",
            Source:   true,
            Position: module.PositionTop,
            Schema:   schema.FromGo(Settings{}),
        },
        {
            Name:     "input",
            Label:    "Input",
            Source:   true,
            Position: module.PositionLeft,
            Schema:   d.buildDynamicSchema(),  // Based on field config
        },
        {
            Name:     "output",
            Label:    "Output",
            Source:   false,
            Position: module.PositionRight,
            Schema:   d.buildDynamicSchema(),
        },
    }
}

func (d *DynamicForm) buildDynamicSchema() schema.Schema {
    // Build JSON Schema dynamically from field configuration
    properties := make(map[string]interface{})
    required := []string{}

    for _, field := range d.settings.Fields {
        prop := map[string]interface{}{
            "title": field.Name,
        }

        switch field.Type {
        case "string":
            prop["type"] = "string"
        case "number":
            prop["type"] = "number"
        case "boolean":
            prop["type"] = "boolean"
        }

        properties[field.Name] = prop

        if field.Required {
            required = append(required, field.Name)
        }
    }

    return schema.Schema{
        Type:       "object",
        Properties: properties,
        Required:   required,
    }
}

SchemaSource Configuration

For ports that inherit schemas from data:

go
{
    Name:   "output",
    Label:  "Output",
    Source: false,
    Configuration: module.Configuration{
        SchemaSource: "data",  // Schema comes from "data" field
    },
}

This allows the output schema to mirror whatever data flows through.

Complete Dynamic Router Example

go
package router

import (
    "context"

    "github.com/tiny-systems/module/api/v1alpha1"
    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/pkg/schema"
)

type Settings struct {
    RouteField string   `json:"routeField" title:"Routing Field" default:"type"`
    Routes     []Route  `json:"routes" title:"Routes" minItems:"1"`
}

type Route struct {
    Name      string `json:"name" title:"Route Name" required:"true"`
    Condition string `json:"condition" title:"Match Value" required:"true"`
}

type Message struct {
    Data any `json:"data"`
}

type Router struct {
    settings Settings
}

func (r *Router) GetInfo() module.Info {
    return module.Info{
        Name:        "dynamic-router",
        Description: "Routes messages based on configurable conditions",
        Icon:        "IconSitemap",
        Tags:        []string{"routing"},
    }
}

func (r *Router) Ports() []module.Port {
    ports := []module.Port{
        {
            Name:     v1alpha1.SettingsPort,
            Label:    "Settings",
            Source:   true,
            Position: module.PositionTop,
            Schema:   schema.FromGo(Settings{}),
        },
        {
            Name:     "input",
            Label:    "Input",
            Source:   true,
            Position: module.PositionLeft,
            Schema:   schema.FromGo(Message{}),
        },
        {
            Name:     "default",
            Label:    "No Match",
            Source:   false,
            Position: module.PositionBottom,
            Schema:   schema.FromGo(Message{}),
        },
    }

    // Dynamic output ports from settings
    for _, route := range r.settings.Routes {
        ports = append(ports, module.Port{
            Name:     route.Name,
            Label:    route.Name,
            Source:   false,
            Position: module.PositionRight,
            Schema:   schema.FromGo(Message{}),
            Status: module.Status{
                Description: fmt.Sprintf("Match: %s", route.Condition),
            },
        })
    }

    return ports
}

func (r *Router) Handle(ctx context.Context, output module.Handler, port string, msg any) error {
    switch port {
    case v1alpha1.SettingsPort:
        r.settings = msg.(Settings)
        // Trigger port refresh
        output(context.Background(), v1alpha1.ReconcilePort, nil)
        return nil

    case "input":
        message := msg.(Message)
        return r.route(ctx, output, message)
    }
    return nil
}

func (r *Router) route(ctx context.Context, output module.Handler, msg Message) error {
    data, ok := msg.Data.(map[string]any)
    if !ok {
        return output(ctx, "default", msg)
    }

    value := fmt.Sprintf("%v", data[r.settings.RouteField])

    for _, route := range r.settings.Routes {
        if route.Condition == value {
            return output(ctx, route.Name, msg)
        }
    }

    return output(ctx, "default", msg)
}

func (r *Router) Instance() module.Component {
    return &Router{}
}

Best Practices

1. Cache When Possible

go
type Component struct {
    settings      Settings
    cachedPorts   []module.Port
    portsCacheMu  sync.RWMutex
}

func (c *Component) Ports() []module.Port {
    c.portsCacheMu.RLock()
    if c.cachedPorts != nil {
        defer c.portsCacheMu.RUnlock()
        return c.cachedPorts
    }
    c.portsCacheMu.RUnlock()

    // Build ports
    ports := c.buildPorts()

    c.portsCacheMu.Lock()
    c.cachedPorts = ports
    c.portsCacheMu.Unlock()

    return ports
}

2. Invalidate on Settings Change

go
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) error {
    if port == v1alpha1.SettingsPort {
        c.portsCacheMu.Lock()
        c.settings = msg.(Settings)
        c.cachedPorts = nil  // Invalidate cache
        c.portsCacheMu.Unlock()

        output(context.Background(), v1alpha1.ReconcilePort, nil)
    }
    return nil
}

3. Thread Safety

go
func (c *Component) Ports() []module.Port {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.buildPortsUnsafe()
}

Next Steps

Build flow-based applications on Kubernetes