init
This commit is contained in:
commit
9a69fdaac5
|
@ -0,0 +1 @@
|
|||
*.db
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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$$"
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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, ", "),
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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()),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
package storage
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrURLNotFound = errors.New("url not found")
|
||||
ErrURLExists = errors.New("url exists")
|
||||
)
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue