Merge pull request 'Introduce database and first endpoint with service/repository' (#1) from dev into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.env
|
||||
+22
-50
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -0,0 +1,7 @@
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'otel-collector'
|
||||
static_configs:
|
||||
- targets: ['otel-collector:8889']
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user