Skip to content

Commit 0a297f2

Browse files
committed
feat: implement tenant service with CRUD operations and health check
- Add handler for tenant service with routes for health check, create, list, get, update, and delete tenants. - Implement PostgreSQL repository for tenant management, including methods for creating, retrieving, listing, updating, and soft-deleting tenants. - Create unit tests for handler methods to ensure proper functionality and error handling. - Define repository interface for abstraction and ease of testing.
1 parent f8f59c8 commit 0a297f2

12 files changed

Lines changed: 1724 additions & 52 deletions

File tree

.claude/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(go get:*)",
5+
"Bash(go version:*)"
6+
]
7+
}
8+
}

docs/on-prem/docker-compose.yml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ x-service-defaults: &service-defaults
4343

4444
services:
4545

46-
#────────────────────────────────────────────────
46+
#
4747
# INFRASTRUCTURE
48-
#────────────────────────────────────────────────
48+
#
4949

5050
postgres:
5151
image: postgres:16-alpine
@@ -136,9 +136,9 @@ services:
136136
limits:
137137
memory: 1G
138138

139-
#────────────────────────────────────────────────
139+
#
140140
# REVERSE PROXY
141-
#────────────────────────────────────────────────
141+
#
142142

143143
traefik:
144144
image: traefik:v3.1
@@ -162,9 +162,9 @@ services:
162162
networks:
163163
- serviceforge
164164

165-
#────────────────────────────────────────────────
165+
#
166166
# APPLICATION SERVICES
167-
#────────────────────────────────────────────────
167+
#
168168

169169
gateway:
170170
<<: *service-defaults
@@ -285,9 +285,9 @@ services:
285285
SF_SERVICE_NAME: logging
286286
SF_LOGGING_PORT: 8005
287287

288-
#────────────────────────────────────────────────
288+
#
289289
# MANAGEMENT UI
290-
#────────────────────────────────────────────────
290+
#
291291

292292
web:
293293
<<: *service-defaults
@@ -312,9 +312,9 @@ services:
312312
limits:
313313
memory: 512M
314314

315-
#────────────────────────────────────────────────
315+
#
316316
# DATABASE MIGRATIONS (run once)
317-
#────────────────────────────────────────────────
317+
#
318318

319319
migrate:
320320
image: serviceforge/migrate:${SF_VERSION:-latest}
@@ -331,9 +331,9 @@ services:
331331
- serviceforge
332332
restart: "no"
333333

334-
#────────────────────────────────────────────────
334+
#
335335
# MONITORING (optional, activate with --profile monitoring)
336-
#────────────────────────────────────────────────
336+
#
337337

338338
prometheus:
339339
image: prom/prometheus:v2.52.0

go.work

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@
1414
// specific language governing permissions and limitations
1515
// under the LICENSE.
1616

17-
go 1.23.0
17+
go 1.25.0
1818

1919
use (
2020
./packages/go-common
2121
./services/api-gateway
2222
./services/auth-service
23-
./services/tenant-service
24-
./services/config-service
2523
./services/booking-service
24+
./services/config-service
25+
./services/tenant-service
2626
)

services/tenant-service/cmd/server/main.go

Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,55 +16,144 @@
1616
* under the LICENSE.
1717
*/
1818

19+
// Command server is the entry point for the tenant-service.
20+
//
21+
// Environment variables:
22+
//
23+
// PORT HTTP listen port (default: 8083)
24+
// DATABASE_URL PostgreSQL connection string (required)
25+
// LOG_LEVEL debug | info | warn | error (default: info)
26+
// LOG_FORMAT json | text (default: json)
1927
package main
2028

2129
import (
22-
"encoding/json"
23-
"log"
30+
"context"
31+
"errors"
32+
"log/slog"
2433
"net/http"
34+
"os"
35+
"os/signal"
36+
"syscall"
37+
"time"
38+
39+
"github.com/jackc/pgx/v5/pgxpool"
2540

2641
"github.com/SoftLaneIT/serviceforge/packages/go-common/config"
42+
"github.com/SoftLaneIT/serviceforge/packages/go-common/logger"
43+
"github.com/SoftLaneIT/serviceforge/packages/go-common/tenant"
44+
"github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/handler"
45+
"github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/repository"
2746
)
2847

29-
type createTenantRequest struct {
30-
Name string `json:"name"`
31-
Slug string `json:"slug"`
32-
PlanID string `json:"planId"`
33-
}
34-
3548
func main() {
49+
// ── logger ────────────────────────────────────────────────────────────────
50+
log := logger.NewFromEnv("tenant-service")
51+
52+
// ── database ──────────────────────────────────────────────────────────────
53+
dsn := config.GetEnv("DATABASE_URL",
54+
"postgres://serviceforge:serviceforge@localhost:5432/serviceforge?sslmode=disable")
55+
56+
pool := mustConnectPool(log, dsn)
57+
defer pool.Close()
58+
59+
// ── repository + handler ──────────────────────────────────────────────────
60+
repo := repository.NewPostgres(pool)
61+
h := handler.New(repo, log)
62+
63+
// ── HTTP server ───────────────────────────────────────────────────────────
3664
mux := http.NewServeMux()
37-
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
38-
respondJSON(w, http.StatusOK, map[string]any{"service": "tenant-service", "status": "ok"})
39-
})
40-
mux.HandleFunc("/v1/tenants", func(w http.ResponseWriter, r *http.Request) {
41-
switch r.Method {
42-
case http.MethodPost:
43-
var req createTenantRequest
44-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
45-
http.Error(w, "invalid payload", http.StatusBadRequest)
46-
return
47-
}
48-
respondJSON(w, http.StatusCreated, map[string]any{
49-
"id": "tenant_001",
50-
"name": req.Name,
51-
"slug": req.Slug,
52-
"planId": req.PlanID,
53-
})
54-
default:
55-
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
56-
}
57-
})
65+
h.RegisterRoutes(mux)
66+
67+
// Middleware chain (outermost first):
68+
// tenant.Middleware → injects tenant_id from X-Tenant-ID header
69+
// logger.HTTPMiddleware → structured request logging with tenant_id + trace_id
70+
httpHandler := tenant.Middleware(logger.HTTPMiddleware(log)(mux))
5871

