diff --git a/.gitignore b/.gitignore index 3362f51..2d66831 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ vendor/ # IDEs directories .idea .vscode +cmd/.DS_Store +.DS_Store +/tmp diff --git a/cmd/shortener/main.go b/cmd/shortener/main.go index 38dd16d..c3da68d 100644 --- a/cmd/shortener/main.go +++ b/cmd/shortener/main.go @@ -1,3 +1,38 @@ package main -func main() {} +import ( + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/shilin-anton/urlreducer/internal/app/config" + filemanager "github.com/shilin-anton/urlreducer/internal/app/file-manager" + handler "github.com/shilin-anton/urlreducer/internal/app/handlers" + "github.com/shilin-anton/urlreducer/internal/app/storage" + "github.com/shilin-anton/urlreducer/internal/logger" + "log" + "net/http" +) + +func main() { + config.ParseConfig() + err := logger.Initialize(config.LogLevel) + if err != nil { + log.Fatal("Error initializing logger", err.Error()) + } + + var fl *filemanager.ExportedManager + if config.FilePath != "" { + fl = filemanager.New() + } + + var db *storage.DataBase + if config.DBDSN != "" { + db, err = storage.NewDB(config.DBDSN) + if err != nil { + log.Fatal("Error initializing DB: ", err.Error()) + } + defer db.Close() + } + + myStorage := storage.New(fl, db) + myHandler := handler.New(myStorage) + log.Fatal(http.ListenAndServe(config.RunAddr, myHandler)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dce3620 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/shilin-anton/urlreducer + +go 1.21.3 + +require ( + github.com/go-chi/chi/v5 v5.0.11 + github.com/golang/mock v1.6.0 + github.com/jackc/pgx/v5 v5.5.5 + github.com/stretchr/testify v1.8.1 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ee5d90a --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +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/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +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/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.4.2/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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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= diff --git a/internal/app/config/config.go b/internal/app/config/config.go new file mode 100644 index 0000000..1e935b0 --- /dev/null +++ b/internal/app/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "flag" + "os" +) + +var RunAddr string +var BaseAddr string +var LogLevel string +var FilePath string +var DBDSN string + +const ( + defaultRunURL = "localhost:8080" + defaultBaseURL = "http://localhost:8080" + defaultLogLevel = "info" + defaultFilePath = "/tmp/short-url-db.json" + //defaultDSN = "host=127.0.0.1 user=postgres dbname=shortener sslmode=disable" + defaultDSN = "" +) + +func ParseConfig() { + flag.StringVar(&RunAddr, "a", defaultRunURL, "address and port to run server") + flag.StringVar(&BaseAddr, "b", defaultBaseURL, "base URL before short link") + flag.StringVar(&LogLevel, "l", defaultLogLevel, "log level") + flag.StringVar(&FilePath, "f", defaultFilePath, "file storage path") + flag.StringVar(&DBDSN, "d", defaultDSN, "database DSN") + + flag.Parse() + + if envRunAddr := os.Getenv("SERVER_ADDRESS"); envRunAddr != "" { + RunAddr = envRunAddr + } + if envBaseAddr := os.Getenv("BASE_URL"); envBaseAddr != "" { + BaseAddr = envBaseAddr + } + if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" { + LogLevel = envLogLevel + } + if envFilePath := os.Getenv("FILE_STORAGE_PATH"); envFilePath != "" { + FilePath = envFilePath + } + if envDSN := os.Getenv("DATABASE_DSN"); envDSN != "" { + DBDSN = envDSN + } +} diff --git a/internal/app/file-manager/file-manager.go b/internal/app/file-manager/file-manager.go new file mode 100644 index 0000000..e1712d4 --- /dev/null +++ b/internal/app/file-manager/file-manager.go @@ -0,0 +1,111 @@ +package filemanager + +import ( + "bufio" + "encoding/json" + "github.com/shilin-anton/urlreducer/internal/app/config" + "log" + "os" +) + +type record struct { + UUID string `json:"uuid"` + ShortURL string `json:"short_url"` + OriginalURL string `json:"original_url"` +} + +type ExportedManager struct { +} + +type FileWriter struct { + file *os.File + scanner *bufio.Scanner + writer *bufio.Writer +} + +type FileReader struct { + file *os.File + scanner *bufio.Scanner +} + +func NewWriter() (*FileWriter, error) { + file, err := os.OpenFile(config.FilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + return nil, err + } + + return &FileWriter{ + file: file, + scanner: bufio.NewScanner(file), + writer: bufio.NewWriter(file), + }, nil +} + +func (fw *FileWriter) Close() error { + return fw.file.Close() +} + +func NewReader() (*FileReader, error) { + file, err := os.OpenFile(config.FilePath, os.O_RDONLY|os.O_CREATE, 0666) + if err != nil { + return nil, err + } + + return &FileReader{ + file: file, + scanner: bufio.NewScanner(file), + }, nil +} + +func (fr *FileReader) Close() { + fr.file.Close() +} + +func New() *ExportedManager { + return &ExportedManager{} +} + +func (em *ExportedManager) ReadFromFile(data map[string]string) { + reader, err := NewReader() + if err != nil { + log.Fatal("Error opening file:", err) + } + defer reader.Close() + + for reader.scanner.Scan() { + line := reader.scanner.Bytes() + + rec := &record{} + if err := json.Unmarshal(line, &rec); err != nil { + log.Fatal("Error decoding data from file:", err) + } + data[rec.ShortURL] = rec.OriginalURL + } + + if err := reader.scanner.Err(); err != nil { + log.Fatal("Error scanning from file:", err) + } +} + +func (em *ExportedManager) AddRecord(short string, url string, uuid string) error { + writer, err := NewWriter() + if err != nil { + return err + } + defer writer.Close() + + newRecord := record{ + UUID: uuid, + ShortURL: short, + OriginalURL: url, + } + recordJSON, err := json.Marshal(newRecord) + if err != nil { + return err + } + if _, err := writer.file.WriteString(string(recordJSON) + "\n"); err != nil { + return err + } + + return nil +} diff --git a/internal/app/gzip/gzip.go b/internal/app/gzip/gzip.go new file mode 100644 index 0000000..2047f7b --- /dev/null +++ b/internal/app/gzip/gzip.go @@ -0,0 +1,71 @@ +package gzip + +import ( + "compress/gzip" + "io" + "net/http" +) + +// compressWriter реализует интерфейс http.ResponseWriter и позволяет прозрачно для сервера +// сжимать передаваемые данные и выставлять правильные HTTP-заголовки +type compressWriter struct { + w http.ResponseWriter + zw *gzip.Writer +} + +func NewCompressWriter(w http.ResponseWriter) *compressWriter { + return &compressWriter{ + w: w, + zw: gzip.NewWriter(w), + } +} + +func (c *compressWriter) Header() http.Header { + return c.w.Header() +} + +func (c *compressWriter) Write(p []byte) (int, error) { + return c.zw.Write(p) +} + +func (c *compressWriter) WriteHeader(statusCode int) { + if statusCode < 300 { + c.w.Header().Set("Content-Encoding", "gzip") + } + c.w.WriteHeader(statusCode) +} + +// Close закрывает gzip.Writer и досылает все данные из буфера. +func (c *compressWriter) Close() error { + return c.zw.Close() +} + +// compressReader реализует интерфейс io.ReadCloser и позволяет прозрачно для сервера +// декомпрессировать получаемые от клиента данные +type compressReader struct { + r io.ReadCloser + zr *gzip.Reader +} + +func NewCompressReader(r io.ReadCloser) (*compressReader, error) { + zr, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + + return &compressReader{ + r: r, + zr: zr, + }, nil +} + +func (c compressReader) Read(p []byte) (n int, err error) { + return c.zr.Read(p) +} + +func (c *compressReader) Close() error { + if err := c.r.Close(); err != nil { + return err + } + return c.zr.Close() +} diff --git a/internal/app/handlers/handler.go b/internal/app/handlers/handler.go new file mode 100644 index 0000000..af7584e --- /dev/null +++ b/internal/app/handlers/handler.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "encoding/json" + "github.com/go-chi/chi/v5" + "github.com/shilin-anton/urlreducer/internal/app/config" + "github.com/shilin-anton/urlreducer/internal/app/gzip" + "github.com/shilin-anton/urlreducer/internal/logger" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +type Storage interface { + Add(short string, url string) error + Get(short string) (string, error) + FindByValue(url string) (string, bool) + PingDB() error +} + +type Server struct { + data Storage + handler http.Handler +} + +// types for logger +type responseData struct { + status int + size int +} + +type shortenRequest struct { + URL string `json:"url"` +} + +type shortenResponse struct { + Result string `json:"result"` +} + +type loggingResponseWriter struct { + http.ResponseWriter + responseData *responseData +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.responseData.status = code + lrw.ResponseWriter.WriteHeader(code) +} + +func (lrw *loggingResponseWriter) Write(data []byte) (int, error) { + size, err := lrw.ResponseWriter.Write(data) + lrw.responseData.size += size + return size, err +} + +func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler.ServeHTTP(w, r) +} + +func New(storage Storage) *Server { + r := chi.NewRouter() + + r.Use(requestLoggerMiddleware) + r.Use(responseLoggerMiddleware) + + s := &Server{ + data: storage, + handler: r, + } + r.Get("/{short}", gzipMiddleware(s.GetHandler)) + r.Post("/", gzipMiddleware(s.PostHandler)) + r.Post("/api/shorten", gzipMiddleware(s.PostShortenHandler)) + r.Get("/ping", s.GetPingHandler) + + return s +} + +func shortenURL(url string) string { + // Решил использовать хэширование и первые символы результата, как короткую форму URL + hash := md5.Sum([]byte(url)) + hashString := hex.EncodeToString(hash[:]) + shortURL := hashString[:8] + return shortURL +} + +func (s Server) PostHandler(res http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(res, "Error reading request body", http.StatusInternalServerError) + return + } + defer req.Body.Close() + + url := string(body) + var short string + if existShort, contains := s.data.FindByValue(url); !contains { + short = shortenURL(url) + if err := s.data.Add(short, url); err != nil { + http.Error(res, "Error store data to file", http.StatusInternalServerError) + return + } + } else { + short = existShort + } + + res.Header().Set("Content-Type", "text/plain") + res.WriteHeader(http.StatusCreated) + res.Write([]byte(config.BaseAddr + "/" + short)) +} + +func (s Server) GetHandler(res http.ResponseWriter, req *http.Request) { + short := chi.URLParam(req, "short") + + url, err := s.data.Get(short) + if err != nil || url == "" { + http.NotFound(res, req) + return + } + res.Header().Set("Location", url) + res.WriteHeader(http.StatusTemporaryRedirect) +} + +func (s Server) GetPingHandler(res http.ResponseWriter, req *http.Request) { + if err := s.data.PingDB(); err != nil { + http.Error(res, "no DB connection", http.StatusInternalServerError) + return + } + + res.WriteHeader(http.StatusOK) +} + +func (s Server) PostShortenHandler(res http.ResponseWriter, req *http.Request) { + var request shortenRequest + var buf bytes.Buffer + _, err := buf.ReadFrom(req.Body) + if err != nil { + http.Error(res, err.Error(), http.StatusInternalServerError) + return + } + if err = json.Unmarshal(buf.Bytes(), &request); err != nil { + http.Error(res, err.Error(), http.StatusBadRequest) + return + } + if request.URL == "" { + http.Error(res, "url must be passed", http.StatusUnprocessableEntity) + return + } + + var short string + if existShort, contains := s.data.FindByValue(request.URL); !contains { + short = shortenURL(request.URL) + if err := s.data.Add(short, request.URL); err != nil { + http.Error(res, "Error store data to file", http.StatusInternalServerError) + return + } + } else { + short = existShort + } + + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusCreated) + response := shortenResponse{ + Result: config.BaseAddr + "/" + short, + } + + enc := json.NewEncoder(res) + if err = enc.Encode(response); err != nil { + http.Error(res, err.Error(), http.StatusInternalServerError) + return + } +} + +func requestLoggerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + logger.RequestLogger(r.RequestURI, r.Method, time.Since(start).String()) + next.ServeHTTP(w, r) + }) +} + +func responseLoggerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lrw := &loggingResponseWriter{ResponseWriter: w, responseData: &responseData{}} + next.ServeHTTP(lrw, r) + logger.ResponseLogger(strconv.Itoa(lrw.responseData.status), strconv.Itoa(lrw.responseData.size)) + }) +} + +func gzipMiddleware(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ow := w + acceptEncoding := r.Header.Get("Accept-Encoding") + supportsGzip := strings.Contains(acceptEncoding, "gzip") + if supportsGzip { + cw := gzip.NewCompressWriter(w) + ow = cw + defer cw.Close() + } + contentEncoding := r.Header.Get("Content-Encoding") + sendsGzip := strings.Contains(contentEncoding, "gzip") + if sendsGzip { + cr, err := gzip.NewCompressReader(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + r.Body = cr + defer cr.Close() + } + h.ServeHTTP(ow, r) + } +} diff --git a/internal/app/handlers/handler_test.go b/internal/app/handlers/handler_test.go new file mode 100644 index 0000000..357f988 --- /dev/null +++ b/internal/app/handlers/handler_test.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "github.com/shilin-anton/urlreducer/internal/app/config" + filemanager "github.com/shilin-anton/urlreducer/internal/app/file-manager" + "github.com/shilin-anton/urlreducer/internal/app/storage" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPostHandler(t *testing.T) { + config.FilePath = "" + fl := filemanager.New() + myStorage := storage.New(fl, nil) + myHandler := New(myStorage) + + tests := []struct { + name string + method string + url string + requestBody string + wantStatusCode int + }{ + { + name: "Valid POST request", + method: http.MethodPost, + url: "/", + requestBody: "http://example.com", + wantStatusCode: http.StatusCreated, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest(test.method, test.url, strings.NewReader(test.requestBody)) + w := httptest.NewRecorder() + + myHandler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != test.wantStatusCode { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, test.wantStatusCode) + } + }) + } +} + +func TestGetHandler(t *testing.T) { + config.FilePath = "" + fl := filemanager.New() + myStorage := storage.New(fl, nil) + myStorage.Add("test_short", "https://smth.ru") + myHandler := New(myStorage) + + tests := []struct { + name string + method string + url string + wantStatusCode int + wantLocationHeader string + }{ + { + name: "Valid GET request with existing short link", + method: http.MethodGet, + url: "/test_short", + wantStatusCode: http.StatusTemporaryRedirect, + wantLocationHeader: "https://smth.ru", + }, + { + name: "Invalid GET request with non-existing short link", + method: http.MethodGet, + url: "/non_existing_short_link", + wantStatusCode: http.StatusNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest(test.method, test.url, nil) + w := httptest.NewRecorder() + + myHandler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != test.wantStatusCode { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, test.wantStatusCode) + } + + if test.wantLocationHeader != "" { + location := resp.Header.Get("Location") + if location != test.wantLocationHeader { + t.Errorf("unexpected Location header: got %s, want %s", location, test.wantLocationHeader) + } + } + }) + } +} + +func TestServer_PostShortenHandler(t *testing.T) { + config.FilePath = "" + config.BaseAddr = "http://localhost:8080" + fl := filemanager.New() + myStorage := storage.New(fl, nil) + myHandler := New(myStorage) + + testCases := []struct { + name string + method string + body string + expectedCode int + expectedBody string + }{ + { + name: "method_post_without_body", + method: http.MethodPost, + expectedCode: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "method_post_unsupported_type", + method: http.MethodPost, + body: `{"url": ""}`, + expectedCode: http.StatusUnprocessableEntity, + expectedBody: "", + }, + { + name: "method_post_success", + method: http.MethodPost, + body: `{"url": "https://yandex.ru"}`, + expectedCode: http.StatusCreated, + expectedBody: `{"result": "http://localhost:8080/e9db20b2"}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, "/api/shorten", strings.NewReader(tc.body)) + w := httptest.NewRecorder() + + if len(tc.body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + + myHandler.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != tc.expectedCode { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, tc.expectedCode) + } + if tc.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + bodyString := string(body) + assert.JSONEq(t, tc.expectedBody, bodyString) + } + }) + } +} diff --git a/internal/app/storage/db.go b/internal/app/storage/db.go new file mode 100644 index 0000000..f0f57f5 --- /dev/null +++ b/internal/app/storage/db.go @@ -0,0 +1,64 @@ +package storage + +import ( + "context" + "database/sql" + _ "github.com/jackc/pgx/v5/stdlib" +) + +type DataBase struct { + DB *sql.DB +} + +type DataBaseInterface interface { + Ping() error + AddRecord(short string, url string) error + GetRecord(short string) (string, error) +} + +func NewDB(dataBaseDSN string) (*DataBase, error) { + db, err := sql.Open("pgx", dataBaseDSN) + if err != nil { + return nil, err + } + + ctx := context.Background() + + _, err = db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS urls ( + id SERIAL PRIMARY KEY, + url VARCHAR(255) NOT NULL, + short VARCHAR(50) NOT NULL + )`) + if err != nil { + db.Close() + return nil, err + } + + return &DataBase{DB: db}, err +} + +func (db *DataBase) AddRecord(short, url string) error { + _, err := db.DB.ExecContext(context.Background(), + "INSERT INTO urls (short, url) VALUES ($1, $2)", short, url) + return err +} + +func (db *DataBase) GetRecord(short string) (string, error) { + row := db.DB.QueryRowContext(context.Background(), + "SELECT url FROM urls where short = $1", short) + + var url string + err := row.Scan(&url) + if err != nil { + return "", err + } + return url, nil +} + +func (db *DataBase) Close() { + db.DB.Close() +} + +func (db *DataBase) Ping() error { + return db.DB.Ping() +} diff --git a/internal/app/storage/mock/db.go b/internal/app/storage/mock/db.go new file mode 100644 index 0000000..7fe45e6 --- /dev/null +++ b/internal/app/storage/mock/db.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/app/storage/db.go + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockDataBaseInterface is a mock of DataBaseInterface interface. +type MockDataBaseInterface struct { + ctrl *gomock.Controller + recorder *MockDataBaseInterfaceMockRecorder +} + +// MockDataBaseInterfaceMockRecorder is the mock recorder for MockDataBaseInterface. +type MockDataBaseInterfaceMockRecorder struct { + mock *MockDataBaseInterface +} + +// NewMockDataBaseInterface creates a new mock instance. +func NewMockDataBaseInterface(ctrl *gomock.Controller) *MockDataBaseInterface { + mock := &MockDataBaseInterface{ctrl: ctrl} + mock.recorder = &MockDataBaseInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDataBaseInterface) EXPECT() *MockDataBaseInterfaceMockRecorder { + return m.recorder +} + +// Ping mocks base method. +func (m *MockDataBaseInterface) Ping() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Ping") + ret0, _ := ret[0].(error) + return ret0 +} + +// Ping indicates an expected call of Ping. +func (mr *MockDataBaseInterfaceMockRecorder) Ping() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockDataBaseInterface)(nil).Ping)) +} diff --git a/internal/app/storage/storage.go b/internal/app/storage/storage.go new file mode 100644 index 0000000..dbe0a91 --- /dev/null +++ b/internal/app/storage/storage.go @@ -0,0 +1,75 @@ +package storage + +import ( + "github.com/shilin-anton/urlreducer/internal/app/config" + "strconv" +) + +type Storage struct { + data map[string]string + manager Manager + db DataBaseInterface +} + +type Manager interface { + AddRecord(short string, url string, uuid string) error + ReadFromFile(storage map[string]string) +} + +type DB interface { + AddRecord(short string, url string) error + GetRecord(short string) (string, error) +} + +func (s Storage) Add(short string, url string) error { + s.data[short] = url + if isDB() { + return s.db.AddRecord(short, url) + } else if isLocalStorage() { + uuid := len(s.data) + 1 + return s.manager.AddRecord(short, url, strconv.Itoa(uuid)) + } + return nil +} + +func (s Storage) Get(short string) (string, error) { + if isDB() { + url, err := s.db.GetRecord(short) + return url, err + } + url := s.data[short] + return url, nil +} + +func New(manager Manager, db DataBaseInterface) *Storage { + storage := &Storage{ + data: make(map[string]string), + manager: manager, + db: db, + } + if isLocalStorage() { + manager.ReadFromFile(storage.data) + } + return storage +} + +func (s Storage) FindByValue(url string) (string, bool) { + for k, v := range s.data { + if v == url { + return k, true + } + } + return "", false +} + +func isLocalStorage() bool { + return config.FilePath != "" +} + +func isDB() bool { + return config.DBDSN != "" +} + +func (s Storage) PingDB() error { + return s.db.Ping() +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..d5a2de8 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,45 @@ +package logger + +import ( + "go.uber.org/zap" +) + +// Log синглтон. +var Log *zap.Logger = zap.NewNop() + +// Initialize инициализирует логер. +func Initialize(level string) error { + // преобразуем текстовый уровень логирования в zap.AtomicLevel + lvl, err := zap.ParseAtomicLevel(level) + if err != nil { + return err + } + // создаём новую конфигурацию логера + cfg := zap.NewProductionConfig() + // устанавливаем уровень + cfg.Level = lvl + // создаём логер на основе конфигурации + zl, err := cfg.Build() + if err != nil { + return err + } + // устанавливаем синглтон + Log = zl + return nil +} + +func RequestLogger(uri string, method string, duration string) { + Log.Info("got incoming HTTP request", + zap.String("URI", uri), + zap.String("method", method), + zap.String("duration", duration), + ) +} + +// ResponseLogger — middleware-логер для HTTP-ответов. +func ResponseLogger(status string, size string) { + Log.Info("HTTP response has been sent", + zap.String("code", status), + zap.String("size", size), + ) +}