Skip to content

Commit

Permalink
Better Runtime error detection for bad paths
Browse files Browse the repository at this point in the history
  • Loading branch information
kbrock committed Jul 11, 2024
1 parent 50bc558 commit ced4e5c
Show file tree
Hide file tree
Showing 17 changed files with 175 additions and 55 deletions.
2 changes: 2 additions & 0 deletions lib/floe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ module Floe
class Error < StandardError; end
class InvalidWorkflowError < Error; end
class InvalidExecutionInput < Error; end
class PathError < Error; end
class ExecutionError < Error; end

def self.logger
@logger ||= NullLogger.new
Expand Down
27 changes: 21 additions & 6 deletions lib/floe/validation_mixin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def self.included(base)
end

def parser_error!(comment)
self.class.parser_error!(name, comment)
raise Floe::InvalidWorkflowError, "#{self.class.location_name_str(name)} #{comment}"
end

def missing_field_error!(field_name)
Expand All @@ -18,6 +18,16 @@ def invalid_field_error!(field_name, field_value = nil, comment = nil)
self.class.invalid_field_error!(name, field_name, field_value, comment)
end

def runtime_field_error!(field_name, field_value, comment)
raise Floe::ExecutionError, self.class.field_error_text(name, field_name, field_value, comment)
end

def wrap_runtime_error(field_name, field_value)
yield
rescue Floe::PathError => e
runtime_field_error!(field_name, field_value, e.message)
end

def workflow_state?(field_value, workflow)
workflow.payload["States"] ? workflow.payload["States"].include?(field_value) : true
end
Expand All @@ -29,20 +39,25 @@ def wrap_parser_error(field_name, field_value)
end

module ClassMethods
def parser_error!(name, comment)
name = name.join(".") if name.kind_of?(Array)
raise Floe::InvalidWorkflowError, "#{name} #{comment}"
def location_name_str(name)
name.kind_of?(Array) ? name.join(".") : name
end

# these are here for State.build!

def missing_field_error!(name, field_name)
parser_error!(name, "does not have required field \"#{field_name}\"")
raise Floe::InvalidWorkflowError, "#{location_name_str(name)} does not have required field \"#{field_name}\""
end

def invalid_field_error!(name, field_name, field_value, comment)
raise Floe::InvalidWorkflowError, field_error_text(name, field_name, field_value, comment)
end

def field_error_text(name, field_name, field_value, comment = nil)
# instead of displaying a large hash or array, just displaying the word Hash or Array
field_value = field_value.class if field_value.kind_of?(Hash) || field_value.kind_of?(Array)

parser_error!(name, "field \"#{field_name}\"#{" value \"#{field_value}\"" unless field_value.nil?} #{comment}")
"#{location_name_str(name)} field \"#{field_name}\"#{" value \"#{field_value}\"" unless field_value.nil?} #{comment}"
end
end
end
Expand Down
18 changes: 13 additions & 5 deletions lib/floe/workflow/choice_rule/data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@ class Workflow
class ChoiceRule
class Data < Floe::Workflow::ChoiceRule
def true?(context, input)
return presence_check(context, input) if compare_key == "IsPresent"

lhs = variable_value(context, input)
rhs = compare_value(context, input)

validate!(lhs)

case compare_key
when "IsNull" then is_null?(lhs)
when "IsPresent" then is_present?(lhs)
when "IsNumeric" then is_numeric?(lhs)
when "IsString" then is_string?(lhs)
when "IsBoolean" then is_boolean?(lhs)
Expand Down Expand Up @@ -47,8 +46,17 @@ def true?(context, input)

private

def validate!(value)
raise "No such variable [#{variable}]" if value.nil? && !%w[IsNull IsPresent].include?(compare_key)
def presence_check(context, input)
rhs = compare_value(context, input)
# don't need the value, just need to see if the path finds the value
variable_value(context, input)

# path found the variable_value, (so if they said true, return true)
rhs
rescue Floe::ExecutionError
# variable_value (path) threw an error
# it was not found (so if they said false, return true)
!rhs
end

def is_null?(value) # rubocop:disable Naming/PredicateName
Expand Down
9 changes: 8 additions & 1 deletion lib/floe/workflow/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ def value(context, input = {})
return obj if path == "$"

results = JsonPath.on(obj, path)
results.count < 2 ? results.first : results
case results.count
when 0
raise Floe::PathError, "references an invalid value"
when 1
results.first
else
results
end
end

def to_s
Expand Down
21 changes: 18 additions & 3 deletions lib/floe/workflow/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,35 @@ def wait(context, timeout: nil)
# @return for incomplete Errno::EAGAIN, for completed 0
def run_nonblock!(context)
start(context) unless context.state_started?

