This commit is contained in:
yyasha 2023-12-08 13:59:55 +03:00
commit 9a69fdaac5
24 changed files with 1374 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.db

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
run:
CONFIG_PATH=./config/local.yaml go run ./cmd/url-shortener/main.go
test:
go test ./internal/...
generate:
go generate ./...
functional_tests:
go test ./tests/url_shortener_test.go

104
cmd/url-shortener/main.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"log/slog"
"net/http"
"os"
"url-shortener/internal/config"
"url-shortener/internal/http-server/handlers/url/redirect"
"url-shortener/internal/http-server/handlers/url/save"
mwLogger "url-shortener/internal/http-server/middleware/logger"
"url-shortener/internal/lib/logger/handlers/slogpretty"
"url-shortener/internal/lib/logger/sl"
"url-shortener/internal/storage/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
const (
envLocal = "local"
envDev = "dev"
envProd = "prod"
)
func main() {
// init config
conf := config.MustLoad()
// init logger
log := setupLogger(conf.Env)
log.Info("starting url-shortener", slog.String("env", conf.Env))
log.Debug("debug messages are enabled")
// init storage
storage, err := sqlite.New(conf.StoragePath)
if err != nil {
log.Error("failed to init storage", sl.Err(err))
os.Exit(1)
}
// init router
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(mwLogger.New(log))
router.Use(middleware.Recoverer)
router.Use(middleware.URLFormat)
router.Route("/url", func(r chi.Router) {
r.Use(middleware.BasicAuth("url-shortener", map[string]string{
conf.Admin.User: conf.Admin.Password,
}))
r.Post("/", save.New(log, storage))
})
router.Get("/{alias}", redirect.New(log, storage))
log.Info("Starting server", slog.String("address", conf.Address))
srv := &http.Server{
Addr: conf.HTTPServer.Address,
Handler: router,
ReadTimeout: conf.HTTPServer.Timeout,
WriteTimeout: conf.HTTPServer.Timeout,
IdleTimeout: conf.HTTPServer.IdleTimeout,
}
if err = srv.ListenAndServe(); err != nil {
log.Error("failed to start server", sl.Err(err))
}
log.Error("server stopped")
}
func setupLogger(env string) *slog.Logger {
var log *slog.Logger
switch env {
case envLocal:
log = setupPrettySlog()
case envDev:
log = slog.New(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
)
case envProd:
log = slog.New(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
)
}
return log
}
func setupPrettySlog() *slog.Logger {
opts := slogpretty.PrettyHandlerOptions{
SlogOpts: &slog.HandlerOptions{
Level: slog.LevelDebug,
},
}
handler := opts.NewPrettyHandler(os.Stdout)
return slog.New(handler)
}

9
config/local.yaml Normal file
View File

@ -0,0 +1,9 @@
env: "local" # local, dev, prod
storage_path: "./storage/storage.db"
http_server:
address: "localhost:8082"
timeout: 4s
idle_timeout: 60s
admin:
username: "user"
password: "pa$$"

55
go.mod Normal file
View File

@ -0,0 +1,55 @@
module url-shortener
go 1.21
toolchain go1.21.4
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/brianvoe/gofakeit/v6 v6.26.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gavv/httpexpect/v2 v2.16.0 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
github.com/go-chi/render v1.0.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect
github.com/imkira/go-interpol v1.1.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/compress v1.15.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sanity-io/litter v1.5.5 // indirect
github.com/sergi/go-diff v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.34.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
github.com/yudai/gojsondiff v1.0.0 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)

151
go.sum Normal file
View File

@ -0,0 +1,151 @@
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/brianvoe/gofakeit/v6 v6.26.0 h1:DzJHo4K6RrAbglU6cReh+XqoaunuUMZ8OAQGXrYsXt8=
github.com/brianvoe/gofakeit/v6 v6.26.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gavv/httpexpect/v2 v2.16.0 h1:Ty2favARiTYTOkCRZGX7ojXXjGyNAIohM1lZ3vqaEwI=
github.com/gavv/httpexpect/v2 v2.16.0/go.mod h1:uJLaO+hQ25ukBJtQi750PsztObHybNllN+t+MbbW8PY=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs=
moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

59
internal/config/config.go Normal file
View File

