guillotine 1.0.8 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -1,12 +1,22 @@
1
1
  source 'http://rubygems.org'
2
2
 
3
- gem 'sinatra'
4
- gem 'addressable'
3
+ gemspec
4
+
5
+ gem 'rake'
5
6
 
6
7
  group :test do
7
8
  gem 'rack-test'
8
9
  end
9
10
 
11
+ if ENV['TRAVIS']
12
+ gem 'sequel'
13
+ gem 'sqlite3'
14
+ gem 'redis'
15
+ gem 'mongo'
16
+ gem 'bson_ext'
17
+ gem 'riak-client'
18
+ end
19
+
10
20
  # Bundler isn't designed to provide optional functionality like this. You're
11
21
  # on your own
12
22
  #
data/guillotine.gemspec CHANGED
@@ -13,8 +13,8 @@ Gem::Specification.new do |s|
13
13
  ## If your rubyforge_project name is different, then edit it and comment out
14
14
  ## the sub! line in the Rakefile
15
15
  s.name = 'guillotine'
16
- s.version = '1.0.8'
17
- s.date = '2011-11-11'
16
+ s.version = '1.1.0'
17
+ s.date = '2011-11-26'
18
18
  s.rubyforge_project = 'guillotine'
19
19
 
20
20
  ## Make sure your summary is short. The description may be as long
@@ -56,15 +56,22 @@ Gem::Specification.new do |s|
56
56
  lib/guillotine.rb
57
57
  lib/guillotine/adapters/active_record_adapter.rb
58
58
  lib/guillotine/adapters/memory_adapter.rb
59
+ lib/guillotine/adapters/mongo_adapter.rb
60
+ lib/guillotine/adapters/redis_adapter.rb
59
61
  lib/guillotine/adapters/riak_adapter.rb
60
62
  lib/guillotine/adapters/sequel_adapter.rb
61
63
  lib/guillotine/app.rb
64
+ lib/guillotine/service.rb
65
+ script/cibuild
62
66
  test/active_record_adapter_test.rb
63
67
  test/app_test.rb
64
68
  test/helper.rb
65
69
  test/memory_adapter_test.rb
70
+ test/mongo_adapter_test.rb
71
+ test/redis_adapter_test.rb
66
72
  test/riak_adapter_test.rb
67
73
  test/sequel_adapter_test.rb
74
+ test/service_test.rb
68
75
  ]
69
76
  # = MANIFEST =
70
77
 
@@ -23,7 +23,7 @@ module Guillotine
23
23
  code ||= shorten url
24
24
  begin
25
25
  Url.create :url => url, :code => code
26
- rescue ActiveRecord::RecordNotUnique
26
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid
27
27
  row = Url.select(:url).where(:code => code).first
28
28
  existing_url = row && row[:url]
29
29
  raise DuplicateCodeError.new(existing_url, url, code)
@@ -38,9 +38,7 @@ module Guillotine
38
38
  #
39
39
  # Returns the String URL.
40
40
  def find(code)
41
- if row = Url.select(:url).where(:code => code).first
42
- row[:url]
43
- end
41
+ select :url, :code => code
44
42
  end
45
43
 
46
44
  # Public: Retrieves the code for a given URL.
@@ -49,9 +47,7 @@ module Guillotine
49
47
  #
50
48
  # Returns the String code, or nil if none is found.
51
49
  def code_for(url)
52
- if row = Url.select(:code).where(:url => url).first
53
- row[:code]
54
- end
50
+ select :code, :url => url
55
51
  end
56
52
 
57
53
  # Public: Removes the assigned short code for a URL.
@@ -60,7 +56,7 @@ module Guillotine
60
56
  #
61
57
  # Returns nothing.
62
58
  def clear(url)
63
- Url.where(:url => url).delete
59
+ Url.where(:url => url).delete_all
64
60
  end
65
61
 
66
62
  def setup
@@ -73,6 +69,12 @@ module Guillotine
73
69
  conn.add_index :urls, :url, :unique => true
74
70
  conn.add_index :urls, :code, :unique => true
75
71
  end
72
+
73
+ def select(field, query)
74
+ if row = Url.select(field).where(query).first
75
+ row[field]
76
+ end
77
+ end
76
78
  end
77
79
  end
78
80
  end