return Errno::EAGAIN unless ready?(context)

finish(context)
rescue Floe::ExecutionError => e
# input / output paths were bad
context.next_state = nil
context.output = {"Error" => "States.RuntimeError", "Cause" => e.message}
# finish.super was never called
mark_finished(context)
end

def start(context)
mark_started(context)
end

def finish(context)
mark_finished(context)
end

def mark_started(context)
context.state["EnteredTime"] = Time.now.utc.iso8601

logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...")
end

def finish(context)
finished_time = Time.now.utc
entered_time = Time.parse(context.state["EnteredTime"])
def mark_finished(context)
finished_time = Time.now.utc
entered_time = Time.parse(context.state["EnteredTime"])

context.state["FinishedTime"] ||= finished_time.iso8601
context.state["Duration"] = finished_time - entered_time
Expand Down
6 changes: 4 additions & 2 deletions lib/floe/workflow/states/choice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ def initialize(workflow, name, payload)
end

def finish(context)
input = input_path.value(context, context.input)
output = output_path.value(context, input)
input = wrap_runtime_error("InputPath", input_path.to_s) { input_path.value(context, context.input) }
output = wrap_runtime_error("OutputPath", output_path.to_s) { output_path.value(context, input) }
# For a bad path, throws an ExecutionError
next_state = choices.detect { |choice| choice.true?(context, output) }&.next || default

context.next_state = next_state
context.output = output

super
end

Expand Down
6 changes: 3 additions & 3 deletions lib/floe/workflow/states/fail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Floe
class Workflow
module States
class Fail < Floe::Workflow::State
attr_reader :cause, :error
attr_reader :cause, :error, :cause_path, :error_path

def initialize(workflow, name, payload)
super
Expand All @@ -21,8 +21,8 @@ def finish(context)
# see https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-fail-state.html
# https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html#asl-intrsc-func-generic
context.output = {
"Error" => @error_path ? @error_path.value(context, context.input) : error,
"Cause" => @cause_path ? @cause_path.value(context, context.input) : cause
"Error" => error_path ? wrap_runtime_error("ErrorPath", error_path.to_s) { @error_path.value(context, context.input) } : error,
"Cause" => cause_path ? wrap_runtime_error("CausePath", cause_path.to_s) { @cause_path.value(context, context.input) } : cause
}.compact
super
end
Expand Down
23 changes: 14 additions & 9 deletions lib/floe/workflow/states/input_output_mixin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,30 @@ class Workflow
module States
module InputOutputMixin
def process_input(context)
input = input_path.value(context, context.input)
input = parameters.value(context, input) if parameters
input = wrap_runtime_error("InputPath", input_path.to_s) { input_path.value(context, context.input) }
input = wrap_runtime_error("Parameters", parameters.to_s) { parameters.value(context, input) } if parameters
input
end

def process_output(context, results)
return context.input.dup if results.nil?
return if output_path.nil?
results = context.input.dup if results.nil?

results = result_selector.value(context, results) if @result_selector
if result_path.payload.start_with?("$.Credentials")
credentials = result_path.set(context.credentials, results)["Credentials"]
results = wrap_runtime_error("ResultSelector", @result_selector.to_s) { result_selector.value(context, results) } if @result_selector
if result_path.nil?
output = results
elsif result_path.payload.start_with?("$.Credentials")
credentials = wrap_runtime_error("ResultPath", result_path.to_s) { result_path.set(context.credentials, results)["Credentials"] }
context.credentials.merge!(credentials)
output = context.input.dup
else
output = result_path.set(context.input.dup, results)
output = wrap_runtime_error("ResultPath", result_path.to_s) { result_path.set(context.input.dup, results) }
end

output_path.value(context, output)
if output_path
wrap_runtime_error("OutputPath", output_path.to_s) { output_path.value(context, output) }
else
output
end
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/floe/workflow/states/succeed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ def initialize(workflow, name, payload)
end

def finish(context)
input = input_path.value(context, context.input)
context.output = output_path.value(context, input)
input = wrap_runtime_error("InputPath", input_path.to_s) { input_path.value(context, context.input) }
context.output = wrap_runtime_error("OutputPath", output_path.to_s) { output_path.value(context, input) }
context.next_state = nil

super
Expand Down
2 changes: 1 addition & 1 deletion lib/floe/workflow/states/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def catch_error!(context, error)
return if catcher.nil?

context.next_state = catcher.next
context.output = catcher.result_path.set(context.input, error)
context.output = wrap_runtime_error("ResultPath", result_path.to_s) { catcher.result_path.set(context.input, error) }
logger.info("Running state: [#{long_name}] with input [#{context.json_input}]...CatchError - next state: [#{context.next_state}] output: [#{context.json_output}]")

