Skip to content

Latest commit

 

History

History
803 lines (638 loc) · 16.6 KB

signals.livemd

File metadata and controls

803 lines (638 loc) · 16.6 KB

Signals Guide

Mix.install(
  [
    {:lux, "~> 0.4.0"}
    {:kino, "~> 0.14.2"}
  ]
)

Application.ensure_all_started([:ex_unit])

Overview

Signals are the fundamental units of communication in Lux. They provide a type-safe, schema-validated way for components to exchange information.

A Signal consists of:

  • A unique identifier
  • A schema identifier that defines its structure
  • Content that conforms to the schema
  • Metadata about the signal's context and processing

Creating a Signal Schema

Signal schemas define the structure and validation rules for signals:

defmodule MyApp.Schemas.TaskSchema do
  use Lux.SignalSchema,
    name: "task",
    version: "1.0.0",
    description: "Represents a task assignment",
    schema: %{
      type: :object,
      properties: %{
        title: %{type: :string},
        description: %{type: :string},
        priority: %{type: :string, enum: ["low", "medium", "high"]},
        due_date: %{type: :string, format: "date-time"},
        assignee: %{type: :string},
        tags: %{type: :array, items: %{type: :string}}
      },
      required: ["title", "priority", "assignee"]
    },
    tags: ["task", "workflow"],
    compatibility: :full,
    format: :json
end

Kino.nothing()

Creating a Signal

Signals are created by modules that use the Lux.Signal behaviour:

defmodule MyApp.Signals.Task do
  use Lux.Signal,
    schema_id: MyApp.Schemas.TaskSchema
end

Kino.nothing()

When you create a signal with new/1 function, signal module perform validation with schema.

MyApp.Signals.Task.new(%{
  payload: %{
    title: "new signal",
    priority: "low",
    assignee: "agent"
  }
})
{:ok,
 %Lux.Signal{
   id: "8597236a-ee9e-4f24-a05e-a0f86816e2cb",
   payload: %{priority: "low", title: "new signal", assignee: "agent"},
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.873770Z],
   metadata: %{},
   schema_id: MyApp.Schemas.TaskSchema
 }}
MyApp.Signals.Task.new(%{
  payload: %{message: "invalid"}
})
{:error, [{"Required properties title, priority, assignee were not present.", "#"}]}

Signal Validation

Lux uses JSON Schema (Draft 4) for validating signal payloads. This provides a robust, standardized way to ensure your signals conform to their expected structure.

Basic Types

The following basic types are supported:

Null Validation

defmodule NullSchema do
  use Lux.SignalSchema,
    schema: %{type: :null}
end

defmodule NullSignal do
  use Lux.Signal, schema_id: NullSchema
end

NullSignal.new(%{payload: nil})
{:ok,
 %Lux.Signal{
   id: "bb30cfe4-ab2c-4716-9e4e-9ef7419ad021",
   payload: nil,
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.899178Z],
   metadata: %{},
   schema_id: NullSchema
 }}

Boolean Validation

defmodule BooleanSchema do
  use Lux.SignalSchema,
    schema: %{type: :boolean}
end

defmodule BooleanSignal do
  use Lux.Signal, schema_id: BooleanSchema
end

BooleanSignal.new(%{payload: true})
{:ok,
 %Lux.Signal{
   id: "3d26ee2f-f2bc-42bb-89e8-3942079dc1dc",
   payload: true,
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.918708Z],
   metadata: %{},
   schema_id: BooleanSchema
 }}

Integer Vlidation

defmodule IntegerSchema do
  use Lux.SignalSchema,
    schema: %{type: :integer}
end

defmodule IntegerSignal do
  use Lux.Signal, schema_id: IntegerSchema
end

IntegerSignal.new(%{payload: 1})
{:ok,
 %Lux.Signal{
   id: "7adaf40b-e022-4b72-9dd8-9457078e0fde",
   payload: 1,
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.938289Z],
   metadata: %{},
   schema_id: IntegerSchema
 }}

String Validation

defmodule StringSchema do
  use Lux.SignalSchema,
    schema: %{type: :string}
