Finished step '2.2 User Service (Port 8082)'

master
oabrivard 1 month ago
parent 4797cf8c42
commit 8336cc255a

@ -14,6 +14,7 @@ import (
qent "knowfoolery/backend/services/question-bank-service/internal/infra/persistence/ent" qent "knowfoolery/backend/services/question-bank-service/internal/infra/persistence/ent"
httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http"
"knowfoolery/backend/shared/infra/auth/zitadel" "knowfoolery/backend/shared/infra/auth/zitadel"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
sharedredis "knowfoolery/backend/shared/infra/database/redis" sharedredis "knowfoolery/backend/shared/infra/database/redis"
"knowfoolery/backend/shared/infra/observability/logging" "knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
@ -39,7 +40,7 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
persistence, err := qent.NewClient(ctx, cfg.Postgres) persistence, err := sharedpostgres.NewClient(ctx, cfg.Postgres)
if err != nil { if err != nil {
logger.WithError(err).Fatal("failed to initialize postgres client") logger.WithError(err).Fatal("failed to initialize postgres client")
} }
@ -72,7 +73,25 @@ func main() {
} }
app := serviceboot.NewFiberApp(bootCfg) app := serviceboot.NewFiberApp(bootCfg)
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
registerReadiness(app, persistence, redisClient) serviceboot.RegisterReadiness(
app,
2*time.Second,
serviceboot.ReadyCheck{
Name: "postgres",
Required: true,
Probe: persistence.Pool.Ping,
},
serviceboot.ReadyCheck{
Name: "redis",
Required: false,
Probe: func(ctx context.Context) error {
if redisClient == nil {
return nil
}
return redisClient.HealthCheck(ctx)
},
},
)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
adminMiddleware := buildAdminMiddleware(cfg) adminMiddleware := buildAdminMiddleware(cfg)
@ -83,50 +102,16 @@ func main() {
} }
func buildAdminMiddleware(cfg qbconfig.Config) fiber.Handler { func buildAdminMiddleware(cfg qbconfig.Config) fiber.Handler {
if cfg.ZitadelBaseURL == "" { return zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{
return nil
}
client := zitadel.NewClient(zitadel.Config{
BaseURL: cfg.ZitadelBaseURL, BaseURL: cfg.ZitadelBaseURL,
ClientID: cfg.ZitadelClientID, ClientID: cfg.ZitadelClientID,
ClientSecret: cfg.ZitadelSecret, ClientSecret: cfg.ZitadelSecret,
Issuer: cfg.ZitadelIssuer, Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience, Audience: cfg.ZitadelAudience,
Timeout: 10 * time.Second, RequiredClaims: []string{
}) "sub",
},
return zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{
Client: client,
Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience,
RequiredClaims: []string{"sub"},
AdminEndpoints: []string{"/admin"}, AdminEndpoints: []string{"/admin"},
}) Timeout: 10 * time.Second,
}
func registerReadiness(app *fiber.App, persistence *qent.Client, redisClient *sharedredis.Client) {
app.Get("/ready", func(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
defer cancel()
checks := map[string]string{"postgres": "ok", "redis": "ok"}
if err := persistence.Pool.Ping(ctx); err != nil {
checks["postgres"] = "down"
}
if redisClient != nil {
if err := redisClient.HealthCheck(ctx); err != nil {
checks["redis"] = "down"
}
}
status := fiber.StatusOK
if checks["postgres"] != "ok" {
status = fiber.StatusServiceUnavailable
}
return c.Status(status).JSON(fiber.Map{
"status": "ready",
"checks": checks,
})
}) })
} }

@ -1,57 +0,0 @@
package ent
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
)
// Client wraps the pgx pool used by repository implementations.
type Client struct {
Pool *pgxpool.Pool
}
// NewClient creates a persistence client and verifies connectivity.
func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.URL())
if err != nil {
return nil, fmt.Errorf("parse postgres config: %w", err)
}
poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns)
poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns)
poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime
poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("create postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return &Client{Pool: pool}, nil
}
// Close closes the underlying pool.
func (c *Client) Close() {
if c != nil && c.Pool != nil {
c.Pool.Close()
}
}
func clampIntToInt32(v int) int32 {
const maxInt32 = int(^uint32(0) >> 1)
if v <= 0 {
return 0
}
if v > maxInt32 {
return int32(maxInt32)
}
return int32(v)
}

