first commit

This commit is contained in:
2026-02-06 21:08:15 +03:00
commit 2d7bd20ac0
12 changed files with 395 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
go.mod
go.sum

43
cmd/main.go Normal file
View File

@@ -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()
}

49
docker-compose.yml Normal file
View File

@@ -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

18
dockerfile Normal file
View File

@@ -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"]

38
internal/config/config.go Normal file
View File

@@ -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
}

33
internal/db/redis.go Normal file
View File

@@ -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
}

View File

@@ -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
}

11
internal/handlers/ping.go Normal file
View File

@@ -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"})
}

11
internal/http/routes.go Normal file
View File

@@ -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)
}

42
internal/http/server.go Normal file
View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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)
}