end

defmodule StringSignal do
  use Lux.Signal, schema_id: StringSchema
end

StringSignal.new(%{payload: "payload"})
{:ok,
 %Lux.Signal{
   id: "a6f4b7fa-88fc-47d8-8242-a3d588807d78",
   payload: "payload",
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.958540Z],
   metadata: %{},
   schema_id: StringSchema
 }}

Array Validation

# Array validation
defmodule ArraySchema do
  use Lux.SignalSchema,
    schema: %{
      type: :array,
      items: %{type: :string}  # Validates each array item
    }
end

defmodule ArraySignal do
  use Lux.Signal, schema_id: ArraySchema
end

ArraySignal.new(%{payload: ["data"]})
{:ok,
 %Lux.Signal{
   id: "37d23fea-bf81-43a2-8930-ba0aefbd7ff7",
   payload: ["data"],
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.978059Z],
   metadata: %{},
   schema_id: ArraySchema
 }}

Object Validation

Objects can have nested properties and required fields:

defmodule ComplexObjectSchema do
  use Lux.SignalSchema,
    schema: %{
      type: :object,
      properties: %{
        name: %{type: :string},
        age: %{type: :integer},
        tags: %{
          type: :array,
          items: %{type: :string}
        },
        metadata: %{
          type: :object,
          properties: %{
            created_at: %{type: :string, format: "date-time"},
            priority: %{type: :string, enum: ["low", "medium", "high"]}
          },
          required: ["created_at"]
        }
      },
      required: ["name", "age"]  # Top-level required fields
    }
end

defmodule ComplexObjectSignal do
  use Lux.Signal, schema_id: ComplexObjectSchema
end

ComplexObjectSignal.new(%{payload: %{
  name: "John Doe",
  age: 10,
  tags: ["user"],
  metadata: %{
    created_at: "2024-03-21T17:32:28Z",
  }
}})
{:ok,
 %Lux.Signal{
   id: "134ec6d0-e05f-4142-bc7b-561c70335b3f",
   payload: %{
     name: "John Doe",
     metadata: %{created_at: "2024-03-21T17:32:28Z"},
     tags: ["user"],
     age: 10
   },
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:51.998781Z],
   metadata: %{},
   schema_id: ComplexObjectSchema
 }}

Format Validation

Lux supports the following formats out of the box:

  • date-time: ISO 8601 dates (e.g., "2024-03-21T17:32:28Z")
  • email: Email addresses
  • hostname: Valid hostnames
  • ipv4: IPv4 addresses
  • ipv6: IPv6 addresses

Example:

defmodule UserSchema do
  use Lux.SignalSchema,
    schema: %{
      type: :object,
      properties: %{
        email: %{type: :string, format: "email"},
        last_login: %{type: :string, format: "date-time"},
        server: %{type: :string, format: "hostname"}
      }
    }
end

defmodule UserSignal do
  use Lux.Signal, schema_id: UserSchema
end

UserSignal.new(%{payload: %{
  email: "[email protected]",
  last_login: "2024-03-21T17:32:28Z",
  server: "lux.io"
}})
{:ok,
 %Lux.Signal{
   id: "6bb89ac0-cba3-45fd-a06d-3c3bf0c6bda1",
   payload: %{server: "lux.io", email: "[email protected]", last_login: "2024-03-21T17:32:28Z"},
   sender: nil,
   recipient: nil,
   timestamp: ~U[2025-02-12 12:56:52.026939Z],
   metadata: %{},
   schema_id: UserSchema
 }}

Custom Format Validation

You can add custom format validators in your configuration:

# In config/config.exs
config :ex_json_schema,
  :custom_format_validator,
  fn
    # Validate a custom UUID format
    "uuid", value ->
      case UUID.info(value) do
        {:ok, _} -> true
        {:error, _} -> false
      end
    
    # Return true for unknown formats (as per JSON Schema spec)
    _, _ -> true
  end

Validation Errors

When validation fails, you get detailed error messages:

# Missing required field
{:error, [{"Required property name was not present.", "#"}]}

