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]
|
||||
}
|
||||
Reference in New Issue
Block a user