├── .gitignore ├── LICENSE ├── README.md ├── main.go └── shortener ├── bijection.go ├── bijection_test.go ├── connector.go ├── connector_test.go ├── luhn.go ├── luhn_test.go ├── router.go └── router_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | TODO.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ### The BSD 3-Clause License ### 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 14 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Gohort 2 | ================== 3 | 4 | Gohort is a simple URL shortener written in Go. 5 | 6 | Its design is based out the [Stack Overflow question](https://stackoverflow.com/questions/742013/how-to-code-a-url-shortener) about writing a URL shortner. It uses [gorilla/mux](http://www.gorillatoolkit.org/pkg/mux) for routing requests. 7 | 8 | It provides a RESTful API to create and retrive short URL and their corresponding expanded forms. 9 | 10 | Running Gohort 11 | ================= 12 | 13 | Gohort requires a working Redis installation. 14 | 15 | Once you have a working Redis installation, go get the project from Github. 16 | 17 | ```go get github.com/aishraj/gohort``` 18 | 19 | Now change into the project directory and run 20 | ```go build``` 21 | 22 | Next run the executable connecting to a local Redis installation: 23 | 24 | ```./gohort -cpus=1 -rhost="localhost" -rport=6379 -sport=8090 -timeout=10``` 25 | 26 | 27 | Example 28 | =================== 29 | In order to create a new short URL: 30 | 31 | ```curl -X POST http://localhost:8080/api/v1/?base=www.google.com``` 32 | 33 | In order to retrive the original URL from the shortend URL: 34 | 35 | ```curl http://localhost:8080/api/v1/?alias=8CQ``` 36 | 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "github.com/aishraj/gohort/shortener" 6 | "log" 7 | "runtime" 8 | "strconv" 9 | ) 10 | 11 | func main() { 12 | 13 | redisHost := flag.String("rhost", "localhost", "Host on which Redis is running") 14 | redisDatabaseInt := flag.Int("rdb", 0, "Redis database to select") 15 | redisPortInt := flag.Int("rport", 6379, "Port on which Redis is running") 16 | redisTimeOutSeconds := flag.Int("timeout", 10, "Timeout for Redis connection in seconds") 17 | serverPortInt := flag.Int("sport", 8080, "Port for the HTTP server") 18 | cpus := flag.Int("cpus", runtime.NumCPU(), "Number of CPUs to use") 19 | 20 | flag.Parse() 21 | 22 | redisDatabase := strconv.Itoa(*redisDatabaseInt) 23 | serverPort := strconv.Itoa(*serverPortInt) 24 | redisPort := strconv.Itoa(*redisPortInt) 25 | 26 | runtime.GOMAXPROCS(*cpus) 27 | 28 | log.Printf("Starting the server with properties. Redis host %s "+ 29 | "Redis port number %s Redis Timeout seconds %d HTTP Server port %s", 30 | *redisHost, redisPort, *redisTimeOutSeconds, serverPort) 31 | 32 | shortener.RegisterAndStart(*redisHost, redisDatabase, redisPort, serverPort, *redisTimeOutSeconds) 33 | } 34 | -------------------------------------------------------------------------------- /shortener/bijection.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | const alphaBetSet = "23456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ-_" 9 | const base = uint64(len(alphaBetSet)) 10 | 11 | func EncodeToBase(seedNumber uint64) (string, error) { 12 | encodedString := "" 13 | if seedNumber <= 0 { 14 | return "", errors.New("Argument cannot zero or less.") 15 | } 16 | for seedNumber > 0 { 17 | numAtIndex := seedNumber % base 18 | charAtIndex := string(alphaBetSet[numAtIndex]) 19 | encodedString += charAtIndex 20 | seedNumber = seedNumber / base 21 | } 22 | return reverse(encodedString), nil 23 | } 24 | 25 | func reverse(s string) string { 26 | runes := []rune(s) 27 | for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { 28 | runes[i], runes[j] = runes[j], runes[i] 29 | } 30 | return string(runes) 31 | } 32 | 33 | func DecodeFromBase(encodedString string) (uint64, error) { 34 | if len(encodedString) == 0 { 35 | return 0, errors.New("Argument cannot empty.") 36 | } 37 | decodedVal := uint64(0) 38 | for _, c := range encodedString { 39 | decodedVal = (decodedVal * base) + uint64(strings.Index(alphaBetSet, string(c))) 40 | } 41 | return decodedVal, nil 42 | } 43 | -------------------------------------------------------------------------------- /shortener/bijection_test.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import "testing" 4 | import "log" 5 | 6 | func TestEnCode(t *testing.T) { 7 | actual, err := EncodeToBase(6657949) 8 | if err != nil { 9 | t.Error("Error while trying to encode 12345. Error is", err) 10 | } 11 | if actual != "_cP3" { 12 | t.Error("Expected _cP3, got", actual) 13 | } 14 | } 15 | 16 | func TestDecodeFail(t *testing.T) { 17 | _, err := DecodeFromBase("") 18 | if err == nil { 19 | t.Error("Expecting error while trying to decode an empty string. Got no error") 20 | } 21 | } 22 | 23 | func TestDecode(t *testing.T) { 24 | result, err := DecodeFromBase("_cP3") 25 | if err != nil { 26 | t.Error("Expecting NO error while trying to decode 5N6. Got ", err) 27 | } 28 | if result != 6657949 { 29 | log.Println(result) 30 | t.Fail() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shortener/connector.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/fzzy/radix/redis" 7 | "log" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | func SetupRedisConnection(serverHost string, serverDb string, serverport string, timeOutSeconds int) (*redis.Client, error) { 13 | c, err := redis.DialTimeout("tcp", 14 | serverHost+":"+serverport, time.Duration(timeOutSeconds)*time.Second) 15 | performErrorCheck(err) 16 | c.Cmd("SELECT", serverDb) 17 | return c, err 18 | } 19 | 20 | func performErrorCheck(err error) { 21 | if err != nil { 22 | log.Fatal("Error while setting a redis connection. Error is ", err) 23 | } 24 | } 25 | 26 | func LookupAlias(alias string, c *redis.Client) (string, error) { 27 | defer c.Close() 28 | if !ValidateAlias(alias) { 29 | return "", errors.New("Unable to validate the lookup string.") 30 | } 31 | alias = alias[1:] 32 | lookupId, err := DecodeFromBase(alias) 33 | if err != nil { 34 | fmt.Println("ERROR!!!!! Can't convert string id") 35 | return "", err 36 | } 37 | s, err := c.Cmd("get", lookupId).Str() 38 | if err != nil { 39 | fmt.Println("ERROR!!!!! Can't convert string id") 40 | return "", err 41 | } 42 | return s, nil 43 | 44 | } 45 | 46 | func StoreUrl(baseUrl string, c *redis.Client) (string, error) { 47 | defer c.Close() 48 | 49 | res := c.Cmd("incr", "globalCounter") 50 | performErrorCheck(res.Err) 51 | 52 | currentCounter := res.String() 53 | 54 | idNumber, err := strconv.ParseUint(currentCounter, 10, 64) 55 | performErrorCheck(err) 56 | 57 | setREsp, err := c.Cmd("setnx", idNumber, baseUrl).Bool() 58 | performErrorCheck(err) 59 | 60 | if setREsp == false { 61 | fmt.Println("The ID already exits. ERROR!!") 62 | //TODO: Need to end the program here. ? 63 | } 64 | 65 | encodedAlias, ok := EncodeToBase(idNumber) 66 | if ok != nil { 67 | return "", ok 68 | } 69 | checkDigit := CalculateCheckDigit(idNumber) 70 | checkDigitStr := strconv.FormatUint(checkDigit, 10) 71 | return checkDigitStr + encodedAlias, nil 72 | } 73 | -------------------------------------------------------------------------------- /shortener/connector_test.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import "testing" 4 | import "fmt" 5 | 6 | func TestConnectorSet(t *testing.T) { 7 | //Okay need a better way to do this. 8 | // Shouldn't be connecting to redis. 9 | c, err := SetupRedisConnection("localhost", "0", "6379", 10) 10 | if err != nil { 11 | fmt.Println("Error getting redis connections") 12 | } 13 | a, ok := StoreUrl("http://www.amazon.com", c) 14 | if ok != nil { 15 | fmt.Println(ok) 16 | t.Fail() 17 | } 18 | fmt.Println("Short URL iD is:", a) 19 | } 20 | 21 | func TestConnectorGet(t *testing.T) { 22 | //Okay need a better way to do this. 23 | // Shouldn't be connecting to redis. 24 | 25 | c, err := SetupRedisConnection("localhost", "0", "6379", 10) 26 | if err != nil { 27 | fmt.Println("Error getting redis connections") 28 | } 29 | a, ok := LookupAlias("1c", c) 30 | if ok != nil { 31 | fmt.Println(ok) 32 | t.Fail() 33 | } 34 | fmt.Println("Long URL is:", a) 35 | } 36 | -------------------------------------------------------------------------------- /shortener/luhn.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | ) 7 | 8 | const Base10 = 10 9 | const Base64 = 64 10 | 11 | func CheckSum(id uint64) uint64 { 12 | numStr := strconv.FormatUint(id, Base10) 13 | runes := []rune(numStr) 14 | checkSum := uint64(0) 15 | oddSum := uint64(0) 16 | evenSum := uint64(0) 17 | for i := len(runes) - 1; i >= 0; i-- { 18 | item := runes[i] 19 | digitNum := uint64(item - '0') 20 | ii := i - len(runes) + 1 21 | if ii%2 == 0 { 22 | evenSum += digitNum 23 | } else { 24 | sumOfDigits := digitSum(2 * digitNum) 25 | oddSum += sumOfDigits 26 | } 27 | } 28 | checkSum = evenSum + oddSum 29 | return checkSum % Base10 30 | } 31 | 32 | func IsCheckSumValid(id uint64) bool { 33 | return CheckSum(id) == 0 34 | } 35 | 36 | func CalculateCheckDigit(partialId uint64) uint64 { 37 | checkDigit := CheckSum(partialId * Base10) 38 | if checkDigit == 0 { 39 | return checkDigit 40 | } 41 | return Base10 - checkDigit 42 | } 43 | 44 | func digitSum(num uint64) uint64 { 45 | retNum := uint64(0) 46 | for num > 0 { 47 | r := num % Base10 48 | retNum += r 49 | num = num / Base10 50 | } 51 | return retNum 52 | } 53 | 54 | func ValidateAlias(alias string) bool { 55 | if len(alias) < 2 || alias[0] < '0' || alias[0] > '9' { 56 | return false 57 | } 58 | checkDigit := string(alias[0]) 59 | checkDigitNum, _ := strconv.ParseUint(checkDigit, Base10, Base64) 60 | actualAlias := string(alias[1:]) 61 | aliasId, ok := DecodeFromBase(actualAlias) 62 | if ok != nil { 63 | log.Printf("Unable to decode the value %s . Error is %s", actualAlias, ok.Error()) 64 | return false 65 | } 66 | return CalculateCheckDigit(aliasId) == checkDigitNum 67 | } 68 | -------------------------------------------------------------------------------- /shortener/luhn_test.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import "testing" 4 | import "fmt" 5 | 6 | func TestCheckSum(t *testing.T) { 7 | checkSum := CheckSum(6657949) 8 | fmt.Println("Checksum is: ", checkSum) 9 | 10 | if checkSum != 5 { 11 | fmt.Println("The test failed. Checksum is : ", checkSum) 12 | t.Fail() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shortener/router.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/fzzy/radix/redis" 7 | "github.com/gorilla/mux" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | type UrlMsg struct { 13 | Url string `json:shorturl` 14 | ErrorMessage string `json:errorMessage` 15 | } 16 | 17 | var hostRedis string = "" 18 | var dbRedis string = "" 19 | var portRedis string = "" 20 | var timeOutRedis int = 10 21 | 22 | func RegisterAndStart(redisHost string, redisDatabase string, redisPort string, serverPort string, timeOutSeconds int) { 23 | hostRedis = redisHost 24 | dbRedis = redisDatabase 25 | portRedis = redisPort 26 | timeOutRedis = timeOutSeconds 27 | serverPort = ":" + serverPort 28 | 29 | r := mux.NewRouter() 30 | r.HandleFunc("/", RootHandler) 31 | 32 | shortener := r.Path("/{alias}").Subrouter() 33 | shortener.Methods("GET").HandlerFunc(RedirectToBaseHandler) 34 | 35 | api := r.PathPrefix("/api/v1/").Subrouter() 36 | api.Methods("GET").MatcherFunc(AliasMatcher).HandlerFunc(AliasHandler) 37 | api.Methods("POST").MatcherFunc(BaseMatcher).HandlerFunc(BaseHandler) 38 | 39 | log.Println("Server starting on port ", serverPort) 40 | log.Fatal(http.ListenAndServe(serverPort, r)) 41 | 42 | } 43 | 44 | func RootHandler(rw http.ResponseWriter, r *http.Request) { 45 | log.Println(r.UserAgent(), " ", r.Method) 46 | fmt.Fprint(rw, "Welcome to the shortener URL shortener v0.01") 47 | } 48 | 49 | func RedirectToBaseHandler(rw http.ResponseWriter, r *http.Request) { 50 | vars := mux.Vars(r) 51 | log.Println(r.UserAgent(), " ", r.Method, r.URL) 52 | c, err := SetupRedisConnection(hostRedis, dbRedis, portRedis, timeOutRedis) 53 | if err != nil { 54 | log.Fatal("Unable to setup a redis connection", err) 55 | } 56 | 57 | baseUrl, ok := LookupAlias(vars["alias"], c) 58 | if ok != nil { 59 | log.Println("Error while redirecting") 60 | log.Println(ok) 61 | http.NotFound(rw, r) 62 | } 63 | if baseUrl != "" { 64 | http.Redirect(rw, r, baseUrl, http.StatusMovedPermanently) 65 | } else { 66 | http.NotFound(rw, r) 67 | } 68 | 69 | } 70 | 71 | func AliasHandler(rw http.ResponseWriter, r *http.Request) { 72 | log.Println(r.UserAgent(), " ", r.Method, r.URL) 73 | c, err := SetupRedisConnection(hostRedis, dbRedis, portRedis, timeOutRedis) 74 | if err != nil { 75 | log.Fatal("Unable to setup a redis connection", err) 76 | } 77 | 78 | baseUrl, ok := ExtractBaseUrl(r, c) 79 | urlMessage := UrlMsg{"", ""} 80 | if ok == nil { 81 | urlMessage = UrlMsg{baseUrl, ""} 82 | } else { 83 | urlMessage = UrlMsg{baseUrl, ok.Error()} 84 | } 85 | js, _ := json.Marshal(urlMessage) 86 | rw.Header().Set("Content-Type", "application/json") 87 | rw.Write(js) 88 | } 89 | 90 | func ExtractBaseUrl(r *http.Request, c *redis.Client) (string, error) { 91 | alias := extractParam(r, "alias") 92 | return LookupAlias(alias, c) 93 | } 94 | 95 | func BaseHandler(rw http.ResponseWriter, r *http.Request) { 96 | log.Println(r.UserAgent(), " ", r.Method, r.URL) 97 | c, err := SetupRedisConnection(hostRedis, dbRedis, portRedis, timeOutRedis) 98 | if err != nil { 99 | log.Fatal("Unable to setup a redis connection", err) 100 | } 101 | 102 | baseUrl := extractParam(r, "base") 103 | alias, ok := StoreUrl(baseUrl, c) 104 | if ok != nil { 105 | http.Error(rw, ok.Error(), http.StatusInternalServerError) 106 | } else { 107 | urlMessage := UrlMsg{alias, ""} 108 | js, _ := json.Marshal(urlMessage) 109 | rw.Header().Set("Content-Type", "application/json") 110 | rw.Write(js) 111 | } 112 | } 113 | 114 | func AliasMatcher(r *http.Request, rm *mux.RouteMatch) bool { 115 | queryParams := r.URL.Query() 116 | return queryParams.Get("alias") != "" 117 | } 118 | 119 | func BaseMatcher(r *http.Request, rm *mux.RouteMatch) bool { 120 | queryParams := r.URL.Query() 121 | return queryParams.Get("base") != "" 122 | } 123 | 124 | func MultiMatcher(r *http.Request, rm *mux.RouteMatch) bool { 125 | return AliasMatcher(r, rm) && BaseMatcher(r, rm) 126 | } 127 | 128 | func extractParam(r *http.Request, a string) string { 129 | queryPrams := r.URL.Query() 130 | return queryPrams.Get(a) 131 | } 132 | -------------------------------------------------------------------------------- /shortener/router_test.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import "testing" 4 | 5 | func TestRouter(t *testing.T) { 6 | if t == nil { 7 | t.Error("t is nil") 8 | } 9 | //TODO: Need to figure out a better way to test this 10 | //httptest seems fine but I'm not yet fully convinced about its use with gorilla/mux. 11 | //RegisterAndStart("localhost", "6379", "8090", 10) 12 | } 13 | --------------------------------------------------------------------------------