@ -0,0 +1,59 @@
package config
import (
"flag"
"log"
"os"
"time"
"github.com/ilyakaznacheev/cleanenv"
)
type Config struct {
Env string `yaml:"env" env-required:"true"`
StoragePath string `yaml:"storage_path" env-default:"./storage/storage.db"`
HTTPServer `yaml:"http_server"`
Admin `yaml:"admin"`
}
type Admin struct {
User string `yaml:"username" env-required:"true"`
Password string `yaml:"password" env:"HTTP_SERVER_PASSWORD" env-required:"true"`
}
type HTTPServer struct {
Address string `yaml:"address" env-default:"localhost:8080"`
Timeout time.Duration `yaml:"timeout" env-default:"4s"`
IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"`
}
func MustLoad() *Config {
// Get config path from env or flag
configPath := fetchConfigPath()
if configPath == "" {
log.Fatal("CONFIG_PATH is not set")
}
// check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
log.Fatalf("config file does not exist: %s", configPath)
}
var cfg Config
// read config
if err := cleanenv.ReadConfig(configPath, &cfg); err != nil {
log.Fatalf("cannot read config: %s", err)
}
return &cfg
}
func fetchConfigPath() string {
var res string = os.Getenv("CONFIG_PATH")
if res == "" {
flag.StringVar(&res, "config", "", "path to config file")
flag.Parse()
}
return res
}

View File

