Last active
July 13, 2016 21:06
-
-
Save maxwells/02340eb37af87659f84af3268831c4bf to your computer and use it in GitHub Desktop.
2 hour sinatra
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
[email protected](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