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
8 changed files with 183 additions and 5 deletions
Showing only changes of commit 85602dbf95 - Show all commits
+7 -4
View File
@@ -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,
+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)
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
}