yaml

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 6, 2026 License: MIT Imports: 14 Imported by: 0

README

YAML Configuration Support

The workflow package now supports loading workflow definitions from YAML configuration files, making it easy to define workflows declaratively without writing Go code.

Features

  • Expression-based Guards: Use expr-lang/expr for powerful guard expressions
  • Metadata Support: Add metadata at workflow, place, and transition levels
  • Context Injection: Metadata automatically injected into workflow context
  • History Integration: Configure default notes, actor, and custom fields for transitions
  • Storage Configuration: Define storage settings directly in YAML

Quick Start

1. Create a YAML Configuration
workflow:
  name: blog_publishing
  initial_marking: draft
  
  metadata:
    title: "Blog Publishing Workflow"
    version: "1.0"
  
  transitions:
    - name: to_review
      from: [draft]
      to: [reviewed]
      guard: "workflow.Context('word_count') <= 500 and hasRole('author')"
      notes: "Submitted for review"
      actor: "author"
      custom_fields:
        submission_method: "web"
    
    - name: publish
      from: [reviewed]
      to: [published]
      guard: "hasRole('editor') or hasRole('admin')"
      notes: "Published by editor"
      actor: "editor"

storage:
  type: sqlite
  table: workflow_states
  custom_fields:
    title: "title TEXT"
    author: "author TEXT"
2. Load and Use
package main

import (
    "github.com/ehabterra/workflow"
    "github.com/ehabterra/workflow/yaml"
)

func main() {
    // Load configuration
    config, err := yaml.LoadConfig("workflow.yaml")
    if err != nil {
        panic(err)
    }

    // Create loader
    loader := yaml.NewLoader()

    // Create workflow instance
    wf, err := loader.LoadWorkflow(config, "blog-post-1")
    if err != nil {
        panic(err)
    }

    // Set context for expression evaluation
    wf.SetContext("word_count", 450)
    wf.SetContext("roles", []string{"author"})

    // Apply transition (guard will be evaluated)
    err = wf.Apply([]workflow.Place{"reviewed"})
    if err != nil {
        // Transition blocked by guard
        panic(err)
    }
}

Configuration Reference

Workflow Configuration
workflow:
  name: string              # Required: Workflow name
  initial_marking: <place | [places] | {place: [tokens]}>  # Required: starting marking
  metadata:                 # Optional: Workflow-level metadata (injected into context)
    key: value
  places:                   # Optional: Explicit place definitions
    - name: string
      metadata:             # Optional: Place-level metadata
        key: value
  transitions:             # Required: List of transitions
    - name: string
      from: [string]        # Required: Source places
      to: [string]          # Required: Target places
      guard: string         # Optional: Expression guard
      metadata:             # Optional: Transition metadata
        key: value
      notes: string         # Optional: Default notes for history
      actor: string         # Optional: Default actor for history
      custom_fields:        # Optional: Default custom fields for history
        key: value
Storage Configuration Format
storage:
  type: string              # Required: "sqlite", "postgres", etc.
  table: string             # Optional: Table name (default: "workflow_states")
  id_column: string         # Optional: ID column name (default: "id")
  state_column: string      # Optional: State column name (default: "state")
  custom_fields:            # Optional: Custom field definitions
    field_name: "SQL_TYPE"
  database: string          # Optional: Database connection string
  options:                  # Optional: Additional options
    key: value

Expression Guards

Guards use the expr-lang/expr expression language. Expressions must return a boolean value.

Available Variables
  • workflow - The workflow instance
  • transition - The transition name (string)
  • from - Source places ([]Place)
  • to - Target places ([]Place)
  • All workflow context values are available directly by key
Helper Functions
  • hasRole(role string) bool - Check if workflow has a role
  • hasPermission(permission string) bool - Check if workflow has a permission
  • in(value, list) bool - Check if value is in list
Examples
# Simple role check
guard: "hasRole('admin')"

# Multiple conditions
guard: "workflow.Context('amount') > 1000 and hasRole('manager')"

# Complex logic
guard: "hasRole('editor') or (hasRole('author') and workflow.Context('word_count') <= 500)"

# Using context values
guard: "workflow.Context('status') == 'active' and workflow.Context('priority') > 5"
Custom Environment Builder