@@ -0,0 +1,70 @@
1
+ require 'mongo'
2
+
3
+ module Guillotine
4
+ module Adapters
5
+ class MongoAdapter < Adapter
6
+ def initialize(collection)
7
+ @collection = collection
8
+ @collection.ensure_index([['url', Mongo::ASCENDING]])
9
+
10
+ # \m/
11
+ @transformers = {
12
+ :url => lambda { |doc| doc['url'] },
13
+ :code => lambda { |doc| doc['_id'] }
14
+ }
15
+ end
16
+
17
+ # Public: Stores the shortened version of a URL.
18
+ #
19
+ # url - The String URL to shorten and store.
20
+ # code - Optional String code for the URL.
21
+ #
22
+ # Returns the unique String code for the URL. If the URL is added
23
+ # multiple times, this should return the same code.
24
+ def add(url, code = nil)
25
+ code_for(url) || insert(url, code || shorten(url))
26
+ end
27
+
28
+
29
+ # Public: Retrieves a URL from the code.
30
+ #
31
+ # code - The String code to lookup the URL.
32
+ #
33
+ # Returns the String URL, or nil if none is found.
34
+ def find(code)
35
+ select :url, :_id => code
36
+ end
37
+
38
+ # Public: Retrieves the code for a given URL.
39
+ #
40
+ # url - The String URL to lookup.
41
+ #
42
+ # Returns the String code, or nil if none is found.
43
+ def code_for(url)
44
+ select :code, :url => url
45
+ end
46
+
47
+ # Public: Removes the assigned short code for a URL.
48
+ #
49
+ # url - The String URL to remove.
50
+ #
51
+ # Returns nothing.
52
+ def clear(url)
53
+ @collection.remove(:url => url)
54
+ end
55
+
56
+ def select(field, query)
57
+ @collection.find_one(query, {:transformer => @transformers[field]})
58
+ end
59
+
60
+ private
61
+ def insert(url, code)
62
+ if existing_url = find(code)
63
+ raise DuplicateCodeError.new(existing_url, url, code) if url != existing_url
64
+ end
65
+ @collection.insert(:_id => code, :url => url)
66
+ code
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,63 @@
1
+ module Guillotine
2
+ module Adapters
3
+ class RedisAdapter < Adapter
4
+ # Public: Initialise the adapter with a Redis instance.
5
+ #
6
+ # redis - A Redis instance to persist urls and codes to.
7
+ def initialize(redis)
8
+ @redis = redis
9
+ end
10
+
11
+ # Public: Stores the shortened version of a URL.
12
+ #
13
+ # url - The String URL to shorten and store.
14
+ # code - Optional String code for the URL.
15
+ #
16
+ # Returns the unique String code for the URL. If the URL is added
17
+ # multiple times, this should return the same code.
18
+ def add(url, code = nil)
19
+ if existing_code = @redis.get("guillotine:urls:#{url}")
20
+ existing_code
21
+ else
22
+ code ||= shorten(url)
23
+ if existing_url = @redis.get("guillotine:hash:#{code}")
24
+ raise DuplicateCodeError.new(existing_url, url, code) if url != existing_url
25
+ end
26
+ @redis.set "guillotine:hash:#{code}", url
27
+ @redis.set "guillotine:urls:#{url}", code
28
+ code
29
+ end
30
+ end
31
+
32
+ # Public: Retrieves a URL from the code.
33
+ #
34
+ # code - The String code to lookup the URL.
35
+ #
36
+ # Returns the String URL, or nil if none is found.
37
+ def find(code)
38
+ @redis.get "guillotine:hash:#{code}"
39
+ end
40
+
41
+ # Public: Retrieves the code for a given URL.
42
+ #
43
+ # url - The String URL to lookup.
44
+ #
45
+ # Returns the String code, or nil if none is found.
46
+ def code_for(url)
47
+ @redis.get "guillotine:urls:#{url}"
48
+ end
49
+
50
+ # Public: Removes the assigned short code for a URL.
51
+ #
52
+ # url - The String URL to remove.
53
+ #
54
+ # Returns nothing.
55
+ def clear(url)
56
+ if code = @redis.get("guillotine:urls:#{url}")
57
+ @redis.del "guillotine:urls:#{url}"
58
+ @redis.del "guillotine:hash:#{code}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -37,9 +37,7 @@ module Guillotine
37
37
  #
38
38
  # Returns the String URL.
39
39
  def find(code)
40
- if row = @table.select(:url).where(:code => code).first
41
- row[:url]
42
- end
40
+ select :url, :code => code
43
41
  end
44
42
 
45
43
  # Public: Retrieves the code for a given URL.
@@ -48,9 +46,7 @@ module Guillotine
48
46
  #
49
47
  # Returns the String code, or nil if none is found.
50
48
  def code_for(url)
51
- if row = @table.select(:code).where(:url => url).first
52
- row[:code]
53
- end
49
+ select :code, :url => url
54
50
  end
55
51
 
56
52
  # Public: Removes the assigned short code for a URL.
@@ -64,13 +60,19 @@ module Guillotine
64
60
 
65
61
  def setup
66
62
  @db.create_table :urls do
67
- string :url
68
- string :code
63
+ String :url
64
+ String :code
69
65
 
70
66
  unique :url
71
67
  unique :code
72
68
  end
73
69
  end
70
+
71
+ def select(field, query)
72
+ if row = @table.select(field).where(query).first
73
+ row[field]
74
+ end
75
+ end
74
76
  end
75
77
  end
76
78
  end
@@ -1,53 +1,35 @@
1
1
  require 'sinatra/base'
2
2
 
3
3
  module Guillotine
4
+ # Essentially herds Sinatra input to Guillotine::Service, and ensures the
5
+ # output is fit Sinatra to return.
4
6
  class App < Sinatra::Base
5
- set :required_host, nil
7
+ set :service, nil
6
8
 
7
9
  get "/:code" do
8
- code = params[:code]
9
- if url = settings.db.find(Addressable::URI.escape(code))
10
- redirect settings.db.parse_url(url).to_s
11
- else
12
- halt 404, simple_escape("No url found for #{code}")
13
- end
10
+ escaped = Addressable::URI.escape(params[:code])
11
+ status, head, body = settings.service.get(escaped)
12
+ [status, head, simple_escape(body)]
14
13
  end
