From 2d7bd20ac09b19863bfb7219b55d13598316c52c Mon Sep 17 00:00:00 2001 From: Giovanni Rezcjikov Date: Fri, 6 Feb 2026 21:08:15 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + cmd/main.go | 43 ++++++++++++++++ docker-compose.yml | 49 ++++++++++++++++++ dockerfile | 18 +++++++ internal/config/config.go | 38 ++++++++++++++ internal/db/redis.go | 33 ++++++++++++ internal/handlers/links.go | 93 ++++++++++++++++++++++++++++++++++ internal/handlers/ping.go | 11 ++++ internal/http/routes.go | 11 ++++ internal/http/server.go | 42 +++++++++++++++ internal/repositories/links.go | 24 +++++++++ internal/services/links.go | 31 ++++++++++++ 12 files changed, 395 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/main.go create mode 100644 docker-compose.yml create mode 100644 dockerfile create mode 100644 internal/config/config.go create mode 100644 internal/db/redis.go create mode 100644 internal/handlers/links.go create mode 100644 internal/handlers/ping.go create mode 100644 internal/http/routes.go create mode 100644 internal/http/server.go create mode 100644 internal/repositories/links.go create mode 100644 internal/services/links.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f117bc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +go.mod +go.sum \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..0a2ad5f --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "log" + "os" + + "example.com/m/internal/config" + "example.com/m/internal/db" + "example.com/m/internal/handlers" + httpserver "example.com/m/internal/http" + "example.com/m/internal/repositories" + "example.com/m/internal/services" +) + +func main() { + if err := run(); err != nil { + fmt.Printf("server has been stopped: %v", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load configs: %v", err) + } + + database, err := db.InitRedis(cfg) + if err != nil { + log.Fatalf("failed to connect to redis: %v", err) + } + defer database.Close() + + address := fmt.Sprintf("0.0.0.0:%s", cfg.ServerPort) + + repo := repositories.NewLinksRepository(database) + service := services.NewLinksService(repo) + handler := handlers.NewLinksHandler(service, cfg.ServerHost, cfg.ServerPort) + + server := httpserver.NewServer(address, handler) + return server.Start() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..77aee97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + link-shortener: + build: + context: . + dockerfile: Dockerfile + container_name: link-shortener-app + networks: [isolated] + depends_on: + redis: + condition: service_healthy + ports: + - "8080:8080" + expose: + - 8080 + environment: + - SERVER_HOST=localhost + - SERVER_PORT=8080 + - REDIS_URL=redis://redis:6379 + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/ping"] + interval: 5s + timeout: 2s + retries: 20 + start_period: 10s + + redis: + image: redis:latest + container_name: link-shortener-redis + networks: [isolated] + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + +volumes: + redis_data: + driver: local + +networks: + isolated: + driver: bridge \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..37acbe5 --- /dev/null +++ b/dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine3.21 AS builder + +WORKDIR /opt + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . ./ + +RUN go build -o bin/application ./cmd + +FROM alpine:3.21 AS runner + +WORKDIR /opt + +COPY --from=builder /opt/bin/application ./ + +CMD ["./application"] \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d6423a8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,38 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + ServerHost string + ServerPort string + RedisURL string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + cfg := &Config{ + ServerHost: os.Getenv("SERVER_HOST"), + ServerPort: os.Getenv("SERVER_PORT"), + RedisURL: os.Getenv("REDIS_URL"), + } + + if cfg.ServerHost == "" { + cfg.ServerHost = "localhost" + } + + if cfg.ServerPort == "" { + return nil, fmt.Errorf("server port is required") + } + + if cfg.RedisURL == "" { + return nil, fmt.Errorf("redis connection url is required") + } + + return cfg, nil +} diff --git a/internal/db/redis.go b/internal/db/redis.go new file mode 100644 index 0000000..cf25e7a --- /dev/null +++ b/internal/db/redis.go @@ -0,0 +1,33 @@ +package db + +import ( + "context" + "log" + "time" + + "example.com/m/internal/config" + "github.com/redis/go-redis/v9" +) + +var Redis *redis.Client + +func InitRedis(cfg *config.Config) (*redis.Client, error) { + opt, err := redis.ParseURL(cfg.RedisURL) + + if err != nil { + log.Fatalf("impossible to parse redis url: %v", err) + return nil, err + } + + Redis = redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := Redis.Ping(ctx).Err(); err != nil { + log.Fatalf("impossible to conenct to redis db: %v", err) + return nil, err + } + + return Redis, nil +} diff --git a/internal/handlers/links.go b/internal/handlers/links.go new file mode 100644 index 0000000..d5f93ae --- /dev/null +++ b/internal/handlers/links.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "example.com/m/internal/services" + "github.com/gin-gonic/gin" +) + +type LinksHandler struct { + service *services.LinksService + host string + port string +} + +type CreateLinkResponse struct { + Status string `json:"status"` + ShortLink string `json:"short_link"` + OriginalLink string `json:"original_link"` + ExpiresIn string `json:"expires_in"` +} + +func NewLinksHandler(service *services.LinksService, host string, port string) *LinksHandler { + return &LinksHandler{service: service, host: host, port: port} +} + +func (h *LinksHandler) CreateLink(c *gin.Context) { + link := c.Param("link") + link = strings.TrimPrefix(link, "/") + if c.Request.URL.RawQuery != "" { + link += "?" + c.Request.URL.RawQuery + } + + link, err := NormalizeURL(link) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed", "message": "invalid link"}) + return + } + + id, err := h.service.CreateLink(link) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed", "message": "failed to create link"}) + return + } + + address := fmt.Sprintf("http://%v:%s/r/%v", h.host, h.port, id) + + response := CreateLinkResponse{ + Status: "success", + ShortLink: address, + OriginalLink: link, + ExpiresIn: "30d", + } + + c.JSON(http.StatusOK, response) +} + +func (h *LinksHandler) Redirect(c *gin.Context) { + id := c.Param("id") + original, err := h.service.GetLink(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "failed", "message": "link not found"}) + return + } + + c.Redirect(http.StatusFound, original) +} + +func NormalizeURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("empty URL") + } + + if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + raw = "https://" + raw + } + + u, err := url.Parse(raw) + if err != nil { + return "", errors.New("invalid URL") + } + + if u.Host == "" { + return "", errors.New("invalid host in URL") + } + + return u.String(), nil +} diff --git a/internal/handlers/ping.go b/internal/handlers/ping.go new file mode 100644 index 0000000..8561e18 --- /dev/null +++ b/internal/handlers/ping.go @@ -0,0 +1,11 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func Ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} diff --git a/internal/http/routes.go b/internal/http/routes.go new file mode 100644 index 0000000..1bec923 --- /dev/null +++ b/internal/http/routes.go @@ -0,0 +1,11 @@ +package http + +import "example.com/m/internal/handlers" + +func (s *Server) routes() { + api := s.engine + + api.GET("/ping", handlers.Ping) + api.GET("/l/*link", s.linksHandler.CreateLink) + s.engine.GET("/r/:id", s.linksHandler.Redirect) +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..5fd9027 --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,42 @@ +package http + +import ( + "fmt" + "log" + "net/http" + + "example.com/m/internal/handlers" + "github.com/gin-gonic/gin" +) + +type Server struct { + address string + + engine *gin.Engine + + linksHandler *handlers.LinksHandler +} + +func NewServer(address string, lh *handlers.LinksHandler) *Server { + engine := gin.New() + + engine.Use(gin.Recovery()) + + return &Server{ + address: address, + engine: engine, + linksHandler: lh, + } +} + +func (s *Server) Start() error { + s.routes() + + if err := s.engine.Run(s.address); err != nil && err != http.ErrServerClosed { + log.Fatalf("error occured while starting the server: %v", err) + return err + } + + fmt.Println("server started: &v", s.address) + return nil +} diff --git a/internal/repositories/links.go b/internal/repositories/links.go new file mode 100644 index 0000000..6b0f777 --- /dev/null +++ b/internal/repositories/links.go @@ -0,0 +1,24 @@ +package repositories + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +type LinksRepository struct { + redis *redis.Client +} + +func NewLinksRepository(redis *redis.Client) *LinksRepository { + return &LinksRepository{redis: redis} +} + +func (r *LinksRepository) CreateLink(shortID, original string) error { + return r.redis.Set(context.Background(), shortID, original, 30*24*time.Hour).Err() +} + +func (r *LinksRepository) GetLink(shortID string) (string, error) { + return r.redis.Get(context.Background(), shortID).Result() +} diff --git a/internal/services/links.go b/internal/services/links.go new file mode 100644 index 0000000..b3111b5 --- /dev/null +++ b/internal/services/links.go @@ -0,0 +1,31 @@ +package services + +import ( + "math/rand" + + "example.com/m/internal/repositories" + "github.com/sqids/sqids-go" +) + +type LinksService struct { + repo *repositories.LinksRepository + sqid *sqids.Sqids +} + +func NewLinksService(repo *repositories.LinksRepository) *LinksService { + s, _ := sqids.New() + return &LinksService{ + repo: repo, + sqid: s, + } +} + +func (s *LinksService) CreateLink(original string) (string, error) { + id, _ := s.sqid.Encode([]uint64{rand.Uint64()}) + err := s.repo.CreateLink(id, original) + return id, err +} + +func (s *LinksService) GetLink(id string) (string, error) { + return s.repo.GetLink(id) +}