# Type mismatch
{:error, [{"Type mismatch. Expected Integer but got String.", "#/age"}]}

# Invalid format
{:error, [{"Format validation failed.", "#/email"}]}

# Invalid enum value
{:error, [{"Value not allowed.", "#/metadata/priority"}]}

Best Practices for Schema Validation

  1. Type Safety

    • Always specify types for properties
    • Use appropriate types (e.g., :integer vs :number)
    • Consider using enums for constrained string values
  2. Required Fields

    • Mark essential fields as required
    • Consider the impact on backward compatibility
    • Document why fields are required
  3. Nested Validation

    • Break down complex objects into logical groups
    • Use nested required fields for sub-objects
    • Keep nesting depth reasonable
  4. Format Validation

    • Use built-in formats when possible
    • Create custom formats for domain-specific values
    • Document format requirements
  5. Error Handling

    • Handle validation errors gracefully
    • Provide clear error messages
    • Consider aggregating multiple validation errors
  6. Testing

    • Test both valid and invalid cases
    • Test edge cases and boundary values
    • Test format validation thoroughly
defmodule MyApp.Schemas.TaskSchemaTest do
  use UnitCase, async: true

  alias MyApp.Schemas.TaskSchema

  test "validates required fields" do
    assert {:error, _} = TaskSchema.validate(%Lux.Signal{payload: %{}})
    assert {:error, _} = TaskSchema.validate(%Lux.Signal{payload: %{title: "Test"}})
    assert {:ok, _} = TaskSchema.validate(
      %Lux.Signal{payload: %{title: "Test", priority: "high", assignee: "alice"}}
    )
  end

  test "validates field types" do
    assert {:error, _} = TaskSchema.validate(
      %Lux.Signal{payload: %{title: 123, priority: "high", assignee: "alice"}}
    )
  end

  test "validates enums" do
    assert {:error, _} = TaskSchema.validate(
      %Lux.Signal{payload: %{title: "Test", priority: "invalid", assignee: "alice"}}
    )
  end
end

ExUnit.run()
Running ExUnit with seed: 49621, max_cases: 40

...
Finished in 0.00 seconds (0.00s async, 0.00s sync)
3 tests, 0 failures
%{total: 3, failures: 0, excluded: 0, skipped: 0}

Using Signals

Signals can be created and used in various ways:

# Create a new task signal
{:ok, signal} =
  MyApp.Signals.Task.new(%{
    payload: %{
      title: "Review PR",
      priority: "high",
      assignee: "alice",
      tags: ["github", "code-review"]
    }
  })

signal
%Lux.Signal{
  id: "9c69dae9-fa78-416d-afb2-e94d0cb8cf3a",
  payload: %{
    priority: "high",
    title: "Review PR",
    tags: ["github", "code-review"],
    assignee: "alice"
  },
  sender: nil,
  recipient: nil,
  timestamp: ~U[2025-02-12 12:58:06.802667Z],
  metadata: %{},
  schema_id: MyApp.Schemas.TaskSchema
}

Schema Evolution

Lux supports schema evolution through versioning and compatibility levels:

  • :full - New schema must be fully compatible with old schema
  • :backward - New schema can read old data
  • :forward - Old schema can read new data
  • :none - No compatibility guarantees

Example of schema evolution:

defmodule MyApp.Schemas.TaskSchemaV2 do
  use Lux.SignalSchema,
    name: "task",
    version: "2.0.0",
    description: "Task assignment with status tracking",
    schema: %{
      type: :object,
      properties: %{
        title: %{type: :string},
        description: %{type: :string},
        priority: %{type: :string, enum: ["low", "medium", "high"]},
        due_date: %{type: :string, format: "date-time"},
        assignee: %{type: :string},
        tags: %{type: :array, items: %{type: :string}},
        status: %{type: :string, enum: ["pending", "in_progress", "completed"]},
        progress: %{type: :integer, minimum: 0, maximum: 100}
      },
      required: ["title", "priority", "assignee", "status"]
    },
    compatibility: :backward,
    reference: "v1: MyApp.Schemas.TaskSchema"