15
14
 
16
15
  post "/" do
17
- url = settings.db.parse_url params[:url].to_s
18
-
19
- if !(url && url.scheme =~ /^https?$/)
20
- halt 422, simple_escape("Invalid url: #{url}")
21
- end
16
+ status, head, body = settings.service.create(params[:url], params[:code])
22
17
 
23
- case settings.required_host
24
- when String
25
- if url.host != settings.required_host
26
- halt 422, simple_escape("URL must be from #{settings.required_host}")
27
- end
28
- when Regexp
29
- if url.host.to_s !~ settings.required_host
30
- halt 422, simple_escape("URL must match #{settings.required_host.inspect}")
31
- end
18
+ if loc = head['Location']
19
+ head['Location'] = File.join(request.url, loc)
32
20
  end
33
21
 
34
- begin
35
- if code = settings.db.add(url.to_s, params[:code])
36
- redirect code, 201
37
- else
38
- halt 422, simple_escape("Unable to shorten #{url}")
39
- end
40
- rescue Guillotine::DuplicateCodeError => err
41
- halt 422, simple_escape(err.to_s)
42
- end
22
+ [status, head, simple_escape(body)]
43
23
  end
44
24
 
45
25
  # Guillotine output is supposed to be text/plain friendly, so only strip
46
26
  # /<|>/. Broken tie fighter :( If you're passing these characters in,
47
27
  # you're probably doing something naughty.
48
28
  def simple_escape(s)
29
+ s = s.to_s
49
30
  s.gsub! /<|>/, ''
50
31
  s
51
32
  end
52
33
  end
53
34
  end
