diff --git a/base62/base62.go b/base62/base62.go new file mode 100644 index 0000000..feb60bb --- /dev/null +++ b/base62/base62.go @@ -0,0 +1,38 @@ +package base62 + +import ( + "fmt" + "strings" +) + +// All characters +const ( + alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + length = int64(len(alphabet)) +) + +// Encode number to base62. +func Encode(n int64) string { + if n == 0 { + return string(alphabet[0]) + } + + s := "" + for ; n > 0; n = n / length { + s = string(alphabet[n%length]) + s + } + return s +} + +// Decode converts a base62 token to int. +func Decode(key string) (int64, error) { + var n int64 + for _, c := range []byte(key) { + i := strings.IndexByte(alphabet, c) + if i < 0 { + return 0, fmt.Errorf("unexpected character %c in base62 literal", c) + } + n = length*n + int64(i) + } + return n, nil +} diff --git a/config/config.go b/config/config.go index 8e52833..a376128 100644 --- a/config/config.go +++ b/config/config.go @@ -2,63 +2,42 @@ package config import ( "encoding/json" - "os" - "io" - "bytes" + "io/ioutil" ) +// Config contains the configuration of the url shortener. type Config struct { - Server Server `json:"server"` - Redis Redis `json:"redis"` - Postgres Postgres `json:"postgres"` - Options Options `json:"options"` + Server struct { + Host string `json:"host"` + Port string `json:"port"` + } `json:"server"` + Redis struct { + Host string `json:"host"` + Password string `json:"password"` + DB string `json:"db"` + } `json:"redis"` + Postgres struct { + Host string `json:"host"` + User string `json:"user"` + Password string `json:"password"` + DB string `json:"db"` + } `json:"postgres"` + Options struct { + Prefix string `json:"prefix"` + } `json:"options"` } -type Server struct { - Host string `json:"host"` - Port string `json:"port"` -} - -type Redis struct { - Host string `json:"host"` - Password string `json:"password"` - DB string `json:"db"` -} - -type Postgres struct { - Host string `json:"host"` - User string `json:"user"` - Password string `json:"password"` - DB string `json:"db"` -} - -type Options struct { - Prefix string `json:"prefix"` -} - -func ReadConfig() (*Config, error) { - var objectConfig *Config - var buf bytes.Buffer - - // open input file - file, err := os.Open("./config/config.json") +// FromFile returns a configuration parsed from the given file. +func FromFile(path string) (*Config, error) { + b, err := ioutil.ReadFile(path) if err != nil { return nil, err } - // close file - defer file.Close() - - // copy to buffer - _, err = io.Copy(&buf, file) - if err != nil { + var cfg Config + if err := json.Unmarshal(b, &cfg); err != nil { return nil, err } - // Unmarshal data - if err := json.Unmarshal(buf.Bytes(), &objectConfig); err != nil { - return nil, err - } - - return objectConfig, nil -} \ No newline at end of file + return &cfg, nil +} diff --git a/enconding/base62.go b/enconding/base62.go deleted file mode 100644 index 1448fc2..0000000 --- a/enconding/base62.go +++ /dev/null @@ -1,52 +0,0 @@ -package enconding - -import ( - "bytes" - "math" -) - -// All characters -const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -// Convert number to base62 -func Encode(n int) string { - if n == 0 { - return string(alphabet[0]) - } - - chars := make([]byte, 0) - - length := len(alphabet) - - for n > 0 { - r := n / length - remainder := n % length - chars = append(chars, alphabet[remainder]) - n = r - } - - for i, j := 0, len(chars)-1; i < j; i, j = i+1, j-1 { - chars[i], chars[j] = chars[j], chars[i] - } - - return string(chars) -} - -// convert base62 token to int -func Decode(t string) int { - n := 0 - idx := 0.0 - chars := []byte(alphabet) - - charsLength := float64(len(chars)) - tokenLength := float64(len(t)) - - for _, c := range []byte(t) { - power := tokenLength - (idx + 1) - ind := bytes.IndexByte(chars, c) - n += ind * int(math.Pow(charsLength, power)) - idx++ - } - - return n -} diff --git a/handler/handler.go b/handler/handler.go new file mode 100644 index 0000000..449f1f4 --- /dev/null +++ b/handler/handler.go @@ -0,0 +1,103 @@ +// Package handlers provides HTTP request handlers. +package handler + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "github.com/douglasmakey/ursho/storage" +) + +// New returns an http handler for the url shortener. +func New(prefix string, storage storage.Service) http.Handler { + mux := http.NewServeMux() + h := handler{prefix, storage} + mux.HandleFunc("/encode/", responseHandler(h.encode)) + mux.HandleFunc("/", h.redirect) + mux.HandleFunc("/info/", responseHandler(h.decode)) + return mux +} + +type response struct { + Success bool `json:"success"` + Data interface{} `json:"response"` +} + +type handler struct { + prefix string + storage storage.Service +} + +func responseHandler(h func(io.Writer, *http.Request) (interface{}, int, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, status, err := h(w, r) + if err != nil { + data = err.Error() + } + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(response{Data: data, Success: err == nil}) + if err != nil { + log.Printf("could not encode response to output: %v", err) + } + } +} + +func (h handler) encode(w io.Writer, r *http.Request) (interface{}, int, error) { + if r.Method != http.MethodPost { + return nil, http.StatusMethodNotAllowed, fmt.Errorf("method %s not allowed", r.Method) + } + + var input struct{ URL string } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("Unable to decode JSON request body: %v", err) + } + + url := strings.TrimSpace(input.URL) + if url == "" { + return nil, http.StatusBadRequest, fmt.Errorf("URL is empty") + } + + c, err := h.storage.Save(url) + if err != nil { + return nil, http.StatusBadRequest, fmt.Errorf("Could not store in database: %v", err) + } + + return h.prefix + c, http.StatusCreated, nil +} + +func (h handler) decode(w io.Writer, r *http.Request) (interface{}, int, error) { + if r.Method != http.MethodGet { + return nil, http.StatusMethodNotAllowed, fmt.Errorf("Method %s not allowed", r.Method) + } + + code := r.URL.Path[len("/info/"):] + + model, err := h.storage.LoadInfo(code) + if err != nil { + return nil, http.StatusNotFound, fmt.Errorf("URL not found") + } + + return model, http.StatusOK, nil +} + +func (h handler) redirect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + code := r.URL.Path[len("/"):] + + model, err := h.storage.Load(code) + if err != nil { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("URL Not Found")) + return + } + + http.Redirect(w, r, string(model.URL), http.StatusMovedPermanently) +} diff --git a/handlers/base.go b/handlers/base.go deleted file mode 100644 index 38f622d..0000000 --- a/handlers/base.go +++ /dev/null @@ -1,39 +0,0 @@ -package handlers - -import ( - "encoding/json" - "github.com/douglasmakey/ursho/config" - "log" - "net/http" -) - -type Response struct { - Success bool `json:"success"` - Data interface{} `json:"response"` -} - - -var prefix string - -func init() { - c, err := config.ReadConfig() - if err != nil { - log.Fatal(err) - } - - if c.Options.Prefix == "" { - prefix = "" - } else { - prefix = c.Options.Prefix - } -} - - -func createResponse(w http.ResponseWriter, r Response) { - d, err := json.Marshal(r) - if err != nil { - panic(err) - } - - w.Write(d) -} diff --git a/handlers/handlers.go b/handlers/handlers.go deleted file mode 100644 index aee4070..0000000 --- a/handlers/handlers.go +++ /dev/null @@ -1,108 +0,0 @@ -// Package handlers provides HTTP request handlers. -package handlers - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/douglasmakey/ursho/storages" -) - -type bodyRequest struct { - URL string -} - -func EncodeHandler(storage storages.IFStorage) http.Handler { - handleFunc := func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - - w.Header().Set("Content-Type", "application/json") - - var b bodyRequest - if err := json.NewDecoder(r.Body).Decode(&b); err != nil { - w.WriteHeader(http.StatusInternalServerError) - e := Response{Data: "Unable to decode JSON request body: " + err.Error(), Success: false} - createResponse(w, e) - return - } - - b.URL = strings.TrimSpace(b.URL) - - if b.URL == "" { - w.WriteHeader(http.StatusBadRequest) - e := Response{Data: "URL is Empty", Success: false} - createResponse(w, e) - return - } - - c, err := storage.Save(b.URL) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - e := Response{Data: err.Error(), Success: false} - createResponse(w, e) - return - } - - response := Response{Data: prefix + c, Success: true} - createResponse(w, response) - - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - } - - return http.HandlerFunc(handleFunc) -} - -func DecodeHandler(storage storages.IFStorage) http.Handler { - handleFunc := func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("Content-Type", "application/json") - code := r.URL.Path[len("/info/"):] - - model, err := storage.LoadInfo(code) - if err != nil { - w.WriteHeader(http.StatusNotFound) - e := Response{Data: "URL Not Found", Success: false} - createResponse(w, e) - return - } - - response := Response{Data: model, Success: true} - createResponse(w, response) - - } else { - - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - } - - return http.HandlerFunc(handleFunc) -} - -func RedirectHandler(storage storages.IFStorage) http.Handler { - handleFunc := func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - code := r.URL.Path[len("/"):] - - model, err := storage.Load(code) - if err != nil { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("URL Not Found")) - return - } - - http.Redirect(w, r, string(model.Url), 301) - - } else { - - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - } - - return http.HandlerFunc(handleFunc) -} diff --git a/main.go b/main.go index 1a6d50f..a128e71 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "context" + "flag" "fmt" "log" "net/http" @@ -8,72 +10,54 @@ import ( "os/signal" "github.com/douglasmakey/ursho/config" - "github.com/douglasmakey/ursho/handlers" - "github.com/douglasmakey/ursho/storages" + "github.com/douglasmakey/ursho/handler" + "github.com/douglasmakey/ursho/storage/postgres" ) func main() { - // Set use storage, select [Postgres, Filesystem, Redis ...] - storage := &storages.Postgres{} + configPath := flag.String("config", "./config/config.json", "path of the config file") + + flag.Parse() // Read config - config, err := config.ReadConfig() + config, err := config.FromFile(*configPath) if err != nil { log.Fatal(err) } - // Init storage - if err = storage.Init(config); err != nil { + + // Set use storage, select [Postgres, Filesystem, Redis ...] + svc, err := postgres.New(config.Postgres.User, config.Postgres.Password, config.Postgres.DB) + if err != nil { log.Fatal(err) } - - // Defers - defer storage.Close() - - // Handlers - http.Handle("/encode/", handlers.EncodeHandler(storage)) - http.Handle("/", handlers.RedirectHandler(storage)) - http.Handle("/info/", handlers.DecodeHandler(storage)) - - // Graceful shutdown - sigquit := make(chan os.Signal, 1) - signal.Notify(sigquit, os.Interrupt, os.Kill) - - // Wait signal - close := make(chan bool, 1) + defer svc.Close() // Create a server + http.Handle("/", handler.New(config.Options.Prefix, svc)) server := &http.Server{Addr: fmt.Sprintf("%s:%s", config.Server.Host, config.Server.Port)} - // Start server - go func() { - log.Printf("Starting HTTP Server. Listening at %q", server.Addr) - if err := server.ListenAndServe(); err != nil { - if err := server.ListenAndServe(); err != nil { - if err != http.ErrServerClosed { - log.Println(err.Error()) - } else { - log.Println("Server closed!") - } - close <- true - } - } - - }() - // Check for a closing signal go func() { + // Graceful shutdown + sigquit := make(chan os.Signal, 1) + signal.Notify(sigquit, os.Interrupt, os.Kill) + sig := <-sigquit log.Printf("caught sig: %+v", sig) log.Printf("Gracefully shutting down server...") - if err := server.Shutdown(nil); err != nil { - log.Println("Unable to shut down server: " + err.Error()) - close <- true + if err := server.Shutdown(context.Background()); err != nil { + log.Printf("Unable to shut down server: %v", err) } else { log.Println("Server stopped") - close <- true } }() - <-close + // Start server + log.Printf("Starting HTTP Server. Listening at %q", server.Addr) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Printf("%v", err) + } else { + log.Println("Server closed!") + } } diff --git a/storage/postgres/postgres.go b/storage/postgres/postgres.go new file mode 100644 index 0000000..4392999 --- /dev/null +++ b/storage/postgres/postgres.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "database/sql" + "fmt" + + // This loads the postgres drivers. + _ "github.com/lib/pq" + + "github.com/douglasmakey/ursho/base62" + "github.com/douglasmakey/ursho/storage" +) + +// New returns a postgres backed storage service. +func New(user, password, dbName string) (storage.Service, error) { + // Coonect postgres + connect := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", + user, password, dbName) + db, err := sql.Open("postgres", connect) + if err != nil { + return nil, err + } + + // Ping to connection + err = db.Ping() + if err != nil { + return nil, err + } + + // Create table if not exists + strQuery := "CREATE TABLE IF NOT EXISTS shortener (uid serial NOT NULL, url VARCHAR not NULL, " + + "visited boolean DEFAULT FALSE, count INTEGER DEFAULT 0);" + + _, err = db.Exec(strQuery) + if err != nil { + return nil, err + } + return &postgres{db}, nil +} + +type postgres struct{ db *sql.DB } + +func (p *postgres) Save(url string) (string, error) { + var id int64 + err := p.db.QueryRow("INSERT INTO shortener(url,visited,count) VALUES($1,$2,$3) returning uid;", url, false, 0).Scan(&id) + if err != nil { + return "", err + } + return base62.Encode(id), nil +} + +func (p *postgres) Load(code string) (*storage.Item, error) { + id, err := base62.Decode(code) + if err != nil { + return nil, err + } + + item, err := p.LoadInfo(code) + if err != nil { + return nil, err + } + + _, err = p.db.Exec("update shortener set visited=$1, count=$2 where uid=$3", true, item.Count+1, id) + if err != nil { + return nil, err + } + return item, nil +} + +func (p *postgres) LoadInfo(code string) (*storage.Item, error) { + id, err := base62.Decode(code) + if err != nil { + return nil, err + } + + var item storage.Item + err = p.db.QueryRow("SELECT url, visited, count FROM shortener where uid=$1 limit 1", id). + Scan(&item.URL, &item.Visited, &item.Count) + if err != nil { + return nil, err + } + + return &item, nil +} + +func (p *postgres) Close() error { return p.db.Close() } diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..a761561 --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,14 @@ +package storage + +type Service interface { + Save(string) (string, error) + Load(string) (*Item, error) + LoadInfo(string) (*Item, error) + Close() error +} + +type Item struct { + URL string `json:"url"` + Visited bool `json:"visited"` + Count int `json:"count"` +} diff --git a/storages/base.go b/storages/base.go deleted file mode 100644 index be8225a..0000000 --- a/storages/base.go +++ /dev/null @@ -1,14 +0,0 @@ -package storages - -type IFStorage interface { - Save(string) (string, error) - Load(string) (*Model, error) - LoadInfo(string) (*Model, error) - Close() -} - -type Model struct { - Url string `json:"url"` - Visited bool `json:"visited"` - Count int `json:"count"` -} diff --git a/storages/postgres.go b/storages/postgres.go deleted file mode 100644 index 05b2a09..0000000 --- a/storages/postgres.go +++ /dev/null @@ -1,95 +0,0 @@ -package storages - -import ( - "database/sql" - "fmt" - - "github.com/douglasmakey/ursho/config" - _ "github.com/lib/pq" - - "github.com/douglasmakey/ursho/enconding" -) - -type Postgres struct { - DB *sql.DB - model Model -} - -func (p *Postgres) Init(config *config.Config) error { - // Coonect postgres - strConnect := fmt.Sprintf("user=%s password=%s dbname=%s sslmode=disable", - config.Postgres.User, config.Postgres.Password, config.Postgres.DB) - db, err := sql.Open("postgres", strConnect) - if err != nil { - return err - } - - // Ping to connection - err = db.Ping() - if err != nil { - return err - } - - // Create table if not exists - strQuery := "CREATE TABLE IF NOT EXISTS shortener (uid serial NOT NULL, url VARCHAR not NULL, " + - "visited boolean DEFAULT FALSE, count INTEGER DEFAULT 0);" - - _, err = db.Exec(strQuery) - if err != nil { - return err - } - // Set db in Model - p.DB = db - - return nil -} - -func (p *Postgres) Save(url string) (string, error) { - - var lastInsertId int - err := p.DB.QueryRow("INSERT INTO shortener(url,visited,count) VALUES($1,$2,$3) returning uid;", url, false, 0).Scan(&lastInsertId) - if err != nil { - return "", err - } - fmt.Println("last inserted id =", lastInsertId) - - return enconding.Encode(lastInsertId), nil -} - -func (p *Postgres) Load(code string) (*Model, error) { - // Decode code - decodeID := enconding.Decode(code) - - // Query select - err := p.DB.QueryRow("SELECT url, visited, count FROM shortener where uid=$1 limit 1", - decodeID).Scan(&p.model.Url, &p.model.Visited, &p.model.Count) - if err != nil { - return nil, err - } - - // Query update - stmt, err := p.DB.Prepare("update shortener set visited=$1, count=$2 where uid=$3") - if err != nil { - return nil, err - } - _, err = stmt.Exec(true, p.model.Count+1, decodeID) - return &p.model, err -} - -func (p *Postgres) LoadInfo(code string) (*Model, error) { - // Decode code - decodeID := enconding.Decode(code) - - // Query select - err := p.DB.QueryRow("SELECT url, visited, count FROM shortener where uid=$1 limit 1", - decodeID).Scan(&p.model.Url, &p.model.Visited, &p.model.Count) - if err != nil { - return nil, err - } - - return &p.model, err -} - -func (p *Postgres) Close() { - p.DB.Close() -} \ No newline at end of file