commit 5f26ad6941b030d9d1e0b9fb4fe22b23c6458d22 Author: ostiwe Date: Sun Jul 20 23:50:47 2025 +0300 init: first steps diff --git a/.env b/.env new file mode 100644 index 0000000..538702c --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DATABASE_HOST= +DATABASE_PORT= +DATABASE_USER= +DATABASE_PASS= +DATABASE_DB= +DATABASE_TZ=Europe/Moscow \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49b25ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +cmake-build-*/ +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.idea/ +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +go.work.sum +.env.* +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..39e60bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + + postgresql: + image: postgres:latest + environment: + - POSTGRES_USER=status + - POSTGRES_DB=status + - POSTGRES_PASSWORD=status + - POSTGRES_HOST_AUTH_METHOD=trust + volumes: + - database_postgres:/var/lib/postgresql/data + ports: + - "5444:5432" + +volumes: + database_postgres: \ No newline at end of file diff --git a/dto/service.go b/dto/service.go new file mode 100644 index 0000000..6fc0f90 --- /dev/null +++ b/dto/service.go @@ -0,0 +1,9 @@ +package dto + +import "git.ostiwe.com/ostiwe-com/status/model" + +type PublicService struct { + Name string `json:"name"` + Description string `json:"description"` + Statuses []model.Status `json:"statuses"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fc5979f --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module git.ostiwe.com/ostiwe-com/status + +go 1.24.2 + +require ( + github.com/alexflint/go-arg v1.6.0 + github.com/go-andiamo/chioas v1.16.4 + github.com/go-chi/chi/v5 v5.2.2 + github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 + go.uber.org/mock v0.5.2 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.30.0 +) + +require ( + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/go-andiamo/splitter v1.2.5 // indirect + github.com/go-andiamo/urit v1.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..958b992 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo= +github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-andiamo/chioas v1.16.4 h1:aHtA3KLmfQfHRsGxjYPNjrD8cMR2uTncbjvcxY64B3Q= +github.com/go-andiamo/chioas v1.16.4/go.mod h1:5ZZYYuGwlF/amxErKFUu3eXz6hZ5GEYu5vCk+Guw+uc= +github.com/go-andiamo/splitter v1.2.5 h1:P3NovWMY2V14TJJSolXBvlOmGSZo3Uz+LtTl2bsV/eY= +github.com/go-andiamo/splitter v1.2.5/go.mod h1:8WHU24t9hcMKU5FXDQb1hysSEC/GPuivIp0uKY1J8gw= +github.com/go-andiamo/urit v1.2.1 h1:5JHJb+TuzuGvXw9Y/LK/lQltCL2gpgHkCznI2vv9ZeY= +github.com/go-andiamo/urit v1.2.1/go.mod h1:9kgXBxUPHFZvXwlOPN1GimDuHl+JCfQuetN5nxBNlpQ= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc87094 --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "net/http" + + "git.ostiwe.com/ostiwe-com/status/model" + "git.ostiwe.com/ostiwe-com/status/modules/db" + appLog "git.ostiwe.com/ostiwe-com/status/modules/log" + "git.ostiwe.com/ostiwe-com/status/router" + "git.ostiwe.com/ostiwe-com/status/version" + "github.com/alexflint/go-arg" + "github.com/go-chi/chi/v5" + "github.com/sirupsen/logrus" + + _ "git.ostiwe.com/ostiwe-com/status/settings" +) + +type ServerCmd struct { + Port string `arg:"-p,--port" help:"Port to listen on" default:"8080"` +} + +type ServerDocumentationCmd struct { + Port string `arg:"-p,--port" help:"Port to listen on" default:"8081"` + Plain bool `arg:"--plain" help:"Enable plain text output" default:"true"` + PlainFormat string `arg:"--plain-format" help:"Set format for output (json, yaml)" default:"yaml"` +} + +type appArgs struct { + Server *ServerCmd `arg:"subcommand:server" help:"Start the api server"` + ServerDocumentation *ServerDocumentationCmd `arg:"subcommand:server-docs" help:"Generate documentation for api server"` +} + +var args appArgs + +func (appArgs) Version() string { + return version.AppVersion() +} + +func main() { + arg.MustParse(&args) + + connect, err := db.Connect() + if err != nil { + appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Startup server error, failed connect to database: %v", err)) + return + } + + db.SetGlobal(connect) + appLog.Global.Get(appLog.SYSTEM).Info("Run db migration") + if err = runMigrate(); err != nil { + appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Migration failed, error: %v", err)) + return + } + + if args.Server != nil { + appLog.Global.Put(appLog.SERVER, logrus.New()) + appLog.Global.Get(appLog.SERVER).Info("Startup server on port: ", args.Server.Port) + + err := http.ListenAndServe(fmt.Sprintf(":%s", args.Server.Port), router.InitRoutes()) + if err != nil { + appLog.Global.Get(appLog.SERVER).Error(fmt.Sprintf("Startup server error: %v", err)) + } + + return + } + + if args.ServerDocumentation != nil { + appLog.Global.Get(appLog.SYSTEM).Info("Collect documentation") + + docs := router.Documentate() + if !args.ServerDocumentation.Plain { + chiRouter := chi.NewRouter() + + err := docs.SetupRoutes(chiRouter, docs) + if err != nil { + appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Setup docs routes error: %v", err)) + return + } + + appLog.Global.Get(appLog.SYSTEM).Info(fmt.Sprintf("Start documentation server on port: %s", args.ServerDocumentation.Port)) + err = http.ListenAndServe(fmt.Sprintf(":%s", args.ServerDocumentation.Port), chiRouter) + if err != nil { + appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Startup server error: %v", err)) + } + + return + } + + } + + appLog.Global.Get(appLog.SYSTEM).Info("Exit from application") +} + +func runMigrate() error { + return db.Global.AutoMigrate( + model.Service{}, + model.Status{}, + ) +} diff --git a/model/service.go b/model/service.go new file mode 100644 index 0000000..b721924 --- /dev/null +++ b/model/service.go @@ -0,0 +1,34 @@ +package model + +type HTTPConfig struct { + Authorization string `json:"authorization"` +} + +type ServiceTypeCheckConfig struct { + Version string `json:"version"` + HTTPConfig *HTTPConfig `json:"http_config"` +} + +type Service struct { + // Unique ID for entity + ID int `gorm:"primary_key;auto_increment" json:"id"` + // Human-readable service name + Name string `gorm:"size:255;not null" json:"name"` + // Human-readable service description + Description string `gorm:"size:255" json:"description"` + PublicDescription string `gorm:"size:255" json:"public_description"` + Public *bool `gorm:"default:false" json:"public"` + // Host to check, for example 192.168.1.44 + Host string `gorm:"size:255;not null" json:"host"` + // Port to check, for example 5432 (postgresql) + Port *int `gorm:"default:null" json:"port"` + // Type for check, for now is TCP or HTTP + Type string `gorm:"size:255;not null" json:"type"` + TypeConfig *ServiceTypeCheckConfig `gorm:"serializer:json" json:"type_config"` + + Statuses []Status `gorm:"foreignkey:ServiceID" json:"statuses"` +} + +func (Service) TableName() string { + return "service" +} diff --git a/model/status.go b/model/status.go new file mode 100644 index 0000000..2b98e10 --- /dev/null +++ b/model/status.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type Status struct { + ID int `gorm:"primary_key;auto_increment" json:"-"` + ServiceID int `gorm:"one" json:"-"` + Status string `gorm:"size:255;not null" json:"status"` + Description *string `gorm:"size:255" json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +func (Status) TableName() string { + return "status" +} + +func (s *Status) BeforeCreate(*gorm.DB) error { + s.CreatedAt = time.Now() + + return nil +} diff --git a/modules/db/db.go b/modules/db/db.go new file mode 100644 index 0000000..d03585a --- /dev/null +++ b/modules/db/db.go @@ -0,0 +1,53 @@ +package db + +import ( + "fmt" + "os" + "time" + + appLog "git.ostiwe.com/ostiwe-com/status/modules/log" + "github.com/sirupsen/logrus" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var Global *gorm.DB + +func init() { + appLog.Global.Put(appLog.DATABASE, logrus.New()) +} + +func Connect() (*gorm.DB, error) { + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=%s", + os.Getenv("DATABASE_HOST"), + os.Getenv("DATABASE_USER"), + os.Getenv("DATABASE_PASS"), + os.Getenv("DATABASE_DB"), + os.Getenv("DATABASE_PORT"), + os.Getenv("DATABASE_TZ"), + ) + + newLogger := logger.New( + appLog.Global.Get(appLog.DATABASE), + logger.Config{ + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: logger.Info, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + ParameterizedQueries: false, // Don't include params in the SQL log + Colorful: false, // Disable color + }, + ) + + return gorm.Open( + postgres.Open(dsn), + &gorm.Config{ + Logger: newLogger, + }, + ) +} + +func SetGlobal(bd *gorm.DB) { + Global = bd +} diff --git a/modules/log/formatter.go b/modules/log/formatter.go new file mode 100644 index 0000000..e7ea531 --- /dev/null +++ b/modules/log/formatter.go @@ -0,0 +1,28 @@ +package log + +import ( + "fmt" + + "github.com/sirupsen/logrus" +) + +type TextFormatter struct { + logrusFormatter logrus.TextFormatter + prefix []byte +} + +func NewTextFormatter(prefix string) *TextFormatter { + return &TextFormatter{ + logrusFormatter: logrus.TextFormatter{}, + prefix: []byte(fmt.Sprintf("[%s] ", prefix)), + } +} + +func (t *TextFormatter) Format(item *logrus.Entry) ([]byte, error) { + original, err := t.logrusFormatter.Format(item) + if err != nil { + return nil, err + } + + return append(t.prefix, original...), nil +} diff --git a/modules/log/manager.go b/modules/log/manager.go new file mode 100644 index 0000000..6e507ed --- /dev/null +++ b/modules/log/manager.go @@ -0,0 +1,47 @@ +package log + +import ( + "sync" + + "github.com/sirupsen/logrus" +) + +const ( + SERVER = "server" + SYSTEM = "system" + DATABASE = "database" +) + +var Global *LoggerManager + +func SetGlobalManager(manager *LoggerManager) { + Global = manager +} + +type LoggerManager struct { + loggers map[string]*logrus.Logger + mu sync.Mutex +} + +func (m *LoggerManager) Get(name string) *logrus.Logger { + if logger, ok := m.loggers[name]; ok { + return logger + } + + return nil +} + +func (m *LoggerManager) Put(name string, logger *logrus.Logger) { + m.mu.Lock() + defer m.mu.Unlock() + + logger.Formatter = NewTextFormatter(name) + + m.loggers[name] = logger +} + +func NewManager() *LoggerManager { + return &LoggerManager{ + loggers: make(map[string]*logrus.Logger), + } +} diff --git a/pkg/http/errors.go b/pkg/http/errors.go new file mode 100644 index 0000000..811eeb1 --- /dev/null +++ b/pkg/http/errors.go @@ -0,0 +1,118 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5/middleware" +) + +type ResponseErr struct { + Message string `json:"message"` + Trace string `json:"trace"` + Details map[string]any `json:"details"` +} + +type ResponseErrReadyToSend interface { + Send(http.ResponseWriter) error +} + +type ResponseErrBuilder interface { + WithDetails(map[string]any) ResponseErrBuilder + WithMessage(string) ResponseErrBuilder + WithStatusCode(int) ResponseErrBuilder + WithTrace(string) ResponseErrBuilder + Ready() ResponseErrReadyToSend + Send(http.ResponseWriter, *http.Request) error +} + +type responseErrBuilder struct { + details map[string]any + message string + status int + trace string + + ready *ResponseErr +} + +type readyResponseErr struct { + builder responseErrBuilder +} + +func (r readyResponseErr) Send(response http.ResponseWriter) error { + format, err := json.Marshal(r.builder.ready) + if err != nil { + return err + } + + if r.builder.status == 0 { + r.builder.status = http.StatusInternalServerError + } + + response.WriteHeader(r.builder.status) + + _, err = response.Write(format) + + return err +} + +func (r responseErrBuilder) WithTrace(s string) ResponseErrBuilder { + r.trace = s + return r +} + +func (r responseErrBuilder) WithStatusCode(i int) ResponseErrBuilder { + r.status = i + return r +} + +func NewResponseErrBuilder() ResponseErrBuilder { + return &responseErrBuilder{} +} + +func (r responseErrBuilder) WithDetails(m map[string]any) ResponseErrBuilder { + r.details = m + return r +} + +func (r responseErrBuilder) WithMessage(s string) ResponseErrBuilder { + r.message = s + return r +} + +func (r responseErrBuilder) Ready() ResponseErrReadyToSend { + r.ready = &ResponseErr{ + Message: r.message, + Trace: r.trace, + Details: r.details, + } + + return readyResponseErr{ + builder: r, + } +} + +func (r responseErrBuilder) Send(response http.ResponseWriter, request *http.Request) error { + if r.ready == nil { + r.ready = &ResponseErr{ + Message: r.message, + Trace: middleware.GetReqID(request.Context()), + Details: r.details, + } + } + + format, err := json.Marshal(r.ready) + if err != nil { + return err + } + + if r.status == 0 { + r.status = http.StatusInternalServerError + } + + response.WriteHeader(r.status) + + _, err = response.Write(format) + + return err +} diff --git a/pkg/http/extract.go b/pkg/http/extract.go new file mode 100644 index 0000000..155777a --- /dev/null +++ b/pkg/http/extract.go @@ -0,0 +1,46 @@ +package http + +import ( + "net/http" + "strconv" +) + +func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) { + limit, offset := r.URL.Query().Get("limit"), r.URL.Query().Get("offset") + + if limit == "" { + limit = "20" + } + + if offset == "" { + offset = "0" + } + + limitInt, err := strconv.Atoi(limit) + if err != nil { + writeErr := NewResponseErrBuilder(). + WithMessage("Incorrect limit parameter"). + WithDetails(map[string]interface{}{ + "err": err.Error(), + }). + WithStatusCode(http.StatusBadRequest). + Ready() + + return 0, 0, writeErr + } + + offsetInt, err := strconv.Atoi(offset) + if err != nil { + writeErr := NewResponseErrBuilder(). + WithMessage("Incorrect offset parameter"). + WithDetails(map[string]interface{}{ + "err": err.Error(), + }). + WithStatusCode(http.StatusBadRequest). + Ready() + + return 0, 0, writeErr + } + + return limitInt, offsetInt, nil +} diff --git a/pkg/http/response.go b/pkg/http/response.go new file mode 100644 index 0000000..06772f6 --- /dev/null +++ b/pkg/http/response.go @@ -0,0 +1,19 @@ +package http + +import ( + "encoding/json" + "net/http" +) + +func JSON(w http.ResponseWriter, i any, httpCode int) error { + response, err := json.Marshal(i) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpCode) + _, err = w.Write(response) + + return err +} diff --git a/repository/mocks/service.go b/repository/mocks/service.go new file mode 100644 index 0000000..535c332 --- /dev/null +++ b/repository/mocks/service.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: service.go +// +// Generated by this command: +// +// mockgen -source=service.go -destination=mocks/service.go +// + +// Package mock_repository is a generated GoMock package. +package mock_repository + +import ( + context "context" + reflect "reflect" + + model "git.ostiwe.com/ostiwe-com/status/model" + gomock "go.uber.org/mock/gomock" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder + isgomock struct{} +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// All mocks base method. +func (m *MockService) All(ctx context.Context, limit, offset int) ([]model.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "All", ctx, limit, offset) + ret0, _ := ret[0].([]model.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// All indicates an expected call of All. +func (mr *MockServiceMockRecorder) All(ctx, limit, offset any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "All", reflect.TypeOf((*MockService)(nil).All), ctx, limit, offset) +} diff --git a/repository/repository.go b/repository/repository.go new file mode 100644 index 0000000..baea615 --- /dev/null +++ b/repository/repository.go @@ -0,0 +1,7 @@ +package repository + +import "gorm.io/gorm" + +type repository struct { + db *gorm.DB +} diff --git a/repository/service.go b/repository/service.go new file mode 100644 index 0000000..c8cac92 --- /dev/null +++ b/repository/service.go @@ -0,0 +1,50 @@ +package repository + +//go:generate mockgen -source=$GOFILE -destination=mocks/$GOFILE + +import ( + "context" + "time" + + "git.ostiwe.com/ostiwe-com/status/model" + "git.ostiwe.com/ostiwe-com/status/modules/db" + "gorm.io/gorm" +) + +type Service interface { + All(ctx context.Context, limit, offset int, publicOnly bool) ([]model.Service, error) +} + +type service struct { + repository +} + +func NewServiceRepository() Service { + return &service{ + repository{db: db.Global}, + } +} + +func (s *service) All(ctx context.Context, limit, offset int, publicOnly bool) ([]model.Service, error) { + items := make([]model.Service, 0) + + query := s.db. + WithContext(ctx). + Preload("Statuses", func(db *gorm.DB) *gorm.DB { + return db. + Where("created_at > ?", time.Now().Truncate(24*time.Hour)). + Order("created_at desc") + }) + + if publicOnly { + query = query.Where("public = ?", true) + } + + query = query. + Limit(limit). + Offset(offset). + Find(&items) + + return items, query. + Error +} diff --git a/router/controller/controller.go b/router/controller/controller.go new file mode 100644 index 0000000..de9b44d --- /dev/null +++ b/router/controller/controller.go @@ -0,0 +1,12 @@ +package controller + +import ( + "github.com/go-andiamo/chioas" + "github.com/go-chi/chi/v5" +) + +type Controller interface { + New() Controller + Group(r chi.Router) + Documentate() (*chioas.Paths, *chioas.Components) +} diff --git a/router/controller/ping/controller.go b/router/controller/ping/controller.go new file mode 100644 index 0000000..4e4d67e --- /dev/null +++ b/router/controller/ping/controller.go @@ -0,0 +1,57 @@ +package ping + +import ( + "net/http" + + "git.ostiwe.com/ostiwe-com/status/modules/log" + "git.ostiwe.com/ostiwe-com/status/router/controller" + "github.com/go-andiamo/chioas" + "github.com/go-chi/chi/v5" +) + +type Controller struct { +} + +func (Controller) New() controller.Controller { + return &Controller{} +} + +func (c *Controller) Group(r chi.Router) { + + r.Get("/ping", c.PingHandler) +} + +func (c *Controller) PingHandler(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("pong")) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + return + } +} + +func (c *Controller) Documentate() (*chioas.Paths, *chioas.Components) { + return &chioas.Paths{ + "/ping": chioas.Path{ + Methods: map[string]chioas.Method{ + http.MethodGet: { + Handler: c.PingHandler, + Responses: chioas.Responses{ + http.StatusOK: { + ContentType: "plain/text", + Schema: chioas.Schema{Type: "string"}, + Examples: chioas.Examples{ + { + + Name: "200 OK", + Value: "pong", + }, + }, + }, + }, + }, + }, + Tag: "service", + Comment: "Route for check service is alive", + }, + }, nil +} diff --git a/router/controller/service/controller.go b/router/controller/service/controller.go new file mode 100644 index 0000000..336fd37 --- /dev/null +++ b/router/controller/service/controller.go @@ -0,0 +1,106 @@ +package service + +import ( + "net/http" + + "git.ostiwe.com/ostiwe-com/status/modules/log" + http2 "git.ostiwe.com/ostiwe-com/status/pkg/http" + "git.ostiwe.com/ostiwe-com/status/repository" + "git.ostiwe.com/ostiwe-com/status/router/controller" + "git.ostiwe.com/ostiwe-com/status/transform" + "github.com/go-andiamo/chioas" + "github.com/go-chi/chi/v5" +) + +type Controller struct { + serviceRepository repository.Service +} + +func (c Controller) New() controller.Controller { + return &Controller{ + serviceRepository: repository.NewServiceRepository(), + } +} + +func (c *Controller) Group(r chi.Router) { + c.public(r) + c.internal(r) +} + +func (c *Controller) public(r chi.Router) { + r.Get("/api/public/service", c.GetAllServicesPublic) +} + +func (c *Controller) internal(r chi.Router) { + r.Get("/api/v1/service", c.GetAllServices) +} + +func (c *Controller) GetAllServicesPublic(w http.ResponseWriter, r *http.Request) { + limit, offset, errReady := http2.ExtractLimitOffset(r) + if errReady != nil { + err := errReady.Send(w) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + return + } + + return + } + + items, err := c.serviceRepository.All(r.Context(), limit, offset, true) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + + writeErr := http2.NewResponseErrBuilder(). + WithMessage("Fetch service error"). + WithStatusCode(http.StatusInternalServerError). + Send(w, r) + if writeErr != nil { + log.Global.Get(log.SERVER).Error(writeErr) + } + + return + } + + err = http2.JSON(w, transform.PublicServices(items...), http.StatusOK) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + } +} + +func (c *Controller) GetAllServices(w http.ResponseWriter, r *http.Request) { + limit, offset, errReady := http2.ExtractLimitOffset(r) + if errReady != nil { + err := errReady.Send(w) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + return + } + + return + } + + items, err := c.serviceRepository.All(r.Context(), limit, offset, false) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + + writeErr := http2.NewResponseErrBuilder(). + WithMessage("Fetch service error"). + WithStatusCode(http.StatusInternalServerError). + Send(w, r) + if writeErr != nil { + log.Global.Get(log.SERVER).Error(writeErr) + } + + return + } + + err = http2.JSON(w, items, http.StatusOK) + if err != nil { + log.Global.Get(log.SERVER).Error(err) + } +} + +func (c *Controller) Documentate() (*chioas.Paths, *chioas.Components) { + return nil, nil +} diff --git a/router/init.go b/router/init.go new file mode 100644 index 0000000..f67b87a --- /dev/null +++ b/router/init.go @@ -0,0 +1,99 @@ +package router + +import ( + "fmt" + "maps" + "time" + + "git.ostiwe.com/ostiwe-com/status/modules/log" + "git.ostiwe.com/ostiwe-com/status/router/controller" + "git.ostiwe.com/ostiwe-com/status/router/controller/ping" + "git.ostiwe.com/ostiwe-com/status/router/controller/service" + "git.ostiwe.com/ostiwe-com/status/version" + "github.com/go-andiamo/chioas" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func getControllers() []controller.Controller { + return []controller.Controller{ + ping.Controller{}.New(), + service.Controller{}.New(), + } +} + +func InitRoutes() *chi.Mux { + log.Global.Get(log.SERVER).Info("Setting up routers") + startTime := time.Now() + + httpLogger := middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.Global.Get(log.SERVER), NoColor: false}) + + r := chi.NewRouter() + r.Use(httpLogger) + r.Use( + middleware.RequestID, + middleware.RealIP, + middleware.StripSlashes, + middleware.Recoverer, + middleware.CleanPath, + middleware.Timeout(15*time.Second), + ) + + ctrlList := getControllers() + for _, ctrl := range ctrlList { + r.Group(ctrl.Group) + } + + log.Global.Get(log.SERVER).Info(fmt.Sprintf("Initialized %d routers", len(ctrlList))) + log.Global.Get(log.SERVER).Info(fmt.Sprintf("Setting up routers is done for %dms, start server", time.Since(startTime).Milliseconds())) + + return r +} + +func Documentate() chioas.Definition { + ctrlList := getControllers() + apiDoc := chioas.Definition{ + AutoHeadMethods: true, + DocOptions: chioas.DocOptions{ + ServeDocs: true, + HideHeadMethods: true, + }, + Info: chioas.Info{ + Version: version.AppVersion(), + Title: "Status page API Documentation", + }, + Paths: make(chioas.Paths), + Components: &chioas.Components{ + Schemas: make(chioas.Schemas, 0), + Requests: make(chioas.CommonRequests), + Responses: make(chioas.CommonResponses), + Examples: make(chioas.Examples, 0), + Parameters: make(chioas.CommonParameters), + SecuritySchemes: make(chioas.SecuritySchemes, 0), + Extensions: make(chioas.Extensions), + }, + } + + for _, ctrl := range ctrlList { + documentatePaths, components := ctrl.Documentate() + + if documentatePaths != nil { + for path, pathDoc := range *documentatePaths { + apiDoc.Paths[path] = pathDoc + } + } + + if components != nil { + apiDoc.Components.Schemas = append(apiDoc.Components.Schemas, components.Schemas...) + apiDoc.Components.Examples = append(apiDoc.Components.Examples, components.Examples...) + apiDoc.Components.SecuritySchemes = append(apiDoc.Components.SecuritySchemes, components.SecuritySchemes...) + + maps.Copy(apiDoc.Components.Requests, components.Requests) + maps.Copy(apiDoc.Components.Responses, components.Responses) + maps.Copy(apiDoc.Components.Parameters, components.Parameters) + maps.Copy(apiDoc.Components.Extensions, components.Extensions) + } + } + + return apiDoc +} diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..d65faea --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,48 @@ +package settings + +import ( + "os" + "time" + + appLog "git.ostiwe.com/ostiwe-com/status/modules/log" + "github.com/joho/godotenv" + "github.com/sirupsen/logrus" +) + +var ( + AppVersion string + AppStartTime time.Time +) + +func init() { + appLog.SetGlobalManager(appLog.NewManager()) + appLog.Global.Put(appLog.SYSTEM, logrus.New()) + + if AppVersion == "" { + AppVersion = "dev" + } + + AppStartTime = time.Now() + + env := os.Getenv("APP_ENV") + if env == "" { + env = "development" + } + + appLog.Global.Get(appLog.SYSTEM).Info("Load environment variables for ", env, " env") + + err := godotenv.Load() + if err != nil { + appLog.Global.Get(appLog.SYSTEM).Errorf("Error loading .env file %v", err) + appLog.Global.Get(appLog.SYSTEM).Exit(1) + } + + appLog.Global.Get(appLog.SYSTEM).Info("Loaded .env file") + + appLog.Global.Get(appLog.SYSTEM).Info("Try to load .env.local file") + if err = godotenv.Overload(".env.local"); err != nil { + appLog.Global.Get(appLog.SYSTEM).Info("Failed to load .env.local file") + } else { + appLog.Global.Get(appLog.SYSTEM).Info("Loaded .env.local file") + } +} diff --git a/transform/service.go b/transform/service.go new file mode 100644 index 0000000..38db7e5 --- /dev/null +++ b/transform/service.go @@ -0,0 +1,24 @@ +package transform + +import ( + "git.ostiwe.com/ostiwe-com/status/dto" + "git.ostiwe.com/ostiwe-com/status/model" +) + +func PublicServices(items ...model.Service) []dto.PublicService { + result := make([]dto.PublicService, 0, len(items)) + + for _, item := range items { + result = append(result, PublicService(item)) + } + + return result +} + +func PublicService(item model.Service) dto.PublicService { + return dto.PublicService{ + Name: item.Name, + Description: item.Description, + Statuses: item.Statuses, + } +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..c7878da --- /dev/null +++ b/version/version.go @@ -0,0 +1,5 @@ +package version + +func AppVersion() string { + return "0.0.1" +}