Загрузка данных


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)
	}
}