@ -0,0 +1,52 @@
// Code generated by mockery v2.38.0. DO NOT EDIT.
package mocks
import mock "github.com/stretchr/testify/mock"
// URLProvider is an autogenerated mock type for the URLProvider type
type URLProvider struct {
mock.Mock
}
// GetURL provides a mock function with given fields: alias
func (_m *URLProvider) GetURL(alias string) (string, error) {
ret := _m.Called(alias)
if len(ret) == 0 {
panic("no return value specified for GetURL")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
return rf(alias)
}
if rf, ok := ret.Get(0).(func(string) string); ok {
r0 = rf(alias)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(alias)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewURLProvider creates a new instance of URLProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewURLProvider(t interface {
mock.TestingT
Cleanup(func())
}) *URLProvider {
mock := &URLProvider{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,55 @@
package redirect
import (
"errors"
"log/slog"
"net/http"
resp "url-shortener/internal/lib/api/response"
"url-shortener/internal/lib/logger/sl"
"url-shortener/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
)
//go:generate go run github.com/vektra/mockery/v2@v2.38.0 --name=URLProvider
type URLProvider interface {
GetURL(alias string) (string, error)
}
func New(log *slog.Logger, urlProvider URLProvider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// setup logs
const op = "handlers.url.redirect.New"
log = log.With(
slog.String("op", op),
slog.String("request_id", middleware.GetReqID(r.Context())),
)
// get alias
alias := chi.URLParam(r, "alias")
if alias == "" {
log.Info("alias is empty")
render.JSON(w, r, resp.Error("invalid request"))
return
}
// get url from storage
url, err := urlProvider.GetURL(alias)
if err != nil {
if errors.Is(err, storage.ErrURLNotFound) {
render.JSON(w, r, resp.Error("not found"))
return
}
log.Error("failed to get url", sl.Err(err))
render.JSON(w, r, resp.Error("internal error"))
return
}
log.Info("got url", slog.String("url", url))
// redirect to url
http.Redirect(w, r, url, http.StatusFound)
}
}

View File

@ -0,0 +1,89 @@
package redirect_test
import (
"encoding/json"
"errors"
"net/http/httptest"
"testing"
"url-shortener/internal/http-server/handlers/url/redirect"
"url-shortener/internal/http-server/handlers/url/redirect/mocks"
"url-shortener/internal/lib/api"
resp "url-shortener/internal/lib/api/response"
"url-shortener/internal/lib/logger/handlers/slogdiscard"
"url-shortener/internal/storage"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRedirectHandler(t *testing.T) {
// test table
cases := []struct {
name string
alias string
url string
respError string
mockError error
}{
{
name: "Success",
alias: "test_alias",
url: "https://www.google.com/",
},
{
name: "Not Found",
alias: "test_alias",
mockError: storage.ErrURLNotFound,
respError: "not found",
},
{
name: "Database error",
alias: "test_alias",
mockError: errors.New("ERROR"),
respError: "internal error",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// create mocks
urlGetterMock := mocks.NewURLProvider(t)
if tc.respError == "" || tc.mockError != nil {
urlGetterMock.On("GetURL", tc.alias).
Return(tc.url, tc.mockError).Once()
}
// create handler
r := chi.NewRouter()
r.Get("/{alias}", redirect.New(slogdiscard.NewDiscardLogger(), urlGetterMock))
ts := httptest.NewServer(r)
defer ts.Close()
var redirectedToURL string
// Если ошибки быть не должно - проверить перенаправление
// Если ошибка быть должна - проверить ошибку
if tc.respError == "" {
// check redirection
var err error
redirectedToURL, err = api.GetRedirect(ts.URL + "/" + tc.alias)
require.NoError(t, err)
} else {
// create http recorder
rr := httptest.NewRecorder()
// создать http запрос
req := httptest.NewRequest("GET", ts.URL+"/"+tc.alias, nil)
// обработать http запрос
r.ServeHTTP(rr, req)
// получить тело ответа
body := rr.Body.String()
var resp resp.Response
require.NoError(t, json.Unmarshal([]byte(body), &resp))
require.Equal(t, tc.respError, resp.Error)
}
// Check the final URL after redirection.
assert.Equal(t, tc.url, redirectedToURL)
})
}
}

View File

@ -0,0 +1,42 @@
// Code generated by mockery v2.38.0. DO NOT EDIT.
package mocks
import mock "github.com/stretchr/testify/mock"
// URLSaver is an autogenerated mock type for the URLSaver type
type URLSaver struct {
mock.Mock
}
// SaveURL provides a mock function with given fields: urlToSave, alias
func (_m *URLSaver) SaveURL(urlToSave string, alias string) error {
ret := _m.Called(urlToSave, alias)
if len(ret) == 0 {
panic("no return value specified for SaveURL")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(urlToSave, alias)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewURLSaver creates a new instance of URLSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewURLSaver(t interface {
mock.TestingT
Cleanup(func())
}) *URLSaver {
mock := &URLSaver{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,91 @@
package save
import (
"errors"
"log/slog"
"net/http"
resp "url-shortener/internal/lib/api/response"
"url-shortener/internal/lib/logger/sl"
"url-shortener/internal/lib/random"
"url-shortener/internal/storage"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/go-playground/validator/v10"
)
type Request struct {
URL string `json:"url" validate:"required,url"`
Alias string `json:"alias,omitempty"`
}
type Response struct {
resp.Response
Alias string `json:"alias,omitempty"`
}
// TODO: move to config
const aliasLength = 6
//go:generate go run github.com/vektra/mockery/v2@v2.38.0 --name=URLSaver
type URLSaver interface {
SaveURL(urlToSave string, alias string) error
}
func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
const op = "handlers.url.save.New"
log = log.With(
slog.String("op", op),
slog.String("request_id", middleware.GetReqID(r.Context())),
)
var req Request
// decode
err := render.DecodeJSON(r.Body, &req)
if err != nil {
log.Error("failed to decode request body", sl.Err(err))
render.JSON(w, r, resp.Error("failed to decode request"))
return
}
log.Debug("request body decoded", slog.Any("request", req))
// validate fields
if err := validator.New().Struct(req); err != nil {
validateErr := err.(validator.ValidationErrors)
log.Error("invalid request", sl.Err(err))
render.JSON(w, r, resp.ValidationError(validateErr))
return
}
// get or generate alias
// TODO:
alias := req.Alias
if alias == "" {
alias = random.NewRandomString(aliasLength)
}
// save to storage
err = urlSaver.SaveURL(req.URL, alias)
if errors.Is(err, storage.ErrURLExists) {
log.Info("alias already exists", slog.String("url", req.URL))
render.JSON(w, r, resp.Error("alias already exists"))
return
}
if err != nil {
log.Error("failed to add url", sl.Err(err))
render.JSON(w, r, resp.Error("failed to add url"))
return
}
log.Info("url added", slog.String("alias", alias))
render.JSON(w, r, Response{
Response: resp.OK(),
Alias: alias,
})
}
}

View File

@ -0,0 +1,102 @@
package save_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"url-shortener/internal/http-server/handlers/url/save"
"url-shortener/internal/http-server/handlers/url/save/mocks"
"url-shortener/internal/lib/logger/handlers/slogdiscard"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestSaveHandler(t *testing.T) {
cases := []struct {
name string
alias string
url string
respError string
mockError error
storageTimes int
}{
{
name: "Success",
alias: "test_alias",
url: "https://google.com",
storageTimes: 1,
},
{
name: "Empty alias",
alias: "",
url: "https://google.com",
storageTimes: 1,
},
{
name: "Empty URL",
alias: "test_alias",
url: "",
respError: "field URL is a required field",
storageTimes: 0,
},
{
name: "Invalid URL",
alias: "test_alias",
url: "invalid URL",
respError: "field URL is not a valid url",
storageTimes: 0,
},
{
name: "SaveURL Error",
alias: "test_alias",
url: "https://google.com",
respError: "failed to add url",
storageTimes: 1,
mockError: errors.New("unexpected error"),
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// создать мок urlSaver
urlSaverMock := mocks.NewURLSaver(t)
// описание поведения мока при вызове, если не ожидается respError или ожидается mockError (если вызовов не ноль)
// if tc.respError == "" || tc.mockError != nil {
if tc.storageTimes > 0 {
urlSaverMock.On("SaveURL", tc.url, mock.AnythingOfType("string")).Return(tc.mockError).Times(tc.storageTimes)
}
// }
// создать тестируемый handler, передаём discard logger и мок
handler := save.New(slogdiscard.NewDiscardLogger(), urlSaverMock)
// создаём json для теста
input := fmt.Sprintf(`{"url": "%s", "alias": "%s"}`, tc.url, tc.alias)
// создание http запроса к /save
req, err := http.NewRequest(http.MethodPost, "/save", bytes.NewReader([]byte(input)))
require.NoError(t, err)
// recorder для ответов
rr := httptest.NewRecorder()
// обработать http запрос
handler.ServeHTTP(rr, req)
// сравнить ожидание и реальность
require.Equal(t, rr.Code, http.StatusOK)
// получить тело запроса
body := rr.Body.String()
var resp save.Response
// Декодировать ответ в структуру
require.NoError(t, json.Unmarshal([]byte(body), &resp))
// проверить ожидание и реальность
require.Equal(t, tc.respError, resp.Error)
// TODO: add more checks
})
}
}

View File

@ -0,0 +1,42 @@
package logger
import (
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
)
func New(log *slog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
log = log.With(
slog.String("component", "midddleware/logger"),
)
log.Info("logger middleware enabled")
fn := func(w http.ResponseWriter, r *http.Request) {
entry := log.With(
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
slog.String("user_agent", r.UserAgent()),
slog.String("request_id", middleware.GetReqID(r.Context())),
)
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
entry.Info("request completed",
slog.Int("status", ww.Status()),
slog.Int("bytes", ww.BytesWritten()),
slog.String("duration", time.Since(t1).String()),
)
}()
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
}

34
internal/lib/api/api.go Normal file
View File

@ -0,0 +1,34 @@
package api
import (
"errors"
"fmt"
"net/http"
)
var (
ErrInvalidStatusCode = errors.New("invalid status code")
)
// GetRedirect returns the final URL after redirection.
func GetRedirect(url string) (string, error) {
const op = "api.GetRedirect"
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // stop after 1st redirect
},
}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
return "", fmt.Errorf("%s: %w: %d", op, ErrInvalidStatusCode, resp.StatusCode)
}
return resp.Header.Get("Location"), nil
}

View File

@ -0,0 +1,51 @@
package response
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
type Response struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
const (
StatusOK = "OK"
StatusError = "Error"
)
func OK() Response {
return Response{
Status: StatusOK,
}
}
func Error(msg string) Response {
return Response{
Status: StatusError,
Error: msg,
}
}
func ValidationError(errs validator.ValidationErrors) Response {
var errMsgs []string
for _, err := range errs {
switch err.ActualTag() {
case "required":
errMsgs = append(errMsgs, fmt.Sprintf("field %s is a required field", err.Field()))
case "url":
errMsgs = append(errMsgs, fmt.Sprintf("field %s is not a valid url", err.Field()))
default:
errMsgs = append(errMsgs, fmt.Sprintf("field %s is not valid", err.Field()))
}
}
return Response{
Status: StatusError,
Error: strings.Join(errMsgs, ", "),
}
}

View File

@ -0,0 +1,40 @@
package slogdiscard
/*
Logger for tests with ignoring input
*/
import (
"context"
"log/slog"
)
func NewDiscardLogger() *slog.Logger {
return slog.New(NewDiscardHandler())
}
type DiscardHandler struct{}
func NewDiscardHandler() *DiscardHandler {
return &DiscardHandler{}
}
func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error {
// Просто игнорируем запись журнала
return nil
}
func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler {
// Возвращает тот же обработчик, так как нет атрибутов для сохранения
return h
}
func (h *DiscardHandler) WithGroup(_ string) slog.Handler {
// Возвращает тот же обработчик, так как нет группы для сохранения
return h
}
func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool {
// Всегда возвращает false, так как запись журнала игнорируется
return false
}

View File

@ -0,0 +1,99 @@
package slogpretty
import (
"context"
"encoding/json"
"io"
stdLog "log"
"log/slog"
"github.com/fatih/color"
)
type PrettyHandlerOptions struct {
SlogOpts *slog.HandlerOptions
}
type PrettyHandler struct {
opts PrettyHandlerOptions
slog.Handler
l *stdLog.Logger
attrs []slog.Attr
}
func (opts PrettyHandlerOptions) NewPrettyHandler(
out io.Writer,
) *PrettyHandler {
h := &PrettyHandler{
Handler: slog.NewJSONHandler(out, opts.SlogOpts),
l: stdLog.New(out, "", 0),
}
return h
}
func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
level := r.Level.String() + ":"
switch r.Level {
case slog.LevelDebug:
level = color.MagentaString(level)
case slog.LevelInfo:
level = color.BlueString(level)
case slog.LevelWarn:
level = color.YellowString(level)
case slog.LevelError:
level = color.RedString(level)
}
fields := make(map[string]interface{}, r.NumAttrs())
r.Attrs(func(a slog.Attr) bool {
fields[a.Key] = a.Value.Any()
return true
})
for _, a := range h.attrs {
fields[a.Key] = a.Value.Any()
}
var b []byte
var err error
if len(fields) > 0 {
b, err = json.MarshalIndent(fields, "", " ")
if err != nil {
return err
}
}
timeStr := r.Time.Format("[15:05:05.000]")
msg := color.CyanString(r.Message)
h.l.Println(
timeStr,
level,
msg,
color.WhiteString(string(b)),
)
return nil
}
func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &PrettyHandler{
Handler: h.Handler,
l: h.l,
attrs: attrs,
}
}
func (h *PrettyHandler) WithGroup(name string) slog.Handler {
// TODO: implement
return &PrettyHandler{
Handler: h.Handler.WithGroup(name),
l: h.l,
}
}

