├── database ├── repositories │ ├── sql │ │ ├── profile_delete_by_id.sql │ │ ├── profile_get_by_id.sql │ │ ├── profile_insert.sql │ │ └── profile_update.sql │ ├── health_repository.go │ ├── tests │ │ ├── testdata │ │ │ └── 20240816215158_seed.sql │ │ ├── suite_test.go │ │ └── profile_test.go │ └── profile_repository.go ├── migrations │ └── 20240805193305_init_schema.sql ├── tx.go ├── database_test.go ├── README.md └── database.go ├── main.go ├── cmd ├── root.go ├── serve.go ├── migrate.go ├── migrate │ ├── down.go │ ├── up.go │ └── migrate.go └── serve │ └── serve.go ├── pkg ├── errorx │ └── error.go ├── resolver │ └── resolver.go └── testinfra │ └── postgres.go ├── .gitignore ├── internal ├── profile │ ├── request.go │ ├── response.go │ ├── model.go │ ├── service.go │ ├── mock │ │ └── service.go │ └── service_test.go ├── health │ └── health.go ├── base.go └── README.md ├── config ├── default.toml ├── README.md └── config.go ├── api ├── mappers │ ├── health.go │ ├── profile_get.go │ ├── profile_update.go │ ├── profile_create.go │ └── profile_delete.go ├── docs │ ├── swagger.html │ └── v1 │ │ └── api.yaml ├── server.go ├── error.go ├── routes.go ├── router.go ├── README.md ├── handler.go └── bootstrap.go ├── tests ├── testdata │ └── 20240816215158_seed.sql ├── health_test.go ├── suite_test.go └── profile_test.go ├── .dockerignore ├── .air.toml ├── .github └── workflows │ ├── test.yml │ └── lint.yml ├── LICENSE ├── compose.yaml ├── Makefile ├── Dockerfile ├── go.mod ├── README.md └── go.sum /database/repositories/sql/profile_delete_by_id.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM profiles WHERE id = $1; -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/softika/gopherizer/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /database/repositories/sql/profile_get_by_id.sql: -------------------------------------------------------------------------------- 1 | SELECT p.id, p.first_name, p.last_name, p.created_at, p.updated_at 2 | FROM profiles AS p 3 | WHERE p.id = $1; -------------------------------------------------------------------------------- /database/repositories/sql/profile_insert.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO profiles (first_name, last_name, created_at, updated_at) 2 | VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) 3 | RETURNING id, created_at, updated_at; -------------------------------------------------------------------------------- /database/repositories/sql/profile_update.sql: -------------------------------------------------------------------------------- 1 | UPDATE profiles 2 | SET 3 | first_name = $1, 4 | last_name = $2, 5 | updated_at = CURRENT_TIMESTAMP 6 | WHERE id = $3 7 | RETURNING id, created_at, updated_at; -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var rootCmd = &cobra.Command{ 10 | Use: "gopherizer", 11 | Short: "gopherizer", 12 | Long: `A golang template`, 13 | } 14 | 15 | func Execute() { 16 | err := rootCmd.Execute() 17 | if err != nil { 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/softika/gopherizer/cmd/serve" 7 | ) 8 | 9 | func init() { 10 | rootCmd.AddCommand(serverCmd) 11 | } 12 | 13 | var serverCmd = &cobra.Command{ 14 | Use: "serve", 15 | Short: "serve command", 16 | Long: `serve command runs the server`, 17 | Run: func(cmd *cobra.Command, args []string) { 18 | serve.Run() 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /cmd/migrate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/softika/gopherizer/cmd/migrate" 7 | ) 8 | 9 | func init() { 10 | migrateCmd.AddCommand(migrate.UpCmd) 11 | migrateCmd.AddCommand(migrate.DownCmd) 12 | 13 | rootCmd.AddCommand(migrateCmd) 14 | } 15 | 16 | var migrateCmd = &cobra.Command{ 17 | Use: "migrate", 18 | Short: "Migrate commands", 19 | Long: `Migrate commands run the migrations`, 20 | } 21 | -------------------------------------------------------------------------------- /pkg/errorx/error.go: -------------------------------------------------------------------------------- 1 | package errorx 2 | 3 | type ErrorType int 4 | 5 | const ( 6 | ErrInternal ErrorType = iota 7 | ErrInvalidInput 8 | ErrForbidden 9 | ErrNotFound 10 | ErrUnauthorized 11 | ) 12 | 13 | type Error struct { 14 | Err error 15 | Type ErrorType 16 | } 17 | 18 | func NewError(err error, code ErrorType) *Error { 19 | return &Error{ 20 | Err: err, 21 | Type: code, 22 | } 23 | } 24 | 25 | func (e *Error) Error() string { 26 | return e.Err.Error() 27 | } 28 | -------------------------------------------------------------------------------- /database/repositories/health_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/softika/gopherizer/database" 7 | ) 8 | 9 | type HealthRepository struct { 10 | database.Service 11 | } 12 | 13 | func NewHealthRepository(db database.Service) HealthRepository { 14 | return HealthRepository{ 15 | Service: db, 16 | } 17 | } 18 | 19 | func (r HealthRepository) Health(ctx context.Context) map[string]string { 20 | return r.Service.Health(ctx) 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with "go test -c" 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | tmp/ 20 | 21 | # IDE specific files 22 | .vscode 23 | .idea 24 | 25 | # .env file 26 | .env 27 | 28 | # Project build 29 | main 30 | *templ.go 31 | 32 | -------------------------------------------------------------------------------- /internal/profile/request.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | type GetRequest struct { 4 | Id string `validate:"required,uuid"` 5 | } 6 | 7 | type CreateRequest struct { 8 | FirstName string `validate:"required,max=72"` 9 | LastName string `validate:"required,max=72"` 10 | } 11 | 12 | type UpdateRequest struct { 13 | Id string `validate:"required,uuid"` 14 | FirstName string `validate:"required,max=72"` 15 | LastName string `validate:"required,max=72"` 16 | } 17 | 18 | type DeleteRequest struct { 19 | Id string `validate:"required,uuid"` 20 | } 21 | -------------------------------------------------------------------------------- /internal/profile/response.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Response struct { 8 | Id string `json:"id"` 9 | FirstName string `json:"firstName"` 10 | LastName string `json:"lastName"` 11 | CreatedAt time.Time `json:"createdAt"` 12 | UpdatedAt time.Time `json:"updatedAt"` 13 | } 14 | 15 | func (r *Response) fromModel(u *Profile) *Response { 16 | r.Id = u.Id 17 | r.FirstName = u.FirstName 18 | r.LastName = u.LastName 19 | r.CreatedAt = u.CreatedAt 20 | r.UpdatedAt = u.UpdatedAt 21 | return r 22 | } 23 | -------------------------------------------------------------------------------- /config/default.toml: -------------------------------------------------------------------------------- 1 | [app] 2 | environment = "local" 3 | name = "gopherizer" 4 | version = "1.0.0" 5 | 6 | [http] 7 | host = "0.0.0.0" 8 | port = 8080 9 | read_timeout = "2m" 10 | write_timeout = "2m" 11 | idle_timeout = "2m" 12 | 13 | [http.auth] 14 | secret = "change-me" 15 | 16 | [http.cors] 17 | origins = "*" 18 | methods = "HEAD,GET,POST,PUT,PATCH,DELETE" 19 | headers = "Content-Type,Content-Length" 20 | 21 | [database] 22 | host = "localhost" 23 | port = 5432 24 | user = "postgres" 25 | password = "password" 26 | dbname = "gopher" 27 | sslmode_disabled = true -------------------------------------------------------------------------------- /api/mappers/health.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/softika/gopherizer/internal/health" 8 | ) 9 | 10 | type HealthRequest struct{} 11 | 12 | func (rm HealthRequest) Map(*http.Request) (health.Request, error) { 13 | return health.Request{Status: "OK"}, nil 14 | } 15 | 16 | type HealthResponse struct{} 17 | 18 | func (rm HealthResponse) Map(w http.ResponseWriter, out *health.Response) error { 19 | w.Header().Set("Content-Type", "application/json") 20 | w.WriteHeader(http.StatusOK) 21 | return json.NewEncoder(w).Encode(out) 22 | } 23 | -------------------------------------------------------------------------------- /database/repositories/tests/testdata/20240816215158_seed.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO profiles (id, first_name, last_name, created_at, updated_at) 4 | VALUES 5 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb4', 'John', 'Doe', NOW(), NOW()), 6 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb5', 'Jane', 'Smith', NOW(), NOW()), 7 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb6', 'Alice', 'Wonderland', NOW(), NOW()); 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | TRUNCATE TABLE profiles RESTART IDENTITY CASCADE; 13 | -- +goose StatementEnd 14 | -------------------------------------------------------------------------------- /database/migrations/20240805193305_init_schema.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS profiles ( 4 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 5 | first_name VARCHAR(255) NOT NULL CHECK (first_name <> ''), 6 | last_name VARCHAR(255) NOT NULL CHECK (last_name <> ''), 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 8 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 9 | ); 10 | -- +goose StatementEnd 11 | 12 | -- +goose Down 13 | -- +goose StatementBegin 14 | DROP TABLE IF EXISTS accounts, profiles, roles, account_roles; 15 | -- +goose StatementEnd 16 | -------------------------------------------------------------------------------- /internal/profile/model.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import "github.com/softika/gopherizer/internal" 4 | 5 | type Profile struct { 6 | internal.Base 7 | 8 | FirstName string `db:"first_name"` 9 | LastName string `db:"last_name"` 10 | } 11 | 12 | func New() *Profile { 13 | return &Profile{} 14 | } 15 | 16 | func (p *Profile) WithId(id string) *Profile { 17 | p.Id = id 18 | return p 19 | } 20 | 21 | func (p *Profile) WithFirstName(firstName string) *Profile { 22 | p.FirstName = firstName 23 | return p 24 | } 25 | 26 | func (p *Profile) WithLastName(lastName string) *Profile { 27 | p.LastName = lastName 28 | return p 29 | } 30 | -------------------------------------------------------------------------------- /tests/testdata/20240816215158_seed.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO profiles (id, first_name, last_name, created_at, updated_at) 4 | VALUES 5 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb4', 'John', 'Doe', NOW(), NOW()), 6 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb5', 'Jane', 'Smith', NOW(), NOW()), 7 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb6', 'Alice', 'Wonderland', NOW(), NOW()), 8 | ('0dd35f9a-0d20-41f1-80c2-d7993e313fb7', 'Bob', 'Builder', NOW(), NOW()); 9 | -- +goose StatementEnd 10 | 11 | -- +goose Down 12 | -- +goose StatementBegin 13 | TRUNCATE TABLE profiles RESTART IDENTITY CASCADE; 14 | -- +goose StatementEnd 15 | -------------------------------------------------------------------------------- /tests/health_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | ) 7 | 8 | func (s *E2ETestSuite) TestHealthEndpoint() { 9 | testCases := []struct { 10 | name string 11 | wantCode int 12 | }{ 13 | { 14 | name: "health check", 15 | wantCode: http.StatusOK, 16 | }, 17 | } 18 | 19 | for _, tc := range testCases { 20 | tt := tc 21 | s.Run(tt.name, func() { 22 | s.T().Parallel() 23 | req := httptest.NewRequest(http.MethodGet, "/health", nil) 24 | w := httptest.NewRecorder() 25 | 26 | s.router.ServeHTTP(w, req) 27 | 28 | s.Equal(tt.wantCode, w.Code) 29 | s.NotEmpty(s.T(), w.Body.String()) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import "context" 4 | 5 | type Repository interface { 6 | Health(ctx context.Context) map[string]string 7 | } 8 | 9 | type Request struct { 10 | Status string 11 | } 12 | 13 | type Response map[string]string 14 | 15 | // Service is a dummy service to confirm the health of the server. 16 | type Service struct { 17 | repo Repository 18 | } 19 | 20 | func NewService(r Repository) Service { 21 | return Service{ 22 | repo: r, 23 | } 24 | } 25 | 26 | // Check respond with the health status. 27 | func (s Service) Check(ctx context.Context, _ Request) (*Response, error) { 28 | res := s.repo.Health(ctx) 29 | response := Response(res) 30 | return &response, nil 31 | } 32 | -------------------------------------------------------------------------------- /api/docs/swagger.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SwaggerUI 8 | 9 | 10 | 11 |
12 | 13 | 21 | 22 | -------------------------------------------------------------------------------- /api/mappers/profile_get.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/softika/gopherizer/internal/profile" 9 | ) 10 | 11 | type GetProfileByIdRequest struct{} 12 | 13 | func (g GetProfileByIdRequest) Map(r *http.Request) (profile.GetRequest, error) { 14 | id := r.PathValue("id") 15 | if id == "" { 16 | return profile.GetRequest{}, fmt.Errorf("path param id is missing") 17 | } 18 | return profile.GetRequest{Id: id}, nil 19 | } 20 | 21 | type GetProfileResponse struct{} 22 | 23 | func (g GetProfileResponse) Map(w http.ResponseWriter, out *profile.Response) error { 24 | w.Header().Set("Content-Type", "application/json") 25 | w.WriteHeader(http.StatusOK) 26 | return json.NewEncoder(w).Encode(out) 27 | } 28 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/go/build-context-dockerignore/ 6 | 7 | **/.DS_Store 8 | **/.classpath 9 | **/.dockerignore 10 | **/.env 11 | **/.git 12 | **/.gitignore 13 | **/.project 14 | **/.settings 15 | **/.toolstarget 16 | **/.vs 17 | **/.vscode 18 | **/.idea 19 | **/*.*proj.user 20 | **/*.dbmdl 21 | **/*.jfm 22 | **/bin 23 | **/charts 24 | **/docker-compose* 25 | compose.yaml 26 | Dockerfile 27 | **/node_modules 28 | **/npm-debug.log 29 | **/obj 30 | **/secrets.dev.yaml 31 | **/values.dev.yaml 32 | LICENSE 33 | README.md 34 | .air.toml 35 | -------------------------------------------------------------------------------- /api/mappers/profile_update.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/softika/gopherizer/internal/profile" 8 | ) 9 | 10 | type UpdateProfileRequest struct{} 11 | 12 | func (m UpdateProfileRequest) Map(r *http.Request) (profile.UpdateRequest, error) { 13 | req := new(profile.UpdateRequest) 14 | err := json.NewDecoder(r.Body).Decode(req) 15 | if err != nil { 16 | return profile.UpdateRequest{}, err 17 | } 18 | 19 | return *req, nil 20 | } 21 | 22 | type UpdateProfileResponse struct{} 23 | 24 | func (m UpdateProfileResponse) Map(w http.ResponseWriter, out *profile.Response) error { 25 | w.Header().Set("Content-Type", "application/json") 26 | w.WriteHeader(http.StatusOK) 27 | return json.NewEncoder(w).Encode(out) 28 | } 29 | -------------------------------------------------------------------------------- /api/mappers/profile_create.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/softika/gopherizer/internal/profile" 8 | ) 9 | 10 | type CreateProfileRequest struct{} 11 | 12 | func (m CreateProfileRequest) Map(r *http.Request) (profile.CreateRequest, error) { 13 | req := new(profile.CreateRequest) 14 | err := json.NewDecoder(r.Body).Decode(req) 15 | if err != nil { 16 | return profile.CreateRequest{}, err 17 | } 18 | 19 | return *req, nil 20 | } 21 | 22 | type CreateProfileResponse struct{} 23 | 24 | func (m CreateProfileResponse) Map(w http.ResponseWriter, out *profile.Response) error { 25 | w.Header().Set("Content-Type", "application/json") 26 | w.WriteHeader(http.StatusCreated) 27 | return json.NewEncoder(w).Encode(out) 28 | } 29 | -------------------------------------------------------------------------------- /api/mappers/profile_delete.go: -------------------------------------------------------------------------------- 1 | package mappers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/softika/gopherizer/internal/profile" 9 | ) 10 | 11 | type DeleteProfileRequest struct{} 12 | 13 | func (m DeleteProfileRequest) Map(r *http.Request) (profile.DeleteRequest, error) { 14 | id := r.PathValue("id") 15 | if id == "" { 16 | return profile.DeleteRequest{}, fmt.Errorf("path param id is missing") 17 | } 18 | 19 | return profile.DeleteRequest{Id: id}, nil 20 | } 21 | 22 | type DeleteProfileResponse struct{} 23 | 24 | func (m DeleteProfileResponse) Map(w http.ResponseWriter, d bool) error { 25 | w.Header().Set("Content-Type", "application/json") 26 | w.WriteHeader(http.StatusNoContent) 27 | 28 | res := map[string]bool{"deleted": d} 29 | return json.NewEncoder(w).Encode(res) 30 | } 31 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/softika/gopherizer/config" 8 | ) 9 | 10 | type Server struct { 11 | cfg config.HTTPConfig 12 | http *http.Server 13 | } 14 | 15 | // NewServer creates a new Server. 16 | func NewServer(cfg config.HTTPConfig) *Server { 17 | return &Server{cfg: cfg} 18 | } 19 | 20 | // Run starts the server and listens for incoming requests. 21 | func (s *Server) Run(api http.Handler) error { 22 | s.http = &http.Server{ 23 | Addr: s.cfg.Host + ":" + s.cfg.Port, 24 | ReadTimeout: s.cfg.ReadTimeout, 25 | WriteTimeout: s.cfg.WriteTimeout, 26 | MaxHeaderBytes: 1 << 20, 27 | Handler: api, 28 | } 29 | 30 | return s.http.ListenAndServe() 31 | } 32 | 33 | // Shutdown gracefully shuts down the server without interrupting any active connections. 34 | func (s *Server) Shutdown(ctx context.Context) error { 35 | return s.http.Shutdown(ctx) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/migrate/down.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/softika/slogging" 9 | 10 | "github.com/softika/gopherizer/config" 11 | "github.com/softika/gopherizer/database" 12 | ) 13 | 14 | var DownCmd = &cobra.Command{ 15 | Use: "down", 16 | Short: "rollback database migrations", 17 | Long: "rollback database migrations for all tables", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | down() 20 | }, 21 | } 22 | 23 | func down() { 24 | slog.SetDefault(slogging.Slogger()) 25 | 26 | cfg, err := config.New() 27 | if err != nil { 28 | slog.Error("failed to read config", "error", err) 29 | return 30 | } 31 | 32 | dvSvc := database.New(cfg.Database) 33 | 34 | slog.Info("rollback database migrations") 35 | if err := rollback(dvSvc.DB()); err != nil { 36 | slog.Error("failed to rollback database migrations", "error", err) 37 | return 38 | } 39 | 40 | slog.Info("database migrations rollback completed successfully") 41 | } 42 | -------------------------------------------------------------------------------- /cmd/migrate/up.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/softika/slogging" 9 | 10 | "github.com/softika/gopherizer/config" 11 | "github.com/softika/gopherizer/database" 12 | ) 13 | 14 | var UpCmd = &cobra.Command{ 15 | Use: "up", 16 | Short: "runs up database migrations", 17 | Long: "runs up database migrations for all storage options defined in go-template", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | up() 20 | }, 21 | } 22 | 23 | func up() { 24 | slog.SetDefault(slogging.Slogger()) 25 | 26 | cfg, err := config.New() 27 | if err != nil { 28 | slog.Error("failed to read config", "error", err) 29 | return 30 | } 31 | 32 | dvSvc := database.New(cfg.Database) 33 | 34 | slog.Info("running database migrations") 35 | if err := migrate(dvSvc.DB()); err != nil { 36 | slog.Error("failed to run database migrations", "error", err) 37 | return 38 | } 39 | 40 | slog.Info("database migrations completed successfully") 41 | } 42 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = ["serve"] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [proxy] 45 | app_port = 0 46 | enabled = false 47 | proxy_port = 0 48 | 49 | [screen] 50 | clear_on_rebuild = false 51 | keep_scroll = true 52 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build and Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "*" ] 11 | 12 | permissions: 13 | contents: read 14 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 15 | pull-requests: read 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.25' 28 | 29 | - name: Login to Docker Hub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKERHUB_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | - name: Build 36 | run: go build -v ./... 37 | 38 | - name: Vet 39 | run: go vet ./... 40 | 41 | - name: Test 42 | run: go test -vet=off -count=1 -race -timeout=30s ./... 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Softika 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/pressly/goose/v3" 8 | 9 | "github.com/softika/gopherizer/database" 10 | ) 11 | 12 | // migrate runs all migration scripts inside the migrations folder. 13 | func migrate(db *sql.DB) error { 14 | if err := goose.SetDialect(database.GetDialect()); err != nil { 15 | return fmt.Errorf("failed to set goose dialect: %w", err) 16 | } 17 | 18 | goose.SetBaseFS(database.GetMigrationFS()) 19 | if err := goose.Up(db, "migrations", goose.WithAllowMissing()); err != nil { 20 | return fmt.Errorf("failed to run database migrations: %w", err) 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // rollback runs all migration scripts inside the migrations folder. 27 | func rollback(db *sql.DB) error { 28 | if err := goose.SetDialect(database.GetDialect()); err != nil { 29 | return fmt.Errorf("failed to set goose dialect: %w", err) 30 | } 31 | 32 | goose.SetBaseFS(database.GetMigrationFS()) 33 | if err := goose.Down(db, "migrations"); err != nil { 34 | return fmt.Errorf("failed to run database migrations: %w", err) 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/base.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Base is a base model for all model entities. 9 | type Base struct { 10 | Id string `db:"id,pk"` // uuid 11 | CreatedAt time.Time `db:"created_at"` 12 | UpdatedAt time.Time `db:"updated_at"` 13 | } 14 | 15 | // PageRequest is a pagination request. 16 | type PageRequest struct { 17 | Limit int 18 | Offset int 19 | } 20 | 21 | // DefaultPageRequest returns default page request. 22 | func DefaultPageRequest() PageRequest { 23 | return PageRequest{Limit: 10, Offset: 0} 24 | } 25 | 26 | // Page is a generic pagination response. 27 | type Page[T any] struct { 28 | TotalPages int 29 | TotalItems int 30 | Items []T 31 | } 32 | 33 | func EmptyPage[T any]() Page[T] { 34 | return Page[T]{ 35 | TotalPages: 0, 36 | TotalItems: 0, 37 | Items: []T{}, 38 | } 39 | } 40 | 41 | // Repository is embeddable generic repository. 42 | type Repository[T any, ID any] interface { 43 | GetById(context.Context, ID) (*T, error) 44 | Create(context.Context, *T) (*T, error) 45 | Update(context.Context, *T) (*T, error) 46 | DeleteById(context.Context, ID) error 47 | } 48 | -------------------------------------------------------------------------------- /pkg/resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // Resolver is used to resolve the result of an asynchronous operation 9 | type Resolver[T any] struct { 10 | ResultChan chan T 11 | ErrorChan chan error 12 | 13 | timeout time.Duration 14 | } 15 | 16 | // New creates a new Resolver 17 | func New[T any](timeout time.Duration) Resolver[T] { 18 | return Resolver[T]{ 19 | ResultChan: make(chan T, 1), 20 | ErrorChan: make(chan error, 1), 21 | timeout: timeout, 22 | } 23 | } 24 | 25 | // Get waits for the result of the asynchronous operation and returns it 26 | func (r Resolver[T]) Get() (T, error) { 27 | var zeroValue T 28 | 29 | select { 30 | case result := <-r.ResultChan: 31 | return result, nil 32 | case err := <-r.ErrorChan: 33 | return zeroValue, err 34 | case <-time.After(r.timeout): 35 | return zeroValue, errors.New("resolver get operation timed out") 36 | } 37 | } 38 | 39 | // Close closes the Resolver channels 40 | func (r Resolver[T]) Close() { 41 | select { 42 | case <-r.ResultChan: 43 | default: 44 | close(r.ResultChan) 45 | } 46 | 47 | select { 48 | case <-r.ErrorChan: 49 | default: 50 | close(r.ErrorChan) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /database/tx.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgx/v5" 7 | "github.com/jackc/pgx/v5/pgxpool" 8 | ) 9 | 10 | // TxManager defines a method to execute a transaction from Begin until Commit or Rollback. 11 | type TxManager interface { 12 | Begin(context.Context) (pgx.Tx, error) 13 | Execute(context.Context, func(pgx.Tx) error) error 14 | } 15 | 16 | func NewTxManager(db Service) TxManager { 17 | return &txManager{db.Pool()} 18 | } 19 | 20 | type txManager struct { 21 | *pgxpool.Pool 22 | } 23 | 24 | func (tm *txManager) Begin(ctx context.Context) (pgx.Tx, error) { 25 | return tm.Pool.Begin(ctx) 26 | } 27 | 28 | func (tm *txManager) Execute(ctx context.Context, fn func(tx pgx.Tx) error) error { 29 | tx, err := tm.Begin(ctx) 30 | if err != nil { 31 | return err 32 | } 33 | defer func() { 34 | if p := recover(); p != nil { 35 | err = tx.Rollback(ctx) 36 | // re-throw panic after rollbacks 37 | panic(p) 38 | } else if err != nil { 39 | // rollback if error happen 40 | err = tx.Rollback(ctx) 41 | } else { 42 | // if Commit returns error update err with commit err 43 | err = tx.Commit(ctx) 44 | } 45 | }() 46 | err = fn(tx) 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | 8 | "github.com/softika/gopherizer/config" 9 | "github.com/softika/gopherizer/pkg/testinfra" 10 | ) 11 | 12 | var dbCfg config.DatabaseConfig 13 | 14 | func TestMain(m *testing.M) { 15 | postgres, err := testinfra.RunPostgres() 16 | if err != nil { 17 | log.Fatalf("could not start postgres container: %v", err) 18 | } 19 | 20 | dbCfg = postgres.Config 21 | 22 | m.Run() 23 | 24 | if err = postgres.Shutdown(); err != nil { 25 | log.Fatalf("could not teardown postgres container: %v", err) 26 | } 27 | } 28 | 29 | func TestNew(t *testing.T) { 30 | srv := New(dbCfg) 31 | if srv == nil { 32 | t.Fatal("New() returned nil") 33 | } 34 | } 35 | 36 | func TestHealth(t *testing.T) { 37 | srv := New(dbCfg) 38 | 39 | stats := srv.Health(context.Background()) 40 | 41 | if stats["status"] != "up" { 42 | t.Fatalf("expected status to be up, got %s", stats["status"]) 43 | } 44 | 45 | if _, ok := stats["error"]; ok { 46 | t.Fatalf("expected error not to be present") 47 | } 48 | 49 | if stats["message"] != "It's healthy" { 50 | t.Fatalf("expected message to be 'It's healthy', got %s", stats["message"]) 51 | } 52 | } 53 | 54 | func TestClose(t *testing.T) { 55 | srv := New(dbCfg) 56 | 57 | if srv.Close() != nil { 58 | t.Fatalf("expected Close() to return nil") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cmd/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "time" 11 | 12 | "github.com/softika/slogging" 13 | 14 | "github.com/softika/gopherizer/api" 15 | "github.com/softika/gopherizer/config" 16 | ) 17 | 18 | // Run starts the http server with graceful shutdown option. 19 | func Run() { 20 | slog.SetDefault(slogging.Slogger()) // inject default logger 21 | 22 | cfg, err := config.New() 23 | if err != nil { 24 | slog.Error("failed to read config", "error", err) 25 | os.Exit(1) 26 | } 27 | 28 | router := api.NewRouter(cfg) 29 | 30 | srv := api.NewServer(cfg.Http) 31 | 32 | // Start the server in a goroutine. 33 | go func() { 34 | slog.Info("starting the server...", "address", cfg.Http.Host+":"+cfg.Http.Port) 35 | if err = srv.Run(router); !errors.Is(err, http.ErrServerClosed) { 36 | slog.Error("server failed to run", "error", err) 37 | os.Exit(1) 38 | } 39 | slog.Info("stopped serving new connections.") 40 | }() 41 | 42 | // Wait for interrupt signal to gracefully shut down the server with a timeout. 43 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) 44 | defer stop() 45 | <-ctx.Done() 46 | 47 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 48 | defer cancel() 49 | 50 | if err = srv.Shutdown(ctx); err != nil { 51 | slog.Error("server shutdown error", "error", err) 52 | } 53 | slog.Info("Graceful shutdown completed.") 54 | 55 | } 56 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/softika/gopherizer/pkg/errorx" 11 | ) 12 | 13 | type Error struct { 14 | Code int `json:"code"` 15 | Message string `json:"message"` 16 | Cause error `json:"-"` 17 | } 18 | 19 | func newError(code int, message string, internal error) Error { 20 | return Error{ 21 | Code: code, 22 | Message: message, 23 | Cause: internal, 24 | } 25 | } 26 | 27 | func newServiceError(err error) Error { 28 | code := http.StatusInternalServerError 29 | // Check if the error is a service error 30 | // and set the appropriate HTTP status code 31 | var errService *errorx.Error 32 | if errors.As(err, &errService) { 33 | switch errService.Type { 34 | case errorx.ErrInvalidInput: 35 | code = http.StatusBadRequest 36 | case errorx.ErrForbidden: 37 | code = http.StatusForbidden 38 | case errorx.ErrNotFound: 39 | code = http.StatusNotFound 40 | default: 41 | code = http.StatusInternalServerError 42 | } 43 | } 44 | 45 | return Error{ 46 | Code: code, 47 | Message: err.Error(), 48 | Cause: err, 49 | } 50 | } 51 | 52 | func (e Error) Error() string { 53 | jsonErr, err := json.Marshal(e) 54 | if err != nil { 55 | b := strings.Builder{} 56 | b.WriteString(`{"message":"`) 57 | b.WriteString(e.Message) 58 | b.WriteString(`","code":`) 59 | b.WriteString(strconv.Itoa(e.Code)) 60 | b.WriteString(`,"cause":"`) 61 | b.WriteString(e.Cause.Error()) 62 | b.WriteString(`}`) 63 | return b.String() 64 | } 65 | return string(jsonErr) 66 | } 67 | -------------------------------------------------------------------------------- /database/repositories/tests/suite_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/pressly/goose/v3" 8 | "github.com/stretchr/testify/suite" 9 | 10 | "github.com/softika/slogging" 11 | 12 | "github.com/softika/gopherizer/database" 13 | "github.com/softika/gopherizer/pkg/testinfra" 14 | ) 15 | 16 | type RepositoriesTestSuite struct { 17 | suite.Suite 18 | dbContainer *testinfra.PostgresContainer 19 | dbService database.Service 20 | } 21 | 22 | func (s *RepositoriesTestSuite) SetupSuite() { 23 | slog.SetDefault(slogging.Slogger()) 24 | 25 | var err error 26 | 27 | s.dbContainer, err = testinfra.RunPostgres() 28 | if err != nil { 29 | s.T().Fatal("failed to start postgres container", err) 30 | } 31 | 32 | s.dbService = database.New(s.dbContainer.Config) 33 | 34 | if err = goose.Up(s.dbService.DB(), "../../migrations"); err != nil { 35 | s.T().Fatal("failed to run migrations", err) 36 | } 37 | 38 | if err = goose.Up(s.dbService.DB(), "testdata"); err != nil { 39 | s.T().Fatal("failed to seed test data", err) 40 | } 41 | } 42 | 43 | func (s *RepositoriesTestSuite) TearDownSuite() { 44 | if err := s.dbService.Close(); err != nil { 45 | slog.Warn("failed to close db connection", "error", err) 46 | } 47 | 48 | if err := s.dbContainer.Shutdown(); err != nil { 49 | slog.Warn("failed to shutdown postgres container", "error", err) 50 | } 51 | } 52 | 53 | func TestRepositoriesTestSuite(t *testing.T) { 54 | if testing.Short() { 55 | t.Skip("skipping repository tests in short mode") 56 | return 57 | } 58 | suite.Run(t, new(RepositoriesTestSuite)) 59 | } 60 | -------------------------------------------------------------------------------- /api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "net/http" 8 | 9 | "github.com/getkin/kin-openapi/openapi3" 10 | "github.com/go-chi/chi/v5" 11 | 12 | "github.com/softika/slogging" 13 | ) 14 | 15 | //go:embed docs/v1/api.yaml 16 | var apiV1Docs []byte 17 | 18 | //go:embed docs/swagger.html 19 | var swaggerUI []byte 20 | 21 | func (r *Router) initOpenApiDocs() { 22 | ctx := context.Background() 23 | loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true} 24 | doc, err := loader.LoadFromData(apiV1Docs) 25 | if err != nil { 26 | slogging.Slogger().ErrorContext(ctx, "failed to load openapi3 document", "error", err) 27 | return 28 | } 29 | 30 | r.Get("/api/v1/docs", func(w http.ResponseWriter, req *http.Request) { 31 | w.Header().Set("Content-Type", "application/json") 32 | err = json.NewEncoder(w).Encode(doc) 33 | if err != nil { 34 | slogging.Slogger().ErrorContext(ctx, "failed to encode openapi3 document", "error", err) 35 | } 36 | }) 37 | 38 | r.Get("/docs", func(w http.ResponseWriter, req *http.Request) { 39 | w.Header().Set("Content-Type", "text/html") 40 | _, err = w.Write(swaggerUI) 41 | if err != nil { 42 | slogging.Slogger().ErrorContext(ctx, "failed to write swaggerUI", "error", err) 43 | } 44 | }) 45 | } 46 | 47 | func (r *Router) initRoutes(h handlers) { 48 | r.Get("/health", r.HttpHandlerFunc(h.health.Handle)) 49 | 50 | r.Route("/api/v1", func(c chi.Router) { 51 | c.Route("/profile", func(c chi.Router) { 52 | c.Post("/", r.HttpHandlerFunc(h.profileCreate.Handle)) 53 | c.Put("/", r.HttpHandlerFunc(h.profileUpdate.Handle)) 54 | c.Get("/{id}", r.HttpHandlerFunc(h.profileGet.Handle)) 55 | c.Delete("/{id}", r.HttpHandlerFunc(h.profileDelete.Handle)) 56 | }) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /tests/suite_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | 7 | "github.com/pressly/goose/v3" 8 | "github.com/stretchr/testify/suite" 9 | 10 | "github.com/softika/slogging" 11 | 12 | "github.com/softika/gopherizer/api" 13 | "github.com/softika/gopherizer/config" 14 | "github.com/softika/gopherizer/database" 15 | "github.com/softika/gopherizer/pkg/testinfra" 16 | ) 17 | 18 | type E2ETestSuite struct { 19 | suite.Suite 20 | 21 | dbContainer *testinfra.PostgresContainer 22 | dbService database.Service 23 | router *api.Router 24 | } 25 | 26 | func (s *E2ETestSuite) SetupSuite() { 27 | slog.SetDefault(slogging.Slogger()) 28 | 29 | var err error 30 | 31 | s.dbContainer, err = testinfra.RunPostgres() 32 | if err != nil { 33 | s.T().Fatal("failed to start postgres container", err) 34 | } 35 | 36 | s.dbService = database.New(s.dbContainer.Config) 37 | 38 | s.prepareDb() 39 | 40 | cfg := &config.Config{ 41 | App: config.AppConfig{Environment: "test"}, 42 | Database: s.dbContainer.Config, 43 | } 44 | s.router = api.NewRouter(cfg) 45 | } 46 | 47 | func (s *E2ETestSuite) prepareDb() { 48 | if err := goose.Up(s.dbService.DB(), "../database/migrations"); err != nil { 49 | s.T().Fatal("failed to run migrations", err) 50 | } 51 | 52 | if err := goose.Up(s.dbService.DB(), "testdata"); err != nil { 53 | s.T().Fatal("failed to seed test data", err) 54 | } 55 | } 56 | 57 | func (s *E2ETestSuite) TearDownSuite() { 58 | if err := s.dbService.Close(); err != nil { 59 | slog.Warn("failed to close db connection", "error", err) 60 | } 61 | 62 | if err := s.dbContainer.Shutdown(); err != nil { 63 | slog.Warn("failed to shutdown postgres container", "error", err) 64 | } 65 | } 66 | 67 | func TestE2ETestSuite(t *testing.T) { 68 | if testing.Short() { 69 | t.Skip("skipping e2e tests in short mode") 70 | return 71 | } 72 | suite.Run(t, new(E2ETestSuite)) 73 | } 74 | -------------------------------------------------------------------------------- /api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/go-chi/chi/v5/middleware" 9 | 10 | "github.com/softika/gopherizer/config" 11 | ) 12 | 13 | // Router is the main API router. 14 | // It is a wrapper around chi.Router with some additional functionality. 15 | // Chi router can be replaced with any other router that implements net/http. 16 | type Router struct { 17 | chi.Router 18 | 19 | environment string 20 | } 21 | 22 | func NewRouter(cfg *config.Config) *Router { 23 | r := chi.NewRouter() 24 | defaultMiddlewares(r) 25 | 26 | api := &Router{ 27 | Router: r, 28 | environment: cfg.App.Environment, 29 | } 30 | 31 | s := api.initServices(api.initRepositories(cfg.Database)) 32 | h := api.initHandlers(s) 33 | 34 | api.initRoutes(h) 35 | api.initOpenApiDocs() 36 | 37 | return api 38 | } 39 | 40 | func defaultMiddlewares(r *chi.Mux) { 41 | r.Use(middleware.Logger) 42 | r.Use(middleware.CleanPath) 43 | r.Use(middleware.Recoverer) 44 | r.Use(middleware.Heartbeat("/")) 45 | r.Use(middleware.NoCache) 46 | r.Use(middleware.AllowContentEncoding("deflate", "gzip")) 47 | } 48 | 49 | // HandlerFunc is API generic handler func type. 50 | type HandlerFunc func(http.ResponseWriter, *http.Request) error 51 | 52 | // HttpHandlerFunc creates http.HandlerFunc from custom HandlerFunc. 53 | // It handles API errors and returns them as HTTP errors. 54 | func (r *Router) HttpHandlerFunc(h HandlerFunc) http.HandlerFunc { 55 | return func(w http.ResponseWriter, req *http.Request) { 56 | if err := h(w, req); err != nil { 57 | var apiError Error 58 | if errors.As(err, &apiError) { 59 | http.Error(w, apiError.Error(), apiError.Code) 60 | return 61 | } 62 | 63 | apiError = newError(http.StatusInternalServerError, "internal server error", err) 64 | http.Error(w, apiError.Error(), http.StatusInternalServerError) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/README.md: -------------------------------------------------------------------------------- 1 | # Internal Package Documentation 2 | 3 | The internal package encapsulates the core functionality and business logic of the application. 4 | This package is intended for internal use only and is inaccessible to external packages, enforcing a clear boundary for your application's domain logic. 5 | 6 | ## Overview 7 | 8 | The internal package serves as the foundation for implementing domain-specific logic. It provides examples through the profile entity and supports a modular structure, allowing you to extend the package based on your application's needs. 9 | 10 | ## Structure and Customization 11 | 12 | ### Profile and Model Directories 13 | 14 | - **Purpose:**
15 | - The `profile` package offer examples of organizing domain entities and their associated logic. 16 | - **Flexibility:**
17 | - If you don't require these specific directories, you can safely remove them. 18 | - Replace or extend the directory structure to align with your application's domain logic. 19 | 20 | 21 | ### Adding Domain-Specific Directories 22 | 23 | You can expand the internal package to include additional directories tailored to your application. For instance: 24 | - **Health Checks:**: 25 | - A health directory can be useful for implementing monitoring and diagnostic services. 26 | - Example use cases: 27 | - Application heartbeat. 28 | - Dependency status checks (e.g., database, message queue). 29 | 30 | ### Best Practices 31 | 32 | - **Keep Internal Logic Encapsulated:**
33 | The internal package should only be used within the application to ensure a clear separation of concerns. Avoid exposing its contents to external consumers. 34 | 35 | - **Modular Design:**
36 | Structure the package to reflect your application's core domains and responsibilities, ensuring scalability and maintainability. 37 | 38 | - **Consistency:**
39 | Maintain a consistent organization of domain logic, making it easier for developers to navigate and extend. -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | # Config Package Documentation 2 | 3 | The `config` package is responsible for managing application configuration. It provides a centralized approach to loading, validating, and accessing configuration values from embedded files, environment variables, and defaults. 4 | 5 | --- 6 | 7 | ## Overview 8 | 9 | This package uses: 10 | - **[Viper](https://github.com/spf13/viper)** for flexible configuration management. 11 | - **[Go Validator](https://github.com/go-playground/validator)** to ensure all required fields are populated and valid. 12 | - An **embedded default configuration file** (`default.ini`) as a baseline, which can be overridden by environment variables. 13 | 14 | --- 15 | 16 | ## Features 17 | 18 | 1. **Centralized Configuration**: 19 | - Loads default values from an embedded `default.ini` file. 20 | - Overrides defaults with values from environment variables. 21 | 22 | 2. **Validation**: 23 | - Ensures critical configuration fields are present and valid using `validator`. 24 | 25 | 3. **Flexibility**: 26 | - Uses the `ini` format for defaults but allows customization via environment variables. 27 | - Environment variables are automatically mapped by replacing `.` with `_`. 28 | 29 | ## Environment Variable Overrides 30 | 31 | Environment variables can override `default.ini` values. 32 | - Format: Replace `.` with `_` in variable names. 33 | - Example: 34 | - `app.name` → `APP_NAME` 35 | - `database.password` → `DATABASE_PASSWORD` 36 | 37 | 38 | ## Best Practices 39 | 40 | 1. **Keep Secrets Secure**: 41 | Avoid committing sensitive values (e.g., passwords, secrets) to version control. Use environment variables for sensitive data. 42 | 43 | 2. **Validate Configuration Early**: 44 | Ensure that configuration validation is performed during application startup to catch issues early. 45 | 46 | 3. **Use Environment Variables for Deployment**: 47 | Rely on environment variables to override default values in different environments (e.g., production, staging). -------------------------------------------------------------------------------- /database/repositories/profile_repository.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | 7 | "github.com/softika/gopherizer/database" 8 | "github.com/softika/gopherizer/internal/profile" 9 | ) 10 | 11 | var ( 12 | //go:embed sql/profile_get_by_id.sql 13 | profileGetByIdSql string 14 | //go:embed sql/profile_insert.sql 15 | profileInsertSql string 16 | //go:embed sql/profile_update.sql 17 | profileUpdateSql string 18 | //go:embed sql/profile_delete_by_id.sql 19 | profileDeleteByIdSql string 20 | ) 21 | 22 | type ProfileRepository struct { 23 | database.TxManager 24 | database.Service 25 | } 26 | 27 | func NewProfileRepository(db database.Service) ProfileRepository { 28 | return ProfileRepository{ 29 | TxManager: database.NewTxManager(db), 30 | Service: db, 31 | } 32 | } 33 | 34 | func (r ProfileRepository) GetById(ctx context.Context, id string) (*profile.Profile, error) { 35 | p := new(profile.Profile) 36 | if err := r.Pool().QueryRow(ctx, profileGetByIdSql, id).Scan( 37 | &p.Id, 38 | &p.FirstName, 39 | &p.LastName, 40 | &p.CreatedAt, 41 | &p.UpdatedAt, 42 | ); err != nil { 43 | return nil, err 44 | } 45 | 46 | return p, nil 47 | } 48 | 49 | func (r ProfileRepository) Create(ctx context.Context, p *profile.Profile) (*profile.Profile, error) { 50 | if err := r.Pool().QueryRow(ctx, profileInsertSql, 51 | p.FirstName, // $1 52 | p.LastName, // $2 53 | ).Scan(&p.Id, &p.CreatedAt, &p.UpdatedAt); err != nil { 54 | return nil, err 55 | } 56 | 57 | return p, nil 58 | } 59 | 60 | func (r ProfileRepository) Update(ctx context.Context, p *profile.Profile) (*profile.Profile, error) { 61 | if err := r.Pool().QueryRow(ctx, profileUpdateSql, 62 | p.FirstName, // $1 63 | p.LastName, // $2 64 | p.Id, // $3 65 | ).Scan(&p.Id, &p.CreatedAt, &p.UpdatedAt); err != nil { 66 | return nil, err 67 | } 68 | return p, nil 69 | } 70 | 71 | func (r ProfileRepository) DeleteById(ctx context.Context, id string) error { 72 | _, err := r.Pool().Exec(ctx, profileDeleteByIdSql, id) 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # Comments are provided throughout this file to help you get started. 2 | # If you need more help, visit the Docker Compose reference guide at 3 | # https://docs.docker.com/go/compose-spec-reference/ 4 | 5 | # Here the instructions define your application as a service called "server". 6 | # This service is built from the Dockerfile in the current directory. 7 | # You can add other services your application may depend on here, such as a 8 | # database or a cache. For examples, see the Awesome Compose repository: 9 | # https://github.com/docker/awesome-compose 10 | services: 11 | 12 | # Backend API server. 13 | server: 14 | container_name: server 15 | build: 16 | context: . 17 | target: final 18 | command: ["serve"] 19 | environment: 20 | DATABASE_HOST: database 21 | HTTP_PORT: 8080 22 | ports: 23 | - "8080:8080" 24 | networks: 25 | - gopher 26 | depends_on: 27 | database: 28 | condition: service_healthy 29 | 30 | # Database migration command. 31 | migration: 32 | container_name: migration 33 | build: 34 | context: . 35 | target: final 36 | command: ["migrate", "up"] 37 | environment: 38 | DATABASE_HOST: database 39 | networks: 40 | - gopher 41 | depends_on: 42 | database: 43 | condition: service_healthy 44 | 45 | # Postgres database. 46 | database: 47 | container_name: database 48 | image: postgres:latest 49 | restart: always 50 | # secrets: 51 | # - db-password 52 | environment: 53 | POSTGRES_DB: gopher 54 | POSTGRES_USER: postgres 55 | POSTGRES_PASSWORD: password 56 | # POSTGRES_PASSWORD_FILE: /run/secrets/db-password 57 | ports: 58 | - "5432:5432" 59 | volumes: 60 | - db_data:/var/lib/postgresql/data 61 | networks: 62 | - gopher 63 | healthcheck: 64 | test: ["CMD", "pg_isready", "-U", "postgres"] 65 | interval: 10s 66 | timeout: 5s 67 | retries: 5 68 | 69 | volumes: 70 | db_data: 71 | 72 | # secrets: 73 | # db-password: 74 | # file: db/password.txt 75 | 76 | networks: 77 | gopher: 78 | driver: bridge -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: [ "*" ] 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.25' 23 | cache: false 24 | - name: lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | # Require: The version of golangci-lint to use. 28 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 29 | # When `install-mode` is `goinstall` the value can be v1.54, `latest`, or the hash of a commit. 30 | version: latest 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # 37 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 38 | # The location of the configuration file can be changed by using `--config=` 39 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 40 | args: --timeout=30m 41 | # Optional: show only new issues if it's a pull request. The default value is `false`. 42 | # only-new-issues: true 43 | 44 | # Optional: if set to true, then all caching functionality will be completely disabled, 45 | # takes precedence over all other caching options. 46 | # skip-cache: true 47 | 48 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 49 | # skip-pkg-cache: true 50 | 51 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 52 | # skip-build-cache: true 53 | 54 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 55 | install-mode: "goinstall" -------------------------------------------------------------------------------- /pkg/testinfra/postgres.go: -------------------------------------------------------------------------------- 1 | package testinfra 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/testcontainers/testcontainers-go" 9 | "github.com/testcontainers/testcontainers-go/wait" 10 | 11 | "github.com/softika/gopherizer/config" 12 | ) 13 | 14 | const ( 15 | dbName = "testdb" 16 | dbUser = "test" 17 | dbPassword = "test" 18 | timeout = time.Second * 240 // 4 minutes 19 | ) 20 | 21 | type PostgresContainer struct { 22 | Ctx context.Context 23 | Config config.DatabaseConfig 24 | Shutdown func() error 25 | } 26 | 27 | func RunPostgres() (*PostgresContainer, error) { 28 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 29 | 30 | req := testcontainers.ContainerRequest{ 31 | Image: "postgres:alpine", 32 | ExposedPorts: []string{"5432/tcp"}, 33 | Env: map[string]string{ 34 | "POSTGRES_USER": dbUser, 35 | "POSTGRES_PASSWORD": dbPassword, 36 | "POSTGRES_DB": dbName, 37 | }, 38 | WaitingFor: wait.ForLog("database system is ready to accept connections"). 39 | WithOccurrence(2). 40 | WithStartupTimeout(5 * time.Second), 41 | } 42 | 43 | dbContainer, err := testcontainers.GenericContainer( 44 | ctx, 45 | testcontainers.GenericContainerRequest{ 46 | ContainerRequest: req, 47 | Started: true, 48 | }, 49 | ) 50 | if err != nil { 51 | cancel() 52 | return nil, err 53 | } 54 | var cfg config.DatabaseConfig 55 | for { 56 | if dbContainer.IsRunning() { 57 | slog.Info("database container is running") 58 | host, err := dbContainer.Host(ctx) 59 | if err != nil { 60 | cancel() 61 | return nil, err 62 | } 63 | 64 | port, err := dbContainer.MappedPort(ctx, "5432/tcp") 65 | if err != nil { 66 | cancel() 67 | return nil, err 68 | } 69 | 70 | cfg = config.DatabaseConfig{ 71 | Host: host, 72 | Port: port.Port(), 73 | DBName: dbName, 74 | Password: dbPassword, 75 | User: dbUser, 76 | SSLModeDisabled: true, 77 | } 78 | break 79 | } 80 | slog.Info("waiting for database container to start...") 81 | } 82 | 83 | return &PostgresContainer{ 84 | Ctx: ctx, 85 | Config: cfg, 86 | Shutdown: func() error { 87 | defer cancel() 88 | slog.Info("terminating database container...") 89 | return dbContainer.Terminate(ctx) 90 | }, 91 | }, nil 92 | } 93 | -------------------------------------------------------------------------------- /database/README.md: -------------------------------------------------------------------------------- 1 | # Database Package Documentation 2 | 3 | The database package provides essential functionality for interacting with a Postgres database, including connection management, transaction handling, and repositories. 4 | It also includes a migrations folder containing SQL scripts to manage schema changes. 5 | 6 | --- 7 | 8 | ## Components 9 | 10 | ### Database Service 11 | - **Purpose**: 12 | - Manages the connection pool to the Postgres database using [pgxpool](https://pkg.go.dev/github.com/jackc/pgx/v4/pgxpool). 13 | - **Features**: 14 | - Configurable parameters (host, port, user, password, database, connection pool size, etc.). 15 | - Connection health checks. 16 | 17 | 18 | ### Migrations 19 | - **Purpose**: 20 | - Simplifies database transaction management by abstracting the process of starting, committing, and rolling back transactions. 21 | - **Key Features:**: 22 | - Implements a TxManager interface with: 23 | - `Begin(ctx context.Context)`: Starts a new transaction. 24 | - `Execute(ctx context.Context, func(pgx.Tx) error)`: Executes a transaction block with automatic commit/rollback logic. 25 | - **Error and Panic Handling**: 26 | - Ensures rollback in case of errors or panics during transaction execution. 27 | - Re-throws panics after rollback to preserve original error flow. 28 | - **Example Usage**: 29 | ```go 30 | type ProfileRepository struct { 31 | database.TxManager 32 | database.Service 33 | } 34 | 35 | func (r ProfileRepository) UpdateWithTx(ctx context.Context, p *profile.Profile) (*profile.Profile, error) { 36 | // add more db operations here like lock by id or other operations 37 | 38 | err := r.Execute(ctx, func(tx pgx.Tx) error { 39 | if err := tx.QueryRow(ctx, profileUpdateSql, 40 | p.FirstName, // $1 41 | p.LastName, // $2 42 | p.Id, // $3 43 | ).Scan(&p.Id, &p.CreatedAt, &p.UpdatedAt); err != nil { 44 | return err 45 | } 46 | return nil 47 | }) 48 | return p, err 49 | } 50 | ``` 51 | 52 | ### Repositories 53 | - **Purpose**: 54 | Encapsulates data access logic to keep it separate from business logic. 55 | - **Features**: 56 | - Implements repository patterns for entities, e.g., `UserRepository`, `ProfileRepository`. 57 | - Each repository interacts with the database using the connection pool or a transaction context. 58 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "embed" 5 | "log/slog" 6 | "strings" 7 | "time" 8 | 9 | "github.com/go-playground/validator/v10" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | //go:embed default.toml 14 | var configFile embed.FS 15 | 16 | type Config struct { 17 | App AppConfig `mapstructure:"app"` 18 | Http HTTPConfig `mapstructure:"http"` 19 | Database DatabaseConfig `mapstructure:"database" validate:"required"` 20 | } 21 | 22 | func New() (*Config, error) { 23 | viper.SetConfigType("toml") 24 | viper.SetEnvKeyReplacer(strings.NewReplacer(`.`, `_`)) 25 | viper.AutomaticEnv() 26 | 27 | file, err := configFile.Open("default.toml") 28 | if err != nil { 29 | slog.Error("failed reading config file", "error", err.Error()) 30 | return nil, err 31 | } 32 | 33 | if err = viper.ReadConfig(file); err != nil { 34 | slog.Error("failed reading config file", "error", err.Error()) 35 | return nil, err 36 | } 37 | 38 | config := new(Config) 39 | if err = viper.Unmarshal(config); err != nil { 40 | return nil, err 41 | } 42 | validate := validator.New() 43 | if err = validate.Struct(config); err != nil { 44 | return nil, err 45 | } 46 | return config, nil 47 | } 48 | 49 | type AppConfig struct { 50 | Name string `mapstructure:"name" validate:"required"` 51 | Environment string `mapstructure:"environment" validate:"required"` 52 | Version string `mapstructure:"version" validate:"required"` 53 | } 54 | 55 | type HTTPConfig struct { 56 | Host string `mapstructure:"host"` 57 | Port string `mapstructure:"port" validate:"required"` 58 | ReadTimeout time.Duration `mapstructure:"read_timeout"` 59 | WriteTimeout time.Duration `mapstructure:"write_timeout"` 60 | IdleTimeout time.Duration `mapstructure:"idle_timeout"` 61 | Cors struct { 62 | Origins string `mapstructure:"origins"` 63 | Methods string `mapstructure:"methods"` 64 | Headers string `mapstructure:"headers"` 65 | } `mapstructure:"cors"` 66 | } 67 | 68 | type DatabaseConfig struct { 69 | Host string `mapstructure:"host" validate:"required"` 70 | Port string `mapstructure:"port" validate:"required"` 71 | DBName string `mapstructure:"dbname" validate:"required"` 72 | Password string `mapstructure:"password" validate:"required"` 73 | User string `mapstructure:"user" validate:"required"` 74 | SSLModeDisabled bool `mapstructure:"sslmode_disabled"` 75 | } 76 | -------------------------------------------------------------------------------- /internal/profile/service.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -source=service.go -destination=./mock/service.go -package=mock 2 | package profile 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/softika/gopherizer/internal" 10 | "github.com/softika/gopherizer/pkg/errorx" 11 | ) 12 | 13 | type Repository interface { 14 | internal.Repository[Profile, string] 15 | } 16 | 17 | type Service struct { 18 | repo Repository 19 | } 20 | 21 | func NewService(r Repository) Service { 22 | return Service{repo: r} 23 | } 24 | 25 | func (s Service) GetById(ctx context.Context, req GetRequest) (*Response, error) { 26 | u, err := s.repo.GetById(ctx, req.Id) 27 | if err != nil { 28 | slog.ErrorContext(ctx, "failed to get profile by id", "id", req.Id, "error", err) 29 | return nil, errorx.NewError( 30 | fmt.Errorf("failed to get profile by id: %w", err), 31 | errorx.ErrNotFound, 32 | ) 33 | } 34 | 35 | res := new(Response) 36 | return res.fromModel(u), nil 37 | } 38 | 39 | func (s Service) Create(ctx context.Context, req CreateRequest) (*Response, error) { 40 | p := New().WithFirstName(req.FirstName).WithLastName(req.LastName) 41 | 42 | created, err := s.repo.Create(ctx, p) 43 | if err != nil { 44 | slog.ErrorContext(ctx, "failed to create profile", "error", err) 45 | return nil, errorx.NewError( 46 | fmt.Errorf("failed to create profile: %w", err), 47 | errorx.ErrInternal, 48 | ) 49 | } 50 | 51 | res := new(Response) 52 | res.fromModel(created) 53 | 54 | return res, nil 55 | } 56 | 57 | func (s Service) Update(ctx context.Context, req UpdateRequest) (*Response, error) { 58 | u := New().WithId(req.Id).WithFirstName(req.FirstName).WithLastName(req.LastName) 59 | 60 | updated, err := s.repo.Update(ctx, u) 61 | if err != nil { 62 | slog.ErrorContext(ctx, "failed to update profile", "error", err) 63 | return nil, errorx.NewError( 64 | fmt.Errorf("failed to update profile: %w", err), 65 | errorx.ErrInternal, 66 | ) 67 | } 68 | 69 | res := new(Response) 70 | res.fromModel(updated) 71 | 72 | return res, nil 73 | } 74 | 75 | func (s Service) DeleteById(ctx context.Context, req DeleteRequest) (bool, error) { 76 | if err := s.repo.DeleteById(ctx, req.Id); err != nil { 77 | slog.ErrorContext(ctx, "failed to delete profile by id", "id", req.Id, "error", err) 78 | return false, errorx.NewError( 79 | fmt.Errorf("failed to delete profile by id: %w", err), 80 | errorx.ErrInternal, 81 | ) 82 | } 83 | 84 | return true, nil 85 | } 86 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | ## API package documentation 2 | 3 | The api package implements the API layer of the application, providing a structured approach to manage HTTP servers, routes, handlers, and request/response mappings. 4 | 5 | ## Components 6 | 7 | ### Server 8 | The Server struct manages the lifecycle of the HTTP server. It is responsible for: 9 | 10 | - Listening for incoming requests. 11 | - Serving responses via the [http.Server](https://pkg.go.dev/net/http#Server). 12 | - Gracefully shutting down the server when required. 13 | 14 | This component is highly configurable, allowing you to set: 15 | 16 | - Server address and port. 17 | - Read and write timeouts. 18 | 19 | ### Router 20 | 21 | The default router is based on the lightweight [Chi router](https://go-chi.io/#/README), which can be replaced with any router implementing the [http.Handler](https://pkg.go.dev/net/http#Handler) interface. 22 | 23 | Features: 24 | 25 | - [Route Initialization](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/router.go#L19): Configures application routes and middleware. 26 | - [Centralized Error Handling](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/router.go#L51): Provides a unified approach for managing errors across routes. 27 | 28 | ### Handler 29 | 30 | The `Handler` struct serves as a generic interface for handling incoming HTTP requests. It decouples business logic from the HTTP layer by: 31 | 32 | - Validating and passing the request's context and inputs to the service functions. 33 | - Returning a response or error from the service functions. 34 | 35 | How to Use: 36 | 37 | - [Create a New Handler](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/bootstrap.go#L54) 38 | - [Initialize Endpoints](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/routes.go#L47) 39 | 40 | ### Mappers 41 | 42 | The Mappers struct simplifies the process of translating HTTP requests into service-layer inputs and mapping service outputs to HTTP responses. 43 | 44 | Key Features: 45 | 46 | - No need to create new handlers for each endpoint. 47 | - Implement the following interfaces for custom mappings: 48 | - [RequestMapper](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/handler.go#L14) 49 | - [ResponseMapper](https://github.com/softika/gopherizer/blob/884d805e6adabedf965c2e7ee4569a11012a97ff/api/handler.go#L19) 50 | 51 | **Examples**:
52 | The mappers package includes examples for handling various types of requests and responses. 53 | -------------------------------------------------------------------------------- /api/handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | ) 8 | 9 | // ServiceFunc is a generic service function type called in a handler. 10 | type ServiceFunc[In any, Out any] func(context.Context, In) (Out, error) 11 | 12 | // RequestMapper is generic interfaces for mapping request objects. 13 | type RequestMapper[In any] interface { 14 | Map(*http.Request) (In, error) 15 | } 16 | 17 | // ResponseMapper is generic interfaces for mapping response objects. 18 | type ResponseMapper[Out any] interface { 19 | Map(http.ResponseWriter, Out) error 20 | } 21 | 22 | type Validator interface { 23 | StructCtx(ctx context.Context, s interface{}) (err error) 24 | } 25 | 26 | // Handler is a generic handler type. 27 | type Handler[In any, Out any] struct { 28 | serviceFunc ServiceFunc[In, Out] 29 | requestMapper RequestMapper[In] 30 | responseMapper ResponseMapper[Out] 31 | validator Validator 32 | } 33 | 34 | // NewHandler creates new handler. 35 | func NewHandler[In any, Out any]( 36 | reqMapper RequestMapper[In], 37 | resMapper ResponseMapper[Out], 38 | svcFunc ServiceFunc[In, Out], 39 | vld Validator, 40 | ) Handler[In, Out] { 41 | return Handler[In, Out]{ 42 | requestMapper: reqMapper, 43 | responseMapper: resMapper, 44 | serviceFunc: svcFunc, 45 | validator: vld, 46 | } 47 | } 48 | 49 | // Handle handles the http request. 50 | // No need to write a separate handler for each endpoint. 51 | // Just create request and response mappers and use this generic handler. 52 | // It will map the request, validate it, call the service function and map the response. 53 | // It will return an error if any of the steps fail. 54 | // Assumes that the service function receives context and input type, and returns a output object and an error. 55 | func (h Handler[In, Out]) Handle(w http.ResponseWriter, r *http.Request) error { 56 | // map request 57 | in, err := h.requestMapper.Map(r) 58 | if err != nil { 59 | slog.ErrorContext(r.Context(), "failed to map request", "error", err) 60 | return newError(http.StatusBadRequest, err.Error(), err) 61 | } 62 | 63 | // validate request 64 | if h.validator != nil { 65 | err = h.validator.StructCtx(r.Context(), in) 66 | if err != nil { 67 | slog.ErrorContext(r.Context(), "request validation failed", "error", err) 68 | return newError(http.StatusBadRequest, err.Error(), err) 69 | } 70 | } 71 | 72 | // call out to service function 73 | out, err := h.serviceFunc(r.Context(), in) 74 | if err != nil { 75 | slog.ErrorContext(r.Context(), "service function failed", "error", err) 76 | return newServiceError(err) 77 | } 78 | 79 | // map and return response 80 | return h.responseMapper.Map(w, out) 81 | } 82 | -------------------------------------------------------------------------------- /api/bootstrap.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/go-playground/validator/v10" 5 | 6 | "github.com/softika/gopherizer/config" 7 | "github.com/softika/gopherizer/database" 8 | 9 | // internal services 10 | "github.com/softika/gopherizer/internal/health" 11 | "github.com/softika/gopherizer/internal/profile" 12 | 13 | // database repositories 14 | repos "github.com/softika/gopherizer/database/repositories" 15 | 16 | // http handler mappers 17 | "github.com/softika/gopherizer/api/mappers" 18 | ) 19 | 20 | type repositories struct { 21 | health health.Repository 22 | profile profile.Repository 23 | } 24 | 25 | func (r *Router) initRepositories(cfg config.DatabaseConfig) repositories { 26 | db := database.New(cfg) 27 | return repositories{ 28 | health: repos.NewHealthRepository(db), 29 | profile: repos.NewProfileRepository(db), 30 | } 31 | } 32 | 33 | type services struct { 34 | health health.Service 35 | profile profile.Service 36 | } 37 | 38 | func (r *Router) initServices(s repositories) services { 39 | return services{ 40 | health: health.NewService(s.health), 41 | profile: profile.NewService(s.profile), 42 | } 43 | } 44 | 45 | type handlers struct { 46 | health Handler[health.Request, *health.Response] 47 | 48 | profileCreate Handler[profile.CreateRequest, *profile.Response] 49 | profileGet Handler[profile.GetRequest, *profile.Response] 50 | profileUpdate Handler[profile.UpdateRequest, *profile.Response] 51 | profileDelete Handler[profile.DeleteRequest, bool] 52 | } 53 | 54 | func (r *Router) initHandlers(s services) handlers { 55 | vld := validator.New() 56 | 57 | healthHandler := NewHandler( 58 | mappers.HealthRequest{}, 59 | mappers.HealthResponse{}, 60 | s.health.Check, 61 | vld, 62 | ) 63 | 64 | profileCreateHandler := NewHandler( 65 | mappers.CreateProfileRequest{}, 66 | mappers.CreateProfileResponse{}, 67 | s.profile.Create, 68 | vld, 69 | ) 70 | 71 | profileGetHandler := NewHandler( 72 | mappers.GetProfileByIdRequest{}, 73 | mappers.GetProfileResponse{}, 74 | s.profile.GetById, 75 | vld, 76 | ) 77 | 78 | profileUpdateHandler := NewHandler( 79 | mappers.UpdateProfileRequest{}, 80 | mappers.UpdateProfileResponse{}, 81 | s.profile.Update, 82 | vld, 83 | ) 84 | 85 | profileDeleteHandler := NewHandler( 86 | mappers.DeleteProfileRequest{}, 87 | mappers.DeleteProfileResponse{}, 88 | s.profile.DeleteById, 89 | vld, 90 | ) 91 | 92 | return handlers{ 93 | health: healthHandler, 94 | 95 | profileCreate: profileCreateHandler, 96 | profileGet: profileGetHandler, 97 | profileUpdate: profileUpdateHandler, 98 | profileDelete: profileDeleteHandler, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # project name 2 | PROJECT_NAME = gopherizer 3 | 4 | ## help: Show makefile commands. 5 | .PHONY: help 6 | help: Makefile 7 | @echo "===== Project: $(PROJECT_NAME) =====" 8 | @echo 9 | @echo " Usage: make " 10 | @echo 11 | @echo " Available Commands:" 12 | @echo 13 | @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' 14 | @echo 15 | 16 | ## start: Start all services from docker compose file. 17 | .PHONY: start 18 | start: 19 | @echo "=== Running docker compose..." 20 | @if docker compose up -d 2>/dev/null; then \ 21 | : ; \ 22 | else \ 23 | echo "Falling back to Docker Compose V1"; \ 24 | docker-compose up -d; \ 25 | fi 26 | 27 | ## stop: Stop all services from docker compose file. 28 | .PHONY: stop 29 | stop: 30 | @echo "=== Stopping docker compose..." 31 | @if docker compose down 2>/dev/null; then \ 32 | : ; \ 33 | else \ 34 | echo "Falling back to Docker Compose V1"; \ 35 | docker-compose down; \ 36 | fi 37 | 38 | ## build: Build the project. 39 | .PHONY: build 40 | build: 41 | @echo "=== Building $(PROJECT_NAME)..." 42 | @go build -o $(PROJECT_NAME) main.go 43 | 44 | ## run: Run the api server. 45 | .PHONY: run 46 | run: 47 | @echo "=== Running server..." 48 | @go run main.go serve 49 | 50 | ## db-start: Start database in a docker container. 51 | .PHONY: db-start 52 | db-start: 53 | @echo "=== Running database docker container..." 54 | @if docker compose up database -d 2>/dev/null; then \ 55 | : ; \ 56 | else \ 57 | echo "Falling back to Docker Compose V1"; \ 58 | docker-compose up -d; \ 59 | fi 60 | 61 | ## db-stop: Shutdown database docker container. 62 | .PHONY: db-stop 63 | db-stop: 64 | @echo "=== Stopping database docker container..." 65 | @if docker compose down database 2>/dev/null; then \ 66 | : ; \ 67 | else \ 68 | echo "Falling back to Docker Compose V1"; \ 69 | docker-compose down; \ 70 | fi 71 | 72 | ## migrate-up: Migrate the database. 73 | .PHONY: migrate-up 74 | migrate-up: 75 | @echo "=== Migrating database..." 76 | @go run main.go migrate up 77 | 78 | ## migrate-down: Rollback the database migration. 79 | .PHONY: migrate-down 80 | migrate-down: 81 | @echo "=== Rolling back database..." 82 | @go run main.go migrate down 83 | 84 | ## test: Run tests. 85 | .PHONY: test 86 | test: 87 | @echo "=== Running tests with race detector" 88 | go test -vet=off -count=1 -race -timeout=30s ./... 89 | 90 | ## clean: Clean the binary. 91 | .PHONY: clean 92 | clean: 93 | @echo "=== Cleaning..." 94 | @rm -f $(PROJECT_NAME) 95 | 96 | ## watch: Live Reload. 97 | .PHONY: watch 98 | watch: 99 | @if command -v air > /dev/null; then \ 100 | air; \ 101 | echo "Watching...";\ 102 | else \ 103 | read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ 104 | if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ 105 | go install github.com/air-verse/air@latest; \ 106 | air; \ 107 | echo "Watching...";\ 108 | else \ 109 | echo "You chose not to install air. Exiting..."; \ 110 | exit 1; \ 111 | fi; \ 112 | fi 113 | 114 | ## mocks: Generate mocks. 115 | .PHONY: mocks 116 | mocks: 117 | @go generate -x ./... 118 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # Comments are provided throughout this file to help you get started. 4 | # If you need more help, visit the Dockerfile reference guide at 5 | # https://docs.docker.com/go/dockerfile-reference/ 6 | 7 | # Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 8 | 9 | ################################################################################ 10 | # Create a stage for building the application. 11 | ARG GO_VERSION=1.25.2 12 | FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build 13 | WORKDIR /src 14 | 15 | # Download dependencies as a separate step to take advantage of Docker's caching. 16 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 17 | # Leverage bind mounts to go.sum and go.mod to avoid having to copy them into 18 | # the container. 19 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 20 | --mount=type=bind,source=go.sum,target=go.sum \ 21 | --mount=type=bind,source=go.mod,target=go.mod \ 22 | go mod download -x 23 | 24 | # This is the architecture you're building for, which is passed in by the builder. 25 | # Placing it here allows the previous steps to be cached across architectures. 26 | ARG TARGETARCH 27 | 28 | # Build the application. 29 | # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. 30 | # Leverage a bind mount to the current directory to avoid having to copy the 31 | # source code into the container. 32 | RUN --mount=type=cache,target=/go/pkg/mod/ \ 33 | --mount=type=bind,target=. \ 34 | CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /bin/server . 35 | 36 | ################################################################################ 37 | # Create a new stage for running the application that contains the minimal 38 | # runtime dependencies for the application. This often uses a different base 39 | # image from the build stage where the necessary files are copied from the build 40 | # stage. 41 | # 42 | # The example below uses the alpine image as the foundation for running the app. 43 | # By specifying the "latest" tag, it will also use whatever happens to be the 44 | # most recent version of that image when you build your Dockerfile. If 45 | # reproducibility is important, consider using a versioned tag 46 | # (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff). 47 | FROM alpine:latest AS final 48 | 49 | # Install any runtime dependencies that are needed to run your application. 50 | # Leverage a cache mount to /var/cache/apk/ to speed up subsequent builds. 51 | RUN --mount=type=cache,target=/var/cache/apk \ 52 | apk --update add \ 53 | ca-certificates \ 54 | tzdata \ 55 | && \ 56 | update-ca-certificates 57 | 58 | # Create a non-privileged user that the app will run under. 59 | # See https://docs.docker.com/go/dockerfile-user-best-practices/ 60 | ARG UID=10001 61 | RUN adduser \ 62 | --disabled-password \ 63 | --gecos "" \ 64 | --home "/nonexistent" \ 65 | --shell "/sbin/nologin" \ 66 | --no-create-home \ 67 | --uid "${UID}" \ 68 | appuser 69 | USER appuser 70 | 71 | # Copy the executable from the "build" stage. 72 | COPY --from=build /bin/server /bin/ 73 | 74 | # Expose the port that the application listens on. 75 | EXPOSE ${HTTP_PORT:-8080} 76 | 77 | # What the container should run when it is started. 78 | ENTRYPOINT [ "/bin/server" ] -------------------------------------------------------------------------------- /internal/profile/mock/service.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: service.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=service.go -destination=./mock/service.go -package=mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | context "context" 14 | profile "github.com/softika/gopherizer/internal/profile" 15 | reflect "reflect" 16 | 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockRepository is a mock of Repository interface. 21 | type MockRepository struct { 22 | ctrl *gomock.Controller 23 | recorder *MockRepositoryMockRecorder 24 | } 25 | 26 | // MockRepositoryMockRecorder is the mock recorder for MockRepository. 27 | type MockRepositoryMockRecorder struct { 28 | mock *MockRepository 29 | } 30 | 31 | // NewMockRepository creates a new mock instance. 32 | func NewMockRepository(ctrl *gomock.Controller) *MockRepository { 33 | mock := &MockRepository{ctrl: ctrl} 34 | mock.recorder = &MockRepositoryMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Create mocks base method. 44 | func (m *MockRepository) Create(arg0 context.Context, arg1 *profile.Profile) (*profile.Profile, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Create", arg0, arg1) 47 | ret0, _ := ret[0].(*profile.Profile) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Create indicates an expected call of Create. 53 | func (mr *MockRepositoryMockRecorder) Create(arg0, arg1 any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), arg0, arg1) 56 | } 57 | 58 | // DeleteById mocks base method. 59 | func (m *MockRepository) DeleteById(arg0 context.Context, arg1 string) error { 60 | m.ctrl.T.Helper() 61 | ret := m.ctrl.Call(m, "DeleteById", arg0, arg1) 62 | ret0, _ := ret[0].(error) 63 | return ret0 64 | } 65 | 66 | // DeleteById indicates an expected call of DeleteById. 67 | func (mr *MockRepositoryMockRecorder) DeleteById(arg0, arg1 any) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteById", reflect.TypeOf((*MockRepository)(nil).DeleteById), arg0, arg1) 70 | } 71 | 72 | // GetById mocks base method. 73 | func (m *MockRepository) GetById(arg0 context.Context, arg1 string) (*profile.Profile, error) { 74 | m.ctrl.T.Helper() 75 | ret := m.ctrl.Call(m, "GetById", arg0, arg1) 76 | ret0, _ := ret[0].(*profile.Profile) 77 | ret1, _ := ret[1].(error) 78 | return ret0, ret1 79 | } 80 | 81 | // GetById indicates an expected call of GetById. 82 | func (mr *MockRepositoryMockRecorder) GetById(arg0, arg1 any) *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetById", reflect.TypeOf((*MockRepository)(nil).GetById), arg0, arg1) 85 | } 86 | 87 | // Update mocks base method. 88 | func (m *MockRepository) Update(arg0 context.Context, arg1 *profile.Profile) (*profile.Profile, error) { 89 | m.ctrl.T.Helper() 90 | ret := m.ctrl.Call(m, "Update", arg0, arg1) 91 | ret0, _ := ret[0].(*profile.Profile) 92 | ret1, _ := ret[1].(error) 93 | return ret0, ret1 94 | } 95 | 96 | // Update indicates an expected call of Update. 97 | func (mr *MockRepositoryMockRecorder) Update(arg0, arg1 any) *gomock.Call { 98 | mr.mock.ctrl.T.Helper() 99 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), arg0, arg1) 100 | } 101 | -------------------------------------------------------------------------------- /database/repositories/tests/profile_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/softika/gopherizer/database/repositories" 9 | "github.com/softika/gopherizer/internal/profile" 10 | ) 11 | 12 | func (s *RepositoriesTestSuite) TestProfileRepository_Create() { 13 | repo := repositories.NewProfileRepository(s.dbService) 14 | 15 | tests := []struct { 16 | name string 17 | input *profile.Profile 18 | wantErr assert.ErrorAssertionFunc 19 | }{ 20 | { 21 | name: "valid profile", 22 | input: profile.New(). 23 | WithFirstName("Milan"). 24 | WithLastName("Miami"), 25 | wantErr: assert.NoError, 26 | }, 27 | { 28 | name: "empty input", 29 | input: &profile.Profile{}, 30 | wantErr: assert.Error, 31 | }, 32 | } 33 | 34 | for _, tt := range tests { 35 | s.T().Run(tt.name, func(t *testing.T) { 36 | p, err := repo.Create(s.dbContainer.Ctx, tt.input) 37 | 38 | tt.wantErr(t, err, "Create() error = %v, wantErr %v", err, tt.wantErr) 39 | if err != nil { 40 | return 41 | } 42 | 43 | s.Assert().NotEmpty(p.Id) 44 | s.Assert().NotEmpty(p.CreatedAt) 45 | s.Assert().NotEmpty(p.UpdatedAt) 46 | s.Assert().Equal(tt.input.FirstName, p.FirstName) 47 | s.Assert().Equal(tt.input.LastName, p.LastName) 48 | }) 49 | } 50 | } 51 | 52 | func (s *RepositoriesTestSuite) TestProfileRepository_GetById() { 53 | repo := repositories.NewProfileRepository(s.dbService) 54 | 55 | tests := []struct { 56 | name string 57 | input string 58 | wantErr assert.ErrorAssertionFunc 59 | }{ 60 | { 61 | name: "empty id", 62 | input: "", 63 | wantErr: assert.Error, 64 | }, 65 | { 66 | name: "invalid id", 67 | input: "invalid-id", 68 | wantErr: assert.Error, 69 | }, 70 | { 71 | name: "valid id", 72 | input: "0dd35f9a-0d20-41f1-80c2-d7993e313fb4", 73 | wantErr: assert.NoError, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | s.T().Run(tt.name, func(t *testing.T) { 79 | p, err := repo.GetById(s.dbContainer.Ctx, tt.input) 80 | 81 | tt.wantErr(t, err, "GetById() error = %v, wantErr %v", err, tt.wantErr) 82 | if err != nil { 83 | return 84 | } 85 | 86 | s.Assert().NotEmpty(p.Id) 87 | s.Assert().NotEmpty(p.CreatedAt) 88 | s.Assert().NotEmpty(p.UpdatedAt) 89 | }) 90 | } 91 | } 92 | 93 | func (s *RepositoriesTestSuite) TestProfileRepository_Update() { 94 | repo := repositories.NewProfileRepository(s.dbService) 95 | 96 | tests := []struct { 97 | name string 98 | input *profile.Profile 99 | wantErr assert.ErrorAssertionFunc 100 | }{ 101 | { 102 | name: "valid profile", 103 | input: profile.New(). 104 | WithId("0dd35f9a-0d20-41f1-80c2-d7993e313fb4"). 105 | WithFirstName("Lanmi"). 106 | WithLastName("Miami"), 107 | wantErr: assert.NoError, 108 | }, 109 | { 110 | name: "invalid id", 111 | input: profile.New(). 112 | WithId("invalid-id"). 113 | WithFirstName("John"). 114 | WithLastName("Doe"), 115 | wantErr: assert.Error, 116 | }, 117 | } 118 | 119 | for _, tt := range tests { 120 | s.T().Run(tt.name, func(t *testing.T) { 121 | p, err := repo.Update(s.dbContainer.Ctx, tt.input) 122 | 123 | tt.wantErr(t, err, "Update() error = %v, wantErr %v", err, tt.wantErr) 124 | if err != nil { 125 | return 126 | } 127 | 128 | s.Assert().NotEmpty(p.Id) 129 | s.Assert().NotEmpty(p.CreatedAt) 130 | s.Assert().NotEmpty(p.UpdatedAt) 131 | s.Assert().Equal(tt.input.FirstName, p.FirstName) 132 | s.Assert().Equal(tt.input.LastName, p.LastName) 133 | }) 134 | } 135 | } 136 | 137 | func (s *RepositoriesTestSuite) TestProfileRepository_DeleteById() { 138 | repo := repositories.NewProfileRepository(s.dbService) 139 | 140 | tests := []struct { 141 | name string 142 | input string 143 | wantErr assert.ErrorAssertionFunc 144 | }{ 145 | { 146 | name: "empty id", 147 | input: "", 148 | wantErr: assert.Error, 149 | }, 150 | { 151 | name: "invalid id", 152 | input: "invalid-id", 153 | wantErr: assert.Error, 154 | }, 155 | { 156 | name: "valid id", 157 | input: "0dd35f9a-0d20-41f1-80c2-d7993e313fb6", 158 | wantErr: assert.NoError, 159 | }, 160 | } 161 | 162 | for _, tt := range tests { 163 | s.T().Run(tt.name, func(t *testing.T) { 164 | err := repo.DeleteById(s.dbContainer.Ctx, tt.input) 165 | 166 | tt.wantErr(t, err, "DeleteById() error = %v, wantErr %v", err, tt.wantErr) 167 | 168 | if err != nil { 169 | got, err := repo.GetById(s.dbContainer.Ctx, tt.input) 170 | s.Assert().Error(err) 171 | s.Assert().Nil(got) 172 | } 173 | }) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /internal/profile/service_test.go: -------------------------------------------------------------------------------- 1 | package profile_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "go.uber.org/mock/gomock" 9 | 10 | "github.com/softika/gopherizer/internal/profile" 11 | "github.com/softika/gopherizer/internal/profile/mock" 12 | ) 13 | 14 | func TestService_Create(t *testing.T) { 15 | t.Parallel() 16 | 17 | ctx := context.Background() 18 | 19 | ctrl := gomock.NewController(t) 20 | 21 | req := func() profile.CreateRequest { 22 | return profile.CreateRequest{ 23 | FirstName: "John", 24 | LastName: "Doe", 25 | } 26 | } 27 | 28 | tests := []struct { 29 | name string 30 | req profile.CreateRequest 31 | mockFn func(*mock.MockRepository) 32 | wantErr assert.ErrorAssertionFunc 33 | }{ 34 | { 35 | name: "success", 36 | req: req(), 37 | mockFn: func(r *mock.MockRepository) { 38 | u := profile.New(). 39 | WithFirstName("John"). 40 | WithLastName("Doe") 41 | 42 | r.EXPECT(). 43 | Create(ctx, gomock.Any()). 44 | Return(u, nil) 45 | }, 46 | wantErr: assert.NoError, 47 | }, 48 | { 49 | name: "error", 50 | req: req(), 51 | mockFn: func(r *mock.MockRepository) { 52 | r.EXPECT(). 53 | Create(ctx, gomock.Any()). 54 | Return(nil, assert.AnError) 55 | }, 56 | wantErr: assert.Error, 57 | }, 58 | } 59 | for _, tc := range tests { 60 | tt := tc 61 | t.Run(tt.name, func(t *testing.T) { 62 | t.Parallel() 63 | 64 | // given 65 | repo := mock.NewMockRepository(ctrl) 66 | s := profile.NewService(repo) 67 | 68 | tt.mockFn(repo) 69 | 70 | // when 71 | got, err := s.Create(ctx, tt.req) 72 | 73 | // then 74 | if err != nil && tt.wantErr(t, err) { 75 | return 76 | } 77 | assert.Equal(t, tt.req.FirstName, got.FirstName) 78 | assert.Equal(t, tt.req.LastName, got.LastName) 79 | }) 80 | } 81 | } 82 | 83 | func TestService_DeleteById(t *testing.T) { 84 | t.Parallel() 85 | 86 | ctx := context.Background() 87 | 88 | ctrl := gomock.NewController(t) 89 | 90 | id := "b8c22ea5-0d76-4abc-8ff2-5a31bb4daddc" 91 | 92 | tests := []struct { 93 | name string 94 | mockFn func(r *mock.MockRepository) 95 | req profile.DeleteRequest 96 | want bool 97 | wantErr assert.ErrorAssertionFunc 98 | }{ 99 | { 100 | name: "success", 101 | req: profile.DeleteRequest{Id: id}, 102 | mockFn: func(r *mock.MockRepository) { 103 | r.EXPECT().DeleteById(ctx, id).Return(nil) 104 | }, 105 | want: true, 106 | wantErr: assert.NoError, 107 | }, 108 | { 109 | name: "error", 110 | req: profile.DeleteRequest{Id: id}, 111 | mockFn: func(r *mock.MockRepository) { 112 | r.EXPECT().DeleteById(ctx, id).Return(assert.AnError) 113 | }, 114 | want: false, 115 | wantErr: assert.Error, 116 | }, 117 | } 118 | for _, tc := range tests { 119 | tt := tc 120 | t.Run(tt.name, func(t *testing.T) { 121 | t.Parallel() 122 | 123 | // given 124 | repo := mock.NewMockRepository(ctrl) 125 | s := profile.NewService(repo) 126 | tt.mockFn(repo) 127 | 128 | // when 129 | got, err := s.DeleteById(ctx, tt.req) 130 | 131 | // then 132 | tt.wantErr(t, err) 133 | assert.Equal(t, tt.want, got) 134 | }) 135 | } 136 | } 137 | 138 | func TestService_GetById(t *testing.T) { 139 | t.Parallel() 140 | 141 | ctx := context.Background() 142 | 143 | ctrl := gomock.NewController(t) 144 | 145 | id := "b8c22ea5-0d76-4abc-8ff2-5a31bb4daddc" 146 | 147 | tests := []struct { 148 | name string 149 | req profile.GetRequest 150 | mockFn func(r *mock.MockRepository) 151 | want *profile.Response 152 | wantErr assert.ErrorAssertionFunc 153 | }{ 154 | { 155 | name: "success", 156 | req: profile.GetRequest{Id: id}, 157 | mockFn: func(r *mock.MockRepository) { 158 | u := profile.New(). 159 | WithFirstName("John"). 160 | WithLastName("Doe") 161 | r.EXPECT().GetById(ctx, id).Return(u, nil) 162 | }, 163 | want: &profile.Response{ 164 | FirstName: "John", 165 | LastName: "Doe", 166 | }, 167 | wantErr: assert.NoError, 168 | }, 169 | { 170 | name: "error", 171 | req: profile.GetRequest{Id: id}, 172 | mockFn: func(r *mock.MockRepository) { 173 | r.EXPECT().GetById(ctx, id).Return(nil, assert.AnError) 174 | }, 175 | want: nil, 176 | wantErr: assert.Error, 177 | }, 178 | } 179 | for _, tc := range tests { 180 | tt := tc 181 | t.Run(tt.name, func(t *testing.T) { 182 | t.Parallel() 183 | 184 | // given 185 | repo := mock.NewMockRepository(ctrl) 186 | s := profile.NewService(repo) 187 | tt.mockFn(repo) 188 | 189 | // when 190 | got, err := s.GetById(ctx, tt.req) 191 | 192 | // then 193 | if err != nil && tt.wantErr(t, err) { 194 | return 195 | } 196 | 197 | assert.Equal(t, tt.want.FirstName, got.FirstName) 198 | assert.Equal(t, tt.want.LastName, got.LastName) 199 | }) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/softika/gopherizer 2 | 3 | go 1.25.2 4 | 5 | require ( 6 | github.com/getkin/kin-openapi v0.133.0 7 | github.com/go-chi/chi/v5 v5.2.3 8 | github.com/go-playground/validator/v10 v10.28.0 9 | github.com/jackc/pgx/v5 v5.7.6 10 | github.com/pressly/goose/v3 v3.26.0 11 | github.com/softika/slogging v1.0.2 12 | github.com/spf13/cobra v1.10.1 13 | github.com/spf13/viper v1.21.0 14 | github.com/stretchr/testify v1.11.1 15 | github.com/testcontainers/testcontainers-go v0.39.0 16 | go.uber.org/mock v0.6.0 17 | ) 18 | 19 | require ( 20 | dario.cat/mergo v1.0.2 // indirect 21 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 22 | github.com/Microsoft/go-winio v0.6.2 // indirect 23 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 24 | github.com/containerd/errdefs v1.0.0 // indirect 25 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 26 | github.com/containerd/log v0.1.0 // indirect 27 | github.com/containerd/platforms v0.2.1 // indirect 28 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 29 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 30 | github.com/distribution/reference v0.6.0 // indirect 31 | github.com/docker/docker v28.3.3+incompatible // indirect 32 | github.com/docker/go-connections v0.6.0 // indirect 33 | github.com/docker/go-units v0.5.0 // indirect 34 | github.com/ebitengine/purego v0.8.4 // indirect 35 | github.com/felixge/httpsnoop v1.0.4 // indirect 36 | github.com/fsnotify/fsnotify v1.9.0 // indirect 37 | github.com/gabriel-vasile/mimetype v1.4.10 // indirect 38 | github.com/go-logr/logr v1.4.3 // indirect 39 | github.com/go-logr/stdr v1.2.2 // indirect 40 | github.com/go-ole/go-ole v1.2.6 // indirect 41 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 42 | github.com/go-openapi/swag v0.23.0 // indirect 43 | github.com/go-playground/locales v0.14.1 // indirect 44 | github.com/go-playground/universal-translator v0.18.1 // indirect 45 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 46 | github.com/gogo/protobuf v1.3.2 // indirect 47 | github.com/google/uuid v1.6.0 // indirect 48 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 49 | github.com/jackc/pgpassfile v1.0.0 // indirect 50 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 51 | github.com/jackc/puddle/v2 v2.2.2 // indirect 52 | github.com/josharian/intern v1.0.0 // indirect 53 | github.com/klauspost/compress v1.18.0 // indirect 54 | github.com/leodido/go-urn v1.4.0 // indirect 55 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 56 | github.com/magiconair/properties v1.8.10 // indirect 57 | github.com/mailru/easyjson v0.7.7 // indirect 58 | github.com/mfridman/interpolate v0.0.2 // indirect 59 | github.com/moby/docker-image-spec v1.3.1 // indirect 60 | github.com/moby/go-archive v0.1.0 // indirect 61 | github.com/moby/patternmatcher v0.6.0 // indirect 62 | github.com/moby/sys/sequential v0.6.0 // indirect 63 | github.com/moby/sys/user v0.4.0 // indirect 64 | github.com/moby/sys/userns v0.1.0 // indirect 65 | github.com/moby/term v0.5.0 // indirect 66 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 67 | github.com/morikuni/aec v1.0.0 // indirect 68 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 69 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 70 | github.com/opencontainers/go-digest v1.0.0 // indirect 71 | github.com/opencontainers/image-spec v1.1.1 // indirect 72 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 73 | github.com/perimeterx/marshmallow v1.1.5 // indirect 74 | github.com/pkg/errors v0.9.1 // indirect 75 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 76 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 77 | github.com/sagikazarmark/locafero v0.11.0 // indirect 78 | github.com/sethvargo/go-retry v0.3.0 // indirect 79 | github.com/shirou/gopsutil/v4 v4.25.6 // indirect 80 | github.com/sirupsen/logrus v1.9.3 // indirect 81 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 82 | github.com/spf13/afero v1.15.0 // indirect 83 | github.com/spf13/cast v1.10.0 // indirect 84 | github.com/spf13/pflag v1.0.10 // indirect 85 | github.com/subosito/gotenv v1.6.0 // indirect 86 | github.com/tklauser/go-sysconf v0.3.12 // indirect 87 | github.com/tklauser/numcpus v0.6.1 // indirect 88 | github.com/woodsbury/decimal128 v1.3.0 // indirect 89 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 90 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 91 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 92 | go.opentelemetry.io/otel v1.37.0 // indirect 93 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect 94 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 95 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 96 | go.opentelemetry.io/proto/otlp v1.0.0 // indirect 97 | go.uber.org/multierr v1.11.0 // indirect 98 | go.yaml.in/yaml/v3 v3.0.4 // indirect 99 | golang.org/x/crypto v0.42.0 // indirect 100 | golang.org/x/sync v0.17.0 // indirect 101 | golang.org/x/sys v0.36.0 // indirect 102 | golang.org/x/text v0.29.0 // indirect 103 | gopkg.in/yaml.v3 v3.0.1 // indirect 104 | ) 105 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "embed" 7 | "fmt" 8 | "log/slog" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/jackc/pgx/v5/pgxpool" 14 | "github.com/jackc/pgx/v5/stdlib" 15 | 16 | "github.com/softika/gopherizer/config" 17 | ) 18 | 19 | //go:embed migrations/*.sql 20 | var migrations embed.FS 21 | 22 | func GetMigrationFS() embed.FS { 23 | return migrations 24 | } 25 | 26 | func GetDialect() string { 27 | return "postgres" 28 | } 29 | 30 | // Service represents a service that interacts with a database. 31 | type Service interface { 32 | // Health returns a map of health status information. 33 | // The keys and values in the map are service-specific. 34 | Health(ctx context.Context) map[string]string 35 | 36 | // Close terminates the database connection. 37 | // It returns an error if the connection cannot be closed. 38 | Close() error 39 | 40 | // DB returns the database connection. 41 | DB() *sql.DB 42 | 43 | // Pool returns the pgx connection pool. 44 | Pool() *pgxpool.Pool 45 | } 46 | 47 | type service struct { 48 | pool *pgxpool.Pool 49 | } 50 | 51 | var ( 52 | dbService *service 53 | 54 | once sync.Once 55 | ) 56 | 57 | func New(cfg config.DatabaseConfig) Service { 58 | once.Do(func() { 59 | slog.Info("creating a new database connection pool...") 60 | 61 | ctx := context.Background() 62 | 63 | pool, err := pgxpool.New(ctx, dsnFromConfig(cfg)) 64 | if err != nil { 65 | slog.Error("failed to create db connection pool", "error", err) 66 | panic(err) 67 | } 68 | 69 | if err = pool.Ping(ctx); err != nil { 70 | slog.Error("failed to ping db", "error", err) 71 | panic(err) 72 | } 73 | 74 | dbService = &service{ 75 | pool: pool, 76 | } 77 | }) 78 | 79 | return dbService 80 | } 81 | 82 | // Health checks the health of the database connection by pinging the database. 83 | // It returns a map with keys indicating various health statistics. 84 | func (s *service) Health(ctx context.Context) map[string]string { 85 | stats := make(map[string]string) 86 | 87 | // Ping the database 88 | err := s.pool.Ping(ctx) 89 | if err != nil { 90 | stats["status"] = "down" 91 | stats["error"] = fmt.Sprintf("db down: %v", err) 92 | slog.ErrorContext(ctx, "db is down", "error", err) 93 | return stats 94 | } 95 | 96 | // Database is up, add more statistics 97 | stats["status"] = "up" 98 | stats["message"] = "It's healthy" 99 | 100 | // Logger database stats (like open connections, in use, idle, etc.) 101 | poolStat := s.pool.Stat() 102 | stats["max_connections"] = strconv.Itoa(int(poolStat.MaxConns())) 103 | stats["total_connections"] = strconv.Itoa(int(poolStat.TotalConns())) 104 | stats["acquired_connections"] = strconv.Itoa(int(poolStat.AcquiredConns())) 105 | stats["new_acquired_connections"] = strconv.FormatInt(poolStat.NewConnsCount(), 10) 106 | stats["empty_acquire_count"] = strconv.FormatInt(poolStat.EmptyAcquireCount(), 10) 107 | stats["canceled_acquire_count"] = strconv.FormatInt(poolStat.CanceledAcquireCount(), 10) 108 | stats["acquire_count"] = strconv.FormatInt(poolStat.AcquireCount(), 10) 109 | stats["acquire_duration"] = poolStat.AcquireDuration().String() 110 | stats["idle_connections"] = strconv.Itoa(int(poolStat.IdleConns())) 111 | stats["constructing_connections"] = strconv.Itoa(int(poolStat.ConstructingConns())) 112 | stats["max_idle_destroy_count"] = strconv.FormatInt(poolStat.MaxIdleDestroyCount(), 10) 113 | stats["max_lifetime_destroy_count"] = strconv.FormatInt(poolStat.MaxLifetimeDestroyCount(), 10) 114 | 115 | // Evaluate stats to provide a health message 116 | if poolStat.TotalConns() > 40 { // Assuming 50 is the max for this example 117 | stats["message"] = "The database is experiencing heavy load." 118 | } 119 | 120 | if poolStat.ConstructingConns() > 1000 { 121 | stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks." 122 | } 123 | 124 | if poolStat.MaxIdleDestroyCount() > int64(poolStat.TotalConns())/2 { 125 | stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings." 126 | } 127 | 128 | if poolStat.MaxLifetimeDestroyCount() > int64(poolStat.TotalConns())/2 { 129 | stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern." 130 | } 131 | 132 | return stats 133 | } 134 | 135 | // Close closes the database connection. 136 | // If the connection is successfully closed, it returns nil. 137 | // If an error occurs while closing the connection, it returns the error. 138 | func (s *service) Close() error { 139 | slog.Info("closing the database connection...") 140 | s.pool.Close() 141 | return nil 142 | } 143 | 144 | func dsnFromConfig(config config.DatabaseConfig) string { 145 | dsn := fmt.Sprintf( 146 | "postgresql://%s:%s@%s:%s/%s?sslmode=require", 147 | config.User, config.Password, config.Host, config.Port, config.DBName, 148 | ) 149 | 150 | if config.SSLModeDisabled { 151 | dsn = strings.Replace(dsn, "sslmode=require", "sslmode=disable", 1) 152 | } 153 | 154 | return dsn 155 | } 156 | 157 | func (s *service) DB() *sql.DB { 158 | return stdlib.OpenDBFromPool(s.pool) 159 | } 160 | 161 | func (s *service) Pool() *pgxpool.Pool { 162 | return s.pool 163 | } 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![go workflow](https://github.com/softika/gopherizer/actions/workflows/test.yml/badge.svg) 2 | ![lint workflow](https://github.com/softika/gopherizer/actions/workflows/lint.yml/badge.svg) 3 | 4 | # Gopherizer 5 | 6 | The motivation behind creating this template repository was to establish a unified architecture across multiple repositories, eliminating the need to repeat boilerplate code. This approach not only ensures consistency and maintainability but also provides the flexibility to extend and adapt the architecture as needed. By leveraging this template, teams can focus on developing unique features rather than reinventing the wheel for each project. 7 | 8 | ## Features 9 | - ✅ HTTP Server run with graceful shutdown 10 | - ✅ Routing with [Chi](https://go-chi.io/#/README) - easy to swap with other routers 11 | - ✅ Database Service (Postgres) 12 | - ✅ Migrations ([goose](https://github.com/pressly/goose)) 13 | - ✅ Dynamic configuration 14 | - ✅ Structured [logging](https://github.com/softika/slogging) 15 | - ✅ Centralized error Handling 16 | - ✅ Integration testing with [Testcontainers](https://golang.testcontainers.org/) 17 | - ✅ CI Pipeline (GitHub Actions) 18 | - ✅ Dockerized development environment 19 | - ✅ OpenAPI Documentation 20 | - 🏗️ OpenTelemetry 21 | 22 | 23 | ## Project Structure 24 | 25 | - [api/](api) - http server, handlers and routes. More about api [here](api/README.md). 26 | - [cmd/](cmd) - cli commands, `serve` and `migrate`. 27 | - [config/](config) - configuration and loading environment variables. More about config [here](config/README.md). 28 | - [database/](database) - database service, repositories and migration files. More about database [here](database/README.md). 29 | - [internal/](internal) - core logic, `services` as business use cases and `model` as domain entities. More about internal [here](internal/README.md). 30 | - [pkg/](pkg) - reusable packages. 31 | - [tests/](tests) - e2e tests. 32 | 33 | ### Building and running your application 34 | When you're ready, start your application by running: 35 | 36 | ``` bash 37 | make start 38 | ``` 39 | 40 | Your application will be available at http://localhost:8080. 41 | 42 | ### Deploying your application to the cloud 43 | 44 | First, build your image, e.g.: `docker build -t myapp .`. 45 | If your cloud uses a different CPU architecture than your development 46 | machine (e.g., you are on a Mac M1 and your cloud provider is amd64), 47 | you'll want to build the image for that platform, e.g.: 48 | `docker build --platform=linux/amd64 -t myapp .`. 49 | 50 | Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. 51 | 52 | Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) 53 | docs for more detail on building and pushing. 54 | 55 | #### References 56 | * [Docker's Go guide](https://docs.docker.com/language/golang/) 57 | 58 | ### Environment Config 59 | 60 | All required environment variables for running this service are defined in `config/default.toml`. 61 | 62 | Values in this file can be overridden by setting the corresponding environment variables. For example: 63 | 64 | - Set `ENVIRONMENT` to change the `environment` value 65 | - Set `HTTP_HOST` to adjust the `http.host` value to your desired setting 66 | 67 | Additionally, you can use [direnv](https://direnv.net/) to define environment variables on a per-workspace basis. 68 | 69 | #### Environment Struct 70 | 71 | The `Config` struct is organized into sections to improve readability and make it easier to pass specific configurations to downstream services. 72 | For instance, the database service only needs `DatabaseConfig` rather than the entire configuration object. 73 | 74 | All configuration sections are contained within the `Config` struct, which holds every configuration used in the service. 75 | Each individual configuration is defined as a struct within `Config`, enabling selective passing of specific configurations to downstream services. 76 | 77 | example: 78 | 79 | ```go 80 | package database 81 | 82 | import ( 83 | "context" 84 | "fmt" 85 | 86 | // pgx 87 | "github.com/jackc/pgx/v5/pgxpool" 88 | 89 | "github.com/softika/gopherizer/config" 90 | ) 91 | 92 | type Service struct { 93 | pool *pgxpool.Pool 94 | } 95 | 96 | func New(cfg config.DatabaseConfig) Service { 97 | dsn := fmt.Sprintf( 98 | "postgresql://%s:%s@%s:%s/%s?sslmode=require", 99 | cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, 100 | ) 101 | 102 | ctx := context.Background() 103 | 104 | pool, err := pgxpool.New(ctx, dsn) 105 | if err != nil { 106 | panic(err) 107 | } 108 | 109 | if err = pool.Ping(ctx); err != nil { 110 | panic(err) 111 | } 112 | 113 | return Service{ 114 | pool: pool, 115 | } 116 | } 117 | ``` 118 | ##### AppConfig 119 | 120 | AppConfig provides essential application settings, including the name, environment, and version. 121 | These settings are typically used for observability, 122 | allowing us to identify the service version and the environment in which it is running. 123 | 124 | ### Adding new migration file 125 | 126 | We use [goose](https://github.com/pressly/goose) to run 127 | SQL database migration and managing migration files. 128 | 129 | To create a new migration file. 130 | ```sh 131 | goose -dir database/migrations create xxx sql 132 | ``` 133 | 134 | ### Generating mocks 135 | 136 | We use [gomock](https://github.com/uber-go/mock) to generate mocks. 137 | 138 | If you change the interface make sure to always run this command: 139 | ```sh 140 | make mocks 141 | ``` 142 | 143 | ## Testing 144 | 145 | To run the tests locally, run `make test` to run all the unit tests 146 | or run `go ./... -run ` to run specific unit test. 147 | 148 | By default `make test` will run the tests in parallel n-times. 149 | You can also do this manually by running: `go test ./... -parallel -count=5` 150 | 151 | 152 | ## MakeFile 153 | 154 | Check the [Makefile](Makefile) for more available commands.
155 | Run `make help` to see all available commands. 156 | 157 | ## License 158 | 159 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 160 | 161 | ## Contributing 162 | 163 | If you have any suggestions, questions or want to contribute, feel free to create an issue or a pull request. -------------------------------------------------------------------------------- /api/docs/v1/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | 3 | info: 4 | title: Gopherizer API 5 | description: Gopherizer is a template API. Provides as an example CRUD operations for profile resources. 6 | version: 1.0.0 7 | contact: 8 | name: Softika Team 9 | email: office@softika.dev 10 | license: 11 | name: MIT 12 | url: https://opensource.org/licenses/MIT 13 | 14 | paths: 15 | /api/v1/profile: 16 | post: 17 | tags: 18 | - Profile 19 | summary: Create profile 20 | requestBody: 21 | required: true 22 | content: 23 | application/json: 24 | schema: 25 | $ref: '#/components/schemas/CreateRequest' 26 | responses: 27 | '201': 28 | description: Profile created successfully. 29 | content: 30 | application/json: 31 | schema: 32 | $ref: '#/components/schemas/ProfileResponse' 33 | '400': 34 | description: The request is invalid. Check the input for missing or incorrect fields. 35 | content: 36 | application/json: 37 | schema: 38 | $ref: '#/components/schemas/ErrorResponse' 39 | '500': 40 | description: An unexpected error occurred on the server. 41 | content: 42 | application/json: 43 | schema: 44 | $ref: '#/components/schemas/ErrorResponse' 45 | 46 | put: 47 | tags: 48 | - Profile 49 | summary: Update profile 50 | requestBody: 51 | required: true 52 | content: 53 | application/json: 54 | schema: 55 | $ref: '#/components/schemas/UpdateRequest' 56 | responses: 57 | '200': 58 | description: Profile updated successfully. 59 | content: 60 | application/json: 61 | schema: 62 | $ref: '#/components/schemas/ProfileResponse' 63 | '400': 64 | description: The request is invalid. Check the input for missing or incorrect fields. 65 | content: 66 | application/json: 67 | schema: 68 | $ref: '#/components/schemas/ErrorResponse' 69 | '500': 70 | description: An unexpected error occurred on the server. 71 | content: 72 | application/json: 73 | schema: 74 | $ref: '#/components/schemas/ErrorResponse' 75 | 76 | /api/v1/profile/{id}: 77 | get: 78 | tags: 79 | - Profile 80 | summary: Get profile 81 | parameters: 82 | - name: id 83 | in: path 84 | required: true 85 | schema: 86 | type: string 87 | format: uuid 88 | responses: 89 | '200': 90 | description: Successfully retrieved profile. 91 | '400': 92 | description: The request is invalid. Check the input for missing or incorrect fields. 93 | content: 94 | application/json: 95 | schema: 96 | $ref: '#/components/schemas/ErrorResponse' 97 | '404': 98 | description: Profile not found. 99 | content: 100 | application/json: 101 | schema: 102 | $ref: '#/components/schemas/ErrorResponse' 103 | '500': 104 | description: An unexpected error occurred on the server. 105 | content: 106 | application/json: 107 | schema: 108 | $ref: '#/components/schemas/ErrorResponse' 109 | 110 | delete: 111 | tags: 112 | - Profile 113 | summary: Delete profile 114 | parameters: 115 | - name: id 116 | in: path 117 | required: true 118 | schema: 119 | type: string 120 | format: uuid 121 | responses: 122 | '204': 123 | description: Successfully deleted profile. 124 | '400': 125 | description: The request is invalid. Check the input for missing or incorrect fields. 126 | content: 127 | application/json: 128 | schema: 129 | $ref: '#/components/schemas/ErrorResponse' 130 | '405': 131 | description: Invalid profile id. 132 | content: 133 | application/json: 134 | schema: 135 | $ref: '#/components/schemas/ErrorResponse' 136 | '500': 137 | description: An unexpected error occurred on the server. 138 | content: 139 | application/json: 140 | schema: 141 | $ref: '#/components/schemas/ErrorResponse' 142 | 143 | components: 144 | schemas: 145 | ErrorResponse: 146 | type: object 147 | properties: 148 | code: 149 | type: integer 150 | example: 400 151 | message: 152 | type: string 153 | example: "Invalid input data" 154 | 155 | CreateRequest: 156 | type: object 157 | properties: 158 | FirstName: 159 | type: string 160 | maxLength: 72 161 | nullable: false 162 | example: John 163 | LastName: 164 | type: string 165 | maxLength: 72 166 | nullable: false 167 | example: Doe 168 | required: 169 | - FirstName 170 | - LastName 171 | 172 | UpdateRequest: 173 | type: object 174 | properties: 175 | Id: 176 | type: string 177 | format: uuid 178 | nullable: false 179 | example: 123e4567-e89b-12d3-a456-426614174000 180 | FirstName: 181 | type: string 182 | maxLength: 72 183 | nullable: false 184 | example: John 185 | LastName: 186 | type: string 187 | maxLength: 72 188 | nullable: false 189 | example: Doe 190 | required: 191 | - Id 192 | - FirstName 193 | - LastName 194 | 195 | ProfileResponse: 196 | type: object 197 | properties: 198 | Id: 199 | type: string 200 | format: uuid 201 | example: 123e4567-e89b-12d3-a456-426614174000 202 | FirstName: 203 | type: string 204 | example: John 205 | LastName: 206 | type: string 207 | example: Doe 208 | CreatedAt: 209 | type: string 210 | format: date-time 211 | example: "2021-10-01T12:00:00Z" 212 | UpdatedAt: 213 | type: string 214 | format: date-time 215 | example: "2021-10-01T12:00:00Z" 216 | required: 217 | - Id 218 | - FirstName 219 | - LastName 220 | - CreatedAt 221 | - UpdatedAt 222 | -------------------------------------------------------------------------------- /tests/profile_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | 9 | "github.com/softika/gopherizer/internal/profile" 10 | ) 11 | 12 | func (s *E2ETestSuite) TestCreateProfileHandler() { 13 | cases := []struct { 14 | name string 15 | req profile.CreateRequest 16 | wantCode int 17 | }{ 18 | { 19 | name: "create profile", 20 | req: profile.CreateRequest{ 21 | FirstName: "John", 22 | LastName: "Snow", 23 | }, 24 | wantCode: http.StatusCreated, 25 | }, 26 | { 27 | name: "create profile with empty request", 28 | req: profile.CreateRequest{}, 29 | wantCode: http.StatusBadRequest, 30 | }, 31 | { 32 | name: "create profile with empty first name", 33 | req: profile.CreateRequest{ 34 | FirstName: "", 35 | LastName: "Snow", 36 | }, 37 | wantCode: http.StatusBadRequest, 38 | }, 39 | { 40 | name: "create profile with empty last name", 41 | req: profile.CreateRequest{ 42 | FirstName: "John", 43 | LastName: "", 44 | }, 45 | wantCode: http.StatusBadRequest, 46 | }, 47 | { 48 | name: "create profile with long first name", 49 | req: profile.CreateRequest{ 50 | FirstName: "John John John John John John John John John John John John John John John John John John John John John", 51 | LastName: "Snow", 52 | }, 53 | wantCode: http.StatusBadRequest, 54 | }, 55 | } 56 | 57 | for _, tc := range cases { 58 | tt := tc 59 | s.Run(tt.name, func() { 60 | s.T().Parallel() 61 | 62 | // given 63 | body, err := json.Marshal(tt.req) 64 | s.NoError(err) 65 | 66 | req := httptest.NewRequest(http.MethodPost, "/api/v1/profile", bytes.NewReader(body)) 67 | w := httptest.NewRecorder() 68 | 69 | // when 70 | s.router.ServeHTTP(w, req) 71 | 72 | // then 73 | s.Equal(tt.wantCode, w.Code) 74 | s.NotEmpty(s.T(), w.Body.String()) 75 | 76 | if w.Code != http.StatusCreated { 77 | return 78 | } 79 | 80 | var res profile.Response 81 | err = json.Unmarshal(w.Body.Bytes(), &res) 82 | 83 | s.NoError(err) 84 | s.NotEmpty(res.Id) 85 | s.NotEmpty(res.CreatedAt) 86 | s.NotEmpty(res.UpdatedAt) 87 | s.Equal(tt.req.FirstName, res.FirstName) 88 | s.Equal(tt.req.LastName, res.LastName) 89 | }) 90 | } 91 | } 92 | 93 | func (s *E2ETestSuite) TestGetProfileHandler() { 94 | cases := []struct { 95 | name string 96 | id string 97 | wantCode int 98 | }{ 99 | { 100 | name: "get profile by id", 101 | id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb4", //John Doe 102 | wantCode: http.StatusOK, 103 | }, 104 | { 105 | name: "get profile by invalid id", 106 | id: "invalid", 107 | wantCode: http.StatusBadRequest, 108 | }, 109 | { 110 | name: "get profile by non-existent id", 111 | id: "e72e6527-6496-43ee-961f-e9d2b97bbdf3", // non-existent 112 | wantCode: http.StatusNotFound, 113 | }, 114 | { 115 | name: "get profile by empty id", 116 | id: "", 117 | wantCode: http.StatusMethodNotAllowed, 118 | }, 119 | } 120 | 121 | for _, tc := range cases { 122 | tt := tc 123 | s.Run(tt.name, func() { 124 | s.T().Parallel() 125 | 126 | // given 127 | req := httptest.NewRequest(http.MethodGet, "/api/v1/profile/"+tt.id, nil) 128 | w := httptest.NewRecorder() 129 | 130 | // when 131 | s.router.ServeHTTP(w, req) 132 | 133 | // then 134 | s.Equal(tt.wantCode, w.Code) 135 | s.NotEmpty(s.T(), w.Body.String()) 136 | 137 | if w.Code != http.StatusOK { 138 | return 139 | } 140 | 141 | var res profile.Response 142 | err := json.Unmarshal(w.Body.Bytes(), &res) 143 | 144 | s.NoError(err) 145 | s.NotEmpty(res.Id) 146 | s.NotEmpty(res.CreatedAt) 147 | s.NotEmpty(res.UpdatedAt) 148 | }) 149 | } 150 | } 151 | 152 | func (s *E2ETestSuite) TestUpdateProfileHandler() { 153 | cases := []struct { 154 | name string 155 | req profile.UpdateRequest 156 | wantCode int 157 | }{ 158 | { 159 | name: "update profile", 160 | req: profile.UpdateRequest{ 161 | Id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb6", // Alice Wonderland 162 | FirstName: "Jane", 163 | LastName: "Doe", 164 | }, 165 | wantCode: http.StatusOK, 166 | }, 167 | { 168 | name: "update profile with empty request", 169 | req: profile.UpdateRequest{}, 170 | wantCode: http.StatusBadRequest, 171 | }, 172 | { 173 | name: "update profile with empty first name", 174 | req: profile.UpdateRequest{ 175 | Id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb6", // Alice Wonderland 176 | FirstName: "", 177 | LastName: "Doe", 178 | }, 179 | wantCode: http.StatusBadRequest, 180 | }, 181 | { 182 | name: "update profile with empty last name", 183 | req: profile.UpdateRequest{ 184 | Id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb6", // Alice Wonderland 185 | FirstName: "Jane", 186 | LastName: "", 187 | }, 188 | wantCode: http.StatusBadRequest, 189 | }, 190 | { 191 | name: "update profile with long first name", 192 | req: profile.UpdateRequest{ 193 | Id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb6", // Alice Wonderland 194 | FirstName: "John John John John John John John John John John John John John John John John John John John John", 195 | LastName: "Doe", 196 | }, 197 | wantCode: http.StatusBadRequest, 198 | }, 199 | { 200 | name: "update profile with invalid id", 201 | req: profile.UpdateRequest{ 202 | Id: "invalid", 203 | FirstName: "Jane", 204 | LastName: "Doe", 205 | }, 206 | wantCode: http.StatusBadRequest, 207 | }, 208 | { 209 | name: "update profile with non-existent id", 210 | req: profile.UpdateRequest{ 211 | Id: "999", 212 | FirstName: "Jane", 213 | LastName: "Doe", 214 | }, 215 | wantCode: http.StatusBadRequest, 216 | }, 217 | } 218 | 219 | for _, tc := range cases { 220 | tt := tc 221 | s.Run(tt.name, func() { 222 | s.T().Parallel() 223 | 224 | // given 225 | body, err := json.Marshal(tt.req) 226 | s.NoError(err) 227 | 228 | req := httptest.NewRequest(http.MethodPut, "/api/v1/profile", bytes.NewReader(body)) 229 | w := httptest.NewRecorder() 230 | 231 | // when 232 | s.router.ServeHTTP(w, req) 233 | 234 | // then 235 | s.Equal(tt.wantCode, w.Code) 236 | s.NotEmpty(s.T(), w.Body.String()) 237 | 238 | if w.Code != http.StatusOK { 239 | return 240 | } 241 | 242 | var res profile.Response 243 | err = json.Unmarshal(w.Body.Bytes(), &res) 244 | 245 | s.NoError(err) 246 | s.Equal(tt.req.Id, res.Id) 247 | s.Equal(tt.req.FirstName, res.FirstName) 248 | s.Equal(tt.req.LastName, res.LastName) 249 | }) 250 | } 251 | } 252 | 253 | func (s *E2ETestSuite) TestDeleteProfileHandler() { 254 | cases := []struct { 255 | name string 256 | id string 257 | wantCode int 258 | }{ 259 | { 260 | name: "delete profile by id", 261 | id: "0dd35f9a-0d20-41f1-80c2-d7993e313fb7", // Bob Builder 262 | wantCode: http.StatusNoContent, 263 | }, 264 | { 265 | name: "delete profile by invalid id", 266 | id: "invalid", 267 | wantCode: http.StatusBadRequest, 268 | }, 269 | { 270 | name: "delete profile by non-existent id", 271 | id: "999", 272 | wantCode: http.StatusBadRequest, 273 | }, 274 | { 275 | name: "delete profile by empty id", 276 | id: "", 277 | wantCode: http.StatusMethodNotAllowed, 278 | }, 279 | } 280 | 281 | for _, tc := range cases { 282 | tt := tc 283 | s.Run(tt.name, func() { 284 | s.T().Parallel() 285 | 286 | // given 287 | req := httptest.NewRequest(http.MethodDelete, "/api/v1/profile/"+tt.id, nil) 288 | w := httptest.NewRecorder() 289 | 290 | // when 291 | s.router.ServeHTTP(w, req) 292 | 293 | // then 294 | s.Equal(tt.wantCode, w.Code) 295 | 296 | if w.Code != http.StatusNoContent { 297 | return 298 | } 299 | 300 | // check if the profile is deleted 301 | req = httptest.NewRequest(http.MethodGet, "/api/v1/profile/"+tt.id, nil) 302 | w = httptest.NewRecorder() 303 | s.router.ServeHTTP(w, req) 304 | s.Equal(http.StatusNotFound, w.Code) 305 | }) 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 | dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= 4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 5 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 6 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 8 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 9 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 10 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 11 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 12 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 13 | github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= 14 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 15 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 16 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 17 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 18 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 19 | github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= 20 | github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 21 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 22 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 23 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 29 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 30 | github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= 31 | github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 32 | github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= 33 | github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 34 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 35 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 36 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 37 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 38 | github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= 39 | github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 40 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 41 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 42 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 43 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 44 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 45 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 46 | github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= 47 | github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 48 | github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= 49 | github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= 50 | github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 51 | github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 52 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 53 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 54 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 55 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 56 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 57 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 58 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 59 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 60 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 61 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 62 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 63 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 64 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 65 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 66 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 67 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 68 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 69 | github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= 70 | github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= 71 | github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= 72 | github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 73 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 74 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 75 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 76 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 77 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 78 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 79 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 80 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 81 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 82 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= 83 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= 84 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 85 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 86 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 87 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 88 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 89 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 90 | github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= 91 | github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 92 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 93 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 94 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 95 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 96 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 97 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 98 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 99 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 100 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 101 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 102 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 103 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 104 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 105 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 106 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 107 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 108 | github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= 109 | github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 110 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 111 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 112 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 113 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 114 | github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= 115 | github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 116 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 117 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 118 | github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= 119 | github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= 120 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 121 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 122 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= 123 | github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= 124 | github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= 125 | github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 126 | github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= 127 | github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= 128 | github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= 129 | github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= 130 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 131 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 132 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 133 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 134 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 135 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 136 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 137 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 138 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= 139 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= 140 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= 141 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= 142 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 143 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 144 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 145 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 146 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 147 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 148 | github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= 149 | github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 150 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 151 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 152 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 153 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 154 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 155 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 156 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 157 | github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= 158 | github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= 159 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 160 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 161 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 162 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 163 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 164 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 165 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 166 | github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= 167 | github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 168 | github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= 169 | github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= 170 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 171 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 172 | github.com/softika/slogging v1.0.2 h1:nkD2QRiTpu1RvfXwE1aT+EtrjeCyXzqKFUXmgEE1yI4= 173 | github.com/softika/slogging v1.0.2/go.mod h1:FKEuMBNbYrYKguXVgaVdI6m1EwGFpNqQe6ssTGRWwUY= 174 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 175 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 176 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 177 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 178 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 179 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 180 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 181 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 182 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 183 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 184 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 185 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 186 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 187 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 188 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 189 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 190 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 191 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 192 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 193 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 194 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 195 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 196 | github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= 197 | github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= 198 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 199 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 200 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 201 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 202 | github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= 203 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 204 | github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= 205 | github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= 206 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 207 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 208 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 209 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 210 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 211 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 212 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= 213 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 214 | go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 215 | go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 216 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= 217 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= 218 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= 219 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= 220 | go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 221 | go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 222 | go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= 223 | go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= 224 | go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 225 | go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 226 | go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= 227 | go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= 228 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= 229 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 230 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 231 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 232 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 233 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 234 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 235 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 236 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 237 | golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= 238 | golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 239 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 240 | golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 241 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 242 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 243 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 244 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 245 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 246 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 247 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 248 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 249 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 250 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 251 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 252 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 253 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 254 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 255 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 256 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 257 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 258 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 259 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 260 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 261 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 262 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 263 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 264 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 265 | golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= 266 | golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 267 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 268 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 269 | golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 270 | golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 271 | golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= 272 | golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 273 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 274 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 275 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 276 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 277 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 278 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 279 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 280 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 281 | google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= 282 | google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= 283 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= 284 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 285 | google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= 286 | google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= 287 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 288 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 289 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 290 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 291 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 292 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 293 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 294 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 295 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 296 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 297 | modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= 298 | modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= 299 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 300 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 301 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 302 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 303 | modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= 304 | modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= 305 | --------------------------------------------------------------------------------