Skip to content

Instantly share code, notes, and snippets.

@maxwells
Last active July 13, 2016 21:06
Show Gist options
  • Save maxwells/02340eb37af87659f84af3268831c4bf to your computer and use it in GitHub Desktop.
Save maxwells/02340eb37af87659f84af3268831c4bf to your computer and use it in GitHub Desktop.
2 hour sinatra
require 'rack'
require 'json'
require 'erb'
class Application
def self.application
@application ||= new
end
def self.configure(&blk)
application.instance_eval(&blk)
application
end
def handle_request(env)
request_method = env['REQUEST_METHOD'].downcase
request_path = env['REQUEST_PATH']
route_match = router.get_match_for(request_method, request_path)
response = if route_match
begin
param_group = route_match.route.param_group
query_params = env['QUERY_STRING'].split("&").map{|el| el.split("=")}.to_h
scrubbed_params = param_group.validate_and_scrub_params!(route_match.params.merge(query_params))
route_match.route.handler.call(scrubbed_params)
rescue UserError => e
Response.new(status_code: e.status_code, body: e.body)
end
elsif request_method == HTTPMethod.options
allowable_methods = router.all_matching_route_methods(request_path).map(&:upcase).join(",")
Response.new(status_code: 200, headers: {'Allow' => allowable_methods}, body: "")
else
Response.new(status_code: 404, body: "Not found.")
end
response.to_rack_format
end
private
def router
@router ||= Router.new
end
end
class UserError < StandardError
attr_reader :body, :status_code
def initialize(body, status_code: 400)
@body = body
@status_code = status_code
end
end
module HTTPMethod
%w(get post put delete patch head options).each do |m|
define_singleton_method(m) { m }
end
end
class Router
def initialize
@routes = []
end
def self.compile_path(path)
regexpified = path.gsub(/:([^\/]*)/, '(?<\1>[^\/]*)')
Regexp.new("\\A#{regexpified}\\z")
end
def get(path, param_group, &blk)
@routes << Route.new(HTTPMethod.get, Router.compile_path(path), param_group, &blk)
end
def post(path, param_group, &blk)
@routes << Route.new(HTTPMethod.post, Router.compile_path(path), param_group, &blk)
end
def put(path, param_group, &blk)
@routes << Route.new(HTTPMethod.put, Router.compile_path(path), param_group, &blk)
end
def delete(path, param_group, &blk)
@routes << Route.new(HTTPMethod.delete, Router.compile_path(path), param_group, &blk)
end
def patch(path, param_group, &blk)
@routes << Route.new(HTTPMethod.patch, Router.compile_path(path), param_group, &blk)
end
def head(path, param_group, &blk)
@routes << Route.new(HTTPMethod.head, Router.compile_path(path), param_group, &blk)
end
def options(path, param_group, &blk)
@routes << Route.new(HTTPMethod.options, Router.compile_path(path), param_group, &blk)
end
def get_match_for(http_method, path)
@routes.each do |route|
uri_params = route.match(http_method, path)
return RouteMatch.new(route, uri_params) if uri_params
end
nil
end
def all_matching_route_methods(path)
matching_routes = @routes.select do |route|
route.path_match?(path)
end
matching_routes.map(&:method)
end
end
class RouteMatch
attr_reader :route, :params
def initialize(route, params)
@route = route
@params = params
end
end
class Route
attr_reader :method, :matcher, :param_group, :handler
def initialize(method, matcher, param_group, &handler)
@method = method
@matcher = matcher
@param_group = param_group
@handler = handler
end
def path_match?(uri)
!!@matcher.match(uri)
end
def match(method, uri)
if method == self.method
match = @matcher.match(uri)
if match
return match.names.zip(match.captures).to_h
end
end
nil
end
end
class Response
attr_reader :status_code, :headers, :body
def initialize(body:, status_code: 200, headers:{})
@status_code = status_code
@headers = headers
@body = body
end
def to_rack_format
[status_code, headers, [body]]
end
end
class AbstractPresenter
attr_reader :target
def initialize(target)
@target = target
end
def representation; raise NotImplementedError; end
end
class AbstractJSONPresenter < AbstractPresenter
class Error < StandardError; end
class Node
attr_reader :name, :handler
def initialize(name, &blk)
@name = name
@handler = blk
end
end
def self.node(name, &blk)
nodes << Node.new(name, &blk)
end
def self.nodes
@nodes ||= []
end
# @Override
def representation
kv_pairs = self.class.nodes.map do |node|
name = node.name
if node.handler
[name, node.handler.call(target)]
elsif target.respond_to?(name)
[name, target.send(name)]
else
raise ::AbstractJSONPresenter::Error.new("Unable to determine representation of field '#{name}' for target #{target.class.name}")
end
end
kv_pairs.to_h.to_json
end
end
class AbstractERBPresenter < AbstractPresenter
def template; raise NotImplementedError; end
def representation
target_binding = @target.instance_eval { binding }
ERB.new(template).result(target_binding)
end
end
class EntityJSONPresenter < AbstractJSONPresenter
node :id
node :amount
node :synthetic do
"derived property"
end
end
class Entity < Struct.new(:id, :amount); end
class EntityERBPresenter < AbstractERBPresenter
def template
"<div class='lg'>entity <%= id %> with amount <%= amount %></div>"
end
end
class ParameterDefinition
attr_reader :name, :type, :validators
class Type
def self.scrub
value
end
end
class Integer < Type
def self.scrub(value)
value && value.match(/\A[-]*\d+\z/) && value.to_i
end
end
class Float < Type
def self.scrub(value)
value && value.match(/\A[-]*\d+\.\d+\z/) && value.to_f
end
end
class Boolean < Type
def self.scrub(value)
value == "true"
end
end
class String < Type; end
def initialize(name, type, required, validators)
@name = name
@type = type
@validators = validators
@validators.push(PresentValidator) if required
@validators.freeze
end
def validate(scrubbed_value)
validation_results = validators.map do |validator|
validator.validate(scrubbed_value)
end
validation_results.compact
end
def scrub(value)
@type.scrub(value)
end
end
class AbstractParameterGroup
def self.param(name, type, validators = [], required: true)
self.params << ParameterDefinition.new(name, type, required, validators)
end
def self.params
@params ||= []
end
def self.validate_and_scrub_params!(params)
extraneous_params = params.keys - self.params.map(&:name)
if reject_extraneous_params && extraneous_params.any?
raise ::UserError.new("Extraneous parameters provided: #{extraneous_params.join(', ')}")
end
scrubbed_params = scrub_params(params)
self.params.each do |param_def|
validation_results = param_def.validate(scrubbed_params[param_def.name])
unless validation_results.all?(&:valid)
validation_result = validation_results.first
validation_error = validation_result.error_message.gsub('{}', param_def.name)
raise ::UserError.new(validation_error)
end
end
scrubbed_params
end
def self.scrub_params(params)
results = self.params.map do |param_def|
[param_def.name, param_def.scrub(params[param_def.name])]
end
results.to_h
end
def self.reject_extraneous_params
true
end
end
class AbstractValidator
class ValidationResult < Struct.new(:valid, :error_message); end
def self.validate(value); raise NotImplementedError; end
end
class PresentValidator < AbstractValidator
def self.validate(value)
ValidationResult.new(!!value, "{} must be provided.")
end
end
class NonNegativeIntegerValidator < AbstractValidator
def self.validate(value)
ValidationResult.new(value && value >= 0 && value.is_a?(Fixnum), "{} is not a non-negative integer.")
end
end
class EntityIdInput < AbstractParameterGroup
param('id', ParameterDefinition::Integer, [NonNegativeIntegerValidator])
end
class AnyInput < AbstractParameterGroup
def self.reject_extraneous_params; false; end
end
class EntityIdRefudIdInput < AbstractParameterGroup
param('id', ParameterDefinition::Integer)
param('subentity_id', ParameterDefinition::Integer)
end
Application.configure do
router.get "/v1/entities", AnyInput do |params|
Response.new(body: "This is /v1/entities with #{params}")
end
router.get "/v1/entities/:id", EntityIdInput do |params|
entity = Entity.new(params['id'], 15000)
Response.new(body: EntityJSONPresenter.new(entity).representation)
end
router.get "/entities/:id", EntityIdInput do |params|
entity = Entity.new(params['id'], 15000)
Response.new(body: EntityERBPresenter.new(entity).representation)
end
router.get "/v1/entities/:id/subentities/:subentity_id", AnyInput do |params|
Response.new(body: "This is /v1/entities/:id/subentities/:subentity_id with #{params}")
end
end
Rack::Handler::WEBrick.run -> env { Application.application.handle_request(env) }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment