From 853ebfaccb36ef99eb51f04e43b29026d98a6e3c Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Sun, 3 May 2026 07:52:50 +0700 Subject: [PATCH] [api] add RFC9727 support --- api/requests.http | 5 + internal/sms-gateway/handlers/3rdparty.go | 7 ++ internal/sms-gateway/handlers/api_catalog.go | 96 +++++++++++++++++++ internal/sms-gateway/handlers/module.go | 1 + internal/sms-gateway/jwt/metrics.go | 38 ++++---- .../sms-gateway/modules/events/metrics.go | 18 ++-- internal/sms-gateway/modules/push/metrics.go | 22 ++--- internal/sms-gateway/modules/sse/metrics.go | 25 ++--- internal/sms-gateway/online/metrics.go | 29 +++--- internal/worker/executor/metrics.go | 18 ++-- 10 files changed, 185 insertions(+), 74 deletions(-) create mode 100644 internal/sms-gateway/handlers/api_catalog.go diff --git a/api/requests.http b/api/requests.http index 78fb3570..e1dd7466 100644 --- a/api/requests.http +++ b/api/requests.http @@ -265,3 +265,8 @@ GET http://localhost:3000/health/ready HTTP/1.1 ### GET http://localhost:3000/health/live HTTP/1.1 +### +HEAD http://localhost:3000/.well-known/api-catalog HTTP/1.1 + +### +GET http://localhost:3000/.well-known/api-catalog HTTP/1.1 diff --git a/internal/sms-gateway/handlers/3rdparty.go b/internal/sms-gateway/handlers/3rdparty.go index f918c7da..bb7adeb6 100644 --- a/internal/sms-gateway/handlers/3rdparty.go +++ b/internal/sms-gateway/handlers/3rdparty.go @@ -73,6 +73,13 @@ func newThirdPartyHandler( func (h *thirdPartyHandler) Register(router fiber.Router) { router = router.Group("/3rdparty/v1") + // Add Link header pointing to api-catalog (RFC 9727 Section 3) + router.Use(func(c *fiber.Ctx) error { + err := c.Next() + c.Set(fiber.HeaderLink, `; rel="api-catalog"`) + return err //nolint:wrapcheck // passed through to fiber's error handler + }) + h.healthHandler.Register(router) router.Use( diff --git a/internal/sms-gateway/handlers/api_catalog.go b/internal/sms-gateway/handlers/api_catalog.go new file mode 100644 index 00000000..2e24f324 --- /dev/null +++ b/internal/sms-gateway/handlers/api_catalog.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "go.uber.org/zap" +) + +type APICatalogHandler struct { + config Config + logger *zap.Logger +} + +func newAPICatalogHandler(cfg Config, logger *zap.Logger) *APICatalogHandler { + return &APICatalogHandler{ + config: cfg, + logger: logger.Named("api_catalog"), + } +} + +func (h *APICatalogHandler) get(c *fiber.Ctx) error { + const ( + fieldHref = "href" + fieldType = "type" + ) + + c.Set(fiber.HeaderContentType, `application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"`) + c.Set(fiber.HeaderLink, `; rel="api-catalog"`) + c.Set(fiber.HeaderCacheControl, "public, max-age=3600") + + host := h.getHost(c) + path := h.getPath(c) + linkset := fiber.Map{ + "linkset": []fiber.Map{ + { + "anchor": fmt.Sprintf("https://%s/%s/3rdparty/v1", host, path), + "service-desc": []fiber.Map{ + { + fieldHref: fmt.Sprintf("https://%s/%s/docs/doc.json", host, path), + fieldType: "application/json", + }, + }, + "service-doc": []fiber.Map{ + { + fieldHref: "https://docs.sms-gate.app/", + fieldType: "text/html", + }, + }, + "status": []fiber.Map{ + { + fieldHref: fmt.Sprintf("https://%s/%s/3rdparty/v1/health/ready", host, path), + fieldType: "application/json", + }, + }, + }, + }, + } + + return c.JSON(linkset) +} + +func (h *APICatalogHandler) head(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, `application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"`) + c.Set("Link", `; rel="api-catalog"`) + c.Set(fiber.HeaderCacheControl, "public, max-age=3600") + return c.SendStatus(fiber.StatusOK) +} + +func (h *APICatalogHandler) getHost(c *fiber.Ctx) string { + if h.config.PublicHost != "" { + return h.config.PublicHost + } + return c.Hostname() +} + +func (h *APICatalogHandler) getPath(_ *fiber.Ctx) any { + return strings.TrimLeft(h.config.PublicPath, "/") +} + +func (h *APICatalogHandler) Register(app *fiber.App) { + const limit = 60 + + rateLimiter := limiter.New(limiter.Config{ + Max: limit, + Expiration: time.Minute, + LimiterMiddleware: limiter.SlidingWindow{}, + }) + + group := app.Group("/.well-known/api-catalog", rateLimiter) + group.Get("", h.get) + group.Head("", h.head) +} diff --git a/internal/sms-gateway/handlers/module.go b/internal/sms-gateway/handlers/module.go index 6c3ca086..66a1a6df 100644 --- a/internal/sms-gateway/handlers/module.go +++ b/internal/sms-gateway/handlers/module.go @@ -22,6 +22,7 @@ func Module() fx.Option { }), fx.Provide( http.AsRootHandler(newRootHandler), + http.AsRootHandler(newAPICatalogHandler), http.AsApiHandler(newThirdPartyHandler), http.AsApiHandler(newMobileHandler), http.AsApiHandler(newUpstreamHandler), diff --git a/internal/sms-gateway/jwt/metrics.go b/internal/sms-gateway/jwt/metrics.go index b0f0a1fc..efec0381 100644 --- a/internal/sms-gateway/jwt/metrics.go +++ b/internal/sms-gateway/jwt/metrics.go @@ -7,6 +7,9 @@ import ( // Metric constants. const ( + metricsNamespace = "sms" + metricsSubsystem = "jwt" + MetricTokensIssuedTotal = "jwt_tokens_issued_total" //nolint:gosec // false positive MetricTokensValidatedTotal = "jwt_tokens_validated_total" //nolint:gosec // false positive MetricTokensRevokedTotal = "jwt_tokens_revoked_total" //nolint:gosec // false positive @@ -37,67 +40,64 @@ type Metrics struct { // NewMetrics creates and initializes all JWT metrics. func NewMetrics() *Metrics { - const namespace = "sms" - const subsystem = "auth" - var defBuckets = []float64{.0005, .001, .0025, .005, .01, .025, .05, .1, .25, .5, 1} return &Metrics{ tokensIssuedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricTokensIssuedTotal, Help: "Total number of JWT tokens issued", }, []string{labelStatus}), tokensValidatedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricTokensValidatedTotal, Help: "Total number of JWT tokens validated", }, []string{labelStatus}), tokensRevokedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricTokensRevokedTotal, Help: "Total number of JWT tokens revoked", }, []string{labelStatus}), tokensRefreshedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricTokensRefreshedTotal, Help: "Total number of JWT tokens refreshed", }, []string{labelStatus}), issuanceDurationHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricIssuanceDurationSeconds, Help: "JWT issuance duration in seconds", Buckets: defBuckets, }), validationDurationHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricValidationDurationSeconds, Help: "JWT validation duration in seconds", Buckets: defBuckets, }), revocationDurationHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricRevocationDurationSeconds, Help: "JWT revocation duration in seconds", Buckets: defBuckets, }), refreshDurationHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricRefreshDurationSeconds, Help: "JWT refresh duration in seconds", Buckets: defBuckets, diff --git a/internal/sms-gateway/modules/events/metrics.go b/internal/sms-gateway/modules/events/metrics.go index 9b6bc423..84e64e17 100644 --- a/internal/sms-gateway/modules/events/metrics.go +++ b/internal/sms-gateway/modules/events/metrics.go @@ -7,6 +7,9 @@ import ( // Metric constants. const ( + metricsNamespace = "sms" + metricsSubsystem = "events" + MetricEnqueuedTotal = "enqueued_total" MetricSentTotal = "sent_total" MetricFailedTotal = "failed_total" @@ -35,25 +38,22 @@ type metrics struct { // newMetrics creates and initializes all events metrics. func newMetrics() *metrics { - const namespace = "sms" - const subsystem = "events" - return &metrics{ enqueuedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricEnqueuedTotal, Help: "Total number of events enqueued", }, []string{LabelEvent}), sentCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricSentTotal, Help: "Total number of events sent", }, []string{LabelEvent, LabelDeliveryType}), failedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricFailedTotal, Help: "Total number of failed notifications", }, []string{LabelEvent, LabelDeliveryType, LabelReason}), diff --git a/internal/sms-gateway/modules/push/metrics.go b/internal/sms-gateway/modules/push/metrics.go index 6bcfac19..53638b8f 100644 --- a/internal/sms-gateway/modules/push/metrics.go +++ b/internal/sms-gateway/modules/push/metrics.go @@ -8,6 +8,9 @@ import ( type BlacklistOperation string const ( + metricsNamespace = "sms" + metricsSubsystem = "push" + BlacklistOperationAdded BlacklistOperation = "added" BlacklistOperationSkipped BlacklistOperation = "skipped" ) @@ -20,34 +23,31 @@ type metrics struct { } func newMetrics() *metrics { - const namespace = "sms" - const subsystem = "push" - return &metrics{ enqueuedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "enqueued_total", Help: "Total number of messages enqueued", }, []string{"event"}), retriesCounter: promauto.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "retries_total", Help: "Total retry attempts", }), blacklistCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "blacklist_total", Help: "Blacklist operations", }, []string{"operation"}), errorsCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "errors_total", Help: "Total number of errors", }, []string{}), diff --git a/internal/sms-gateway/modules/sse/metrics.go b/internal/sms-gateway/modules/sse/metrics.go index 5c845e23..5042a143 100644 --- a/internal/sms-gateway/modules/sse/metrics.go +++ b/internal/sms-gateway/modules/sse/metrics.go @@ -7,6 +7,9 @@ import ( // Metric constants. const ( + metricsNamespace = "sms" + metricsSubsystem = "sse" + MetricActiveConnections = "active_connections" MetricEventsSent = "events_sent_total" MetricConnectionErrors = "connection_errors_total" @@ -33,39 +36,37 @@ type metrics struct { // newMetrics creates and initializes all SSE metrics. func newMetrics() *metrics { - const namespace = "sms" - const subsystem = "sse" var defBuckets = []float64{1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, .001, .005, .01, .05, .1} metrics := &metrics{ activeConnections: promauto.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricActiveConnections, Help: "Current number of active SSE connections", }, []string{}), eventsSent: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricEventsSent, Help: "Total number of SSE events sent, labeled by event type", }, []string{LabelEventType}), connectionErrors: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricConnectionErrors, Help: "Total number of SSE connection errors, labeled by error type", }, []string{LabelErrorType}), eventDeliveryLatency: promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricEventLatency, Help: "Event delivery latency in seconds", Buckets: defBuckets, }, []string{}), keepalivesSent: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: MetricKeepalivesSent, Help: "Total keepalive messages sent", }, []string{}), diff --git a/internal/sms-gateway/online/metrics.go b/internal/sms-gateway/online/metrics.go index 007e608b..19f50147 100644 --- a/internal/sms-gateway/online/metrics.go +++ b/internal/sms-gateway/online/metrics.go @@ -7,6 +7,9 @@ import ( // Metric constants. const ( + metricsNamespace = "sms" + metricsSubsystem = "online" + metricStatusSetTotal = "status_set_total" metricCacheOperations = "cache_operations_total" metricCacheLatency = "cache_latency_seconds" @@ -36,52 +39,50 @@ type metrics struct { // newMetrics creates and initializes all online metrics. func newMetrics() *metrics { - const namespace = "sms" - const subsystem = "online" var memBuckets = []float64{1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, .001, .005, .01, .05, .1} var dbBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10} return &metrics{ statusSetCounter: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricStatusSetTotal, Help: "Total number of online status updates", }, []string{labelStatus}), cacheOperations: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricCacheOperations, Help: "Total cache operations by type", }, []string{labelOperation, labelStatus}), cacheLatency: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricCacheLatency, Help: "Cache operation latency in seconds", Buckets: memBuckets, }), persistenceLatency: promauto.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricPersistenceLatency, Help: "Persistence operation latency in seconds", Buckets: dbBuckets, }), persistenceErrors: promauto.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricPersistenceErrors, Help: "Total persistence errors by type", }), batchSize: promauto.NewGauge(prometheus.GaugeOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: metricBatchSize, Help: "Current batch size", }), diff --git a/internal/worker/executor/metrics.go b/internal/worker/executor/metrics.go index 216e5c4d..8d46881a 100644 --- a/internal/worker/executor/metrics.go +++ b/internal/worker/executor/metrics.go @@ -10,6 +10,9 @@ import ( type metricsTaskResult string const ( + metricsNamespace = "worker" + metricsSubsystem = "executor" + metricsTaskResultSuccess metricsTaskResult = "success" metricsTaskResultError metricsTaskResult = "error" ) @@ -21,27 +24,24 @@ type metrics struct { } func newMetrics() *metrics { - const namespace = "worker" - const subsystem = "executor" - var defBuckets = []float64{.01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25} return &metrics{ activeTasksCounter: promauto.NewGauge(prometheus.GaugeOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "active_tasks", Help: "Number of active tasks", }), taskResult: promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "task_result_total", Help: "Task result, labeled by task name and result", }, []string{"task", "result"}), taskDuration: promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, + Namespace: metricsNamespace, + Subsystem: metricsSubsystem, Name: "task_duration_seconds", Help: "Task duration in seconds", Buckets: defBuckets,