statifier 0.0.1 → 0.1.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 +35 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +601 -16
- data/lib/statifier/accepts.rb +167 -0
- data/lib/statifier/basichttp/decode.rb +142 -0
- data/lib/statifier/basichttp/encoding.rb +224 -0
- data/lib/statifier/basichttp/net_http_transport.rb +157 -0
- data/lib/statifier/basichttp/outbound.rb +87 -0
- data/lib/statifier/basichttp/processor.rb +170 -0
- data/lib/statifier/basichttp/request.rb +94 -0
- data/lib/statifier/basichttp/response.rb +34 -0
- data/lib/statifier/basichttp.rb +47 -0
- data/lib/statifier/compiler/content.rb +131 -0
- data/lib/statifier/compiler/error.rb +30 -0
- data/lib/statifier/compiler/expressions.rb +64 -0
- data/lib/statifier/compiler/numbering.rb +145 -0
- data/lib/statifier/compiler/parts.rb +144 -0
- data/lib/statifier/compiler/walk.rb +61 -0
- data/lib/statifier/compiler.rb +82 -0
- data/lib/statifier/datamodel/data_binding.rb +114 -0
- data/lib/statifier/datamodel/delay.rb +54 -0
- data/lib/statifier/datamodel/location.rb +145 -0
- data/lib/statifier/datamodel/program.rb +82 -0
- data/lib/statifier/datamodel/reason_value.rb +42 -0
- data/lib/statifier/datamodel/reasons.rb +88 -0
- data/lib/statifier/datamodel/stores.rb +169 -0
- data/lib/statifier/datamodel/system_variables.rb +69 -0
- data/lib/statifier/datamodel.rb +120 -0
- data/lib/statifier/document/data.rb +36 -0
- data/lib/statifier/document/executable.rb +69 -0
- data/lib/statifier/document/invoke.rb +17 -0
- data/lib/statifier/document/states.rb +40 -0
- data/lib/statifier/document.rb +51 -0
- data/lib/statifier/driver/calls.rb +183 -0
- data/lib/statifier/driver/checks.rb +110 -0
- data/lib/statifier/driver/delivery.rb +45 -0
- data/lib/statifier/driver/invocations.rb +129 -0
- data/lib/statifier/driver/mailbox.rb +120 -0
- data/lib/statifier/driver/routing.rb +193 -0
- data/lib/statifier/driver/session_id.rb +40 -0
- data/lib/statifier/driver/work.rb +196 -0
- data/lib/statifier/driver.rb +205 -0
- data/lib/statifier/effect.rb +208 -0
- data/lib/statifier/event.rb +67 -0
- data/lib/statifier/frozen.rb +22 -0
- data/lib/statifier/interpreter/blocks.rb +142 -0
- data/lib/statifier/interpreter/content.rb +228 -0
- data/lib/statifier/interpreter/entering.rb +141 -0
- data/lib/statifier/interpreter/exit_entry.rb +174 -0
- data/lib/statifier/interpreter/foreach.rb +83 -0
- data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
- data/lib/statifier/interpreter/invoke/passes.rb +143 -0
- data/lib/statifier/interpreter/invoke.rb +145 -0
- data/lib/statifier/interpreter/loop.rb +170 -0
- data/lib/statifier/interpreter/machine_state.rb +152 -0
- data/lib/statifier/interpreter/macrostep.rb +185 -0
- data/lib/statifier/interpreter/name_match.rb +43 -0
- data/lib/statifier/interpreter/selection.rb +221 -0
- data/lib/statifier/interpreter/send.rb +223 -0
- data/lib/statifier/interpreter/step.rb +138 -0
- data/lib/statifier/interpreter/stop.rb +66 -0
- data/lib/statifier/interpreter/targets.rb +107 -0
- data/lib/statifier/interpreter.rb +57 -0
- data/lib/statifier/lowering/branches.rb +84 -0
- data/lib/statifier/lowering/data.rb +102 -0
- data/lib/statifier/lowering/error.rb +102 -0
- data/lib/statifier/lowering/executable.rb +106 -0
- data/lib/statifier/lowering/placement.rb +65 -0
- data/lib/statifier/lowering/reading.rb +53 -0
- data/lib/statifier/lowering/states.rb +111 -0
- data/lib/statifier/lowering.rb +129 -0
- data/lib/statifier/machine/content.rb +81 -0
- data/lib/statifier/machine/data.rb +42 -0
- data/lib/statifier/machine/states.rb +56 -0
- data/lib/statifier/machine/values.rb +37 -0
- data/lib/statifier/machine.rb +103 -0
- data/lib/statifier/position/rebuild.rb +84 -0
- data/lib/statifier/position/shape.rb +73 -0
- data/lib/statifier/position/text.rb +43 -0
- data/lib/statifier/position.rb +206 -0
- data/lib/statifier/validator/attributes.rb +35 -0
- data/lib/statifier/validator/context.rb +74 -0
- data/lib/statifier/validator/data.rb +136 -0
- data/lib/statifier/validator/error.rb +90 -0
- data/lib/statifier/validator/executable.rb +139 -0
- data/lib/statifier/validator/history.rb +81 -0
- data/lib/statifier/validator/messages.rb +142 -0
- data/lib/statifier/validator/states.rb +161 -0
- data/lib/statifier/validator.rb +94 -0
- data/lib/statifier/version.rb +4 -1
- data/lib/statifier/xml/character_data.rb +142 -0
- data/lib/statifier/xml/cursor.rb +131 -0
- data/lib/statifier/xml/markup.rb +153 -0
- data/lib/statifier/xml/nodes.rb +50 -0
- data/lib/statifier/xml/parser.rb +121 -0
- data/lib/statifier/xml/scope.rb +46 -0
- data/lib/statifier/xml/tags.rb +112 -0
- data/lib/statifier/xml.rb +85 -0
- data/lib/statifier.rb +386 -2
- metadata +129 -20
- data/.gitignore +0 -8
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -33
- data/Rakefile +0 -10
- data/bin/console +0 -8
- data/bin/setup +0 -6
- data/statifier.gemspec +0 -34
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Statifier
|
|
7
|
+
module BasicHTTP
|
|
8
|
+
# The default transport, on the standard library's Net::HTTP (the
|
|
9
|
+
# `net-http` default gem), so it adds no runtime dependency.
|
|
10
|
+
#
|
|
11
|
+
# Each {#call} makes one request once, on a connection of its own that
|
|
12
|
+
# it opens and closes, and answers the {Response}, whatever its status;
|
|
13
|
+
# retrying is not a transport's job. It raises a {TransportError} when no
|
|
14
|
+
# response came back: a URL that is not an absolute `http` or `https` URL
|
|
15
|
+
# with a host (no connection is then opened); any error raised while it
|
|
16
|
+
# connects, sends or reads, a host's `connect` included, which is then
|
|
17
|
+
# the error's `cause`; or a response it cannot build a {Response} from:
|
|
18
|
+
# a status line outside 100 to 599, a header name that is not an HTTP
|
|
19
|
+
# token, or a header value holding a carriage return, a line feed or a
|
|
20
|
+
# NUL.
|
|
21
|
+
#
|
|
22
|
+
# Net::HTTP adds the headers it always adds (`host`, `content-length`
|
|
23
|
+
# (`0` for an empty body, a GET included), and the `accept-encoding`,
|
|
24
|
+
# `accept` and `user-agent` defaults) unless the
|
|
25
|
+
# request names them, and a response body it decodes from `gzip` or
|
|
26
|
+
# `deflate` is answered decoded. Two headers of one name in a request are
|
|
27
|
+
# sent as one header, their values joined by a comma.
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# transport = Statifier::BasicHTTP::NetHTTPTransport.new(timeout: 2)
|
|
31
|
+
# request = Statifier::BasicHTTP::Request.new(http_method: "POST", url: "http://localhost:8080/s1", body: "e.go")
|
|
32
|
+
# begin
|
|
33
|
+
# transport.call(request).status # => 200
|
|
34
|
+
# rescue Statifier::BasicHTTP::TransportError => e
|
|
35
|
+
# e.message # why no response came back
|
|
36
|
+
# end
|
|
37
|
+
class NetHTTPTransport
|
|
38
|
+
# How long, in seconds, each of opening the connection, writing the
|
|
39
|
+
# request and reading the response may take when the host names no
|
|
40
|
+
# bound: the reference's five seconds.
|
|
41
|
+
DEFAULT_TIMEOUT = 5
|
|
42
|
+
|
|
43
|
+
# The connection the default transport opens for `uri`: a Net::HTTP
|
|
44
|
+
# object, not yet started, for its host and port, using TLS for an
|
|
45
|
+
# `https` URL (with Net::HTTP's default, the peer verified against the
|
|
46
|
+
# default certificate store), each of its open, TLS handshake, write
|
|
47
|
+
# and read bounds `timeout` seconds. A host's own `connect:` callable
|
|
48
|
+
# may call this and change the answer before it returns it (a proxy, a
|
|
49
|
+
# certificate file).
|
|
50
|
+
#
|
|
51
|
+
# @param uri [URI::HTTP] the URL requested
|
|
52
|
+
# @param timeout [Numeric] the bound on each step, in seconds
|
|
53
|
+
# @return [Net::HTTP] the connection, not started
|
|
54
|
+
def self.connection(uri, timeout: DEFAULT_TIMEOUT)
|
|
55
|
+
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
|
56
|
+
http.use_ssl = uri.scheme == "https"
|
|
57
|
+
http.open_timeout = timeout
|
|
58
|
+
http.ssl_timeout = timeout
|
|
59
|
+
http.write_timeout = timeout
|
|
60
|
+
http.read_timeout = timeout
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param timeout [Numeric] the bound, in seconds, on each of opening
|
|
65
|
+
# the connection, writing the request and reading the response;
|
|
66
|
+
# {DEFAULT_TIMEOUT} when absent
|
|
67
|
+
# @param connect [#call, nil] answers the connection for a URL, called
|
|
68
|
+
# with the request's `URI::HTTP`; the connection answers `start`
|
|
69
|
+
# with a block, yielding something that answers `request` with a
|
|
70
|
+
# `Net::HTTPGenericRequest` and returns its `Net::HTTPResponse`, as
|
|
71
|
+
# a Net::HTTP object does. {connection} with `timeout` when nil; a
|
|
72
|
+
# host's own sets its own bounds, so the two are not given together
|
|
73
|
+
# @raise [ArgumentError] for a timeout that is not a positive finite
|
|
74
|
+
# number, a `connect` that does not answer `call`, or both given
|
|
75
|
+
def initialize(timeout: nil, connect: nil)
|
|
76
|
+
raise ArgumentError, "give timeout or connect, not both" if timeout && connect
|
|
77
|
+
raise ArgumentError, "expected connect to answer call" unless connect.nil? || connect.respond_to?(:call)
|
|
78
|
+
|
|
79
|
+
@timeout = timeout.nil? ? DEFAULT_TIMEOUT : checked_timeout(timeout)
|
|
80
|
+
@connect = connect
|
|
81
|
+
freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The connection a {#call} for `uri` opens, not yet started: what the
|
|
85
|
+
# host's `connect` answers for it, or {connection} with this
|
|
86
|
+
# transport's timeout when the host gave none.
|
|
87
|
+
#
|
|
88
|
+
# @param uri [URI::HTTP] the URL requested
|
|
89
|
+
# @return [Net::HTTP, Object] the connection
|
|
90
|
+
def connect(uri) = @connect ? @connect.call(uri) : self.class.connection(uri, timeout: @timeout)
|
|
91
|
+
|
|
92
|
+
# Makes `request` once and answers what came back.
|
|
93
|
+
#
|
|
94
|
+
# @param request [Request] the request
|
|
95
|
+
# @return [Response] the response, whatever its status
|
|
96
|
+
# @raise [TransportError] when no response came back
|
|
97
|
+
# @raise [TypeError] for a request that is not a {Request}
|
|
98
|
+
def call(request)
|
|
99
|
+
raise TypeError, "expected a #{Request}, got #{request.class}" unless request.is_a?(Request)
|
|
100
|
+
|
|
101
|
+
make(request)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# The request made, every error but a {TransportError} raised as one
|
|
107
|
+
# whose `cause` it is.
|
|
108
|
+
def make(request)
|
|
109
|
+
uri = target(request.url)
|
|
110
|
+
response(exchange(uri, net_request(request, uri)))
|
|
111
|
+
rescue TransportError
|
|
112
|
+
raise
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
raise TransportError, "#{e.class}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def checked_timeout(timeout)
|
|
118
|
+
return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.positive? && timeout.to_f.finite?
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "expected timeout to be a positive finite number of seconds, got #{timeout.inspect}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The request's URL as an absolute `http` or `https` URI with a host.
|
|
124
|
+
def target(url)
|
|
125
|
+
uri = URI.parse(url)
|
|
126
|
+
return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
127
|
+
|
|
128
|
+
raise TransportError, "not an absolute http or https URL: #{url}"
|
|
129
|
+
rescue URI::InvalidURIError => e
|
|
130
|
+
raise TransportError, "not a URL: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# `request` as Net::HTTP's request for `uri`: the first header of a
|
|
134
|
+
# name replaces Net::HTTP's default of that name, and a later one is
|
|
135
|
+
# added to it.
|
|
136
|
+
def net_request(request, uri)
|
|
137
|
+
net = Net::HTTPGenericRequest.new(request.http_method, true, request.http_method != "HEAD", uri.request_uri)
|
|
138
|
+
named = {}
|
|
139
|
+
request.headers.each do |name, value|
|
|
140
|
+
named.key?(name.downcase) ? net.add_field(name, value) : net[name] = value
|
|
141
|
+
named[name.downcase] = true
|
|
142
|
+
end
|
|
143
|
+
net.body = request.body
|
|
144
|
+
net
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def exchange(uri, net_request)
|
|
148
|
+
connect(uri).start { |http| http.request(net_request) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def response(net_response)
|
|
152
|
+
headers = net_response.to_hash.flat_map { |name, values| values.map { [name, _1] } }
|
|
153
|
+
Response.new(status: Integer(net_response.code, 10), headers:, body: net_response.body.to_s)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module BasicHTTP
|
|
5
|
+
# The request each send the {Processor} is handed becomes, and the
|
|
6
|
+
# send's deduplication key, built as the reference's processor builds
|
|
7
|
+
# them (the {Processor} says how).
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Outbound
|
|
11
|
+
# The form parameter that carries the event name.
|
|
12
|
+
EVENT_NAME_PARAM = "_scxmleventname"
|
|
13
|
+
|
|
14
|
+
# The content type of a form body.
|
|
15
|
+
FORM = "application/x-www-form-urlencoded"
|
|
16
|
+
|
|
17
|
+
# The header that carries the send's deduplication key.
|
|
18
|
+
SEND_KEY_HEADER = "scxml-send-key"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The request one send becomes ({Processor} says how).
|
|
23
|
+
#
|
|
24
|
+
# @param session_id [String] the sending session's id
|
|
25
|
+
# @param send [Statifier::Effect::Send, Statifier::Effect::SendDelayed]
|
|
26
|
+
# a send whose target is a String
|
|
27
|
+
# @return [Request]
|
|
28
|
+
def request(session_id, send)
|
|
29
|
+
url, content_type, body = message(send)
|
|
30
|
+
Request.new(http_method: "POST", url:, headers: [["content-type", content_type],
|
|
31
|
+
[SEND_KEY_HEADER, send_key(session_id, send)]], body:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The URL, the content type and the body of the send's request.
|
|
35
|
+
def message(send)
|
|
36
|
+
named = send.event ? [[EVENT_NAME_PARAM, Encoding.value(send.event)]] : []
|
|
37
|
+
data = send.data
|
|
38
|
+
if data.is_a?(Hash)
|
|
39
|
+
[send.target, FORM, Encoding.form(named + fields(data))]
|
|
40
|
+
elsif data.equal?(Predicator::UNDEFINED)
|
|
41
|
+
[send.target, FORM, Encoding.form(named)]
|
|
42
|
+
else
|
|
43
|
+
[with_query(send.target, named), "text/plain", Encoding.value(data)]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A Hash's names and values as form fields, in the byte order of the
|
|
48
|
+
# names.
|
|
49
|
+
def fields(data)
|
|
50
|
+
data.map { |name, value| [Encoding.value(name), Encoding.value(value)] }.sort_by { _1.first.b }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A send's deduplication key, as the `scxml-send-key` header carries
|
|
54
|
+
# it and the reference spells it: eight fields joined by `/`, the
|
|
55
|
+
# session's id and the send id escaped ({Encoding.field}), the
|
|
56
|
+
# macrostep, microstep, round and content index in decimal, the owner
|
|
57
|
+
# (`onentry.S.B`, `onexit.S.B`, `finalize.S.B` with the state's index
|
|
58
|
+
# and the block's, or `transition.T`) and the ordinal; a field the send
|
|
59
|
+
# does not carry is empty.
|
|
60
|
+
#
|
|
61
|
+
# @param session_id [String] the sending session's id
|
|
62
|
+
# @param send [Statifier::Effect::Send, Statifier::Effect::SendDelayed]
|
|
63
|
+
# @return [String]
|
|
64
|
+
def send_key(session_id, send)
|
|
65
|
+
[Encoding.field(session_id), Encoding.field(send.send_id), send.macrostep, send.microstep, send.round,
|
|
66
|
+
send.c_index, owner(send.owner), send.ordinal].join("/")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A content owner as the key spells it.
|
|
70
|
+
def owner(owner)
|
|
71
|
+
case owner&.fetch(:kind)
|
|
72
|
+
when :onentry, :onexit then "#{owner[:kind]}.#{owner[:state_index]}.#{owner[:ordinal]}"
|
|
73
|
+
when :finalize then "finalize.#{owner[:state_index]}.#{owner[:invoke_index]}"
|
|
74
|
+
when :transition then "transition.#{owner[:t_index]}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `url` with the pairs appended as a query string, after `&` when it
|
|
79
|
+
# already carries one.
|
|
80
|
+
def with_query(url, pairs)
|
|
81
|
+
return url if pairs.empty?
|
|
82
|
+
|
|
83
|
+
"#{url}#{url.include?("?") ? "&" : "?"}#{Encoding.form(pairs)}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module BasicHTTP
|
|
5
|
+
# A send the processor could not deliver, as it hands it to the host's
|
|
6
|
+
# `report:` callable: the host passes `effect` and `reason` to
|
|
7
|
+
# {Statifier.report_send_failed} with the state it holds for
|
|
8
|
+
# `session_id`.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] session_id
|
|
11
|
+
# @return [String] the sending session's id
|
|
12
|
+
# @!attribute [r] effect
|
|
13
|
+
# @return [Statifier::Effect::Send, Statifier::Effect::SendDelayed] the
|
|
14
|
+
# send the processor was handed
|
|
15
|
+
# @!attribute [r] reason
|
|
16
|
+
# @return [Symbol] why: `:http_status` (a response outside 2xx),
|
|
17
|
+
# `:transport_error` (the transport raised), `:invalid_target` (a
|
|
18
|
+
# target that is not a String) or `:no_timer` (a delayed send, which
|
|
19
|
+
# this processor holds no timer for)
|
|
20
|
+
# @!attribute [r] detail
|
|
21
|
+
# @return [Object, nil] the {Response} for `:http_status`; for
|
|
22
|
+
# `:transport_error`, the error the transport raised, or a TypeError
|
|
23
|
+
# when it answered something that is not a {Response}; the target
|
|
24
|
+
# for `:invalid_target`; nil for `:no_timer`
|
|
25
|
+
Failure = ::Data.define(:session_id, :effect, :reason, :detail)
|
|
26
|
+
|
|
27
|
+
# The Basic HTTP Event I/O Processor (SCXML appendix C.2), outbound, as
|
|
28
|
+
# the reference's `Statifier.Send.BasicHTTP` is: a processor a host
|
|
29
|
+
# registers like any other, under {EVENT_PROCESSOR} and its short form
|
|
30
|
+
# {SHORT_TYPE} ({#send_types} names it under both).
|
|
31
|
+
#
|
|
32
|
+
# Each send it is handed becomes one POST, made once through the
|
|
33
|
+
# transport while `deliver` runs. The request is built from the send's
|
|
34
|
+
# data, as the reference builds it:
|
|
35
|
+
#
|
|
36
|
+
# - a Hash is a form body (`application/x-www-form-urlencoded`): the
|
|
37
|
+
# event name as the `_scxmleventname` parameter, then each name and
|
|
38
|
+
# value, in the byte order of the names (the reference lists a map of
|
|
39
|
+
# more than 32 names in an order of its own, so such a body differs);
|
|
40
|
+
# - no data (`Predicator::UNDEFINED`) is a form body of the event name
|
|
41
|
+
# alone;
|
|
42
|
+
# - any other value is the body, as `text/plain`, with the event name as
|
|
43
|
+
# `_scxmleventname` in the target's query string.
|
|
44
|
+
#
|
|
45
|
+
# A value is written as {Encoding.value} writes it, and a send with no
|
|
46
|
+
# event name carries no `_scxmleventname`. Every request carries the
|
|
47
|
+
# send's deduplication key in the `scxml-send-key` header, the same
|
|
48
|
+
# value each time the same send is handed, so a receiver that
|
|
49
|
+
# deduplicates on it takes each send once.
|
|
50
|
+
#
|
|
51
|
+
# A send with no target fails within the call that handed it, as
|
|
52
|
+
# appendix C.2.2 requires: `deliver` answers a
|
|
53
|
+
# {Statifier::Driver::DeliveryFailure} and makes no request. Any other
|
|
54
|
+
# miss is handed to the host's `report:` callable as a {Failure} before
|
|
55
|
+
# `deliver` returns, as the reference's processor reports a miss to the
|
|
56
|
+
# sending session, which takes it after the step it is in: a response
|
|
57
|
+
# outside 2xx, a transport that raised, a target that is not a String
|
|
58
|
+
# and a delayed send, which this processor holds no timer for. The host
|
|
59
|
+
# passes each to {Statifier.report_send_failed} once the call that
|
|
60
|
+
# handed the send has returned, with the state that call answered.
|
|
61
|
+
#
|
|
62
|
+
# The processor keeps nothing between sends and is frozen; one
|
|
63
|
+
# processor serves every session it is registered for.
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# misses = []
|
|
67
|
+
# http = Statifier::BasicHTTP::Processor.new(base_url: "http://localhost:8080/scxml", report: misses.method(:<<))
|
|
68
|
+
# moved = Statifier.start(machine, session_id: "s1", processors: http.send_types).value
|
|
69
|
+
# misses.each do |miss|
|
|
70
|
+
# moved = Statifier.report_send_failed(machine, moved.state, miss.effect, reason: miss.reason,
|
|
71
|
+
# processors: http.send_types).value
|
|
72
|
+
# end
|
|
73
|
+
class Processor
|
|
74
|
+
# @return [String] the address the host's own front answers at
|
|
75
|
+
attr_reader :base_url
|
|
76
|
+
|
|
77
|
+
# @param base_url [String] the address the host's own front answers
|
|
78
|
+
# at; a session's `_ioprocessors` location is this address, `/` and
|
|
79
|
+
# the session's id. Copied, never frozen in place
|
|
80
|
+
# @param report [#call] takes each {Failure}, before `deliver`
|
|
81
|
+
# returns; its answer is not read, and an error it raises is not
|
|
82
|
+
# caught
|
|
83
|
+
# @param transport [#call, nil] makes each request ({BasicHTTP} says
|
|
84
|
+
# what a transport is); a {NetHTTPTransport} with its defaults when
|
|
85
|
+
# nil
|
|
86
|
+
# @raise [TypeError] for a base URL that is not a String
|
|
87
|
+
# @raise [ArgumentError] for an empty base URL, or a `report` or a
|
|
88
|
+
# `transport` that does not answer `call`
|
|
89
|
+
def initialize(base_url:, report:, transport: nil)
|
|
90
|
+
raise ArgumentError, "expected report to answer call" unless report.respond_to?(:call)
|
|
91
|
+
raise ArgumentError, "expected transport to answer call" unless transport.nil? || transport.respond_to?(:call)
|
|
92
|
+
|
|
93
|
+
@base_url = address(base_url)
|
|
94
|
+
@report = report
|
|
95
|
+
@transport = transport || NetHTTPTransport.new
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The send types to register this processor under, as a host passes
|
|
100
|
+
# them in `processors:`: {EVENT_PROCESSOR} and {SHORT_TYPE}, so a chart
|
|
101
|
+
# may name either and `_ioprocessors` carries an entry under each.
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash{String => Processor}] a frozen Hash
|
|
104
|
+
def send_types = { EVENT_PROCESSOR => self, SHORT_TYPE => self }.freeze
|
|
105
|
+
|
|
106
|
+
# The `_ioprocessors` entry for a session (appendix C.2.3): its
|
|
107
|
+
# `"location"`, the base URL, `/` and the session's id, the same under
|
|
108
|
+
# either type.
|
|
109
|
+
#
|
|
110
|
+
# @param _type [String] the type it is registered under
|
|
111
|
+
# @param context [Statifier::Driver::Context] the session
|
|
112
|
+
# @return [Hash{String => String}] a frozen Hash
|
|
113
|
+
def ioprocessors_entry(_type, context) = { "location" => "#{base_url}/#{context.session_id}".freeze }.freeze
|
|
114
|
+
|
|
115
|
+
# Takes one send (see the class's description): makes its request, or
|
|
116
|
+
# answers a {Statifier::Driver::DeliveryFailure} for a send with no
|
|
117
|
+
# target.
|
|
118
|
+
#
|
|
119
|
+
# @param send [Statifier::Effect::Send, Statifier::Effect::SendDelayed]
|
|
120
|
+
# @param _event [Statifier::Event] the event a delivery would carry,
|
|
121
|
+
# not read
|
|
122
|
+
# @param context [Statifier::Driver::Context] the sending session
|
|
123
|
+
# @return [Statifier::Driver::DeliveryFailure, nil]
|
|
124
|
+
def deliver(send, _event, context)
|
|
125
|
+
return Driver::DeliveryFailure.new(reason: :no_target) if send.target.nil?
|
|
126
|
+
|
|
127
|
+
session_id = context.session_id
|
|
128
|
+
return miss(session_id, send, :no_timer, nil) if send.is_a?(Effect::SendDelayed)
|
|
129
|
+
return miss(session_id, send, :invalid_target, send.target) unless send.target.is_a?(String)
|
|
130
|
+
|
|
131
|
+
post(session_id, send)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# `base_url`, checked, as a frozen copy.
|
|
137
|
+
def address(base_url)
|
|
138
|
+
raise TypeError, "expected base_url to be a String, got #{base_url.class}" unless base_url.is_a?(String)
|
|
139
|
+
raise ArgumentError, "expected base_url to be an address, got an empty String" if base_url.empty?
|
|
140
|
+
|
|
141
|
+
base_url.frozen? ? base_url : base_url.dup.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The send's request made once, and a miss handed to the host.
|
|
145
|
+
def post(session_id, send)
|
|
146
|
+
answered = exchange(Outbound.request(session_id, send))
|
|
147
|
+
return miss(session_id, send, :transport_error, answered) if answered.is_a?(Exception)
|
|
148
|
+
return miss(session_id, send, :http_status, answered) unless (200..299).cover?(answered.status)
|
|
149
|
+
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What the transport answered for `request`: its {Response}, or the
|
|
154
|
+
# error it raised, a transport that answers anything but a {Response}
|
|
155
|
+
# answered as a TypeError.
|
|
156
|
+
def exchange(request)
|
|
157
|
+
answered = @transport.call(request)
|
|
158
|
+
answered.is_a?(Response) ? answered : TypeError.new("expected a #{Response}, got #{answered.class}")
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
e
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A miss handed to the host's `report:`; `deliver` answers nil.
|
|
164
|
+
def miss(session_id, send, reason, detail)
|
|
165
|
+
@report.call(Failure.new(session_id:, effect: send, reason:, detail:))
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module BasicHTTP
|
|
5
|
+
# One request a transport makes, a frozen value, deeply: a string it
|
|
6
|
+
# is built from that is not frozen is copied, never frozen in place, and
|
|
7
|
+
# one already frozen is kept. A transport sends it
|
|
8
|
+
# as it is handed and changes nothing in it.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# request = Statifier::BasicHTTP::Request.new(
|
|
12
|
+
# http_method: "POST",
|
|
13
|
+
# url: "http://localhost:8080/s1",
|
|
14
|
+
# headers: [["content-type", "text/plain"]],
|
|
15
|
+
# body: "e.go"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] http_method
|
|
19
|
+
# @return [String] the HTTP method, an HTTP token such as `"POST"`
|
|
20
|
+
# @!attribute [r] url
|
|
21
|
+
# @return [String] the absolute URL requested
|
|
22
|
+
# @!attribute [r] headers
|
|
23
|
+
# @return [Array<Array(String, String)>] name and value pairs, in
|
|
24
|
+
# order; a name may appear more than once. Each name is an HTTP
|
|
25
|
+
# token, and no value holds a carriage return, a line feed or a NUL
|
|
26
|
+
# @!attribute [r] body
|
|
27
|
+
# @return [String] the body, already encoded; empty for none
|
|
28
|
+
Request = ::Data.define(:http_method, :url, :headers, :body) do
|
|
29
|
+
# @param http_method [String] the HTTP method
|
|
30
|
+
# @param url [String] the absolute URL
|
|
31
|
+
# @param headers [Array<Array(String, String)>] name and value pairs;
|
|
32
|
+
# none when absent
|
|
33
|
+
# @param body [String] the body; empty when absent
|
|
34
|
+
# @raise [TypeError] for a member of the wrong kind
|
|
35
|
+
# @raise [ArgumentError] for a method or a header name that is not an
|
|
36
|
+
# HTTP token, or a header value holding a carriage return, a line
|
|
37
|
+
# feed or a NUL
|
|
38
|
+
def initialize(http_method:, url:, headers: [], body: "")
|
|
39
|
+
super(
|
|
40
|
+
http_method: Values.token(http_method, "http_method"),
|
|
41
|
+
url: Values.string(url, "url"),
|
|
42
|
+
headers: Values.headers(headers),
|
|
43
|
+
body: Values.string(body, "body")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The checks and copies {Request} and {Response} make of what they are
|
|
49
|
+
# built from.
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
module Values
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# An HTTP token (RFC 9110, section 5.6.2).
|
|
56
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
57
|
+
|
|
58
|
+
# `value`, a String, as a frozen copy.
|
|
59
|
+
def string(value, field)
|
|
60
|
+
raise TypeError, "expected #{field} to be a String, got #{value.class}" unless value.is_a?(String)
|
|
61
|
+
|
|
62
|
+
value.frozen? ? value : value.dup.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `value`, an HTTP token, as a frozen copy.
|
|
66
|
+
def token(value, field)
|
|
67
|
+
copy = string(value, field)
|
|
68
|
+
raise ArgumentError, "expected #{field} to be an HTTP token, got #{copy.inspect}" unless TOKEN.match?(copy)
|
|
69
|
+
|
|
70
|
+
copy
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `value`, a list of name and value pairs, as a frozen copy.
|
|
74
|
+
def headers(value)
|
|
75
|
+
raise TypeError, "expected headers to be an Array of pairs, got #{value.class}" unless value.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
value.map { header(_1) }.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# One name and value pair, as a frozen copy.
|
|
81
|
+
def header(pair)
|
|
82
|
+
unless pair.is_a?(Array) && pair.size == 2
|
|
83
|
+
raise TypeError, "expected each header to be a [name, value] pair, got #{pair.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
name = token(pair[0], "a header name")
|
|
87
|
+
value = string(pair[1], "a header value")
|
|
88
|
+
raise ArgumentError, "the value of header #{name} holds CR, LF or NUL" if value.match?(/[\r\n\0]/)
|
|
89
|
+
|
|
90
|
+
[name, value].freeze
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module BasicHTTP
|
|
5
|
+
# The response a transport answers, whatever its status: a status
|
|
6
|
+
# outside 2xx is a response, not a {TransportError}. A frozen value,
|
|
7
|
+
# deeply: a string it is built from that is not frozen is copied, never
|
|
8
|
+
# frozen in place, and one already frozen is kept.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] status
|
|
11
|
+
# @return [Integer] the HTTP status code, from 100 to 599
|
|
12
|
+
# @!attribute [r] headers
|
|
13
|
+
# @return [Array<Array(String, String)>] name and value pairs, in the
|
|
14
|
+
# order the transport read them; a name may appear more than once.
|
|
15
|
+
# {NetHTTPTransport} answers every name in lower case
|
|
16
|
+
# @!attribute [r] body
|
|
17
|
+
# @return [String] the body as the transport read it; empty for none
|
|
18
|
+
Response = ::Data.define(:status, :headers, :body) do
|
|
19
|
+
# @param status [Integer] the HTTP status code
|
|
20
|
+
# @param headers [Array<Array(String, String)>] name and value pairs;
|
|
21
|
+
# none when absent
|
|
22
|
+
# @param body [String] the body; empty when absent
|
|
23
|
+
# @raise [TypeError] for a member of the wrong kind
|
|
24
|
+
# @raise [ArgumentError] for a status outside 100 to 599, or a header
|
|
25
|
+
# as {Request} refuses one
|
|
26
|
+
def initialize(status:, headers: [], body: "")
|
|
27
|
+
raise TypeError, "expected status to be an Integer, got #{status.class}" unless status.is_a?(Integer)
|
|
28
|
+
raise ArgumentError, "expected status to be from 100 to 599, got #{status}" unless (100..599).cover?(status)
|
|
29
|
+
|
|
30
|
+
super(status:, headers: Values.headers(headers), body: Values.string(body, "body"))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../statifier"
|
|
4
|
+
|
|
5
|
+
module Statifier
|
|
6
|
+
# The Basic HTTP Event I/O Processor (SCXML appendix C.2), loaded only by
|
|
7
|
+
# `require "statifier/basichttp"`: `require "statifier"` alone loads none
|
|
8
|
+
# of it, and so loads no HTTP library.
|
|
9
|
+
#
|
|
10
|
+
# It holds three things. The {Processor}, a send processor a host
|
|
11
|
+
# registers under {EVENT_PROCESSOR} and {SHORT_TYPE}, turns each send it
|
|
12
|
+
# is handed into one POST. {BasicHTTP.decode} turns an HTTP request a
|
|
13
|
+
# host's own server took into the event it carries. And the HTTP
|
|
14
|
+
# transport is the seam between the processor and whatever makes a
|
|
15
|
+
# request. A transport is anything that responds to `call` (a lambda, a
|
|
16
|
+
# method object, an object): it takes one {Request} (the method, the URL,
|
|
17
|
+
# the headers and the body) and makes it, and it answers the {Response}
|
|
18
|
+
# that came back (the status, the headers and the body), whatever its
|
|
19
|
+
# status, or raises a {TransportError} when no response came back.
|
|
20
|
+
# {NetHTTPTransport} is the default transport, on the standard library's
|
|
21
|
+
# Net::HTTP, and a host may pass any other callable of the same contract
|
|
22
|
+
# instead.
|
|
23
|
+
module BasicHTTP
|
|
24
|
+
# The processor's type, appendix C.2's URI.
|
|
25
|
+
EVENT_PROCESSOR = "http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor"
|
|
26
|
+
|
|
27
|
+
# The processor's short type, which a chart may name in its place.
|
|
28
|
+
SHORT_TYPE = "basichttp"
|
|
29
|
+
|
|
30
|
+
# A transport answered no response: the URL could not be requested, the
|
|
31
|
+
# connection failed or was refused, a time bound passed, or the exchange
|
|
32
|
+
# broke off. This is the one error a transport raises for a request it
|
|
33
|
+
# could not make; the error that caused it, when there was one, is its
|
|
34
|
+
# `cause`.
|
|
35
|
+
class TransportError < StandardError; end
|
|
36
|
+
|
|
37
|
+
require_relative "basichttp/request"
|
|
38
|
+
require_relative "basichttp/response"
|
|
39
|
+
require_relative "basichttp/net_http_transport"
|
|
40
|
+
require_relative "basichttp/encoding"
|
|
41
|
+
require_relative "basichttp/outbound"
|
|
42
|
+
require_relative "basichttp/processor"
|
|
43
|
+
require_relative "basichttp/decode"
|
|
44
|
+
|
|
45
|
+
private_constant :Decode, :Encoding, :Outbound, :Values
|
|
46
|
+
end
|
|
47
|
+
end
|