diff --git a/cmd/psqlqueue/main.go b/cmd/psqlqueue/main.go index 9eef909..5529c18 100644 --- a/cmd/psqlqueue/main.go +++ b/cmd/psqlqueue/main.go @@ -47,21 +47,24 @@ func main() { messageRepository := repository.NewMessage(pool) topicRepository := repository.NewTopic(pool) subscriptionRepository := repository.NewSubscription(pool) + healthCheckRepository := repository.NewHealthCheck(pool) // services queueService := service.NewQueue(queueRepository) messageService := service.NewMessage(messageRepository, queueRepository) topicService := service.NewTopic(topicRepository, subscriptionRepository, queueRepository, messageRepository) subscriptionService := service.NewSubscription(subscriptionRepository) + healthCheckService := service.NewHealthCheck(healthCheckRepository) // http handlers queueHandler := http.NewQueueHandler(queueService) messageHandler := http.NewMessageHandler(messageService) topicHandler := http.NewTopicHandler(topicService) subscriptionHandler := http.NewSubscriptionHandler(subscriptionService) + healthCheckHandler := http.NewHealthCheckHandler(healthCheckService) // run http server - http.RunServer(c.Context, cfg, http.SetupRouter(logger, queueHandler, messageHandler, topicHandler, subscriptionHandler)) + http.RunServer(c.Context, cfg, http.SetupRouter(logger, queueHandler, messageHandler, topicHandler, subscriptionHandler, healthCheckHandler)) return nil }, diff --git a/docs/docs.go b/docs/docs.go index 54f8c41..ba87974 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,34 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/healthz": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health-check" + ], + "summary": "Execute a health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/HealthCheckResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/queue/{queue_id}/messages": { "post": { "consumes": [ @@ -987,6 +1015,14 @@ const docTemplate = `{ "subscriptionNotFound" ] }, + "HealthCheckResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + }, "MessageListResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 3d528bc..5a306bc 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4,6 +4,34 @@ "contact": {} }, "paths": { + "/healthz": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health-check" + ], + "summary": "Execute a health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/HealthCheckResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, "/queue/{queue_id}/messages": { "post": { "consumes": [ @@ -976,6 +1004,14 @@ "subscriptionNotFound" ] }, + "HealthCheckResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + }, "MessageListResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index cc6ee5d..16c3e68 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -32,6 +32,11 @@ definitions: - topicNotFound - subscriptionAlreadyExists - subscriptionNotFound + HealthCheckResponse: + properties: + success: + type: boolean + type: object MessageListResponse: properties: data: @@ -240,6 +245,24 @@ definitions: info: contact: {} paths: + /healthz: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/HealthCheckResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Execute a health check + tags: + - health-check /queue/{queue_id}/messages: post: consumes: diff --git a/domain/health_check.go b/domain/health_check.go new file mode 100644 index 0000000..71fddcd --- /dev/null +++ b/domain/health_check.go @@ -0,0 +1,18 @@ +package domain + +import "context" + +// HealthCheck entity. +type HealthCheck struct { + Success bool `json:"success"` +} + +// HealthCheckRepository is the repository interface for the HealthCheck entity. +type HealthCheckRepository interface { + Check(ctx context.Context) (*HealthCheck, error) +} + +// HealthCheckService is the service interface for the HealthCheck entity. +type HealthCheckService interface { + Check(ctx context.Context) (*HealthCheck, error) +} diff --git a/http/health_check.go b/http/health_check.go new file mode 100644 index 0000000..0a28449 --- /dev/null +++ b/http/health_check.go @@ -0,0 +1,44 @@ +package http + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/allisson/psqlqueue/domain" +) + +// nolint:unused +type healthCheckResponse struct { + Success bool `json:"success"` +} //@name HealthCheckResponse + +// HealthCheckHandler exposes a REST API for domain.HealthCheckService. +type HealthCheckHandler struct { + healthCheckService domain.HealthCheckService +} + +// Execute a health check. +// +// @Summary Execute a health check +// @Tags health-check +// @Accept json +// @Produce json +// @Success 200 {object} healthCheckResponse +// @Failure 500 {object} errorResponse +// @Router /healthz [get] +func (h *HealthCheckHandler) Check(c *gin.Context) { + healthCheck, err := h.healthCheckService.Check(c.Request.Context()) + if err != nil { + er := parseServiceError("healthCheckService", "Check", err) + c.JSON(er.StatusCode, &er) + return + } + + c.JSON(http.StatusOK, &healthCheck) +} + +// NewHealthCheckHandler returns a new HealthCheckHandler. +func NewHealthCheckHandler(healthCheckService domain.HealthCheckService) *HealthCheckHandler { + return &HealthCheckHandler{healthCheckService: healthCheckService} +} diff --git a/http/health_check_test.go b/http/health_check_test.go new file mode 100644 index 0000000..3bb4305 --- /dev/null +++ b/http/health_check_test.go @@ -0,0 +1,27 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/allisson/psqlqueue/domain" +) + +func TestHealthCheckHandler(t *testing.T) { + t.Run("Check", func(t *testing.T) { + expectedPayload := `{"success":true}` + tc := makeTestContext(t) + reqRec := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/v1/healthz", nil) + + tc.healthCheckService.On("Check", mock.Anything).Return(&domain.HealthCheck{Success: true}, nil) + tc.router.ServeHTTP(reqRec, req) + + assert.Equal(t, http.StatusOK, reqRec.Code) + assert.Equal(t, expectedPayload, reqRec.Body.String()) + }) +} diff --git a/http/queue_test.go b/http/queue_test.go index 5a22688..2f7a397 100644 --- a/http/queue_test.go +++ b/http/queue_test.go @@ -24,6 +24,8 @@ type testContext struct { topicHandler *TopicHandler subscriptionService *mocks.SubscriptionService subscriptionHandler *SubscriptionHandler + healthCheckService *mocks.HealthCheckService + healthCheckHandler *HealthCheckHandler router *gin.Engine } @@ -37,7 +39,9 @@ func makeTestContext(t *testing.T) *testContext { topicHandler := NewTopicHandler(topicService) subscriptionService := mocks.NewSubscriptionService(t) subscriptionHandler := NewSubscriptionHandler(subscriptionService) - router := SetupRouter(logger, queueHandler, messageHandler, topicHandler, subscriptionHandler) + healthCheckService := mocks.NewHealthCheckService(t) + healthCheckHandler := NewHealthCheckHandler(healthCheckService) + router := SetupRouter(logger, queueHandler, messageHandler, topicHandler, subscriptionHandler, healthCheckHandler) return &testContext{ queueService: queueService, queueHandler: queueHandler, @@ -47,6 +51,8 @@ func makeTestContext(t *testing.T) *testContext { topicHandler: topicHandler, subscriptionService: subscriptionService, subscriptionHandler: subscriptionHandler, + healthCheckService: healthCheckService, + healthCheckHandler: healthCheckHandler, router: router, } } diff --git a/http/setup.go b/http/setup.go index f22d004..ab39474 100644 --- a/http/setup.go +++ b/http/setup.go @@ -19,7 +19,7 @@ import ( "github.com/allisson/psqlqueue/domain" ) -func SetupRouter(logger *slog.Logger, queueHandler *QueueHandler, messageHandler *MessageHandler, topicHandler *TopicHandler, subscriptionHandler *SubscriptionHandler) *gin.Engine { +func SetupRouter(logger *slog.Logger, queueHandler *QueueHandler, messageHandler *MessageHandler, topicHandler *TopicHandler, subscriptionHandler *SubscriptionHandler, healthCheckHandler *HealthCheckHandler) *gin.Engine { // router setup gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -64,6 +64,9 @@ func SetupRouter(logger *slog.Logger, queueHandler *QueueHandler, messageHandler v1.GET("/subscriptions", subscriptionHandler.List) v1.DELETE("/subscriptions/:subscription_id", subscriptionHandler.Delete) + // health check handler + v1.GET("/healthz", healthCheckHandler.Check) + return router } diff --git a/mocks/HealthCheckRepository.go b/mocks/HealthCheckRepository.go new file mode 100644 index 0000000..10cab1b --- /dev/null +++ b/mocks/HealthCheckRepository.go @@ -0,0 +1,59 @@ +// Code generated by mockery v2.39.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + domain "github.com/allisson/psqlqueue/domain" + mock "github.com/stretchr/testify/mock" +) + +// HealthCheckRepository is an autogenerated mock type for the HealthCheckRepository type +type HealthCheckRepository struct { + mock.Mock +} + +// Check provides a mock function with given fields: ctx +func (_m *HealthCheckRepository) Check(ctx context.Context) (*domain.HealthCheck, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Check") + } + + var r0 *domain.HealthCheck + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*domain.HealthCheck, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *domain.HealthCheck); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.HealthCheck) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewHealthCheckRepository creates a new instance of HealthCheckRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewHealthCheckRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *HealthCheckRepository { + mock := &HealthCheckRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/HealthCheckService.go b/mocks/HealthCheckService.go new file mode 100644 index 0000000..9546604 --- /dev/null +++ b/mocks/HealthCheckService.go @@ -0,0 +1,59 @@ +// Code generated by mockery v2.39.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + domain "github.com/allisson/psqlqueue/domain" + mock "github.com/stretchr/testify/mock" +) + +// HealthCheckService is an autogenerated mock type for the HealthCheckService type +type HealthCheckService struct { + mock.Mock +} + +// Check provides a mock function with given fields: ctx +func (_m *HealthCheckService) Check(ctx context.Context) (*domain.HealthCheck, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Check") + } + + var r0 *domain.HealthCheck + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*domain.HealthCheck, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *domain.HealthCheck); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.HealthCheck) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewHealthCheckService creates a new instance of HealthCheckService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewHealthCheckService(t interface { + mock.TestingT + Cleanup(func()) +}) *HealthCheckService { + mock := &HealthCheckService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/repository/health_check.go b/repository/health_check.go new file mode 100644 index 0000000..98be76d --- /dev/null +++ b/repository/health_check.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/allisson/psqlqueue/domain" +) + +// HealthCheck is an implementation of domain.HealthCheckRepository. +type HealthCheck struct { + pool *pgxpool.Pool +} + +func (h *HealthCheck) Check(ctx context.Context) (*domain.HealthCheck, error) { + result := 0 + check := &domain.HealthCheck{} + + if err := h.pool.QueryRow(ctx, "SELECT 1+1").Scan(&result); err != nil { + return check, err + } + + check.Success = result == 2 + return check, nil +} + +// NewHealthCheck returns an implementation of domain.HealthCheckRepository. +func NewHealthCheck(pool *pgxpool.Pool) *HealthCheck { + return &HealthCheck{pool: pool} +} diff --git a/repository/health_check_test.go b/repository/health_check_test.go new file mode 100644 index 0000000..eb45317 --- /dev/null +++ b/repository/health_check_test.go @@ -0,0 +1,28 @@ +package repository + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + + "github.com/allisson/psqlqueue/domain" +) + +func TestHealthCheck(t *testing.T) { + cfg := domain.NewConfig() + ctx := context.Background() + pool, _ := pgxpool.New(ctx, cfg.TestDatabaseURL) + defer pool.Close() + + t.Run("Check", func(t *testing.T) { + defer clearDatabase(t, ctx, pool) + + healthCheckRepo := NewHealthCheck(pool) + + healthCheck, err := healthCheckRepo.Check(ctx) + assert.Nil(t, err) + assert.True(t, healthCheck.Success) + }) +} diff --git a/service/health_check.go b/service/health_check.go new file mode 100644 index 0000000..2200d1d --- /dev/null +++ b/service/health_check.go @@ -0,0 +1,21 @@ +package service + +import ( + "context" + + "github.com/allisson/psqlqueue/domain" +) + +// HealthCheck is an implementation of domain.HealthCheckService. +type HealthCheck struct { + healthCheckRepository domain.HealthCheckRepository +} + +func (h *HealthCheck) Check(ctx context.Context) (*domain.HealthCheck, error) { + return h.healthCheckRepository.Check(ctx) +} + +// NewHealthCheck returns an implementation of domain.HealthCheckService. +func NewHealthCheck(healthCheckRepository domain.HealthCheckRepository) *HealthCheck { + return &HealthCheck{healthCheckRepository: healthCheckRepository} +} diff --git a/service/health_check_test.go b/service/health_check_test.go new file mode 100644 index 0000000..a811c1f --- /dev/null +++ b/service/health_check_test.go @@ -0,0 +1,26 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/allisson/psqlqueue/domain" + "github.com/allisson/psqlqueue/mocks" +) + +func TestHealthCheck(t *testing.T) { + ctx := context.Background() + + t.Run("Check", func(t *testing.T) { + healthCheckRepository := mocks.NewHealthCheckRepository(t) + healthCheckService := NewHealthCheck(healthCheckRepository) + + healthCheckRepository.On("Check", ctx).Return(&domain.HealthCheck{Success: true}, nil) + + healthCheck, err := healthCheckService.Check(ctx) + assert.Nil(t, err) + assert.True(t, healthCheck.Success) + }) +}