You can provide a custom environment builder for expressions:

loader := yaml.NewLoaderWithEnv(func(event workflow.Event) map[string]interface{} {
    env := make(map[string]interface{})
    
    // Add custom variables
    env["user"] = getUserFromContext(event)
    env["request"] = getRequestFromContext(event)
    
    // Add custom functions
    env["isBusinessHours"] = func() bool {
        // Your logic here
        return true
    }
    
    return env
})

Metadata

Workflow Metadata

Workflow-level metadata is automatically injected into the workflow context:

workflow:
  metadata:
    title: "My Workflow"
    version: "1.0"

Access in code:

title, _ := wf.Context("title")  // "My Workflow"

⚠️ Reserved Keys: None. All workflow metadata keys are available for your use.

Place Metadata

Place metadata is stored in context with prefix _place_metadata:

places:
  - name: draft
    metadata:
      max_words: 500

Access in code:

placeMeta, _ := wf.Context("_place_metadata")
metaMap := placeMeta.(map[string]map[string]interface{})
maxWords := metaMap["draft"]["max_words"]

⚠️ Reserved Keys: None. All place metadata keys are available for your use.

Transition Metadata

Transition metadata is available in expressions and can be accessed via the transition object:

transitions:
  - name: publish
    metadata:
      priority: 0.5
      icon: "check-circle"

⚠️ Reserved Keys in Transition Metadata:

The following keys are reserved and used internally by the system. Do not use these keys in your transition metadata section:

  • guard - Used internally to store the guard expression string (for diagram generation)
  • history_notes - Used internally to store default notes (use notes field in YAML instead)
  • history_actor - Used internally to store default actor (use actor field in YAML instead)
  • history_custom_fields - Used internally to store default custom fields (use custom_fields field in YAML instead)

These reserved keys are automatically set from the notes, actor, and custom_fields fields in your YAML configuration. You should use the YAML fields directly, not set them in metadata.

History Integration

Transitions can define default values for history records:

transitions:
  - name: publish
    from: [reviewed]
    to: [published]
    notes: "Published by editor"
    actor: "editor"
    custom_fields:
      publish_time: "now()"  # Resolved to current timestamp
      ip_address: "{{request.ip}}"  # Resolved from context

Template Resolution:

Custom field values support template variables that are resolved at runtime:

  • "now()" - Resolved to current timestamp in RFC3339 format
  • "{{variable}}" - Resolved from context: ctx.Value("variable") or wf.Context("variable")
  • "{{object.property}}" - Nested property access: ctx.Value("object").(map[string]interface{})["property"]
  • Mixed templates - "User: {{user}}" resolves to "User: alice" if user="alice" in context

⚠️ Reserved Keys in History Custom Fields:

The following keys are automatically added to history custom fields and should not be used in your configuration:

  • from_states - Automatically set to array of source places (e.g., ["draft"] or ["qa_testing", "security_review"] for parallel workflows)
  • to_states - Automatically set to array of target places (e.g., ["reviewed"] or ["approved"])

These keys preserve complete state information for parallel workflows where a workflow can be in multiple places simultaneously.

Example with context:

ctx := context.WithValue(context.Background(), "request", map[string]interface{}{
    "ip": "192.168.1.1",
})
ctx = context.WithValue(ctx, "reason", "Insufficient information")

// Template variables in custom_fields will be resolved automatically
yaml.ApplyTransitionWithHistory(wf, []workflow.Place{"rejected"}, historyStore, ctx, "", "", nil)

⚠️ Reserved Context Keys:

The following context keys are used internally and should be avoided:

  • custom_fields - Used internally to merge custom fields from context
  • timestamp - Used internally to set custom CreatedAt timestamp for history records
  • actor - Used as fallback if transition's actor field is empty (you can use this, but prefer setting actor in YAML)

Use the helper function to apply transitions with history:

import "github.com/ehabterra/workflow/yaml"

// Use WithTemplateValue for template resolution
ctx := yaml.WithTemplateValue(context.Background(), "actor", "current-user")
ctx = yaml.WithTemplateValue(ctx, "request", map[string]interface{}{
    "ip": "192.168.1.1",
})

