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