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
- Schema from Go - Struct-based schemas
- Component Patterns - Common patterns
- Settings and Configuration - Settings handling