Initial work on shortner package

This commit is contained in:
2016-07-05 23:19:24 +01:00
parent d1a330187e
commit 089b481fc0
4 changed files with 369 additions and 0 deletions

65
shortner/normalize_url.go Normal file
View File

@@ -0,0 +1,65 @@
package shortner
import (
"errors"
"net/url"
)
var errURLFileSchema = errors.New("Schema 'file://' not allowed")
var errURLInvalid = errors.New("Invalid URL")
var slashByte = byte(47)
var maxLengthURL = 2048
// NormalizeURL validates and normalizes given rawURL string.
func NormalizeURL(rawURL []byte) ([]byte, error) {
url, err := normalizeURLPassOne(rawURL)
if err != nil {
return []byte{}, err
}
url, err = normalizeURLPassTwo(url)
if err != nil {
return []byte{}, err
}
return url, nil
}
func normalizeURLPassOne(rawURL []byte) ([]byte, error) {
if len(rawURL) > maxLengthURL {
return []byte{}, errURLInvalid
}
u, err := url.Parse(string(rawURL))
if err != nil {
return []byte{}, errURLInvalid
}
if u.Scheme == "" {
u.Scheme = "http"
}
if u.Scheme == "file" {
return []byte{}, errURLFileSchema
}
if u.Host == "" && (u.Path == "" || u.Path[0] == slashByte) {
return []byte{}, errURLInvalid
}
return []byte(u.String()), nil
}
func normalizeURLPassTwo(rawURL []byte) ([]byte, error) {
u, err := url.Parse(string(rawURL))
if err != nil {
return []byte{}, errURLInvalid
}
if u.Host != "" && u.Path == "" {
u.Path = "/"
}
return []byte(u.String()), nil
}

View File

@@ -0,0 +1,115 @@
package shortner
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var examples = []struct {
valid bool
url string
normalized string
error string
}{
{valid: true, url: "google.com", normalized: "http://google.com/"},
{valid: true, url: "google.com/", normalized: "http://google.com/"},
{valid: true, url: "http://google.com", normalized: "http://google.com/"},
{valid: true, url: "http://google.com/"},
{valid: true, url: "https://google.com", normalized: "https://google.com/"},
{valid: true, url: "https://google.com/"},
{valid: true, url: "google.yeah", normalized: "http://google.yeah/"},
{valid: true, url: "http://news.google.com/"},
{valid: true, url: "http://google.com/?h=en&foo=bar"},
{valid: true,
url: "http://google.com?h=en&foo=bar",
normalized: "http://google.com/?h=en&foo=bar"},
{valid: true,
url: "google.com/?h=en&foo=bar",
normalized: "http://google.com/?h=en&foo=bar"},
{valid: true,
url: "google.com?h=en&foo=bar",
normalized: "http://google.com/?h=en&foo=bar"},
{valid: true, url: "http://google.com/#nope"},
{valid: true,
url: "http://google.com#nope",
normalized: "http://google.com/#nope"},
{valid: true,
url: "google.com/#nope",
normalized: "http://google.com/#nope"},
{valid: true,
url: "google.com#nope",
normalized: "http://google.com/#nope"},
{valid: true, url: "http://google.com/?h=en&foo=bar#nope"},
{valid: true,
url: "http://google.com?h=en&foo=bar#nope",
normalized: "http://google.com/?h=en&foo=bar#nope"},
{valid: true,
url: "google.com/?h=en&foo=bar#nope",
normalized: "http://google.com/?h=en&foo=bar#nope"},
{valid: true,
url: "google.com?h=en&foo=bar#nope",
normalized: "http://google.com/?h=en&foo=bar#nope"},
{valid: true, url: "(248034)", normalized: "http://(248034)/"},
{
valid: false,
url: "*$)]+_<?)",
error: "Invalid URL",
},
{
valid: false,
url: "",
error: "Invalid URL",
},
{
valid: false,
url: "file:///bin/bash",
error: "Schema 'file://' not allowed",
},
{
valid: false,
url: "/users/view.php?uid=138495",
error: "Invalid URL",
},
{
valid: true,
url: "users/view.php?uid=138495",
normalized: "http://users/view.php?uid=138495",
},
{
valid: true,
url: "http://long.com/" + strings.Repeat("0", 2032),
},
{
valid: false,
url: "http://long.com/" + strings.Repeat("0", 3000),
error: "Invalid URL",
},
}
func TestNormalizeURL(t *testing.T) {
assert := assert.New(t)
for _, e := range examples {
result, err := NormalizeURL([]byte(e.url))
if e.valid {
assert.Nil(err)
if e.normalized != "" {
assert.Equal([]byte(e.normalized), result)
} else {
assert.Equal([]byte(e.url), result)
}
} else {
assert.NotNil(err, "Expected error, got nil.")
if e.error != "" {
assert.EqualError(err, e.error, "URL: "+e.url)
}
}
}
}

