From 9a69fdaac5c327aef1fab0c7458578f7682cb5d3 Mon Sep 17 00:00:00 2001 From: yyasha Date: Fri, 8 Dec 2023 13:59:55 +0300 Subject: [PATCH] init --- .gitignore | 1 + Makefile | 8 + cmd/url-shortener/main.go | 104 ++++++++++++ config/local.yaml | 9 ++ go.mod | 55 +++++++ go.sum | 151 ++++++++++++++++++ internal/config/config.go | 59 +++++++ .../url/redirect/mocks/URLProvider.go | 52 ++++++ .../handlers/url/redirect/redirect.go | 55 +++++++ .../handlers/url/redirect/redirect_test.go | 89 +++++++++++ .../handlers/url/save/mocks/URLSaver.go | 42 +++++ .../http-server/handlers/url/save/save.go | 91 +++++++++++ .../handlers/url/save/save_test.go | 102 ++++++++++++ .../http-server/middleware/logger/logger.go | 42 +++++ internal/lib/api/api.go | 34 ++++ internal/lib/api/response/response.go | 51 ++++++ .../handlers/slogdiscard/slogdiscard.go | 40 +++++ .../logger/handlers/slogpretty/slogpretty.go | 99 ++++++++++++ internal/lib/logger/sl/sl.go | 10 ++ internal/lib/random/random.go | 20 +++ internal/lib/random/random_test.go | 47 ++++++ internal/storage/sqlite/sqlite.go | 80 ++++++++++ internal/storage/storage.go | 8 + tests/url_shortener_test.go | 125 +++++++++++++++ 24 files changed, 1374 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/url-shortener/main.go create mode 100644 config/local.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/http-server/handlers/url/redirect/mocks/URLProvider.go create mode 100644 internal/http-server/handlers/url/redirect/redirect.go create mode 100644 internal/http-server/handlers/url/redirect/redirect_test.go create mode 100644 internal/http-server/handlers/url/save/mocks/URLSaver.go create mode 100644 internal/http-server/handlers/url/save/save.go create mode 100644 internal/http-server/handlers/url/save/save_test.go create mode 100644 internal/http-server/middleware/logger/logger.go create mode 100644 internal/lib/api/api.go create mode 100644 internal/lib/api/response/response.go create mode 100644 internal/lib/logger/handlers/slogdiscard/slogdiscard.go create mode 100644 internal/lib/logger/handlers/slogpretty/slogpretty.go create mode 100644 internal/lib/logger/sl/sl.go create mode 100644 internal/lib/random/random.go create mode 100644 internal/lib/random/random_test.go create mode 100644 internal/storage/sqlite/sqlite.go create mode 100644 internal/storage/storage.go create mode 100644 tests/url_shortener_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4823ef2 --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/url-shortener/main.go b/cmd/url-shortener/main.go new file mode 100644 index 0000000..fcb7a7b --- /dev/null +++ b/cmd/url-shortener/main.go @@ -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) +} diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..6a571a8 --- /dev/null +++ b/config/local.yaml @@ -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$$" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f0739cb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a7c642e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..297d6cf --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/http-server/handlers/url/redirect/mocks/URLProvider.go b/internal/http-server/handlers/url/redirect/mocks/URLProvider.go new file mode 100644 index 0000000..e3a29db --- /dev/null +++ b/internal/http-server/handlers/url/redirect/mocks/URLProvider.go @@ -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 +} diff --git a/internal/http-server/handlers/url/redirect/redirect.go b/internal/http-server/handlers/url/redirect/redirect.go new file mode 100644 index 0000000..3227b15 --- /dev/null +++ b/internal/http-server/handlers/url/redirect/redirect.go @@ -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) + } +} diff --git a/internal/http-server/handlers/url/redirect/redirect_test.go b/internal/http-server/handlers/url/redirect/redirect_test.go new file mode 100644 index 0000000..f75c26a --- /dev/null +++ b/internal/http-server/handlers/url/redirect/redirect_test.go @@ -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) + }) + } +} diff --git a/internal/http-server/handlers/url/save/mocks/URLSaver.go b/internal/http-server/handlers/url/save/mocks/URLSaver.go new file mode 100644 index 0000000..df7ad95 --- /dev/null +++ b/internal/http-server/handlers/url/save/mocks/URLSaver.go @@ -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 +} diff --git a/internal/http-server/handlers/url/save/save.go b/internal/http-server/handlers/url/save/save.go new file mode 100644 index 0000000..fa0e573 --- /dev/null +++ b/internal/http-server/handlers/url/save/save.go @@ -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, + }) + } +} diff --git a/internal/http-server/handlers/url/save/save_test.go b/internal/http-server/handlers/url/save/save_test.go new file mode 100644 index 0000000..0701e35 --- /dev/null +++ b/internal/http-server/handlers/url/save/save_test.go @@ -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 + }) + } +} diff --git a/internal/http-server/middleware/logger/logger.go b/internal/http-server/middleware/logger/logger.go new file mode 100644 index 0000000..c39b714 --- /dev/null +++ b/internal/http-server/middleware/logger/logger.go @@ -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) + } +} diff --git a/internal/lib/api/api.go b/internal/lib/api/api.go new file mode 100644 index 0000000..6b9682d --- /dev/null +++ b/internal/lib/api/api.go @@ -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 +} diff --git a/internal/lib/api/response/response.go b/internal/lib/api/response/response.go new file mode 100644 index 0000000..fe4696c --- /dev/null +++ b/internal/lib/api/response/response.go @@ -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, ", "), + } +} diff --git a/internal/lib/logger/handlers/slogdiscard/slogdiscard.go b/internal/lib/logger/handlers/slogdiscard/slogdiscard.go new file mode 100644 index 0000000..4f19f4e --- /dev/null +++ b/internal/lib/logger/handlers/slogdiscard/slogdiscard.go @@ -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 +} diff --git a/internal/lib/logger/handlers/slogpretty/slogpretty.go b/internal/lib/logger/handlers/slogpretty/slogpretty.go new file mode 100644 index 0000000..000556b --- /dev/null +++ b/internal/lib/logger/handlers/slogpretty/slogpretty.go @@ -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, + } +} diff --git a/internal/lib/logger/sl/sl.go b/internal/lib/logger/sl/sl.go new file mode 100644 index 0000000..ac19308 --- /dev/null +++ b/internal/lib/logger/sl/sl.go @@ -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()), + } +} diff --git a/internal/lib/random/random.go b/internal/lib/random/random.go new file mode 100644 index 0000000..a3b3497 --- /dev/null +++ b/internal/lib/random/random.go @@ -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) +} diff --git a/internal/lib/random/random_test.go b/internal/lib/random/random_test.go new file mode 100644 index 0000000..ca2b055 --- /dev/null +++ b/internal/lib/random/random_test.go @@ -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) + }) + } +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go new file mode 100644 index 0000000..8202a2d --- /dev/null +++ b/internal/storage/sqlite/sqlite.go @@ -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 diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..8ce10d9 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,8 @@ +package storage + +import "errors" + +var ( + ErrURLNotFound = errors.New("url not found") + ErrURLExists = errors.New("url exists") +) diff --git a/tests/url_shortener_test.go b/tests/url_shortener_test.go new file mode 100644 index 0000000..628e66c --- /dev/null +++ b/tests/url_shortener_test.go @@ -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) +}