Module: Hook0::Runtime
- Defined in:
- lib/hook0/runtime.rb
Overview
What the generated half of this gem reads and writes values through.
Everything here is hand-written and never regenerated. It is the one seam between what the API
declares — the classes, the problems and the methods the generator writes under generated/ —
and what it does not: how a JSON document is turned into a value, and what happens to a document
that does not say what it was declared to say.
Reading is deliberately strict. A member the document declares as a string and the API answered as a number stops the read with the name of that member, rather than yielding an object whose documentation lies about what it holds. Every failure of that kind is a DecodeError, so a caller has one thing to rescue whatever the shape of the answer was.
A reader is anything answering to call. The scalar ones are constants, since there is exactly
one of each; the ones built around another reader are methods, since there is one per shape.
Defined Under Namespace
Classes: DecodeError
Constant Summary collapse
- MAX_PREVIEW_BYTES =
Longest fragment of a response body an error message carries. Bodies are answered by a server this gem does not control, so they are cut at a fixed budget rather than echoed whole into whatever the caller logs.
256- MAX_PAYLOAD_BYTES =
Largest JSON document read out of a response body, in bytes. The transport caps what it reads off a socket; this caps what is handed to the parser whichever way the bytes arrived.
8 * 1024 * 1024
- MAX_PAYLOAD_NESTING =
Deepest a JSON document may nest before the parser gives up, which is what keeps a document that is nothing but brackets from growing the stack.
64- UNRESERVED =
The characters a path segment carries as themselves; everything else travels percent-encoded.
/[^A-Za-z0-9\-._~]/- UUID_PATTERN =
The shape a UUID is written in, whichever version it carries.
/\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/- TEXT =
A string, refusing what merely spells like one.
lambda { |value| raise DecodeError, "expected a string, got #{value.class}" unless value.is_a?(String) value }
- UUID =
A UUID, as the document spells one. It travels as the text the API answered, since that text is what has to go back out unchanged.
lambda { |value| text = TEXT.call(value) raise DecodeError, "expected a UUID, got `#{text}`" unless UUID_PATTERN.match?(text) text }
- INTEGER =
A whole number.
trueis not one, here or on the wire. lambda { |value| raise DecodeError, "expected a whole number, got #{value.class}" unless value.is_a?(Integer) value }
- FLOAT =
A number, whether the document wrote it with a fractional part or not.
lambda { |value| raise DecodeError, "expected a number, got #{value.class}" unless value.is_a?(Integer) || value.is_a?(Float) value.to_f }
- BOOLEAN =
A boolean, refusing the numbers that stand in for one elsewhere.
lambda { |value| raise DecodeError, "expected a boolean, got #{value.class}" unless [true, false].include?(value) value }
- DATE_TIME =
A moment, as RFC 3339 spells one.
lambda { |value| text = TEXT.call(value) begin Time.iso8601(text) rescue ArgumentError => e raise DecodeError, "expected a date and a time, got `#{text}`: #{e.}" end }
- DATE =
A day, as ISO 8601 spells one.
lambda { |value| text = TEXT.call(value) begin Date.iso8601(text) rescue ArgumentError => e # `Date::Error` is an `ArgumentError`, so rescuing the one covers the other. raise DecodeError, "expected a date, got `#{text}`: #{e.}" end }
- JSON_VALUE =
A value the document does not describe, which is therefore kept as it arrived.
->(value) { value }
Class Method Summary collapse
-
.as_fields(value, owner) ⇒ Hash
The members of an object the document declares, under the name it declares it with.
-
.day(day) ⇒ String
A day, written the way the API reads one.
-
.decode_payload(payload) ⇒ Object
The JSON document a response body carries.
-
.list(reader) ⇒ Proc
Every item of an array, each one read the same way.
-
.map(reader) ⇒ Proc
Every value of an object whose keys the document leaves open.
-
.maybe(fields, key, reader) ⇒ Object?
A member the document does not require, absent as readily as answered as null.
-
.member_of(declared) ⇒ Proc
One of the values a closed list declares, refusing anything the list does not carry.
-
.moment(moment) ⇒ String
A moment, written the way the API reads one.
-
.path(template, filled = {}) ⇒ String
Where a request lands, with each placeholder of the template filled in.
-
.path_segment(value) ⇒ String
A value as one segment of a path, with nothing left in it that could name another one.
-
.preview(payload) ⇒ String
As much of a response body as a message may carry.
-
.query(required, optional = []) ⇒ Array<Array<String>>
What travels in the query string: everything the document requires, and everything it does not that the caller actually passed.
-
.read(fields, key, reader) ⇒ Object
A member the document requires, which is therefore missing when it is absent.
-
.reported(status, problem) ⇒ String
What to say about a problem the API reported.
-
.unreadable(status, payload) ⇒ String
What to say about an answer the API document does not describe.
-
.written(value) ⇒ String
How a value travels in a request line, which is not always how Ruby prints it.
Class Method Details
.as_fields(value, owner) ⇒ Hash
The members of an object the document declares, under the name it declares it with.
159 160 161 162 163 |
# File 'lib/hook0/runtime.rb', line 159 def self.as_fields(value, owner) raise DecodeError, "#{owner} is not a JSON object" unless value.is_a?(Hash) value end |
.day(day) ⇒ String
A day, written the way the API reads one.
243 244 245 |
# File 'lib/hook0/runtime.rb', line 243 def self.day(day) day.iso8601 end |
.decode_payload(payload) ⇒ Object
The JSON document a response body carries.
140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/hook0/runtime.rb', line 140 def self.decode_payload(payload) bytes = payload.to_s if bytes.bytesize > MAX_PAYLOAD_BYTES raise DecodeError, "the response is #{bytes.bytesize} bytes, above the #{MAX_PAYLOAD_BYTES} accepted" end begin JSON.parse(bytes, max_nesting: MAX_PAYLOAD_NESTING) rescue JSON::ParserError, EncodingError => e raise DecodeError, "the response is not JSON: #{preview(bytes)} (#{e.})" end end |
.list(reader) ⇒ Proc
Every item of an array, each one read the same way.
195 196 197 198 199 200 201 |
# File 'lib/hook0/runtime.rb', line 195 def self.list(reader) lambda { |value| raise DecodeError, "expected an array, got #{value.class}" unless value.is_a?(Array) value.map { |item| reader.call(item) } } end |
.map(reader) ⇒ Proc
Every value of an object whose keys the document leaves open.
207 208 209 210 211 212 213 |
# File 'lib/hook0/runtime.rb', line 207 def self.map(reader) lambda { |value| raise DecodeError, "expected an object, got #{value.class}" unless value.is_a?(Hash) value.to_h { |key, item| [TEXT.call(key), reader.call(item)] } } end |
.maybe(fields, key, reader) ⇒ Object?
A member the document does not require, absent as readily as answered as null.
185 186 187 188 189 |
# File 'lib/hook0/runtime.rb', line 185 def self.maybe(fields, key, reader) return nil if fields[key].nil? named(key) { reader.call(fields[key]) } end |
.member_of(declared) ⇒ Proc
One of the values a closed list declares, refusing anything the list does not carry.
219 220 221 222 223 224 225 226 |
# File 'lib/hook0/runtime.rb', line 219 def self.member_of(declared) lambda { |value| text = TEXT.call(value) raise DecodeError, "`#{text}` is not one of the values #{declared} declares" unless declared.member?(text) text } end |
.moment(moment) ⇒ String
A moment, written the way the API reads one.
A moment carrying no fraction of a second is written without one, and one that does keeps every digit it has, so that what was read comes back out unchanged either way.
235 236 237 |
# File 'lib/hook0/runtime.rb', line 235 def self.moment(moment) moment.nsec.zero? ? moment.iso8601 : moment.iso8601(9) end |
.path(template, filled = {}) ⇒ String
Where a request lands, with each placeholder of the template filled in.
252 253 254 255 256 |
# File 'lib/hook0/runtime.rb', line 252 def self.path(template, filled = {}) filled.reduce(template) do |written, (name, value)| written.gsub("{#{name}}", path_segment(value)) end end |
.path_segment(value) ⇒ String
A value as one segment of a path, with nothing left in it that could name another one.
262 263 264 |
# File 'lib/hook0/runtime.rb', line 262 def self.path_segment(value) written(value).b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) } end |
.preview(payload) ⇒ String
As much of a response body as a message may carry.
110 111 112 113 114 115 |
# File 'lib/hook0/runtime.rb', line 110 def self.preview(payload) bytes = payload.to_s.b kept = bytes.byteslice(0, MAX_PREVIEW_BYTES).force_encoding(Encoding::UTF_8) rendered = kept.scrub("�") bytes.bytesize > MAX_PREVIEW_BYTES ? "#{rendered}…" : rendered end |
.query(required, optional = []) ⇒ Array<Array<String>>
What travels in the query string: everything the document requires, and everything it does not that the caller actually passed.
272 273 274 275 |
# File 'lib/hook0/runtime.rb', line 272 def self.query(required, optional = []) asked = required.map { |name, value| [name, written(value)] } asked + optional.filter_map { |name, value| [name, written(value)] unless value.nil? } end |
.read(fields, key, reader) ⇒ Object
A member the document requires, which is therefore missing when it is absent.
172 173 174 175 176 |
# File 'lib/hook0/runtime.rb', line 172 def self.read(fields, key, reader) raise DecodeError, "`#{key}` is required and was not answered" unless fields.key?(key) named(key) { reader.call(fields[key]) } end |
.reported(status, problem) ⇒ String
What to say about a problem the API reported.
131 132 133 |
# File 'lib/hook0/runtime.rb', line 131 def self.reported(status, problem) "the API answered #{status}: #{problem.to_h}" end |
.unreadable(status, payload) ⇒ String
What to say about an answer the API document does not describe.
122 123 124 |
# File 'lib/hook0/runtime.rb', line 122 def self.unreadable(status, payload) "the API answered #{status} with a body this client cannot read: #{preview(payload)}" end |
.written(value) ⇒ String
How a value travels in a request line, which is not always how Ruby prints it.
281 282 283 284 285 286 287 288 289 |
# File 'lib/hook0/runtime.rb', line 281 def self.written(value) case value when true then "true" when false then "false" when Time then moment(value) when Date then day(value) else value.to_s end end |