Introduce database and first endpoint with service/repository #1

Merged
robin merged 6 commits from dev into main 2026-07-10 14:14:05 +02:00
15 changed files with 351 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
.env
+22 -50
View File
@@ -2,34 +2,29 @@ package main
import (
"context"
"database/sql"
"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
}
func main() {
serviceName := "dttmr-api"
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)
}
}
@@ -40,7 +35,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,
@@ -62,7 +57,19 @@ func run(serviceName string, serviceVersion string) error {
}
}()
srv := makeServer(cfg)
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(db, cfg.Port)
go func() {
slog.Info("Starting http server", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -99,49 +106,14 @@ 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")
flag.Parse()
cfg := &Config{
Environment: *envFlag,
Port: *portFlag,
OTLPEndpoint: *otlpEndpointFlag,
func makeServer(db *sql.DB, port int) *http.Server {
routerConfig := router.Config{
Database: db,
}
assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment)
assignIntFromEnv("DTTMR_PORT", &cfg.Port)
assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint)
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 {
routerConfig := router.Config{}
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,
+33
View File
@@ -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
+23
View File
@@ -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]
+7
View File
@@ -0,0 +1,7 @@
global:
scrape_interval: 5s
scrape_configs:
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8889']
+5
View File
@@ -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
+35
View File
@@ -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)
}
View File
+27
View File
@@ -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
}
+13 -1
View File
@@ -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)
+55
View File
@@ -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
}
}
}
+29
View File
@@ -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
}
View File
+37
View File
@@ -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)
}
+64
View File
@@ -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
}