Skip to content

Commit

Permalink
extract a service class
Browse files Browse the repository at this point in the history
  • Loading branch information
technoweenie committed Nov 26, 2011
1 parent 1d80687 commit 228ff3d
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 35 deletions.
1 change: 1 addition & 0 deletions lib/guillotine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module Guillotine
VERSION = "1.0.8"

dir = File.expand_path '../guillotine', __FILE__
require "#{dir}/service"
autoload :App, "#{dir}/app"

class DuplicateCodeError < StandardError
Expand Down
42 changes: 12 additions & 30 deletions lib/guillotine/app.rb
Original file line number Diff line number Diff line change
@@ -1,53 +1,35 @@
require 'sinatra/base'

module Guillotine
# Essentially herds Sinatra input to Guillotine::Service, and ensures the
# output is fit Sinatra to return.
class App < Sinatra::Base
set :required_host, nil
set :service, nil

get "/:code" do
code = params[:code]
if url = settings.db.find(Addressable::URI.escape(code))
redirect settings.db.parse_url(url).to_s
else
halt 404, simple_escape("No url found for #{code}")
end
escaped = Addressable::URI.escape(params[:code])
status, head, body = settings.service.get(escaped)
[status, head, simple_escape(body)]
end

post "/" do
url = settings.db.parse_url params[:url].to_s

if !(url && url.scheme =~ /^https?$/)
halt 422, simple_escape("Invalid url: #{url}")
end
status, head, body = settings.service.create(params[:url], params[:code])

case settings.required_host
when String
if url.host != settings.required_host
halt 422, simple_escape("URL must be from #{settings.required_host}")
end
when Regexp
if url.host.to_s !~ settings.required_host
halt 422, simple_escape("URL must match #{settings.required_host.inspect}")
end
if loc = head && head['Location']
head['Location'] = File.join(request.url, loc)
end

begin
if code = settings.db.add(url.to_s, params[:code])
redirect code, 201
else
halt 422, simple_escape("Unable to shorten #{url}")
end
rescue Guillotine::DuplicateCodeError => err
halt 422, simple_escape(err.to_s)
end
[status, head, simple_escape(body)]
end

# Guillotine output is supposed to be text/plain friendly, so only strip
# /<|>/. Broken tie fighter :( If you're passing these characters in,
# you're probably doing something naughty.
def simple_escape(s)
s = s.to_s
s.gsub! /<|>/, ''
s
end
end
end

131 changes: 131 additions & 0 deletions lib/guillotine/service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
module Guillotine
class Service
class NullChecker
def call(url)
end
end

# This is the public API to the Guillotine service. Wire this up to Sinatra
# or whatever. Every public method should return a compatible Rack Response:
# [Integer Status, Hash headers, String body].
#
# db - A Guillotine::Adapter instance.
# required_host - Either a String or Regex limiting which domains the
# shortened URLs can come from.
#
def initialize(db, required_host = nil)
@db = db
build_host_check(required_host)
end

# Public: Gets the full URL for a shortened code.
#
# code - A String short code.
#
# Returns 302 with the Location header pointing to the URL on a hit,
# or 404 on a miss.
def get(code)
if url = @db.find(code)
[302, {"Location" => @db.parse_url(url).to_s}]
else
[404, nil, "No url found for #{code}"]
end
end

# Public: Maps a URL to a shortened code.
#
# url - A String or Addressable::URI URL to shorten.
# code - Optional String code to use. Defaults to a random String.
#
# Returns 201 with the Location pointing to the code, or 422.
def create(url, code = nil)
url = ensure_url(url)
if !(url && url.scheme.to_s =~ /^https?$/)
return [422, nil, "Invalid url: #{url}"]
end

if resp = check_host(url)
return resp
end

begin
if code = @db.add(url.to_s, code)
[201, {"Location" => code}]
else
[422, nil, "Unable to shorten #{url}"]
end
rescue DuplicateCodeError => err
[422, nil, err.to_s]
end
end

# Checks to see if the input URL is using a valid host. You can constrain
# the hosts with the `required_host` argument of the Service constructor.
#
# url - An Addressible::URI instance to check.
#
# Returns a 422 Rack::Response if the host is invalid, or nil.
def check_host(url)
@host_check.call url
end

# Converts the `required_host` argument to a lambda for #check_host.
#
# host_check - Either a String or Regex limiting which domains the
# shortened URLs can come from.
#
# Returns nothing.
def build_host_check(host_check)
case host_check
when nil
@host_check = NullChecker.new
when Regexp
build_host_regex_check(host_check)
else
build_host_string_check(host_check.to_s)
end
end

# Builds the host check lambda for regexes.
#
# regex - The Regexp that limits allowable URL hosts.
#
# Returns a Lambda that verifies an Addressible::URI.
def build_host_regex_check(regex)
@host_check = lambda do |url|
if url.host.to_s !~ regex
[422, nil, "URL must match #{regex.inspect}"]
end
end
end

# Builds the host check lambda for Strings.
#
# hostname - The String that limits allowable URL hosts.
#
# Returns a Lambda that verifies an Addressible::URI.
def build_host_string_check(hostname)
@host_check = lambda do |url|
if url.host != hostname
[422, nil, "URL must be from #{hostname}"]
end
end
end