View File

@ -0,0 +1,10 @@
package sl
import "log/slog"
func Err(err error) slog.Attr {
return slog.Attr{
Key: "error",
Value: slog.StringValue(err.Error()),
}
}

View File

@ -0,0 +1,20 @@
package random
import (
"math/rand"
"time"
)
// NewRandomString geneartes random string with given size
func NewRandomString(length int) string {
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
b := make([]rune, length)
for i := range b {
b[i] = chars[rnd.Intn(len(chars))]
}
return string(b)
}

View File

@ -0,0 +1,47 @@
package random_test
import (
"testing"
"url-shortener/internal/lib/random"
"github.com/stretchr/testify/assert"
)
func TestNewRandomString(t *testing.T) {
tests := []struct {
name string
size int
}{
{
name: "size = 1",
size: 1,
},
{
name: "size = 6",
size: 6,
},
{
name: "size = 10",
size: 10,
},
{
name: "size = 20",
size: 20,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str1 := random.NewRandomString(tt.size)
str2 := random.NewRandomString(tt.size)
assert.Len(t, str1, tt.size)
assert.Len(t, str2, tt.size)
// Check that two generated strings are different
// This is not an absolute guarantee that the function works correctly,
// but this is a good heuristic for a simple random generator.
assert.NotEqual(t, str1, str2)
})
}
}

