From a9e1d1e5d976f10da1dc909cfc0cc6e6f1301545 Mon Sep 17 00:00:00 2001 From: KoCoder Date: Tue, 2 Jun 2026 19:28:22 +0200 Subject: [PATCH] Implement rudimentary link shortening service and wire everything together --- internal/service/http.go | 83 +++++++++++++++++++++++ internal/service/shorten_proto.go | 105 ++++++++++++++++++++++++++++++ main.go | 86 ++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 internal/service/http.go create mode 100644 internal/service/shorten_proto.go create mode 100644 main.go diff --git a/internal/service/http.go b/internal/service/http.go new file mode 100644 index 0000000..7b23212 --- /dev/null +++ b/internal/service/http.go @@ -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) +} diff --git a/internal/service/shorten_proto.go b/internal/service/shorten_proto.go new file mode 100644 index 0000000..f41e8e2 --- /dev/null +++ b/internal/service/shorten_proto.go @@ -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, + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..eb880b1 --- /dev/null +++ b/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + + "connectrpc.com/connect" + "connectrpc.com/grpcreflect" + "connectrpc.com/validate" + "git.kocoder.xyz/vt/shortener/internal/config" + "git.kocoder.xyz/vt/shortener/internal/database" + "git.kocoder.xyz/vt/shortener/internal/proto/shorten/v1/shortenv1connect" + "git.kocoder.xyz/vt/shortener/internal/service" + "git.kocoder.xyz/vt/shortener/internal/telemetry" + _ "github.com/lib/pq" + "github.com/uptrace/opentelemetry-go-extra/otelsql" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" +) + +func main() { + // Fallback logger for early startup errors + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + conf := config.Read() + + ctx := context.Background() + shutdown, err := telemetry.Init(ctx, conf.OTLP_ENDPOINT, "shortener-server") + if err != nil { + slog.Error("Failed to initialize telemetry", slog.String("err", err.Error())) + } + defer func() { + if err := shutdown(ctx); err != nil { + slog.Error("Error shutting down telemetry", slog.String("err", err.Error())) + } + }() + + // Connect to DB and wrap with otelsql for database tracing + db, err := otelsql.Open("postgres", conf.DB_URL) + if err != nil { + slog.Error("failed to connect to database", slog.String("err", err.Error())) + os.Exit(1) + } + defer db.Close() + + otelsql.ReportDBStatsMetrics(db, otelsql.WithAttributes( + attribute.String("db.system", "postgresql"), + )) + + dbQueries := database.New(db) + + srv := service.NewServer(conf, dbQueries) + + mux := http.NewServeMux() + path, handler := shortenv1connect.NewShortenServiceHandler( + service.NewShortenerService(conf, dbQueries), + connect.WithInterceptors(validate.NewInterceptor()), + ) + mux.Handle(path, handler) + + reflector := grpcreflect.NewStaticReflector(shortenv1connect.ShortenServiceName) + mux.Handle(grpcreflect.NewHandlerV1(reflector)) + mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) + + mux.Handle("/", srv) + + // Wrap the server with OpenTelemetry HTTP handler for tracing & metrics + instrumentedHandler := otelhttp.NewHandler(mux, "shortener-server2") + + p := new(http.Protocols) + p.SetHTTP1(true) + // Use h2c so we can serve HTTP/2 without TLS. + p.SetUnencryptedHTTP2(true) + s := http.Server{ + Addr: ":3001", + Handler: instrumentedHandler, + Protocols: p, + } + + slog.Info("Starting server on :3001") + if err := s.ListenAndServe(); err != nil { + slog.Error("Server failed", slog.String("err", err.Error())) + } +}