├── docker-compose.yml ├── .drone.yml ├── common ├── jwt.go └── erros.go ├── go.mod ├── Dockerfile ├── main.go ├── handler ├── handler.go ├── routes.go ├── user.go ├── response.go ├── request.go └── url.go ├── db └── db.go ├── .gitignore ├── middleware └── jwt.go ├── model ├── user.go ├── model_test.go └── url.go ├── monitor ├── scheduler.go ├── monitor_test.go └── monitor.go ├── store ├── store_test.go └── store.go ├── README.md └── go.sum /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "8080:8080" -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | steps: 5 | - name: test 6 | image: golang 7 | commands: 8 | - go test ./... 9 | - go build ./... -------------------------------------------------------------------------------- /common/jwt.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/dgrijalva/jwt-go" 5 | "time" 6 | ) 7 | 8 | var JWTSecret = []byte("SuperSecret") 9 | 10 | func GenerateJWT(id uint) (string, error) { 11 | token := jwt.New(jwt.SigningMethodHS256) 12 | claims := token.Claims.(jwt.MapClaims) 13 | claims["id"] = id 14 | claims["exp"] = time.Now().Add(time.Hour * 72).Unix() 15 | t, err := token.SignedString(JWTSecret) 16 | if err != nil { 17 | return "", err 18 | } 19 | return t, nil 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/smf8/http-monitor 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/gammazero/workerpool v0.0.0-20200108033143-79b2336fad7a 9 | github.com/jinzhu/gorm v1.9.11 10 | github.com/kr/pretty v0.2.0 // indirect 11 | github.com/labstack/echo/v4 v4.1.13 12 | github.com/labstack/gommon v0.3.0 13 | github.com/stretchr/testify v1.4.0 14 | golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876 15 | ) 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest AS builder 2 | 3 | ENV GO111MODULE=on \ 4 | CGO_ENABLED=1 5 | 6 | #Maintainer info 7 | LABEL maintainer="Mohammad Fatemi " 8 | 9 | #change it with your proxy server 10 | #ARG http_proxy=68.183.214.82:443 11 | #ARG https_proxy=68.183.214.82:443 12 | 13 | 14 | WORKDIR /build 15 | 16 | COPY go.mod . 17 | COPY go.sum . 18 | 19 | RUN go mod download 20 | 21 | COPY . . 22 | 23 | RUN go build -o main . 24 | 25 | #this step is for CGO libraries 26 | RUN ldd main | tr -s '[:blank:]' '\n' | grep '^/' | \ 27 | xargs -I % sh -c 'mkdir -p $(dirname ./%); cp % ./%;' 28 | RUN mkdir -p lib64 && cp /lib64/ld-linux-x86-64.so.2 lib64/ 29 | 30 | #Second stage of build 31 | FROM alpine 32 | RUN apk update && apk --no-cache add ca-certificates 33 | 34 | COPY --from=builder /build ./ 35 | 36 | ENTRYPOINT ["./main"] 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/smf8/http-monitor/common" 6 | "github.com/smf8/http-monitor/db" 7 | "github.com/smf8/http-monitor/handler" 8 | "github.com/smf8/http-monitor/monitor" 9 | "github.com/smf8/http-monitor/store" 10 | "log" 11 | "time" 12 | ) 13 | 14 | func main() { 15 | d := db.Setup("http-monitor.db") 16 | st := store.NewStore(d) 17 | mnt := monitor.NewMonitor(st, nil, 10) 18 | //mnt.Do() 19 | sch, _ := monitor.NewScheduler(mnt) 20 | sch.DoWithIntervals(time.Minute * 5) 21 | 22 | err := mnt.LoadFromDatabase() 23 | if err != nil { 24 | log.Println(err) 25 | } 26 | e := echo.New() 27 | v1 := e.Group("/api") 28 | h := handler.NewHandler(st, sch) 29 | h.RegisterRoutes(v1) 30 | 31 | e.HTTPErrorHandler = common.CustomHTTPErrorHandler 32 | e.Logger.Fatal(e.Start(":8080")) 33 | } 34 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/asaskevich/govalidator" 5 | "github.com/dgrijalva/jwt-go" 6 | "github.com/labstack/echo/v4" 7 | "github.com/smf8/http-monitor/monitor" 8 | "github.com/smf8/http-monitor/store" 9 | ) 10 | 11 | // require validator to add "required" tag to every struct field in the package 12 | func init() { 13 | govalidator.SetFieldsRequiredByDefault(true) 14 | } 15 | 16 | type Handler struct { 17 | st *store.Store 18 | sch *monitor.Scheduler 19 | } 20 | 21 | // NewHandler creates a new handler with given store instance 22 | func NewHandler(st *store.Store, sch *monitor.Scheduler) *Handler { 23 | return &Handler{st: st, sch: sch} 24 | } 25 | 26 | func extractID(c echo.Context) uint { 27 | e := c.Get("user").(*jwt.Token) 28 | claims := e.Claims.(jwt.MapClaims) 29 | id := uint(claims["id"].(float64)) 30 | return id 31 | } 32 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jinzhu/gorm" 6 | _ "github.com/jinzhu/gorm/dialects/sqlite" 7 | "github.com/smf8/http-monitor/model" 8 | "strings" 9 | ) 10 | 11 | // Setup initializes database and returns DB instance 12 | func Setup(databaseName string) *gorm.DB { 13 | db := newDB(databaseName) 14 | migrate(db) 15 | // setup Database loading mode and connection pool and other settings as you prefer 16 | //db.DB().SetMaxIdleConns(5) 17 | db.LogMode(true) 18 | return db 19 | } 20 | 21 | func newDB(name string) *gorm.DB { 22 | if !strings.HasSuffix(name, ".db") { 23 | name = name + ".db" 24 | } 25 | db, err := gorm.Open("sqlite3", "./"+name) 26 | if err != nil { 27 | fmt.Println("Error in creating database file : ", err) 28 | return nil 29 | } 30 | return db 31 | } 32 | func migrate(db *gorm.DB) { 33 | db.AutoMigrate(&model.User{}, &model.Request{}, &model.URL{}) 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go,linux 2 | # Edit at https://www.gitignore.io/?templates=go,linux 3 | 4 | ### Go ### 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | ### Go Patch ### 22 | /vendor/ 23 | /Godeps/ 24 | 25 | ### Linux ### 26 | *~ 27 | 28 | # temporary files which can be created if a process still has a handle open of a deleted file 29 | .fuse_hidden* 30 | 31 | # KDE directory preferences 32 | .directory 33 | 34 | # Linux trash folder which might appear on any partition or disk 35 | .Trash-* 36 | 37 | # .nfs files are created when an open file is removed but is still being accessed 38 | .nfs* 39 | 40 | # End of https://www.gitignore.io/api/go,l -------------------------------------------------------------------------------- /handler/routes.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | middleware2 "github.com/labstack/echo/v4/middleware" 6 | "github.com/smf8/http-monitor/common" 7 | "github.com/smf8/http-monitor/middleware" 8 | ) 9 | 10 | // RegisterRoutes registers routes with their corresponding handler function 11 | // functions are defined in handler package 12 | func (h *Handler) RegisterRoutes(v *echo.Group) { 13 | 14 | v.Use(middleware.JWT(common.JWTSecret)) 15 | v.Use(middleware2.RemoveTrailingSlash()) 16 | 17 | // adding white list 18 | middleware.AddToWhiteList("/api/users/login", "POST") 19 | middleware.AddToWhiteList("/api/users", "POST") 20 | 21 | userGroup := v.Group("/users") 22 | userGroup.POST("", h.SignUp) 23 | userGroup.POST("/login", h.Login) 24 | 25 | urlGroup := v.Group("/urls") 26 | urlGroup.GET("", h.FetchURLs) 27 | urlGroup.POST("", h.CreateURL) 28 | urlGroup.GET("/:urlID", h.GetURLStats) 29 | urlGroup.DELETE("/:urlID", h.DeleteURL) 30 | 31 | alertGroup := v.Group("/alerts") 32 | alertGroup.GET("", h.FetchAlerts) 33 | alertGroup.PUT("/:urlID", h.DismissAlert) 34 | 35 | } 36 | -------------------------------------------------------------------------------- /middleware/jwt.go: -------------------------------------------------------------------------------- 1 | // codes from https://github.com/labstack/echo/blob/master/middleware/jwt.go 2 | package middleware 3 | 4 | import ( 5 | "github.com/labstack/echo/v4" 6 | "github.com/labstack/echo/v4/middleware" 7 | ) 8 | 9 | type whiteList struct { 10 | path string 11 | method string 12 | } 13 | 14 | // authWhiteList specifies paths to be skipped by jwt authentication middleware 15 | var authWhiteList []whiteList 16 | 17 | // AddToWhiteList is used to add a path to skipper white list 18 | // provide path relative to api version like /api/your/path/here as skipper uses strings.Contains to find whether 19 | // it is in context path or not 20 | func AddToWhiteList(path string, method string) { 21 | if authWhiteList == nil { 22 | authWhiteList = make([]whiteList, 0) 23 | } 24 | authWhiteList = append(authWhiteList, whiteList{path, method}) 25 | } 26 | 27 | func skipper(c echo.Context) bool { 28 | for _, v := range authWhiteList { 29 | if c.Path() == v.path && c.Request().Method == v.method { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | 36 | func JWT(key interface{}) echo.MiddlewareFunc { 37 | c := middleware.DefaultJWTConfig 38 | c.SigningKey = key 39 | c.Skipper = skipper 40 | return middleware.JWTWithConfig(c) 41 | } 42 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "github.com/jinzhu/gorm" 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | type User struct { 10 | gorm.Model 11 | Username string `gorm:"unique_index;not null"` 12 | Password string `gorm:"not null"` 13 | Urls []URL `gorm:"foreignkey:user_id"` 14 | } 15 | 16 | // NewUser creates a user with username and Hashed password 17 | // returns error if username or password is empty 18 | func NewUser(username, password string) (*User, error) { 19 | if len(password) == 0 || len(username) == 0 { 20 | return nil, errors.New("username of password cannot be empty") 21 | } 22 | pass, _ := HashPassword(password) 23 | return &User{Username: username, Password: pass}, nil 24 | } 25 | 26 | // HashPassword generates a hashed string from 'pass' 27 | // returns error if 'pass' is empty 28 | func HashPassword(pass string) (string, error) { 29 | if len(pass) == 0 { 30 | return "", errors.New("password cannot be empty") 31 | } 32 | hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) 33 | return string(hash), err 34 | } 35 | 36 | // ValidatePassword compares 'pass' with 'users' password 37 | // returns true if their equivalent 38 | func (user *User) ValidatePassword(pass string) bool { 39 | return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(pass)) == nil 40 | } 41 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestPasswordValidation(t *testing.T) { 9 | foo, err := NewUser("Foo", "Bar") 10 | assert.NoError(t, err, "Error creating user instance") 11 | assert.True(t, foo.ValidatePassword("Bar"), "Error validating password") 12 | } 13 | func TestHashPassword(t *testing.T) { 14 | _, err := HashPassword("") 15 | assert.Error(t, err, "error hashing error") 16 | } 17 | 18 | func TestUserCreation(t *testing.T) { 19 | _, err := NewUser("", "") 20 | assert.Error(t, err, "error creating user") 21 | } 22 | 23 | func TestURLCreation(t *testing.T) { 24 | url, err := NewURL(0, "google.com", 10) 25 | assert.NoError(t, err, "error creating url") 26 | assert.Equal(t, url.Address, "http://google.com") 27 | url, err = NewURL(0, "hppt://foo.bar", 10) 28 | assert.Error(t, err, "error validating url") 29 | } 30 | 31 | func TestAlarmTrigger(t *testing.T) { 32 | url, _ := NewURL(0, "google.com", 5) 33 | url.FailedTimes = 5 34 | assert.True(t, url.ShouldTriggerAlarm(), "error triggering alarm") 35 | } 36 | 37 | func TestURLSendRequest(t *testing.T) { 38 | url, _ := NewURL(0, "127.0.0.1:9999", 5) 39 | _, err := url.SendRequest() 40 | assert.Error(t, err) 41 | url.Address = "http://google.com" 42 | req, err := url.SendRequest() 43 | assert.NoError(t, err) 44 | assert.Equal(t, req.Result/100, 2) 45 | } 46 | -------------------------------------------------------------------------------- /monitor/scheduler.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/gammazero/workerpool" 7 | "time" 8 | ) 9 | 10 | type Scheduler struct { 11 | Mnt *Monitor 12 | Quit chan struct{} 13 | } 14 | 15 | // NewScheduler creates a new scheduler instance with mnt as monitor 16 | // it also creates a quit signal channel for emergency exits 17 | func NewScheduler(mnt *Monitor) (*Scheduler, error) { 18 | sch := &Scheduler{Quit: make(chan struct{})} 19 | if mnt != nil { 20 | sch.Mnt = mnt 21 | return sch, nil 22 | } 23 | return nil, errors.New("cannot create a scheduler with nil monitor") 24 | } 25 | 26 | // DoWithIntervals creates a ticker to the execute mnt.Do() every d duration 27 | // it listens to a quit channel as well for termination signal. 28 | // in order to stop it, Call StopSchedule(). 29 | func (sch *Scheduler) DoWithIntervals(d time.Duration) { 30 | ticker := time.NewTicker(d) 31 | go func() { 32 | for { 33 | select { 34 | case <-ticker.C: 35 | sch.Mnt.Do() 36 | case <-sch.Quit: 37 | // stopping worker pool from accepting anymore jobs 38 | err := sch.Mnt.Cancel() 39 | 40 | if err != nil { 41 | fmt.Println("error canceling monitor on quit signal in DoWithIntervals()") 42 | } 43 | 44 | // since out mnt's worker pool is useless after cancel we instantiate another one 45 | sch.Mnt.wp = workerpool.New(sch.Mnt.workerSize) 46 | 47 | ticker.Stop() 48 | return 49 | } 50 | } 51 | }() 52 | } 53 | 54 | // StopSchedule simply closes sch.Quit channel in order to stop it's running schedule 55 | func (sch *Scheduler) StopSchedule() { 56 | close(sch.Quit) 57 | } 58 | -------------------------------------------------------------------------------- /model/url.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "github.com/asaskevich/govalidator" 6 | "github.com/jinzhu/gorm" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type URL struct { 12 | gorm.Model 13 | UserId uint `gorm:"unique_index:index_addr_user"` // for preventing url duplication for a single user 14 | Address string `gorm:"unique_index:index_addr_user"` 15 | Threshold int 16 | FailedTimes int 17 | Requests []Request `gorm:"foreignkey:url_id"` 18 | } 19 | 20 | type Request struct { 21 | gorm.Model 22 | UrlId uint 23 | Result int 24 | } 25 | 26 | //NewURL creates a URL instance if it's address is a valid URL address 27 | func NewURL(userID uint, address string, threshold int) (*URL, error) { 28 | url := new(URL) 29 | url.UserId = userID 30 | url.Threshold = threshold 31 | url.FailedTimes = 0 32 | 33 | isValid := govalidator.IsURL(address) 34 | if !strings.HasPrefix("http://", address) { 35 | address = "http://" + address 36 | } 37 | if isValid { 38 | //valid URL address 39 | url.Address = address 40 | return url, nil 41 | } 42 | return nil, errors.New("not a valid URL address") 43 | } 44 | 45 | // ShouldTriggerAlarm checks if current url's failed times is greater than it's threshold 46 | // 47 | // Use this function to check alarm and trigger an alarm with other functions 48 | func (url *URL) ShouldTriggerAlarm() bool { 49 | return url.FailedTimes >= url.Threshold 50 | } 51 | 52 | // SendRequest sends a HTTP GET request to the url 53 | // returns a *Request with result status code 54 | func (url *URL) SendRequest() (*Request, error) { 55 | resp, err := http.Get(url.Address) 56 | req := new(Request) 57 | req.UrlId = url.ID 58 | if err != nil { 59 | return req, err 60 | } 61 | req.Result = resp.StatusCode 62 | return req, nil 63 | } 64 | -------------------------------------------------------------------------------- /handler/user.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/smf8/http-monitor/common" 6 | "github.com/smf8/http-monitor/model" 7 | "net/http" 8 | ) 9 | 10 | // Login handler function, if login is successful a response with JWT and username is returned followed by a 200 status code 11 | // json request format: 12 | // 13 | //{ 14 | // "username": "foo" [alpha numeric, len > 3], 15 | // "password": "bar1" [len > 3] 16 | //} 17 | func (h *Handler) Login(c echo.Context) error { 18 | req := &userAuthRequest{} 19 | user := &model.User{} 20 | if err := req.bind(c, user); err != nil { 21 | return err 22 | } 23 | // retrieving user from database 24 | u, err := h.st.GetUserByUserName(user.Username) 25 | if err != nil || !u.ValidatePassword(user.Password) { 26 | return common.NewRequestError("Invalid username or password", err, http.StatusUnauthorized) 27 | } 28 | return c.JSON(http.StatusOK, NewResponseData(NewUserResponse(u))) 29 | } 30 | 31 | // SignUp user handler to handle sign up request, if successful it returns JWT with username followed by 201 status code 32 | // json request format: 33 | // 34 | //{ 35 | // "username": "foo" [alpha numeric, len > 3], 36 | // "password": "bar1" [len > 3] 37 | //} 38 | func (h *Handler) SignUp(c echo.Context) error { 39 | req := &userAuthRequest{} 40 | user := &model.User{} 41 | if err := req.bind(c, user); err != nil { 42 | return err 43 | } 44 | user.Password, _ = model.HashPassword(user.Password) 45 | // saving user 46 | if err := h.st.AddUser(user); err != nil { 47 | return common.NewRequestError("could not save user in database", err, http.StatusInternalServerError) 48 | } 49 | 50 | return c.JSON(http.StatusCreated, NewResponseData(NewUserResponse(user))) 51 | } 52 | 53 | // FetchAlerts retrieves all alerts for the user, returns a list of urls with alert 54 | func (h *Handler) FetchAlerts(c echo.Context) error { 55 | userID := extractID(c) 56 | alerts, err := h.st.FetchAlerts(userID) 57 | if err != nil { 58 | return common.NewRequestError("coult not get alerts from database", err, http.StatusBadRequest) 59 | } 60 | return c.JSON(http.StatusOK, NewResponseData(alerts)) 61 | } 62 | -------------------------------------------------------------------------------- /monitor/monitor_test.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | "github.com/labstack/gommon/log" 6 | "github.com/smf8/http-monitor/db" 7 | "github.com/smf8/http-monitor/model" 8 | "github.com/smf8/http-monitor/store" 9 | "github.com/stretchr/testify/assert" 10 | "os" 11 | "testing" 12 | ) 13 | 14 | var mnt *Monitor 15 | var st *store.Store 16 | var d *gorm.DB 17 | 18 | func TestMain(m *testing.M) { 19 | setupDB() 20 | 21 | exitCode := m.Run() 22 | 23 | tearDown() 24 | 25 | os.Exit(exitCode) 26 | 27 | } 28 | 29 | func setupDB() { 30 | d = db.Setup("test-monitor") 31 | st = store.NewStore(d) 32 | user, _ := model.NewUser("foo", "bar") 33 | _ = st.AddUser(user) 34 | 35 | mnt = NewMonitor(st, nil, 20) 36 | 37 | } 38 | 39 | func tearDown() { 40 | // removing file and closing database after all tests are done 41 | if err := d.Close(); err != nil { 42 | log.Error(err) 43 | } 44 | if err := os.Remove("test-monitor.db"); err != nil { 45 | log.Error(err) 46 | } 47 | } 48 | func TestMonitor_Do(t *testing.T) { 49 | tearDown() 50 | setupDB() 51 | urls := []model.URL{ 52 | {UserId: 1, Address: "http://google.com", Threshold: 10, FailedTimes: 0}, 53 | {UserId: 2, Address: "http://google.com", Threshold: 10, FailedTimes: 0}, 54 | } 55 | st.AddURL(&urls[0]) 56 | st.AddURL(&urls[1]) 57 | mnt.AddURL(urls) 58 | mnt.Do() 59 | req, _ := st.GetRequestsByUrl(urls[0].ID) 60 | assert.Len(t, req, 1) 61 | } 62 | func TestMonitor_DoURL(t *testing.T) { 63 | tearDown() 64 | setupDB() 65 | url, _ := model.NewURL(1, "http://128.0.0.1", 5) 66 | st.AddURL(url) 67 | 68 | mnt.DoURL(*url) 69 | req, _ := st.GetRequestsByUrl(url.ID) 70 | assert.Len(t, req, 1) 71 | assert.Equal(t, req[0].Result, 400) 72 | 73 | url, err := st.GetURLById(1) 74 | assert.NoError(t, err) 75 | 76 | assert.Equal(t, url.FailedTimes, 1) 77 | } 78 | 79 | func TestMonitor_Cancel(t *testing.T) { 80 | tearDown() 81 | setupDB() 82 | mnt.Do() 83 | res := mnt.Cancel() 84 | assert.NoError(t, res) 85 | } 86 | 87 | func TestMonitor_RemoveURL(t *testing.T) { 88 | urls := []model.URL{ 89 | {UserId: 1, Address: "http://google.com", Threshold: 10, FailedTimes: 0}, 90 | {UserId: 2, Address: "http://google.com", Threshold: 10, FailedTimes: 0}, 91 | } 92 | urls[0].ID = 1 93 | urls[1].ID = 2 94 | mnt.AddURL(urls) 95 | err := mnt.RemoveURL(urls[0]) 96 | assert.NoError(t, err) 97 | 98 | u := model.URL{} 99 | u.ID = 4 100 | err = mnt.RemoveURL(u) 101 | assert.Error(t, err) 102 | } 103 | -------------------------------------------------------------------------------- /handler/response.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/smf8/http-monitor/common" 5 | "github.com/smf8/http-monitor/model" 6 | "time" 7 | ) 8 | 9 | type responseData struct { 10 | Data interface{} `json:"data"` 11 | } 12 | 13 | func NewResponseData(data interface{}) *responseData { 14 | return &responseData{data} 15 | } 16 | 17 | type userResponse struct { 18 | Username string `json:"username"` 19 | Token string `json:"token"` 20 | } 21 | 22 | func NewUserResponse(user *model.User) *userResponse { 23 | token, _ := common.GenerateJWT(user.ID) 24 | ur := &userResponse{Username: user.Username, Token: token} 25 | return ur 26 | } 27 | 28 | // TODO : as model.url struct does not have an inner User instance, create one for it and update urlResponse to send username instead of it's id 29 | type urlResponse struct { 30 | ID int `json:"id"` 31 | URL string `json:"url"` 32 | UserID uint `json:"user_id"` 33 | CreatedAt time.Time `json:"created_at"` 34 | Threshold int `json:"threshold"` 35 | FailedTimes int `json:"failed_times"` 36 | } 37 | 38 | func newURLResponse(url *model.URL) *urlResponse { 39 | u := new(urlResponse) 40 | u.URL = url.Address 41 | u.UserID = url.UserId 42 | u.CreatedAt = url.CreatedAt 43 | u.Threshold = url.Threshold 44 | u.FailedTimes = url.FailedTimes 45 | return u 46 | } 47 | 48 | type urlListResponse struct { 49 | URLs []*urlResponse `json:"urls"` 50 | UrlCount int `json:"url_count"` 51 | } 52 | 53 | func newURLListResponse(list []model.URL) *urlListResponse { 54 | resp := new(urlListResponse) 55 | resp.URLs = make([]*urlResponse, 0) 56 | for i := range list { 57 | resp.URLs = append(resp.URLs, newURLResponse(&list[i])) 58 | } 59 | resp.UrlCount = len(list) 60 | return resp 61 | } 62 | 63 | type requestResponse struct { 64 | ResultCode int `json:"result_code"` 65 | CreatedAt time.Time `json:"created_at"` 66 | } 67 | 68 | func newRequestResponse(req *model.Request) *requestResponse { 69 | return &requestResponse{ResultCode: req.Result, CreatedAt: req.CreatedAt} 70 | } 71 | 72 | type requestListResponse struct { 73 | URL string `json:"url"` 74 | RequestsCount int `json:"requests_count"` 75 | Requests []*requestResponse `json:"requests"` 76 | } 77 | 78 | //TODO update request struct to have a field for url instance 79 | func newRequestListResponse(reqs []model.Request, url string) *requestListResponse { 80 | resp := new(requestListResponse) 81 | resp.Requests = make([]*requestResponse, len(reqs)) 82 | for i := range reqs { 83 | resp.Requests[i] = newRequestResponse(&reqs[i]) 84 | } 85 | resp.URL = url 86 | resp.RequestsCount = len(reqs) 87 | return resp 88 | } 89 | 90 | type alertResponse struct { 91 | URL string `json:"url"` 92 | Threshold int `json:"threshold"` 93 | FailedTimes int `json:"failed_times"` 94 | } 95 | 96 | func newAlertResponse(url *model.URL) *alertResponse { 97 | resp := new(alertResponse) 98 | resp.URL = url.Address 99 | resp.FailedTimes = url.FailedTimes 100 | resp.Threshold = url.Threshold 101 | return resp 102 | } 103 | 104 | type alertListResponse struct { 105 | Alarms []*alertResponse `json:"alarms"` 106 | } 107 | -------------------------------------------------------------------------------- /handler/request.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/asaskevich/govalidator" 5 | "github.com/labstack/echo/v4" 6 | "github.com/smf8/http-monitor/common" 7 | "github.com/smf8/http-monitor/model" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | type userAuthRequest struct { 13 | Username string `valid:"stringlength(4|32), alphanum" json:"username"` 14 | Password string `valid:"stringlength(4|32)" json:"password"` 15 | } 16 | 17 | // binding user auth request with model.User instance 18 | func (r *userAuthRequest) bind(c echo.Context, user *model.User) error { 19 | if err := c.Bind(r); err != nil { 20 | return common.NewRequestError("error binding user request", err, http.StatusBadRequest) 21 | } 22 | if _, err := govalidator.ValidateStruct(r); err != nil { 23 | e := common.NewValidationError(err, "Error validating sign-up request") 24 | return e 25 | } 26 | user.Username = r.Username 27 | user.Password = r.Password 28 | return nil 29 | } 30 | 31 | type urlCreateRequest struct { 32 | Address string `json:"address" valid:"url"` 33 | Threshold int `json:"threshold" valid:"int"` 34 | } 35 | 36 | func (r *urlCreateRequest) bind(c echo.Context, url *model.URL) error { 37 | if err := c.Bind(r); err != nil { 38 | return common.NewRequestError("error binding url create request, check json structure and try again", err, http.StatusBadRequest) 39 | } 40 | if _, err := govalidator.ValidateStruct(r); err != nil { 41 | e := common.NewValidationError(err, "Error validating create url request") 42 | return e 43 | } 44 | url.Address = r.Address 45 | url.Threshold = r.Threshold 46 | url.FailedTimes = 0 47 | return nil 48 | } 49 | 50 | type alertDismissRequest struct { 51 | URLID uint `json:"url_id"` 52 | } 53 | 54 | func (r *alertDismissRequest) bind(c echo.Context, url *model.URL) error { 55 | if err := c.Bind(r); err != nil { 56 | return err 57 | } 58 | if _, err := govalidator.ValidateStruct(r); err != nil { 59 | e := common.NewValidationError(err, "Error validating alert dismiss request") 60 | return e 61 | } 62 | url.ID = r.URLID 63 | return nil 64 | } 65 | 66 | type urlStatusRequest struct { 67 | FromTime int64 `valid:"optional, time~Provide time as unix timestamp before current time" json:"from_time, omitempty" query:"from_time"` 68 | ToTime int64 `valid:"optional, time~Provide time as unix timestamp before current time" json:"to_time, omitempty" query:"to_time"` 69 | } 70 | 71 | func (r *urlStatusRequest) parse(c echo.Context) error { 72 | if err := c.Bind(r); err != nil { 73 | return common.NewRequestError("error parsing url status request, if you want to specify time, use unix timestamp", err, http.StatusBadRequest) 74 | } 75 | govalidator.CustomTypeTagMap.Set("time", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool { 76 | if _, ok := context.(urlStatusRequest); ok { 77 | if t, ok := i.(int64); ok { 78 | if time.Now().Unix() > t { 79 | return true 80 | } 81 | } 82 | } 83 | return false 84 | })) 85 | if _, err := govalidator.ValidateStruct(r); err != nil { 86 | e := common.NewValidationError(err, "error validating url status request") 87 | return e 88 | } 89 | if r.FromTime > r.ToTime && r.ToTime != 0 { 90 | return common.NewRequestError("end of time interval must be later than it's start", nil, http.StatusBadRequest) 91 | } 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /common/erros.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // got error handling idea from https://medium.com/ki-labs-engineering/rest-api-error-handling-in-go-behavioral-type-assertion-509d93636afd 4 | 5 | import ( 6 | "github.com/asaskevich/govalidator" 7 | "github.com/labstack/echo/v4" 8 | "net/http" 9 | ) 10 | 11 | const ( 12 | BindingError = "error parsing request, check request format and try again." 13 | ) 14 | 15 | // ClientError has An additional ErrorBody() function which returns map[string]interface{} 16 | // implementing Error() function is for server-side error logging 17 | // implementing ErrorBody() is for json response to clients 18 | type ClientError interface { 19 | Error() string 20 | ErrorBody() Error 21 | } 22 | 23 | type Error struct { 24 | Body map[string]interface{} `json:"errors"` 25 | } 26 | 27 | func NewError() *Error { 28 | body := make(map[string]interface{}) 29 | return &Error{Body: body} 30 | } 31 | 32 | // RequestError implements ClientError interface 33 | // call ErrorBody() on it to provide a map version of errors 34 | type RequestError struct { 35 | Cause error 36 | Detail string 37 | Status int 38 | } 39 | 40 | // NewBindingError creates an error with given data. "bindingType" is the type that is being bound 41 | // provide short description for "detail" to display to user as error message 42 | func NewRequestError(detail string, err error, statusCode int) *RequestError { 43 | if e, ok := err.(*RequestError); ok { 44 | return e 45 | } 46 | return &RequestError{err, detail, statusCode} 47 | } 48 | func (se *RequestError) Error() string { 49 | if se.Cause == nil { 50 | return se.Detail 51 | } 52 | return se.Detail + " : " + se.Cause.Error() 53 | } 54 | func (se *RequestError) ErrorBody() Error { 55 | body := NewError() 56 | body.Body["body"] = se.Detail 57 | return *body 58 | } 59 | 60 | type ValidationError struct { 61 | Cause error 62 | ErrorMap map[string]string 63 | Detail string 64 | } 65 | 66 | func NewValidationError(err error, detail string) *ValidationError { 67 | m := govalidator.ErrorsByField(err) 68 | return &ValidationError{err, m, detail} 69 | } 70 | 71 | func (e *ValidationError) Error() string { 72 | if e.Cause == nil { 73 | return e.Detail 74 | } 75 | return e.Detail + " : " + e.Cause.Error() 76 | } 77 | 78 | func (e *ValidationError) ErrorBody() Error { 79 | err := new(Error) 80 | m := make(map[string]interface{}) 81 | for key, value := range e.ErrorMap { 82 | m[key] = value 83 | } 84 | err.Body = m 85 | return *err 86 | } 87 | 88 | // CustomHTTPErrorHandler, here we define what to do with each types of errors 89 | func CustomHTTPErrorHandler(err error, c echo.Context) { 90 | code := http.StatusInternalServerError 91 | switch err.(type) { 92 | case ClientError: 93 | ce := err.(ClientError) 94 | e := c.JSON(code, ce.ErrorBody()) 95 | if e != nil { 96 | c.Logger().Error(e) 97 | } 98 | 99 | c.Logger().Error(ce) 100 | switch e := ce.(type) { 101 | case *RequestError: 102 | code = e.Status 103 | case *ValidationError: 104 | code = http.StatusBadRequest 105 | } 106 | case *echo.HTTPError: 107 | e := c.JSON(err.(*echo.HTTPError).Code, err.(*echo.HTTPError).Message) 108 | if e != nil { 109 | c.Logger().Error(e) 110 | } 111 | c.Logger().Error(err) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/gammazero/workerpool" 7 | "github.com/smf8/http-monitor/model" 8 | "github.com/smf8/http-monitor/store" 9 | "net/http" 10 | "sync" 11 | ) 12 | 13 | type Monitor struct { 14 | store *store.Store 15 | URLs []model.URL 16 | wp *workerpool.WorkerPool 17 | workerSize int 18 | } 19 | 20 | // NewMonitor creates a Monitor instance with 'store' and 'url' 21 | // it also creates a worker pool of size 'workerSize' 22 | // if 'urls' is set to nil it will be initialized with an empty slice 23 | func NewMonitor(store *store.Store, urls []model.URL, workerSize int) *Monitor { 24 | mnt := new(Monitor) 25 | if urls == nil { 26 | mnt.URLs = make([]model.URL, 0) 27 | } 28 | mnt.URLs = urls 29 | mnt.store = store 30 | mnt.workerSize = workerSize 31 | // max number of workers 32 | mnt.wp = workerpool.New(workerSize) 33 | return mnt 34 | } 35 | 36 | // LoadFromDatabase loads all urls from database into monitor to start working on them 37 | // this function will replace all of saved URLs with the ones from database 38 | func (mnt *Monitor) LoadFromDatabase() error { 39 | urls, err := mnt.store.GetAllURLs() 40 | if err != nil { 41 | return err 42 | } 43 | mnt.URLs = urls 44 | return nil 45 | } 46 | 47 | // RemoveURL removes a URL from current list of monitor's urls 48 | // returns error if the URL to be deleted was not found 49 | func (mnt *Monitor) RemoveURL(url model.URL) error { 50 | var index = -1 51 | for i := range mnt.URLs { 52 | if mnt.URLs[i].ID == url.ID { 53 | index = i 54 | } 55 | } 56 | if index == -1 { 57 | return errors.New("url to be deleted was not found in the slice") 58 | } 59 | // deleting from list efficiently 60 | mnt.URLs[index], mnt.URLs[len(mnt.URLs)-1] = mnt.URLs[len(mnt.URLs)-1], mnt.URLs[index] 61 | mnt.URLs = mnt.URLs[:len(mnt.URLs)-1] 62 | return nil 63 | } 64 | 65 | // AddURL appends a slice of urls to the current list of urls 66 | func (mnt *Monitor) AddURL(urls []model.URL) { 67 | mnt.URLs = append(mnt.URLs, urls...) 68 | } 69 | 70 | // Cancel stops all tasks of fetching urls 71 | // it will wait for current running jobs to finish 72 | // note that if you call this method, for reusing the monitor 73 | // you need to instantiate it again. 74 | func (mnt *Monitor) Cancel() error { 75 | mnt.wp.Stop() 76 | if !mnt.wp.Stopped() { 77 | return errors.New("could not stop monitor") 78 | } 79 | return nil 80 | } 81 | 82 | // DoURL checks a single URL's response and saves it's request into database 83 | func (mnt *Monitor) DoURL(url model.URL) { 84 | var wg sync.WaitGroup 85 | wg.Add(1) 86 | mnt.wp.Submit(func() { 87 | defer wg.Done() 88 | mnt.monitorURL(url) 89 | }) 90 | wg.Wait() 91 | } 92 | 93 | // Do ranges over URLs currently inside Monitor instance 94 | // and save each one's request inside database 95 | // this function does not block 96 | func (mnt *Monitor) Do() { 97 | var wg sync.WaitGroup 98 | 99 | for urlIndex := range mnt.URLs { 100 | url := mnt.URLs[urlIndex] 101 | wg.Add(1) 102 | mnt.wp.Submit(func() { 103 | defer wg.Done() 104 | mnt.monitorURL(url) 105 | }) 106 | } 107 | wg.Wait() 108 | } 109 | 110 | func (mnt *Monitor) monitorURL(url model.URL) { 111 | // sending request 112 | req, err := url.SendRequest() 113 | if err != nil { 114 | fmt.Println(err, "could not make request") 115 | req = new(model.Request) 116 | req.UrlId = url.ID 117 | req.Result = http.StatusBadRequest 118 | } 119 | // add request to database 120 | if err = mnt.store.AddRequest(req); err != nil { 121 | fmt.Println(err, "could not save request to database") 122 | } 123 | // status code was other than 2XX 124 | if req.Result/100 != 2 { 125 | if err = mnt.store.IncrementFailed(&url); err != nil { 126 | fmt.Println(err, "could not increment failed times for url") 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | "github.com/jinzhu/gorm" 6 | "github.com/labstack/gommon/log" 7 | "github.com/smf8/http-monitor/db" 8 | "github.com/smf8/http-monitor/model" 9 | "github.com/stretchr/testify/assert" 10 | "os" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | var database *gorm.DB 16 | var st *Store 17 | var usersList []*model.User 18 | var urlsList []*model.URL 19 | 20 | func TestMain(m *testing.M) { 21 | //initializing database 22 | database = db.Setup("test.db") 23 | st = NewStore(database) 24 | 25 | setup() 26 | 27 | returnCode := m.Run() 28 | // removing file and closing database after all tests are done 29 | if err := database.Close(); err != nil { 30 | log.Error(err) 31 | } 32 | if err := os.Remove("test.db"); err != nil { 33 | log.Error(err) 34 | } 35 | 36 | os.Exit(returnCode) 37 | } 38 | 39 | func setup() { 40 | usersList = make([]*model.User, 2) 41 | usersList[0], _ = model.NewUser("TestUser", "TestPassword") 42 | usersList[1], _ = model.NewUser("TestUser1", "TestPassword1") 43 | 44 | urlsList = make([]*model.URL, 10) 45 | for i := range urlsList { 46 | urlsList[i] = new(model.URL) 47 | urlsList[i].UserId = usersList[0].ID 48 | urlsList[i].Address = fmt.Sprintf("www.foo%d.bar", i) 49 | urlsList[i].Threshold = 10 50 | } 51 | } 52 | 53 | //TestUsers tests user insertion / reading 54 | func TestUsers(t *testing.T) { 55 | err := st.AddUser(usersList[0]) 56 | assert.NoError(t, err, "error adding user to database") 57 | _ = st.AddUser(usersList[1]) 58 | dbUser, err := st.GetUserByUserName("TestUser") 59 | assert.NoError(t, err, "error reading user from database") 60 | assert.Equal(t, dbUser.Username, "TestUser") 61 | _, err = st.GetUserByUserName("invalid-username") 62 | assert.Error(t, err) 63 | users, err := st.GetAllUsers() 64 | assert.NoError(t, err, "error reading all users from database") 65 | assert.Equal(t, 2, len(users)) 66 | // Changing usersList so that they have valid ID value from database 67 | usersList[0], usersList[1] = &users[0], &users[1] 68 | } 69 | 70 | func TestUrls(t *testing.T) { 71 | // URL insertion 72 | for i := range urlsList { 73 | urlsList[i].UserId = usersList[0].ID 74 | err := st.AddURL(urlsList[i]) 75 | assert.NoError(t, err, "Error inserting url into database") 76 | } 77 | // URL reading 78 | u, err := st.GetURLById(1) 79 | assert.NoError(t, err, "Error reading url with id 1 from database") 80 | 81 | assert.Equal(t, u.Address, "www.foo0.bar", "Mismatch url in database") 82 | 83 | _, err = st.GetURLById(1000) 84 | assert.Error(t, err) 85 | // Updating URL 86 | 87 | _, err = st.GetURLsByUser(usersList[0].ID) 88 | assert.NoError(t, err) 89 | 90 | err = st.IncrementFailed(u) 91 | err = st.IncrementFailed(u) 92 | assert.NoError(t, err, "Error incrementing failed times") 93 | 94 | u, _ = st.GetURLById(1) 95 | assert.Equal(t, 2, u.FailedTimes, "Increment failed_times didn't work") 96 | 97 | err = st.DismissAlert(u.ID) 98 | assert.NoError(t, err, "Error resetting failed times in database") 99 | 100 | u, _ = st.GetURLById(1) 101 | assert.Equal(t, 0, u.FailedTimes, "Resetting failed times didn't work") 102 | } 103 | 104 | func TestRequests(t *testing.T) { 105 | // test url insertion 106 | for i := range urlsList { 107 | req := new(model.Request) 108 | req.Result = 300 109 | req.UrlId = urlsList[i/3].ID 110 | err := st.AddRequest(req) 111 | assert.NoError(t, err) 112 | } 113 | // test request retrieval 114 | reqs, err := st.GetRequestsByUrl(urlsList[0].ID) 115 | assert.NoError(t, err, "Error retrieving requests from database") 116 | assert.Equal(t, 3, len(reqs), "Mismatch between number of inserted and retrieved requests") 117 | 118 | urlsByTime, err := st.GetUserRequestsInPeriod(urlsList[0].ID, time.Now().Add(-time.Minute*3), time.Now()) 119 | assert.NoError(t, err) 120 | assert.Equal(t, 3, len(urlsByTime.Requests), "error getting urls filtered by time") 121 | } 122 | -------------------------------------------------------------------------------- /handler/url.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | "github.com/labstack/echo/v4" 6 | "github.com/smf8/http-monitor/common" 7 | "github.com/smf8/http-monitor/model" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | // TODO: add pagination support 14 | // FetchURLs is used to retrieve a user's urls 15 | // accessible with GET /api/urls 16 | func (h *Handler) FetchURLs(c echo.Context) error { 17 | userID := extractID(c) 18 | urls, err := h.st.GetURLsByUser(userID) 19 | if err != nil { 20 | return common.NewRequestError("Error retrieving urls from database, maybe check your token again", err, http.StatusBadRequest) 21 | } 22 | resp := newURLListResponse(urls) 23 | return c.JSON(http.StatusOK, NewResponseData(resp)) 24 | } 25 | 26 | // CreateURL is used to add a url to monitor service 27 | // urls are validated and if there isn't any error a response code 201 is returned 28 | // json request format: 29 | // 30 | //{ 31 | // "address": "http://google.com", 32 | // "threshold": 10 33 | //} 34 | func (h *Handler) CreateURL(c echo.Context) error { 35 | userID := extractID(c) 36 | req := &urlCreateRequest{} 37 | url := &model.URL{} 38 | 39 | if err := req.bind(c, url); err != nil { 40 | return err 41 | } 42 | url.UserId = userID 43 | // adding url to database 44 | if err := h.st.AddURL(url); err != nil { 45 | // internal error 46 | return common.NewRequestError("error adding url to database", err, http.StatusInternalServerError) 47 | } 48 | // adding url to monitor scheduler 49 | h.sch.Mnt.AddURL([]model.URL{*url}) 50 | return c.JSON(http.StatusCreated, NewResponseData("URL created successfully")) 51 | } 52 | 53 | // DismissAlert updates a url inside database, resetting it's "failed_times" to 0 54 | // returns an error in case of bad format request or invalid url_id 55 | // json request format: 56 | // 57 | //{ 58 | // "url_id": id 59 | //} 60 | func (h *Handler) DismissAlert(c echo.Context) error { 61 | userID := extractID(c) 62 | urlID, err := strconv.Atoi(c.Param("urlID")) 63 | if err != nil { 64 | return common.NewRequestError("Invalid path parameter", err, http.StatusBadRequest) 65 | } 66 | url, err := h.st.GetURLById(uint(urlID)) 67 | if err != nil { 68 | return common.NewRequestError("error updating url status, invalid url id", err, http.StatusBadRequest) 69 | } 70 | if url.UserId != userID { 71 | return common.NewRequestError("operation not permitted", errors.New("user is not the owner of url"), http.StatusUnauthorized) 72 | } 73 | _ = h.st.DismissAlert(uint(urlID)) 74 | return c.JSON(http.StatusOK, NewResponseData("URL status updated")) 75 | } 76 | 77 | // GetURLStats reports stats of a url 78 | // returns error in case of invalid url_id or unauthenticated request 79 | // param request format : 80 | // 81 | // /api/urls/:urlID 82 | // you can also specify time intervals to get stats in 83 | // just use unix timestamp with the syntax below (to_time is optional): 84 | // /api/urls/:urlID?from_time=1579184689[&to_time] 85 | func (h *Handler) GetURLStats(c echo.Context) error { 86 | userID := extractID(c) 87 | urlID, err := strconv.Atoi(c.Param("urlID")) 88 | if err != nil { 89 | return common.NewRequestError("Invalid path parameter", err, http.StatusBadRequest) 90 | } 91 | 92 | req := &urlStatusRequest{} 93 | url := new(model.URL) 94 | if err := req.parse(c); err != nil { 95 | return err 96 | } 97 | if req.FromTime != 0 { 98 | if req.ToTime == 0 { 99 | req.ToTime = time.Now().Unix() 100 | } 101 | from := time.Unix(req.FromTime, 0) 102 | to := time.Unix(req.ToTime, 0) 103 | url, err = h.st.GetUserRequestsInPeriod(uint(urlID), from, to) 104 | } else { 105 | url, err = h.st.GetURLById(uint(urlID)) 106 | } 107 | if err != nil { 108 | return common.NewRequestError("error retrieving url stats, invalid url id", err, http.StatusBadRequest) 109 | } 110 | if url.UserId != userID { 111 | return common.NewRequestError("operation not permitted", errors.New("user is not the owner of url"), http.StatusUnauthorized) 112 | } 113 | return c.JSON(http.StatusOK, NewResponseData(newRequestListResponse(url.Requests, url.Address))) 114 | } 115 | 116 | // DeleteURL deletes a url with given id 117 | // returns error if url_id is invalid or user can't modify this url 118 | // request format : 119 | // 120 | // DELETE /api/urls/:urlID 121 | func (h *Handler) DeleteURL(c echo.Context) error { 122 | userID := extractID(c) 123 | urlID, err := strconv.Atoi(c.Param("urlID")) 124 | if err != nil { 125 | return common.NewRequestError("Invalid path parameter", err, http.StatusBadRequest) 126 | } 127 | url, err := h.st.GetURLById(uint(urlID)) 128 | if err != nil { 129 | return common.NewRequestError("error retrieving url stats, invalid url id", err, http.StatusBadRequest) 130 | } 131 | if url.UserId != userID { 132 | return common.NewRequestError("operation not permitted", errors.New("user is not the owner of url"), http.StatusUnauthorized) 133 | } 134 | _ = h.st.DeleteURL(uint(urlID)) 135 | _ = h.sch.Mnt.RemoveURL(*url) 136 | return c.JSON(http.StatusOK, NewResponseData("URL deleted successfully.")) 137 | } 138 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | "github.com/jinzhu/gorm" 6 | "github.com/smf8/http-monitor/model" 7 | "time" 8 | ) 9 | 10 | type Store struct { 11 | db *gorm.DB 12 | } 13 | 14 | func NewStore(db *gorm.DB) *Store { 15 | return &Store{db: db} 16 | } 17 | 18 | // GetUserByUserName retrieves user from database based on it's ID 19 | // this method loads user's URLs and Requests lists 20 | // returns error if user was not found 21 | func (s *Store) GetUserByUserName(username string) (*model.User, error) { 22 | user := new(model.User) 23 | // remove pre loading in the future if necessary 24 | if err := s.db.Preload("Urls").Preload("Urls.Requests").First(user, model.User{Username: username}).Error; err != nil { 25 | return nil, err 26 | } 27 | return user, nil 28 | } 29 | 30 | // GetUserById retrieves a user from database with given id 31 | // returns error if user was not found 32 | func (s *Store) GetUserByID(id uint) (*model.User, error) { 33 | usr := &model.User{} 34 | usr.ID = id 35 | if err := s.db.Model(usr).Preload("Urls").Find(usr).Error; err != nil { 36 | return nil, err 37 | } 38 | return usr, nil 39 | } 40 | 41 | // GetAllUsers retrieves all users from database 42 | func (s *Store) GetAllUsers() ([]model.User, error) { 43 | var users []model.User 44 | if err := s.db.Find(&users).Error; err != nil { 45 | return nil, err 46 | } 47 | return users, nil 48 | } 49 | 50 | // AddUser add's a user to the database 51 | func (s *Store) AddUser(user *model.User) error { 52 | return s.db.Create(user).Error 53 | } 54 | 55 | // AddURL add's a url to the database 56 | func (s *Store) AddURL(url *model.URL) error { 57 | return s.db.Create(url).Error 58 | } 59 | 60 | func (s *Store) GetAllURLs() ([]model.URL, error) { 61 | var urls []model.URL 62 | if err := s.db.Model(&model.URL{}).Find(&urls).Error; err != nil { 63 | return nil, err 64 | } 65 | return urls, nil 66 | } 67 | 68 | // GetURLById retrieves a URL from database based on it's ID 69 | // returns error if an URL was not fount 70 | func (s *Store) GetURLById(id uint) (*model.URL, error) { 71 | url := new(model.URL) 72 | if err := s.db.Preload("Requests").First(url, id).Error; err != nil { 73 | return nil, err 74 | } 75 | return url, nil 76 | } 77 | 78 | // GetURLByUser retrieves urls for this user 79 | // returns error if nothing was found 80 | func (s *Store) GetURLsByUser(userID uint) ([]model.URL, error) { 81 | var urls []model.URL 82 | if err := s.db.Model(&model.URL{}).Where("user_id == ?", userID).Find(&urls).Error; err != nil { 83 | return nil, err 84 | } 85 | return urls, nil 86 | } 87 | 88 | //UpdateURL updates a URL to it's new value 89 | func (s *Store) UpdateURL(url *model.URL) error { 90 | return s.db.Model(url).Update(url).Error 91 | } 92 | 93 | // DeleteURL deletes a url with it's requests from database 94 | // returns an error if url was not found 95 | func (s *Store) DeleteURL(urlID uint) error { 96 | url := &model.URL{} 97 | url.ID = urlID 98 | // for hard deleting user s.db.Unscoped() 99 | q := s.db.Model(url).Preload("Requests").Delete(&model.Request{}, "url_id == ?", urlID).Delete(url) 100 | if q.Error != nil { 101 | return q.Error 102 | } 103 | if q.RowsAffected == 0 { 104 | return errors.New("no rows found to delete at delete url") 105 | } 106 | return nil 107 | } 108 | 109 | //DismissAlert sets "FailedTimes" value to 0 and updates it's record in database 110 | // https://github.com/jinzhu/gorm/issues/202#issuecomment-52582525 111 | func (s *Store) DismissAlert(urlID uint) error { 112 | url := &model.URL{} 113 | url.ID = urlID 114 | return s.db.Model(url).Update("failed_times", 0).Error 115 | } 116 | 117 | // FetchAlerts retrieves urls which "failed_times" is greater than it's "threshold" for given userID 118 | // TODO: write tests for this function 119 | func (s *Store) FetchAlerts(userID uint) ([]model.URL, error) { 120 | var urls []model.URL 121 | if err := s.db.Model(&model.URL{}).Where("user_id == ? and failed_times >= threshold", userID).Find(urls).Error; err != nil { 122 | return nil, err 123 | } 124 | return urls, nil 125 | } 126 | 127 | //IncrementFailed increments failed_times of a URL 128 | func (s *Store) IncrementFailed(url *model.URL) error { 129 | url.FailedTimes += 1 130 | return s.UpdateURL(url) 131 | } 132 | 133 | // AddRequest adds a request to database 134 | func (s *Store) AddRequest(req *model.Request) error { 135 | return s.db.Create(req).Error 136 | } 137 | 138 | // GetRequestByUrl retrieves all requests for this url 139 | func (s *Store) GetRequestsByUrl(urlID uint) ([]model.Request, error) { 140 | var requests []model.Request 141 | if err := s.db.Model(&model.Request{UrlId: urlID}).Where("url_id == ?", urlID).Find(&requests).Error; err != nil { 142 | return nil, err 143 | } 144 | return requests, nil 145 | } 146 | 147 | // GetUserRequestsInPeriod retrieves requests between 2 time intervals 148 | func (s *Store) GetUserRequestsInPeriod(urlID uint, from, to time.Time) (*model.URL, error) { 149 | url := &model.URL{} 150 | url.ID = urlID 151 | if err := s.db.Model(url).Preload("Requests", "created_at >= ? and created_at <= ?", from, to).First(url).Error; err != nil { 152 | return nil, err 153 | } 154 | return url, nil 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-monitor 2 | 3 | [![Build Status](https://cloud.drone.io/api/badges/smf8/http-monitor/status.svg)](https://cloud.drone.io/smf8/http-monitor) 4 | 5 | A HTTP endpoint monitor service written in go with RESTful API. 6 | 7 | ORM library [Gorm](https://github.com/jinzhu/gorm) 8 | 9 | Web framework [Echo](https://echo.labstack.com/) 10 | 11 | Job queue manager [workerpool](https://github.com/gammazero/workerpool) 12 | 13 | struct and data validator [Govalidator](https://github.com/asaskevich/govalidator) 14 | 15 | - [Installation](#Installation) 16 | - [Database](#Database) 17 | - [API](#API) 18 | - [Package Structure](#Package-Structure) 19 | 20 | ## Installation 21 | 22 | - Make sure go is installed properly. 23 | 24 | - Download project into your GOPATH 25 | 26 | ``` 27 | $ go get -u -v github.com/smf8/http-monitor 28 | $ cd $GOPATH/src/github.com/smf8/http-monitor 29 | ``` 30 | 31 | - Build project using `go build main.go` 32 | 33 | - You can also run the project using docker-compose by running `docker-compose up` ( change container port in `docker-compose.yml`) 34 | 35 | ## Database 36 | 37 | #### Tables : 38 | 39 | **Users:** 40 | 41 | | id(pk) | created_at | updated_at | deleted_at | username | password | 42 | | :------ | ---------- | ---------- | ---------- | ------------ | ------------ | 43 | | integer | datetime | datetime | datetime | varchar(255) | varchar(255) | 44 | 45 | **URLs:** 46 | 47 | | id(pk) | created_at | updated_at | deleted_at | user_id(fk) | address | threshold | failed_times | 48 | | ------- | ---------- | ---------- | ---------- | ----------- | ------------ | --------- | :----------- | 49 | | integer | datetime | datetime | datetime | integer | varchar(255) | integer | integer | 50 | 51 | **Requests:** 52 | 53 | | id(pk) | created_at | updated_at | deleted_at | url_id(fk) | result | 54 | | ------- | ---------- | ---------- | ---------- | ---------- | ------- | 55 | | integer | datetime | datetime | datetime | integer | integer | 56 | 57 | ## API 58 | 59 | ### Specs: 60 | 61 | For all requests and responses we have `Content-Type: application/json`. 62 | 63 | Authorization is with JWT. 64 | 65 | #### User endpoints: 66 | 67 | **Login:** 68 | 69 | `POST /api/users/login` 70 | 71 | request structure: 72 | 73 | ``` 74 | { 75 | "username":"foo", // alpha numeric, length >= 4 76 | "password":"*bar*" // text, length >=4 77 | } 78 | ``` 79 | 80 | **Sign Up:** 81 | 82 | `POST /api/users` 83 | 84 | request structure (same as login): 85 | 86 | ``` 87 | { 88 | "username":"foo", // alpha numeric, length >= 4 89 | "password":"*bar*" // text, length >=4 90 | } 91 | ``` 92 | 93 | #### URL endpoints: 94 | 95 | **Create URL:** 96 | 97 | `POST /api/urls` 98 | 99 | request structure: 100 | 101 | ``` 102 | { 103 | "address":"http://some-valid-url.com" // valid url address 104 | "threshold":20 // url fail threshold 105 | } 106 | ``` 107 | 108 | ##### **Get user URLs:** 109 | 110 | `GET /api/urls` 111 | 112 | **Get URL stats:** 113 | 114 | `GET /api/urls/:urlID?from_time&to_time` 115 | 116 | `urlID` a valid url id 117 | 118 | `from_time` a starting time in unix time format(Optional, `to_time` only is not allowed) 119 | 120 | `to_time` an ending time in unix time format.(Optoinal) 121 | 122 | **Delete URL:** 123 | 124 | `DELETE /api/urls/:urlID` 125 | 126 | `urlID` a valid url id to be deleted 127 | 128 | **Get URL alerts:** 129 | 130 | `GET /api/alerts` 131 | 132 | **Dismiss URL alerts:** 133 | 134 | `PUT /api/alerts/:urlID` 135 | 136 | `urlID` a valid url. **This endpoint reset given url's failed_times to 0 ** 137 | 138 | #### Responses: 139 | 140 | ##### Errors: 141 | 142 | If there was an error during processing the request, a json response with the following format is returned with related response code: 143 | 144 | ``` 145 | { 146 | "errors":{ 147 | "key":"value" // a list of key,value of errors occurred 148 | } 149 | } 150 | ``` 151 | 152 | ##### URL stat: 153 | 154 | ``` 155 | { 156 | "data": { 157 | "url": "http://google.com", 158 | "requests_count": 1, 159 | "requests": [ 160 | { 161 | "result_code": 200, 162 | "created_at": "2019-01-16T14:07:25.443300581+03:30" 163 | } 164 | ] 165 | } 166 | } 167 | ``` 168 | 169 | ##### List of URLs: 170 | 171 | ``` 172 | { 173 | "data": { 174 | "url_count": 1, 175 | "urls": [ 176 | { 177 | "id": 0, 178 | "url": "http://google.com", 179 | "user_id": 1, 180 | "created_at": "2020-01-16T14:07:15.066047519+03:30", 181 | "threshold": 10, 182 | "failed_times": 0 183 | } 184 | ] 185 | } 186 | } 187 | ``` 188 | 189 | ##### Request report: 190 | 191 | ``` 192 | { 193 | "data": "A message with report" 194 | } 195 | ``` 196 | 197 | 198 | 199 | ## Package Structure 200 | 201 | ``` 202 | ├── common // common package for commonly used functions 203 | │   ├── erros.go 204 | │   └── jwt.go 205 | ├── db // database creation and initialization 206 | │   └── db.go 207 | ├── handler // handler package for routing and request handling 208 | │   ├── handler.go 209 | │   ├── request.go 210 | │   ├── response.go 211 | │   ├── routes.go 212 | │   ├── url.go 213 | │   └── user.go 214 | ├── main.go // main entry of application 215 | ├── middleware // middlewares used in API 216 | │   └── jwt.go 217 | ├── model // data types used in application 218 | │   ├── model_test.go 219 | │   ├── url.go 220 | │   └── user.go 221 | ├── monitor // monitor package to handle url monitoring and scheduling 222 | │   ├── monitor.go 223 | │   ├── monitor_test.go 224 | │   └── scheduler.go 225 | └── store // a layer for model-database interactions 226 | ├── store.go 227 | └── store_test.go 228 | ``` 229 | 230 | #### TODO 231 | 232 | - [ ] Refactor error management system 233 | - [ ] Improve scheduler to accept different intervals 234 | - [ ] Integrate project with a configuration library -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU= 4 | cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= 5 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 6 | github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= 7 | github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= 8 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 9 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 10 | github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= 11 | github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= 12 | github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= 13 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 14 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= 19 | github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= 20 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 21 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 22 | github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= 23 | github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= 24 | github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= 25 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 26 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 27 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 28 | github.com/gammazero/deque v0.0.0-20190521012701-46e4ffb7a622 h1:lxbhOGZ9pU3Kf8P6lFluUcE82yVZn2EqEf4+mWRNPV0= 29 | github.com/gammazero/deque v0.0.0-20190521012701-46e4ffb7a622/go.mod h1:D90+MBHVc9Sk1lJAbEVgws0eYEurY4mv2TDso3Nxh3w= 30 | github.com/gammazero/workerpool v0.0.0-20200108033143-79b2336fad7a h1:9diiB3IwqsdHNhROa3AIrAyT6Gp+WjgjhO0azrxXE6U= 31 | github.com/gammazero/workerpool v0.0.0-20200108033143-79b2336fad7a/go.mod h1:ZObaTlXZGgqKXhhlk+zNvSOXT+h6VGThA0ZQxLqn8x0= 32 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 33 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 34 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 35 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 36 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 37 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 38 | github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 39 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 40 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 41 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 42 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 43 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 44 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 45 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 46 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 47 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 48 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 49 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 50 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 51 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 52 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 53 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 54 | github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE= 55 | github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw= 56 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 57 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 58 | github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= 59 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 60 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 61 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 62 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 63 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 64 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 65 | github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= 66 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 67 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 68 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 69 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 70 | github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= 71 | github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= 72 | github.com/labstack/echo/v4 v4.1.13 h1:JYgKq6NQQSaKbQcsOadAKX1kUVLCUzLGwu8sxN5tC34= 73 | github.com/labstack/echo/v4 v4.1.13/go.mod h1:3WZNypykZ3tnqpF2Qb4fPg27XDunFqgP3HGDmCMgv7U= 74 | github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= 75 | github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= 76 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 77 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 78 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 79 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 80 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 81 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 82 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 83 | github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= 84 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 85 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 86 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 87 | github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= 88 | github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 89 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 90 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 91 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 92 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 93 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 94 | github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= 95 | github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 96 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 97 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 98 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 99 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 100 | github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= 101 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 102 | github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 103 | github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 104 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 105 | github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 106 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 107 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 108 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 109 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 110 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 111 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 112 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 113 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 114 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 115 | github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= 116 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 117 | github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4= 118 | github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 119 | go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= 120 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 121 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 122 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI= 123 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 124 | golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876 h1:sKJQZMuxjOAR/Uo2LBfU90onWEf1dF4C+0hPJCc9Mpc= 125 | golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 126 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 127 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 128 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 129 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 130 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 131 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 132 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 133 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 134 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 135 | golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 136 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 137 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 138 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 139 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= 140 | golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 141 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 142 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 143 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 144 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 145 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 146 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 147 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 148 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 149 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 150 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 151 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 152 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 153 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 154 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 155 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= 156 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 158 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM= 159 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 161 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 162 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 163 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 164 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 165 | golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 166 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 167 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 168 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 169 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 170 | google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= 171 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 172 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 173 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 174 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 175 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 176 | google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 177 | google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= 178 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 179 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 180 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 181 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 182 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 183 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 184 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 185 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 186 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 187 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 188 | honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 189 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 190 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 191 | --------------------------------------------------------------------------------