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,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "machine"
|
|
4
|
+
require_relative "interpreter"
|
|
5
|
+
|
|
6
|
+
module Statifier
|
|
7
|
+
# What {Statifier.check_accepts} answers: the declared names no descriptor
|
|
8
|
+
# in the chart's event vocabulary matches, and the vocabulary's descriptors
|
|
9
|
+
# that match no declared name. A frozen value, deeply.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] unreachable
|
|
12
|
+
# @return [Array<String>] each declared name no descriptor in the
|
|
13
|
+
# vocabulary matches, in the declaration's order and without
|
|
14
|
+
# duplicates: a name the declaration promises and the chart can never
|
|
15
|
+
# select on
|
|
16
|
+
# @!attribute [r] undeclared
|
|
17
|
+
# @return [Array<String>] each descriptor in the vocabulary that matches
|
|
18
|
+
# no declared name, in the vocabulary's order: a name the chart listens
|
|
19
|
+
# for that the declaration does not state
|
|
20
|
+
AcceptsCheck = ::Data.define(:unreachable, :undeclared)
|
|
21
|
+
|
|
22
|
+
# The accepts check: a declaration of the event names a chart accepts,
|
|
23
|
+
# compared with the chart's event vocabulary, as the reference compares
|
|
24
|
+
# them (`Statifier.Chart.check_accepts/2` and the vocabulary it reads,
|
|
25
|
+
# `Statifier.Chart.events/1`, in statifier-ex). {Statifier.check_accepts}
|
|
26
|
+
# answers through this module.
|
|
27
|
+
#
|
|
28
|
+
# The vocabulary is every event descriptor on a transition whose source
|
|
29
|
+
# state can be active, each returned as authored and a pattern never
|
|
30
|
+
# expanded. "Can be active" is a static rule over the chart's structure: a
|
|
31
|
+
# state some path enters, its ancestors included, following Appendix D's
|
|
32
|
+
# `addDescendantStatesToEnter` and `addAncestorStatesToEnter`. The root is
|
|
33
|
+
# entered by its default. Entering a state by its default enters its
|
|
34
|
+
# `initial` states as targets (a compound state or the root), every child
|
|
35
|
+
# that is not a history by its default (a parallel state), or its history
|
|
36
|
+
# default transition's targets as targets (a history pseudo-state).
|
|
37
|
+
# Entering a state as a target enters it by its default, enters each of its
|
|
38
|
+
# proper ancestors, and, for each parallel ancestor, enters by its default
|
|
39
|
+
# every child region that holds none of the transition's targets. Every
|
|
40
|
+
# transition of an entered state enters its targets as targets. No `cond`
|
|
41
|
+
# and no `event` is read, so the rule over-counts and never under-counts.
|
|
42
|
+
# A history pseudo-state contributes no descriptor. Descriptors come in
|
|
43
|
+
# `t_index` order (a state's own transitions before its children's, as the
|
|
44
|
+
# compiler numbers them), within one `event` attribute in the order
|
|
45
|
+
# written, and a descriptor equal as a string to one already listed is
|
|
46
|
+
# dropped.
|
|
47
|
+
#
|
|
48
|
+
# Matching is transition selection's own, {Interpreter::NameMatch}.
|
|
49
|
+
#
|
|
50
|
+
# @api private
|
|
51
|
+
module Accepts
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
# The accepts check of `declared` against `machine`'s vocabulary.
|
|
55
|
+
#
|
|
56
|
+
# @param machine [Machine] a compiled chart
|
|
57
|
+
# @param declared [Array<String>, nil] the declared event names, or nil
|
|
58
|
+
# for no declaration
|
|
59
|
+
# @return [AcceptsCheck]
|
|
60
|
+
def check(machine, declared)
|
|
61
|
+
return AcceptsCheck.new(unreachable: [].freeze, undeclared: [].freeze) if declared.nil?
|
|
62
|
+
|
|
63
|
+
descriptors = tokenized(vocabulary(machine))
|
|
64
|
+
names = tokenized(declared.uniq.map(&:-@))
|
|
65
|
+
AcceptsCheck.new(unreachable: unreachable(names, descriptors), undeclared: undeclared(names, descriptors))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Each of `strings` with its tokens, as `[string, tokens]`.
|
|
69
|
+
def tokenized(strings) = strings.map { [_1, Interpreter::NameMatch.tokenize(_1)] }
|
|
70
|
+
|
|
71
|
+
# The declared names no descriptor matches, frozen.
|
|
72
|
+
def unreachable(names, descriptors)
|
|
73
|
+
descriptor_tokens = descriptors.map(&:last)
|
|
74
|
+
names.reject { Interpreter::NameMatch.name_match?(descriptor_tokens, _2) }.map(&:first).freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The descriptors that match no declared name, frozen.
|
|
78
|
+
def undeclared(names, descriptors)
|
|
79
|
+
descriptors.reject { |_, tokens| names.any? { Interpreter::NameMatch.name_match?([tokens], _2) } }
|
|
80
|
+
.map(&:first).freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The chart's event vocabulary (the reference's `events/1`): every
|
|
84
|
+
# descriptor on a transition of a state that can be active, history
|
|
85
|
+
# pseudo-states left out, in `t_index` order and without duplicates.
|
|
86
|
+
#
|
|
87
|
+
# @param machine [Machine] a compiled chart
|
|
88
|
+
# @return [Array<String>] frozen descriptors
|
|
89
|
+
def vocabulary(machine)
|
|
90
|
+
entered_states(machine)
|
|
91
|
+
.reject { machine.history?(_1) }
|
|
92
|
+
.flat_map { machine.state(_1).transitions }
|
|
93
|
+
.sort
|
|
94
|
+
.flat_map { |t_index| machine.transitions.fetch(t_index).events.map { -_1.join(".") } }
|
|
95
|
+
.uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The least set of state indexes closed under the entry rule, as a
|
|
99
|
+
# worklist: `[:default, index]` enters a state by its default and
|
|
100
|
+
# `[:targets, indexes]` enters one transition's (or one `initial`'s)
|
|
101
|
+
# targets as targets. `defaulted` bounds the default expansions and
|
|
102
|
+
# `entered` bounds the per-state transition walk, so each state's work is
|
|
103
|
+
# queued once and the walk ends on any chart, cycles included.
|
|
104
|
+
def entered_states(machine)
|
|
105
|
+
entered = Set.new
|
|
106
|
+
defaulted = Set.new
|
|
107
|
+
work = [[:default, 0]]
|
|
108
|
+
work.concat(entry_work(machine, work.pop, entered, defaulted)) until work.empty?
|
|
109
|
+
entered.to_a
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The work one entry adds: a state entered by its default (once), or one
|
|
113
|
+
# list of targets entered as targets.
|
|
114
|
+
def entry_work(machine, (kind, subject), entered, defaulted)
|
|
115
|
+
return enter_targets(machine, subject, entered) if kind == :targets
|
|
116
|
+
return [] unless defaulted.add?(subject)
|
|
117
|
+
|
|
118
|
+
mark_entered(machine, subject, entered) + default_entry(machine, subject)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The work entering `targets` as targets adds: each target by its
|
|
122
|
+
# default, and each proper ancestor entered, with its own transitions'
|
|
123
|
+
# work and, for a parallel ancestor, its untargeted regions by default.
|
|
124
|
+
def enter_targets(machine, targets, entered)
|
|
125
|
+
ancestors = targets.flat_map { machine.proper_ancestors(_1) }.uniq
|
|
126
|
+
ancestor_work = ancestors.flat_map do |ancestor|
|
|
127
|
+
mark_entered(machine, ancestor, entered) + untargeted_regions(machine, ancestor, targets)
|
|
128
|
+
end
|
|
129
|
+
targets.map { [:default, _1] } + ancestor_work
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# What entering `index` by its default enters next, by kind: a parallel
|
|
133
|
+
# state's regions by their defaults, a history's default transition's
|
|
134
|
+
# targets as targets, and otherwise the compiler-resolved `initial` as
|
|
135
|
+
# targets - nothing on an atomic state.
|
|
136
|
+
def default_entry(machine, index)
|
|
137
|
+
state = machine.state(index)
|
|
138
|
+
case state.kind
|
|
139
|
+
when :parallel then machine.child_states(index).map { [:default, _1] }
|
|
140
|
+
when :history
|
|
141
|
+
state.history_default.nil? ? [] : [[:targets, machine.transitions.fetch(state.history_default).targets]]
|
|
142
|
+
else state.initial.empty? ? [] : [[:targets, state.initial]]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A parallel ancestor's regions that hold none of `targets`, each to be
|
|
147
|
+
# entered by its default; nothing for any other ancestor.
|
|
148
|
+
def untargeted_regions(machine, ancestor, targets)
|
|
149
|
+
return [] unless machine.parallel?(ancestor)
|
|
150
|
+
|
|
151
|
+
machine.child_states(ancestor)
|
|
152
|
+
.reject { |region| targets.any? { _1 == region || machine.descendant?(_1, region) } }
|
|
153
|
+
.map { [:default, _1] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Marks `index` entered. The first time only, answers the work its own
|
|
157
|
+
# transitions add: each targeted transition's targets, as targets.
|
|
158
|
+
def mark_entered(machine, index, entered)
|
|
159
|
+
return [] unless entered.add?(index)
|
|
160
|
+
|
|
161
|
+
machine.state(index).transitions
|
|
162
|
+
.map { machine.transitions.fetch(_1).targets }
|
|
163
|
+
.reject(&:empty?)
|
|
164
|
+
.map { [:targets, _1] }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
# The receive side of the Basic HTTP processor: {BasicHTTP.decode} and
|
|
5
|
+
# its {BasicHTTP::Refusal}.
|
|
6
|
+
module BasicHTTP
|
|
7
|
+
# Why {BasicHTTP.decode} formed no event from a request.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] reason
|
|
10
|
+
# @return [Symbol] `:method_not_allowed` (the method is not POST; a
|
|
11
|
+
# front answers 405 with `Allow: POST`), `:not_utf8` (the query
|
|
12
|
+
# string or the body does not decode to UTF-8) or
|
|
13
|
+
# `:malformed_send_key` (the `scxml-send-key` value is not eight
|
|
14
|
+
# fields joined by `/` whose second decodes to UTF-8); a front
|
|
15
|
+
# answers either of the last two 400
|
|
16
|
+
# @!attribute [r] detail
|
|
17
|
+
# @return [String, Symbol] the method, `:query` or `:body`, or the
|
|
18
|
+
# header's value
|
|
19
|
+
Refusal = ::Data.define(:reason, :detail)
|
|
20
|
+
|
|
21
|
+
# The event one HTTP request carries (appendix C.2.1), as the
|
|
22
|
+
# reference's `Statifier.Send.BasicHTTP.decode/1` forms it. It is pure
|
|
23
|
+
# and knows no session: a host's own server resolves the request's
|
|
24
|
+
# location to a session, decodes the request here and passes the event's
|
|
25
|
+
# `name` and `data` to {Statifier.step} with the state it holds for that
|
|
26
|
+
# session.
|
|
27
|
+
#
|
|
28
|
+
# - The method must be POST, in any case.
|
|
29
|
+
# - The event's name is the first `_scxmleventname` parameter found, the
|
|
30
|
+
# query string's before a form body's, else `HTTP.` and the method in
|
|
31
|
+
# upper case (`HTTP.POST`).
|
|
32
|
+
# - A form body (a content type that begins
|
|
33
|
+
# `application/x-www-form-urlencoded`, in any case) and the query
|
|
34
|
+
# string give the other parameters, which are the event's data: a
|
|
35
|
+
# Hash whose values are each read as a `<content>` body's text is read
|
|
36
|
+
# (a predicator literal, else the text with its whitespace collapsed),
|
|
37
|
+
# so `2` is the number 2; the last of a repeated name wins, and no
|
|
38
|
+
# parameters is no data (`Predicator::UNDEFINED`).
|
|
39
|
+
# - A body of any other content type, a JSON body included, is the data,
|
|
40
|
+
# read the same way, and the query string then gives the name only. No
|
|
41
|
+
# charset is read.
|
|
42
|
+
# - The event is external, its `origintype` {EVENT_PROCESSOR} and its
|
|
43
|
+
# `origin` and `sendid` nil.
|
|
44
|
+
# - The `scxml-send-key` value is checked and sets nothing on the event:
|
|
45
|
+
# deduplicating on it is the server's. A server that has already taken
|
|
46
|
+
# a request with the same value answers 204 again and takes nothing.
|
|
47
|
+
#
|
|
48
|
+
# A server answers 204 once it has taken the event, 405 or 400 for a
|
|
49
|
+
# refusal, and its own 404 for a location that names no session it holds.
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# decoded = Statifier::BasicHTTP.decode(method: "POST", content_type: "application/x-www-form-urlencoded",
|
|
53
|
+
# body: "_scxmleventname=e.go&n=2", query: nil)
|
|
54
|
+
# decoded.value.name # => "e.go"
|
|
55
|
+
# decoded.value.data # => {"n" => 2}
|
|
56
|
+
#
|
|
57
|
+
# @param method [String] the request's method
|
|
58
|
+
# @param content_type [String, nil] its content type, nil when it carries
|
|
59
|
+
# none
|
|
60
|
+
# @param body [String] its body, empty for none
|
|
61
|
+
# @param query [String, nil] its query string without the `?`, nil when
|
|
62
|
+
# the URL has none
|
|
63
|
+
# @param send_key [String, nil] its `scxml-send-key` header's value, nil
|
|
64
|
+
# when it carries none
|
|
65
|
+
# @return [Statifier::Result] the {Statifier::Event}, deeply frozen, or a
|
|
66
|
+
# {Refusal}
|
|
67
|
+
# @raise [TypeError] for an argument of the wrong kind
|
|
68
|
+
def self.decode(method:, content_type:, body:, query:, send_key: nil)
|
|
69
|
+
Decode.request(method, content_type, body, query, send_key)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The body of {BasicHTTP.decode}.
|
|
73
|
+
#
|
|
74
|
+
# @api private
|
|
75
|
+
module Decode
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# {BasicHTTP.decode}'s body, its checks in the reference's order: the
|
|
79
|
+
# method, the query string, the body, the send key.
|
|
80
|
+
def request(method, content_type, body, query, send_key)
|
|
81
|
+
checked(method:, body:, content_type:, query:, send_key:)
|
|
82
|
+
return refused(:method_not_allowed, method) unless method.upcase == "POST"
|
|
83
|
+
|
|
84
|
+
query_pairs = query.nil? ? [] : Encoding.pairs(query)
|
|
85
|
+
return refused(:not_utf8, :query) if query_pairs.nil?
|
|
86
|
+
|
|
87
|
+
decoded(method, query_pairs, body(content_type, body), send_key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The event, after the body and the send key are checked.
|
|
91
|
+
def decoded(method, query_pairs, (body_pairs, text), send_key)
|
|
92
|
+
return refused(:not_utf8, :body) if body_pairs.nil?
|
|
93
|
+
return refused(:malformed_send_key, send_key) unless send_key.nil? || send_key?(send_key)
|
|
94
|
+
|
|
95
|
+
Result.new(value: event(method, query_pairs + body_pairs, text), error: nil)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A form body's pairs and no text, or no pairs and the body as text;
|
|
99
|
+
# nil pairs when it does not decode to UTF-8.
|
|
100
|
+
def body(content_type, body)
|
|
101
|
+
return [Encoding.pairs(body), nil] if content_type&.downcase&.start_with?(Outbound::FORM)
|
|
102
|
+
|
|
103
|
+
text = Encoding.utf8(body.b)
|
|
104
|
+
text.valid_encoding? ? [[], text] : [nil, nil]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether `value` is eight fields joined by `/` whose second decodes to
|
|
108
|
+
# UTF-8.
|
|
109
|
+
def send_key?(value)
|
|
110
|
+
fields = value.split("/", -1)
|
|
111
|
+
fields.size == 8 && !Encoding.unfield(fields[1]).nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The event the pairs and the text carry.
|
|
115
|
+
def event(method, pairs, text)
|
|
116
|
+
named, params = pairs.partition { _1.first == Outbound::EVENT_NAME_PARAM }
|
|
117
|
+
name = named.empty? ? "HTTP.#{method.upcase}" : named.first.last
|
|
118
|
+
data = text ? Interpreter::Send.text_data(text) : params_data(params)
|
|
119
|
+
Frozen.copy(Event.external(name, data:, origintype: EVENT_PROCESSOR))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Each parameter's value read as text, the last of a name winning.
|
|
123
|
+
def params_data(params)
|
|
124
|
+
Interpreter::Send.params_data(params.map { |name, value| [name, Interpreter::Send.text_data(value)] })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def refused(reason, detail) = Result.new(value: nil, error: Refusal.new(reason:, detail: frozen(detail)))
|
|
128
|
+
|
|
129
|
+
def frozen(detail) = detail.is_a?(String) ? detail.dup.freeze : detail
|
|
130
|
+
|
|
131
|
+
# The arguments, after checking their kinds.
|
|
132
|
+
def checked(**arguments)
|
|
133
|
+
arguments.each do |name, value|
|
|
134
|
+
next if value.is_a?(String) || (value.nil? && !%i[method body].include?(name))
|
|
135
|
+
|
|
136
|
+
raise TypeError, "expected #{name} to be a String#{" or nil" unless %i[method body].include?(name)}, " \
|
|
137
|
+
"got #{value.class}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module BasicHTTP
|
|
5
|
+
# The Basic HTTP processor's text encodings, as the reference writes and
|
|
6
|
+
# reads them (`Statifier.Send.BasicHTTP` in statifier-ex): a form body
|
|
7
|
+
# and a query string (its `URI.encode_query(pairs, :www_form)` and
|
|
8
|
+
# `URI.query_decoder(text, :www_form)`), the two escaped fields of the
|
|
9
|
+
# `scxml-send-key` header (`URI.encode/2` keeping RFC 3986's unreserved
|
|
10
|
+
# set, and `URI.decode/1`), and a parameter value written as text (its
|
|
11
|
+
# private `encode/1` and `json/1`).
|
|
12
|
+
#
|
|
13
|
+
# Every encoding works on a string's bytes, so a value that is not UTF-8
|
|
14
|
+
# is escaped byte for byte, as the reference escapes one.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
module Encoding
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The bytes neither encoding escapes: RFC 3986's unreserved set.
|
|
21
|
+
ESCAPED = /[^A-Za-z0-9\-._~]/n
|
|
22
|
+
|
|
23
|
+
# An escape the decoders read: `%` and two hex digits.
|
|
24
|
+
ESCAPE = /%(\h\h)/n
|
|
25
|
+
|
|
26
|
+
# The deepest a list or a map is written as JSON text; one nested
|
|
27
|
+
# deeper is written as predicator's tagged-value text instead, whose
|
|
28
|
+
# encoder refuses past its own limit.
|
|
29
|
+
JSON_DEPTH = 256
|
|
30
|
+
|
|
31
|
+
# The magnitude from which a float is always written with an exponent,
|
|
32
|
+
# as the reference's runtime writes it: 2 to the 53rd.
|
|
33
|
+
EXPONENT_FROM = 2**53
|
|
34
|
+
|
|
35
|
+
# The JSON escapes of the characters with a short one.
|
|
36
|
+
SHORT_ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\t" => "\\t", "\n" => "\\n", "\f" => "\\f",
|
|
37
|
+
"\r" => "\\r" }.freeze
|
|
38
|
+
|
|
39
|
+
# Name and value pairs as an `application/x-www-form-urlencoded` body,
|
|
40
|
+
# in the order given: every byte outside the unreserved set escaped as
|
|
41
|
+
# `%` and two upper-case hex digits, a space written `+`.
|
|
42
|
+
#
|
|
43
|
+
# @param pairs [Array<Array(String, String)>]
|
|
44
|
+
# @return [String]
|
|
45
|
+
def form(pairs) = pairs.map { |name, value| "#{www_form(name)}=#{www_form(value)}" }.join("&")
|
|
46
|
+
|
|
47
|
+
# One name or value of a form: escaped, a space as `+`.
|
|
48
|
+
def www_form(text) = text.b.gsub(ESCAPED) { _1 == " " ? "+" : format("%%%02X", _1.ord) }.force_encoding("UTF-8")
|
|
49
|
+
|
|
50
|
+
# A `scxml-send-key` field: every byte outside the unreserved set
|
|
51
|
+
# escaped, a space as `%20`, so the field carries no `/`.
|
|
52
|
+
#
|
|
53
|
+
# @param text [String]
|
|
54
|
+
# @return [String]
|
|
55
|
+
def field(text) = text.b.gsub(ESCAPED) { format("%%%02X", _1.ord) }.force_encoding("UTF-8")
|
|
56
|
+
|
|
57
|
+
# A form body or a query string as its name and value pairs, in order,
|
|
58
|
+
# or nil when a name or a value does not decode to UTF-8. Pairs are
|
|
59
|
+
# split at each `&` and a pair at its first `=`; a pair with no `=` has
|
|
60
|
+
# the empty value, an empty pair is two empty strings, and nothing
|
|
61
|
+
# after a final `&` is a pair.
|
|
62
|
+
#
|
|
63
|
+
# @param text [String]
|
|
64
|
+
# @return [Array<Array(String, String)>, nil]
|
|
65
|
+
def pairs(text)
|
|
66
|
+
out = []
|
|
67
|
+
rest = text.b
|
|
68
|
+
until rest.empty?
|
|
69
|
+
pair, _, rest = rest.partition("&")
|
|
70
|
+
name, _, value = pair.partition("=")
|
|
71
|
+
out << [decoded(name, plus: true), decoded(value, plus: true)]
|
|
72
|
+
end
|
|
73
|
+
out.flatten.all? ? out : nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A field escaped as {field} escapes one, decoded, or nil when it does
|
|
77
|
+
# not decode to UTF-8. A `+` stays a `+`.
|
|
78
|
+
def unfield(text) = decoded(text.b, plus: false)
|
|
79
|
+
|
|
80
|
+
# Text with each escape decoded to its byte (an escape that is not `%`
|
|
81
|
+
# and two hex digits stays as it is written) and, when `plus`, each
|
|
82
|
+
# `+` read as a space; nil when the bytes are not UTF-8.
|
|
83
|
+
def decoded(bytes, plus:)
|
|
84
|
+
bytes = bytes.tr("+", " ") if plus
|
|
85
|
+
text = bytes.gsub(ESCAPE) { Regexp.last_match(1).hex.chr }.force_encoding("UTF-8")
|
|
86
|
+
text.valid_encoding? ? text : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A value written as text: a string as it is, nil as `null`,
|
|
90
|
+
# `Predicator::UNDEFINED` as the empty string, a boolean as its
|
|
91
|
+
# literal, an Integer in decimal, a finite Float as {float} writes it,
|
|
92
|
+
# a list or a map as JSON text ({json}) when every value inside it has
|
|
93
|
+
# a JSON form. Anything else (a date, a datetime, a duration, a list
|
|
94
|
+
# or a map holding a value with no JSON form) is predicator's
|
|
95
|
+
# tagged-value text, or the empty string when that encoder refuses it.
|
|
96
|
+
#
|
|
97
|
+
# @param value [Object] a predicator value
|
|
98
|
+
# @return [String]
|
|
99
|
+
def value(value)
|
|
100
|
+
case value
|
|
101
|
+
when String then value
|
|
102
|
+
when nil then "null"
|
|
103
|
+
when true, false, Integer then value.to_s
|
|
104
|
+
when Float then value.finite? ? float(value) : tagged(value)
|
|
105
|
+
else scalar_or_tagged(value)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What {value} writes for a value that is not a String, nil, a
|
|
110
|
+
# boolean or a number.
|
|
111
|
+
def scalar_or_tagged(value)
|
|
112
|
+
return "" if value.equal?(Predicator::UNDEFINED)
|
|
113
|
+
|
|
114
|
+
(json(value) if value.is_a?(Array) || value.is_a?(Hash)) || tagged(value)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Predicator's tagged-value text of `value`, or the empty string.
|
|
118
|
+
def tagged(value)
|
|
119
|
+
encoded = Predicator::Tagged.encode(value)
|
|
120
|
+
encoded.ok? ? encoded.value : ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A list or a map as JSON text, or nil when a value inside has no JSON
|
|
124
|
+
# form. Inside, `Predicator::UNDEFINED` and nil are `null`; a map's
|
|
125
|
+
# keys must be UTF-8 Strings, and its members stand in the byte order
|
|
126
|
+
# of their keys; a string must be UTF-8; a Float must be finite; a
|
|
127
|
+
# value nested deeper than {JSON_DEPTH} has none. Byte order is the
|
|
128
|
+
# order the reference's runtime lists a small map's string keys in (32
|
|
129
|
+
# keys or fewer); it lists a larger map in an order of its own, so for
|
|
130
|
+
# a map of more than 32 keys this text differs from the reference's.
|
|
131
|
+
#
|
|
132
|
+
# @param value [Array, Hash]
|
|
133
|
+
# @return [String, nil]
|
|
134
|
+
def json(value, depth = 1)
|
|
135
|
+
return nil if depth > JSON_DEPTH
|
|
136
|
+
|
|
137
|
+
case value
|
|
138
|
+
when Array then json_list(value, depth)
|
|
139
|
+
when Hash then json_map(value, depth)
|
|
140
|
+
else json_scalar(value)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def json_list(list, depth)
|
|
145
|
+
parts = list.map { json(_1, depth + 1) || (return nil) }
|
|
146
|
+
"[#{parts.join(",")}]"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def json_map(map, depth)
|
|
150
|
+
return nil unless map.each_key.all? { _1.is_a?(String) && utf8?(_1) }
|
|
151
|
+
|
|
152
|
+
parts = map.keys.sort_by(&:b).map { "#{json_string(_1)}:#{json(map[_1], depth + 1) || (return nil)}" }
|
|
153
|
+
"{#{parts.join(",")}}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def json_scalar(value)
|
|
157
|
+
case value
|
|
158
|
+
when nil, Predicator::UNDEFINED then "null"
|
|
159
|
+
when true, false, Integer then value.to_s
|
|
160
|
+
when Float then float(value) if value.finite?
|
|
161
|
+
when String then json_string(value) if utf8?(value)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A UTF-8 string as JSON text, as the reference's runtime writes one:
|
|
166
|
+
# `"` and `\` escaped, the control characters with a short escape
|
|
167
|
+
# written so, every other control character below U+0020 as `\u` and
|
|
168
|
+
# four upper-case hex digits, everything else as it is.
|
|
169
|
+
def json_string(text)
|
|
170
|
+
body = utf8(text).gsub(/["\\\x00-\x1f]/) { SHORT_ESCAPES.fetch(_1) { |char| format("\\u%04X", char.ord) } }
|
|
171
|
+
"\"#{body}\""
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Whether `text`'s bytes are UTF-8.
|
|
175
|
+
def utf8?(text) = utf8(text).valid_encoding?
|
|
176
|
+
|
|
177
|
+
# `text`'s bytes read as UTF-8.
|
|
178
|
+
def utf8(text) = text.encoding == ::Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
|
|
179
|
+
|
|
180
|
+
# A finite Float as the reference's runtime writes it: the shortest
|
|
181
|
+
# digits that read back as the same number, in the plain form (a point,
|
|
182
|
+
# and `.0` when whole) or the exponent form (one digit, a point, at
|
|
183
|
+
# least one more digit, `e` and the exponent, signed only when
|
|
184
|
+
# negative), whichever is shorter, the plain form on a tie; from 2 to
|
|
185
|
+
# the 53rd up always the exponent form. Zero is `0.0`, and negative
|
|
186
|
+
# zero `-0.0`.
|
|
187
|
+
#
|
|
188
|
+
# @param number [Float] a finite Float
|
|
189
|
+
# @return [String]
|
|
190
|
+
def float(number)
|
|
191
|
+
sign = number.to_s.start_with?("-") ? "-" : ""
|
|
192
|
+
return "#{sign}0.0" if number.zero?
|
|
193
|
+
|
|
194
|
+
digits, point = shortest_digits(number.abs)
|
|
195
|
+
exponent = exponent_form(digits, point)
|
|
196
|
+
return sign + exponent if number.abs >= EXPONENT_FROM
|
|
197
|
+
|
|
198
|
+
plain = plain_form(digits, point)
|
|
199
|
+
sign + (plain.size <= exponent.size ? plain : exponent)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The shortest significant digits of a positive Float, without leading
|
|
203
|
+
# or trailing zeros, and where the point falls: the Float is
|
|
204
|
+
# 0.DIGITS times ten to the power `point`. Ruby's Float#to_s writes the
|
|
205
|
+
# same shortest digits, in a form of its own read apart here.
|
|
206
|
+
def shortest_digits(magnitude)
|
|
207
|
+
mantissa, exponent = magnitude.to_s.split("e")
|
|
208
|
+
whole, fraction = mantissa.split(".")
|
|
209
|
+
digits = whole + fraction
|
|
210
|
+
significant = digits.sub(/\A0+/, "")
|
|
211
|
+
[significant.sub(/0+\z/, ""), whole.size + exponent.to_i - (digits.size - significant.size)]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def exponent_form(digits, point) = "#{digits[0]}.#{digits.size > 1 ? digits[1..] : "0"}e#{point - 1}"
|
|
215
|
+
|
|
216
|
+
def plain_form(digits, point)
|
|
217
|
+
return "0.#{"0" * -point}#{digits}" if point <= 0
|
|
218
|
+
return "#{digits}#{"0" * (point - digits.size)}.0" if digits.size <= point
|
|
219
|
+
|
|
220
|
+
"#{digits[0, point]}.#{digits[point..]}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|