diff --git a/.gitignore b/.gitignore index 3b735ec..3c285ff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +.vscode/ +.env \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2ac9db8 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/zhang2092/go-url-shortener + +go 1.21.5 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/itchyny/base58-go v0.2.1 + github.com/redis/go-redis/v9 v9.3.1 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8a874bd --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/itchyny/base58-go v0.2.1 h1:wtnhAVdOcW3WuHEASmGHMms4juOB8yEpj/KJxlB57+k= +github.com/itchyny/base58-go v0.2.1/go.mod h1:BNvrKeAtWNSca1GohNbyhfff9/v0IrZjzWCAGeAvZZE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= +github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/handlers.go b/handler/handlers.go new file mode 100644 index 0000000..e67baac --- /dev/null +++ b/handler/handlers.go @@ -0,0 +1,74 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/zhang2092/go-url-shortener/shortener" + "github.com/zhang2092/go-url-shortener/store" +) + +type UrlCreationRequest struct { + LongUrl string `json:"long_url"` + UserId string `json:"user_id"` +} + +type UrlCreationResponse struct { + Message string `json:"message"` + ShortUrl string `json:"short_url"` +} + +func CreateShortUrl(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var req UrlCreationRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "invalid parameter", http.StatusInternalServerError) + return + } + + shortUrl, err := shortener.GenerateShortLink(req.LongUrl, req.UserId) + if err != nil { + http.Error(w, "failed to generate short link", http.StatusInternalServerError) + return + } + + err = store.SaveUrlMapping(shortUrl, req.LongUrl, req.UserId) + if err != nil { + http.Error(w, "failed to store url mapping", http.StatusInternalServerError) + return + } + + scheme := "http://" + if r.TLS != nil { + scheme = "https://" + } + + res := &UrlCreationResponse{ + Message: "short url created successfully", + ShortUrl: scheme + r.Host + "/" + shortUrl, + } + + b, err := json.Marshal(res) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(b) +} + +func HandleShortUrlRedirect(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + shorUrl := vars["shortUrl"] + link, err := store.RetrieveInitialUrl(shorUrl) + if err != nil { + http.Error(w, "failed to get url", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, link, http.StatusFound) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..33cd51c --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "time" + + "github.com/gorilla/mux" + "github.com/zhang2092/go-url-shortener/handler" + "github.com/zhang2092/go-url-shortener/store" +) + +func main() { + router := mux.NewRouter() + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Wecome to the URL Shortener API")) + }).Methods(http.MethodGet) + + router.HandleFunc("/create-short-url", handler.CreateShortUrl).Methods(http.MethodPost) + router.HandleFunc("/{shortUrl}", handler.HandleShortUrlRedirect).Methods(http.MethodGet) + + store.InitializeStore() + + srv := &http.Server{ + Addr: "0.0.0.0:9090", + Handler: router, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + } + + go func() { + if err := srv.ListenAndServe(); err != nil { + log.Printf("failed to start server on :9000, err: %v", err) + } + }() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + store.CloseStoreRedisConn() + + srv.Shutdown(ctx) + log.Println("shutting down") + os.Exit(0) +} diff --git a/shortener/shortener.go b/shortener/shortener.go new file mode 100644 index 0000000..9b6b737 --- /dev/null +++ b/shortener/shortener.go @@ -0,0 +1,35 @@ +package shortener + +import ( + "crypto/sha256" + "fmt" + "math/big" + + "github.com/itchyny/base58-go" +) + +func sha256Of(input string) []byte { + algorithm := sha256.New() + algorithm.Write([]byte(input)) + return algorithm.Sum(nil) +} + +func base58Encoded(bytes []byte) (string, error) { + encoded, err := base58.BitcoinEncoding.Encode(bytes) + if err != nil { + return "", err + } + + return string(encoded), nil +} + +func GenerateShortLink(originUrl string, userId string) (string, error) { + urlHashByte := sha256Of(originUrl + userId) + generateNumber := new(big.Int).SetBytes(urlHashByte).Uint64() + result, err := base58Encoded([]byte(fmt.Sprintf("%d", generateNumber))) + if err != nil { + return "", err + } + + return result, nil +} diff --git a/shortener/shortener_test.go b/shortener/shortener_test.go new file mode 100644 index 0000000..517fc28 --- /dev/null +++ b/shortener/shortener_test.go @@ -0,0 +1,26 @@ +package shortener + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const userId = "7c729139-7ff4-445f-976b-2d842f55cb0e" + +func TestGenerateShortLink(t *testing.T) { + link1 := "https://www.baidu.com/" + short1, err := GenerateShortLink(link1, userId) + assert.NoError(t, err) + assert.Equal(t, short1, "egtq236P5f3") + + link2 := "https://www.163.com/" + short2, err := GenerateShortLink(link2, userId) + assert.NoError(t, err) + assert.Equal(t, short2, "DiCqg9YpV89") + + link3 := "https://www.qq.com/" + short3, err := GenerateShortLink(link3, userId) + assert.NoError(t, err) + assert.Equal(t, short3, "4QhQ62cZem1") +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..91abaf1 --- /dev/null +++ b/store/store.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +type StorageService struct { + redisClient *redis.Client +} + +var ( + storeService = &StorageService{} + ctx = context.Background() +) + +const CacheDuration = 6 * time.Hour + +func InitializeStore() { + redisClient := redis.NewClient(&redis.Options{ + Addr: "localhost:6378", + Password: "secret", + DB: 0, + }) + + pong, err := redisClient.Ping(ctx).Result() + if err != nil { + log.Fatalf("failed to init redis: %v", err) + } + + fmt.Printf("Redis started successfully, ping message = %s\n", pong) + storeService.redisClient = redisClient +} + +func CloseStoreRedisConn() { + storeService.redisClient.Close() +} + +func SaveUrlMapping(shortUrl string, originUrl string, userId string) error { + return storeService.redisClient.Set(ctx, shortUrl, originUrl, CacheDuration).Err() +} + +func RetrieveInitialUrl(shortUrl string) (string, error) { + result, err := storeService.redisClient.Get(ctx, shortUrl).Result() + if err != nil { + return "", err + } + + return result, nil +}