@ -12,15 +12,16 @@ import (
domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question"
sharederrors "knowfoolery/backend/shared/domain/errors" sharederrors "knowfoolery/backend/shared/domain/errors"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
) )
// QuestionRepository implements question storage on PostgreSQL. // QuestionRepository implements question storage on PostgreSQL.
type QuestionRepository struct { type QuestionRepository struct {
client *Client client *sharedpostgres.Client
} }
// NewQuestionRepository creates a new question repository. // NewQuestionRepository creates a new question repository.
func NewQuestionRepository(client *Client) *QuestionRepository { func NewQuestionRepository(client *sharedpostgres.Client) *QuestionRepository {
return &QuestionRepository{client: client} return &QuestionRepository{client: client}
} }

@ -21,6 +21,7 @@ import (
httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/utils/validation" "knowfoolery/backend/shared/infra/utils/validation"
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
) )
// inMemoryRepo is an in-memory repository used for HTTP integration tests. // inMemoryRepo is an in-memory repository used for HTTP integration tests.
@ -145,16 +146,12 @@ func TestHTTPRandomAndGet(t *testing.T) {
bytes.NewReader(payload), bytes.NewReader(payload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req) resp := sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusOK { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "random failed")
t.Fatalf("random failed: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil) req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil)
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusOK { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed")
t.Fatalf("get failed: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
} }
@ -176,10 +173,8 @@ func TestHTTPAdminAuthAndMetrics(t *testing.T) {
bytes.NewReader(createPayload), bytes.NewReader(createPayload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req) resp := sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusUnauthorized { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized")
t.Fatalf("expected unauthorized: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest( req = httptest.NewRequest(
http.MethodPost, http.MethodPost,
@ -188,16 +183,12 @@ func TestHTTPAdminAuthAndMetrics(t *testing.T) {
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin") req.Header.Set("Authorization", "Bearer admin")
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusCreated { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusCreated, "expected created")
t.Fatalf("expected created: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/metrics", nil) req = httptest.NewRequest(http.MethodGet, "/metrics", nil)
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusOK { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
t.Fatalf("metrics failed: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
} }
@ -212,10 +203,8 @@ func TestHTTPValidateAnswer(t *testing.T) {
bytes.NewReader(matchedPayload), bytes.NewReader(matchedPayload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req) resp := sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusOK { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected matched 200")
t.Fatalf("expected matched 200: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
unmatchedPayload, _ := json.Marshal(map[string]any{"answer": "Mars"}) unmatchedPayload, _ := json.Marshal(map[string]any{"answer": "Mars"})
req = httptest.NewRequest( req = httptest.NewRequest(
@ -224,10 +213,8 @@ func TestHTTPValidateAnswer(t *testing.T) {
bytes.NewReader(unmatchedPayload), bytes.NewReader(unmatchedPayload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusOK { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected unmatched 200")
t.Fatalf("expected unmatched 200: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest( req = httptest.NewRequest(
http.MethodPost, http.MethodPost,
@ -235,10 +222,8 @@ func TestHTTPValidateAnswer(t *testing.T) {
bytes.NewReader(matchedPayload), bytes.NewReader(matchedPayload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusNotFound { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNotFound, "expected 404 for missing question")
t.Fatalf("expected 404 for missing question: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
badPayload, _ := json.Marshal(map[string]any{"answer": ""}) badPayload, _ := json.Marshal(map[string]any{"answer": ""})
req = httptest.NewRequest( req = httptest.NewRequest(
@ -247,9 +232,7 @@ func TestHTTPValidateAnswer(t *testing.T) {
bytes.NewReader(badPayload), bytes.NewReader(badPayload),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err = app.Test(req) resp = sharedhttpx.MustTest(t, app, req)
if err != nil || resp.StatusCode != http.StatusBadRequest { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected 400 for empty answer")
t.Fatalf("expected 400 for empty answer: err=%v status=%d", err, resp.StatusCode)
}
defer resp.Body.Close() defer resp.Body.Close()
} }

@ -42,7 +42,7 @@ func TestRepositoryLifecycle(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
client, err := qent.NewClient(ctx, cfg) client, err := sharedpostgres.NewClient(ctx, cfg)
if err != nil { if err != nil {
t.Fatalf("new client: %v", err) t.Fatalf("new client: %v", err)
} }

@ -13,6 +13,7 @@ import (
uent "knowfoolery/backend/services/user-service/internal/infra/persistence/ent" uent "knowfoolery/backend/services/user-service/internal/infra/persistence/ent"
httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http" httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http"
"knowfoolery/backend/shared/infra/auth/zitadel" "knowfoolery/backend/shared/infra/auth/zitadel"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
"knowfoolery/backend/shared/infra/observability/logging" "knowfoolery/backend/shared/infra/observability/logging"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/observability/tracing" "knowfoolery/backend/shared/infra/observability/tracing"
@ -37,7 +38,7 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
persistence, err := uent.NewClient(ctx, cfg.Postgres) persistence, err := sharedpostgres.NewClient(ctx, cfg.Postgres)
if err != nil { if err != nil {
logger.WithError(err).Fatal("failed to initialize postgres client") logger.WithError(err).Fatal("failed to initialize postgres client")
} }
@ -66,7 +67,15 @@ func main() {
} }
app := serviceboot.NewFiberApp(bootCfg) app := serviceboot.NewFiberApp(bootCfg)
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
registerReadiness(app, persistence) serviceboot.RegisterReadiness(
app,
2*time.Second,
serviceboot.ReadyCheck{
Name: "postgres",
Required: true,
Probe: persistence.Pool.Ping,
},
)
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
authMiddleware, adminMiddleware := buildAuthMiddleware(cfg) authMiddleware, adminMiddleware := buildAuthMiddleware(cfg)
@ -77,47 +86,18 @@ func main() {
} }
func buildAuthMiddleware(cfg uconfig.Config) (fiber.Handler, fiber.Handler) { func buildAuthMiddleware(cfg uconfig.Config) (fiber.Handler, fiber.Handler) {
if cfg.ZitadelBaseURL == "" { auth := zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{
return nil, nil
}
client := zitadel.NewClient(zitadel.Config{
BaseURL: cfg.ZitadelBaseURL, BaseURL: cfg.ZitadelBaseURL,
ClientID: cfg.ZitadelClientID, ClientID: cfg.ZitadelClientID,
ClientSecret: cfg.ZitadelSecret, ClientSecret: cfg.ZitadelSecret,
Issuer: cfg.ZitadelIssuer, Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience, Audience: cfg.ZitadelAudience,
Timeout: 10 * time.Second, RequiredClaims: []string{
}) "sub",
"email",
auth := zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{ },
Client: client,
Issuer: cfg.ZitadelIssuer,
Audience: cfg.ZitadelAudience,
RequiredClaims: []string{"sub", "email"},
AdminEndpoints: []string{"/admin"}, AdminEndpoints: []string{"/admin"},
Timeout: 10 * time.Second,
}) })
return auth, nil return auth, nil
} }
func registerReadiness(app *fiber.App, persistence *uent.Client) {
app.Get("/ready", func(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
defer cancel()
checks := map[string]string{"postgres": "ok"}
if err := persistence.Pool.Ping(ctx); err != nil {
checks["postgres"] = "down"
}
status := fiber.StatusOK
if checks["postgres"] != "ok" {
status = fiber.StatusServiceUnavailable
}
return c.Status(status).JSON(fiber.Map{
"status": "ready",
"checks": checks,
})
})
}

@ -1,57 +0,0 @@
package ent
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
)
// Client wraps the pgx pool used by repository implementations.
type Client struct {
Pool *pgxpool.Pool
}
// NewClient creates a persistence client and verifies connectivity.
func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.URL())
if err != nil {
return nil, fmt.Errorf("parse postgres config: %w", err)
}
poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns)
poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns)
poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime
poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("create postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return &Client{Pool: pool}, nil
}
// Close closes the underlying pool.
func (c *Client) Close() {
if c != nil && c.Pool != nil {
c.Pool.Close()
}
}
func clampIntToInt32(v int) int32 {
const maxInt32 = int(^uint32(0) >> 1)
if v <= 0 {
return 0
}
if v > maxInt32 {
return int32(maxInt32)
}
return int32(v)
}

@ -14,15 +14,16 @@ import (
domain "knowfoolery/backend/services/user-service/internal/domain/user" domain "knowfoolery/backend/services/user-service/internal/domain/user"
sharederrors "knowfoolery/backend/shared/domain/errors" sharederrors "knowfoolery/backend/shared/domain/errors"
sharedtypes "knowfoolery/backend/shared/domain/types" sharedtypes "knowfoolery/backend/shared/domain/types"
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
) )
// UserRepository implements user storage on PostgreSQL. // UserRepository implements user storage on PostgreSQL.
type UserRepository struct { type UserRepository struct {
client *Client client *sharedpostgres.Client
} }
// NewUserRepository creates a new user repository. // NewUserRepository creates a new user repository.
func NewUserRepository(client *Client) *UserRepository { func NewUserRepository(client *sharedpostgres.Client) *UserRepository {
return &UserRepository{client: client} return &UserRepository{client: client}
} }

@ -21,6 +21,7 @@ import (
sharedtypes "knowfoolery/backend/shared/domain/types" sharedtypes "knowfoolery/backend/shared/domain/types"
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
"knowfoolery/backend/shared/infra/utils/validation" "knowfoolery/backend/shared/infra/utils/validation"
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
) )
// inMemoryRepo is a lightweight repository double for HTTP integration tests. // inMemoryRepo is a lightweight repository double for HTTP integration tests.
@ -230,13 +231,13 @@ func TestRegisterAndCRUD(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload)) req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp := mustTest(t, app, req) resp := sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "register failed") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "register failed")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil) req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "get failed") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed")
defer resp.Body.Close() defer resp.Body.Close()
updatePayload, _ := json.Marshal(map[string]any{ updatePayload, _ := json.Marshal(map[string]any{
@ -247,13 +248,13 @@ func TestRegisterAndCRUD(t *testing.T) {
req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload)) req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "update failed") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil) req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil)
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusNoContent, "delete failed") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "delete failed")
defer resp.Body.Close() defer resp.Body.Close()
} }
@ -262,32 +263,32 @@ func TestAuthGuardsAndAdminRoutes(t *testing.T) {
app, _ := setupApp(t) app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil) req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
resp := mustTest(t, app, req) resp := sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil) req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil)
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
req.Header.Set("Authorization", "Bearer user-1") req.Header.Set("Authorization", "Bearer user-1")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
req.Header.Set("Authorization", "Bearer admin") req.Header.Set("Authorization", "Bearer admin")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok")
defer resp.Body.Close() defer resp.Body.Close()
req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil) req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil)
req.Header.Set("Authorization", "Bearer admin") req.Header.Set("Authorization", "Bearer admin")
resp = mustTest(t, app, req) resp = sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "expected export ok") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected export ok")
defer resp.Body.Close() defer resp.Body.Close()
} }
@ -296,26 +297,7 @@ func TestMetricsEndpoint(t *testing.T) {
app, _ := setupApp(t) app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil) req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
resp := mustTest(t, app, req) resp := sharedhttpx.MustTest(t, app, req)
assertStatusAndClose(t, resp, http.StatusOK, "metrics failed") sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
defer resp.Body.Close() defer resp.Body.Close()
} }
// mustTest executes a test request and fails immediately on transport errors.
func mustTest(t *testing.T, app *fiber.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test failed: %v", err)
}
return resp
}
// assertStatusAndClose checks the HTTP status and always closes the response body.
func assertStatusAndClose(t *testing.T, resp *http.Response, want int, msg string) {
t.Helper()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != want {
t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want)
}
}

