├── .gitignore ├── config └── prod.yaml ├── internal ├── storage │ ├── storage.go │ └── sqlite │ │ └── sqlite.go ├── lib │ ├── logger │ │ ├── sl │ │ │ └── sl.go │ │ └── handlers │ │ │ ├── slogdiscard │ │ │ └── slogdiscard.go │ │ │ └── slogpretty │ │ │ └── slogpretty.go │ ├── random │ │ ├── random.go │ │ └── random_test.go │ └── api │ │ ├── api.go │ │ └── response │ │ └── response.go ├── http-server │ ├── middleware │ │ └── logger │ │ │ └── logger.go │ └── handlers │ │ ├── redirect │ │ ├── mocks │ │ │ └── URLGetter.go │ │ ├── redirect_test.go │ │ └── redirect.go │ │ └── url │ │ └── save │ │ ├── mocks │ │ └── URLSaver.go │ │ ├── save_test.go │ │ └── save.go └── config │ └── config.go ├── deployment └── url-shortener.service ├── go.mod ├── tests └── url_shortener_test.go ├── .github └── workflows │ └── deploy.yaml ├── cmd └── url-shortener │ └── main.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /storage/storage.db 2 | -------------------------------------------------------------------------------- /config/prod.yaml: -------------------------------------------------------------------------------- 1 | env: "prod" 2 | storage_path: "./storage.db" 3 | http_server: 4 | address: "0.0.0.0:8082" 5 | timeout: 4s 6 | idle_timeout: 30s 7 | user: "Shabby8574" -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrURLNotFound = errors.New("url not found") 7 | ErrURLExists = errors.New("url exists") 8 | ) 9 | -------------------------------------------------------------------------------- /internal/lib/logger/sl/sl.go: -------------------------------------------------------------------------------- 1 | package sl 2 | 3 | import ( 4 | "golang.org/x/exp/slog" 5 | ) 6 | 7 | func Err(err error) slog.Attr { 8 | return slog.Attr{ 9 | Key: "error", 10 | Value: slog.StringValue(err.Error()), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /deployment/url-shortener.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Url Shortener 3 | After=network.target 4 | 5 | [Service] 6 | User=root 7 | WorkingDirectory=/root/apps/url-shortener 8 | ExecStart=/root/apps/url-shortener/url-shortener 9 | Restart=always 10 | RestartSec=4 11 | StandardOutput=inherit 12 | EnvironmentFile=/root/apps/url-shortener/config.env 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /internal/lib/random/random.go: -------------------------------------------------------------------------------- 1 | package random 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // NewRandomString generates random string with given size. 9 | func NewRandomString(size int) string { 10 | rnd := rand.New(rand.NewSource(time.Now().UnixNano())) 11 | 12 | chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 13 | "abcdefghijklmnopqrstuvwxyz" + 14 | "0123456789") 15 | 16 | b := make([]rune, size) 17 | for i := range b { 18 | b[i] = chars[rnd.Intn(len(chars))] 19 | } 20 | 21 | return string(b) 22 | } 23 | -------------------------------------------------------------------------------- /internal/lib/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | var ( 10 | ErrInvalidStatusCode = errors.New("invalid status code") 11 | ) 12 | 13 | // GetRedirect returns the final URL after redirection. 14 | func GetRedirect(url string) (string, error) { 15 | const op = "api.GetRedirect" 16 | 17 | client := &http.Client{ 18 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 19 | return http.ErrUseLastResponse // stop after 1st redirect 20 | }, 21 | } 22 | 23 | resp, err := client.Get(url) 24 | if err != nil { 25 | return "", err 26 | } 27 | defer func() { _ = resp.Body.Close() }() 28 | 29 | if resp.StatusCode != http.StatusFound { 30 | return "", fmt.Errorf("%s: %w: %d", op, ErrInvalidStatusCode, resp.StatusCode) 31 | } 32 | 33 | return resp.Header.Get("Location"), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/lib/logger/handlers/slogdiscard/slogdiscard.go: -------------------------------------------------------------------------------- 1 | package slogdiscard 2 | 3 | import ( 4 | "context" 5 | 6 | "golang.org/x/exp/slog" 7 | ) 8 | 9 | func NewDiscardLogger() *slog.Logger { 10 | return slog.New(NewDiscardHandler()) 11 | } 12 | 13 | type DiscardHandler struct{} 14 | 15 | func NewDiscardHandler() *DiscardHandler { 16 | return &DiscardHandler{} 17 | } 18 | 19 | func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { 20 | // Просто игнорируем запись журнала 21 | return nil 22 | } 23 | 24 | func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { 25 | // Возвращает тот же обработчик, так как нет атрибутов для сохранения 26 | return h 27 | } 28 | 29 | func (h *DiscardHandler) WithGroup(_ string) slog.Handler { 30 | // Возвращает тот же обработчик, так как нет группы для сохранения 31 | return h 32 | } 33 | 34 | func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { 35 | // Всегда возвращает false, так как запись журнала игнорируется 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /internal/lib/random/random_test.go: -------------------------------------------------------------------------------- 1 | package random 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewRandomString(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | size int 13 | }{ 14 | { 15 | name: "size = 1", 16 | size: 1, 17 | }, 18 | { 19 | name: "size = 5", 20 | size: 5, 21 | }, 22 | { 23 | name: "size = 10", 24 | size: 10, 25 | }, 26 | { 27 | name: "size = 20", 28 | size: 20, 29 | }, 30 | { 31 | name: "size = 30", 32 | size: 30, 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | str1 := NewRandomString(tt.size) 38 | str2 := NewRandomString(tt.size) 39 | 40 | assert.Len(t, str1, tt.size) 41 | assert.Len(t, str2, tt.size) 42 | 43 | // Check that two generated strings are different 44 | // This is not an absolute guarantee that the function works correctly, 45 | // but this is a good heuristic for a simple random generator. 46 | assert.NotEqual(t, str1, str2) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/lib/api/response/response.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/go-playground/validator/v10" 8 | ) 9 | 10 | type Response struct { 11 | Status string `json:"status"` 12 | Error string `json:"error,omitempty"` 13 | } 14 | 15 | const ( 16 | StatusOK = "OK" 17 | StatusError = "Error" 18 | ) 19 | 20 | func OK() Response { 21 | return Response{ 22 | Status: StatusOK, 23 | } 24 | } 25 | 26 | func Error(msg string) Response { 27 | return Response{ 28 | Status: StatusError, 29 | Error: msg, 30 | } 31 | } 32 | 33 | func ValidationError(errs validator.ValidationErrors) Response { 34 | var errMsgs []string 35 | 36 | for _, err := range errs { 37 | switch err.ActualTag() { 38 | case "required": 39 | errMsgs = append(errMsgs, fmt.Sprintf("field %s is a required field", err.Field())) 40 | case "url": 41 | errMsgs = append(errMsgs, fmt.Sprintf("field %s is not a valid URL", err.Field())) 42 | default: 43 | errMsgs = append(errMsgs, fmt.Sprintf("field %s is not valid", err.Field())) 44 | } 45 | } 46 | 47 | return Response{ 48 | Status: StatusError, 49 | Error: strings.Join(errMsgs, ", "), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/http-server/middleware/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi/v5/middleware" 8 | "golang.org/x/exp/slog" 9 | ) 10 | 11 | func New(log *slog.Logger) func(next http.Handler) http.Handler { 12 | return func(next http.Handler) http.Handler { 13 | log := log.With( 14 | slog.String("component", "middleware/logger"), 15 | ) 16 | 17 | log.Info("logger middleware enabled") 18 | 19 | fn := func(w http.ResponseWriter, r *http.Request) { 20 | entry := log.With( 21 | slog.String("method", r.Method), 22 | slog.String("path", r.URL.Path), 23 | slog.String("remote_addr", r.RemoteAddr), 24 | slog.String("user_agent", r.UserAgent()), 25 | slog.String("request_id", middleware.GetReqID(r.Context())), 26 | ) 27 | ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 28 | 29 | t1 := time.Now() 30 | defer func() { 31 | entry.Info("request completed", 32 | slog.Int("status", ww.Status()), 33 | slog.Int("bytes", ww.BytesWritten()), 34 | slog.String("duration", time.Since(t1).String()), 35 | ) 36 | }() 37 | 38 | next.ServeHTTP(ww, r) 39 | } 40 | 41 | return http.HandlerFunc(fn) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/ilyakaznacheev/cleanenv" 9 | ) 10 | 11 | type Config struct { 12 | Env string `yaml:"env" env-default:"local"` 13 | StoragePath string `yaml:"storage_path" env-required:"true"` 14 | HTTPServer `yaml:"http_server"` 15 | } 16 | 17 | type HTTPServer struct { 18 | Address string `yaml:"address" env-default:"localhost:8080"` 19 | Timeout time.Duration `yaml:"timeout" env-default:"4s"` 20 | IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"` 21 | User string `yaml:"user" env-required:"true"` 22 | Password string `yaml:"password" env-required:"true" env:"HTTP_SERVER_PASSWORD"` 23 | } 24 | 25 | func MustLoad() *Config { 26 | configPath := os.Getenv("CONFIG_PATH") 27 | if configPath == "" { 28 | log.Fatal("CONFIG_PATH is not set") 29 | } 30 | 31 | // check if file exists 32 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 33 | log.Fatalf("config file does not exist: %s", configPath) 34 | } 35 | 36 | var cfg Config 37 | 38 | if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { 39 | log.Fatalf("cannot read config: %s", err) 40 | } 41 | 42 | return &cfg 43 | } 44 | -------------------------------------------------------------------------------- /internal/http-server/handlers/redirect/mocks/URLGetter.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.28.2. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // URLGetter is an autogenerated mock type for the URLGetter type 8 | type URLGetter struct { 9 | mock.Mock 10 | } 11 | 12 | // GetURL provides a mock function with given fields: alias 13 | func (_m *URLGetter) GetURL(alias string) (string, error) { 14 | ret := _m.Called(alias) 15 | 16 | var r0 string 17 | var r1 error 18 | if rf, ok := ret.Get(0).(func(string) (string, error)); ok { 19 | return rf(alias) 20 | } 21 | if rf, ok := ret.Get(0).(func(string) string); ok { 22 | r0 = rf(alias) 23 | } else { 24 | r0 = ret.Get(0).(string) 25 | } 26 | 27 | if rf, ok := ret.Get(1).(func(string) error); ok { 28 | r1 = rf(alias) 29 | } else { 30 | r1 = ret.Error(1) 31 | } 32 | 33 | return r0, r1 34 | } 35 | 36 | type mockConstructorTestingTNewURLGetter interface { 37 | mock.TestingT 38 | Cleanup(func()) 39 | } 40 | 41 | // NewURLGetter creates a new instance of URLGetter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 42 | func NewURLGetter(t mockConstructorTestingTNewURLGetter) *URLGetter { 43 | mock := &URLGetter{} 44 | mock.Mock.Test(t) 45 | 46 | t.Cleanup(func() { mock.AssertExpectations(t) }) 47 | 48 | return mock 49 | } 50 | -------------------------------------------------------------------------------- /internal/http-server/handlers/url/save/mocks/URLSaver.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.28.2. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // URLSaver is an autogenerated mock type for the URLSaver type 8 | type URLSaver struct { 9 | mock.Mock 10 | } 11 | 12 | // SaveURL provides a mock function with given fields: urlToSave, alias 13 | func (_m *URLSaver) SaveURL(urlToSave string, alias string) (int64, error) { 14 | ret := _m.Called(urlToSave, alias) 15 | 16 | var r0 int64 17 | var r1 error 18 | if rf, ok := ret.Get(0).(func(string, string) (int64, error)); ok { 19 | return rf(urlToSave, alias) 20 | } 21 | if rf, ok := ret.Get(0).(func(string, string) int64); ok { 22 | r0 = rf(urlToSave, alias) 23 | } else { 24 | r0 = ret.Get(0).(int64) 25 | } 26 | 27 | if rf, ok := ret.Get(1).(func(string, string) error); ok { 28 | r1 = rf(urlToSave, alias) 29 | } else { 30 | r1 = ret.Error(1) 31 | } 32 | 33 | return r0, r1 34 | } 35 | 36 | type mockConstructorTestingTNewURLSaver interface { 37 | mock.TestingT 38 | Cleanup(func()) 39 | } 40 | 41 | // 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. 42 | func NewURLSaver(t mockConstructorTestingTNewURLSaver) *URLSaver { 43 | mock := &URLSaver{} 44 | mock.Mock.Test(t) 45 | 46 | t.Cleanup(func() { mock.AssertExpectations(t) }) 47 | 48 | return mock 49 | } 50 | -------------------------------------------------------------------------------- /internal/http-server/handlers/redirect/redirect_test.go: -------------------------------------------------------------------------------- 1 | package redirect_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "url-shortener/internal/http-server/handlers/redirect" 12 | "url-shortener/internal/http-server/handlers/redirect/mocks" 13 | "url-shortener/internal/lib/api" 14 | "url-shortener/internal/lib/logger/handlers/slogdiscard" 15 | ) 16 | 17 | func TestSaveHandler(t *testing.T) { 18 | cases := []struct { 19 | name string 20 | alias string 21 | url string 22 | respError string 23 | mockError error 24 | }{ 25 | { 26 | name: "Success", 27 | alias: "test_alias", 28 | url: "https://www.google.com/", 29 | }, 30 | } 31 | 32 | for _, tc := range cases { 33 | t.Run(tc.name, func(t *testing.T) { 34 | urlGetterMock := mocks.NewURLGetter(t) 35 | 36 | if tc.respError == "" || tc.mockError != nil { 37 | urlGetterMock.On("GetURL", tc.alias). 38 | Return(tc.url, tc.mockError).Once() 39 | } 40 | 41 | r := chi.NewRouter() 42 | r.Get("/{alias}", redirect.New(slogdiscard.NewDiscardLogger(), urlGetterMock)) 43 | 44 | ts := httptest.NewServer(r) 45 | defer ts.Close() 46 | 47 | redirectedToURL, err := api.GetRedirect(ts.URL + "/" + tc.alias) 48 | require.NoError(t, err) 49 | 50 | // Check the final URL after redirection. 51 | assert.Equal(t, tc.url, redirectedToURL) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/http-server/handlers/redirect/redirect.go: -------------------------------------------------------------------------------- 1 | package redirect 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/go-chi/chi/v5/middleware" 9 | "github.com/go-chi/render" 10 | "golang.org/x/exp/slog" 11 | 12 | resp "url-shortener/internal/lib/api/response" 13 | "url-shortener/internal/lib/logger/sl" 14 | "url-shortener/internal/storage" 15 | ) 16 | 17 | // URLGetter is an interface for getting url by alias. 18 | // 19 | //go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLGetter 20 | type URLGetter interface { 21 | GetURL(alias string) (string, error) 22 | } 23 | 24 | func New(log *slog.Logger, urlGetter URLGetter) http.HandlerFunc { 25 | return func(w http.ResponseWriter, r *http.Request) { 26 | const op = "handlers.url.redirect.New" 27 | 28 | log := log.With( 29 | slog.String("op", op), 30 | slog.String("request_id", middleware.GetReqID(r.Context())), 31 | ) 32 | 33 | alias := chi.URLParam(r, "alias") 34 | if alias == "" { 35 | log.Info("alias is empty") 36 | 37 | render.JSON(w, r, resp.Error("invalid request")) 38 | 39 | return 40 | } 41 | 42 | resURL, err := urlGetter.GetURL(alias) 43 | if errors.Is(err, storage.ErrURLNotFound) { 44 | log.Info("url not found", "alias", alias) 45 | 46 | render.JSON(w, r, resp.Error("not found")) 47 | 48 | return 49 | } 50 | if err != nil { 51 | log.Error("failed to get url", sl.Err(err)) 52 | 53 | render.JSON(w, r, resp.Error("internal error")) 54 | 55 | return 56 | } 57 | 58 | log.Info("got url", slog.String("url", resURL)) 59 | 60 | // redirect to found url 61 | http.Redirect(w, r, resURL, http.StatusFound) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/lib/logger/handlers/slogpretty/slogpretty.go: -------------------------------------------------------------------------------- 1 | package slogpretty 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | stdLog "log" 8 | 9 | "github.com/fatih/color" 10 | "golang.org/x/exp/slog" 11 | ) 12 | 13 | type PrettyHandlerOptions struct { 14 | SlogOpts *slog.HandlerOptions 15 | } 16 | 17 | type PrettyHandler struct { 18 | opts PrettyHandlerOptions 19 | slog.Handler 20 | l *stdLog.Logger 21 | attrs []slog.Attr 22 | } 23 | 24 | func (opts PrettyHandlerOptions) NewPrettyHandler( 25 | out io.Writer, 26 | ) *PrettyHandler { 27 | h := &PrettyHandler{ 28 | Handler: slog.NewJSONHandler(out, opts.SlogOpts), 29 | l: stdLog.New(out, "", 0), 30 | } 31 | 32 | return h 33 | } 34 | 35 | func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { 36 | level := r.Level.String() + ":" 37 | 38 | switch r.Level { 39 | case slog.LevelDebug: 40 | level = color.MagentaString(level) 41 | case slog.LevelInfo: 42 | level = color.BlueString(level) 43 | case slog.LevelWarn: 44 | level = color.YellowString(level) 45 | case slog.LevelError: 46 | level = color.RedString(level) 47 | } 48 | 49 | fields := make(map[string]interface{}, r.NumAttrs()) 50 | 51 | r.Attrs(func(a slog.Attr) bool { 52 | fields[a.Key] = a.Value.Any() 53 | 54 | return true 55 | }) 56 | 57 | for _, a := range h.attrs { 58 | fields[a.Key] = a.Value.Any() 59 | } 60 | 61 | var b []byte 62 | var err error 63 | 64 | if len(fields) > 0 { 65 | b, err = json.MarshalIndent(fields, "", " ") 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | timeStr := r.Time.Format("[15:05:05.000]") 72 | msg := color.CyanString(r.Message) 73 | 74 | h.l.Println( 75 | timeStr, 76 | level, 77 | msg, 78 | color.WhiteString(string(b)), 79 | ) 80 | 81 | return nil 82 | } 83 | 84 | func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 85 | return &PrettyHandler{ 86 | Handler: h.Handler, 87 | l: h.l, 88 | attrs: attrs, 89 | } 90 | } 91 | 92 | func (h *PrettyHandler) WithGroup(name string) slog.Handler { 93 | // TODO: implement 94 | return &PrettyHandler{ 95 | Handler: h.Handler.WithGroup(name), 96 | l: h.l, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/storage/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/mattn/go-sqlite3" 9 | 10 | "url-shortener/internal/storage" 11 | ) 12 | 13 | type Storage struct { 14 | db *sql.DB 15 | } 16 | 17 | func New(storagePath string) (*Storage, error) { 18 | const op = "storage.sqlite.New" 19 | 20 | db, err := sql.Open("sqlite3", storagePath) 21 | if err != nil { 22 | return nil, fmt.Errorf("%s: %w", op, err) 23 | } 24 | 25 | stmt, err := db.Prepare(` 26 | CREATE TABLE IF NOT EXISTS url( 27 | id INTEGER PRIMARY KEY, 28 | alias TEXT NOT NULL UNIQUE, 29 | url TEXT NOT NULL); 30 | CREATE INDEX IF NOT EXISTS idx_alias ON url(alias); 31 | `) 32 | if err != nil { 33 | return nil, fmt.Errorf("%s: %w", op, err) 34 | } 35 | 36 | _, err = stmt.Exec() 37 | if err != nil { 38 | return nil, fmt.Errorf("%s: %w", op, err) 39 | } 40 | 41 | return &Storage{db: db}, nil 42 | } 43 | 44 | func (s *Storage) SaveURL(urlToSave string, alias string) (int64, error) { 45 | const op = "storage.sqlite.SaveURL" 46 | 47 | stmt, err := s.db.Prepare("INSERT INTO url(url, alias) VALUES(?, ?)") 48 | if err != nil { 49 | return 0, fmt.Errorf("%s: %w", op, err) 50 | } 51 | 52 | res, err := stmt.Exec(urlToSave, alias) 53 | if err != nil { 54 | if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { 55 | return 0, fmt.Errorf("%s: %w", op, storage.ErrURLExists) 56 | } 57 | 58 | return 0, fmt.Errorf("%s: %w", op, err) 59 | } 60 | 61 | id, err := res.LastInsertId() 62 | if err != nil { 63 | return 0, fmt.Errorf("%s: failed to get last insert id: %w", op, err) 64 | } 65 | 66 | return id, nil 67 | } 68 | 69 | func (s *Storage) GetURL(alias string) (string, error) { 70 | const op = "storage.sqlite.GetURL" 71 | 72 | stmt, err := s.db.Prepare("SELECT url FROM url WHERE alias = ?") 73 | if err != nil { 74 | return "", fmt.Errorf("%s: prepare statement: %w", op, err) 75 | } 76 | 77 | var resURL string 78 | 79 | err = stmt.QueryRow(alias).Scan(&resURL) 80 | if err != nil { 81 | if errors.Is(err, sql.ErrNoRows) { 82 | return "", storage.ErrURLNotFound 83 | } 84 | 85 | return "", fmt.Errorf("%s: execute statement: %w", op, err) 86 | } 87 | 88 | return resURL, nil 89 | } 90 | 91 | // TODO: implement method 92 | // func (s *Storage) DeleteURL(alias string) error 93 | -------------------------------------------------------------------------------- /internal/http-server/handlers/url/save/save_test.go: -------------------------------------------------------------------------------- 1 | package save_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/mock" 13 | "github.com/stretchr/testify/require" 14 | 15 | "url-shortener/internal/http-server/handlers/url/save" 16 | "url-shortener/internal/http-server/handlers/url/save/mocks" 17 | "url-shortener/internal/lib/logger/handlers/slogdiscard" 18 | ) 19 | 20 | func TestSaveHandler(t *testing.T) { 21 | cases := []struct { 22 | name string 23 | alias string 24 | url string 25 | respError string 26 | mockError error 27 | }{ 28 | { 29 | name: "Success", 30 | alias: "test_alias", 31 | url: "https://google.com", 32 | }, 33 | { 34 | name: "Empty alias", 35 | alias: "", 36 | url: "https://google.com", 37 | }, 38 | { 39 | name: "Empty URL", 40 | url: "", 41 | alias: "some_alias", 42 | respError: "field URL is a required field", 43 | }, 44 | { 45 | name: "Invalid URL", 46 | url: "some invalid URL", 47 | alias: "some_alias", 48 | respError: "field URL is not a valid URL", 49 | }, 50 | { 51 | name: "SaveURL Error", 52 | alias: "test_alias", 53 | url: "https://google.com", 54 | respError: "failed to add url", 55 | mockError: errors.New("unexpected error"), 56 | }, 57 | } 58 | 59 | for _, tc := range cases { 60 | tc := tc 61 | 62 | t.Run(tc.name, func(t *testing.T) { 63 | t.Parallel() 64 | 65 | urlSaverMock := mocks.NewURLSaver(t) 66 | 67 | if tc.respError == "" || tc.mockError != nil { 68 | urlSaverMock.On("SaveURL", tc.url, mock.AnythingOfType("string")). 69 | Return(int64(1), tc.mockError). 70 | Once() 71 | } 72 | 73 | handler := save.New(slogdiscard.NewDiscardLogger(), urlSaverMock) 74 | 75 | input := fmt.Sprintf(`{"url": "%s", "alias": "%s"}`, tc.url, tc.alias) 76 | 77 | req, err := http.NewRequest(http.MethodPost, "/save", bytes.NewReader([]byte(input))) 78 | require.NoError(t, err) 79 | 80 | rr := httptest.NewRecorder() 81 | handler.ServeHTTP(rr, req) 82 | 83 | require.Equal(t, rr.Code, http.StatusOK) 84 | 85 | body := rr.Body.String() 86 | 87 | var resp save.Response 88 | 89 | require.NoError(t, json.Unmarshal([]byte(body), &resp)) 90 | 91 | require.Equal(t, tc.respError, resp.Error) 92 | 93 | // TODO: add more checks 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module url-shortener 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/brianvoe/gofakeit/v6 v6.22.0 7 | github.com/fatih/color v1.15.0 8 | github.com/gavv/httpexpect/v2 v2.15.0 9 | github.com/go-chi/chi/v5 v5.0.8 10 | github.com/go-chi/render v1.0.2 11 | github.com/go-playground/validator/v10 v10.14.1 12 | github.com/ilyakaznacheev/cleanenv v1.4.2 13 | github.com/mattn/go-sqlite3 v1.14.17 14 | github.com/stretchr/testify v1.8.2 15 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 16 | ) 17 | 18 | require ( 19 | github.com/BurntSushi/toml v1.1.0 // indirect 20 | github.com/ajg/form v1.5.1 // indirect 21 | github.com/andybalholm/brotli v1.0.4 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/fatih/structs v1.1.0 // indirect 24 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 25 | github.com/go-playground/locales v0.14.1 // indirect 26 | github.com/go-playground/universal-translator v0.18.1 // indirect 27 | github.com/gobwas/glob v0.2.3 // indirect 28 | github.com/google/go-querystring v1.1.0 // indirect 29 | github.com/gorilla/websocket v1.4.2 // indirect 30 | github.com/hpcloud/tail v1.0.0 // indirect 31 | github.com/imkira/go-interpol v1.1.0 // indirect 32 | github.com/joho/godotenv v1.4.0 // indirect 33 | github.com/klauspost/compress v1.15.0 // indirect 34 | github.com/leodido/go-urn v1.2.4 // indirect 35 | github.com/mattn/go-colorable v0.1.13 // indirect 36 | github.com/mattn/go-isatty v0.0.17 // indirect 37 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 38 | github.com/pmezard/go-difflib v1.0.0 // indirect 39 | github.com/sanity-io/litter v1.5.5 // indirect 40 | github.com/sergi/go-diff v1.0.0 // indirect 41 | github.com/stretchr/objx v0.5.0 // indirect 42 | github.com/valyala/bytebufferpool v1.0.0 // indirect 43 | github.com/valyala/fasthttp v1.34.0 // indirect 44 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 45 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 46 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 47 | github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect 48 | github.com/yudai/gojsondiff v1.0.0 // indirect 49 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 50 | golang.org/x/crypto v0.7.0 // indirect 51 | golang.org/x/net v0.8.0 // indirect 52 | golang.org/x/sys v0.6.0 // indirect 53 | golang.org/x/text v0.8.0 // indirect 54 | gopkg.in/fsnotify.v1 v1.4.7 // indirect 55 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | moul.io/http2curl/v2 v2.3.0 // indirect 58 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /tests/url_shortener_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/brianvoe/gofakeit/v6" 9 | "github.com/gavv/httpexpect/v2" 10 | "github.com/stretchr/testify/require" 11 | 12 | "url-shortener/internal/http-server/handlers/url/save" 13 | "url-shortener/internal/lib/api" 14 | "url-shortener/internal/lib/random" 15 | ) 16 | 17 | const ( 18 | host = "localhost:8082" 19 | ) 20 | 21 | func TestURLShortener_HappyPath(t *testing.T) { 22 | u := url.URL{ 23 | Scheme: "http", 24 | Host: host, 25 | } 26 | e := httpexpect.Default(t, u.String()) 27 | 28 | e.POST("/url"). 29 | WithJSON(save.Request{ 30 | URL: gofakeit.URL(), 31 | Alias: random.NewRandomString(10), 32 | }). 33 | WithBasicAuth("myuser", "mypass"). 34 | Expect(). 35 | Status(200). 36 | JSON().Object(). 37 | ContainsKey("alias") 38 | } 39 | 40 | //nolint:funlen 41 | func TestURLShortener_SaveRedirect(t *testing.T) { 42 | testCases := []struct { 43 | name string 44 | url string 45 | alias string 46 | error string 47 | }{ 48 | { 49 | name: "Valid URL", 50 | url: gofakeit.URL(), 51 | alias: gofakeit.Word() + gofakeit.Word(), 52 | }, 53 | { 54 | name: "Invalid URL", 55 | url: "invalid_url", 56 | alias: gofakeit.Word(), 57 | error: "field URL is not a valid URL", 58 | }, 59 | { 60 | name: "Empty Alias", 61 | url: gofakeit.URL(), 62 | alias: "", 63 | }, 64 | // TODO: add more test cases 65 | } 66 | 67 | for _, tc := range testCases { 68 | t.Run(tc.name, func(t *testing.T) { 69 | u := url.URL{ 70 | Scheme: "http", 71 | Host: host, 72 | } 73 | 74 | e := httpexpect.Default(t, u.String()) 75 | 76 | // Save 77 | 78 | resp := e.POST("/url"). 79 | WithJSON(save.Request{ 80 | URL: tc.url, 81 | Alias: tc.alias, 82 | }). 83 | WithBasicAuth("myuser", "mypass"). 84 | Expect().Status(http.StatusOK). 85 | JSON().Object() 86 | 87 | if tc.error != "" { 88 | resp.NotContainsKey("alias") 89 | 90 | resp.Value("error").String().IsEqual(tc.error) 91 | 92 | return 93 | } 94 | 95 | alias := tc.alias 96 | 97 | if tc.alias != "" { 98 | resp.Value("alias").String().IsEqual(tc.alias) 99 | } else { 100 | resp.Value("alias").String().NotEmpty() 101 | 102 | alias = resp.Value("alias").String().Raw() 103 | } 104 | 105 | // Redirect 106 | 107 | testRedirect(t, alias, tc.url) 108 | }) 109 | } 110 | } 111 | 112 | func testRedirect(t *testing.T, alias string, urlToRedirect string) { 113 | u := url.URL{ 114 | Scheme: "http", 115 | Host: host, 116 | Path: alias, 117 | } 118 | 119 | redirectedToURL, err := api.GetRedirect(u.String()) 120 | require.NoError(t, err) 121 | 122 | require.Equal(t, urlToRedirect, redirectedToURL) 123 | } 124 | -------------------------------------------------------------------------------- /internal/http-server/handlers/url/save/save.go: -------------------------------------------------------------------------------- 1 | package save 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5/middleware" 9 | "github.com/go-chi/render" 10 | "github.com/go-playground/validator/v10" 11 | "golang.org/x/exp/slog" 12 | 13 | resp "url-shortener/internal/lib/api/response" 14 | "url-shortener/internal/lib/logger/sl" 15 | "url-shortener/internal/lib/random" 16 | "url-shortener/internal/storage" 17 | ) 18 | 19 | type Request struct { 20 | URL string `json:"url" validate:"required,url"` 21 | Alias string `json:"alias,omitempty"` 22 | } 23 | 24 | type Response struct { 25 | resp.Response 26 | Alias string `json:"alias,omitempty"` 27 | } 28 | 29 | // TODO: move to config if needed 30 | const aliasLength = 6 31 | 32 | //go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLSaver 33 | type URLSaver interface { 34 | SaveURL(urlToSave string, alias string) (int64, error) 35 | } 36 | 37 | func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc { 38 | return func(w http.ResponseWriter, r *http.Request) { 39 | const op = "handlers.url.save.New" 40 | 41 | log := log.With( 42 | slog.String("op", op), 43 | slog.String("request_id", middleware.GetReqID(r.Context())), 44 | ) 45 | 46 | var req Request 47 | 48 | err := render.DecodeJSON(r.Body, &req) 49 | if errors.Is(err, io.EOF) { 50 | // Такую ошибку встретим, если получили запрос с пустым телом. 51 | // Обработаем её отдельно 52 | log.Error("request body is empty") 53 | 54 | render.JSON(w, r, resp.Error("empty request")) 55 | 56 | return 57 | } 58 | if err != nil { 59 | log.Error("failed to decode request body", sl.Err(err)) 60 | 61 | render.JSON(w, r, resp.Error("failed to decode request")) 62 | 63 | return 64 | } 65 | 66 | log.Info("request body decoded", slog.Any("request", req)) 67 | 68 | if err := validator.New().Struct(req); err != nil { 69 | validateErr := err.(validator.ValidationErrors) 70 | 71 | log.Error("invalid request", sl.Err(err)) 72 | 73 | render.JSON(w, r, resp.ValidationError(validateErr)) 74 | 75 | return 76 | } 77 | 78 | alias := req.Alias 79 | if alias == "" { 80 | alias = random.NewRandomString(aliasLength) 81 | } 82 | 83 | id, err := urlSaver.SaveURL(req.URL, alias) 84 | if errors.Is(err, storage.ErrURLExists) { 85 | log.Info("url already exists", slog.String("url", req.URL)) 86 | 87 | render.JSON(w, r, resp.Error("url already exists")) 88 | 89 | return 90 | } 91 | if err != nil { 92 | log.Error("failed to add url", sl.Err(err)) 93 | 94 | render.JSON(w, r, resp.Error("failed to add url")) 95 | 96 | return 97 | } 98 | 99 | log.Info("url added", slog.Int64("id", id)) 100 | 101 | responseOK(w, r, alias) 102 | } 103 | } 104 | 105 | func responseOK(w http.ResponseWriter, r *http.Request, alias string) { 106 | render.JSON(w, r, Response{ 107 | Response: resp.OK(), 108 | Alias: alias, 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy App 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'Tag to deploy' 8 | required: true 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | env: 14 | HOST: root@5.189.237.115 15 | DEPLOY_DIRECTORY: /root/apps/url-shortener 16 | CONFIG_PATH: /root/apps/url-shortener/config/prod.yaml 17 | ENV_FILE_PATH: /root/apps/url-shortener/config.env 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v2 22 | with: 23 | ref: ${{ github.event.inputs.tag }} 24 | - name: Check if tag exists 25 | run: | 26 | git fetch --all --tags 27 | if ! git tag | grep -q "^${{ github.event.inputs.tag }}$"; then 28 | echo "error: Tag '${{ github.event.inputs.tag }}' not found" 29 | exit 1 30 | fi 31 | - name: Set up Go 32 | uses: actions/setup-go@v2 33 | with: 34 | go-version: 1.20.2 35 | - name: Build app 36 | run: | 37 | go mod download 38 | go build -o url-shortener ./cmd/url-shortener 39 | - name: Deploy to VM 40 | run: | 41 | sudo apt-get install -y ssh rsync 42 | echo "$DEPLOY_SSH_KEY" > deploy_key.pem 43 | chmod 600 deploy_key.pem 44 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mkdir -p ${{ env.DEPLOY_DIRECTORY }}" 45 | rsync -avz -e 'ssh -i deploy_key.pem -o StrictHostKeyChecking=no' --exclude='.git' ./ ${{ env.HOST }}:${{ env.DEPLOY_DIRECTORY }} 46 | env: 47 | DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} 48 | - name: Remove old systemd service file 49 | run: | 50 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "rm -f /etc/systemd/system/url-shortener.service" 51 | - name: List workspace contents 52 | run: | 53 | echo "Listing deployment folder contents:" 54 | ls -la ${{ github.workspace }}/deployment 55 | - name: Create environment file on server 56 | run: | 57 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "\ 58 | touch ${{ env.ENV_FILE_PATH }} && \ 59 | chmod 600 ${{ env.ENV_FILE_PATH }} && \ 60 | echo 'CONFIG_PATH=${{ env.CONFIG_PATH }}' > ${{ env.ENV_FILE_PATH }} && \ 61 | echo 'HTTP_SERVER_PASSWORD=${{ secrets.AUTH_PASS }}' >> ${{ env.ENV_FILE_PATH }}" 62 | - name: Copy systemd service file 63 | run: | 64 | scp -i deploy_key.pem -o StrictHostKeyChecking=no ${{ github.workspace }}/deployment/url-shortener.service ${{ env.HOST }}:/tmp/url-shortener.service 65 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mv /tmp/url-shortener.service /etc/systemd/system/url-shortener.service" 66 | - name: Start application 67 | run: | 68 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "systemctl daemon-reload && systemctl restart url-shortener.service" -------------------------------------------------------------------------------- /cmd/url-shortener/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/chi/v5/middleware" 13 | "golang.org/x/exp/slog" 14 | 15 | "url-shortener/internal/config" 16 | "url-shortener/internal/http-server/handlers/redirect" 17 | "url-shortener/internal/http-server/handlers/url/save" 18 | mwLogger "url-shortener/internal/http-server/middleware/logger" 19 | "url-shortener/internal/lib/logger/handlers/slogpretty" 20 | "url-shortener/internal/lib/logger/sl" 21 | "url-shortener/internal/storage/sqlite" 22 | ) 23 | 24 | const ( 25 | envLocal = "local" 26 | envDev = "dev" 27 | envProd = "prod" 28 | ) 29 | 30 | func main() { 31 | cfg := config.MustLoad() 32 | 33 | log := setupLogger(cfg.Env) 34 | 35 | log.Info( 36 | "starting url-shortener", 37 | slog.String("env", cfg.Env), 38 | slog.String("version", "123"), 39 | ) 40 | log.Debug("debug messages are enabled") 41 | 42 | storage, err := sqlite.New(cfg.StoragePath) 43 | if err != nil { 44 | log.Error("failed to init storage", sl.Err(err)) 45 | os.Exit(1) 46 | } 47 | 48 | router := chi.NewRouter() 49 | 50 | router.Use(middleware.RequestID) 51 | router.Use(middleware.Logger) 52 | router.Use(mwLogger.New(log)) 53 | router.Use(middleware.Recoverer) 54 | router.Use(middleware.URLFormat) 55 | 56 | router.Route("/url", func(r chi.Router) { 57 | r.Use(middleware.BasicAuth("url-shortener", map[string]string{ 58 | cfg.HTTPServer.User: cfg.HTTPServer.Password, 59 | })) 60 | 61 | r.Post("/", save.New(log, storage)) 62 | // TODO: add DELETE /url/{id} 63 | }) 64 | 65 | router.Get("/{alias}", redirect.New(log, storage)) 66 | 67 | log.Info("starting server", slog.String("address", cfg.Address)) 68 | 69 | done := make(chan os.Signal, 1) 70 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 71 | 72 | srv := &http.Server{ 73 | Addr: cfg.Address, 74 | Handler: router, 75 | ReadTimeout: cfg.HTTPServer.Timeout, 76 | WriteTimeout: cfg.HTTPServer.Timeout, 77 | IdleTimeout: cfg.HTTPServer.IdleTimeout, 78 | } 79 | 80 | go func() { 81 | if err := srv.ListenAndServe(); err != nil { 82 | log.Error("failed to start server") 83 | } 84 | }() 85 | 86 | log.Info("server started") 87 | 88 | <-done 89 | log.Info("stopping server") 90 | 91 | // TODO: move timeout to config 92 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 93 | defer cancel() 94 | 95 | if err := srv.Shutdown(ctx); err != nil { 96 | log.Error("failed to stop server", sl.Err(err)) 97 | 98 | return 99 | } 100 | 101 | // TODO: close storage 102 | 103 | log.Info("server stopped") 104 | } 105 | 106 | func setupLogger(env string) *slog.Logger { 107 | var log *slog.Logger 108 | 109 | switch env { 110 | case envLocal: 111 | log = setupPrettySlog() 112 | case envDev: 113 | log = slog.New( 114 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 115 | ) 116 | case envProd: 117 | log = slog.New( 118 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), 119 | ) 120 | default: // If env config is invalid, set prod settings by default due to security 121 | log = slog.New( 122 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), 123 | ) 124 | } 125 | 126 | return log 127 | } 128 | 129 | func setupPrettySlog() *slog.Logger { 130 | opts := slogpretty.PrettyHandlerOptions{ 131 | SlogOpts: &slog.HandlerOptions{ 132 | Level: slog.LevelDebug, 133 | }, 134 | } 135 | 136 | handler := opts.NewPrettyHandler(os.Stdout) 137 | 138 | return slog.New(handler) 139 | } 140 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= 2 | github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 4 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 5 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 6 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 7 | github.com/brianvoe/gofakeit/v6 v6.22.0 h1:BzOsDot1o3cufTfOk+fWKE9nFYojyDV+XHdCWL2+uyE= 8 | github.com/brianvoe/gofakeit/v6 v6.22.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= 9 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 14 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 15 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 16 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 17 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 18 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 19 | github.com/gavv/httpexpect/v2 v2.15.0 h1:CCnFk9of4l4ijUhnMxyoEpJsIIBKcuWIFLMwwGTZxNs= 20 | github.com/gavv/httpexpect/v2 v2.15.0/go.mod h1:7myOP3A3VyS4+qnA4cm8DAad8zMN+7zxDB80W9f8yIc= 21 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 22 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 23 | github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= 24 | github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 25 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 26 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 27 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 28 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 29 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 30 | github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= 31 | github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 32 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 33 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 34 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 35 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 36 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 37 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 38 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 39 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 40 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 41 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 42 | github.com/ilyakaznacheev/cleanenv v1.4.2 h1:nRqiriLMAC7tz7GzjzUTBHfzdzw6SQ7XvTagkFqe/zU= 43 | github.com/ilyakaznacheev/cleanenv v1.4.2/go.mod h1:i0owW+HDxeGKE0/JPREJOdSCPIyOnmh6C0xhWAkF/xA= 44 | github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= 45 | github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= 46 | github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= 47 | github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 48 | github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= 49 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 50 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 51 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 52 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 53 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 54 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 55 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 56 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 57 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 58 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 59 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 60 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 61 | github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= 62 | github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= 63 | github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= 64 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 67 | github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= 68 | github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 69 | github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= 70 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 71 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 72 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 73 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 74 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 75 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 76 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 77 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 78 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 79 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 80 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 81 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 82 | github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= 83 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 84 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 85 | github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= 86 | github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= 87 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 88 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 89 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= 90 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 91 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 92 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 93 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 94 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 95 | github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= 96 | github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= 97 | github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= 98 | github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= 99 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= 100 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 101 | github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= 102 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 103 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 104 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 105 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 106 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 107 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 108 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 109 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= 110 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 111 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 112 | golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 113 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 114 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 115 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 116 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 117 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 118 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 119 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 120 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 121 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 122 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 123 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 124 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 125 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 126 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 128 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 129 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 130 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 132 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 134 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 135 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 136 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 137 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 138 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 139 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 140 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 141 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 142 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 143 | golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 144 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 145 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 146 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 147 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 148 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 149 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 150 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 151 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 152 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 153 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 154 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 155 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 156 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 157 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 158 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 159 | moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs= 160 | moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= 161 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 162 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 163 | --------------------------------------------------------------------------------