err := yaml.ApplyTransitionWithHistory(
    wf,
    []workflow.Place{"published"},
    historyStore,
    ctx,
    "",  // Override notes (empty = use default)
    "",  // Override actor (empty = use default or context)
    nil, // Override custom fields (nil = use defaults)
)

Recommended: Use yaml.WithTemplateValue() to add values to context for template resolution, as it ensures proper string key handling.

Storage Configuration

Storage configuration uses a factory pattern with pluggable builders. This allows each storage type (SQLite, PostgreSQL, MongoDB, Redis, etc.) to define its own configuration structure.

YAML Configuration

The storage config is generic - each storage type defines its own fields:

# SQLite example
storage:
  type: sqlite
  table: workflow_states
  id_column: id
  state_column: state
  custom_fields:
    title: "title TEXT"
    author: "author TEXT"
  database: ":memory:"

# MongoDB example (would be handled by MongoDB builder)
# storage:
#   type: mongodb
#   collection: workflow_states
#   database: workflow_db
#   custom_fields:
#     - title
#     - author
#   connection:
#     uri: "mongodb://localhost:27017"
Using Storage Factory
import (
    "database/sql"
    "github.com/ehabterra/workflow/yaml"
    _ "github.com/mattn/go-sqlite3"
)

// Register SQLite builder (typically done at app startup)
dbProvider := func() (*sql.DB, error) {
    return sql.Open("sqlite3", "workflow.db")
}
builder := yaml.NewSQLiteStorageBuilder(dbProvider)
yaml.RegisterStorageBuilder(builder)

// Load config and build storage
config, _ := yaml.LoadConfig("workflow.yaml")
store, init, err := yaml.BuildStorage(config.Storage)
if err != nil {
    panic(err)
}

// Initialize storage (e.g., create tables)
if init.InitFunc != nil {
    if err := init.InitFunc(); err != nil {
        panic(err)
    }
}

// Use storage with workflow manager
registry := workflow.NewRegistry()
manager := workflow.NewManager(registry, store)
Creating Custom Storage Builders

To add support for a new storage type, implement the StorageConfigBuilder interface:

type MyStorageBuilder struct{}

func (b *MyStorageBuilder) Type() string {
    return "mystorage"
}

func (b *MyStorageBuilder) Build(config map[string]interface{}) (workflow.Storage, *yaml.StorageInit, error) {
    // Parse config map
    // Create storage instance
    // Return storage and initialization info
    return storage, &yaml.StorageInit{
        Schema: "CREATE TABLE...",
        InitFunc: func() error { /* init logic */ },
    }, nil
}

// Register it
yaml.RegisterStorageBuilder(&MyStorageBuilder{})

This design allows:

  • Extensibility: Add new storage types without modifying core code
  • Type Safety: Each builder validates its own config structure
  • Flexibility: SQL and NoSQL can have completely different configs
  • Loose Coupling: YAML loader doesn't need to know about specific storage types

Complete Example

See example.yaml for a complete example configuration file.

Testing

Run the tests:

go test ./yaml/...

Documentation

Index

Constants

This section is empty.

Variables

View Source
var DefaultFactory = NewStorageFactory()

DefaultFactory is the default global factory instance.

Functions

func ApplyTransitionByNameWithHistory

func ApplyTransitionByNameWithHistory(
	wf *workflow.Workflow,
	transitionName string,
	historyStore history.HistoryStore,
	ctx context.Context,
	overrideNotes string,
	overrideActor string,
	overrideCustomFields map[string]any,
) error

ApplyTransitionByNameWithHistory applies a transition by name and saves history using metadata from the transition. This is the recommended approach for new code as it avoids ambiguity when multiple transitions lead to the same destination.

func ApplyTransitionWithHistory

func ApplyTransitionWithHistory(
	wf *workflow.Workflow,
	to []workflow.Place,
	historyStore history.HistoryStore,
	ctx context.Context,
	overrideNotes string,
	overrideActor string,
	overrideCustomFields map[string]any,
) error

ApplyTransitionWithHistory applies a transition and saves history using metadata from the transition. This is the legacy version that uses target places. For new code, prefer ApplyTransitionByNameWithHistory.

func RegisterStorageBuilder

