diff --git a/cmd/postmand/main.go b/cmd/postmand/main.go index 6e6211b..79d8c46 100644 --- a/cmd/postmand/main.go +++ b/cmd/postmand/main.go @@ -78,8 +78,11 @@ func main() { Usage: "executes http server", Action: func(c *cli.Context) error { webhookRepository := repository.NewWebhook(db) + deliveryRepository := repository.NewDelivery(db) webhookService := service.NewWebhook(webhookRepository) + deliveryService := service.NewDelivery(deliveryRepository) webhookHandler := handler.NewWebhook(webhookService, logger) + deliveryHandler := handler.NewDelivery(deliveryService, logger) mux := http.NewRouter(logger) mux.Route("/v1/webhooks", func(r chi.Router) { @@ -89,6 +92,12 @@ func main() { r.Put("/{webhook_id}", webhookHandler.Update) r.Delete("/{webhook_id}", webhookHandler.Delete) }) + mux.Route("/v1/deliveries", func(r chi.Router) { + r.Get("/", deliveryHandler.List) + r.Post("/", deliveryHandler.Create) + r.Get("/{delivery_id}", deliveryHandler.Get) + r.Delete("/{delivery_id}", deliveryHandler.Delete) + }) server := http.NewServer(mux, env.GetInt("POSTMAND_HTTP_PORT", 8000), logger) server.Run() diff --git a/error.go b/error.go index 06fc0cc..9c238b2 100644 --- a/error.go +++ b/error.go @@ -5,4 +5,6 @@ import "errors" var ( // ErrWebhookNotFound is returned by any operation that can't load a webhook. ErrWebhookNotFound = errors.New("webhook_not_found") + // ErrDeliveryNotFound is returned by any operation that can't load a delivery. + ErrDeliveryNotFound = errors.New("delivery_not_found") ) diff --git a/http/handler/delivery.go b/http/handler/delivery.go new file mode 100644 index 0000000..ac61f55 --- /dev/null +++ b/http/handler/delivery.go @@ -0,0 +1,147 @@ +package handler + +import ( + "net/http" + + "github.com/allisson/postmand" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "go.uber.org/zap" +) + +type deliveryList struct { + Deliveries []*postmand.Delivery `json:"deliveries"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// Delivery implements rest interface for delivery. +type Delivery struct { + deliveryService postmand.DeliveryService + logger *zap.Logger +} + +// List deliveries. +func (d Delivery) List(w http.ResponseWriter, r *http.Request) { + listOptions, err := makeListOptions(r, []string{}) + if err != nil { + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, d.logger) + return + } + listOptions.OrderBy = "created_at" + listOptions.Order = "desc" + + // Call service + deliveries, err := d.deliveryService.List(r.Context(), listOptions) + if err != nil { + d.logger.Error( + "service-error", + zap.String("name", "DeliveryService"), + zap.String("method", "List"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, d.logger) + return + } + + // Return response + dl := deliveryList{ + Deliveries: deliveries, + Limit: listOptions.Limit, + Offset: listOptions.Offset, + } + makeJSONResponse(w, http.StatusOK, dl, d.logger) +} + +// Get delivery. +func (d Delivery) Get(w http.ResponseWriter, r *http.Request) { + deliveryID, err := uuid.Parse(chi.URLParam(r, "delivery_id")) + if err != nil { + er := errorResponses["invalid_id"] + makeErrorResponse(w, &er, d.logger) + return + } + + // Call service + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": deliveryID}} + delivery, err := d.deliveryService.Get(r.Context(), getOptions) + if err != nil { + if err == postmand.ErrDeliveryNotFound { + er := errorResponses["delivery_not_found"] + makeErrorResponse(w, &er, d.logger) + return + } + d.logger.Error( + "service-error", + zap.String("name", "DeliveryService"), + zap.String("method", "Get"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, d.logger) + return + } + + // Return response + makeJSONResponse(w, http.StatusOK, delivery, d.logger) +} + +// Create delivery. +func (d Delivery) Create(w http.ResponseWriter, r *http.Request) { + // Parse request + delivery := postmand.Delivery{} + if er := readBodyJSON(r, &delivery, d.logger); er != nil { + makeErrorResponse(w, er, d.logger) + return + } + + // Call service + if err := d.deliveryService.Create(r.Context(), &delivery); err != nil { + d.logger.Error( + "service-error", + zap.String("name", "DeliveryService"), + zap.String("method", "Create"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, d.logger) + } + + // Return response + makeJSONResponse(w, http.StatusCreated, delivery, d.logger) +} + +// Delete delivery. +func (d Delivery) Delete(w http.ResponseWriter, r *http.Request) { + deliveryID, err := uuid.Parse(chi.URLParam(r, "delivery_id")) + if err != nil { + er := errorResponses["invalid_id"] + makeErrorResponse(w, &er, d.logger) + return + } + + // Call service + if err := d.deliveryService.Delete(r.Context(), deliveryID); err != nil { + d.logger.Error( + "service-error", + zap.String("name", "DeliveryService"), + zap.String("method", "Delete"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, d.logger) + } + + // Return response + makeResponse(w, []byte(""), http.StatusNoContent, "application/json", d.logger) +} + +// NewDelivery creates a new Delivery. +func NewDelivery(deliveryService postmand.DeliveryService, logger *zap.Logger) *Delivery { + return &Delivery{ + deliveryService: deliveryService, + logger: logger, + } +} diff --git a/http/handler/delivery_test.go b/http/handler/delivery_test.go new file mode 100644 index 0000000..2fc1b2a --- /dev/null +++ b/http/handler/delivery_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "encoding/json" + nethttp "net/http" + "testing" + + "github.com/allisson/postmand" + "github.com/allisson/postmand/http" + "github.com/allisson/postmand/mocks" + "github.com/google/uuid" + "github.com/steinfletcher/apitest" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" +) + +func makeDelivery() postmand.Delivery { + deliveryID, _ := uuid.Parse("b919ca2c-6b0f-4a22-a61f-8c882ee69323") + webhookID, _ := uuid.Parse("cd9b7318-36c6-4534-be84-fe78042aeaf2") + + return postmand.Delivery{ + ID: deliveryID, + WebhookID: webhookID, + Payload: `{}`, + } +} + +func TestDelivery(t *testing.T) { + logger, _ := zap.NewDevelopment() + + t.Run("List", func(t *testing.T) { + deliveryService := &mocks.DeliveryService{} + listOptions := postmand.RepositoryListOptions{Filters: map[string]interface{}{}, Limit: 50, Offset: 0, OrderBy: "created_at", Order: "desc"} + deliveryHandler := NewDelivery(deliveryService, logger) + router := http.NewRouter(logger) + router.Get("/v1/deliveries", deliveryHandler.List) + + deliveryService.On("List", mock.Anything, listOptions).Return([]*postmand.Delivery{{}}, nil) + apitest.New(). + Handler(router). + Get("/v1/deliveries"). + Expect(t). + Body(`{"deliveries":[{"id":"00000000-0000-0000-0000-000000000000","webhook_id":"00000000-0000-0000-0000-000000000000","payload":"","scheduled_at":"0001-01-01T00:00:00Z","delivery_attempts":0,"status":"","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"limit":50,"offset":0}`). + Status(nethttp.StatusOK). + End() + + deliveryService.AssertExpectations(t) + }) + + t.Run("Get", func(t *testing.T) { + deliveryService := &mocks.DeliveryService{} + delivery := makeDelivery() + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": delivery.ID}} + deliveryHandler := NewDelivery(deliveryService, logger) + router := http.NewRouter(logger) + router.Get("/v1/deliveries/{delivery_id}", deliveryHandler.Get) + + deliveryService.On("Get", mock.Anything, getOptions).Return(&delivery, nil) + apitest.New(). + Handler(router). + Get("/v1/deliveries/b919ca2c-6b0f-4a22-a61f-8c882ee69323"). + Expect(t). + Body(`{"created_at":"0001-01-01T00:00:00Z", "delivery_attempts":0, "id":"b919ca2c-6b0f-4a22-a61f-8c882ee69323", "payload":"{}", "scheduled_at":"0001-01-01T00:00:00Z", "status":"", "updated_at":"0001-01-01T00:00:00Z", "webhook_id":"cd9b7318-36c6-4534-be84-fe78042aeaf2"}`). + Status(nethttp.StatusOK). + End() + + deliveryService.AssertExpectations(t) + }) + + t.Run("Create with malformed request body", func(t *testing.T) { + deliveryService := &mocks.DeliveryService{} + deliveryHandler := NewDelivery(deliveryService, logger) + router := http.NewRouter(logger) + router.Post("/v1/deliveries", deliveryHandler.Create) + + apitest.New(). + Handler(router). + Post("/v1/deliveries"). + JSON(`{`). + Expect(t). + Body(`{"code":3, "message":"malformed request body"}`). + Status(nethttp.StatusBadRequest). + End() + + deliveryService.AssertExpectations(t) + }) + + t.Run("Create with valid body", func(t *testing.T) { + deliveryService := &mocks.DeliveryService{} + deliveryHandler := NewDelivery(deliveryService, logger) + delivery := makeDelivery() + jsonDelivery, _ := json.Marshal(&delivery) + router := http.NewRouter(logger) + router.Post("/v1/deliveries", deliveryHandler.Create) + + deliveryService.On("Create", mock.Anything, &delivery).Return(nil) + apitest.New(). + Handler(router). + Post("/v1/deliveries"). + JSON(jsonDelivery). + Expect(t). + Body(`{"created_at":"0001-01-01T00:00:00Z", "delivery_attempts":0, "id":"b919ca2c-6b0f-4a22-a61f-8c882ee69323", "payload":"{}", "scheduled_at":"0001-01-01T00:00:00Z", "status":"", "updated_at":"0001-01-01T00:00:00Z", "webhook_id":"cd9b7318-36c6-4534-be84-fe78042aeaf2"}`). + Status(nethttp.StatusCreated). + End() + + deliveryService.AssertExpectations(t) + }) + + t.Run("Delete", func(t *testing.T) { + deliveryService := &mocks.DeliveryService{} + deliveryHandler := NewDelivery(deliveryService, logger) + delivery := makeDelivery() + router := http.NewRouter(logger) + router.Delete("/v1/deliveries/{delivery_id}", deliveryHandler.Delete) + + deliveryService.On("Delete", mock.Anything, delivery.ID).Return(nil) + apitest.New(). + Handler(router). + Delete("/v1/deliveries/b919ca2c-6b0f-4a22-a61f-8c882ee69323"). + Expect(t). + Status(nethttp.StatusNoContent). + End() + + deliveryService.AssertExpectations(t) + }) +} diff --git a/http/handler/error.go b/http/handler/error.go index d99760c..8b84465 100644 --- a/http/handler/error.go +++ b/http/handler/error.go @@ -28,6 +28,11 @@ var errorResponses = map[string]errorResponse{ Message: "webhook not found", StatusCode: http.StatusNotFound, }, + "delivery_not_found": { + Code: 6, + Message: "delivery not found", + StatusCode: http.StatusNotFound, + }, } type errorResponse struct { diff --git a/http/handler/webhook_test.go b/http/handler/webhook_test.go index b68ef3f..b3771d1 100644 --- a/http/handler/webhook_test.go +++ b/http/handler/webhook_test.go @@ -11,7 +11,6 @@ import ( "github.com/google/uuid" "github.com/lib/pq" "github.com/steinfletcher/apitest" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.uber.org/zap" ) @@ -34,7 +33,6 @@ func makeWebhook() postmand.Webhook { } func TestWebhook(t *testing.T) { - assert.True(t, true) logger, _ := zap.NewDevelopment() t.Run("List", func(t *testing.T) { diff --git a/mocks/DeliveryService.go b/mocks/DeliveryService.go new file mode 100644 index 0000000..373fff2 --- /dev/null +++ b/mocks/DeliveryService.go @@ -0,0 +1,105 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + context "context" + + postmand "github.com/allisson/postmand" + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// DeliveryService is an autogenerated mock type for the DeliveryService type +type DeliveryService struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, delivery +func (_m *DeliveryService) Create(ctx context.Context, delivery *postmand.Delivery) error { + ret := _m.Called(ctx, delivery) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *postmand.Delivery) error); ok { + r0 = rf(ctx, delivery) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *DeliveryService) Delete(ctx context.Context, id uuid.UUID) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: ctx, getOptions +func (_m *DeliveryService) Get(ctx context.Context, getOptions postmand.RepositoryGetOptions) (*postmand.Delivery, error) { + ret := _m.Called(ctx, getOptions) + + var r0 *postmand.Delivery + if rf, ok := ret.Get(0).(func(context.Context, postmand.RepositoryGetOptions) *postmand.Delivery); ok { + r0 = rf(ctx, getOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*postmand.Delivery) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, postmand.RepositoryGetOptions) error); ok { + r1 = rf(ctx, getOptions) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: ctx, listOptions +func (_m *DeliveryService) List(ctx context.Context, listOptions postmand.RepositoryListOptions) ([]*postmand.Delivery, error) { + ret := _m.Called(ctx, listOptions) + + var r0 []*postmand.Delivery + if rf, ok := ret.Get(0).(func(context.Context, postmand.RepositoryListOptions) []*postmand.Delivery); ok { + r0 = rf(ctx, listOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*postmand.Delivery) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, postmand.RepositoryListOptions) error); ok { + r1 = rf(ctx, listOptions) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: ctx, delivery +func (_m *DeliveryService) Update(ctx context.Context, delivery *postmand.Delivery) error { + ret := _m.Called(ctx, delivery) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *postmand.Delivery) error); ok { + r0 = rf(ctx, delivery) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/repository/delivery.go b/repository/delivery.go index 8e5c699..b284d73 100644 --- a/repository/delivery.go +++ b/repository/delivery.go @@ -78,45 +78,48 @@ type Delivery struct { // Get returns postmand.Delivery by options filter. func (d Delivery) Get(ctx context.Context, getOptions postmand.RepositoryGetOptions) (*postmand.Delivery, error) { delivery := postmand.Delivery{} - sql, args := getQuery("deliveries", getOptions) - err := d.db.GetContext(ctx, &delivery, sql, args...) + query, args := getQuery("deliveries", getOptions) + err := d.db.GetContext(ctx, &delivery, query, args...) + if err == sql.ErrNoRows { + return &delivery, postmand.ErrDeliveryNotFound + } return &delivery, err } // List returns a slice of postmand.Delivery by options filter. func (d Delivery) List(ctx context.Context, listOptions postmand.RepositoryListOptions) ([]*postmand.Delivery, error) { deliveries := []*postmand.Delivery{} - sql, args := listQuery("deliveries", listOptions) - err := d.db.SelectContext(ctx, &deliveries, sql, args...) + query, args := listQuery("deliveries", listOptions) + err := d.db.SelectContext(ctx, &deliveries, query, args...) return deliveries, err } // Create postmand.Delivery on database. func (d Delivery) Create(ctx context.Context, delivery *postmand.Delivery) error { - sql, args := insertQuery("deliveries", delivery) - _, err := d.db.ExecContext(ctx, sql, args...) + query, args := insertQuery("deliveries", delivery) + _, err := d.db.ExecContext(ctx, query, args...) return err } // Update postmand.Delivery on database. func (d Delivery) Update(ctx context.Context, delivery *postmand.Delivery) error { - sql, args := updateQuery("deliveries", delivery.ID, delivery) - _, err := d.db.ExecContext(ctx, sql, args...) + query, args := updateQuery("deliveries", delivery.ID, delivery) + _, err := d.db.ExecContext(ctx, query, args...) return err } // Delete postmand.Delivery on database. func (d Delivery) Delete(ctx context.Context, id postmand.ID) error { - sqlStatement := ` + query := ` DELETE FROM deliveries WHERE id = $1 ` - _, err := d.db.ExecContext(ctx, sqlStatement, id) + _, err := d.db.ExecContext(ctx, query, id) return err } // Dispatch fetchs a delivery and send to url destination. func (d Delivery) Dispatch(ctx context.Context) (*postmand.DeliveryAttempt, error) { - sqlStatement := ` + query := ` SELECT deliveries.* FROM @@ -140,7 +143,7 @@ func (d Delivery) Dispatch(ctx context.Context) (*postmand.DeliveryAttempt, erro // Get delivery delivery := postmand.Delivery{} - err = tx.GetContext(ctx, &delivery, sqlStatement, postmand.DeliveryStatusPending, time.Now().UTC()) + err = tx.GetContext(ctx, &delivery, query, postmand.DeliveryStatusPending, time.Now().UTC()) if err != nil { // Skip if no result if err == sql.ErrNoRows { @@ -187,8 +190,8 @@ func (d Delivery) Dispatch(ctx context.Context) (*postmand.DeliveryAttempt, erro delivery.Status = newStatus delivery.ScheduledAt = newScheduledAt delivery.UpdatedAt = time.Now().UTC() - sql, args = updateQuery("deliveries", delivery.ID, delivery) - _, err = tx.ExecContext(ctx, sql, args...) + query, args = updateQuery("deliveries", delivery.ID, delivery) + _, err = tx.ExecContext(ctx, query, args...) if err != nil { rollback("update delivery", tx) return nil, err @@ -206,8 +209,8 @@ func (d Delivery) Dispatch(ctx context.Context) (*postmand.DeliveryAttempt, erro Error: dr.Error, CreatedAt: time.Now().UTC(), } - sql, args = insertQuery("delivery_attempts", deliveryAttempt) - _, err = tx.ExecContext(ctx, sql, args...) + query, args = insertQuery("delivery_attempts", deliveryAttempt) + _, err = tx.ExecContext(ctx, query, args...) if err != nil { rollback("create delivery attempt", tx) return nil, err diff --git a/repository/delivery_test.go b/repository/delivery_test.go index cfee0ec..5d8abae 100644 --- a/repository/delivery_test.go +++ b/repository/delivery_test.go @@ -2,7 +2,6 @@ package repository import ( "context" - "database/sql" "net/http" "net/http/httptest" "testing" @@ -135,7 +134,7 @@ func TestDelivery(t *testing.T) { options := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": delivery.ID}} _, err = th.deliveryRepository.Get(ctx, options) - assert.Equal(t, sql.ErrNoRows, err) + assert.Equal(t, postmand.ErrDeliveryNotFound, err) }) t.Run("Get delivery", func(t *testing.T) { diff --git a/service/delivery.go b/service/delivery.go index 07abae4..a4328b2 100644 --- a/service/delivery.go +++ b/service/delivery.go @@ -27,6 +27,8 @@ func (d Delivery) List(ctx context.Context, listOptions postmand.RepositoryListO func (d Delivery) Create(ctx context.Context, delivery *postmand.Delivery) error { now := time.Now().UTC() delivery.ID = uuid.New() + delivery.ScheduledAt = now + delivery.Status = postmand.DeliveryStatusPending delivery.CreatedAt = now delivery.UpdatedAt = now return d.deliveryRepository.Create(ctx, delivery)