mirror of
https://github.com/jimeh/ozu.io.git
synced 2026-02-19 08:06:39 +00:00
Fix epic typo :O
This commit is contained in:
94
shortener/mocks/Store.go
Normal file
94
shortener/mocks/Store.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package mocks
|
||||
|
||||
import "github.com/stretchr/testify/mock"
|
||||
|
||||
// Store is an autogenerated mock type for the Store type
|
||||
type Store struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Close provides a mock function with given fields:
|
||||
func (_m *Store) Close() error {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: _a0
|
||||
func (_m *Store) Delete(_a0 []byte) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func([]byte) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: _a0
|
||||
func (_m *Store) Get(_a0 []byte) ([]byte, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 []byte
|
||||
if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]byte)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]byte) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NextSequence provides a mock function with given fields:
|
||||
func (_m *Store) NextSequence() (int, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 int
|
||||
if rf, ok := ret.Get(0).(func() int); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(int)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Set provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Store) Set(_a0 []byte, _a1 []byte) error {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func([]byte, []byte) error); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
65
shortener/normalize_url.go
Normal file
65
shortener/normalize_url.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package shortener
|
||||
|
||||
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
|
||||
}
|
||||
115
shortener/normalize_url_test.go
Normal file
115
shortener/normalize_url_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package shortener
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
shortener/shortener.go
Normal file
87
shortener/shortener.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package shortener
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
|
||||
"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) *Shortener {
|
||||
return &Shortener{Store: store}
|
||||
}
|
||||
|
||||
var urlKeyPrefix = []byte("url:")
|
||||
var uidKeyPrefix = []byte("uid:")
|
||||
|
||||
// Shortner interface
|
||||
type Shortener struct {
|
||||
Store storage.Store
|
||||
}
|
||||
|
||||
// Shorten a given URL.
|
||||
func (s *Shortener) Shorten(rawURL []byte) (uid []byte, url []byte, err error) {
|
||||
url, err = NormalizeURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, nil, 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 nil, nil, err
|
||||
}
|
||||
|
||||
uid, err = s.newUID()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = s.Store.Set(urlKey, uid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uidKey := s.makeUIDKey(uid)
|
||||
err = s.Store.Set(uidKey, url)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return uid, url, nil
|
||||
}
|
||||
|
||||
// Lookup the URL of a given UID.
|
||||
func (s *Shortener) Lookup(uid []byte) ([]byte, error) {
|
||||
uidKey := s.makeUIDKey(uid)
|
||||
|
||||
url, err := s.Store.Get(uidKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Shortener) newUID() ([]byte, error) {
|
||||
index, err := s.Store.NextSequence()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return base58.Encode(index), nil
|
||||
}
|
||||
|
||||
func (s *Shortener) makeUIDKey(uid []byte) []byte {
|
||||
return append(uidKeyPrefix, uid...)
|
||||
}
|
||||
|
||||
func (s *Shortener) makeURLKey(rawURL []byte) []byte {
|
||||
urlSHA := fmt.Sprintf("%x", sha1.Sum(rawURL))
|
||||
return append(urlKeyPrefix, urlSHA...)
|
||||
}
|
||||
145
shortener/shortener_test.go
Normal file
145
shortener/shortener_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package shortener
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jimeh/ozu.io/shortener/mocks"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// Mocks
|
||||
|
||||
//go:generate mockery -name Store -dir .. -recursive
|
||||
|
||||
// Suite Setup
|
||||
|
||||
type ShortenerSuite struct {
|
||||
suite.Suite
|
||||
store *mocks.Store
|
||||
shortener *Shortener
|
||||
errNotFound error
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) SetupTest() {
|
||||
s.store = new(mocks.Store)
|
||||
s.shortener = New(s.store)
|
||||
s.errNotFound = errors.New("not found")
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
func (s *ShortenerSuite) TestShortenExisting() {
|
||||
rawURL := []byte("http://google.com/")
|
||||
uid := []byte("ig")
|
||||
urlSHA := fmt.Sprintf("%x", sha1.Sum(rawURL))
|
||||
|
||||
s.store.On("Get", append([]byte("url:"), urlSHA...)).Return(uid, nil)
|
||||
|
||||
resultUID, resultURL, err := s.shortener.Shorten(rawURL)
|
||||
s.NoError(err)
|
||||
s.Equal(uid, resultUID)
|
||||
s.Equal(rawURL, resultURL)
|
||||
s.store.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) TestShortenNew() {
|
||||
rawURL := []byte("https://google.com")
|
||||
url := []byte("https://google.com/")
|
||||
uid := []byte("ig")
|
||||
urlKey := append([]byte("url:"), fmt.Sprintf("%x", sha1.Sum(url))...)
|
||||
|
||||
s.store.On("Get", urlKey).Return(nil, s.errNotFound)
|
||||
s.store.On("NextSequence").Return(1001, nil)
|
||||
s.store.On("Set", urlKey, uid).Return(nil)
|
||||
s.store.On("Set", append([]byte("uid:"), uid...), url).Return(nil)
|
||||
|
||||
rUID, rURL, err := s.shortener.Shorten(rawURL)
|
||||
|
||||
s.NoError(err)
|
||||
s.Equal(uid, rUID)
|
||||
s.Equal(url, rURL)
|
||||
s.store.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) TestShortenInvalidURL() {
|
||||
examples := []struct {
|
||||
url string
|
||||
error string
|
||||
}{
|
||||
{
|
||||
url: "*$)]+_<?)",
|
||||
error: "invalid URL",
|
||||
},
|
||||
{
|
||||
url: "",
|
||||
error: "invalid URL",
|
||||
},
|
||||
{
|
||||
url: "file:///bin/bash",
|
||||
error: "schema 'file://' not allowed",
|
||||
},
|
||||
{
|
||||
url: "/users/view.php?uid=138495",
|
||||
error: "invalid URL",
|
||||
},
|
||||
{
|
||||
url: "http://long.com/" + strings.Repeat("0", 3000),
|
||||
error: "invalid URL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, e := range examples {
|
||||
rUID, rURL, err := s.shortener.Shorten([]byte(e.url))
|
||||
s.Nil(rUID)
|
||||
s.Nil(rURL)
|
||||
s.EqualError(err, e.error)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) TestShortenStoreError() {
|
||||
url := []byte("https://google.com/")
|
||||
storeErr := errors.New("leveldb: something wrong")
|
||||
urlKey := append([]byte("url:"), fmt.Sprintf("%x", sha1.Sum(url))...)
|
||||
|
||||
s.store.On("Get", urlKey).Return(nil, storeErr)
|
||||
|
||||
rUID, rURL, err := s.shortener.Shorten(url)
|
||||
s.Nil(rUID)
|
||||
s.Nil(rURL)
|
||||
s.EqualError(err, storeErr.Error())
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) TestLookupExisting() {
|
||||
url := []byte("https://google.com/")
|
||||
uid := []byte("ig")
|
||||
|
||||
s.store.On("Get", append([]byte("uid:"), uid...)).Return(url, nil)
|
||||
|
||||
rURL, err := s.shortener.Lookup(uid)
|
||||
|
||||
s.NoError(err)
|
||||
s.Equal(url, rURL)
|
||||
s.store.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *ShortenerSuite) TestLookupNonExistant() {
|
||||
uid := []byte("ig")
|
||||
|
||||
s.store.On("Get", append([]byte("uid:"), uid...)).Return(nil, s.errNotFound)
|
||||
|
||||
rURL, err := s.shortener.Lookup(uid)
|
||||
|
||||
s.EqualError(err, "not found")
|
||||
s.Nil(rURL)
|
||||
s.store.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
// Run Suite
|
||||
|
||||
func TestShortenerSuite(t *testing.T) {
|
||||
suite.Run(t, new(ShortenerSuite))
|
||||
}
|
||||
Reference in New Issue
Block a user