Implement rudimentary link shortening service and wire everything together
Some checks failed
Build and Push Docker Image / build (push) Failing after 34s
Some checks failed
Build and Push Docker Image / build (push) Failing after 34s
This commit is contained in:
83
internal/service/http.go
Normal file
83
internal/service/http.go
Normal 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)
|
||||||
|
}
|
||||||
105
internal/service/shorten_proto.go
Normal file
105
internal/service/shorten_proto.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
86
main.go
Normal file
86
main.go
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user