inquirex 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +203 -52
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/01_readme_example.rb +55 -0
- data/examples/02_readme_mermaid.png +0 -0
- data/examples/02_readme_mermaid.rb +36 -0
- data/examples/03_send_email_actions.rb +97 -0
- data/examples/README.md +33 -0
- data/justfile +3 -2
- data/lib/inquirex/accumulator.rb +29 -0
- data/lib/inquirex/actions/action.rb +68 -0
- data/lib/inquirex/actions/base.rb +41 -0
- data/lib/inquirex/actions/custom.rb +31 -0
- data/lib/inquirex/actions/outbox.rb +57 -0
- data/lib/inquirex/actions/runner.rb +52 -0
- data/lib/inquirex/actions/send_email.rb +174 -0
- data/lib/inquirex/actions/template.rb +95 -0
- data/lib/inquirex/actions/webhook.rb +139 -0
- data/lib/inquirex/actions.rb +57 -0
- data/lib/inquirex/answers.rb +13 -0
- data/lib/inquirex/completion_metadata.rb +82 -0
- data/lib/inquirex/definition.rb +67 -5
- data/lib/inquirex/dsl/action_builder.rb +53 -0
- data/lib/inquirex/dsl/flow_builder.rb +49 -6
- data/lib/inquirex/engine/state_serializer.rb +6 -4
- data/lib/inquirex/engine.rb +97 -5
- data/lib/inquirex/errors.rb +5 -0
- data/lib/inquirex/graph/mermaid_exporter.rb +2 -0
- data/lib/inquirex/node.rb +2 -0
- data/lib/inquirex/rules/all.rb +12 -0
- data/lib/inquirex/rules/any.rb +11 -0
- data/lib/inquirex/rules/base.rb +6 -0
- data/lib/inquirex/rules/contains.rb +12 -0
- data/lib/inquirex/rules/equals.rb +12 -0
- data/lib/inquirex/rules/greater_than.rb +12 -0
- data/lib/inquirex/rules/less_than.rb +12 -0
- data/lib/inquirex/rules/not_empty.rb +12 -0
- data/lib/inquirex/validation/adapter.rb +1 -0
- data/lib/inquirex/validation/null_adapter.rb +5 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex/widget_registry.rb +1 -0
- data/lib/inquirex.rb +13 -0
- metadata +35 -5
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
module Actions
|
|
7
|
+
# Declarative webhook effect: POSTs the completed answers as a JSON
|
|
8
|
+
# envelope ({"answers" => {...}}) to a statically-declared URL.
|
|
9
|
+
#
|
|
10
|
+
# Security posture — the URL must be auditable and its host covered by the
|
|
11
|
+
# definition's allowed_domains declaration:
|
|
12
|
+
#
|
|
13
|
+
# - the URL is a literal string; {{field}} templates are rejected so the
|
|
14
|
+
# destination host can never depend on user input
|
|
15
|
+
# - https only; plain http is permitted solely for localhost development
|
|
16
|
+
# - userinfo (https://user@host/) is rejected
|
|
17
|
+
# - redirects are not followed (Net::HTTP does not follow them)
|
|
18
|
+
# - the host check runs in Definition#validate!, which every definition
|
|
19
|
+
# passes through — including JSON-rehydrated ones, so a tampered URL
|
|
20
|
+
# fails at load time, before anything executes
|
|
21
|
+
#
|
|
22
|
+
# A non-2xx response raises Errors::ActionError, which the Runner records
|
|
23
|
+
# as a :failed result without blocking other actions.
|
|
24
|
+
class Webhook < Base
|
|
25
|
+
# Default open/read timeout in seconds for the webhook POST.
|
|
26
|
+
DEFAULT_TIMEOUT = 10
|
|
27
|
+
# Hosts for which plain http is tolerated (local development only).
|
|
28
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :url, :headers, :timeout
|
|
31
|
+
|
|
32
|
+
# @param url [String] literal https URL (no {{field}} templates)
|
|
33
|
+
# @param headers [Hash] extra request headers (literal values)
|
|
34
|
+
# @param timeout [Integer] open/read timeout in seconds
|
|
35
|
+
def initialize(url:, headers: {}, timeout: DEFAULT_TIMEOUT)
|
|
36
|
+
super()
|
|
37
|
+
@url = url.to_s
|
|
38
|
+
@headers = headers.transform_keys(&:to_s).transform_values(&:to_s).freeze
|
|
39
|
+
@timeout = timeout.to_i
|
|
40
|
+
@uri = parse_and_check!(@url)
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [String] lowercase host of the webhook URL
|
|
45
|
+
def host = @uri.host.downcase
|
|
46
|
+
|
|
47
|
+
# POSTs the answers envelope to the declared URL.
|
|
48
|
+
#
|
|
49
|
+
# @param answers [Answers] completed answers
|
|
50
|
+
# @param _outbox [Outbox] unused — webhooks build no messages
|
|
51
|
+
# @return [Net::HTTPResponse] the 2xx response
|
|
52
|
+
# @raise [Errors::ActionError] when the endpoint responds non-2xx
|
|
53
|
+
def call(answers, _outbox)
|
|
54
|
+
require "net/http"
|
|
55
|
+
response = post(answers)
|
|
56
|
+
code = response.code.to_i
|
|
57
|
+
return response if (200..299).cover?(code)
|
|
58
|
+
|
|
59
|
+
raise Errors::ActionError, "webhook #{host} responded with HTTP #{response.code}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Enforced from Definition#validate!.
|
|
63
|
+
#
|
|
64
|
+
# @param definition [Definition] owning definition, source of allowed_domains
|
|
65
|
+
# @return [void]
|
|
66
|
+
# @raise [Errors::DefinitionError] when the host is not allowlisted
|
|
67
|
+
def validate_against(definition)
|
|
68
|
+
return if definition.allowed_host?(host)
|
|
69
|
+
|
|
70
|
+
raise Errors::DefinitionError,
|
|
71
|
+
"webhook url host #{host.inspect} is not covered by allowed_domains " \
|
|
72
|
+
"#{definition.allowed_domains.inspect} — declare it at the top of the definition"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash] wire format, same shape .from_h accepts
|
|
76
|
+
def to_h
|
|
77
|
+
hash = { "type" => "webhook", "url" => @url }
|
|
78
|
+
hash["headers"] = @headers unless @headers.empty?
|
|
79
|
+
hash["timeout"] = @timeout unless @timeout == DEFAULT_TIMEOUT
|
|
80
|
+
hash
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param hash [Hash] string or symbol keys
|
|
84
|
+
# @return [Webhook]
|
|
85
|
+
def self.from_h(hash)
|
|
86
|
+
fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
|
|
87
|
+
new(
|
|
88
|
+
url: fetch.call(:url),
|
|
89
|
+
headers: fetch.call(:headers) || {},
|
|
90
|
+
timeout: fetch.call(:timeout) || DEFAULT_TIMEOUT
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def post(answers)
|
|
97
|
+
request = Net::HTTP::Post.new(@uri)
|
|
98
|
+
request["Content-Type"] = "application/json"
|
|
99
|
+
@headers.each { |name, value| request[name] = value }
|
|
100
|
+
request.body = JSON.generate("answers" => answers.to_h)
|
|
101
|
+
|
|
102
|
+
Net::HTTP.start(
|
|
103
|
+
@uri.host,
|
|
104
|
+
@uri.port,
|
|
105
|
+
use_ssl: @uri.scheme == "https",
|
|
106
|
+
open_timeout: @timeout,
|
|
107
|
+
read_timeout: @timeout
|
|
108
|
+
) { |http| http.request(request) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_and_check!(url)
|
|
112
|
+
if url.include?("{{")
|
|
113
|
+
raise Errors::DefinitionError,
|
|
114
|
+
"webhook url does not support {{field}} templates — the destination must be static"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
uri = parse(url)
|
|
118
|
+
raise Errors::DefinitionError, "webhook url must be http(s): #{url.inspect}" unless uri.is_a?(URI::HTTP)
|
|
119
|
+
raise Errors::DefinitionError, "webhook url must include a host: #{url.inspect}" if uri.host.to_s.empty?
|
|
120
|
+
raise Errors::DefinitionError, "webhook url must not include userinfo: #{url.inspect}" if uri.userinfo
|
|
121
|
+
|
|
122
|
+
if uri.scheme == "http" && !LOCAL_HOSTS.include?(uri.host.downcase)
|
|
123
|
+
raise Errors::DefinitionError,
|
|
124
|
+
"webhook url must use https (plain http is allowed only for localhost): #{url.inspect}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
uri
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse(url)
|
|
131
|
+
URI.parse(url)
|
|
132
|
+
rescue URI::InvalidURIError => e
|
|
133
|
+
raise Errors::DefinitionError, "webhook url is not a valid URL: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
register(:webhook, Webhook)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Post-completion actions: named side-effect declarations that run
|
|
5
|
+
# server-side after a flow finishes, with access to the collected answers.
|
|
6
|
+
#
|
|
7
|
+
# The DSL word `action` groups one or more *effects* (send_email, run, ...).
|
|
8
|
+
# Effects are looked up in a registry keyed by their DSL verb, so new effect
|
|
9
|
+
# types — a webhook, a save_record in inquirex-rails — plug in without core
|
|
10
|
+
# changes: register the class and it gains both the DSL word and JSON wire
|
|
11
|
+
# support.
|
|
12
|
+
#
|
|
13
|
+
# Inquirex::Actions.register(:webhook, MyGem::WebhookEffect)
|
|
14
|
+
#
|
|
15
|
+
# Actions never deliver anything themselves. send_email builds Mail::Message
|
|
16
|
+
# objects into Answers#outbox; the host application decides how to send them.
|
|
17
|
+
module Actions
|
|
18
|
+
@registry = {}
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Registers an effect class under a DSL verb name.
|
|
22
|
+
#
|
|
23
|
+
# @param type [Symbol] DSL verb (e.g. :send_email)
|
|
24
|
+
# @param klass [Class] an Actions::Base subclass
|
|
25
|
+
def register(type, klass)
|
|
26
|
+
@registry[type.to_sym] = klass
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param type [Symbol, String]
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def registered?(type)
|
|
32
|
+
@registry.key?(type.to_sym)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param type [Symbol, String]
|
|
36
|
+
# @return [Class]
|
|
37
|
+
# @raise [Errors::SerializationError] for unknown effect types
|
|
38
|
+
def lookup(type)
|
|
39
|
+
@registry.fetch(type.to_sym) do
|
|
40
|
+
raise Errors::SerializationError, "Unknown action effect type: #{type.inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<Symbol>] registered effect verbs
|
|
45
|
+
def types = @registry.keys
|
|
46
|
+
|
|
47
|
+
# Runs all of the definition's actions against the given answers.
|
|
48
|
+
#
|
|
49
|
+
# @param definition [Definition]
|
|
50
|
+
# @param answers [Answers, Hash]
|
|
51
|
+
# @return [Answers] with #outbox populated
|
|
52
|
+
def run(definition, answers)
|
|
53
|
+
Runner.new(definition).call(answers)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/inquirex/answers.rb
CHANGED
|
@@ -84,10 +84,22 @@ module Inquirex
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# Number of top-level answer keys.
|
|
87
|
+
#
|
|
88
|
+
# @return [Integer]
|
|
87
89
|
def size
|
|
88
90
|
@data.size
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# Mail::Message objects (and result trail) built by post-completion
|
|
94
|
+
# actions. Deliberately excluded from #to_h, #to_flat_h, #to_json and
|
|
95
|
+
# #== — the outbox rides alongside the answer data, never inside it.
|
|
96
|
+
# Delivery is the host application's responsibility.
|
|
97
|
+
#
|
|
98
|
+
# @return [Actions::Outbox]
|
|
99
|
+
def outbox
|
|
100
|
+
@outbox ||= Actions::Outbox.new
|
|
101
|
+
end
|
|
102
|
+
|
|
91
103
|
# Merge another hash or Answers into this one (returns new Answers instance).
|
|
92
104
|
#
|
|
93
105
|
# @param other [Hash, Answers]
|
|
@@ -97,6 +109,7 @@ module Inquirex
|
|
|
97
109
|
Answers.new(@data.merge(other_data))
|
|
98
110
|
end
|
|
99
111
|
|
|
112
|
+
# @return [String] debug representation including the underlying hash
|
|
100
113
|
def inspect
|
|
101
114
|
"#<Inquirex::Answers #{@data.inspect}>"
|
|
102
115
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Inquirex
|
|
7
|
+
# An OpenStruct describing how a flow reached completion: which rendering
|
|
8
|
+
# engine collected the answers, and any environment details that renderer
|
|
9
|
+
# chose to attach (host uname, user, IP addresses, terminal, ...).
|
|
10
|
+
#
|
|
11
|
+
# Only :engine and :engine_version are required members — enforced at
|
|
12
|
+
# construction. Everything else is free-form OpenStruct behavior: unset
|
|
13
|
+
# members read as nil, assignment creates members, nested OpenStructs
|
|
14
|
+
# (e.g. uname) are welcome.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# meta = Inquirex::CompletionMetadata.new(
|
|
18
|
+
# engine: "inquirex-tty", engine_version: "0.5.0",
|
|
19
|
+
# uname: OpenStruct.new(Etc.uname)
|
|
20
|
+
# )
|
|
21
|
+
# meta.engine # => "inquirex-tty"
|
|
22
|
+
# meta.uname.machine # => "arm64"
|
|
23
|
+
# meta.hostname # => nil (unset members read as nil)
|
|
24
|
+
# meta.to_h # => plain nested Hash, JSON-ready
|
|
25
|
+
class CompletionMetadata < OpenStruct
|
|
26
|
+
# Members that must be provided at construction; everything else is optional.
|
|
27
|
+
REQUIRED_MEMBERS = %i[engine engine_version].freeze
|
|
28
|
+
|
|
29
|
+
# Exists solely to make :engine and :engine_version required keywords —
|
|
30
|
+
# OpenStruct itself would accept anything.
|
|
31
|
+
#
|
|
32
|
+
# @param engine [String] rendering front-end name (e.g. "inquirex-tty")
|
|
33
|
+
# @param engine_version [String] rendering front-end version
|
|
34
|
+
# @param extra [Hash] any additional, optional members
|
|
35
|
+
def initialize(engine:, engine_version:, **extra) # rubocop:disable Lint/UselessMethodDefinition
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rebuilds an instance from a hash (e.g. persisted engine state after a
|
|
40
|
+
# JSON round-trip). Keys may be strings or symbols; nested hashes come
|
|
41
|
+
# back as OpenStructs so dot-access survives the round-trip.
|
|
42
|
+
#
|
|
43
|
+
# @param hash [Hash, nil]
|
|
44
|
+
# @return [CompletionMetadata, nil] nil when hash is nil or empty
|
|
45
|
+
# @raise [ArgumentError] when :engine or :engine_version is missing
|
|
46
|
+
def self.from_h(hash)
|
|
47
|
+
return nil if hash.nil? || hash.empty?
|
|
48
|
+
|
|
49
|
+
members = hash.to_h { |k, v| [k.to_sym, v.is_a?(Hash) ? OpenStruct.new(v) : v] }
|
|
50
|
+
new(**members)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# OpenStruct#to_h is shallow; deep-convert nested OpenStructs (uname et
|
|
54
|
+
# al.) so state serialization and JSON output stay plain data.
|
|
55
|
+
#
|
|
56
|
+
# @return [Hash]
|
|
57
|
+
def to_h
|
|
58
|
+
deep_plain(super)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] JSON representation
|
|
62
|
+
def to_json(*)
|
|
63
|
+
JSON.generate(to_h)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String] debug representation of the deep-plain member hash
|
|
67
|
+
def inspect
|
|
68
|
+
"#<Inquirex::CompletionMetadata #{to_h.inspect}>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def deep_plain(value)
|
|
74
|
+
case value
|
|
75
|
+
when OpenStruct then deep_plain(value.to_h)
|
|
76
|
+
when Hash then value.transform_values { |v| deep_plain(v) }
|
|
77
|
+
when Array then value.map { |v| deep_plain(v) }
|
|
78
|
+
else value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/inquirex/definition.rb
CHANGED
|
@@ -13,7 +13,14 @@ module Inquirex
|
|
|
13
13
|
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
14
14
|
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
|
|
15
15
|
class Definition
|
|
16
|
-
attr_reader :id,
|
|
16
|
+
attr_reader :id,
|
|
17
|
+
:version,
|
|
18
|
+
:meta,
|
|
19
|
+
:start_step_id,
|
|
20
|
+
:steps,
|
|
21
|
+
:accumulators,
|
|
22
|
+
:actions,
|
|
23
|
+
:allowed_domains
|
|
17
24
|
|
|
18
25
|
# @param start_step_id [Symbol] id of the initial step
|
|
19
26
|
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
@@ -21,14 +28,20 @@ module Inquirex
|
|
|
21
28
|
# @param version [String] semver
|
|
22
29
|
# @param meta [Hash] frontend metadata
|
|
23
30
|
# @param accumulators [Hash<Symbol, Accumulator>] named running totals
|
|
31
|
+
# @param actions [Array<Actions::Action>] post-completion actions, in order
|
|
32
|
+
# @param allowed_domains [Array<String>] hosts outbound effects (webhook)
|
|
33
|
+
# may send answers to; "example.com" exact, "*.example.com" subdomains
|
|
24
34
|
# @raise [Errors::DefinitionError] if start_step_id is not present in nodes
|
|
25
|
-
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
|
|
35
|
+
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
|
|
36
|
+
accumulators: {}, actions: [], allowed_domains: [])
|
|
26
37
|
@id = id
|
|
27
38
|
@version = version
|
|
28
39
|
@meta = meta.freeze
|
|
29
40
|
@start_step_id = start_step_id.to_sym
|
|
30
41
|
@steps = nodes.freeze
|
|
31
42
|
@accumulators = accumulators.freeze
|
|
43
|
+
@actions = actions.freeze
|
|
44
|
+
@allowed_domains = normalize_domains(allowed_domains)
|
|
32
45
|
validate!
|
|
33
46
|
freeze
|
|
34
47
|
end
|
|
@@ -50,6 +63,25 @@ module Inquirex
|
|
|
50
63
|
@steps.keys
|
|
51
64
|
end
|
|
52
65
|
|
|
66
|
+
# Whether a host is covered by the allowed_domains declaration.
|
|
67
|
+
# "example.com" matches that host exactly; "*.example.com" matches any
|
|
68
|
+
# subdomain but not the apex. Matching is case-insensitive; an empty
|
|
69
|
+
# allowlist allows nothing.
|
|
70
|
+
#
|
|
71
|
+
# @example With allowed_domains ["*.example.com"]
|
|
72
|
+
# definition.allowed_host?("api.example.com") # => true
|
|
73
|
+
# definition.allowed_host?("API.EXAMPLE.COM") # => true (case-insensitive)
|
|
74
|
+
# definition.allowed_host?("example.com") # => false (wildcard excludes the apex)
|
|
75
|
+
#
|
|
76
|
+
# @param host [String]
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def allowed_host?(host)
|
|
79
|
+
target = host.to_s.downcase
|
|
80
|
+
@allowed_domains.any? do |entry|
|
|
81
|
+
entry.start_with?("*.") ? target.end_with?(entry[1..]) : target == entry
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
53
85
|
# Serializes the definition to a JSON string.
|
|
54
86
|
# Lambdas (default procs, compute blocks) are silently stripped.
|
|
55
87
|
#
|
|
@@ -66,6 +98,7 @@ module Inquirex
|
|
|
66
98
|
hash["id"] = @id if @id
|
|
67
99
|
hash["version"] = @version
|
|
68
100
|
hash["meta"] = @meta unless @meta.empty?
|
|
101
|
+
hash["allowed_domains"] = @allowed_domains unless @allowed_domains.empty?
|
|
69
102
|
hash["start"] = @start_step_id.to_s
|
|
70
103
|
unless @accumulators.empty?
|
|
71
104
|
hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
|
|
@@ -73,6 +106,8 @@ module Inquirex
|
|
|
73
106
|
end
|
|
74
107
|
end
|
|
75
108
|
hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
|
|
109
|
+
serializable_actions = @actions.select(&:serializable?)
|
|
110
|
+
hash["actions"] = serializable_actions.map(&:to_h) unless serializable_actions.empty?
|
|
76
111
|
hash
|
|
77
112
|
end
|
|
78
113
|
|
|
@@ -97,6 +132,8 @@ module Inquirex
|
|
|
97
132
|
start = hash["start"] || hash[:start]
|
|
98
133
|
steps_data = hash["steps"] || hash[:steps] || {}
|
|
99
134
|
acc_data = hash["accumulators"] || hash[:accumulators] || {}
|
|
135
|
+
actions_data = hash["actions"] || hash[:actions] || []
|
|
136
|
+
domains = hash["allowed_domains"] || hash[:allowed_domains] || []
|
|
100
137
|
|
|
101
138
|
nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
|
|
102
139
|
sym_id = step_id.to_sym
|
|
@@ -108,15 +145,40 @@ module Inquirex
|
|
|
108
145
|
h[sym] = Accumulator.from_h(sym, entry)
|
|
109
146
|
end
|
|
110
147
|
|
|
111
|
-
|
|
148
|
+
actions = actions_data.map { |entry| Actions::Action.from_h(entry) }
|
|
149
|
+
|
|
150
|
+
new(start_step_id: start,
|
|
151
|
+
nodes:,
|
|
152
|
+
id:,
|
|
153
|
+
version:,
|
|
154
|
+
meta:,
|
|
155
|
+
accumulators:,
|
|
156
|
+
actions:,
|
|
157
|
+
allowed_domains: domains)
|
|
112
158
|
end
|
|
113
159
|
|
|
114
160
|
private
|
|
115
161
|
|
|
116
162
|
def validate!
|
|
117
|
-
|
|
163
|
+
unless @steps.key?(@start_step_id)
|
|
164
|
+
raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
|
|
165
|
+
end
|
|
118
166
|
|
|
119
|
-
|
|
167
|
+
@actions.each do |action|
|
|
168
|
+
action.effects.each { |effect| effect.validate_against(self) }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def normalize_domains(domains)
|
|
173
|
+
domains.map do |entry|
|
|
174
|
+
domain = entry.to_s.strip.downcase
|
|
175
|
+
if domain.empty? || domain == "*" || domain.match?(%r{[/\s:@]})
|
|
176
|
+
raise Errors::DefinitionError,
|
|
177
|
+
"allowed_domains entries must be bare domains like \"example.com\" " \
|
|
178
|
+
"or \"*.example.com\", got #{entry.inspect}"
|
|
179
|
+
end
|
|
180
|
+
domain
|
|
181
|
+
end.freeze
|
|
120
182
|
end
|
|
121
183
|
end
|
|
122
184
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module DSL
|
|
5
|
+
# Builds an Actions::Action from an `action` DSL block. Every effect verb
|
|
6
|
+
# registered in Inquirex::Actions (send_email, plus anything host gems
|
|
7
|
+
# register) is available as a method automatically; `run` wraps arbitrary
|
|
8
|
+
# Ruby in an Actions::Custom effect.
|
|
9
|
+
#
|
|
10
|
+
# action :admin_alert do
|
|
11
|
+
# send_email to: "admin@example.com", subject: "New lead: {{name}}",
|
|
12
|
+
# html: "{{answers_summary}}"
|
|
13
|
+
# run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
|
|
14
|
+
# end
|
|
15
|
+
class ActionBuilder
|
|
16
|
+
def initialize
|
|
17
|
+
@effects = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Escape hatch: arbitrary server-side Ruby. Stripped from JSON.
|
|
21
|
+
#
|
|
22
|
+
# @yield [answers, outbox]
|
|
23
|
+
def run(&block)
|
|
24
|
+
raise Errors::DefinitionError, "run requires a block" unless block
|
|
25
|
+
|
|
26
|
+
@effects << Actions::Custom.new(block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Registered effect verbs (send_email, ...) resolve dynamically so that
|
|
30
|
+
# newly registered effect types become DSL words without core changes.
|
|
31
|
+
def method_missing(name, *args, **params, &)
|
|
32
|
+
return super unless Actions.registered?(name)
|
|
33
|
+
|
|
34
|
+
raise Errors::DefinitionError, "#{name} takes keyword arguments only" unless args.empty?
|
|
35
|
+
|
|
36
|
+
@effects << Actions.lookup(name).new(**params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def respond_to_missing?(name, include_private = false)
|
|
40
|
+
Actions.registered?(name) || super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param id [Symbol]
|
|
44
|
+
# @param rule [Rules::Base, nil]
|
|
45
|
+
# @return [Actions::Action]
|
|
46
|
+
def build(id, rule: nil)
|
|
47
|
+
raise Errors::DefinitionError, "action #{id.inspect} declares no effects" if @effects.empty?
|
|
48
|
+
|
|
49
|
+
Actions::Action.new(id:, effects: @effects, rule:)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -16,6 +16,18 @@ module Inquirex
|
|
|
16
16
|
@nodes = {}
|
|
17
17
|
@meta = {}
|
|
18
18
|
@accumulators = {}
|
|
19
|
+
@actions = []
|
|
20
|
+
@allowed_domains = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Declares the domains outbound effects (webhook) may send answers to.
|
|
24
|
+
# Conventionally the first declaration in a definition, so the flow's
|
|
25
|
+
# egress surface is auditable at a glance. "example.com" matches that
|
|
26
|
+
# host exactly; "*.example.com" matches its subdomains.
|
|
27
|
+
#
|
|
28
|
+
# @param domains [Array<String>]
|
|
29
|
+
def allowed_domains(*domains)
|
|
30
|
+
@allowed_domains.concat(domains.flatten)
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
# Declares a named running total the flow accumulates into as answers come in.
|
|
@@ -104,6 +116,35 @@ module Inquirex
|
|
|
104
116
|
add_step(id, :confirm, &)
|
|
105
117
|
end
|
|
106
118
|
|
|
119
|
+
# Declares a named post-completion action: effects (send_email, run, ...)
|
|
120
|
+
# executed server-side after the flow finishes, with the collected
|
|
121
|
+
# answers. Runs in declaration order; gate with a serializable rule via
|
|
122
|
+
# the if: option.
|
|
123
|
+
#
|
|
124
|
+
# @example Email the collected answers when business income was selected
|
|
125
|
+
# action :notify_sales, if: Rules::Contains.new(:income_types, "Business") do
|
|
126
|
+
# send_email to: "sales@example.com",
|
|
127
|
+
# subject: "New lead: {{name}}",
|
|
128
|
+
# html: "{{answers_summary}}"
|
|
129
|
+
# end
|
|
130
|
+
#
|
|
131
|
+
# @param id [Symbol] action identifier
|
|
132
|
+
# @param opts [Hash] only if: is recognized — a Rules::Base gate
|
|
133
|
+
# @yield block evaluated in ActionBuilder (send_email, run, ...)
|
|
134
|
+
def action(id, **opts, &block)
|
|
135
|
+
rule = opts.delete(:if)
|
|
136
|
+
raise Errors::DefinitionError, "Unknown action options: #{opts.keys.inspect}" unless opts.empty?
|
|
137
|
+
|
|
138
|
+
sym = id.to_sym
|
|
139
|
+
if @actions.any? { |a| a.id == sym }
|
|
140
|
+
raise Errors::DefinitionError, "Duplicate action id: #{sym.inspect}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
builder = ActionBuilder.new
|
|
144
|
+
builder.instance_eval(&block) if block
|
|
145
|
+
@actions << builder.build(sym, rule:)
|
|
146
|
+
end
|
|
147
|
+
|
|
107
148
|
# Produces the frozen Definition.
|
|
108
149
|
#
|
|
109
150
|
# @return [Definition]
|
|
@@ -113,12 +154,14 @@ module Inquirex
|
|
|
113
154
|
raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
|
|
114
155
|
|
|
115
156
|
Definition.new(
|
|
116
|
-
start_step_id:
|
|
117
|
-
nodes:
|
|
118
|
-
id:
|
|
119
|
-
version:
|
|
120
|
-
meta:
|
|
121
|
-
accumulators:
|
|
157
|
+
start_step_id: @start_step_id,
|
|
158
|
+
nodes: @nodes,
|
|
159
|
+
id: @flow_id,
|
|
160
|
+
version: @flow_version,
|
|
161
|
+
meta: @meta,
|
|
162
|
+
accumulators: @accumulators,
|
|
163
|
+
actions: @actions,
|
|
164
|
+
allowed_domains: @allowed_domains
|
|
122
165
|
)
|
|
123
166
|
end
|
|
124
167
|
|
|
@@ -5,11 +5,13 @@ module Inquirex
|
|
|
5
5
|
# Handles state serialization for Engine persistence (DB, session store, etc.).
|
|
6
6
|
# Normalizes string-keyed hashes (from JSON round-trips) to symbol-keyed hashes.
|
|
7
7
|
module StateSerializer
|
|
8
|
+
# Per-key normalizers applied by .symbolize_state; unlisted keys pass through unchanged.
|
|
8
9
|
SYMBOLIZERS = {
|
|
9
|
-
current_step_id:
|
|
10
|
-
history:
|
|
11
|
-
answers:
|
|
12
|
-
totals:
|
|
10
|
+
current_step_id: ->(v) { v&.to_sym },
|
|
11
|
+
history: ->(v) { Array(v).map { |e| e&.to_sym } },
|
|
12
|
+
answers: ->(v) { symbolize_answers(v) },
|
|
13
|
+
totals: ->(v) { symbolize_answers(v) },
|
|
14
|
+
completion_metadata: ->(v) { symbolize_answers(v) }
|
|
13
15
|
}.freeze
|
|
14
16
|
|
|
15
17
|
# Normalizes a state hash so step ids and history entries are symbols.
|