View File

@ -0,0 +1,80 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"url-shortener/internal/storage"
"github.com/mattn/go-sqlite3"
)
type Storage struct {
db *sql.DB
}
func New(storagePath string) (*Storage, error) {
const op = "storage.sqlite.New"
db, err := sql.Open("sqlite3", storagePath)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
// TODO: migrations
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS url(
id INTEGER PRIMARY KEY,
alias TEXT NOT NULL UNIQUE,
url TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_alias ON url(alias);`)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return &Storage{db: db}, nil
}
func (s *Storage) SaveURL(urlToSave string, alias string) error {
const op = "storage.sqlite.SaveURL"
stmt, err := s.db.Prepare("INSERT INTO url (url, alias) values (?, ?)")
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
_, err = stmt.Exec(urlToSave, alias)
if err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return storage.ErrURLExists
}
return fmt.Errorf("%s: %w", op, err)
}
return nil
}
func (s *Storage) GetURL(alias string) (string, error) {
const op = "storage.sqlite.GetURL"
stmt, err := s.db.Prepare("SELECT url from url where alias = ?")
if err != nil {
return "", fmt.Errorf("%s: %w", op, err)
}
var resURL string
err = stmt.QueryRow(alias).Scan(&resURL)
if errors.Is(err, sql.ErrNoRows) {
return "", storage.ErrURLNotFound
}
if err != nil {
return "", fmt.Errorf("%s: %w", op, err)
}
return resURL, nil
}
// TODO: Implement method
// func (s *Storage) DeleteURL(alias string) error

View File

@ -0,0 +1,8 @@
package storage
import "errors"
var (
ErrURLNotFound = errors.New("url not found")
ErrURLExists = errors.New("url exists")
)

125
tests/url_shortener_test.go Normal file
View File

@ -0,0 +1,125 @@
package tests
import (
"net/http"
"net/url"
"testing"
"github.com/brianvoe/gofakeit/v6"
"github.com/gavv/httpexpect/v2"
"github.com/stretchr/testify/require"
"url-shortener/internal/http-server/handlers/url/save"
"url-shortener/internal/lib/api"
"url-shortener/internal/lib/random"
)
const (
host = "localhost:8082"
user = "user"
password = "pa$$"
)
func TestURLShortener_HappyPath(t *testing.T) {
u := url.URL{
Scheme: "http",
Host: host,
}
e := httpexpect.Default(t, u.String())
e.POST("/url").
WithJSON(save.Request{
URL: gofakeit.URL(),
Alias: random.NewRandomString(10),
}).
WithBasicAuth(user, password).
Expect().
Status(200).
JSON().Object().
ContainsKey("alias")
}
//nolint:funlen
func TestURLShortener_SaveRedirect(t *testing.T) {
testCases := []struct {
name string
url string
alias string
error string
}{
{
name: "Valid URL",
url: gofakeit.URL(),
alias: gofakeit.Word() + gofakeit.Word(),
},
{
name: "Invalid URL",
url: "invalid_url",
alias: gofakeit.Word(),
error: "field URL is not a valid url",
},
{
name: "Empty Alias",
url: gofakeit.URL(),
alias: "",
},
// TODO: add more test cases
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
u := url.URL{
Scheme: "http",
Host: host,
}
e := httpexpect.Default(t, u.String())
// Save
resp := e.POST("/url").
WithJSON(save.Request{
URL: tc.url,
Alias: tc.alias,
}).
WithBasicAuth(user, password).
Expect().Status(http.StatusOK).
JSON().Object()
if tc.error != "" {
resp.NotContainsKey("alias")
resp.Value("error").String().IsEqual(tc.error)
return
}
alias := tc.alias
if tc.alias != "" {
resp.Value("alias").String().IsEqual(tc.alias)
} else {
resp.Value("alias").String().NotEmpty()
alias = resp.Value("alias").String().Raw()
}
// Redirect
testRedirect(t, alias, tc.url)
})
}
}
func testRedirect(t *testing.T, alias string, urlToRedirect string) {
u := url.URL{
Scheme: "http",
Host: host,
Path: alias,
}
redirectedToURL, err := api.GetRedirect(u.String())
require.NoError(t, err)
require.Equal(t, urlToRedirect, redirectedToURL)
}