@ -0,0 +1,51 @@
package zitadel
import (
"time"
"github.com/gofiber/fiber/v3"
)
// MiddlewareFactoryConfig configures a JWT middleware created from env-backed settings.
type MiddlewareFactoryConfig struct {
BaseURL string
Issuer string
Audience string
ClientID string
ClientSecret string
RequiredClaims []string
AdminEndpoints []string
SkipPaths []string
Timeout time.Duration
}
// BuildJWTMiddleware builds a JWT middleware or returns nil when auth is disabled.
func BuildJWTMiddleware(cfg MiddlewareFactoryConfig) fiber.Handler {
if cfg.BaseURL == "" {
return nil
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
client := NewClient(Config{
BaseURL: cfg.BaseURL,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Issuer: cfg.Issuer,
Audience: cfg.Audience,
Timeout: timeout,
})
return JWTMiddleware(JWTMiddlewareConfig{
Client: client,
Issuer: cfg.Issuer,
Audience: cfg.Audience,
RequiredClaims: cfg.RequiredClaims,
AdminEndpoints: cfg.AdminEndpoints,
SkipPaths: cfg.SkipPaths,
})
}

@ -123,3 +123,50 @@ func validateConfig(c Config) error {
} }
return nil return nil
} }
// Client wraps a pgx connection pool for service repositories.
type Client struct {
Pool *pgxpool.Pool
}
// NewClient creates a pooled postgres client and verifies connectivity.
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.URL())
if err != nil {
return nil, fmt.Errorf("parse postgres config: %w", err)
}
poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns)
poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns)
poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime
poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, fmt.Errorf("create postgres pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
return &Client{Pool: pool}, nil
}
// Close closes the underlying pool.
func (c *Client) Close() {
if c != nil && c.Pool != nil {
c.Pool.Close()
}
}
func clampIntToInt32(v int) int32 {
const maxInt32 = int(^uint32(0) >> 1)
if v <= 0 {
return 0
}
if v > maxInt32 {
return int32(maxInt32)
}
return int32(v)
}

