├── 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 | 
2 | 
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 |
--------------------------------------------------------------------------------