func RegisterStorageBuilder(builder StorageConfigBuilder)

RegisterStorageBuilder registers a storage builder with the default factory.

func ResolveTemplateValue

func ResolveTemplateValue(value any, ctx context.Context, wf *workflow.Workflow) any

ResolveTemplateValue resolves template variables in custom field values. Supports:

  • "now()" → current timestamp
  • "{{variable}}" → value from context
  • "{{object.property}}" → nested property access
  • Literal strings without templates are returned as-is

func SetupStorageBuilders

func SetupStorageBuilders(storageConfig *StorageConfig, dbProvider func() (*sql.DB, error)) error

SetupStorageBuilders registers storage builders based on the storage configuration. This allows the application to automatically register the required builders without explicitly knowing which storage type is being used.

Currently supports:

  • "sqlite": Registers SQLiteStorageBuilder

If dbProvider is provided, it will be used for SQLite connections. If nil, SQLite builder will create connections from config.

func WithTemplateValue

func WithTemplateValue(ctx context.Context, key string, value any) context.Context

WithTemplateValue stores a value in context using a string key for template resolution. Template resolution requires string keys because template variables like {{reason}} extract the variable name as a string, and Go's context.Value() requires exact type matching.

Note: This function intentionally uses string keys (SA1029 warning) because template resolution extracts variable names as strings from syntax like {{variable}}.

Usage:

ctx = yaml.WithTemplateValue(ctx, "reason", "value")
ctx = yaml.WithTemplateValue(ctx, "request", map[string]any{"ip": "192.168.1.1"})

For non-template context values, you can use custom type keys following Go best practices:

type myKey string
ctx = context.WithValue(ctx, myKey("data"), value)

Types

type Config

type Config struct {
	Workflow WorkflowConfig `yaml:"workflow"`
	Storage  *StorageConfig `yaml:"storage,omitempty"`
}

Config represents the complete YAML workflow configuration.

func LoadConfig

func LoadConfig(filename string) (*Config, error)

LoadConfig loads a workflow configuration from a YAML file.

func LoadConfigFromBytes

func LoadConfigFromBytes(data []byte) (*Config, error)

LoadConfigFromBytes loads a workflow configuration from YAML bytes.

Decoding is strict: any key that is not part of the schema causes an error (reported with its line number) rather than being silently ignored. This prevents typos and not-yet-implemented features from being accepted and then quietly dropped. Colored Petri Net tokens are declared with the polymorphic initial_marking key (see WorkflowConfig.InitialMarking).

func (*Config) Validate

func (c *Config) Validate() error

Validate validates the configuration.

type Connection

type Connection interface {
	// Underlying returns the underlying database connection object.
	// For SQL databases, this returns *sql.DB.
	// For NoSQL databases, this returns the appropriate client (e.g., *mongo.Client, *redis.Client).
	// Returns nil if no connection is needed or available.
	Underlying() any
}

Connection represents a database connection that can be any type (SQL, NoSQL, etc.). The Underlying() method returns the concrete connection object. This allows the system to work with any database type without being tied to SQL.

Example implementations:

  • SQLConnection: wraps *sql.DB for SQL databases (sqlite, postgres, cockroachdb, etc.)
  • MongoDBConnection: wraps *mongo.Client for MongoDB
  • RedisConnection: wraps *redis.Client for Redis
  • DynamoDBConnection: wraps *dynamodb.Client for AWS DynamoDB

type ConnectionProvider

type ConnectionProvider func() (Connection, error)

ConnectionProvider is a function that provides a Connection interface. This allows different storage types to return their appropriate connection type.

func SetupStorageBuildersFromConfig

func SetupStorageBuildersFromConfig(storageConfig *StorageConfig) (ConnectionProvider, error)

SetupStorageBuildersFromConfig automatically sets up storage builders based on the YAML config. It registers the appropriate builders for the storage type.

For SQL databases (sqlite, postgres, cockroachdb, etc.):

  • Returns a ConnectionProvider that returns a SQLConnection wrapping *sql.DB
  • Registers the SQL-specific builder

For NoSQL databases (mongodb, redis, dynamodb, etc.):

  • Returns nil (custom builders should be registered manually)
  • Custom builders can implement their own Connection types

