Skip to content

Inputs - Definitions

Inputs are your task’s public contract: “Here’s what you can pass in, and here’s how we’ll clean it up.” Each line builds a reader method and wires coercion, validation, defaults, transforms, and conditional :if / :unless gates.

Declarations

Order matters

Inputs are resolved top to bottom. If input B reads from input A (as a source: or inside a gate), define A first.

# Good: credentials exists before connection_string looks at it
required :credentials, source: :database_config
input :connection_string, source: :credentials

# Bad: connection_string runs before credentials exists
input :connection_string, source: :credentials
required :credentials, source: :database_config

Name clashes

If an input name would stomp on a Ruby or CMDx method, class loading blows up with CMDx::DefinitionError. Rename with :as, :prefix, or :suffix — see Naming.

Tip

required and optional read nicer than inputs(..., required: …) — they broadcast intent in one word.

Optional

Caller doesn’t have to pass these. If they skip one, you see nil (unless you add a default: elsewhere).

class ScheduleEvent < CMDx::Task
  input :title
  inputs :duration, :location

  # Same as inputs ..., required: false — usually nicer to read
  optional :description
  optional :visibility, :attendees

  def work
    title       #=> "Team Standup"
    duration    #=> 30
    location    #=> nil
    description #=> nil
    visibility  #=> nil
    attendees   #=> ["alice@company.com", "bob@company.com"]
  end
end

ScheduleEvent.execute(
  title: "Team Standup",
  duration: 30,
  attendees: ["alice@company.com", "bob@company.com"]
)

Required

These must show up in the keyword args (or whatever you’re executing with), or the task fails fast.

class PublishArticle < CMDx::Task
  input :title, required: true
  inputs :content, :author_id, required: true

  required :category
  required :status, :tags

  # Sometimes required only in certain situations
  required :publisher, if: :magazine?
  input :approver, required: true, unless: proc { status == :published }

  def work
    title     #=> "Getting Started with Ruby"
    content   #=> "This is a comprehensive guide..."
    author_id #=> 42
    category  #=> "programming"
    status    #=> :published
    tags      #=> ["ruby", "beginner"]
    publisher #=> "Eastbay"
    approver  #=> #<Editor ...>
  end

  private

  def magazine?
    context.title.end_with?("[M]")
  end
end

Note

A “required” input behind a falsy :if / :unless behaves like optional for that run. Coercion, validation, defaults, and transforms still apply when it does run.

Removals

Subclass inherited inputs you don’t want? deregister strips them (and nested children). Always use the original declaration name — not the accessor after :as / :prefix / :suffix:

class ApplicationTask < CMDx::Task
  required :tenant_id
  optional :debug_mode
  required :user_id, as: :customer_id   # reader is customer_id
end

class PublicTask < ApplicationTask
  deregister :input, :tenant_id
  deregister :input, :debug_mode
  deregister :input, :user_id           # still :user_id, not :customer_id

  def work
    # tenant_id, debug_mode, and customer_id are gone
  end
end

Warning

deregister :input, *names removes real inputs only. Typos raise NoMethodError.

Introspection

Want to generate docs or debug? inputs_schema hands back everything CMDx knows:

class CreateUser < CMDx::Task
  required :email, coerce: :string, format: /\A.+@.+\z/
  optional :role, default: "member", inclusion: { in: %w[member admin] }
end

CreateUser.inputs_schema
#=> {
#     email: { name: :email, description: nil, required: true,
#              options: { required: true, coerce: :string, format: /\A.+@.+\z/ },
#              children: [] },
#     role:  { name: :role, ... }
#   }

Each key includes :name (accessor after renaming), :description, :required, raw :options, and nested :children.

Note

:required in the schema is the static flag. Dynamic :if / :unless isn’t evaluated here — read options[:if] / options[:unless] yourself if you document conditionals.

Note

Coercion or validation failure leaves the backing ivar nil, records errors under the accessor name, skips nested children, and fails the run before work.

Sources

