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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. 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