end

Kino.nothing()

Best Practices

  1. Schema Design

    • Use semantic versioning for schemas
    • Document schema changes
    • Consider backward compatibility
    • Use appropriate compatibility levels
  2. Validation

    • Validate business rules in validate/1
    • Keep validations focused and specific
    • Return clear error messages
  3. Testing

    • Test schema validation
    • Test business rule validation
    • Test compatibility between versions

Example test:

defmodule MyApp.Signals.TaskTest do
  use UnitCase, async: true

  describe "new/1" do
    test "creates valid task signal" do
      {:ok, signal} =
        MyApp.Signals.Task.new(%{
          payload: %{
            title: "Test Task",
            priority: "high",
            assignee: "bob"
          }
        })

      assert signal.payload.title == "Test Task"
      assert signal.payload.priority == "high"
      assert signal.payload.assignee == "bob"
    end

    test "validates title presence" do
      assert {:error,
              [
                {"Required property title was not present.", "#"}
              ]} =
               MyApp.Signals.Task.new(%{
                 payload: %{
                   priority: "high",
                   assignee: "bob"
                 }
               })
    end
  end
end

ExUnit.run()
Running ExUnit with seed: 49621, max_cases: 40

..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

Advanced Topics

Schema Documentation

Schemas can include rich documentation:

defmodule MyApp.Schemas.DocumentedTaskSchema do
  use Lux.SignalSchema,
    name: "documented_task",
    version: "1.0.0",
    description: """
    Represents a task assignment in the system.
    Tasks are the basic unit of work assignment and tracking.
    """,
    schema: %{
      type: :object,
      properties: %{
        title: %{
          type: :string,
          description: "Short title describing the task",
          examples: ["Review PR #123", "Deploy to production"]
        },
        priority: %{
          type: :string,
          enum: ["low", "medium", "high"],
          description: "Task priority level",
          default: "medium"
        }
      }
    },
    tags: ["task", "workflow"],
    reference: "https://example.com/docs/task-schema"
end
{:module, MyApp.Schemas.DocumentedTaskSchema, <<70, 79, 82, 49, 0, 0, 14, ...>>, :ok}

Custom Validation Rules

You can implement complex validation rules:

defmodule MyApp.Schemas.CustomSchema do
  use Lux.SignalSchema,
    name: "task",
    version: "1.0.0",
    description: "Represents a task assignment",
    schema: %{
      type: :object,
      properties: %{
        due_date: %{type: :string, format: "date-time"},
      }
    },
    tags: ["task", "workflow"],
    compatibility: :full,
    format: :json

  def validate(%{payload: %{due_date: due_date}} = content) do
    with {:ok, parsed_date, _offset} <- DateTime.from_iso8601(due_date),
         :ok <- validate_future_date(parsed_date),
         :ok <- validate_working_hours(parsed_date) do
      {:ok, content}
    end
  end

  defp validate_future_date(date) do
    if DateTime.compare(date, DateTime.utc_now()) == :gt do
      :ok
    else
      {:error, "Due date must be in the future"}
    end
  end

  defp validate_working_hours(date) do
    if date.hour >= 9 and date.hour <= 17 do
      :ok
    else
      {:error, "Due date must be during working hours (9-17)"}
    end
  end
end

defmodule MyApp.Signals.CustomSignal do
  use Lux.Signal,
    schema_id: MyApp.Schemas.CustomSchema
end

MyApp.Signals.CustomSignal.new(%{
  payload: %{
    due_date: "2024-03-21T17:32:28Z"
  }
})
{:error, "Due date must be in the future"}

Signal Metadata

Metadata provides context about the signal's creation and processing:

defmodule MyApp.Signals.MetadataTask do
  use Lux.Signal,
    schema_id: MyApp.Schemas.TaskSchema
end

{:ok, signal} = MyApp.Signals.MetadataTask.new(%{
  payload: %{
    title: "Test Task",
    priority: "high",
    assignee: "bob"
  }
})

signal.metadata
%{}