first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
go.mod
|
||||||
|
go.sum
|
||||||
43
cmd/main.go
Normal file
43
cmd/main.go
Normal 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
49
docker-compose.yml
Normal 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
18
dockerfile
Normal 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
38
internal/config/config.go
Normal 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
33
internal/db/redis.go
Normal 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
|
||||||
|
}
|
||||||
93
internal/handlers/links.go
Normal file
93
internal/handlers/links.go
Normal 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
11
internal/handlers/ping.go
Normal 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
11
internal/http/routes.go
Normal 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
42
internal/http/server.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/repositories/links.go
Normal file
24
internal/repositories/links.go
Normal 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()
|
||||||
|
}
|
||||||
31
internal/services/links.go
Normal file
31
internal/services/links.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user