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