add db layer
This commit is contained in:
commit
ee164a7aa7
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Server
|
||||||
|
SERVER_PORT=9000
|
||||||
|
BASE_URL=http://localhost:9000
|
||||||
|
|
||||||
|
#Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=SALEH@url-shortner
|
||||||
|
DB_NAME=urlshortner
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_ADDR=localhost:6378
|
||||||
|
REDIS_PASSWORD=SALEH@url-shortner
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# App
|
||||||
|
DEFAULT_EXPIREY_HOURS=720
|
||||||
|
API_KEY=my-secret-api-key
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="github.com/pkg/errors" />
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/url-shortner.iml" filepath="$PROJECT_DIR$/.idea/url-shortner.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/SalehGoML/url-shortner/internal/config"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
log.Printf("Starting server on port %s", cfg.ServerPort)
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "URL Shortener is running",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := r.Run(":" + cfg.ServerPort); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: urlshortner-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: SALEH@url-shortner
|
||||||
|
POSTGRES_DB: urlshortner
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./migrations:/docker-entrypoint-initdb.d
|
||||||
|
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: urlshortner-cache
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.gocasts.ir/msaskarzadeh/url-shortner.git
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerPort string
|
||||||
|
BaseURL string
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
DBSSLMode string
|
||||||
|
RedisAddr string
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
DefaultExpiryHrs int
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Println("No .env file found, using system env vars")
|
||||||
|
}
|
||||||
|
|
||||||
|
redisDB, _ := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
||||||
|
expiryHrs, _ := strconv.Atoi(getEnv("DEFAULT_EXPIRY_HOURS", "720"))
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
ServerPort: getEnv("SERVER_PORT", "9000"),
|
||||||
|
BaseURL: getEnv("BASE_URL", "http://localhost:9000"),
|
||||||
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
|
DBPort: getEnv("DB_PORT", "5432"),
|
||||||
|
DBUser: getEnv("DB_USER", "postgres"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "SALEH@url-shortner"),
|
||||||
|
DBName: getEnv("DB_NAME", "url-shortner"),
|
||||||
|
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||||
|
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||||
|
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||||
|
RedisDB: redisDB,
|
||||||
|
DefaultExpiryHrs: expiryHrs,
|
||||||
|
APIKey: getEnv("API_KEY", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if val, exists := os.LookupEnv(key); exists {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DNS() string {
|
||||||
|
return "host=" + c.DBHost +
|
||||||
|
" port=" + c.DBPort +
|
||||||
|
" user=" + c.DBUser +
|
||||||
|
" password=" + c.DBPassword +
|
||||||
|
" dbname=" + c.DBName +
|
||||||
|
" sslmode=" + c.DBSSLMode
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type URL struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ShortCode string `json:"short_code"`
|
||||||
|
OriginalURL string `json:"original"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Clicks int64 `json:"clicks"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClickLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URLID string `json:"url_id"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
Referer string `json:"referer"`
|
||||||
|
ClickedAt time.Time `json:"clicked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateURLRequest struct {
|
||||||
|
OriginalURL string `json:"original"`
|
||||||
|
CustomCode string `json:"custom_code,omitempty"`
|
||||||
|
ExpiryHours int `json:"expiry_hours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateURLResponse struct {
|
||||||
|
ShortCode string `json:"short_code" binding:"required"`
|
||||||
|
ShortURL string `json:"short_url"`
|
||||||
|
OriginalURL string `json:"original_url"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLStats struct {
|
||||||
|
ShortCode string `json:"short_code"`
|
||||||
|
OriginalURL string `json:"original_url"`
|
||||||
|
Clicks int64 `json:"clicks"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||||
|
RecentClick []ClickLog `json:"recent_click"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gocasts.ir/msaskarzadeh/url-shortner.git/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type URLRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewURLRepository(dns string) (*URLRepository, error) {
|
||||||
|
db, err := sql.Open("postgres", dns)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(25)
|
||||||
|
db.SetMaxIdleConns(10)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &URLRepository{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) Create(ctx context.Context, url *model.URL) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO urls (short_code, original_url, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at, updated_at`
|
||||||
|
|
||||||
|
return r.db.QueryRowContext(ctx, query,
|
||||||
|
url.ShortCode,
|
||||||
|
url.OriginalURL,
|
||||||
|
url.UserID,
|
||||||
|
url.ExpiresAt,
|
||||||
|
).Scan(&url.ID, &url.CreatedAt, &url.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) GetShortCode(ctx context.Context, code string) (*model.URL, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, short_code, original_url, user_id, clicks,
|
||||||
|
is_active, created_at, expires_at, updated_at
|
||||||
|
FROM urls
|
||||||
|
WHERE short_code = $1`
|
||||||
|
|
||||||
|
url := &model.URL{}
|
||||||
|
err := r.db.QueryRowContext(ctx, query, code).Scan(
|
||||||
|
&url.ID,
|
||||||
|
&url.ShortCode,
|
||||||
|
&url.OriginalURL,
|
||||||
|
&url.UserID,
|
||||||
|
&url.Clicks,
|
||||||
|
&url.IsActive,
|
||||||
|
&url.CreatedAt,
|
||||||
|
&url.ExpiresAt,
|
||||||
|
&url.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
func (r *URLRepository) IncrementClicks(ctx context.Context, id int64) error {
|
||||||
|
query := `
|
||||||
|
UPDATE urls
|
||||||
|
SET clicks = clicks + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`
|
||||||
|
_, err := r.db.ExecContext(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to increment clicks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) LogClicks(ctx context.Context, logEntry *model.ClickLog) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO click_logs (url_id, ip_address, user_agent, referer)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, clicked_at`
|
||||||
|
|
||||||
|
return r.db.QueryRowContext(
|
||||||
|
ctx, query,
|
||||||
|
logEntry.URLID,
|
||||||
|
logEntry.IPAddress,
|
||||||
|
logEntry.UserAgent,
|
||||||
|
logEntry.Referer,
|
||||||
|
).Scan(&logEntry.ID, &logEntry.ClickedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) GetClicks(ctx context.Context, urlID int64, limit int) ([]model.ClickLog, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, url_id, ip_address, user_agent, referer, clicked_at
|
||||||
|
FROM click_logs
|
||||||
|
WHERE url_id = $1
|
||||||
|
ORDER BY clicked_at DESC
|
||||||
|
LIMIT $2`
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, urlID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch recent clicks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []model.ClickLog
|
||||||
|
for rows.Next() {
|
||||||
|
var l model.ClickLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.URLID, &l.IPAddress,
|
||||||
|
&l.UserAgent, &l.Referer, &l.ClickedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) DeactivateExpiredLinks(ctx context.Context) (int64, error) {
|
||||||
|
query := `
|
||||||
|
UPDATE urls
|
||||||
|
SET is_active = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE expires_at IS NOT NULL
|
||||||
|
AND expires_at < NOW()
|
||||||
|
AND is_active = true`
|
||||||
|
|
||||||
|
result, err := r.db.ExecContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to deactivate expired links: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *URLRepository) CheckShortExists(ctx context.Context, code string) (bool, error) {
|
||||||
|
query := `SELECT 1 FROM urls WHERE short_code = $1`
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
err := r.db.QueryRowContext(ctx, query, code).Scan(&exists)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS urls (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
short_code VARCHAR(10) NOT NULL UNIQUE,
|
||||||
|
original_url TEXT NOT NULL,
|
||||||
|
user_id VARCHAR(100) DEFAULT 'anonymous',
|
||||||
|
clicks BIGINT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_urls_short_code ON urls(short_code);
|
||||||
|
|
||||||
|
CREATE INDEX idx_urls_active_expiry ON urls(is_active, expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS click_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
url_id BIGINT REFERENCES urls(id) ON DELETE CASCADE,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
referer TEXT,
|
||||||
|
clicked_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_click_logs_url_id ON click_logs(url_id);
|
||||||
|
CREATE INDEX idx_click_logs_time ON click_logs(clicked_at);
|
||||||
Loading…
Reference in New Issue