inquirex 0.5.0 → 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 +119 -0
- data/docs/badges/coverage_badge.svg +2 -2
- data/examples/03_send_email_actions.rb +97 -0
- 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 +30 -4
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module Actions
|
|
5
|
+
# Declarative email effect. Builds a Mail::Message (the object ActionMailer
|
|
6
|
+
# itself wraps) from {{field}} templates and appends it to the outbox.
|
|
7
|
+
# Nothing is delivered — the host application sends the messages.
|
|
8
|
+
#
|
|
9
|
+
# Scalar fields (to, from, cc, bcc, reply_to, subject) and the text body
|
|
10
|
+
# render values verbatim; the html body HTML-escapes every interpolated
|
|
11
|
+
# value automatically. Both bodies given => multipart/alternative.
|
|
12
|
+
#
|
|
13
|
+
# Bodies accept an inline template String or { file: "path" }, which is
|
|
14
|
+
# read once at definition time and inlined — a definition rehydrated from
|
|
15
|
+
# JSON never touches the filesystem.
|
|
16
|
+
#
|
|
17
|
+
# Images: reference external URLs in the html body. Attachments are
|
|
18
|
+
# deliberately unsupported.
|
|
19
|
+
#
|
|
20
|
+
# The mail gem is a soft dependency, required only when a message is
|
|
21
|
+
# actually built. Rails hosts always have it (ActionMailer depends on it).
|
|
22
|
+
class SendEmail < Base
|
|
23
|
+
# Scalar header fields rendered verbatim via Template.render_text in #to_mail and #to_h.
|
|
24
|
+
SCALAR_FIELDS = %i[to from cc bcc reply_to subject].freeze
|
|
25
|
+
|
|
26
|
+
# @return [String] required recipient / subject templates ({{field}} placeholders allowed)
|
|
27
|
+
attr_reader :to, :subject
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] optional address templates ({{field}} placeholders allowed)
|
|
30
|
+
attr_reader :from, :cc, :bcc, :reply_to
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] body template, inlined at definition time when { file: } was given
|
|
33
|
+
attr_reader :text, :html
|
|
34
|
+
|
|
35
|
+
# @return [Hash{String => String}] extra headers (values support {{field}})
|
|
36
|
+
attr_reader :headers
|
|
37
|
+
|
|
38
|
+
# @param to [String] recipient template (required)
|
|
39
|
+
# @param subject [String] subject template (required)
|
|
40
|
+
# @param text [String, Hash, nil] plain-text body template or { file: }
|
|
41
|
+
# @param html [String, Hash, nil] HTML body template or { file: }
|
|
42
|
+
# @param headers [Hash] extra headers (values support {{field}})
|
|
43
|
+
# @raise [Errors::DefinitionError] when required fields are missing
|
|
44
|
+
def initialize(to:, subject:, from: nil, cc: nil, bcc: nil, reply_to: nil,
|
|
45
|
+
text: nil, html: nil, headers: {})
|
|
46
|
+
super()
|
|
47
|
+
@to = to
|
|
48
|
+
@from = from
|
|
49
|
+
@cc = cc
|
|
50
|
+
@bcc = bcc
|
|
51
|
+
@reply_to = reply_to
|
|
52
|
+
@subject = subject
|
|
53
|
+
@text = resolve_body(text)
|
|
54
|
+
@html = resolve_body(html)
|
|
55
|
+
@headers = headers.transform_keys(&:to_s).freeze
|
|
56
|
+
validate!
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds the message and appends it to the outbox.
|
|
61
|
+
#
|
|
62
|
+
# @param answers [Answers] completed answers
|
|
63
|
+
# @param outbox [Outbox] receives the built Mail::Message
|
|
64
|
+
# @return [void]
|
|
65
|
+
def call(answers, outbox)
|
|
66
|
+
outbox.add_message(to_mail(answers))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds a Mail::Message from the templates and the given answers.
|
|
70
|
+
# Pure function — safe to call from a background job to rebuild
|
|
71
|
+
# messages from persisted answers.
|
|
72
|
+
#
|
|
73
|
+
# @param answers [Answers]
|
|
74
|
+
# @return [Mail::Message]
|
|
75
|
+
def to_mail(answers)
|
|
76
|
+
require_mail!
|
|
77
|
+
mail = ::Mail.new
|
|
78
|
+
SCALAR_FIELDS.each do |field|
|
|
79
|
+
value = public_send(field)
|
|
80
|
+
mail.public_send(:"#{field}=", Template.render_text(value, answers)) if value
|
|
81
|
+
end
|
|
82
|
+
@headers.each { |name, value| mail.header[name] = Template.render_text(value.to_s, answers) }
|
|
83
|
+
attach_bodies(mail, answers)
|
|
84
|
+
mail
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash] wire format, same shape .from_h accepts
|
|
88
|
+
def to_h
|
|
89
|
+
hash = { "type" => "send_email" }
|
|
90
|
+
SCALAR_FIELDS.each do |field|
|
|
91
|
+
value = public_send(field)
|
|
92
|
+
hash[field.to_s] = value if value
|
|
93
|
+
end
|
|
94
|
+
hash["text"] = @text if @text
|
|
95
|
+
hash["html"] = @html if @html
|
|
96
|
+
hash["headers"] = @headers unless @headers.empty?
|
|
97
|
+
hash
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param hash [Hash] string or symbol keys
|
|
101
|
+
# @return [SendEmail]
|
|
102
|
+
def self.from_h(hash)
|
|
103
|
+
fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
|
|
104
|
+
new(
|
|
105
|
+
to: fetch.call(:to),
|
|
106
|
+
from: fetch.call(:from),
|
|
107
|
+
cc: fetch.call(:cc),
|
|
108
|
+
bcc: fetch.call(:bcc),
|
|
109
|
+
reply_to: fetch.call(:reply_to),
|
|
110
|
+
subject: fetch.call(:subject),
|
|
111
|
+
text: fetch.call(:text),
|
|
112
|
+
html: fetch.call(:html),
|
|
113
|
+
headers: fetch.call(:headers) || {}
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def attach_bodies(mail, answers)
|
|
120
|
+
text = @text && Template.render_text(@text, answers)
|
|
121
|
+
html = @html && Template.render_html(@html, answers)
|
|
122
|
+
if text && html
|
|
123
|
+
mail.text_part = build_part("text/plain; charset=UTF-8", text)
|
|
124
|
+
mail.html_part = build_part("text/html; charset=UTF-8", html)
|
|
125
|
+
elsif html
|
|
126
|
+
mail.content_type = "text/html; charset=UTF-8"
|
|
127
|
+
mail.body = html
|
|
128
|
+
else
|
|
129
|
+
mail.body = text
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_part(content_type, body)
|
|
134
|
+
part = ::Mail::Part.new
|
|
135
|
+
part.content_type = content_type
|
|
136
|
+
part.body = body
|
|
137
|
+
part
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Inline template string, or { file: "path" } read once at definition time.
|
|
141
|
+
def resolve_body(value)
|
|
142
|
+
return value if value.nil? || value.is_a?(String)
|
|
143
|
+
|
|
144
|
+
path = value.is_a?(Hash) && (value[:file] || value["file"])
|
|
145
|
+
return File.read(File.expand_path(path)) if path.is_a?(String)
|
|
146
|
+
|
|
147
|
+
raise Errors::DefinitionError,
|
|
148
|
+
"send_email body must be a template String or { file: \"path\" }, got #{value.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate!
|
|
152
|
+
raise Errors::DefinitionError, "send_email requires to:" if blank?(@to)
|
|
153
|
+
raise Errors::DefinitionError, "send_email requires subject:" if blank?(@subject)
|
|
154
|
+
return unless @text.nil? && @html.nil?
|
|
155
|
+
|
|
156
|
+
raise Errors::DefinitionError, "send_email requires a text: or html: body"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
160
|
+
|
|
161
|
+
def require_mail!
|
|
162
|
+
return if defined?(::Mail)
|
|
163
|
+
|
|
164
|
+
require "mail"
|
|
165
|
+
rescue LoadError
|
|
166
|
+
raise Errors::ActionError,
|
|
167
|
+
"send_email requires the mail gem — add `gem \"mail\"` to your Gemfile " \
|
|
168
|
+
"(Rails applications already have it via ActionMailer)"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
register(:send_email, SendEmail)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
|
|
5
|
+
module Inquirex
|
|
6
|
+
module Actions
|
|
7
|
+
# Renders {{field}} placeholders against collected answers.
|
|
8
|
+
#
|
|
9
|
+
# Placeholders are dot-notation keys resolved via Answers#to_flat_h
|
|
10
|
+
# ("{{email}}", "{{business.count}}"). The syntax is deliberately inert —
|
|
11
|
+
# no code execution — so flow definitions stored in a database can be
|
|
12
|
+
# rendered server-side safely and a visual editor can preview templates
|
|
13
|
+
# client-side. It matches the {{field}} placeholder convention used by
|
|
14
|
+
# inquirex-llm prompts.
|
|
15
|
+
#
|
|
16
|
+
# Rendering modes:
|
|
17
|
+
# - render_text — values inserted verbatim
|
|
18
|
+
# - render_html — every interpolated value is HTML-escaped
|
|
19
|
+
#
|
|
20
|
+
# The built-in {{answers_summary}} placeholder expands to all collected
|
|
21
|
+
# answers: "key: value" lines in text mode, a simple table in HTML mode.
|
|
22
|
+
# Unknown fields render as empty strings; array values join with ", ".
|
|
23
|
+
module Template
|
|
24
|
+
# Matches one {{field}} placeholder; capture 1 is the dot-notation key.
|
|
25
|
+
PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/
|
|
26
|
+
# Reserved placeholder key that expands to a summary of all collected answers.
|
|
27
|
+
SUMMARY_KEY = "answers_summary"
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param string [String] template with {{field}} placeholders
|
|
32
|
+
# @param answers [Answers]
|
|
33
|
+
# @return [String]
|
|
34
|
+
def render_text(string, answers)
|
|
35
|
+
render(string, answers, html: false)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @example Interpolated values are HTML-escaped
|
|
39
|
+
# answers = Inquirex::Answers.new(name: "Bob & Sons")
|
|
40
|
+
# Inquirex::Actions::Template.render_html("<p>{{name}}</p>", answers)
|
|
41
|
+
# # => "<p>Bob & Sons</p>"
|
|
42
|
+
#
|
|
43
|
+
# @param string [String] template with {{field}} placeholders
|
|
44
|
+
# @param answers [Answers]
|
|
45
|
+
# @return [String]
|
|
46
|
+
def render_html(string, answers)
|
|
47
|
+
render(string, answers, html: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Shared implementation behind render_text and render_html.
|
|
51
|
+
#
|
|
52
|
+
# @param string [String] template with {{field}} placeholders
|
|
53
|
+
# @param answers [Answers]
|
|
54
|
+
# @param html [Boolean] whether to HTML-escape interpolated values
|
|
55
|
+
# @return [String]
|
|
56
|
+
def render(string, answers, html:)
|
|
57
|
+
flat = answers.to_flat_h
|
|
58
|
+
string.gsub(PLACEHOLDER) do
|
|
59
|
+
key = Regexp.last_match(1)
|
|
60
|
+
next(html ? summary_html(flat) : summary_text(flat)) if key == SUMMARY_KEY
|
|
61
|
+
|
|
62
|
+
value = format_value(flat[key])
|
|
63
|
+
html ? CGI.escapeHTML(value) : value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Formats a single answer value for interpolation.
|
|
68
|
+
#
|
|
69
|
+
# @param value [Object] raw answer value (nil renders as "")
|
|
70
|
+
# @return [String] arrays joined with ", ", everything else via #to_s
|
|
71
|
+
def format_value(value)
|
|
72
|
+
value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Expands {{answers_summary}} in text mode.
|
|
76
|
+
#
|
|
77
|
+
# @param flat [Hash{String => Object}] flat answers, dot-notation keys
|
|
78
|
+
# @return [String] one "key: value" line per answer
|
|
79
|
+
def summary_text(flat)
|
|
80
|
+
flat.map { |key, value| "#{key}: #{format_value(value)}" }.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Expands {{answers_summary}} in HTML mode.
|
|
84
|
+
#
|
|
85
|
+
# @param flat [Hash{String => Object}] flat answers, dot-notation keys
|
|
86
|
+
# @return [String] a simple HTML table with one row per answer, fully escaped
|
|
87
|
+
def summary_html(flat)
|
|
88
|
+
rows = flat.map do |key, value|
|
|
89
|
+
"<tr><th>#{CGI.escapeHTML(key)}</th><td>#{CGI.escapeHTML(format_value(value))}</td></tr>"
|
|
90
|
+
end
|
|
91
|
+
%(<table class="inquirex-answers">#{rows.join}</table>)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -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
|