true
Expand Down
6 changes: 3 additions & 3 deletions lib/floe/workflow/states/wait.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def initialize(workflow, name, payload)
def start(context)
super

input = input_path.value(context, context.input)
input = wrap_runtime_error("InputPath", input_path.to_s) { input_path.value(context, context.input) }

wait_until!(
context,
Expand All @@ -38,8 +38,8 @@ def start(context)
end

def finish(context)
input = input_path.value(context, context.input)
context.output = output_path.value(context, input)
input = wrap_runtime_error("InputPath", input_path.to_s) { input_path.value(context, context.input) }
context.output = wrap_runtime_error("OutputPath", input_path.to_s) { output_path.value(context, input) }
super
end

Expand Down
52 changes: 43 additions & 9 deletions spec/workflow/choice_rule_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
let(:input) { {} }

it "raises an exception" do
expect { subject }.to raise_exception(RuntimeError, "No such variable [$.foo]")
expect { subject }.to raise_exception(Floe::PathError, "references an invalid value")
end
end

Expand All @@ -118,22 +118,51 @@
end

context "with IsPresent" do
let(:payload) { {"Variable" => "$.foo", "IsPresent" => true, "Next" => "FirstMatchState"} }
let(:positive) { true }
let(:payload) { {"Variable" => "$.foo", "IsPresent" => positive, "Next" => "FirstMatchState"} }

context "with null" do
let(:input) { {"foo" => nil} }
it { expect(subject).to eq(true) }
end

it "returns false" do
expect(subject).to eq(false)
end
context "with false" do
let(:input) { {"foo" => "bar"} }
it { expect(subject).to eq(true) }
end

context "with non-null" do
context "with string" do
let(:input) { {"foo" => false} }
it { expect(subject).to eq(true) }
end

context "with missing value" do
let(:input) { {} }
it { expect { subject }.to raise_error(Floe::PathError, "references an invalid value") }
end

context "with null" do
let(:positive) { false }
let(:input) { {"foo" => nil} }
it { expect(subject).to eq(false) }
end

context "with false" do
let(:positive) { false }
let(:input) { {"foo" => "bar"} }
it { expect(subject).to eq(false) }
end

it "returns true" do
expect(subject).to eq(true)
end
context "with string" do
let(:positive) { false }
let(:input) { {"foo" => false} }
it { expect(subject).to eq(false) }
end

context "with missing value" do
let(:positive) { false }
let(:input) { {} }
it { expect { subject }.to raise_error(Floe::PathError, "references an invalid value") }
end
end

Expand Down Expand Up @@ -279,6 +308,11 @@
expect(subject).to eq(false)
end
end

context "with path not found" do
let(:input) { {"foo" => 2} }
it { expect { subject }.to raise_error(Floe::PathError, "references an invalid value") }
end
end

context "with a NumericLessThan" do
Expand Down
4 changes: 2 additions & 2 deletions spec/workflow/intrinsic_function_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -655,8 +655,8 @@
end

it "handles invalid path references" do
result = described_class.value("States.Array($.xxx)", {"context" => {"baz" => "qux"}}, {"input" => {"foo" => "bar"}})
expect(result).to eq([nil])
ctx = {"context" => {"baz" => "qux"}}, {"input" => {"foo" => "bar"}}
expect { described_class.value("States.Array($.xxx)", ctx) }.to raise_error(Floe::PathError, "references an invalid value")
end
end

Expand Down
8 changes: 4 additions & 4 deletions spec/workflow/path_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
describe "#value" do
context "referencing the global context" do
it "with a missing value" do
expect(described_class.new("$$.foo").value({})).to be_nil
expect { described_class.new("$$.foo").value({}, {"foo" => "bar"}) }.to raise_error(Floe::PathError, "references an invalid value")
end

it "with a single value" do
expect(described_class.new("$$.foo").value({"foo" => "bar"})).to eq("bar")
expect(described_class.new("$$.foo").value({"foo" => "bar"}, {})).to eq("bar")
end

it "with a nested hash" do
expect(described_class.new("$$.foo.bar").value({"foo" => {"bar" => "baz"}})).to eq("baz")
expect(described_class.new("$$.foo.bar").value({"foo" => {"bar" => "baz"}}, {})).to eq("baz")
end

it "with an array" do
Expand All @@ -33,7 +33,7 @@

context "referencing the inputs" do
it "with a missing value" do
expect(described_class.new("$.foo").value({"foo" => "bar"})).to be_nil
expect { described_class.new("$.foo").value({"foo" => "bar"}, {}) }.to raise_error(Floe::PathError, "references an invalid value")
end

it "with a single value" do
Expand Down
Loading

0 comments on commit ced4e5c

Please sign in to comment.