Implement rudimentary link shortening service and wire everything together
All checks were successful
Build and Push Docker Image / build (push) Successful in 5m55s

This commit is contained in:
2026-06-02 19:28:22 +02:00
parent 1b4d23f9aa
commit a9e1d1e5d9
3 changed files with 274 additions and 0 deletions

83
internal/service/http.go Normal file
View File

@@ -0,0 +1,83 @@
package service
import (
"database/sql"
"log/slog"
"net/http"
"slices"
"strings"
"git.kocoder.xyz/vt/shortener/internal/config"
"git.kocoder.xyz/vt/shortener/internal/database"
)
type Server struct {
conf *config.Config
db *database.Queries
}
func NewServer(conf *config.Config, db *database.Queries) *Server {
return &Server{
conf: conf,
db: db,
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
path := req.URL.Path
path = strings.Trim(path, "/")
slog.InfoContext(ctx, "processing request", slog.String("path", path))
res, err := s.db.GetURLByShortCode(ctx, path)
if err != nil {
slog.ErrorContext(ctx, "failed to get url by short code", slog.String("path", path), slog.String("err", err.Error()))
w.WriteHeader(http.StatusInternalServerError)
return
}
forwardedFor := req.Header.Get("X-Forwarded-For")
remoteAddr := req.RemoteAddr
if slices.Contains(s.conf.TRUSTED_PROXIES, strings.Split(remoteAddr, ":")[0]) {
if strings.Contains(forwardedFor, ", ") {
remoteAddr = strings.Split(forwardedFor, ", ")[0]
} else {
remoteAddr = forwardedFor
}
}
slog.InfoContext(ctx, "tracking click",
slog.String("ip", remoteAddr),
slog.String("user_agent", req.UserAgent()),
slog.String("referrer", req.Referer()),
)
_, err = s.db.TrackClick(ctx, database.TrackClickParams{
UrlID: res.UrlID,
UserAgent: sql.NullString{
String: req.UserAgent(),
Valid: true,
},
Referrer: sql.NullString{
String: req.Referer(),
Valid: true,
},
IpAddress: sql.NullString{
String: remoteAddr,
Valid: true,
},
})
if err != nil {
slog.ErrorContext(ctx, "failed to track click", slog.String("err", err.Error()))
// Still continuing to redirect even if tracking fails, or we can return.
// Usually we still want to redirect the user so they reach their destination.
}
slog.InfoContext(ctx, "redirecting to destination", slog.String("long_url", res.LongUrl))
http.Redirect(w, req, res.LongUrl, http.StatusFound)
}

View File

@@ -0,0 +1,105 @@
package service
import (
"context"
"log/slog"
"time"
"git.kocoder.xyz/vt/shortener/internal/config"
"git.kocoder.xyz/vt/shortener/internal/database"
shortenv1 "git.kocoder.xyz/vt/shortener/internal/proto/shorten/v1"
"git.kocoder.xyz/vt/shortener/internal/proto/shorten/v1/shortenv1connect"
"google.golang.org/protobuf/types/known/timestamppb"
)
type ShortenerService struct {
conf *config.Config
db *database.Queries
}
// CreateURLRedirection implements [shortenv1connect.ShortenServiceHandler].
func (s *ShortenerService) CreateURLRedirection(ctx context.Context, req *shortenv1.CreateURLRedirectionRequest) (*shortenv1.CreateURLRedirectionResponse, error) {
_, err := s.db.CreateURL(ctx, database.CreateURLParams{
ShortCode: req.ShortCode,
LongUrl: req.Url,
IsActive: true,
CreatedAt: time.Now(),
})
if err != nil {
slog.Log(ctx, slog.LevelError, err.Error())
return &shortenv1.CreateURLRedirectionResponse{
Ok: false,
}, err
}
return &shortenv1.CreateURLRedirectionResponse{
Ok: true,
}, nil
}
// DeactivateURLRedirection implements [shortenv1connect.ShortenServiceHandler].
func (s *ShortenerService) DeactivateURLRedirection(ctx context.Context, req *shortenv1.DeactivateURLRedirectionRequest) (*shortenv1.DeactivateURLRedirectionResponse, error) {
_, err := s.db.SetActive(ctx, database.SetActiveParams{
IsActive: req.IsActive,
UrlID: req.UrlId,
})
if err != nil {
slog.Log(ctx, slog.LevelError, err.Error())
return &shortenv1.DeactivateURLRedirectionResponse{
Ok: false,
}, err
}
return &shortenv1.DeactivateURLRedirectionResponse{
Ok: true,
}, nil
}
// DeleteURLRedirection implements [shortenv1connect.ShortenServiceHandler].
func (s *ShortenerService) DeleteURLRedirection(ctx context.Context, req *shortenv1.DeleteURLRedirectionRequest) (*shortenv1.DeleteURLRedirectionResponse, error) {
_, err := s.db.DeleteURL(ctx, req.UrlId)
if err != nil {
slog.Log(ctx, slog.LevelError, err.Error())
return &shortenv1.DeleteURLRedirectionResponse{
Ok: false,
}, err
}
return &shortenv1.DeleteURLRedirectionResponse{
Ok: true,
}, nil
}
// ListURLRedirections implements [shortenv1connect.ShortenServiceHandler].
func (s *ShortenerService) ListURLRedirections(ctx context.Context, req *shortenv1.ListURLRedirectionsRequest) (*shortenv1.ListURLRedirectionsResponse, error) {
urls, err := s.db.GetURLs(ctx)
if err != nil {
slog.Log(ctx, slog.LevelError, err.Error())
return nil, err
}
var redirections []*shortenv1.URLRedirection
for _, url := range urls {
redirections = append(redirections, &shortenv1.URLRedirection{
UrlId: int32(url.UrlID),
ShortCode: url.ShortCode,
Url: url.LongUrl,
CreatedAt: timestamppb.New(url.CreatedAt),
ExpiresAt: timestamppb.New(url.ExpiresAt.Time),
IsActive: url.IsActive,
ClickCountByDay: []int32{},
})
}
return &shortenv1.ListURLRedirectionsResponse{
UrlRedirections: redirections,
}, nil
}
func NewShortenerService(conf *config.Config, db *database.Queries) shortenv1connect.ShortenServiceHandler {
return &ShortenerService{
conf: conf,
db: db,
}
}