@ -0,0 +1,49 @@
package serviceboot
import (
"context"
"time"
"github.com/gofiber/fiber/v3"
)
// ReadyCheck defines a readiness probe function.
type ReadyCheck struct {
Name string
Required bool
Probe func(ctx context.Context) error
}
// RegisterReadiness registers a standard /ready endpoint with named checks.
func RegisterReadiness(app *fiber.App, timeout time.Duration, checks ...ReadyCheck) {
if timeout <= 0 {
timeout = 2 * time.Second
}
app.Get("/ready", func(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(c.Context(), timeout)
defer cancel()
statusMap := make(map[string]string, len(checks))
httpStatus := fiber.StatusOK
for _, check := range checks {
if check.Name == "" || check.Probe == nil {
continue
}
if err := check.Probe(ctx); err != nil {
statusMap[check.Name] = "down"
if check.Required {
httpStatus = fiber.StatusServiceUnavailable
}
continue
}
statusMap[check.Name] = "ok"
}
return c.Status(httpStatus).JSON(fiber.Map{
"status": "ready",
"checks": statusMap,
})
})
}

@ -0,0 +1,27 @@
package httpx
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v3"
)
// MustTest executes a Fiber test request and fails on transport errors.
func MustTest(t testing.TB, app *fiber.App, req *http.Request) *http.Response {
t.Helper()
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test failed: %v", err)
}
return resp
}
// AssertStatusAndClose validates status code and closes response body.
func AssertStatusAndClose(t testing.TB, resp *http.Response, want int, msg string) {
t.Helper()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != want {
t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want)
}
}
Loading…
Cancel
Save