By default values come from context. source: lets you read from a method, proc, callable class, or another input you already declared.

Context

The everyday path: fields mirror context:

class BackupDatabase < CMDx::Task
  required :database_name
  optional :compression_level

  input :backup_path, source: :context   # explicit, same default behavior

  def work
    database_name     #=> context.database_name
    backup_path       #=> context.backup_path
    compression_level #=> context.compression_level
  end
end

Symbol references

Point at an instance method — nice when the value needs a little lookup:

class BackupDatabase < CMDx::Task
  inputs :host, :credentials, source: :database_config

  input :connection_string, source: :credentials

  def work
    # ...
  end

  private

  def database_config
    @database_config ||= DatabaseConfig.find(context.database_name)
  end
end

Proc or Lambda

Inline “go get this” logic:

class BackupDatabase < CMDx::Task
  input :timestamp, source: proc { Time.current }
  input :server, source: -> { Current.server }
end

Class or Module

Heavy lifting in a dedicated object:

class DatabaseResolver
  def self.call(task)
    Database.find(task.context.database_name)
  end
end

class BackupDatabase < CMDx::Task
  input :schema, source: DatabaseResolver
  input :metadata, source: DatabaseResolver.new
end

Description

Pure metadata for humans and tools — doesn’t change behavior:

class CreateUser < CMDx::Task
  required :email, description: "The user's primary email address"

  optional :phone, desc: "Primary contact number"   # alias :desc

  inputs :first_name, :last_name, desc: "Part of user's legal name"
end

Nesting

Got a hash-shaped blob? Nest inputs under a parent. Kids read from the parent value (#[], #key?, or methods). They support the usual options except source: — nested fields always come from the parent.

Note

Nested inputs get the full toolkit: renaming, coercion, validation, defaults, and more.

class ConfigureServer < CMDx::Task
  required :network_config do
    required :hostname, :port, :protocol, :subnet
    optional :load_balancer
    input :firewall_rules
  end

  optional :ssl_config do
    required :certificate_path, :private_key
    optional :enable_http2, prefix: true
  end

  input :monitoring do
    required :provider

    optional :alerting do
      required :threshold_percentage
      optional :notification_channel
    end
  end

  def work
    network_config   #=> { hostname: "api.company.com" ... }
    hostname         #=> "api.company.com"
    load_balancer    #=> nil
  end
end

ConfigureServer.execute(
  server_id: "srv-001",
  network_config: {
    hostname: "api.company.com",
    port: 443,
    protocol: "https",
    subnet: "10.0.1.0/24",
    firewall_rules: "allow_web_traffic"
  },
  monitoring: {
    provider: "datadog",
    alerting: {
      threshold_percentage: 85.0,
      notification_channel: "slack"
    }
  }
)

Warning

Rules inside an optional parent only fire when that parent is actually provided. That’s usually what you want.

Error handling

Problems (missing required fields, bad coercion, failed validation) collect on task.errors. When resolution finishes with any errors, the run fails: result.reason is a sentence; result.errors has the structured map.

Note

Nested children resolve only when the parent exists and isn’t nil.

class ConfigureServer < CMDx::Task
  required :server_id, :environment
  required :network_config do
    required :hostname, :port
  end

  def work
    # ...
  end
end

result = ConfigureServer.execute(server_id: "srv-001")

result.state              #=> "interrupted"
result.status             #=> "failed"
result.reason             #=> "environment is required. network_config is required"
result.metadata           #=> {}
result.errors.to_h        #=> {
                          #     environment:    ["is required"],
                          #     network_config: ["is required"]
                          #   }
result.errors.full_messages
#=> {
#     environment:    ["environment is required"],
#     network_config: ["network_config is required"]
#   }

result = ConfigureServer.execute(
  server_id: "srv-001",
  environment: "production",
  network_config: { hostname: "api.company.com" }
)

result.state       #=> "interrupted"
result.status      #=> "failed"
result.reason      #=> "port is required"
result.errors.to_h #=> { port: ["is required"] }