From 53ab1aee4ff3c8b39168affa30433294ce2466d2 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 12:57:47 +0200 Subject: [PATCH 1/6] Added simple database connection --- cmd/api-server/main.go | 17 +++++++++++++++++ go.mod | 5 +++++ internal/database/db.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 internal/database/db.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 45a82f4..3f9024b 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -15,6 +15,7 @@ import ( "github.com/joho/godotenv" "github.com/robindittmar/dttmr-api/internal/api/router" + "github.com/robindittmar/dttmr-api/internal/database" "github.com/robindittmar/dttmr-api/internal/telemetry" ) @@ -22,6 +23,7 @@ type Config struct { Environment string Port int OTLPEndpoint string + DatabaseURL string } func main() { @@ -62,6 +64,18 @@ func run(serviceName string, serviceVersion string) error { } }() + db, err := database.New(context.Background(), cfg.DatabaseURL) + if err != nil { + slog.Error("Failed to initialize database", slog.Any("error", err)) + return err + } + defer func() { + err := db.Close() + if err != nil { + slog.Error("Failed to close database connection", slog.Any("error", err)) + } + }() + srv := makeServer(cfg) go func() { slog.Info("Starting http server", "addr", srv.Addr) @@ -103,6 +117,7 @@ func loadConfig() *Config { envFlag := flag.String("env", "development", "environment to use") portFlag := flag.Int("port", 8080, "port to listen on") otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint") + databaseUrlFlag := flag.String("database-url", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", "database connection string") flag.Parse() @@ -110,11 +125,13 @@ func loadConfig() *Config { Environment: *envFlag, Port: *portFlag, OTLPEndpoint: *otlpEndpointFlag, + DatabaseURL: *databaseUrlFlag, } assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment) assignIntFromEnv("DTTMR_PORT", &cfg.Port) assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint) + assignStringFromEnv("DTTMR_DATABASE_URL", &cfg.DatabaseURL) return cfg } diff --git a/go.mod b/go.mod index ac214a6..d23146b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/robindittmar/dttmr-api go 1.26 require ( + github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel v1.44.0 @@ -21,11 +22,15 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..7b82c3e --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,29 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func New(ctx context.Context, connURL string) (*sql.DB, error) { + db, err := sql.Open("pgx", connURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(10) + + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(5 * time.Minute) + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("database unreachable: %w", err) + } + + return db, nil +} From dee308def7cc85aef4d628d60bf6929e421564be Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 12:59:46 +0200 Subject: [PATCH 2/6] Moved config to own module --- cmd/api-server/main.go | 54 +++----------------------------------- internal/config/config.go | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 51 deletions(-) create mode 100644 internal/config/config.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 3f9024b..cf855ba 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -3,29 +3,21 @@ package main import ( "context" "errors" - "flag" "fmt" "log/slog" "net/http" "os" "os/signal" - "strconv" "syscall" "time" "github.com/joho/godotenv" "github.com/robindittmar/dttmr-api/internal/api/router" + "github.com/robindittmar/dttmr-api/internal/config" "github.com/robindittmar/dttmr-api/internal/database" "github.com/robindittmar/dttmr-api/internal/telemetry" ) -type Config struct { - Environment string - Port int - OTLPEndpoint string - DatabaseURL string -} - func main() { serviceName := "dttmr-api" serviceVersion := "0.1.0" @@ -42,7 +34,7 @@ func run(serviceName string, serviceVersion string) error { slog.Info("Starting service", slog.String("service", serviceName), slog.String("version", serviceVersion)) - cfg := loadConfig() + cfg := config.Load() telCfg := telemetry.Config{ ServiceName: serviceName, @@ -113,47 +105,7 @@ func setupLogging() { slog.SetDefault(logger) } -func loadConfig() *Config { - envFlag := flag.String("env", "development", "environment to use") - portFlag := flag.Int("port", 8080, "port to listen on") - otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint") - databaseUrlFlag := flag.String("database-url", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", "database connection string") - - flag.Parse() - - cfg := &Config{ - Environment: *envFlag, - Port: *portFlag, - OTLPEndpoint: *otlpEndpointFlag, - DatabaseURL: *databaseUrlFlag, - } - - assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment) - assignIntFromEnv("DTTMR_PORT", &cfg.Port) - assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint) - assignStringFromEnv("DTTMR_DATABASE_URL", &cfg.DatabaseURL) - - return cfg -} - -func assignStringFromEnv(key string, target *string) { - if val, exists := os.LookupEnv(key); exists { - *target = val - } -} - -func assignIntFromEnv(key string, target *int) { - if val, exists := os.LookupEnv(key); exists { - parsed, err := strconv.Atoi(val) - if err != nil { - slog.Error("Failed to parse environment variable", slog.String("var", key), slog.Any("error", err)) - } else { - *target = parsed - } - } -} - -func makeServer(cfg *Config) *http.Server { +func makeServer(cfg *config.Config) *http.Server { routerConfig := router.Config{} mux := router.NewMux(routerConfig) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9c764bc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,55 @@ +package config + +import ( + "flag" + "log/slog" + "os" + "strconv" +) + +type Config struct { + Environment string + Port int + OTLPEndpoint string + DatabaseURL string +} + +func Load() *Config { + envFlag := flag.String("env", "development", "environment to use") + portFlag := flag.Int("port", 8080, "port to listen on") + otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint") + databaseUrlFlag := flag.String("database-url", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", "database connection string") + + flag.Parse() + + cfg := &Config{ + Environment: *envFlag, + Port: *portFlag, + OTLPEndpoint: *otlpEndpointFlag, + DatabaseURL: *databaseUrlFlag, + } + + assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment) + assignIntFromEnv("DTTMR_PORT", &cfg.Port) + assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint) + assignStringFromEnv("DTTMR_DATABASE_URL", &cfg.DatabaseURL) + + return cfg +} + +func assignStringFromEnv(key string, target *string) { + if val, exists := os.LookupEnv(key); exists { + *target = val + } +} + +func assignIntFromEnv(key string, target *int) { + if val, exists := os.LookupEnv(key); exists { + parsed, err := strconv.Atoi(val) + if err != nil { + slog.Error("Failed to parse environment variable", slog.String("var", key), slog.Any("error", err)) + } else { + *target = parsed + } + } +} From d17eb34c2569882efb0736d573d4e4c54d0e6f0a Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 13:05:03 +0200 Subject: [PATCH 3/6] Removed unnecessary "double logging" of error in main() function --- cmd/api-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index cf855ba..c868881 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -23,7 +23,7 @@ func main() { serviceVersion := "0.1.0" if err := run(serviceName, serviceVersion); err != nil { - slog.Error("Service crashed", slog.Any("error", err)) + slog.Error("Service crashed") os.Exit(1) } } From 85602dbf95e3a44742b444c15e7c0700a8479053 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 14:08:47 +0200 Subject: [PATCH 4/6] Added "create list" handler, service + repository --- cmd/api-server/main.go | 11 +++--- internal/api/handler/list.go | 35 +++++++++++++++++++ internal/api/request/.gitkeep | 0 internal/api/request/list.go | 27 +++++++++++++++ internal/api/router/router.go | 14 +++++++- internal/domain/.gitkeep | 0 internal/domain/list.go | 37 ++++++++++++++++++++ internal/repository/list.go | 64 +++++++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 internal/api/handler/list.go delete mode 100644 internal/api/request/.gitkeep create mode 100644 internal/api/request/list.go delete mode 100644 internal/domain/.gitkeep create mode 100644 internal/domain/list.go create mode 100644 internal/repository/list.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index c868881..e654bee 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" @@ -68,7 +69,7 @@ func run(serviceName string, serviceVersion string) error { } }() - srv := makeServer(cfg) + srv := makeServer(db, cfg.Port) go func() { slog.Info("Starting http server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -105,12 +106,14 @@ func setupLogging() { slog.SetDefault(logger) } -func makeServer(cfg *config.Config) *http.Server { - routerConfig := router.Config{} +func makeServer(db *sql.DB, port int) *http.Server { + routerConfig := router.Config{ + Database: db, + } mux := router.NewMux(routerConfig) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Port), + Addr: fmt.Sprintf(":%d", port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go new file mode 100644 index 0000000..513c5ce --- /dev/null +++ b/internal/api/handler/list.go @@ -0,0 +1,35 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/robindittmar/dttmr-api/internal/api/request" + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type ListHandler struct { + ListService *domain.ListService +} + +func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeCreateList(r) + if err != nil { + slog.ErrorContext(ctx, "Failed to decode create list payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "Failed to decode request body") + return + } + + list, err := h.ListService.Create(ctx, payload.Name, payload.UserIDs) + if err != nil { + slog.ErrorContext(ctx, "Failed to create list", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "Failed to create list") + return + } + + slog.InfoContext(ctx, "Created list successfully", slog.Any("list_id", list.ID)) + response.JSON(ctx, w, http.StatusCreated, list) +} diff --git a/internal/api/request/.gitkeep b/internal/api/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/api/request/list.go b/internal/api/request/list.go new file mode 100644 index 0000000..be4f909 --- /dev/null +++ b/internal/api/request/list.go @@ -0,0 +1,27 @@ +package request + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type CreateListPayload struct { + Name string `json:"name"` + UserIDs []string `json:"user_ids"` +} + +func DecodeCreateList(r *http.Request) (CreateListPayload, error) { + var payload CreateListPayload + + r.Body = http.MaxBytesReader(nil, r.Body, 1024*64) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("error decoding create list payload: %w", err) + } + + return payload, nil +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index f4586b5..8ca038f 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -1,20 +1,32 @@ package router import ( + "database/sql" "net/http" "github.com/robindittmar/dttmr-api/internal/api/handler" "github.com/robindittmar/dttmr-api/internal/api/middleware" + "github.com/robindittmar/dttmr-api/internal/domain" + "github.com/robindittmar/dttmr-api/internal/repository" ) -type Config struct{} +type Config struct { + Database *sql.DB +} func NewMux(cfg Config) http.Handler { + listRepo := repository.NewListRepo(cfg.Database) + listService := domain.NewListService(listRepo) + + listHandler := handler.ListHandler{ListService: listService} + mux := http.NewServeMux() mux.HandleFunc("/", handler.DefaultHandler) mux.HandleFunc("GET /health", handler.HealthHandler) + mux.HandleFunc("POST /lists", listHandler.CreateList) + var httpHandler http.Handler = mux httpHandler = middleware.WithTelemetry(httpHandler) diff --git a/internal/domain/.gitkeep b/internal/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/domain/list.go b/internal/domain/list.go new file mode 100644 index 0000000..283ff0d --- /dev/null +++ b/internal/domain/list.go @@ -0,0 +1,37 @@ +package domain + +import ( + "context" + "errors" + "time" +) + +type List struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type ListRepository interface { + CreateList(ctx context.Context, name string, userIDs []string) (*List, error) +} + +type ListService struct { + repo ListRepository +} + +func NewListService(repo ListRepository) *ListService { + return &ListService{repo: repo} +} + +func (s *ListService) Create(ctx context.Context, name string, userIDs []string) (*List, error) { + if len(userIDs) == 0 { + return nil, errors.New("users must have at least one associated user") + } + + if name == "" { + return nil, errors.New("list name must not be empty") + } + + return s.repo.CreateList(ctx, name, userIDs) +} diff --git a/internal/repository/list.go b/internal/repository/list.go new file mode 100644 index 0000000..3671bc1 --- /dev/null +++ b/internal/repository/list.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type ListRepo struct { + db *sql.DB +} + +func NewListRepo(db *sql.DB) *ListRepo { + return &ListRepo{db: db} +} + +func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { + err := tx.Rollback() + if err != nil { + slog.Error("Failed to rollback transaction", slog.Any("error", err)) + } + }() + + list := &domain.List{Name: name} + + err = tx.QueryRowContext(ctx, + "INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at", + name, + ).Scan(&list.ID, &list.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to insert list: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)") + if err != nil { + return nil, fmt.Errorf("failed to prepare user/list association statement: %w", err) + } + defer func() { + err := stmt.Close() + if err != nil { + + } + }() + + for _, userID := range userIDs { + if _, err = stmt.ExecContext(ctx, list.ID, userID); err != nil { + return nil, fmt.Errorf("failed to insert user/list association: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + return list, err +} From 69fb473348184934734fb4c2c81ba3031a3dd925 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 14:09:29 +0200 Subject: [PATCH 5/6] Added .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env From 4472bd24e81579f1fc59df8276d9dde99f77fccb Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 14:10:06 +0200 Subject: [PATCH 6/6] Added docker compose for local development --- docker/compose.yml | 33 +++++++++++++++++++++++++++++++ docker/otel-collector-config.yaml | 23 +++++++++++++++++++++ docker/prometheus.yml | 7 +++++++ 3 files changed, 63 insertions(+) create mode 100644 docker/compose.yml create mode 100644 docker/otel-collector-config.yaml create mode 100644 docker/prometheus.yml diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..3afd106 --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,33 @@ +services: + otel-collector: + image: otel/opentelemetry-collector:latest + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" + + jaeger: + image: jaegertracing/all-in-one:latest + environment: + - COLLECTOR_OTLP_ENABLED=true + ports: + - "16686:16686" + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + postgres: + image: postgres:latest + volumes: + - ./postgresql-data:/var/lib/postgresql/data + ports: + - "15432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=dttmr diff --git a/docker/otel-collector-config.yaml b/docker/otel-collector-config.yaml new file mode 100644 index 0000000..3c9d71b --- /dev/null +++ b/docker/otel-collector-config.yaml @@ -0,0 +1,23 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +exporters: + otlp/jaeger: + endpoint: jaeger:4317 + tls: + insecure: true + + prometheus: + endpoint: 0.0.0.0:8889 + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp/jaeger] + metrics: + receivers: [otlp] + exporters: [prometheus] diff --git a/docker/prometheus.yml b/docker/prometheus.yml new file mode 100644 index 0000000..44b9500 --- /dev/null +++ b/docker/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8889']