From 81e72cedfa613e5e51bafaa96e021b1225362128 Mon Sep 17 00:00:00 2001 From: maotovisk Date: Wed, 6 Aug 2025 23:16:55 -0300 Subject: [PATCH] feat(links): create basic link shortening algo --- .gitignore | 2 ++ cmd/maot-shortner/server.go | 5 ++-- internal/app/routes.go | 19 ------------ internal/database/link.go | 18 +++++++++++ internal/domain/link.go | 2 +- internal/handlers/link.go | 31 ++++++++++++++++++- internal/handlers/log_middleware.go | 46 +++++++++++++++++++++++++++++ internal/repositories/link.go | 2 ++ internal/services/link.go | 42 ++++++++++++++++++++++++++ 9 files changed, 144 insertions(+), 23 deletions(-) delete mode 100644 internal/app/routes.go create mode 100644 internal/handlers/log_middleware.go diff --git a/.gitignore b/.gitignore index 5b90e79..f6aa9c7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ go.work.sum # env file .env + +*.db diff --git a/cmd/maot-shortner/server.go b/cmd/maot-shortner/server.go index 2699c93..77f4d28 100644 --- a/cmd/maot-shortner/server.go +++ b/cmd/maot-shortner/server.go @@ -25,8 +25,9 @@ func createMux(db *database.Database) http.Handler { r := http.NewServeMux() linkHandler := handlers.NewLinkHandler(db) - r.HandleFunc("GET /api/links", linkHandler.ListLinks) + + r.HandleFunc("POST /api/link", linkHandler.CreateLink) r.HandleFunc("GET /{id}", linkHandler.GetUrl) - return r + return handlers.LogMiddleware(r) } diff --git a/internal/app/routes.go b/internal/app/routes.go deleted file mode 100644 index 9f4c58a..0000000 --- a/internal/app/routes.go +++ /dev/null @@ -1,19 +0,0 @@ -package app - -import ( - "maot-shortner/internal/handlers" - "net/http" -) - -type HandlerList struct { - *handlers.LinkHandler -} - -func CreateMux(handlers HandlerList) *http.ServeMux { - r := http.NewServeMux() - - r.HandleFunc("/links", handlers.LinkHandler.ListLinks) - r.HandleFunc("/", handlers.LinkHandler.Handle) - - return r -} diff --git a/internal/database/link.go b/internal/database/link.go index cd68408..450e8e7 100644 --- a/internal/database/link.go +++ b/internal/database/link.go @@ -14,3 +14,21 @@ func (db *Database) GetLinks(ctx context.Context) ([]*domain.Link, error) { return links, nil } + +func (db *Database) CreateLink(ctx context.Context, link *domain.Link) (*domain.Link, error) { + if err := db.WithContext(ctx).Create(link).Error; err != nil { + return nil, err + } + + return link, nil +} + +func (db *Database) GetLinkByShortCode(ctx context.Context, shortCode string) (*domain.Link, error) { + link := &domain.Link{} + + if err := db.WithContext(ctx).Where("short_url = ?", shortCode).First(link).Error; err != nil { + return nil, err + } + + return link, nil +} diff --git a/internal/domain/link.go b/internal/domain/link.go index 0ad7c02..0ea78e3 100644 --- a/internal/domain/link.go +++ b/internal/domain/link.go @@ -6,7 +6,7 @@ import ( type Link struct { ID int64 `json:"id"` - ShortURL string `json:"short_url"` + ShortURL string `json:"short_url" gorm:"uniqueIndex"` LongURL string `json:"long_url"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/handlers/link.go b/internal/handlers/link.go index 2cd5f96..0ce9142 100644 --- a/internal/handlers/link.go +++ b/internal/handlers/link.go @@ -44,8 +44,37 @@ func (h *LinkHandler) ListLinks(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(linkList) } +func (h *LinkHandler) CreateLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + var linkRequest struct { + URL string `json:"url"` + RedirectCode *string `json:"redirect_code,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&linkRequest); err != nil { + http.Error(w, fmt.Sprintf("Failed to decode request body: %v", err), http.StatusBadRequest) + return + } + + link, err := h.linkService.CreateLink(r.Context(), linkRequest.URL, linkRequest.RedirectCode) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create link: %v", err), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(link) +} + func (h *LinkHandler) GetUrl(w http.ResponseWriter, r *http.Request) { currentCode := r.PathValue("id") - json.NewEncoder(w).Encode(&Response{Message: currentCode}) + link, err := h.linkService.GetLink(r.Context(), currentCode) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get link: %v", err), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, link.LongURL, http.StatusFound) } diff --git a/internal/handlers/log_middleware.go b/internal/handlers/log_middleware.go new file mode 100644 index 0000000..8474243 --- /dev/null +++ b/internal/handlers/log_middleware.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "log" + "net/http" + "time" +) + +type statusRecorder struct { + http.ResponseWriter + statusCode int +} + +func (sr *statusRecorder) WriteHeader(code int) { + sr.statusCode = code + sr.ResponseWriter.WriteHeader(code) +} + +func (sr *statusRecorder) Write(b []byte) (int, error) { + if sr.statusCode == 0 { + sr.statusCode = http.StatusOK + } + + return sr.ResponseWriter.Write(b) +} + +func LogMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w} + + h.ServeHTTP(rec, r) + duration := time.Since(start) + + statusCode := rec.statusCode + + log.Printf("%s %s %s from %s - %d %s in %s\n", + r.Method, + r.URL.Path, + r.Proto, + r.RemoteAddr, + statusCode, + http.StatusText(statusCode), + duration.String()) + }) +} diff --git a/internal/repositories/link.go b/internal/repositories/link.go index d20b7b8..93bce65 100644 --- a/internal/repositories/link.go +++ b/internal/repositories/link.go @@ -7,4 +7,6 @@ import ( type LinkRepository interface { GetLinks(ctx context.Context) ([]*domain.Link, error) + CreateLink(ctx context.Context, link *domain.Link) (*domain.Link, error) + GetLinkByShortCode(ctx context.Context, shortCode string) (*domain.Link, error) } diff --git a/internal/services/link.go b/internal/services/link.go index 9cda7ba..945a72c 100644 --- a/internal/services/link.go +++ b/internal/services/link.go @@ -2,8 +2,13 @@ package services import ( "context" + "crypto/rand" + "encoding/base64" + "errors" "maot-shortner/internal/domain" "maot-shortner/internal/repositories" + "strings" + "time" ) type LinkService struct { @@ -19,3 +24,40 @@ func NewLinkService(repo repositories.LinkRepository) *LinkService { func (s *LinkService) GetLinks(ctx context.Context) ([]*domain.Link, error) { return s.repo.GetLinks(ctx) } + +func (s *LinkService) CreateLink(ctx context.Context, longUrl string, redirectCode *string) (*domain.Link, error) { + if !strings.Contains(longUrl, "http://") && !strings.Contains(longUrl, "https://") { + return nil, errors.New("Invalid URL") + } + + var shortUrl string + + if redirectCode != nil { + shortUrl = *redirectCode + } else { + shortUrl = generateShortCode(5) + } + + link := &domain.Link{ + LongURL: longUrl, + ShortURL: shortUrl, + CreatedAt: time.Now(), + } + + return s.repo.CreateLink(ctx, link) +} + +func generateShortCode(length int) string { + b := make([]byte, length) + rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "" + } + + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *LinkService) GetLink(ctx context.Context, shortUrl string) (*domain.Link, error) { + + return s.repo.GetLinkByShortCode(ctx, shortUrl) +}