5972
port := config.GetEnv("PORT", "8083")
60-
log.Printf("tenant-service listening on :%s", port)
61-
if err := http.ListenAndServe(":"+port, mux); err != nil {
62-
log.Fatal(err)
73+
srv := &http.Server{
74+
Addr: ":" + port,
75+
Handler: httpHandler,
76+
ReadTimeout: 10 * time.Second,
77+
WriteTimeout: 30 * time.Second,
78+
IdleTimeout: 120 * time.Second,
79+
}
80+
81+
// ── graceful shutdown ─────────────────────────────────────────────────────
82+
// Start the HTTP server in a goroutine so the main goroutine can block on
83+
// the signal channel.
84+
serverErr := make(chan error, 1)
85+
go func() {
86+
log.Info("tenant-service starting", slog.String("port", port))
87+
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
88+
serverErr <- err
89+
}
90+
}()
91+
92+
quit := make(chan os.Signal, 1)
93+
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
94+
95+
select {
96+
case sig := <-quit:
97+
log.Info("shutdown signal received", slog.String("signal", sig.String()))
98+
case err := <-serverErr:
99+
log.Error("server error", slog.Any("error", err))
100+
os.Exit(1)
101+
}
102+
103+
// Allow up to 30 s for in-flight requests to complete.
104+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
105+
defer cancel()
106+
107+
if err := srv.Shutdown(ctx); err != nil {
108+
log.Error("forced shutdown", slog.Any("error", err))
63109
}
110+
log.Info("tenant-service stopped")
64111
}
65112

66-
func respondJSON(w http.ResponseWriter, status int, payload any) {
67-
w.Header().Set("Content-Type", "application/json")
68-
w.WriteHeader(status)
69-
_ = json.NewEncoder(w).Encode(payload)
113+
// mustConnectPool attempts to connect to PostgreSQL with exponential back-off
114+
// retries. It calls os.Exit(1) if the database is unreachable after all
115+
// attempts, because a tenant-service without a database is not useful.
116+
func mustConnectPool(log *slog.Logger, dsn string) *pgxpool.Pool {
117+
const maxAttempts = 5
118+
119+
cfg, err := pgxpool.ParseConfig(dsn)
120+
if err != nil {
121+
log.Error("invalid DATABASE_URL", slog.Any("error", err))
122+
os.Exit(1)
123+
}
124+
125+
// Reasonable pool limits for a Phase 1 single-replica deployment.
126+
cfg.MaxConns = 10
127+
cfg.MinConns = 2
128+
cfg.MaxConnLifetime = 1 * time.Hour
129+
cfg.MaxConnIdleTime = 5 * time.Minute
130+
131+
ctx := context.Background()
132+
var pool *pgxpool.Pool
133+
134+
for attempt := range maxAttempts {
135+
pool, err = pgxpool.NewWithConfig(ctx, cfg)
136+
if err == nil {
137+
if pingErr := pool.Ping(ctx); pingErr == nil {
138+
log.Info("database connected", slog.Int("attempt", attempt+1))
139+
return pool
140+
} else {
141+
pool.Close()
142+
err = pingErr
143+
}
144+
}
145+
146+
wait := time.Duration(1<<attempt) * time.Second // 1, 2, 4, 8, 16 s
147+
log.Warn("database not ready, retrying",
148+
slog.Int("attempt", attempt+1),
149+
slog.Int("maxAttempts", maxAttempts),
150+
slog.Duration("retryIn", wait),
151+
slog.Any("error", err),
152+
)
153+
time.Sleep(wait)
154+
}
155+
156+
log.Error("could not connect to database after retries", slog.Any("error", err))
157+
os.Exit(1)
158+
return nil // unreachable — satisfies the compiler
70159
}

services/tenant-service/go.mod

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ module github.com/SoftLaneIT/serviceforge/services/tenant-service
22

33
go 1.23.0
44

5-
require github.com/SoftLaneIT/serviceforge/packages/go-common v0.0.0
5+
require (
6+
github.com/SoftLaneIT/serviceforge/packages/go-common v0.0.0
7+
github.com/jackc/pgx/v5 v5.6.0
8+
)
9+
10+
require (
11+
github.com/jackc/pgpassfile v1.0.0 // indirect
12+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
13+
golang.org/x/crypto v0.17.0 // indirect
14+
golang.org/x/text v0.14.0 // indirect
15+
)
616

717
replace github.com/SoftLaneIT/serviceforge/packages/go-common => ../../packages/go-common

services/tenant-service/go.sum

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
3+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
4+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
5+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
6+
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
7+
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
8+
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
9+
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
10+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
12+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
13+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
14+
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
15+
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
16+
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
17+
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
18+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
19+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)