86
shortner/shortner.go Normal file
View File

@@ -0,0 +1,86 @@
package shortner
import (
"errors"
"github.com/jimeh/go-base58"
"github.com/jimeh/ozu.io/storage"
)
// New returns a new *Shortner that uses the given storage.Store.
func New(store storage.Store) *Shortner {
return &Shortner{Store: store}
}
var urlKeyPrefix = []byte("url:")
var uidKeyPrefix = []byte("uid:")
var errNotFound = errors.New("not found")
// Shortner interface
type Shortner struct {
Store storage.Store
}
// Shorten a given URL.
func (s *Shortner) Shorten(rawURL []byte) (uid []byte, url []byte, err error) {
url, err = NormalizeURL(rawURL)
if err != nil {
return []byte{}, []byte{}, err
}
urlKey := s.makeURLKey(url)
uid, err = s.Store.Get(urlKey)
if uid != nil && err == nil {
return uid, url, nil
} else if err != nil && err.Error() != "not found" {
return []byte{}, []byte{}, nil
}
uid, err = s.newUID()
if err != nil {
return []byte{}, []byte{}, err
}
err = s.Store.Set(urlKey, uid)
if err != nil {
return []byte{}, []byte{}, err
}
uidKey := s.makeUIDKey(uid)
err = s.Store.Set(uidKey, url)
if err != nil {
return []byte{}, []byte{}, err
}
return uid, url, nil
}
// Lookup the URL of a given UID.
func (s *Shortner) Lookup(uid []byte) ([]byte, error) {
uidKey := s.makeUIDKey(uid)
url, err := s.Store.Get(uidKey)
if err != nil {
return []byte{}, err
}
return url, nil
}
func (s *Shortner) newUID() ([]byte, error) {
index, err := s.Store.NextSequence()
if err != nil {
return []byte{}, err
}
return base58.Encode(index), nil
}
func (s *Shortner) makeUIDKey(uid []byte) []byte {
return append(uidKeyPrefix, uid...)
}
func (s *Shortner) makeURLKey(rawURL []byte) []byte {
return append(urlKeyPrefix, rawURL...)
}

103
shortner/shortner_test.go Normal file
View File

@@ -0,0 +1,103 @@
package shortner
import (
"os"
"testing"
"github.com/jimeh/ozu.io/storage"
"github.com/jimeh/ozu.io/storage/goleveldbstore"
"github.com/stretchr/testify/suite"
)
// Test Cases
var shortenExamples = []struct {
uid string
url string
normalized string
}{
{uid: "ig", url: "google.com", normalized: "http://google.com/"},
{uid: "ih", url: "https://google.com", normalized: "https://google.com/"},
{uid: "ig", url: "http://google.com", normalized: "http://google.com/"},
{uid: "ih", url: "https://google.com/"},
{uid: "ig", url: "google.com/", normalized: "http://google.com/"},
{uid: "ii", url: "https://github.com/"},
{uid: "ij", url: "https://gist.github.com/"},
}
// Setup Suite
var testDbPath = "./goleveldb_test_data"
type ShortnerSuite struct {
suite.Suite
shortner *Shortner
store storage.Store
}
func (s *ShortnerSuite) SetupTest() {
store, err := goleveldbstore.New(testDbPath)
s.Require().NoError(err)
err = store.Set(goleveldbstore.DefaultSequenceKey, []byte("1000"))
s.Require().NoError(err)
s.store = store
s.shortner = New(store)
}
func (s *ShortnerSuite) TearDownTest() {
_ = s.store.Close()
_ = os.RemoveAll(testDbPath)
}
func (s *ShortnerSuite) Seed() {
r := s.Require()
for _, e := range shortenExamples {
uid, url, err := s.shortner.Shorten([]byte(e.url))
r.Equal([]byte(e.uid), uid)
if e.normalized != "" {
r.Equal([]byte(e.normalized), url)
} else {
r.Equal([]byte(e.url), url)
}
r.NoError(err)
}
}
// Tests
func (s *ShortnerSuite) TestShorten() {
for _, e := range shortenExamples {
uid, url, err := s.shortner.Shorten([]byte(e.url))
s.Equal(nil, err)
s.Equal([]byte(e.uid), uid)
if e.normalized != "" {
s.Equal([]byte(e.normalized), url)
} else {
s.Equal([]byte(e.url), url)
}
}
}
func (s *ShortnerSuite) TestLookup() {
s.Seed()
for _, e := range shortenExamples {
url, err := s.shortner.Lookup([]byte(e.uid))
s.NoError(err)
if e.normalized != "" {
s.Equal([]byte(e.normalized), url)
} else {
s.Equal([]byte(e.url), url)
}
}
}
// Run Suite
func TestShortnerSuite(t *testing.T) {
suite.Run(t, new(ShortnerSuite))
}