diff --git a/api/local.http b/api/local.http index abb9b5f..fbf4804 100644 --- a/api/local.http +++ b/api/local.http @@ -10,7 +10,7 @@ GET {{localUrl}}/device HTTP/1.1 Authorization: Basic {{localCredentials}} ### -POST {{localUrl}}/message?skipPhoneValidation=true HTTP/1.1 +POST {{localUrl}}/message?skipPhoneValidation=false HTTP/1.1 Content-Type: application/json Authorization: Basic {{localCredentials}} diff --git a/api/requests.http b/api/requests.http index 4265f8b..4ab740d 100644 --- a/api/requests.http +++ b/api/requests.http @@ -3,6 +3,9 @@ @mobileToken={{$dotenv MOBILE__TOKEN}} @phone={{$dotenv PHONE}} +### +GET {{baseUrl}}/api/3rdparty/v1/health HTTP/1.1 + ### POST {{baseUrl}}/api/mobile/v1/device HTTP/1.1 Authorization: Bearer 123456789 diff --git a/api/swagger.json b/api/swagger.json index 4030584..265944d 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -61,6 +61,32 @@ } } }, + "/3rdparty/v1/health": { + "get": { + "description": "Checks if service is healthy", + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "Health check result", + "schema": { + "$ref": "#/definitions/smsgateway.HealthResponse" + } + }, + "500": { + "description": "Service is unhealthy", + "schema": { + "$ref": "#/definitions/smsgateway.HealthResponse" + } + } + } + } + }, "/3rdparty/v1/message": { "get": { "security": [ @@ -387,7 +413,6 @@ "application/json" ], "tags": [ - "Device", "Upstream" ], "summary": "Send push notifications", @@ -484,6 +509,79 @@ } } }, + "smsgateway.HealthCheck": { + "type": "object", + "properties": { + "description": { + "description": "A human-readable description of the check.", + "type": "string" + }, + "observedUnit": { + "description": "Unit of measurement for the observed value.", + "type": "string" + }, + "observedValue": { + "description": "Observed value of the check.", + "type": "integer" + }, + "status": { + "description": "Status of the check.\nIt can be one of the following values: \"pass\", \"warn\", or \"fail\".", + "allOf": [ + { + "$ref": "#/definitions/smsgateway.HealthStatus" + } + ] + } + } + }, + "smsgateway.HealthChecks": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/smsgateway.HealthCheck" + } + }, + "smsgateway.HealthResponse": { + "type": "object", + "properties": { + "checks": { + "description": "A map of check names to their respective details.", + "allOf": [ + { + "$ref": "#/definitions/smsgateway.HealthChecks" + } + ] + }, + "releaseId": { + "description": "Release ID of the application.\nIt is used to identify the version of the application.", + "type": "integer" + }, + "status": { + "description": "Overall status of the application.\nIt can be one of the following values: \"pass\", \"warn\", or \"fail\".", + "allOf": [ + { + "$ref": "#/definitions/smsgateway.HealthStatus" + } + ] + }, + "version": { + "description": "Version of the application.", + "type": "string" + } + } + }, + "smsgateway.HealthStatus": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ], + "x-enum-varnames": [ + "HealthStatusPass", + "HealthStatusWarn", + "HealthStatusFail" + ] + }, "smsgateway.Message": { "type": "object", "required": [ diff --git a/api/swagger.yaml b/api/swagger.yaml index 7d0d4a5..cb6de2d 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -39,6 +39,59 @@ definitions: example: An error occurred type: string type: object + smsgateway.HealthCheck: + properties: + description: + description: A human-readable description of the check. + type: string + observedUnit: + description: Unit of measurement for the observed value. + type: string + observedValue: + description: Observed value of the check. + type: integer + status: + allOf: + - $ref: '#/definitions/smsgateway.HealthStatus' + description: |- + Status of the check. + It can be one of the following values: "pass", "warn", or "fail". + type: object + smsgateway.HealthChecks: + additionalProperties: + $ref: '#/definitions/smsgateway.HealthCheck' + type: object + smsgateway.HealthResponse: + properties: + checks: + allOf: + - $ref: '#/definitions/smsgateway.HealthChecks' + description: A map of check names to their respective details. + releaseId: + description: |- + Release ID of the application. + It is used to identify the version of the application. + type: integer + status: + allOf: + - $ref: '#/definitions/smsgateway.HealthStatus' + description: |- + Overall status of the application. + It can be one of the following values: "pass", "warn", or "fail". + version: + description: Version of the application. + type: string + type: object + smsgateway.HealthStatus: + enum: + - pass + - warn + - fail + type: string + x-enum-varnames: + - HealthStatusPass + - HealthStatusWarn + - HealthStatusFail smsgateway.Message: properties: id: @@ -253,6 +306,23 @@ paths: summary: List devices tags: - User + /3rdparty/v1/health: + get: + description: Checks if service is healthy + produces: + - application/json + responses: + "200": + description: Health check result + schema: + $ref: '#/definitions/smsgateway.HealthResponse' + "500": + description: Service is unhealthy + schema: + $ref: '#/definitions/smsgateway.HealthResponse' + summary: Health check + tags: + - User /3rdparty/v1/message: get: description: Returns message state by ID @@ -490,7 +560,6 @@ paths: $ref: '#/definitions/smsgateway.ErrorResponse' summary: Send push notifications tags: - - Device - Upstream schemes: - https diff --git a/internal/sms-gateway/app.go b/internal/sms-gateway/app.go index 5741a24..70fc0d3 100644 --- a/internal/sms-gateway/app.go +++ b/internal/sms-gateway/app.go @@ -12,6 +12,7 @@ import ( appconfig "github.com/capcom6/sms-gateway/internal/config" "github.com/capcom6/sms-gateway/internal/sms-gateway/handlers" "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health" "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/messages" "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" @@ -35,6 +36,7 @@ var Module = fx.Module( repositories.Module, db.Module, messages.Module, + health.Module, ) func Run() { diff --git a/internal/sms-gateway/handlers/3rdparty.go b/internal/sms-gateway/handlers/3rdparty.go index f830ce8..5f7d6b8 100644 --- a/internal/sms-gateway/handlers/3rdparty.go +++ b/internal/sms-gateway/handlers/3rdparty.go @@ -6,9 +6,11 @@ import ( "github.com/capcom6/sms-gateway/internal/sms-gateway/models" "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health" "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/messages" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" "github.com/capcom6/sms-gateway/internal/sms-gateway/services" + "github.com/capcom6/sms-gateway/pkg/maps" "github.com/capcom6/sms-gateway/pkg/smsgateway" "github.com/capcom6/sms-gateway/pkg/types" "github.com/go-playground/validator/v10" @@ -28,6 +30,7 @@ type ThirdPartyHandlerParams struct { AuthSvc *auth.Service MessagesSvc *messages.Service DevicesSvc *services.DevicesService + HealthSvc *health.Service Logger *zap.Logger Validator *validator.Validate @@ -39,6 +42,44 @@ type thirdPartyHandler struct { authSvc *auth.Service messagesSvc *messages.Service devicesSvc *services.DevicesService + healthSvc *health.Service +} + +// @Summary Health check +// @Description Checks if service is healthy +// @Tags User +// @Produce json +// @Success 200 {object} smsgateway.HealthResponse "Health check result" +// @Failure 500 {object} smsgateway.HealthResponse "Service is unhealthy" +// @Router /3rdparty/v1/health [get] +// +// Health check +func (h *thirdPartyHandler) getHealth(c *fiber.Ctx) error { + check, err := h.healthSvc.HealthCheck(c.Context()) + if err != nil { + return err + } + + res := smsgateway.HealthResponse{ + Status: smsgateway.HealthStatus(check.Status), + Checks: maps.MapValues( + check.Checks, + func(c health.CheckDetail) smsgateway.HealthCheck { + return smsgateway.HealthCheck{ + Description: c.Description, + ObservedUnit: c.ObservedUnit, + ObservedValue: c.ObservedValue, + Status: smsgateway.HealthStatus(c.Status), + } + }, + ), + } + + if check.Status == health.StatusFail { + return c.Status(fiber.StatusInternalServerError).JSON(res) + } + + return c.Status(fiber.StatusOK).JSON(res) } // @Summary List devices @@ -180,6 +221,8 @@ func (h *thirdPartyHandler) authorize(handler func(models.User, *fiber.Ctx) erro func (h *thirdPartyHandler) Register(router fiber.Router) { router = router.Group("/3rdparty/v1") + router.Get("/health", h.getHealth) + router.Use(basicauth.New(basicauth.Config{ Authorizer: func(username string, password string) bool { return len(username) > 0 && len(password) > 0 @@ -198,5 +241,6 @@ func newThirdPartyHandler(params ThirdPartyHandlerParams) *thirdPartyHandler { authSvc: params.AuthSvc, messagesSvc: params.MessagesSvc, devicesSvc: params.DevicesSvc, + healthSvc: params.HealthSvc, } } diff --git a/internal/sms-gateway/handlers/upstream.go b/internal/sms-gateway/handlers/upstream.go index 132d640..048192d 100644 --- a/internal/sms-gateway/handlers/upstream.go +++ b/internal/sms-gateway/handlers/upstream.go @@ -39,7 +39,7 @@ func newUpstreamHandler(params upstreamHandlerParams) *upstreamHandler { // @Summary Send push notifications // @Description Enqueues notifications for sending to devices -// @Tags Device, Upstream +// @Tags Upstream // @Accept json // @Produce json // @Param request body smsgateway.UpstreamPushRequest true "Push request" diff --git a/internal/sms-gateway/modules/health/db.go b/internal/sms-gateway/modules/health/db.go new file mode 100644 index 0000000..b53bf52 --- /dev/null +++ b/internal/sms-gateway/modules/health/db.go @@ -0,0 +1,50 @@ +package health + +import ( + "context" + "database/sql" + "sync/atomic" + + "go.uber.org/fx" +) + +type DBProviderParams struct { + fx.In + + DB *sql.DB +} + +type DBProvider struct { + db *sql.DB + + counter atomic.Int32 +} + +func (p *DBProvider) Name() string { + return "db" +} + +func (p *DBProvider) HealthCheck(ctx context.Context) (Checks, error) { + status := StatusPass + + err := p.db.PingContext(ctx) + if err != nil { + p.counter.Store(-1) + status = StatusFail + } + + return Checks{ + "ping": { + Description: "Successful pings since startup or last failure", + ObservedUnit: "", + ObservedValue: int(p.counter.Add(1)), + Status: status, + }, + }, err +} + +func NewDBProvider(params DBProviderParams) *DBProvider { + return &DBProvider{ + db: params.DB, + } +} diff --git a/internal/sms-gateway/modules/health/module.go b/internal/sms-gateway/modules/health/module.go new file mode 100644 index 0000000..4ae67c3 --- /dev/null +++ b/internal/sms-gateway/modules/health/module.go @@ -0,0 +1,20 @@ +package health + +import ( + "go.uber.org/fx" + "go.uber.org/zap" +) + +var Module = fx.Module( + "health", + fx.Decorate(func(log *zap.Logger) *zap.Logger { + return log.Named("health") + }), + fx.Provide( + AsHealthProvider(NewDBProvider), + fx.Private, + ), + fx.Provide( + NewService, + ), +) diff --git a/internal/sms-gateway/modules/health/service.go b/internal/sms-gateway/modules/health/service.go new file mode 100644 index 0000000..9d968ac --- /dev/null +++ b/internal/sms-gateway/modules/health/service.go @@ -0,0 +1,70 @@ +package health + +import ( + "context" + + "go.uber.org/fx" + "go.uber.org/zap" +) + +type ServiceParams struct { + fx.In + + HealthProviders []HealthProvider `group:"health-providers"` + + Logger *zap.Logger +} + +type Service struct { + healthProviders []HealthProvider + + logger *zap.Logger +} + +func NewService(params ServiceParams) *Service { + return &Service{ + healthProviders: params.HealthProviders, + + logger: params.Logger, + } +} + +func (s *Service) HealthCheck(ctx context.Context) (Check, error) { + check := Check{ + Status: StatusPass, + Checks: map[string]CheckDetail{}, + } + + level := levelPass + for _, p := range s.healthProviders { + healthChecks, err := p.HealthCheck(ctx) + if err != nil { + s.logger.Error("Error getting health check", zap.String("provider", p.Name()), zap.Error(err)) + } + if len(healthChecks) == 0 { + continue + } + + for name, detail := range healthChecks { + check.Checks[p.Name()+":"+name] = detail + + if detail.Status == StatusFail { + level = max(level, levelFail) + } else if detail.Status == StatusWarn { + level = max(level, levelWarn) + } + } + } + + check.Status = statusLevels[level] + + return check, nil +} + +func AsHealthProvider(f any) any { + return fx.Annotate( + f, + fx.As(new(HealthProvider)), + fx.ResultTags(`group:"health-providers"`), + ) +} diff --git a/internal/sms-gateway/modules/health/types.go b/internal/sms-gateway/modules/health/types.go new file mode 100644 index 0000000..b3aeef2 --- /dev/null +++ b/internal/sms-gateway/modules/health/types.go @@ -0,0 +1,52 @@ +package health + +import "context" + +type Status string +type statusLevel int + +const ( + StatusPass Status = "pass" + StatusWarn Status = "warn" + StatusFail Status = "fail" + + levelPass statusLevel = 0 + levelWarn statusLevel = 1 + levelFail statusLevel = 2 +) + +var statusLevels = map[statusLevel]Status{ + levelPass: StatusPass, + levelWarn: StatusWarn, + levelFail: StatusFail, +} + +// Health status of the application. +type Check struct { + // Overall status of the application. + // It can be one of the following values: "pass", "warn", or "fail". + Status Status + // A map of check names to their respective details. + Checks Checks +} + +// Details of a health check. +type CheckDetail struct { + // A human-readable description of the check. + Description string + // Unit of measurement for the observed value. + ObservedUnit string + // Observed value of the check. + ObservedValue int + // Status of the check. + // It can be one of the following values: "pass", "warn", or "fail". + Status Status +} + +// Map of check names to their respective details. +type Checks map[string]CheckDetail + +type HealthProvider interface { + Name() string + HealthCheck(ctx context.Context) (Checks, error) +} diff --git a/pkg/maps/map_values.go b/pkg/maps/map_values.go new file mode 100644 index 0000000..e0e9639 --- /dev/null +++ b/pkg/maps/map_values.go @@ -0,0 +1,9 @@ +package maps + +func MapValues[K comparable, V any, R any](m map[K]V, f func(V) R) map[K]R { + result := make(map[K]R, len(m)) + for k, v := range m { + result[k] = f(v) + } + return result +} diff --git a/pkg/smsgateway/responses_health.go b/pkg/smsgateway/responses_health.go new file mode 100644 index 0000000..ff683be --- /dev/null +++ b/pkg/smsgateway/responses_health.go @@ -0,0 +1,39 @@ +package smsgateway + +type HealthStatus string + +const ( + HealthStatusPass HealthStatus = "pass" + HealthStatusWarn HealthStatus = "warn" + HealthStatusFail HealthStatus = "fail" +) + +// Details of a health check. +type HealthCheck struct { + // A human-readable description of the check. + Description string `json:"description,omitempty"` + // Unit of measurement for the observed value. + ObservedUnit string `json:"observedUnit,omitempty"` + // Observed value of the check. + ObservedValue int `json:"observedValue"` + // Status of the check. + // It can be one of the following values: "pass", "warn", or "fail". + Status HealthStatus `json:"status"` +} + +// Map of check names to their respective details. +type HealthChecks map[string]HealthCheck + +// Health status of the application. +type HealthResponse struct { + // Overall status of the application. + // It can be one of the following values: "pass", "warn", or "fail". + Status HealthStatus `json:"status"` + // Version of the application. + Version string `json:"version,omitempty"` + // Release ID of the application. + // It is used to identify the version of the application. + ReleaseID int `json:"releaseId,omitempty"` + // A map of check names to their respective details. + Checks HealthChecks `json:"checks,omitempty"` +}