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. true is 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.message}"
  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.message}"
  end
}
JSON_VALUE =

A value the document does not describe, which is therefore kept as it arrived.

->(value) { value }

Class Method Summary collapse

Class Method Details

.as_fields(value, owner) ⇒ Hash

The members of an object the document declares, under the name it declares it with.

Parameters:

  • value (Object)
  • owner (String)

    what the document calls the object being read

Returns:

  • (Hash)

Raises:

  • (DecodeError)

    when the API answered something that is not an object



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.

Parameters:

  • day (Date)

Returns:

  • (String)


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.

Parameters:

  • payload (String)

Returns:

  • (Object)

Raises:

  • (DecodeError)

    when the body is larger than this gem reads, or is not JSON



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.message})"
  end
end

.list(reader) ⇒ Proc

Every item of an array, each one read the same way.

Parameters:

  • reader (#call)

Returns:

  • (Proc)


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.

Parameters:

  • reader (#call)

Returns:

  • (Proc)


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.

Parameters:

  • fields (Hash)
  • key (String)

    the name the member travels under

  • reader (#call)

Returns:

  • (Object, nil)

Raises:



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.

Parameters:

  • declared (#member?)

    the module the generator wrote for that list

Returns:

  • (Proc)


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.

Parameters:

  • moment (Time)

Returns:

  • (String)


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.

Parameters:

  • template (String)

    the path as the document writes it, placeholders included

  • filled (Hash{String => Object}) (defaults to: {})

    the value each placeholder carries

Returns:

  • (String)


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.

Parameters:

  • value (Object)

Returns:

  • (String)


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.

Parameters:

  • payload (String)

Returns:

  • (String)


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.

Parameters:

  • required (Array<Array>)

    name and value pairs the operation always sends

  • optional (Array<Array>) (defaults to: [])

    name and value pairs it sends only when they carry something

Returns:

  • (Array<Array<String>>)


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.

Parameters:

  • fields (Hash)
  • key (String)

    the name the member travels under

  • reader (#call)

Returns:

  • (Object)

Raises:



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.

Parameters:

  • status (Integer)
  • problem (#to_h)

    the problem document the API answered

Returns:

  • (String)


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.

Parameters:

  • status (Integer)
  • payload (String)

Returns:

  • (String)


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.

Parameters:

  • value (Object)

Returns:

  • (String)


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