class Hyperion::Kim

Constants

Handler

A dumb fake web server. This is minimal object wrapper around Rack/WEBrick. WEBrick was chosen because it comes with ruby and we're not doing rocket science here. Kim runs Rack/WEBrick in a separate thread and keeps an array of handlers. A handler is simply a predicate on a request object and a function to handle the request should the predicate return truthy. When rack notifies us of a request, we dispatch it to the first handler with a truthy predicate.

Again, what we're trying to do is very simple. Most of the existing complexity is due to

  • thread synchronization

  • unmangling WEBrick's header renaming

  • loosening the requirements on what a handler function must return

To support path parameters (e.g., /people/:name), a predicate may return a Request object as a truthy value, augmented with additional params. When the predicate returns a Request, the augmented request object is passed to the handler function in place of the original request.

Request

Public Class Methods

new(port:) click to toggle source
# File lib/hyperion_test/kim.rb, line 47
def initialize(port:)
  @port = port
  @handlers = []
  @lock = Mutex.new # controls access to this instance of Kim (via public methods and callbacks)
end
webrick_mutex() click to toggle source
# File lib/hyperion_test/kim.rb, line 53
def self.webrick_mutex
  @webrick_mutex ||= Mutex.new # controls access to the Rack::Handler::WEBrick singleton
end

Public Instance Methods

add_handler(matcher_or_pred, &handler_proc) click to toggle source

Add a handler. Returns a proc that removes the handler.

# File lib/hyperion_test/kim.rb, line 100
def add_handler(matcher_or_pred, &handler_proc)
  @lock.synchronize do
    handler = Handler.new(Matcher.wrap(matcher_or_pred), handler_proc)
    @handlers.unshift(handler)
    remover = proc { @lock.synchronize { @handlers.delete(handler) } }
    remover
  end
end
clear_handlers() click to toggle source
# File lib/hyperion_test/kim.rb, line 109
def clear_handlers
  @lock.synchronize do
    @handlers = []
  end
end
start() click to toggle source
# File lib/hyperion_test/kim.rb, line 57
def start
  # Notes on synchronization:
  #
  # The only way to start a handler is with static method ::run
  # which touches singleton instance variables. webrick_mutex
  # ensures only one thread is in the singleton at a time.
  #
  # A threadsafe queue is used to notify the calling thread
  # that the server thread has started. The caller needs to
  # wait so it can obtain the webrick instance.

  @lock.synchronize do
    raise 'Cannot restart' if @stopped
    Kim.webrick_mutex.synchronize do
      q = Queue.new
      @thread = Thread.start do
        begin
          opts = {Port: @port, Logger: ::Logger.new('/dev/null'), AccessLog: []} # hide output
          Rack::Handler::WEBrick.run(method(:handle_request), opts) do |webrick|
            q.push(webrick)
          end
        rescue Exception => e
          $stderr.puts "Hyperion fake server on port #{@port} exited unexpectedly!" unless @stopped
          raise e
        end
      end
      @webrick = q.pop
    end
  end
end
stop() click to toggle source
# File lib/hyperion_test/kim.rb, line 88
def stop
  @lock.synchronize do
    return if @stopped
    @stopped = true
    @webrick.shutdown
    @thread.join
    @webrick = nil
    @thread = nil
  end
end

Private Instance Methods

bodies?(x) click to toggle source
# File lib/hyperion_test/kim.rb, line 212
def bodies?(x)
  x.is_a?(Array) && x.all? { |v| v.is_a?(String) }
end
error_headers() click to toggle source
# File lib/hyperion_test/kim.rb, line 220
def error_headers
  {'Content-Type' => 'text/plain'}
end
handle(req) click to toggle source
# File lib/hyperion_test/kim.rb, line 161
def handle(req)
  pred_value, func = @handlers.lazy
                       .map { |h| [h.pred.call(req), h.func] }
                       .select { |(pv, _)| pv }
                       .first || [nil, no_route_matched_func]
  func.call(pred_value.is_a?(Request) ? pred_value : req)
end
handle_request(env) click to toggle source
# File lib/hyperion_test/kim.rb, line 116
def handle_request(env)
  @lock.synchronize do
    req = request_for(env)
    x = handle(req)
    x = massage_response(x)
    x = validate_response(x)
    x
  end
end
headers?(x) click to toggle source
# File lib/hyperion_test/kim.rb, line 207
def headers?(x)
  # TODO: check for valid keys and values
  x.is_a?(Hash)
end
http_code?(x) click to toggle source
# File lib/hyperion_test/kim.rb, line 201
def http_code?(x)
  return false unless x.respond_to?(:to_i)
  v = x.to_i
  100 <= v && v < 600
end
mangled_header?(h) click to toggle source
# File lib/hyperion_test/kim.rb, line 150
def mangled_header?(h)
  h.start_with?('HTTP_') || %w(CONTENT_TYPE CONTENT_LENGTH).include?(h)
end
massage_response(r) click to toggle source
# File lib/hyperion_test/kim.rb, line 169
def massage_response(r)
  if triplet?(r)
    r[0] = r[0].to_s   # code
    r[1] = r[1] || {}  # headers
    r[2] = *r[2]       # body/bodies (coerce to array)
    r[2].map!(&:to_s)
    r
  elsif r.is_a?(String)
    ['200', {}, [r]]
  else
    r
  end
end
no_route_matched_func() click to toggle source
# File lib/hyperion_test/kim.rb, line 191
def no_route_matched_func
  proc do
    ['404', error_headers, ['Request matched no routes.']]
  end
end
read_headers(env) click to toggle source
# File lib/hyperion_test/kim.rb, line 142
def read_headers(env)
  # similar to https://github.com/ruby/ruby/blob/32674b167bddc0d737c38f84722986b0f228b44b/lib/webrick/cgi.rb#L217-L226
  env.each_pair
    .select { |k, _| mangled_header?(k) }
    .map { |k, v| [unmangle_header_key(k), v] }
    .to_h
end
read_query_params(query_string) click to toggle source
# File lib/hyperion_test/kim.rb, line 135
def read_query_params(query_string)
  query_string
    .split('&')
    .map { |kv| kv.split('=') }
    .to_h
end
request_for(env) click to toggle source
# File lib/hyperion_test/kim.rb, line 126
def request_for(env)
  verb = env['REQUEST_METHOD']
  path = env['PATH_INFO']
  params = OpenStruct.new(read_query_params(env['QUERY_STRING']))
  headers = read_headers(env)
  body = env['rack.input'].gets
  Request.new(verb, path, params, headers, body)
end
server_error(msg) click to toggle source
# File lib/hyperion_test/kim.rb, line 216
def server_error(msg)
  ['500', error_headers, [msg]]
end
triplet?(x) click to toggle source
# File lib/hyperion_test/kim.rb, line 197
def triplet?(x)
  x.is_a?(Array) && x.size == 3
end
unmangle_header_key(k) click to toggle source
# File lib/hyperion_test/kim.rb, line 154
def unmangle_header_key(k)
  k.gsub(/^HTTP_/, '')
    .split('_')
    .map(&:titlecase)
    .join('-')
end
validate_response(r) click to toggle source
# File lib/hyperion_test/kim.rb, line 183
def validate_response(r)
  triplet?(r) or return server_error("Invalid response, not a size-3 array: #{r.inspect}.")
  http_code?(r[0]) or return server_error("Invalid response, invalid http code: #{r[0].inspect}")
  headers?(r[1]) or return server_error("Invalid response, invalid header hash: #{r[1].inspect}")
  bodies?(r[2]) or return server_error("Invalid response, invalid bodies array: #{r[2].inspect}")
  r
end