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..66b2ab7 100644 --- a/cmd/shortener/main.go +++ b/cmd/shortener/main.go @@ -1,3 +1,24 @@ package main -func main() {} +import ( + "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") + } + + fl := filemanager.New() + myStorage := storage.New(fl) + 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..376dd62 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/shilin-anton/urlreducer + +go 1.21.3 + +require ( + github.com/go-chi/chi/v5 v5.0.11 + 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/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1d6b63a --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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.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= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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..421f02c --- /dev/null +++ b/internal/app/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "flag" + "os" +) + +var RunAddr string +var BaseAddr string +var LogLevel string +var FilePath string + +const ( + defaultRunURL = "localhost:8080" + defaultBaseURL = "http://localhost:8080" + defaultLogLevel = "info" + defaultFilePath = "/tmp/short-url-db.json" +) + +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.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 + } +} diff --git a/internal/app/file-manager/file-manager.go b/internal/app/file-manager/file-manager.go new file mode 100644 index 0000000..925c10d --- /dev/null +++ b/internal/app/file-manager/file-manager.go @@ -0,0 +1,123 @@ +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) { + if localStorageDisabled() { + return + } + + 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 { + if localStorageDisabled() { + return nil + } + + 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 +} + +func localStorageDisabled() bool { + return config.FilePath == "" +} 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..cd0d31b --- /dev/null +++ b/internal/app/handlers/handler.go @@ -0,0 +1,206 @@ +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, bool) + FindByValue(url string) (string, bool) +} + +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)) + + 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, ok := s.data.Get(short) + if !ok { + http.NotFound(res, req) + return + } + res.Header().Set("Location", url) + res.WriteHeader(http.StatusTemporaryRedirect) +} + +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..164ef5b --- /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) + 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) + 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) + 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/storage.go b/internal/app/storage/storage.go new file mode 100644 index 0000000..dd8ee70 --- /dev/null +++ b/internal/app/storage/storage.go @@ -0,0 +1,43 @@ +package storage + +import "strconv" + +type Storage struct { + data map[string]string + manager Manager +} + +type Manager interface { + AddRecord(short string, url string, uuid string) error + ReadFromFile(storage map[string]string) +} + +func (s Storage) Add(short string, url string) error { + s.data[short] = url + uuid := len(s.data) + 1 + err := s.manager.AddRecord(short, url, strconv.Itoa(uuid)) + return err +} + +func (s Storage) Get(short string) (string, bool) { + url, ok := s.data[short] + return url, ok +} + +func New(manager Manager) *Storage { + storage := &Storage{ + data: make(map[string]string), + manager: manager, + } + 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 +} 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), + ) +}