Files
spsays-bff/internal/handler/shorturl.go
T
2026-05-11 16:13:22 +05:30

186 lines
5.2 KiB
Go

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]
}