# Ensures that the argument is an Addressable::URI.
#
# str - A String URL or an Addressable::URI.
#
# Returns an Addressable::URI.
def ensure_url(str)
if str.respond_to?(:scheme)
str
else
str = str.to_s
str.gsub! /\s/, ''
str.gsub! /(\#|\?).*/, ''
Addressable::URI.parse str
end
end
end
end
12 changes: 7 additions & 5 deletions test/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
module Guillotine
class AppTest < TestCase
ADAPTER = Adapters::MemoryAdapter.new
App.set :db, ADAPTER
SERVICE = Service.new(ADAPTER)
App.set :service, SERVICE

include Rack::Test::Methods

def test_adding_a_link_returns_code
url = 'http://github.com'
post '/', :url => url + '?a=1'
Expand Down Expand Up @@ -87,7 +89,7 @@ def test_rejects_non_http_urls
end

def test_reject_shortened_url_from_other_domain
Guillotine::App.set :required_host, 'abc.com'
App.set :service, Service.new(ADAPTER, 'abc.com')
post '/', :url => 'http://github.com'
assert_equal 422, last_response.status
assert_match /must be from abc\.com/, last_response.body
Expand All @@ -99,7 +101,7 @@ def test_reject_shortened_url_from_other_domain
end

def test_reject_shortened_url_from_other_domain_by_regex
Guillotine::App.set :required_host, /abc\.com$/
App.set :service, Service.new(ADAPTER, /abc\.com$/)
post '/', :url => 'http://github.com'
assert_equal 422, last_response.status
assert_match /must match \/abc\\.com/, last_response.body
Expand All @@ -110,11 +112,11 @@ def test_reject_shortened_url_from_other_domain_by_regex
post '/', :url => 'http://www.abc.com/def'
assert_equal 201, last_response.status
ensure
Guillotine::App.set :required_host, nil
App.set :service, SERVICE
end

def app
Guillotine::App
App
end
end
end
Expand Down
115 changes: 115 additions & 0 deletions test/service_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
require File.expand_path('../helper', __FILE__)

module Guillotine
class ServiceTest < TestCase
def setup
@db = Adapters::MemoryAdapter.new
@service = Service.new @db
end

def test_adding_a_link_returns_code
url = 'http://github.com'
status, head, body = @service.create(url + '?a=1')
assert_equal 201, status
assert code_url = head['Location']
code = code_url.gsub(/.*\//, '')

status, head, body = @service.get(code)
assert_equal 302, status
assert_equal url, head['Location']
end

def test_adding_duplicate_link_returns_same_code
url = 'http://github.com'
code = @db.add url

status, head, body = @service.create(url + '#a=1')
assert code_url = head['Location']
assert_equal code, code_url.gsub(/.*\//, '')
end

def test_adds_url_with_custom_code
url = 'http://github.com/abc'

status, head, body = @service.create(url, 'code')
assert code_url = head['Location']
assert_equal 'code', code_url

status, head, body = @service.get('code')
assert_equal 302, status
assert_equal url, head['Location']
end

def test_adds_url_with_custom_unicode
url = 'http://github.com/abc'
status, head, body = @service.create(url, '%E2%9C%88')
assert code_url = head['Location']
assert_match /^%E2%9C%88$/, code_url

status, head, body = @service.get("%E2%9C%88")
assert_equal 302, status
assert_equal url, head['Location']
end

def test_redirects_to_split_url
url = "http://abc.com\nhttp//def.com"
@db.hash['split'] = url
@db.urls[url] = 'split'

status, head, body = @service.get('split')
assert_equal "http://abc.comhttp//def.com", head['Location']
end

def test_clashing_urls_raises_error
code = @db.add 'http://github.com/123'
status, head, body = @service.create('http://github.com/456', code)
assert_equal 422, status
end

def test_add_without_url
status, head, body = @service.create(nil)
assert_equal 422, status
end

def test_add_url_with_linebreak
status, head, body = @service.create("https://abc.com\n")
assert_equal 'SWtBvQ', head['Location']
end

def test_adds_split_url
status, head, body = @service.create("https://abc.com\nhttp://abc.com")
assert_equal 'cb5CNA', head['Location']

assert_equal 'https://abc.comhttp//abc.com', @db.find('cb5CNA')
end

def test_rejects_non_http_urls
status, head, body = @service.create('ftp://abc.com')
assert_equal 422, status
end

def test_reject_shortened_url_from_other_domain
service = Service.new @db, 'abc.com'
status, head, body = service.create('http://github.com')
assert_equal 422, status
assert_match /must be from abc\.com/, body

status, head, body = service.create('http://abc.com/def')
assert_equal 201, status
end

def test_reject_shortened_url_from_other_domain_by_regex
service = Service.new @db, /abc\.com$/
status, head, body = service.create('http://github.com')
assert_equal 422, status
assert_match /must match \/abc\\.com/, body

status, head, body = service.create('http://abc.com/def')
assert_equal 201, status

status, head, body = service.create('http://www.abc.com/def')
assert_equal 201, status
end
end
end

0 comments on commit 228ff3d

Please sign in to comment.