35
+
@@ -0,0 +1,132 @@
1
+ module Guillotine
2
+ class Service
3
+ class NullChecker
4
+ def call(url)
5
+ end
6
+ end
7
+
8
+ # This is the public API to the Guillotine service. Wire this up to Sinatra
9
+ # or whatever. Every public method should return a compatible Rack Response:
10
+ # [Integer Status, Hash headers, String body].
11
+ #
12
+ # db - A Guillotine::Adapter instance.
13
+ # required_host - Either a String or Regex limiting which domains the
14
+ # shortened URLs can come from.
15
+ #
16
+ def initialize(db, required_host = nil)
17
+ @db = db
18
+ build_host_check(required_host)
19
+ end
20
+
21
+ # Public: Gets the full URL for a shortened code.
22
+ #
23
+ # code - A String short code.
24
+ #
25
+ # Returns 302 with the Location header pointing to the URL on a hit,
26
+ # or 404 on a miss.
27
+ def get(code)
28
+ if url = @db.find(code)
29
+ [302, {"Location" => @db.parse_url(url).to_s}]
30
+ else
31
+ [404, {}, "No url found for #{code}"]
32
+ end
33
+ end
34
+
35
+ # Public: Maps a URL to a shortened code.
36
+ #
37
+ # url - A String or Addressable::URI URL to shorten.
38
+ # code - Optional String code to use. Defaults to a random String.
39
+ #
40
+ # Returns 201 with the Location pointing to the code, or 422.
41
+ def create(url, code = nil)
42
+ url = ensure_url(url)
43
+
44
+ if resp = check_host(url)
45
+ return resp
46
+ end
47
+
48
+ begin
49
+ if code = @db.add(url.to_s, code)
50
+ [201, {"Location" => code}]
51
+ else
52
+ [422, {}, "Unable to shorten #{url}"]
53
+ end
54
+ rescue DuplicateCodeError => err
55
+ [422, {}, err.to_s]
56
+ end
57
+ end
58
+
59
+ # Checks to see if the input URL is using a valid host. You can constrain
60
+ # the hosts with the `required_host` argument of the Service constructor.
61
+ #
62
+ # url - An Addressible::URI instance to check.
63
+ #
64
+ # Returns a 422 Rack::Response if the host is invalid, or nil.
65
+ def check_host(url)
66
+ if url.scheme !~ /^https?$/
67
+ [422, {}, "Invalid url: #{url}"]
68
+ else
69
+ @host_check.call url
70
+ end
71
+ end
72
+
73
+ # Converts the `required_host` argument to a lambda for #check_host.
74
+ #
75
+ # host_check - Either a String or Regex limiting which domains the
76
+ # shortened URLs can come from.
77
+ #
78
+ # Returns nothing.
79
+ def build_host_check(host_check)
80
+ case host_check
81
+ when nil
82
+ @host_check = NullChecker.new
83
+ when Regexp
84
+ build_host_regex_check(host_check)
85
+ else
86
+ build_host_string_check(host_check.to_s)
87
+ end
88
+ end
89
+
90
+ # Builds the host check lambda for regexes.
91
+ #
92
+ # regex - The Regexp that limits allowable URL hosts.
93
+ #
94
+ # Returns a Lambda that verifies an Addressible::URI.
95
+ def build_host_regex_check(regex)
96
+ @host_check = lambda do |url|
97
+ if url.host.to_s !~ regex
98
+ [422, {}, "URL must match #{regex.inspect}"]
99
+ end
100
+ end
101
+ end
102
+
103
+ # Builds the host check lambda for Strings.
104
+ #
105
+ # hostname - The String that limits allowable URL hosts.
106
+ #
107
+ # Returns a Lambda that verifies an Addressible::URI.
108
+ def build_host_string_check(hostname)
109
+ @host_check = lambda do |url|
110
+ if url.host != hostname
111
+ [422, {}, "URL must be from #{hostname}"]
112
+ end
113
+ end
114
+ end
115
+
116
+ # Ensures that the argument is an Addressable::URI.
117
+ #
118
+ # str - A String URL or an Addressable::URI.
119
+ #
120
+ # Returns an Addressable::URI.
121
+ def ensure_url(str)
122
+ if str.respond_to?(:scheme)
123
+ str
124
+ else
125
+ str = str.to_s
126
+ str.gsub! /\s/, ''
127
+ str.gsub! /(\#|\?).*/, ''
128
+ Addressable::URI.parse str
129
+ end
130
+ end
131
+ end
132
+ end
data/lib/guillotine.rb CHANGED
@@ -3,9 +3,10 @@ require 'digest/md5'
3
3
  require 'addressable/uri'
4
4
 
5
5
  module Guillotine
6
- VERSION = "1.0.8"
6
+ VERSION = "1.1.0"
7
7
 
8
8
  dir = File.expand_path '../guillotine', __FILE__
9
+ require "#{dir}/service"
9
10
  autoload :App, "#{dir}/app"
10
11
 
11
12
  class DuplicateCodeError < StandardError
@@ -25,6 +26,8 @@ module Guillotine
25
26
  autoload :SequelAdapter, "#{dir}/sequel_adapter"
26
27
  autoload :RiakAdapter, "#{dir}/riak_adapter"
27
28
  autoload :ActiveRecordAdapter, "#{dir}/active_record_adapter"
29
+ autoload :RedisAdapter, "#{dir}/redis_adapter"
30
+ autoload :MongoAdapter, "#{dir}/mongo_adapter"
28
31
 
29
32
  # Adapters handle the storage and retrieval of URLs in the system. You can
30
33
  # use whatever you want, as long as it implements the #add and #find
data/script/cibuild ADDED
@@ -0,0 +1,2 @@
1
+ bundle install --binstubs --path vendor/gems
2
+ bin/rake
@@ -11,24 +11,24 @@ begin
11
11
  end
12
12
 
13
13
  def test_adding_a_link_returns_code
14
- code = @db.add 'abc'
15
- assert_equal 'abc', @db.find(code)
14
+ code = @db.add 'aaa'
15
+ assert_equal 'aaa', @db.find(code)
16
16
  end
17
17
 
18
18
  def test_adding_duplicate_link_returns_same_code
19
- code = @db.add 'abc'
20
- assert_equal code, @db.add('abc')
19
+ code = @db.add 'bbb'
20
+ assert_equal code, @db.add('bbb')
21
21
  end
22
22
 
23
23
  def test_adds_url_with_custom_code
24
- assert_equal 'code', @db.add('def', 'code')
25
- assert_equal 'def', @db.find('code')
24
+ assert_equal 'code', @db.add('ccc', 'code')
25
+ assert_equal 'ccc', @db.find('code')
26
26
  end
27
27
 
28
28
  def test_clashing_urls_raises_error
29
- code = @db.add '123'
29
+ code = @db.add 'ddd'
30
30
  assert_raises Guillotine::DuplicateCodeError do
31
- code = @db.add '456', code
31
+ code = @db.add 'eee', code
32
32
  end
33
33
  end
34
34
 
@@ -37,15 +37,15 @@ begin
37
37
  end
38
38
 
39
39
  def test_gets_code_for_url
40
- code = @db.add 'abc'
41
- assert_equal code, @db.code_for('abc')
40
+ code = @db.add 'fff'
41
+ assert_equal code, @db.code_for('fff')
42
42
  end
43
43
 
44
44
  def test_clears_code_for_url
45
- code = @db.add 'abc'
46
- assert_equal 'abc', @db.find(code)
45
+ code = @db.add 'ggg'
46
+ assert_equal 'ggg', @db.find(code)
47
47
 
48
- @db.clear 'abc'
48
+ @db.clear 'ggg'
49
49
 
50
50
  assert_nil @db.find(code)
51
51
  end
data/test/app_test.rb CHANGED
@@ -1,119 +1,123 @@
1
1
  require File.expand_path('../helper', __FILE__)
2
2
 
3
- class AppTest < Guillotine::TestCase
4
- ADAPTER = Guillotine::Adapters::MemoryAdapter.new
5
- Guillotine::App.set :db, ADAPTER
6
-
7
- include Rack::Test::Methods
8
-
9
- def test_adding_a_link_returns_code
10
- url = 'http://github.com'
11
- post '/', :url => url + '?a=1'
12
- assert_equal 201, last_response.status
13
- assert code_url = last_response.headers['Location']
14
- code = code_url.gsub(/.*\//, '')
15
-
16
- get "/#{code}"
17
- assert_equal 302, last_response.status
18
- assert_equal url, last_response.headers['Location']
19
- end
20
-
21
- def test_adding_duplicate_link_returns_same_code
22
- url = 'http://github.com'
23
- code = ADAPTER.add url
24
-
25
- post '/', :url => url + '#a=1'
26
- assert code_url = last_response.headers['Location']
27
- assert_equal code, code_url.gsub(/.*\//, '')
28
- end
29
-
30
- def test_adds_url_with_custom_code
31
- url = 'http://github.com/abc'
32
- post '/', :url => url, :code => 'code'
33
- assert code_url = last_response.headers['Location']
34
- assert_match /\/code$/, code_url
35
-
36
- get "/code"
37
- assert_equal 302, last_response.status
38
- assert_equal url, last_response.headers['Location']
39
- end
40
-
41
- def test_adds_url_with_custom_code
42
- url = 'http://github.com/abc'
43
- post '/', :url => url, :code => '%E2%9C%88'
44
- assert code_url = last_response.headers['Location']
45
- assert_match /\/%E2%9C%88$/, code_url
46
-
47
- get "/%E2%9C%88"
48
- assert_equal 302, last_response.status
49
- assert_equal url, last_response.headers['Location']
50
- end
51
-
52
- def test_redirects_to_split_url
53
- url = "http://abc.com\nhttp//def.com"
54
- ADAPTER.hash['split'] = url
55
- ADAPTER.urls[url] = 'split'
56
-
57
- get '/split'
58
- assert_equal "http://abc.comhttp//def.com", last_response.headers['location']
59
- end
60
-
61
- def test_clashing_urls_raises_error
62
- code = ADAPTER.add 'http://github.com/123'
63
- post '/', :url => 'http://github.com/456', :code => code
64
- assert_equal 422, last_response.status
65
- end
66
-
67
- def test_add_without_url
68
- post '/'
69
- assert_equal 422, last_response.status
70
- end
71
-
72
- def test_add_url_with_linebreak
73
- post '/', :url => "https://abc.com\n"
74
- assert_equal 'http://example.org/SWtBvQ', last_response.headers['location']
75
- end
76
-
77
- def test_adds_split_url
78
- post '/', :url => "https://abc.com\nhttp://abc.com"
79
- assert_equal 'http://example.org/cb5CNA', last_response.headers['location']
80
-
81
- assert_equal 'https://abc.comhttp//abc.com', ADAPTER.find('cb5CNA')
82
- end
83
-
84
- def test_rejects_non_http_urls
85
- post '/', :url => 'ftp://abc.com'
86
- assert_equal 422, last_response.status
87
- end
88
-
89
- def test_reject_shortened_url_from_other_domain
90
- Guillotine::App.set :required_host, 'abc.com'
91
- post '/', :url => 'http://github.com'
92
- assert_equal 422, last_response.status
93
- assert_match /must be from abc\.com/, last_response.body
94
-
95
- post '/', :url => 'http://abc.com/def'
96
- assert_equal 201, last_response.status
97
- ensure
98
- Guillotine::App.set :required_host, nil
99
- end
100
-
101
- def test_reject_shortened_url_from_other_domain_by_regex
102
- Guillotine::App.set :required_host, /abc\.com$/
103
- post '/', :url => 'http://github.com'
104
- assert_equal 422, last_response.status
105
- assert_match /must match \/abc\\.com/, last_response.body
106
-
107
- post '/', :url => 'http://abc.com/def'
108
- assert_equal 201, last_response.status
109
-
110
- post '/', :url => 'http://www.abc.com/def'
111
- assert_equal 201, last_response.status
112
- ensure
113
- Guillotine::App.set :required_host, nil
114
- end
115
-
116
- def app
117
- Guillotine::App
3
+ module Guillotine
4
+ class AppTest < TestCase
5
+ ADAPTER = Adapters::MemoryAdapter.new
6
+ SERVICE = Service.new(ADAPTER)
7
+ App.set :service, SERVICE
8
+
9
+ include Rack::Test::Methods
10
+
11
+ def test_adding_a_link_returns_code
12
+ url = 'http://github.com'
13
+ post '/', :url => url + '?a=1'
14
+ assert_equal 201, last_response.status
15
+ assert code_url = last_response.headers['Location']
16
+ code = code_url.gsub(/.*\//, '')
17
+
18
+ get "/#{code}"
19
+ assert_equal 302, last_response.status
20
+ assert_equal url, last_response.headers['Location']
21
+ end
22
+
23
+ def test_adding_duplicate_link_returns_same_code
24
+ url = 'http://github.com'
25
+ code = ADAPTER.add url
26
+
27
+ post '/', :url => url + '#a=1'
28
+ assert code_url = last_response.headers['Location']
29
+ assert_equal code, code_url.gsub(/.*\//, '')
30
+ end
31
+
32
+ def test_adds_url_with_custom_code
33
+ url = 'http://github.com/abc'
34
+ post '/', :url => url, :code => 'code'
35
+ assert code_url = last_response.headers['Location']
36
+ assert_match /\/code$/, code_url
37
+
38
+ get "/code"
39
+ assert_equal 302, last_response.status
40
+ assert_equal url, last_response.headers['Location']
41
+ end
42
+
43
+ def test_adds_url_with_custom_code
44
+ url = 'http://github.com/abc'
45
+ post '/', :url => url, :code => '%E2%9C%88'
46
+ assert code_url = last_response.headers['Location']
47
+ assert_match /\/%E2%9C%88$/, code_url
48
+
49
+ get "/%E2%9C%88"
50
+ assert_equal 302, last_response.status
51
+ assert_equal url, last_response.headers['Location']
52
+ end
53
+
54
+ def test_redirects_to_split_url
55
+ url = "http://abc.com\nhttp//def.com"
56
+ ADAPTER.hash['split'] = url
57
+ ADAPTER.urls[url] = 'split'
58
+
59
+ get '/split'
60
+ assert_equal "http://abc.comhttp//def.com", last_response.headers['location']
61
+ end
62
+
63
+ def test_clashing_urls_raises_error
64
+ code = ADAPTER.add 'http://github.com/123'
65
+ post '/', :url => 'http://github.com/456', :code => code
66
+ assert_equal 422, last_response.status
67
+ end
68
+
69
+ def test_add_without_url
70
+ post '/'
71
+ assert_equal 422, last_response.status
72
+ end
73
+
74
+ def test_add_url_with_linebreak
75
+ post '/', :url => "https://abc.com\n"
76
+ assert_equal 'http://example.org/SWtBvQ', last_response.headers['location']
77
+ end
78
+
79
+ def test_adds_split_url
80
+ post '/', :url => "https://abc.com\nhttp://abc.com"
81
+ assert_equal 'http://example.org/cb5CNA', last_response.headers['location']
82
+
83
+ assert_equal 'https://abc.comhttp//abc.com', ADAPTER.find('cb5CNA')
84
+ end
85
+
86
+ def test_rejects_non_http_urls
87
+ post '/', :url => 'ftp://abc.com'
88
+ assert_equal 422, last_response.status
89
+ end
90
+
91
+ def test_reject_shortened_url_from_other_domain
92
+ App.set :service, Service.new(ADAPTER, 'abc.com')
93
+ post '/', :url => 'http://github.com'
94
+ assert_equal 422, last_response.status
95
+ assert_match /must be from abc\.com/, last_response.body
96
+
97
+ post '/', :url => 'http://abc.com/def'
98
+ assert_equal 201, last_response.status
99
+ ensure
100
+ Guillotine::App.set :required_host, nil
101
+ end
102
+
103
+ def test_reject_shortened_url_from_other_domain_by_regex
104
+ App.set :service, Service.new(ADAPTER, /abc\.com$/)
105
+ post '/', :url => 'http://github.com'
106
+ assert_equal 422, last_response.status
107
+ assert_match /must match \/abc\\.com/, last_response.body
108
+
109
+ post '/', :url => 'http://abc.com/def'
110
+ assert_equal 201, last_response.status
111
+
112
+ post '/', :url => 'http://www.abc.com/def'
113
+ assert_equal 201, last_response.status
114
+ ensure
115
+ App.set :service, SERVICE
116
+ end
117
+
118
+ def app
119
+ App
120
+ end
118
121
  end
119
122
  end
123
+
@@ -0,0 +1,56 @@
1
+ require File.expand_path('../helper', __FILE__)
2
+
3
+ begin
4
+ require 'mongo'
5
+
6
+ class MongoAdapterTest < Guillotine::TestCase
7
+ def setup
8
+ @collection = Mongo::Connection.new.db('test').collection('guillotine')
9
+ @collection.drop
10
+ @db = Guillotine::Adapters::MongoAdapter.new(@collection)
11
+ end
12
+
13
+ def test_adding_a_link_returns_code
14
+ code = @db.add 'abc'
15
+ assert_equal 'abc', @db.find(code)
16
+ end
17
+
18
+ def test_adding_duplicate_link_returns_same_code
19
+ code = @db.add 'abc'
20
+ assert_equal code, @db.add('abc')
21
+ end
22
+
23
+ def test_adds_url_with_custom_code
24
+ assert_equal 'code', @db.add('def', 'code')
25
+ assert_equal 'def', @db.find('code')
26
+ end
27
+
28
+ def test_clashing_urls_raises_error
29
+ code = @db.add 'abc'
30
+ assert_raises Guillotine::DuplicateCodeError do
31
+ @db.add 'def', code
32
+ end
33
+ end
34
+
35
+ def test_missing_code
36
+ assert_nil @db.find('missing')
37
+ end
38
+
39
+ def test_gets_code_for_url
40
+ code = @db.add 'abc'
41
+ assert_equal code, @db.code_for('abc')
42
+ end
43
+
44
+ def test_clears_code_for_url
45
+ code = @db.add 'abc'
46
+ assert_equal 'abc', @db.find(code)
47
+
48
+ @db.clear 'abc'
49
+
50
+ assert_nil @db.find(code)
51
+ end
52
+ end
53
+ rescue LoadError
54
+ puts "Skipping MongoDB tests: #{$!}"
55
+ end
56
+
@@ -0,0 +1,56 @@
1
+ require File.expand_path('../helper', __FILE__)
2
+
3
+ begin
4
+ require "redis"
5
+
6
+ class RedisAdapterTest < Guillotine::TestCase
7
+ redis = Redis.new
8
+ ADAPTER = Guillotine::Adapters::RedisAdapter.new redis
9
+
10
+ def setup
11
+ @db = ADAPTER
12
+ end
13
+
14
+ def test_adding_a_link_returns_code
15
+ code = @db.add 'abc'
16
+ assert_equal 'abc', @db.find(code)
17
+ end
18
+
19
+ def test_adding_duplicate_link_returns_same_code
20
+ code = @db.add 'abc'
21
+ assert_equal code, @db.add('abc')
22
+ end
23
+
24
+ def test_adds_url_with_custom_code
25
+ assert_equal 'code', @db.add('def', 'code')
26
+ assert_equal 'def', @db.find('code')
27
+ end
28
+
29
+ def test_clashing_urls_raises_error
30
+ code = @db.add 'abc'
31
+ assert_raise Guillotine::DuplicateCodeError do
32
+ @db.add 'ghi', code
33
+ end
34
+ end
35
+
36
+ def test_missing_code
37
+ assert_nil @db.find('missing')
38
+ end
39
+
40
+ def test_gets_code_for_url
41
+ code = @db.add 'abc'
42
+ assert_equal code, @db.code_for('abc')
43
+ end
44
+
45
+ def test_clears_code_for_url
46
+ code = @db.add 'abc'
47
+ assert_equal 'abc', @db.find(code)
48
+
49
+ @db.clear 'abc'
50
+
51
+ assert_nil @db.find(code)
52
+ end
53
+ end
54
+ rescue LoadError
55
+ puts "Skipping Redis tests: #{$!}"
56
+ end
@@ -2,6 +2,12 @@ require File.expand_path('../helper', __FILE__)
2
2
 
3
3
  begin
4
4
  require 'riak/client'
5
+ # try Riak::Client with the default Riak port
6
+ RIAK_TEST_CLIENT = Riak::Client.new
7
+ # now try it with the default Riak dev port
8
+ RIAK_TEST_CLIENT.http_port = 8091 unless RIAK_TEST_CLIENT.ping
9
+
10
+ raise LoadError, "Could not ping (#{RIAK_TEST_CLIENT.inspect})" unless RIAK_TEST_CLIENT.ping
5
11
 
6
12
  # Assumes a local dev install of riak is setup
7
13
  #
@@ -11,9 +17,8 @@ begin
11
17
  #
12
18
  # http://localhost:8091/riak/guillotine-test
13
19
  class RiakAdapterTest < Guillotine::TestCase
14
- client = Riak::Client.new(:http_port => 8091)
15
- CODE_BUCKET = client["guillotine-code-test-#{Process.pid}"]
16
- URL_BUCKET = client["guillotine-url-test-#{Process.pid}"]
20
+ CODE_BUCKET = RIAK_TEST_CLIENT["guillotine-code-test-#{Process.pid}"]
21
+ URL_BUCKET = RIAK_TEST_CLIENT["guillotine-url-test-#{Process.pid}"]
17
22
  ADAPTER = Guillotine::Adapters::RiakAdapter.new CODE_BUCKET, URL_BUCKET
18
23
 
19
24
  def setup
@@ -0,0 +1,115 @@
1
+ require File.expand_path('../helper', __FILE__)
2
+
3
+ module Guillotine
4
+ class ServiceTest < TestCase
5
+ def setup
6
+ @db = Adapters::MemoryAdapter.new
7
+ @service = Service.new @db
8
+ end
9
+
10
+ def test_adding_a_link_returns_code
11
+ url = 'http://github.com'
12
+ status, head, body = @service.create(url + '?a=1')
13
+ assert_equal 201, status
14
+ assert code_url = head['Location']
15
+ code = code_url.gsub(/.*\//, '')
16
+
17
+ status, head, body = @service.get(code)
18
+ assert_equal 302, status
19
+ assert_equal url, head['Location']
20
+ end
21
+
22
+ def test_adding_duplicate_link_returns_same_code
23
+ url = 'http://github.com'
24
+ code = @db.add url
25
+
26
+ status, head, body = @service.create(url + '#a=1')
27
+ assert code_url = head['Location']
28
+ assert_equal code, code_url.gsub(/.*\//, '')
29
+ end
30
+
31
+ def test_adds_url_with_custom_code
32
+ url = 'http://github.com/abc'
33
+
34
+ status, head, body = @service.create(url, 'code')
35
+ assert code_url = head['Location']
36
+ assert_equal 'code', code_url
37
+
38
+ status, head, body = @service.get('code')
39
+ assert_equal 302, status
40
+ assert_equal url, head['Location']
41
+ end
42
+
43
+ def test_adds_url_with_custom_unicode
44
+ url = 'http://github.com/abc'
45
+ status, head, body = @service.create(url, '%E2%9C%88')
46
+ assert code_url = head['Location']
47
+ assert_match /^%E2%9C%88$/, code_url
48
+
49
+ status, head, body = @service.get("%E2%9C%88")
50
+ assert_equal 302, status
51
+ assert_equal url, head['Location']
52
+ end
53
+
54
+ def test_redirects_to_split_url
55
+ url = "http://abc.com\nhttp//def.com"
56
+ @db.hash['split'] = url
57
+ @db.urls[url] = 'split'
58
+
59
+ status, head, body = @service.get('split')
60
+ assert_equal "http://abc.comhttp//def.com", head['Location']
61
+ end
62
+
63
+ def test_clashing_urls_raises_error
64
+ code = @db.add 'http://github.com/123'
65
+ status, head, body = @service.create('http://github.com/456', code)
66
+ assert_equal 422, status
67
+ end
68
+
69
+ def test_add_without_url
70
+ status, head, body = @service.create(nil)
71
+ assert_equal 422, status
72
+ end
73
+
74
+ def test_add_url_with_linebreak
75
+ status, head, body = @service.create("https://abc.com\n")
76
+ assert_equal 'SWtBvQ', head['Location']
77
+ end
78
+
79
+ def test_adds_split_url
80
+ status, head, body = @service.create("https://abc.com\nhttp://abc.com")
81
+ assert_equal 'cb5CNA', head['Location']
82
+
83
+ assert_equal 'https://abc.comhttp//abc.com', @db.find('cb5CNA')
84
+ end
85
+
86
+ def test_rejects_non_http_urls
87
+ status, head, body = @service.create('ftp://abc.com')
88
+ assert_equal 422, status
89
+ end
90
+
91
+ def test_reject_shortened_url_from_other_domain
92
+ service = Service.new @db, 'abc.com'
93
+ status, head, body = service.create('http://github.com')
94
+ assert_equal 422, status
95
+ assert_match /must be from abc\.com/, body
96
+
97
+ status, head, body = service.create('http://abc.com/def')
98
+ assert_equal 201, status
99
+ end
100
+
101
+ def test_reject_shortened_url_from_other_domain_by_regex
102
+ service = Service.new @db, /abc\.com$/
103
+ status, head, body = service.create('http://github.com')
104
+ assert_equal 422, status
105
+ assert_match /must match \/abc\\.com/, body
106
+
107
+ status, head, body = service.create('http://abc.com/def')
108
+ assert_equal 201, status
109
+
110
+ status, head, body = service.create('http://www.abc.com/def')
111
+ assert_equal 201, status
112
+ end
113
+ end
114
+ end
115
+
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: guillotine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.1.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,11 +9,11 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2011-11-11 00:00:00.000000000Z
12
+ date: 2011-11-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: sinatra
16
- requirement: &70215141118220 !ruby/object:Gem::Requirement
16
+ requirement: &70212478964620 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ~>
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: 1.2.6
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *70215141118220
24
+ version_requirements: *70212478964620
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: addressable
27
- requirement: &70215141117700 !ruby/object:Gem::Requirement
27
+ requirement: &70212478964160 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ~>
@@ -32,10 +32,10 @@ dependencies:
32
32
  version: 2.2.6
33
33
  type: :runtime
34
34
  prerelease: false
35
- version_requirements: *70215141117700
35
+ version_requirements: *70212478964160
36
36
  - !ruby/object:Gem::Dependency
37
37
  name: rack-test
38
- requirement: &70215141117280 !ruby/object:Gem::Requirement
38
+ requirement: &70212478980140 !ruby/object:Gem::Requirement
39
39
  none: false
40
40
  requirements:
41
41
  - - ! '>='
@@ -43,7 +43,7 @@ dependencies:
43
43
  version: '0'
44
44
  type: :development
45
45
  prerelease: false
46
- version_requirements: *70215141117280
46
+ version_requirements: *70212478980140
47
47
  description: Adaptable private URL shortener
48
48
  email: technoweenie@gmail.com
49
49
  executables: []
@@ -59,15 +59,22 @@ files:
59
59
  - lib/guillotine.rb
60
60
  - lib/guillotine/adapters/active_record_adapter.rb
61
61
  - lib/guillotine/adapters/memory_adapter.rb
62
+ - lib/guillotine/adapters/mongo_adapter.rb
63
+ - lib/guillotine/adapters/redis_adapter.rb
62
64
  - lib/guillotine/adapters/riak_adapter.rb
63
65
  - lib/guillotine/adapters/sequel_adapter.rb
64
66
  - lib/guillotine/app.rb
67
+ - lib/guillotine/service.rb
68
+ - script/cibuild
65
69
  - test/active_record_adapter_test.rb
66
70
  - test/app_test.rb
67
71
  - test/helper.rb
68
72
  - test/memory_adapter_test.rb
73
+ - test/mongo_adapter_test.rb
74
+ - test/redis_adapter_test.rb
69
75
  - test/riak_adapter_test.rb
70
76
  - test/sequel_adapter_test.rb
77
+ - test/service_test.rb
71
78
  homepage: https://github.com/technoweenie/gitio
72
79
  licenses: []
73
80
  post_install_message:
@@ -80,6 +87,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
87
  - - ! '>='
81
88
  - !ruby/object:Gem::Version
82
89
  version: '0'
90
+ segments:
91
+ - 0
92
+ hash: 2311963122741215687
83
93
  required_rubygems_version: !ruby/object:Gem::Requirement
84
94
  none: false
85
95
  requirements:
@@ -88,7 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
98
  version: '0'
89
99
  requirements: []
90
100
  rubyforge_project: guillotine
91
- rubygems_version: 1.8.10
101
+ rubygems_version: 1.8.11
92
102
  signing_key:
93
103
  specification_version: 2
94
104
  summary: Adaptable private URL shortener
@@ -96,5 +106,8 @@ test_files:
96
106
  - test/active_record_adapter_test.rb
97
107
  - test/app_test.rb
98
108
  - test/memory_adapter_test.rb
109
+ - test/mongo_adapter_test.rb
110
+ - test/redis_adapter_test.rb
99
111
  - test/riak_adapter_test.rb
100
112
  - test/sequel_adapter_test.rb
113
+ - test/service_test.rb