feat: add url shortner
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ShortURLHandler struct {
|
||||
repo *repository.ShortURLRepo
|
||||
queue *shorturl.Queue
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewShortURLHandler(repo *repository.ShortURLRepo, queue *shorturl.Queue, logger *zap.Logger) *ShortURLHandler {
|
||||
return &ShortURLHandler{repo: repo, queue: queue, logger: logger}
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type createResponse struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type resolveResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/share. Validates the path, allocates a code in
|
||||
// Redis, enqueues the row for background insertion into Postgres, and returns
|
||||
// the code immediately. The path persisted is exactly what the client sent,
|
||||
// preserving the `?hl=` fragment.
|
||||
func (h *ShortURLHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := shorturl.ValidatePath(req.URL); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if h.queue == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "url shortener unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
code, err := h.queue.AllocateCode(ctx)
|
||||
if err != nil {
|
||||
h.logger.Warn("allocate code", zap.Error(err))
|
||||
response.Error(w, http.StatusServiceUnavailable, "failed to allocate short code")
|
||||
return
|
||||
}
|
||||
|
||||
payload := shorturl.PendingPayload{
|
||||
URL: req.URL,
|
||||
CreatorIP: clientIP(r),
|
||||
CreatorUA: truncateTo(r.UserAgent(), 1024),
|
||||
CreatorReferer: truncateTo(r.Referer(), 1024),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := h.queue.MarkPending(ctx, code, payload); err != nil {
|
||||
h.logger.Warn("mark pending", zap.String("code", code), zap.Error(err))
|
||||
response.Error(w, http.StatusServiceUnavailable, "failed to queue request")
|
||||
return
|
||||
}
|
||||
if err := h.queue.EnqueueCreate(ctx, code); err != nil {
|
||||
h.logger.Warn("enqueue create", zap.String("code", code), zap.Error(err))
|
||||
response.Error(w, http.StatusServiceUnavailable, "failed to queue request")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, createResponse{Code: code})
|
||||
}
|
||||
|
||||
// Resolve handles GET /api/v1/share/{code}. Resolves through the cache chain
|
||||
// (hot cache → pending → Postgres) and enqueues a click event. Click events
|
||||
// are flushed asynchronously by the click-worker.
|
||||
func (h *ShortURLHandler) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.ToLower(chi.URLParam(r, "code"))
|
||||
if !shorturl.ValidCode(code) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid code")
|
||||
return
|
||||
}
|
||||
|
||||
if h.queue == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "url shortener unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 1. Hot cache.
|
||||
if longURL, ok := h.queue.LookupCached(ctx, code); ok {
|
||||
h.enqueueClick(ctx, code, r, now)
|
||||
response.JSON(w, http.StatusOK, resolveResponse{URL: longURL})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Pending (still in flight to Postgres).
|
||||
if p, ok := h.queue.LookupPending(ctx, code); ok {
|
||||
h.enqueueClick(ctx, code, r, now)
|
||||
response.JSON(w, http.StatusOK, resolveResponse{URL: p.URL})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Postgres.
|
||||
row, err := h.repo.GetByCode(ctx, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
response.Error(w, http.StatusNotFound, "short link not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("resolve postgres", zap.String("code", code), zap.Error(err))
|
||||
response.Error(w, http.StatusInternalServerError, "failed to resolve short link")
|
||||
return
|
||||
}
|
||||
if row.Disabled {
|
||||
response.Error(w, http.StatusGone, "short link disabled")
|
||||
return
|
||||
}
|
||||
if row.ExpiresAt != nil && row.ExpiresAt.Before(now) {
|
||||
response.Error(w, http.StatusGone, "short link expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Repopulate hot cache and enqueue the click.
|
||||
_ = h.queue.MarkCached(ctx, code, row.LongURL)
|
||||
h.enqueueClick(ctx, code, r, now)
|
||||
response.JSON(w, http.StatusOK, resolveResponse{URL: row.LongURL})
|
||||
}
|
||||
|
||||
func (h *ShortURLHandler) enqueueClick(ctx context.Context, code string, r *http.Request, ts time.Time) {
|
||||
ua := r.UserAgent()
|
||||
ev := shorturl.ClickEvent{
|
||||
Code: code,
|
||||
IP: clientIP(r),
|
||||
UA: truncateTo(ua, 1024),
|
||||
Referer: truncateTo(r.Referer(), 1024),
|
||||
IsBot: shorturl.IsBot(ua),
|
||||
ClickedAt: ts,
|
||||
}
|
||||
if err := h.queue.EnqueueClick(ctx, ev); err != nil {
|
||||
h.logger.Warn("enqueue click", zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extracts the best-effort client IP. RemoteAddr is "host:port"; we
|
||||
// strip the port. The chi RealIP middleware already overwrites RemoteAddr
|
||||
// from X-Forwarded-For / X-Real-IP when those are present.
|
||||
func clientIP(r *http.Request) string {
|
||||
addr := r.RemoteAddr
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func truncateTo(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Postgres struct {
|
||||
Pool *pgxpool.Pool
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewPostgres(ctx context.Context, uri string, logger *zap.Logger) (*Postgres, error) {
|
||||
if uri == "" {
|
||||
return nil, fmt.Errorf("postgres URI is empty")
|
||||
}
|
||||
|
||||
cfg, err := pgxpool.ParseConfig(uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres parse config: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(pingCtx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres connect: %w", err)
|
||||
}
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("connected to Postgres")
|
||||
return &Postgres{Pool: pool, logger: logger}, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) Close() {
|
||||
if p == nil || p.Pool == nil {
|
||||
return
|
||||
}
|
||||
p.Pool.Close()
|
||||
}
|
||||
|
||||
// RunMigrations applies any *.up.sql files in dir that haven't been recorded
|
||||
// in schema_migrations yet, in lexical order. Tiny homegrown runner — no
|
||||
// external dependency. Down-migrations are not executed automatically.
|
||||
func (p *Postgres) RunMigrations(ctx context.Context, dir string) error {
|
||||
if _, err := p.Pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.up.sql"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("glob migrations: %w", err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, f := range files {
|
||||
version, err := parseMigrationVersion(f)
|
||||
if err != nil {
|
||||
p.logger.Warn("skipping unparseable migration filename", zap.String("file", f), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := p.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version,
|
||||
).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", version, err)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
sqlBytes, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", f, err)
|
||||
}
|
||||
|
||||
tx, err := p.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", f, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("apply %s: %w", f, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("record %s: %w", f, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit %s: %w", f, err)
|
||||
}
|
||||
|
||||
p.logger.Info("applied migration", zap.String("file", filepath.Base(f)), zap.Int64("version", version))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationVersion(path string) (int64, error) {
|
||||
base := filepath.Base(path)
|
||||
idx := strings.Index(base, "_")
|
||||
if idx <= 0 {
|
||||
return 0, fmt.Errorf("filename %q must start with <version>_", base)
|
||||
}
|
||||
v, err := strconv.ParseInt(base[:idx], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("version prefix not an integer: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Redis struct {
|
||||
Client *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewRedis(ctx context.Context, uri string, logger *zap.Logger) (*Redis, error) {
|
||||
if uri == "" {
|
||||
return nil, fmt.Errorf("redis URI is empty")
|
||||
}
|
||||
|
||||
opts, err := redis.ParseURL(uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis parse URL: %w", err)
|
||||
}
|
||||
|
||||
client := redis.NewClient(opts)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("redis ping: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("connected to Redis")
|
||||
return &Redis{Client: client, logger: logger}, nil
|
||||
}
|
||||
|
||||
func (r *Redis) Close() error {
|
||||
if r == nil || r.Client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Client.Close()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
)
|
||||
|
||||
// ShortenedURLRow mirrors the analytics-relevant columns of shortened_urls.
|
||||
type ShortenedURLRow struct {
|
||||
ID int64
|
||||
Code string
|
||||
LongURL string
|
||||
Disabled bool
|
||||
ExpiresAt *time.Time
|
||||
ClickCount int64
|
||||
}
|
||||
|
||||
// ShortURLRepo wraps Postgres CRUD for the shortener.
|
||||
type ShortURLRepo struct {
|
||||
pg *Postgres
|
||||
}
|
||||
|
||||
func NewShortURLRepo(pg *Postgres) *ShortURLRepo {
|
||||
return &ShortURLRepo{pg: pg}
|
||||
}
|
||||
|
||||
// Insert persists a new short URL. Returns the new row's id. If a unique
|
||||
// violation occurs (race: another worker already inserted this code) the
|
||||
// caller can detect via IsUniqueViolation(err).
|
||||
func (r *ShortURLRepo) Insert(
|
||||
ctx context.Context,
|
||||
code string,
|
||||
payload shorturl.PendingPayload,
|
||||
parsed shorturl.Parsed,
|
||||
) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO shortened_urls (
|
||||
code, long_url,
|
||||
path, content_type, book_code, division, chapter, verse, slug,
|
||||
has_highlight, highlight_param,
|
||||
creator_ip, creator_ua, creator_referer, creator_country,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1, $2,
|
||||
$3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11,
|
||||
NULLIF($12,'')::inet, NULLIF($13,''), NULLIF($14,''), NULLIF($15,''),
|
||||
$16
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
var id int64
|
||||
err := r.pg.Pool.QueryRow(ctx, q,
|
||||
code, payload.URL,
|
||||
parsed.Path, parsed.ContentType,
|
||||
nullableStr(parsed.BookCode),
|
||||
nullableStr(parsed.Division),
|
||||
nullableStr(parsed.Chapter),
|
||||
nullableStr(parsed.Verse),
|
||||
nullableStr(parsed.Slug),
|
||||
parsed.HasHighlight, nullableStr(parsed.HighlightParam),
|
||||
payload.CreatorIP, payload.CreatorUA, payload.CreatorReferer, payload.CreatorCountry,
|
||||
payload.CreatedAt,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert shortened_url: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetByCode returns the row for code, or pgx.ErrNoRows if missing.
|
||||
func (r *ShortURLRepo) GetByCode(ctx context.Context, code string) (*ShortenedURLRow, error) {
|
||||
const q = `
|
||||
SELECT id, code, long_url, disabled, expires_at, click_count
|
||||
FROM shortened_urls
|
||||
WHERE code = $1
|
||||
LIMIT 1
|
||||
`
|
||||
var row ShortenedURLRow
|
||||
err := r.pg.Pool.QueryRow(ctx, q, code).Scan(
|
||||
&row.ID, &row.Code, &row.LongURL, &row.Disabled, &row.ExpiresAt, &row.ClickCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// RecordClick inserts a row in shortened_url_clicks and bumps the aggregate
|
||||
// counters on the parent row in a single transaction. The parent lookup uses
|
||||
// the code (not id) so this works even if the click worker is processing an
|
||||
// event for a URL inserted by a different process.
|
||||
func (r *ShortURLRepo) RecordClick(ctx context.Context, ev shorturl.ClickEvent) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var id int64
|
||||
err = tx.QueryRow(ctx, `SELECT id FROM shortened_urls WHERE code = $1`, ev.Code).Scan(&id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Parent row hasn't landed in PG yet (still pending). Skip silently;
|
||||
// the click is lost. (Rare: only when a resolution and the parent
|
||||
// insert race within the worker drain interval.)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO shortened_url_clicks (
|
||||
short_url_id, code, clicked_at, client_ip, client_ua, referer, country, is_bot
|
||||
) VALUES (
|
||||
$1, $2, $3, NULLIF($4,'')::inet, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8
|
||||
)`,
|
||||
id, ev.Code, ev.ClickedAt, ev.IP, ev.UA, ev.Referer, ev.Country, ev.IsBot,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE shortened_urls
|
||||
SET click_count = click_count + 1,
|
||||
human_click_count = human_click_count + CASE WHEN $2 THEN 0 ELSE 1 END,
|
||||
last_clicked_at = $1,
|
||||
first_clicked_at = COALESCE(first_clicked_at, $1)
|
||||
WHERE id = $3
|
||||
`, ev.ClickedAt, ev.IsBot, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// IsUniqueViolation reports whether err is a Postgres unique constraint error.
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nullableStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
@@ -10,6 +12,8 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -19,6 +23,11 @@ type Server struct {
|
||||
logger *zap.Logger
|
||||
router chi.Router
|
||||
mongo *repository.MongoDB
|
||||
pg *repository.Postgres
|
||||
redis *repository.Redis
|
||||
|
||||
// Worker lifecycle
|
||||
workerCancel context.CancelFunc
|
||||
|
||||
// Handlers
|
||||
bookH *handler.BookHandler
|
||||
@@ -31,6 +40,7 @@ type Server struct {
|
||||
otherDocH *handler.OtherDocumentHandler
|
||||
transcriptionH *handler.TranscriptionHandler
|
||||
searchH *handler.SearchHandler
|
||||
shortURLH *handler.ShortURLHandler
|
||||
}
|
||||
|
||||
// New creates a new Server with all routes registered.
|
||||
@@ -72,10 +82,52 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
||||
searchH: handler.NewSearchHandler(tsClient),
|
||||
}
|
||||
|
||||
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
||||
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
|
||||
s.initShortener(cfg, logger)
|
||||
|
||||
s.setupRouter()
|
||||
return s
|
||||
}
|
||||
|
||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||
// On connection failure it logs and leaves the components nil — the handler
|
||||
// short-circuits to 503 so the rest of the BFF keeps working.
|
||||
func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.workerCancel = cancel
|
||||
|
||||
pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger)
|
||||
if err != nil {
|
||||
logger.Warn("URL shortener: Postgres unavailable — /share endpoints will return 503",
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.pg = pg
|
||||
|
||||
migrationsDir, err := filepath.Abs("migrations")
|
||||
if err == nil {
|
||||
if err := pg.RunMigrations(ctx, migrationsDir); err != nil {
|
||||
logger.Warn("URL shortener: migrations failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
rd, err := repository.NewRedis(ctx, cfg.RedisURI, logger)
|
||||
if err != nil {
|
||||
logger.Warn("URL shortener: Redis unavailable — /share endpoints will return 503",
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.redis = rd
|
||||
|
||||
shortRepo := repository.NewShortURLRepo(pg)
|
||||
queue := shorturl.NewQueue(rd.Client)
|
||||
s.shortURLH = handler.NewShortURLHandler(shortRepo, queue, logger)
|
||||
|
||||
w := worker.NewShortURLWorker(shortRepo, queue, logger)
|
||||
w.Run(ctx)
|
||||
}
|
||||
|
||||
// Router returns the chi router.
|
||||
func (s *Server) Router() http.Handler {
|
||||
return s.router
|
||||
@@ -83,10 +135,20 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
// Close shuts down all connections gracefully.
|
||||
func (s *Server) Close() {
|
||||
if s.workerCancel != nil {
|
||||
s.workerCancel()
|
||||
}
|
||||
if err := s.mongo.Close(); err != nil {
|
||||
s.logger.Error("failed to close MongoDB connection", zap.Error(err))
|
||||
}
|
||||
// TODO: close PostgreSQL, Redis, etc. connections
|
||||
if s.pg != nil {
|
||||
s.pg.Close()
|
||||
}
|
||||
if s.redis != nil {
|
||||
if err := s.redis.Close(); err != nil {
|
||||
s.logger.Error("failed to close Redis connection", zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.logger.Info("all connections closed")
|
||||
}
|
||||
|
||||
@@ -319,9 +381,21 @@ func (s *Server) setupRouter() {
|
||||
// TODO: register mention handlers
|
||||
})
|
||||
|
||||
// URL Shortener
|
||||
r.Route("/s", func(r chi.Router) {
|
||||
// TODO: register URL shortener handlers
|
||||
// URL Shortener — Postgres-backed, Redis-queued.
|
||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||
r.Route("/share", func(r chi.Router) {
|
||||
if s.shortURLH != nil {
|
||||
r.Post("/", s.shortURLH.Create)
|
||||
r.Get("/{code}", s.shortURLH.Resolve)
|
||||
} else {
|
||||
stub := func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"success":false,"error":{"code":503,"message":"url shortener unavailable"}}`))
|
||||
}
|
||||
r.Post("/", stub)
|
||||
r.Get("/{code}", stub)
|
||||
}
|
||||
})
|
||||
|
||||
// Push Notifications
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ShortURLWorker drains the Redis create-queue + click-queue into Postgres.
|
||||
type ShortURLWorker struct {
|
||||
repo *repository.ShortURLRepo
|
||||
queue *shorturl.Queue
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewShortURLWorker(repo *repository.ShortURLRepo, queue *shorturl.Queue, logger *zap.Logger) *ShortURLWorker {
|
||||
return &ShortURLWorker{repo: repo, queue: queue, logger: logger}
|
||||
}
|
||||
|
||||
// Run starts the create-worker and click-worker goroutines. Both exit cleanly
|
||||
// when ctx is cancelled. Call from server startup.
|
||||
func (w *ShortURLWorker) Run(ctx context.Context) {
|
||||
go w.runCreateLoop(ctx)
|
||||
go w.runClickLoop(ctx)
|
||||
}
|
||||
|
||||
func (w *ShortURLWorker) runCreateLoop(ctx context.Context) {
|
||||
w.logger.Info("shorturl create-worker started")
|
||||
defer w.logger.Info("shorturl create-worker stopped")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
code, err := w.queue.DequeueCreate(ctx, 5*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("create-worker dequeue", zap.Error(err))
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
payload, ok := w.queue.LookupPending(ctx, code)
|
||||
if !ok {
|
||||
// Placeholder without a real payload — likely the producer crashed
|
||||
// between MarkPending and EnqueueCreate. Skip.
|
||||
w.logger.Warn("create-worker: missing pending payload", zap.String("code", code))
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := shorturl.ParsePath(payload.URL)
|
||||
if _, err := w.repo.Insert(ctx, code, payload, parsed); err != nil {
|
||||
if repository.IsUniqueViolation(err) {
|
||||
// Already persisted by a previous attempt; clean up the queue entry.
|
||||
_ = w.queue.UnmarkPending(ctx, code)
|
||||
continue
|
||||
}
|
||||
w.logger.Error("create-worker insert", zap.String("code", code), zap.Error(err))
|
||||
_ = w.queue.PushDeadLetter(ctx, code, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.queue.MarkCached(ctx, code, payload.URL); err != nil {
|
||||
w.logger.Warn("create-worker mark cached", zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
if err := w.queue.UnmarkPending(ctx, code); err != nil {
|
||||
w.logger.Warn("create-worker unmark pending", zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ShortURLWorker) runClickLoop(ctx context.Context) {
|
||||
w.logger.Info("shorturl click-worker started")
|
||||
defer w.logger.Info("shorturl click-worker stopped")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
ev, ok, err := w.queue.DequeueClick(ctx, 5*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
w.logger.Warn("click-worker dequeue", zap.Error(err))
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := w.repo.RecordClick(ctx, ev); err != nil {
|
||||
w.logger.Warn("click-worker record", zap.String("code", ev.Code), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user