// Package assets is a minimal client for assets-web's authenticated asset store
// (GET/POST/DELETE /asset/<kind>/<id>), used to promote a staged cover/sample to live (or
// reclaim it) on volume edit session finalize/accept/reject. See durable-volume-editing in
// sweetrpg/platform.
package assets
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"time"
)
// Client calls assets-web's asset store endpoints.
type Client struct {
baseURL string
http *http.Client
}
// NewClient builds a Client against assets-web's base URL. An empty baseURL is accepted so the
// service can still start when ASSETS_WEB_URL isn't configured; every call will then fail with
// a transport error.
func NewClient(baseURL string) *Client {
return &Client{baseURL: baseURL, http: &http.Client{Timeout: 10 * time.Second}}
}
// NotFoundError means assets-web has no asset at the given kind/id.
type NotFoundError struct{ Kind, ID string }
func (e NotFoundError) Error() string { return fmt.Sprintf("assets: %s/%s not found", e.Kind, e.ID) }
// Get downloads an asset's bytes and content type.
func (c *Client) Get(ctx context.Context, kind, id string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/asset/"+kind+"/"+id, nil)
if err != nil {
return nil, "", fmt.Errorf("assets: build get request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("assets: get request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, "", NotFoundError{Kind: kind, ID: id}
}
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("assets: unexpected status %d from get %s/%s", resp.StatusCode, kind, id)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("assets: read get response: %w", err)
}
return body, resp.Header.Get("Content-Type"), nil
}
// Store uploads an asset's bytes under kind/id, overwriting any existing asset there.
func (c *Client) Store(ctx context.Context, kind, id string, data []byte, contentType string) error {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
header := textproto.MIMEHeader{}
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, id))
if contentType != "" {
header.Set("Content-Type", contentType)
}
part, err := writer.CreatePart(header)
if err != nil {
return fmt.Errorf("assets: build multipart form: %w", err)
}
if _, err := part.Write(data); err != nil {
return fmt.Errorf("assets: write multipart body: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("assets: close multipart form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/asset/"+kind+"/"+id, &buf)
if err != nil {
return fmt.Errorf("assets: build store request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("assets: store request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("assets: unexpected status %d from store %s/%s", resp.StatusCode, kind, id)
}
return nil
}
// Delete removes an asset. A missing asset (404) is treated as success - the end state (no
// asset at kind/id) is the same either way.
func (c *Client) Delete(ctx context.Context, kind, id string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/asset/"+kind+"/"+id, nil)
if err != nil {
return fmt.Errorf("assets: build delete request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("assets: delete request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("assets: unexpected status %d from delete %s/%s", resp.StatusCode, kind, id)
}
return nil
}
// Promote copies a staged asset (fromKind/fromID) to a live one (toKind/toID), then deletes the
// staged copy - the promote-then-delete step every editor/admin finalize and accepted proposal
// performs for a referenced staged cover/sample.
func (c *Client) Promote(ctx context.Context, fromKind, fromID, toKind, toID string) error {
data, contentType, err := c.Get(ctx, fromKind, fromID)
if err != nil {
return fmt.Errorf("assets: promote %s/%s -> %s/%s: %w", fromKind, fromID, toKind, toID, err)
}
if err := c.Store(ctx, toKind, toID, data, contentType); err != nil {
return fmt.Errorf("assets: promote %s/%s -> %s/%s: %w", fromKind, fromID, toKind, toID, err)
}
return c.Delete(ctx, fromKind, fromID)
}
// Package authz calls auth-api's POST /authz/check to verify a caller's bearer token and
// resolve their roles, and provides Gin middleware that gates a route on holding one of a set
// of allowed roles. See platform's volume-edit-authorization spec
// (openspec/changes/volume-edit-with-approval-workflow in sweetrpg/platform).
package authz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Platform role names, matching auth-api's fixed role model exactly (see
// openspec/specs/user-authorization/spec.md's "Role model" requirement).
const (
RoleUser = "user"
RoleSubmitter = "submitter"
RoleEditor = "editor"
RoleModerator = "moderator"
RoleApprover = "approver"
RoleAdmin = "admin"
)
// CheckResponse is the union of auth-api's allowed/denied /authz/check response shapes.
type CheckResponse struct {
Allowed bool `json:"allowed"`
Roles []string `json:"roles"`
Sub string `json:"sub"`
Reason string `json:"reason"`
}
// InvalidTokenError means auth-api rejected the bearer token itself (missing, expired,
// unverifiable) - distinct from a service-level deny (Allowed: false with a Reason) or a
// transport/backend failure.
type InvalidTokenError struct{}
func (InvalidTokenError) Error() string { return "authz: invalid or missing token" }
// Client calls auth-api's /authz/check endpoint.
type Client struct {
baseURL string
http *http.Client
}
// NewClient builds a Client against auth-api's base URL (e.g.
// http://api-v1.sweetrpg-auth.svc.cluster.local:8000). An empty baseURL is accepted so the
// service can still start when AUTH_API_URL isn't configured; every Check call will then fail
// with a transport error, which RequireAnyRole surfaces as a 503.
func NewClient(baseURL string) *Client {
return &Client{baseURL: baseURL, http: &http.Client{Timeout: 5 * time.Second}}
}
// Check verifies token against auth-api and returns the caller's allowed/roles/subject for the
// given service name. Returns InvalidTokenError if auth-api rejects the token itself.
func (c *Client) Check(ctx context.Context, token, service string) (*CheckResponse, error) {
body, err := json.Marshal(map[string]string{"service": service})
if err != nil {
return nil, fmt.Errorf("authz: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/authz/check", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("authz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("authz: request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusUnauthorized {
return nil, InvalidTokenError{}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authz: unexpected status %d from auth-api", resp.StatusCode)
}
var out CheckResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("authz: decode response: %w", err)
}
return &out, nil
}
package authz
import (
"net/http"
"github.com/gin-gonic/gin"
apiv "github.com/sweetrpg/api-core.go/vo"
"github.com/sweetrpg/common.go/logging"
)
// Gin context keys set by RequireAnyRole; read via Roles/Subject.
const (
rolesContextKey = "authz.roles"
subjectContextKey = "authz.subject"
)
// RequireAnyRole returns Gin middleware that verifies the caller's bearer token against
// auth-api's /authz/check for the given service name, then requires the caller hold at least
// one of allowedRoles. On success, the verified roles and subject are stashed in the Gin
// context (read via Roles(c) / Subject(c)) for the handler to use.
func RequireAnyRole(client *Client, service string, allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
token := bearerToken(c)
if token == "" {
unauthorized(c)
return
}
result, err := client.Check(c.Request.Context(), token, service)
if err != nil {
if _, ok := err.(InvalidTokenError); ok {
unauthorized(c)
return
}
logging.Logger.Error("authz check failed", "error", err.Error())
c.AbortWithStatusJSON(http.StatusServiceUnavailable, apiv.ErrorVO{
Error: "authz_unavailable",
Message: "Unable to verify authorization",
})
return
}
if !result.Allowed {
forbidden(c)
return
}
if !hasAnyRole(result.Roles, allowedRoles) {
forbidden(c)
return
}
c.Set(rolesContextKey, result.Roles)
c.Set(subjectContextKey, result.Sub)
c.Next()
}
}
// Roles returns the verified roles stashed in the Gin context by RequireAnyRole.
func Roles(c *gin.Context) []string {
if v, ok := c.Get(rolesContextKey); ok {
if roles, ok := v.([]string); ok {
return roles
}
}
return nil
}
// Subject returns the verified Auth0 subject stashed in the Gin context by RequireAnyRole.
func Subject(c *gin.Context) string {
if v, ok := c.Get(subjectContextKey); ok {
if sub, ok := v.(string); ok {
return sub
}
}
return ""
}
// HasRole reports whether roles contains want.
func HasRole(roles []string, want string) bool {
for _, r := range roles {
if r == want {
return true
}
}
return false
}
func hasAnyRole(have []string, want []string) bool {
for _, w := range want {
if HasRole(have, w) {
return true
}
}
return false
}
func bearerToken(c *gin.Context) string {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix {
return ""
}
return auth[len(prefix):]
}
func unauthorized(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized, apiv.ErrorVO{
Error: "invalid_token",
Message: "Missing or invalid bearer token",
})
}
func forbidden(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, apiv.ErrorVO{
Error: "forbidden",
Message: "Caller does not have a qualifying role",
})
}
// Package cachettl resolves the cache TTL for a given route group, loaded once at startup
// from the CACHE_TTLS env var so slower-changing entities and faster-changing ones aren't
// forced onto the same cache policy.
package cachettl
import (
"strings"
"time"
"github.com/sweetrpg/catalog-api/constants"
"github.com/sweetrpg/common.go/logging"
"github.com/sweetrpg/common.go/util"
)
const defaultTTLFallback = time.Hour
// Config resolves a per-route-group TTL, falling back to a configured default for any route
// group without an explicit entry.
type Config struct {
ttls map[string]time.Duration
defaultTTL time.Duration
}
// Load parses CACHE_TTLS ("route=duration,route=duration", e.g. "licenses=30m,volumes=15m")
// and CACHE_DEFAULT_TTL (a single duration string) from the environment. Malformed entries are
// logged and skipped rather than failing startup.
func Load() Config {
defaultTTL := defaultTTLFallback
if raw := util.GetEnv(constants.CACHE_DEFAULT_TTL, ""); raw != "" {
if d, err := time.ParseDuration(raw); err == nil {
defaultTTL = d
} else {
logging.Logger.Warn("Invalid CACHE_DEFAULT_TTL, using fallback default",
"value", raw, "fallback", defaultTTLFallback.String(), "error", err.Error())
}
}
ttls := map[string]time.Duration{}
raw := util.GetEnv(constants.CACHE_TTLS, "")
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
route, value, found := strings.Cut(entry, "=")
if !found {
logging.Logger.Warn("Invalid CACHE_TTLS entry, expected route=duration", "entry", entry)
continue
}
d, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
logging.Logger.Warn("Invalid CACHE_TTLS duration, skipping entry",
"entry", entry, "error", err.Error())
continue
}
ttls[strings.TrimSpace(route)] = d
}
return Config{ttls: ttls, defaultTTL: defaultTTL}
}
// TTL returns the configured TTL for the given route group, or the configured default if the
// route group has no explicit entry.
func (c Config) TTL(route string) time.Duration {
if d, ok := c.ttls[route]; ok {
return d
}
return c.defaultTTL
}
package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/cache/persistence"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/gomodule/redigo/redis"
"github.com/grafana/pyroscope-go"
"github.com/joho/godotenv"
"github.com/penglongli/gin-metrics/ginmetrics"
sloggin "github.com/samber/slog-gin"
actuator "github.com/sinhashubham95/go-actuator"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
apiconstants "github.com/sweetrpg/api-core.go/constants"
"github.com/sweetrpg/api-core.go/featureflags"
"github.com/sweetrpg/api-core.go/tracing"
"github.com/sweetrpg/api-core.go/vo"
"github.com/sweetrpg/catalog-api/assets"
"github.com/sweetrpg/catalog-api/authz"
"github.com/sweetrpg/catalog-api/cachettl"
"github.com/sweetrpg/catalog-api/constants"
"github.com/sweetrpg/catalog-api/docs"
"github.com/sweetrpg/catalog-api/editsession"
"github.com/sweetrpg/catalog-api/proposedchanges"
"github.com/sweetrpg/catalog-api/ratelimit"
"github.com/sweetrpg/catalog-api/readiness"
"github.com/sweetrpg/catalog-api/server"
"github.com/sweetrpg/catalog-api/vocabularies"
"github.com/sweetrpg/catalog-data.go/data"
"github.com/sweetrpg/common.go/logging"
"github.com/sweetrpg/common.go/util"
"github.com/sweetrpg/mongodb.go/database"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
xrate "golang.org/x/time/rate"
)
// redisConnectTimeout bounds startup/readiness pings to Redis so a stalled connection fails
// fast instead of hanging past the caller's own timeout (same rationale as api-core.go's
// healthCheckTimeout for Mongo).
const redisConnectTimeout = 5 * time.Second
// @title Catalog API service
// @version 1.0
// @description Swagger APIs
// @termsOfService https://pilgrimagesoftware.com/terms/
// @contact.name API Support
// @contact.url https://sweetrpg.com
// @contact.email admin@sweetrpg.com
// @license.name MIT
// @license.url https://mit-license.org/
func main() {
_ = godotenv.Load(".env")
logging.Init()
setupSentry()
ff := featureflags.New(constants.ServiceName)
if stopProfiling := setupProfiling(ff); stopProfiling != nil {
defer stopProfiling()
}
httpLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
r := gin.New()
r.Use(sloggin.New(httpLogger))
r.Use(gin.Recovery())
// r.LoadHTMLGlob("tmpl/*")
setupTracing(r)
defer tracing.TeardownTracing()
// CORS
setupCORS(r)
// Setup Prometheus metrics
setupMetrics(r)
redisPool := setupRedisPool()
if redisPool != nil {
defer func() { _ = redisPool.Close() }()
}
cache := setupCache(redisPool)
ttls := cachettl.Load()
database.SetupDatabase()
defer database.TeardownDatabase()
if err := proposedchanges.EnsureIndexes(context.Background()); err != nil {
logging.Logger.Error("Error while ensuring proposed_changes indexes", "error", err.Error())
}
if err := vocabularies.EnsureIndexes(context.Background()); err != nil {
logging.Logger.Error("Error while ensuring vocabularies indexes", "error", err.Error())
}
if err := data.EnsureVolumeVersioningIndexes(context.Background()); err != nil {
logging.Logger.Error("Error while ensuring volume versioning indexes", "error", err.Error())
}
if err := vocabularies.Backfill(context.Background()); err != nil {
logging.Logger.Error("Error while backfilling vocabularies", "error", err.Error())
}
// Actuator
setupAcuator(r)
// Swagger
setupSwagger(r)
// Add rate limiter
r.Use(RateLimiter(redisPool))
authzClient := authz.NewClient(util.GetEnv(constants.AUTH_API_URL, ""))
assetsClient := assets.NewClient(util.GetEnv(constants.ASSETS_WEB_URL, ""))
editSessionPool := setupEditSessionPool()
if editSessionPool != nil {
defer func() { _ = editSessionPool.Close() }()
}
editSessions := editsession.NewStore(editSessionPool)
server.SetupHandlers(r, cache, ttls, authzClient, assetsClient, editSessions)
_ = r.Run(util.GetEnv(apiconstants.BIND_ADDRESS, ":8000"))
}
func setupSwagger(r *gin.Engine) {
logging.Logger.Info("Setting up Swagger...")
docs.SwaggerInfo.Version = os.Getenv(apiconstants.VERSION)
docs.SwaggerInfo.Host = util.GetEnv(apiconstants.INGRESS_HOST, "localhost")
docs.SwaggerInfo.BasePath = util.GetEnv(apiconstants.INGRESS_BASE_PATH, "/")
docs.SwaggerInfo.Schemes = strings.Split(util.GetEnv(apiconstants.INGRESS_SCHEMES, "http"), ",")
// swagger middleware to serve the API docs
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
}
func setupCORS(r *gin.Engine) {
logging.Logger.Info("Setting up CORS...")
origins := util.GetEnv(constants.ALLOWED_ORIGINS, "")
if origins == "" {
logging.Logger.Warn("ALLOWED_ORIGINS not set, no cross-origin requests will be allowed")
return
}
r.Use(cors.New(cors.Config{
AllowOrigins: strings.Split(origins, ","),
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
}
func setupSentry() {
logging.Logger.Info("Setting up Sentry...")
sentryDsn, found := os.LookupEnv(apiconstants.SENTRY_DSN)
if found {
sentryDebug, _ := strconv.ParseBool(util.GetEnv(apiconstants.SENTRY_DEBUG, "false"))
err := sentry.Init(sentry.ClientOptions{
Dsn: sentryDsn,
Debug: sentryDebug,
AttachStacktrace: true,
EnableTracing: true,
TracesSampleRate: 1.0,
TracesSampler: sentry.TracesSampler(func(ctx sentry.SamplingContext) float64 {
if strings.Contains(ctx.Span.Name, "/status/") {
return 0.0
}
return 1.0
}),
// ProfilesSampleRate: 1.0,
ServerName: constants.ServiceName,
})
if err != nil {
logging.Logger.Error("Error while trying to initialize Sentry", "error", err.Error())
}
defer func() {
log.Print("Flushing Sentry...")
sentry.Flush(2 * time.Second)
}()
}
}
// setupProfiling starts continuous profiling only when the profiling-enabled
// feature flag evaluates to true, regardless of whether
// PYROSCOPE_SERVER_ADDRESS happens to be set - the flag is the on/off
// control, PYROSCOPE_SERVER_ADDRESS is only the destination. See the
// pyroscope-profiling-flag spec's three scenarios.
func setupProfiling(ff *featureflags.Client) func() {
logging.Logger.Info("Setting up continuous profiling...")
if !ff.BoolFlag(context.Background(), constants.ProfilingEnabledFlag, false) {
logging.Logger.Info("profiling-enabled flag is off, continuous profiling disabled")
return nil
}
serverAddress, found := os.LookupEnv(constants.PYROSCOPE_SERVER_ADDRESS)
if !found {
logging.Logger.Warn("profiling-enabled flag is on but PYROSCOPE_SERVER_ADDRESS not set, continuous profiling disabled")
return nil
}
profiler, err := pyroscope.Start(pyroscope.Config{
ApplicationName: constants.ServiceName,
ServerAddress: serverAddress,
TenantID: util.GetEnv(constants.PYROSCOPE_TENANT_ID, ""),
Tags: map[string]string{
"env": util.GetEnv(apiconstants.ENV, "dev"),
},
})
if err != nil {
logging.Logger.Error("Error while trying to initialize continuous profiling", "error", err.Error())
return nil
}
return func() {
_ = profiler.Stop()
}
}
func setupAcuator(r *gin.Engine) {
logging.Logger.Info("Setting up actuator...")
actuatorHandler := actuator.GetActuatorHandler(&actuator.Config{
Endpoints: []int{
actuator.Env,
actuator.Info,
actuator.Metrics,
actuator.Ping,
// actuator.Shutdown,
actuator.ThreadDump,
},
Env: util.GetEnv(apiconstants.ENV, "dev"),
Name: constants.ServiceName,
Port: util.GetEnvInt(apiconstants.PORT, 0),
Version: util.GetEnv(apiconstants.VERSION, "v0.0.0"),
})
ginActuatorHandler := func(ctx *gin.Context) {
actuatorHandler(ctx.Writer, ctx.Request)
}
r.GET("/actuator/*endpoint", ginActuatorHandler)
}
// setupRedisPool builds a shared redigo connection pool for both the rate limiter's counters
// and the cache's startup connectivity check, when REDIS_HOST is configured. Returns nil when
// no Redis is configured, so the service runs entirely without an external dependency.
func setupRedisPool() *redis.Pool {
redisHost, found := os.LookupEnv(apiconstants.REDIS_HOST)
if !found {
return nil
}
redisPort := util.GetEnv(apiconstants.REDIS_PORT, "6379")
redisPass := os.Getenv(apiconstants.REDIS_PASS)
addr := fmt.Sprintf("%s:%s", redisHost, redisPort)
return &redis.Pool{
MaxIdle: 5,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", addr, redis.DialConnectTimeout(redisConnectTimeout))
if err != nil {
return nil, err
}
if redisPass != "" {
if _, err := c.Do("AUTH", redisPass); err != nil {
_ = c.Close()
return nil, err
}
}
return c, nil
},
}
}
// editSessionRedisDB is the Redis DB index reserved for edit sessions on catalog-api's own
// Redis instance (REDIS_HOST/_PORT/_PASS) - no separate host/port config needed, see
// docs/frontend-conventions.md's Redis registry in sweetrpg/platform.
const editSessionRedisDB = 2
// setupEditSessionPool builds a second redigo pool against the same Redis instance as
// setupRedisPool, but selecting editSessionRedisDB - a separate pool (not a shared one with a
// per-command SELECT) so cache/rate-limit connections can never accidentally read/write edit
// session keys or vice versa. Returns nil when no Redis is configured, matching
// setupRedisPool's behavior.
func setupEditSessionPool() *redis.Pool {
redisHost, found := os.LookupEnv(apiconstants.REDIS_HOST)
if !found {
return nil
}
redisPort := util.GetEnv(apiconstants.REDIS_PORT, "6379")
redisPass := os.Getenv(apiconstants.REDIS_PASS)
addr := fmt.Sprintf("%s:%s", redisHost, redisPort)
return &redis.Pool{
MaxIdle: 5,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) {
dialOpts := []redis.DialOption{
redis.DialConnectTimeout(redisConnectTimeout),
redis.DialDatabase(editSessionRedisDB),
}
if redisPass != "" {
// DialPassword, not a manual AUTH after Dial - DialDatabase's SELECT runs
// during Dial() itself, before any command this func could issue afterward;
// against a password-protected Redis that SELECT would fail pre-auth. Passing
// the password as a DialOption lets redigo order AUTH before SELECT internally.
dialOpts = append(dialOpts, redis.DialPassword(redisPass))
}
return redis.Dial("tcp", addr, dialOpts...)
},
}
}
// setupCache builds the response cache store and, when REDIS_HOST is configured, registers the
// Redis pool with readiness so /status/health live-pings it on every call (see
// readiness.CacheReady) instead of trusting a boot-time snapshot - a Redis outage that resolves
// on its own then lets readiness recover without a pod restart. Also does one startup ping
// purely to log early if Redis is unreachable at boot; that ping no longer determines
// readiness by itself.
func setupCache(redisPool *redis.Pool) persistence.CacheStore {
logging.Logger.Info("Setting up query cache...")
redisHost, found := os.LookupEnv(apiconstants.REDIS_HOST)
if !found {
readiness.SetCachePool(nil)
return persistence.NewInMemoryStore(time.Hour)
}
redisPort := util.GetEnv(apiconstants.REDIS_PORT, "6379")
redisPass := os.Getenv(apiconstants.REDIS_PASS)
cache := persistence.NewRedisCache(fmt.Sprintf("%s:%s", redisHost, redisPort), redisPass, time.Hour)
readiness.SetCachePool(redisPool)
ctx, cancel := context.WithTimeout(context.Background(), redisConnectTimeout)
defer cancel()
if err := ratelimit.Ping(ctx, redisPool); err != nil {
logging.Logger.Error("REDIS_HOST is configured but unreachable at startup; readiness will keep live-checking on each /status/health call",
"redis_host", redisHost, "error", err.Error())
}
return cache
}
func setupTracing(r *gin.Engine) {
logging.Logger.Info("Setting up tracing...")
// Teardown is deferred by the caller (main), not here - deferring it in this function
// would run it as soon as this function returns, shutting down the tracer provider
// before the server ever serves a request, silently dropping every span.
tracing.SetupTracing(constants.ServiceName)
r.Use(otelgin.Middleware(constants.ServiceName))
}
func setupMetrics(r *gin.Engine) {
logging.Logger.Info("Setting up metrics endpoint...")
m := ginmetrics.GetMonitor()
m.SetMetricPath("/metrics")
m.SetSlowTime(10)
m.SetDuration([]float64{0.1, 0.3, 1.2, 5, 10})
m.Use(r)
}
// rateLimitTierFor groups routes into a looser "cheap" tier (shallow status endpoints) and a
// stricter "standard" tier (everything else), per design.md's decision to start with two tiers
// keyed by route cost rather than one entry per resource.
func rateLimitTierFor(path string) string {
if strings.HasPrefix(path, "/status/") {
return "cheap"
}
return "standard"
}
// rateLimitClientKey identifies the caller for per-client limiting: API key if the client sent
// one, otherwise client IP. This doesn't introduce a new auth mechanism - it keys off whatever
// identity already exists on the request (see design.md's open question on API-key issuance).
func rateLimitClientKey(c *gin.Context) string {
if apiKey := c.GetHeader("X-API-Key"); apiKey != "" {
return "key:" + apiKey
}
return "ip:" + c.ClientIP()
}
// RateLimiter selects between the new Redis-backed per-client limiter and the legacy
// process-wide token bucket, gated by DISTRIBUTED_RATE_LIMIT_ENABLED so the new limiter can be
// validated in dev before the old one is removed (tasks 4.5-4.6).
func RateLimiter(redisPool *redis.Pool) gin.HandlerFunc {
distributedEnabled, _ := strconv.ParseBool(util.GetEnv(constants.DISTRIBUTED_RATE_LIMIT_ENABLED, "false"))
if distributedEnabled && redisPool != nil {
return distributedRateLimiter(redisPool)
}
if distributedEnabled && redisPool == nil {
logging.Logger.Warn("DISTRIBUTED_RATE_LIMIT_ENABLED is set but REDIS_HOST is not; falling back to the process-wide limiter")
}
return globalRateLimiter()
}
// distributedRateLimiter enforces per-client, per-tier limits backed by Redis, consistent
// across replicas. Fails closed (503) if the Redis backend is unreachable, rather than letting
// requests through unlimited during an outage.
func distributedRateLimiter(redisPool *redis.Pool) gin.HandlerFunc {
limiter := ratelimit.New(redisPool, map[string]ratelimit.Tier{
"cheap": {
Limit: util.GetEnvInt(constants.RATE_LIMIT_CHEAP, 120),
Window: util.GetEnvInt(constants.RATE_LIMIT_CHEAP_WINDOW, 60),
},
"standard": {
Limit: util.GetEnvInt(constants.RATE_LIMIT_STANDARD, 30),
Window: util.GetEnvInt(constants.RATE_LIMIT_STANDARD_WINDOW, 60),
},
})
return func(c *gin.Context) {
tier := rateLimitTierFor(c.Request.URL.Path)
clientKey := rateLimitClientKey(c)
allowed, err := limiter.Allow(c.Request.Context(), clientKey, tier)
if err != nil {
logging.Logger.Error("Rate-limit backend unreachable; rejecting request (fail closed)", "error", err.Error())
c.AbortWithStatusJSON(http.StatusServiceUnavailable, vo.ErrorVO{
Error: constants.ErrorRateLimitUnavailable,
Message: "Rate limiting is temporarily unavailable",
})
return
}
if !allowed {
logging.Logger.Warn("Rate limit exceeded", "client", clientKey, "tier", tier, "path", c.Request.URL.Path)
c.AbortWithStatusJSON(http.StatusTooManyRequests, vo.ErrorVO{
Error: apiconstants.ErrorRateLimited,
Message: "Limit exceeded",
})
return
}
c.Next()
}
}
// globalRateLimiter is the legacy process-wide token bucket: one shared limit across every
// client and route, effectively N times looser with N replicas since each pod holds its own
// bucket. Kept behind the DISTRIBUTED_RATE_LIMIT_ENABLED toggle until the new limiter is
// validated in dev (task 4.6 removes this once that happens).
func globalRateLimiter() gin.HandlerFunc {
limiter := xrate.NewLimiter(1, util.GetEnvInt(apiconstants.RATE_LIMIT, 10))
return func(c *gin.Context) {
if limiter.Allow() {
c.Next()
} else {
logging.Logger.Warn(fmt.Sprintf("Rate limit exceeded for request: %v", c.Request))
c.AbortWithStatusJSON(http.StatusTooManyRequests, vo.ErrorVO{
Error: apiconstants.ErrorRateLimited,
Message: "Limit exceeded",
})
}
}
}
// Command migrate-volumes is the one-time cutover for catalog-entity-versioning's volume
// meta+version data model (see openspec's design.md Migration Plan). It backfills every
// existing "volumes" document into a meta record + a single live version, then converts every
// still-pending proposed_changes entry for a volume into a submitted version so no in-flight
// submitter proposal is silently dropped at cutover.
//
// Safe to re-run: MigrateVolumes skips any record that already has a meta record, and each
// proposal is only converted once per run (re-running after a partial failure will create
// duplicate submitted versions for proposals already converted in an earlier run - this command
// doesn't yet mark a proposal as migrated, since proposed_changes is superseded entirely once
// every entity type's migration completes, see tasks.md task group 8).
//
// Known limitation: a pending proposal's staged cover/sample assets (StagedCoverAssetId/
// StagedSampleAssetIds) aren't representable on a VolumeVersion yet - see tasks.md 3.7. Any
// text fields (title/description/notes) on such a proposal are still migrated; the staged asset
// reference itself is logged as skipped and needs manual follow-up.
package main
import (
"context"
"fmt"
"os"
"github.com/joho/godotenv"
"github.com/sweetrpg/catalog-api/proposedchanges"
"github.com/sweetrpg/catalog-data.go/data"
"github.com/sweetrpg/common.go/logging"
"github.com/sweetrpg/mongodb.go/database"
)
func main() {
_ = godotenv.Load(".env")
logging.Init()
database.SetupDatabase()
defer database.TeardownDatabase()
ctx := context.Background()
migratedRecords, err := data.MigrateVolumes(ctx)
if err != nil {
logging.Logger.Error("migrate-volumes: backfill meta+version records failed", "error", err)
os.Exit(1)
}
fmt.Printf("migrated %d volume record(s) into meta+version\n", migratedRecords)
migratedProposals, skippedStagedAssets, err := migratePendingProposals(ctx)
if err != nil {
logging.Logger.Error("migrate-volumes: backfill pending proposals failed", "error", err)
os.Exit(1)
}
fmt.Printf("migrated %d pending proposal(s) into submitted versions\n", migratedProposals)
if skippedStagedAssets > 0 {
fmt.Printf(
"WARNING: %d proposal(s) referenced staged cover/sample assets that were NOT migrated - "+
"needs manual follow-up, see this command's doc comment\n", skippedStagedAssets)
}
}
// migratePendingProposals converts every still-pending "volume" proposed_changes entry into a
// submitted version, applying the proposal's diffed string fields onto the live record's
// current snapshot as the submitted version's base.
func migratePendingProposals(ctx context.Context) (migrated, skippedStagedAssets int, err error) {
pending, err := proposedchanges.ListPendingByType(ctx, "volume")
if err != nil {
return 0, 0, err
}
for _, p := range pending {
existing, err := data.GetVolume(ctx, p.RecordID)
if err != nil {
return migrated, skippedStagedAssets, err
}
if existing == nil {
logging.Logger.Warn(
"migrate-volumes: skipping proposal for a volume that no longer exists",
"proposalId", p.ID.Hex(), "volumeId", p.RecordID)
continue
}
updated := *existing
for field, change := range p.Diff {
newValue, ok := change.New.(string)
if !ok {
continue
}
switch field {
case "title":
updated.Title = newValue
case "description":
updated.Description = newValue
case "notes":
updated.Notes = newValue
}
}
if _, err := data.CreateSubmittedVolumeVersion(
ctx, p.RecordID, &updated, p.SubmittedBy, p.SubmittedAt); err != nil {
return migrated, skippedStagedAssets, err
}
migrated++
if p.StagedCoverAssetId != "" || len(p.StagedSampleAssetIds) > 0 {
logging.Logger.Warn(
"migrate-volumes: proposal referenced staged assets - not migrated onto the submitted version",
"proposalId", p.ID.Hex(), "volumeId", p.RecordID)
skippedStagedAssets++
}
}
return migrated, skippedStagedAssets, nil
}
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"termsOfService": "http://swagger.io/terms/",
"contact": {
"name": "API Support",
"url": "http://www.swagger.io/support",
"email": "support@swagger.io"
},
"license": {
"name": "MIT",
"url": "https://mit-license.org/"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/contributions": {
"get": {
"description": "Lists the contributions in the database.",
"produces": [
"application/json"
],
"tags": [
"contributions"
],
"summary": "List contributions",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/contributions/{id}": {
"get": {
"description": "Get the details of a contribution from the database.",
"produces": [
"application/json"
],
"tags": [
"contributions"
],
"summary": "Get a contribution",
"parameters": [
{
"type": "string",
"description": "Contribution ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/licenses": {
"get": {
"description": "Lists the licenses in the database.",
"produces": [
"application/json"
],
"tags": [
"licenses"
],
"summary": "List licenses",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/licenses/{id}": {
"get": {
"description": "Get the details of a license from the database.",
"produces": [
"application/json"
],
"tags": [
"licenses"
],
"summary": "Get a license",
"parameters": [
{
"type": "string",
"description": "License ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/licenses/{id}/volumes": {
"get": {
"description": "Gets all the volumes associated with a particular license",
"produces": [
"application/json"
],
"tags": [
"licenses"
],
"summary": "Get license volumes",
"parameters": [
{
"type": "string",
"description": "License ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/persons": {
"get": {
"description": "Lists the persons in the database.",
"produces": [
"application/json"
],
"tags": [
"persons"
],
"summary": "List persons",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/persons/{id}": {
"get": {
"description": "Get the details of a person from the database.",
"produces": [
"application/json"
],
"tags": [
"persons"
],
"summary": "Get a person",
"parameters": [
{
"type": "string",
"description": "Person ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/publishers": {
"get": {
"description": "Lists the publishers in the database.",
"produces": [
"application/json"
],
"tags": [
"publishers"
],
"summary": "List publishers",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/publishers/{id}": {
"get": {
"description": "Get the details of a publisher from the database.",
"produces": [
"application/json"
],
"tags": [
"publishers"
],
"summary": "Get a publisher",
"parameters": [
{
"type": "string",
"description": "Publisher ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/reviews": {
"get": {
"description": "Lists the reviews in the database.",
"produces": [
"application/json"
],
"tags": [
"reviews"
],
"summary": "List reviews",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/reviews/{id}": {
"get": {
"description": "Get the details of a review from the database.",
"produces": [
"application/json"
],
"tags": [
"reviews"
],
"summary": "Get a review",
"parameters": [
{
"type": "string",
"description": "Review ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/status/health": {
"get": {
"description": "Health check",
"produces": [
"application/json"
],
"tags": [
"status"
],
"summary": "Health check",
"responses": {
"200": {
"description": "OK",
"schema": {}
}
}
}
},
"/status/ping": {
"get": {
"description": "Ping",
"produces": [
"application/json"
],
"tags": [
"status"
],
"summary": "Ping",
"responses": {
"200": {
"description": "OK",
"schema": {}
}
}
}
},
"/studios": {
"get": {
"description": "Lists the studios in the database.",
"produces": [
"application/json"
],
"tags": [
"studios"
],
"summary": "List studios",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/studios/{id}": {
"get": {
"description": "Get the details of a studio from the database.",
"produces": [
"application/json"
],
"tags": [
"studios"
],
"summary": "Get a studio",
"parameters": [
{
"type": "string",
"description": "Studio ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/systems": {
"get": {
"description": "Lists the systems in the database.",
"produces": [
"application/json"
],
"tags": [
"systems"
],
"summary": "List systems",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/systems/{id}": {
"get": {
"description": "Get the details of a system from the database.",
"produces": [
"application/json"
],
"tags": [
"systems"
],
"summary": "Get a system",
"parameters": [
{
"type": "string",
"description": "System ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/volumes": {
"get": {
"description": "Lists the volumes in the database.",
"produces": [
"application/json"
],
"tags": [
"volumes"
],
"summary": "List volumes",
"responses": {
"200": {
"description": "OK",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/volumes/{id}": {
"get": {
"description": "Get the details of a volume from the database.",
"produces": [
"application/json"
],
"tags": [
"volumes"
],
"summary": "Get a volume",
"parameters": [
{
"type": "string",
"description": "Volume ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "localhost:8000",
BasePath: "/",
Schemes: []string{"http", "https"},
Title: "Catalog API service",
Description: "Testing Swagger APIs.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
// Package editsession reads/deletes the shared, session-backed volume edit state that
// catalog-web writes to Redis (REDIS_DB=2 on catalog-api's own Redis instance - see
// docs/frontend-conventions.md's edit-session schema in sweetrpg/platform). catalog-api reads
// a session at finalize time and deletes it once finalize completes; the one exception to
// "catalog-api never creates a session" is pull-back (task 5.4), which recreates a pending
// proposal's diff as a fresh session so the submitter can resume editing it.
package editsession
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/gomodule/redigo/redis"
)
// KeyPrefix is the Redis key namespace for edit sessions: "edit-session:<userId>:<recordType>".
const KeyPrefix = "edit-session"
// Session mirrors the JSON schema catalog-web writes.
type Session struct {
RecordID string `json:"recordId"`
Fields map[string]any `json:"fields"`
StagedCoverAssetId string `json:"stagedCoverAssetId,omitempty"`
SampleAssetIds []string `json:"sampleAssetIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Key returns the Redis key for a user's in-flight session on a given record type.
func Key(userID, recordType string) string {
return fmt.Sprintf("%s:%s:%s", KeyPrefix, userID, recordType)
}
// Store reads/deletes edit sessions against a Redis pool already selecting REDIS_DB=2.
type Store struct {
pool *redis.Pool
}
// NewStore wraps pool - the caller is responsible for the pool already dialing REDIS_DB=2.
func NewStore(pool *redis.Pool) *Store {
return &Store{pool: pool}
}
// Get fetches a user's in-flight session for recordType, or nil if none exists.
func (s *Store) Get(ctx context.Context, userID, recordType string) (*Session, error) {
conn, err := s.pool.GetContext(ctx)
if err != nil {
return nil, fmt.Errorf("editsession: get connection: %w", err)
}
defer func() { _ = conn.Close() }()
raw, err := redis.Bytes(conn.Do("GET", Key(userID, recordType)))
if err == redis.ErrNil {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("editsession: get %s: %w", Key(userID, recordType), err)
}
var session Session
if err := json.Unmarshal(raw, &session); err != nil {
return nil, fmt.Errorf("editsession: unmarshal %s: %w", Key(userID, recordType), err)
}
return &session, nil
}
// Set writes userID's session for recordType, overwriting any existing one. Used only by
// pull-back (task 5.4) - every other session write comes from catalog-web directly.
func (s *Store) Set(ctx context.Context, userID, recordType string, session Session) error {
conn, err := s.pool.GetContext(ctx)
if err != nil {
return fmt.Errorf("editsession: get connection: %w", err)
}
defer func() { _ = conn.Close() }()
raw, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("editsession: marshal session for %s: %w", Key(userID, recordType), err)
}
if _, err := conn.Do("SET", Key(userID, recordType), raw); err != nil {
return fmt.Errorf("editsession: set %s: %w", Key(userID, recordType), err)
}
return nil
}
// Delete removes a user's in-flight session for recordType. A missing session is not an error.
func (s *Store) Delete(ctx context.Context, userID, recordType string) error {
conn, err := s.pool.GetContext(ctx)
if err != nil {
return fmt.Errorf("editsession: get connection: %w", err)
}
defer func() { _ = conn.Close() }()
if _, err := conn.Do("DEL", Key(userID, recordType)); err != nil {
return fmt.Errorf("editsession: delete %s: %w", Key(userID, recordType), err)
}
return nil
}
// Package ratelimit implements a Redis-backed, per-client, per-route-tier request rate
// limiter. Counters live in Redis (INCR + EXPIRE) rather than per-pod memory, so the limit is
// consistent across catalog-api's replicas instead of being N times looser depending on how
// many pods happen to be up.
package ratelimit
import (
"context"
"fmt"
"github.com/gomodule/redigo/redis"
)
// Tier describes a rate-limit budget: at most Limit requests per Window.
type Tier struct {
Limit int
Window int // seconds
}
// Limiter enforces per-client request limits per tier, backed by a Redis connection pool.
type Limiter struct {
pool *redis.Pool
tiers map[string]Tier
}
// New builds a Limiter using the given Redis pool and tier definitions. Callers should pass at
// least a "standard" and "cheap" tier; Allow falls back to "standard" for an unknown tier name.
func New(pool *redis.Pool, tiers map[string]Tier) *Limiter {
return &Limiter{pool: pool, tiers: tiers}
}
// Allow increments the request counter for clientKey within the named tier and reports whether
// the request is within that tier's limit. A non-nil error means the Redis backend was
// unreachable - callers must treat that as fail-closed (reject the request), not fail-open.
func (l *Limiter) Allow(ctx context.Context, clientKey, tierName string) (bool, error) {
tier, ok := l.tiers[tierName]
if !ok {
tier = l.tiers["standard"]
}
conn, err := l.pool.GetContext(ctx)
if err != nil {
return false, fmt.Errorf("ratelimit: get redis connection: %w", err)
}
defer func() { _ = conn.Close() }()
key := fmt.Sprintf("ratelimit:%s:%s", tierName, clientKey)
count, err := redis.Int(conn.Do("INCR", key))
if err != nil {
return false, fmt.Errorf("ratelimit: incr: %w", err)
}
if count == 1 {
if _, err := conn.Do("EXPIRE", key, tier.Window); err != nil {
return false, fmt.Errorf("ratelimit: expire: %w", err)
}
}
return count <= tier.Limit, nil
}
// Ping verifies the Redis backend is reachable, for use in startup/readiness checks.
func Ping(ctx context.Context, pool *redis.Pool) error {
conn, err := pool.GetContext(ctx)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
_, err = conn.Do("PING")
return err
}
// Package readiness tracks the reachability of backend dependencies (beyond Mongo, which
// api-core.go's HealthHandler already covers) so /status/health can fail loud instead of the
// service silently degrading to an uncached or unlimited mode.
package readiness
import (
"context"
"sync/atomic"
"github.com/gomodule/redigo/redis"
"github.com/sweetrpg/catalog-api/ratelimit"
)
var cachePool atomic.Pointer[redis.Pool]
// SetCachePool records the Redis pool CacheReady live-pings on each call. Pass nil when no
// cache backend is configured (REDIS_HOST unset) - CacheReady then reports true, since the
// in-memory fallback store has no external dependency to fail.
func SetCachePool(pool *redis.Pool) {
cachePool.Store(pool)
}
// CacheReady live-pings the configured cache backend rather than returning a cached boot-time
// result - a Redis outage that resolves on its own should let readiness recover without
// requiring a pod restart.
func CacheReady(ctx context.Context) bool {
pool := cachePool.Load()
if pool == nil {
return true
}
return ratelimit.Ping(ctx, pool) == nil
}