Returns:

  • connectionProvider: For supported databases, a function returning Connection. For others, nil.
  • error: If storage type setup fails

type InitialMarkingConfig

type InitialMarkingConfig struct {
	// Places maps each initial place to its colored tokens (nil = presence token).
	Places map[string][]map[string]any
}

InitialMarkingConfig declares a workflow's starting marking. It accepts three YAML forms, so the simple case stays a one-liner and the Colored Petri Net case is a single coherent construct:

initial_marking: draft                 # one place, an uncolored presence token
initial_marking: [draft, needs_legal]  # several presence places
initial_marking:                       # data-carrying (colored) tokens per place
  pending:
    - {order_id: "001", amount: 100}
    - {order_id: "002", amount: 250}

A place with a nil/empty token list gets a single uncolored presence token (the boolean case); a place with tokens is seeded with exactly those tokens.

func (*InitialMarkingConfig) UnmarshalYAML

func (im *InitialMarkingConfig) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML accepts a scalar place name, a sequence of place names, or a mapping of place name to token list.

type Loader

type Loader struct {
	// EnvBuilder allows customizing the expression evaluation environment
	EnvBuilder func(workflow.Event) map[string]any
}

Loader provides functionality to load workflow definitions from YAML configuration.

func NewLoader

func NewLoader() *Loader

NewLoader creates a new YAML loader.

func NewLoaderWithEnv

func NewLoaderWithEnv(envBuilder func(workflow.Event) map[string]any) *Loader

NewLoaderWithEnv creates a new YAML loader with a custom environment builder for expressions.

func (*Loader) LoadDefinition

func (l *Loader) LoadDefinition(config *Config) (*workflow.Definition, error)

LoadDefinition loads a workflow definition from a YAML configuration.

func (*Loader) LoadWorkflow

func (l *Loader) LoadWorkflow(config *Config, workflowID string) (*workflow.Workflow, error)

LoadWorkflow creates a workflow instance from a YAML configuration.

type PlaceConfig

type PlaceConfig struct {
	Name     string         `yaml:"name"`
	Metadata map[string]any `yaml:"metadata,omitempty"`
}

PlaceConfig defines a place with optional metadata.

type SQLConnection

type SQLConnection struct {
	// contains filtered or unexported fields
}

SQLConnection wraps a SQL database connection (*sql.DB). Use this for SQL databases like SQLite, PostgreSQL, MySQL, CockroachDB, etc.

func (*SQLConnection) DB

func (c *SQLConnection) DB() *sql.DB

DB returns the underlying *sql.DB connection. This provides type-safe access to the database connection.

func (*SQLConnection) Underlying

func (c *SQLConnection) Underlying() any

Underlying returns the underlying *sql.DB connection. This implements the Connection interface for polymorphic use.

type SQLiteStorageBuilder

type SQLiteStorageBuilder struct {
	// DBProvider is a function that provides a *sql.DB instance.
	// This allows the builder to work with existing database connections.
	DBProvider func() (*sql.DB, error)
}

SQLiteStorageBuilder implements StorageConfigBuilder for SQLite storage.

func NewSQLiteStorageBuilder

func NewSQLiteStorageBuilder(dbProvider func() (*sql.DB, error)) *SQLiteStorageBuilder

NewSQLiteStorageBuilder creates a new SQLite storage builder. dbProvider is a function that returns a *sql.DB instance. If nil, the builder will try to create a connection from config.

func (*SQLiteStorageBuilder) Build

func (b *SQLiteStorageBuilder) Build(config map[string]any) (workflow.Storage, *StorageInit, error)

Build creates a SQLite storage instance from YAML configuration.

func (*SQLiteStorageBuilder) Type

func (b *SQLiteStorageBuilder) Type() string

Type returns "sqlite".

type StorageConfig

type StorageConfig struct {
	Type string `yaml:"type"` // "sqlite", "postgres", "mongodb", "redis", etc.

	// Raw configuration data - structure depends on storage type
	// This allows each storage implementation to define its own config structure
	Config map[string]any `yaml:",inline"`
}

StorageConfig defines generic storage configuration. The actual structure is storage-type specific and handled by StorageConfigBuilder implementations. This uses a custom unmarshaler to capture all fields except "type" into the Config map.

