Загрузка данных
package main
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"encoding/json"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv"
"github.com/jackc/pgx/v5/pgconn"
)
type HandlerURL struct {
db *sql.DB
logger *slog.Logger
}
type UrlRequest struct {
Url string `json:"url"`
}
type UrlResponse struct {
Url string `json:"url"`
Short string `json:"short"`
}
type ErrResponse struct {
Detail string `json:"detail"`
}
func (h *HandlerURL) genShortCode() (string, error) {
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = charset[int(b[i]) % len(charset)]
}
return string(b), nil
}
func (h *HandlerURL) POST(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
var req UrlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
response := ErrResponse{
Detail: "bad JSON format",
}
encoder.Encode(response)
h.logger.Error("bad JSON format")
return
}
fullURL := req.Url
ctx := r.Context()
var code string
maxAttempts := 5
successGenCode := false
for attempt := 1; attempt <= maxAttempts; attempt++ {
var err error
code, err = h.genShortCode()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
response := ErrResponse{
Detail: "failed to generate short code",
}
encoder.Encode(response)
h.logger.Error("failed to generate short code", slog.Any("error", err))
return
}
_, err = h.db.ExecContext(ctx, "INSERT INTO shorturltable (short_url, full_url) VALUES ($1, $2)", code, fullURL)
if err == nil {
successGenCode = true
break
}
if errors.Is(err, context.DeadlineExceeded) {
w.WriteHeader(http.StatusGatewayTimeout)
response := ErrResponse{
Detail: "request timed out",
}
encoder.Encode(response)
h.logger.ErrorContext(ctx, "request timed out", slog.Any("error", err))
return
}
if errors.Is(err, context.Canceled) {
h.logger.InfoContext(ctx, "canceled", slog.Any("error", err))
return
}
//check collision
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
h.logger.WarnContext(ctx, "collision detected, retrying",
slog.Group("info",
slog.String("code", code),
slog.Int("attempt", attempt),
slog.String("err_code", pgErr.Code)),
)
continue
}
w.WriteHeader(http.StatusInternalServerError)
response := ErrResponse{
Detail: "database error",
}
encoder.Encode(response)
h.logger.ErrorContext(ctx, "database error", slog.Any("error", err))
return
}
if !successGenCode {
h.logger.ErrorContext(ctx, "short code generation overflow", slog.Int("max_attempts", maxAttempts))
w.WriteHeader(http.StatusInternalServerError)
response := ErrResponse{
Detail: "generation overload",
}
encoder.Encode(response)
return
}
w.WriteHeader(http.StatusCreated)
response := UrlResponse{
Url: fullURL,
Short: "http://localhost:8080/"+code,
}
encoder.Encode(response)
h.logger.Info("created new link", slog.String("link", "http://localhost:8080/"+code))
}
func (h *HandlerURL) GET(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
shortURL := r.PathValue("code")
if shortURL == "" {
w.WriteHeader(http.StatusBadRequest)
response := ErrResponse{
Detail: "no code in url",
}
encoder.Encode(response)
h.logger.Error("bad request, no code in url")
return
}
ctx := r.Context()
var fullURL string
err := h.db.QueryRowContext(ctx, "SELECT full_url FROM shorturltable WHERE short_url = $1", shortURL).Scan(&fullURL)
if err != nil {
response := ErrResponse{}
if errors.Is(err, sql.ErrNoRows) {
w.WriteHeader(http.StatusNotFound)
response.Detail = "not found"
h.logger.ErrorContext(ctx, "not found", slog.Any("error", err))
} else if errors.Is(err, context.Canceled) {
h.logger.InfoContext(ctx, "canceled", slog.Any("error", err))
return
} else if errors.Is(err, context.DeadlineExceeded) {
w.WriteHeader(http.StatusGatewayTimeout)
response.Detail = "request timed out"
h.logger.ErrorContext(ctx, "request timed out", slog.Any("error", err))
} else {
w.WriteHeader(http.StatusInternalServerError)
response.Detail = "database error"
h.logger.ErrorContext(ctx, "database error", slog.Any("error", err))
}
encoder.Encode(response)
return
}
h.logger.Debug("redirect", slog.Group("info",
slog.String("from", "http://localhost:8080/"+shortURL),
slog.String("to", fullURL)))
http.Redirect(w, r, fullURL, http.StatusFound)
}
func main() {
//create logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
//load .env
_ = godotenv.Load()
getRequiredEnv := func(key string) string {
env := os.Getenv(key)
if env == "" {
logger.Error("failed to start application", slog.String("missing_env", key))
os.Exit(1)
}
return env
}
dbHost := getRequiredEnv("DB_HOST")
dbPort := getRequiredEnv("DB_PORT")
dbUser := getRequiredEnv("DB_USER")
dbUserPass := getRequiredEnv("DB_USER_PASS")
dbName := getRequiredEnv("DB_NAME")
//connect PostgreSQL database
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, dbUser, dbUserPass, dbName)
db, err := sql.Open("pgx", dsn)
if err != nil {
logger.Error("failed to connect to database", slog.Any("error", err))
os.Exit(1)
}
db.SetMaxIdleConns(25)
db.SetMaxOpenConns(25)
//connection check
if err := db.Ping(); err != nil {
logger.Error("no connection to the database", slog.Any("error", err))
}
h := &HandlerURL{
db,
logger,
}
mux := http.NewServeMux()
mux.HandleFunc("POST /shorten", h.POST)
mux.HandleFunc("GET /{code}", h.GET)
logger.Info("server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
logger.Error("server launch error", slog.Any("error", err))
os.Exit(1)
}
}