func (*StorageConfig) ToMap

func (sc *StorageConfig) ToMap() map[string]any

ToMap converts StorageConfig to a raw map for builder consumption.

func (*StorageConfig) UnmarshalYAML

func (sc *StorageConfig) UnmarshalYAML(unmarshal func(any) error) error

UnmarshalYAML implements custom YAML unmarshaling to capture all fields.

type StorageConfigBuilder

type StorageConfigBuilder interface {
	// Type returns the storage type identifier (e.g., "sqlite", "postgres", "mongodb", "redis")
	Type() string

	// Build creates a workflow.Storage instance from the raw YAML configuration data.
	// The config parameter contains the raw YAML data for the storage section.
	// Returns the Storage instance and any initialization schema/commands needed.
	Build(config map[string]any) (workflow.Storage, *StorageInit, error)
}

StorageConfigBuilder is an interface for building storage instances from YAML configuration. Each storage implementation (SQLite, PostgreSQL, MongoDB, Redis, etc.) should implement this interface.

type StorageFactory

type StorageFactory struct {
	// contains filtered or unexported fields
}

StorageFactory manages storage config builders and creates storage instances from YAML config.

func NewStorageFactory

func NewStorageFactory() *StorageFactory

NewStorageFactory creates a new storage factory.

func (*StorageFactory) Build

Build creates a storage instance from YAML configuration.

func (*StorageFactory) Register

func (f *StorageFactory) Register(builder StorageConfigBuilder)

Register registers a storage config builder.

func (*StorageFactory) RegisteredTypes

func (f *StorageFactory) RegisteredTypes() []string

RegisteredTypes returns a list of registered storage types.

type StorageInit

type StorageInit struct {
	// Schema contains initialization SQL or commands (e.g., CREATE TABLE statements)
	Schema string

	// InitFunc is an optional function to run after schema initialization
	InitFunc func() error
}

StorageInit contains information needed to initialize the storage (e.g., SQL schema).

func BuildStorage

func BuildStorage(config *StorageConfig) (workflow.Storage, *StorageInit, error)

BuildStorage creates a storage instance using the default factory.

type StorageSetupResult

type StorageSetupResult struct {
	Storage      workflow.Storage
	HistoryStore history.HistoryStore
	Connection   Connection // Generic connection interface - can be SQL, MongoDB, Redis, etc.
}

StorageSetupResult contains all the initialized components from storage configuration.

func SetupStorageFromConfig

func SetupStorageFromConfig(storageConfig *StorageConfig) (*StorageSetupResult, error)

SetupStorageFromConfig is a comprehensive helper that: 1. Sets up storage builders based on config 2. Builds and initializes storage 3. Sets up history store (if configured) 4. Returns everything ready to use

Supports both SQL and NoSQL storage types:

  • SQL (sqlite, postgres): Initializes schema, sets up DB connection, supports history store
  • NoSQL (mongodb, redis): Uses custom builders, no SQL schema initialization

type TransitionConfig

type TransitionConfig struct {
	Name         string         `yaml:"name"`
	From         []string       `yaml:"from"`
	To           []string       `yaml:"to"`
	Guard        string         `yaml:"guard,omitempty"`         // Expression string
	After        string         `yaml:"after,omitempty"`         // Timeout duration, e.g. "72h" (Go time.ParseDuration)
	Metadata     map[string]any `yaml:"metadata,omitempty"`      // Transition metadata
	Notes        string         `yaml:"notes,omitempty"`         // Default notes for history
	Actor        string         `yaml:"actor,omitempty"`         // Default actor for history
	CustomFields map[string]any `yaml:"custom_fields,omitempty"` // Default custom fields for history
}

TransitionConfig defines a transition with guards, metadata, and history fields.

type WorkflowConfig

type WorkflowConfig struct {
	Name           string               `yaml:"name"`
	InitialMarking InitialMarkingConfig `yaml:"initial_marking"`
	Metadata       map[string]any       `yaml:"metadata,omitempty"`
	Places         []PlaceConfig        `yaml:"places,omitempty"`
	Transitions    []TransitionConfig   `yaml:"transitions"`
}

WorkflowConfig defines the workflow structure.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL