From ae8f58b37d950d344b0529316655e325b30685e6 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Fri, 5 Mar 2021 10:14:29 -0300 Subject: [PATCH] feat: Add WebhookService --- cmd/postmand/main.go | 27 +++++ entity.go | 4 - entity_test.go | 9 +- error.go | 8 ++ go.mod | 2 + go.sum | 4 + http/handler/error.go | 38 +++++++ http/handler/util.go | 108 ++++++++++++++++++ http/handler/webhook.go | 180 ++++++++++++++++++++++++++++++ http/handler/webhook_test.go | 210 +++++++++++++++++++++++++++++++++++ http/middleware/logger.go | 51 +++++++++ http/server.go | 79 +++++++++++++ local.env | 1 + mocks/WebhookService.go | 105 ++++++++++++++++++ repository/webhook.go | 24 ++-- repository/webhook_test.go | 3 +- service.go | 9 ++ service/webhook.go | 55 +++++++++ service/webhook_test.go | 77 +++++++++++++ 19 files changed, 971 insertions(+), 23 deletions(-) create mode 100644 error.go create mode 100644 http/handler/error.go create mode 100644 http/handler/util.go create mode 100644 http/handler/webhook.go create mode 100644 http/handler/webhook_test.go create mode 100644 http/middleware/logger.go create mode 100644 http/server.go create mode 100644 mocks/WebhookService.go create mode 100644 service/webhook.go create mode 100644 service/webhook_test.go diff --git a/cmd/postmand/main.go b/cmd/postmand/main.go index 797b04c..f496928 100644 --- a/cmd/postmand/main.go +++ b/cmd/postmand/main.go @@ -8,8 +8,11 @@ import ( "time" "github.com/allisson/go-env" + "github.com/allisson/postmand/http" + "github.com/allisson/postmand/http/handler" "github.com/allisson/postmand/repository" "github.com/allisson/postmand/service" + "github.com/go-chi/chi/v5" "github.com/jmoiron/sqlx" _ "github.com/joho/godotenv/autoload" "github.com/urfave/cli/v2" @@ -90,6 +93,30 @@ func main() { <-idleConnsClosed + return nil + }, + }, + { + Name: "server", + Aliases: []string{"s"}, + Usage: "executes http server", + Action: func(c *cli.Context) error { + webhookRepository := repository.NewWebhook(db) + webhookService := service.NewWebhook(webhookRepository) + webhookHandler := handler.NewWebhook(webhookService, logger) + + mux := http.NewRouter(logger) + mux.Route("/v1/webhooks", func(r chi.Router) { + r.Get("/", webhookHandler.List) + r.Post("/", webhookHandler.Create) + r.Get("/{webhook_id}", webhookHandler.Get) + r.Put("/{webhook_id}", webhookHandler.Update) + r.Delete("/{webhook_id}", webhookHandler.Delete) + }) + + server := http.NewServer(mux, env.GetInt("POSTMAND_HTTP_PORT", 8000), logger) + server.Run() + return nil }, }, diff --git a/entity.go b/entity.go index ecab1d1..bd8a64e 100644 --- a/entity.go +++ b/entity.go @@ -41,7 +41,6 @@ type Webhook struct { // Validate implements ozzo validation Validatable interface func (w Webhook) Validate() error { return validation.ValidateStruct(&w, - validation.Field(&w.ID, validation.Required, is.UUIDv4), validation.Field(&w.Name, validation.Required, validation.Length(3, 255)), validation.Field(&w.URL, validation.Required, is.URL), validation.Field(&w.ContentType, validation.Required, validation.In("application/x-www-form-urlencoded", "application/json")), @@ -68,10 +67,7 @@ type Delivery struct { // Validate implements ozzo validation Validatable interface func (d Delivery) Validate() error { return validation.ValidateStruct(&d, - validation.Field(&d.ID, validation.Required, is.UUIDv4), validation.Field(&d.WebhookID, validation.Required, is.UUIDv4), - validation.Field(&d.ScheduledAt, validation.Required), - validation.Field(&d.Status, validation.Required, validation.In(DeliveryStatusPending, DeliveryStatusSucceeded, DeliveryStatusFailed)), ) } diff --git a/entity_test.go b/entity_test.go index c0ffcba..080a423 100644 --- a/entity_test.go +++ b/entity_test.go @@ -21,7 +21,7 @@ func TestWebhook(t *testing.T) { { "required fields", Webhook{}, - `{"content_type":"cannot be blank","delivery_attempt_timeout":"cannot be blank","id":"must be a valid UUID v4","max_delivery_attempts":"cannot be blank","name":"cannot be blank","retry_max_backoff":"cannot be blank","retry_min_backoff":"cannot be blank","url":"cannot be blank","valid_status_codes":"cannot be blank"}`, + `{"content_type":"cannot be blank","delivery_attempt_timeout":"cannot be blank","max_delivery_attempts":"cannot be blank","name":"cannot be blank","retry_max_backoff":"cannot be blank","retry_min_backoff":"cannot be blank","url":"cannot be blank","valid_status_codes":"cannot be blank"}`, }, { "Short name", @@ -73,12 +73,7 @@ func TestDelivery(t *testing.T) { { "required fields", Delivery{}, - `{"id":"must be a valid UUID v4","scheduled_at":"cannot be blank","status":"cannot be blank","webhook_id":"must be a valid UUID v4"}`, - }, - { - "invalid status option", - Delivery{ID: uuid.New(), WebhookID: uuid.New(), ScheduledAt: time.Now().UTC(), Status: "error"}, - `{"status":"must be a valid value"}`, + `{"webhook_id":"must be a valid UUID v4"}`, }, } for _, tt := range tests { diff --git a/error.go b/error.go new file mode 100644 index 0000000..06fc0cc --- /dev/null +++ b/error.go @@ -0,0 +1,8 @@ +package postmand + +import "errors" + +var ( + // ErrWebhookNotFound is returned by any operation that can't load a webhook. + ErrWebhookNotFound = errors.New("webhook_not_found") +) diff --git a/go.mod b/go.mod index 7d0d12a..d1d2bbb 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.16 require ( github.com/DATA-DOG/go-txdb v0.1.3 github.com/allisson/go-env v0.3.0 + github.com/go-chi/chi/v5 v5.0.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/golang-migrate/migrate/v4 v4.14.1 github.com/google/uuid v1.2.0 @@ -13,6 +14,7 @@ require ( github.com/joho/godotenv v1.3.0 github.com/jpillora/backoff v1.0.0 github.com/lib/pq v1.9.0 + github.com/steinfletcher/apitest v1.5.2 github.com/stretchr/testify v1.7.0 github.com/urfave/cli/v2 v2.3.0 go.uber.org/zap v1.16.0 diff --git a/go.sum b/go.sum index a60c522..1defe7e 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= +github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -291,6 +293,8 @@ github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/snowflakedb/glog v0.0.0-20180824191149-f5055e6f21ce/go.mod h1:EB/w24pR5VKI60ecFnKqXzxX3dOorz1rnVicQTQrGM0= github.com/snowflakedb/gosnowflake v1.3.5/go.mod h1:13Ky+lxzIm3VqNDZJdyvu9MCGy+WgRdYFdXp96UcLZU= +github.com/steinfletcher/apitest v1.5.2 h1:o5R0km8ZI6xooSDwsHdDCD9OpEXda7CJeQwyoSrJmPM= +github.com/steinfletcher/apitest v1.5.2/go.mod h1:TrZemFOZ1yNgKoAeAsth3Z3vEavTloE1hP/U2PSd3w0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= diff --git a/http/handler/error.go b/http/handler/error.go new file mode 100644 index 0000000..d99760c --- /dev/null +++ b/http/handler/error.go @@ -0,0 +1,38 @@ +package handler + +import "net/http" + +var errorResponses = map[string]errorResponse{ + "internal_server_error": { + Code: 1, + Message: "internal server error", + StatusCode: http.StatusInternalServerError, + }, + "invalid_id": { + Code: 2, + Message: "invalid id", + StatusCode: http.StatusNotFound, + }, + "malformed_request_body": { + Code: 3, + Message: "malformed request body", + StatusCode: http.StatusBadRequest, + }, + "request_validation_failed": { + Code: 4, + Message: "request validation failed", + StatusCode: http.StatusBadRequest, + }, + "webhook_not_found": { + Code: 5, + Message: "webhook not found", + StatusCode: http.StatusNotFound, + }, +} + +type errorResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + StatusCode int `json:"-"` +} diff --git a/http/handler/util.go b/http/handler/util.go new file mode 100644 index 0000000..323352d --- /dev/null +++ b/http/handler/util.go @@ -0,0 +1,108 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/allisson/postmand" + validation "github.com/go-ozzo/ozzo-validation/v4" + "go.uber.org/zap" +) + +func makeResponse(w http.ResponseWriter, body []byte, statusCode int, contentType string, logger *zap.Logger) { + w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType)) + w.WriteHeader(statusCode) + _, err := w.Write(body) + if err != nil { + logger.Error("http-failed-to-write-response-body", zap.Error(err)) + } +} + +func makeJSONResponse(w http.ResponseWriter, statusCode int, body interface{}, logger *zap.Logger) { + d, err := json.Marshal(body) + if err != nil { + logger.Error("http-failed-to-marshal-body", zap.Error(err)) + } + c := new(bytes.Buffer) + err = json.Compact(c, d) + if err != nil { + logger.Error("http-failed-to-compact-json", zap.Error(err)) + } + makeResponse(w, c.Bytes(), statusCode, "application/json", logger) +} + +func makeErrorResponse(w http.ResponseWriter, er *errorResponse, logger *zap.Logger) { + makeJSONResponse(w, er.StatusCode, er, logger) +} + +func readBodyJSON(r *http.Request, into interface{}, logger *zap.Logger) *errorResponse { + requestBody, err := io.ReadAll(r.Body) + if err != nil { + logger.Error("read-request-body-error", zap.Error(err)) + er := errorResponses["internal_server_error"] + return &er + + } + + if err := json.Unmarshal(requestBody, into); err != nil { + logger.Error("request-json-unmarshal-error", zap.Error(err)) + er := errorResponses["malformed_request_body"] + return &er + } + + if val, ok := into.(validation.Validatable); ok { + if err := val.Validate(); err != nil { + if e, ok := err.(validation.InternalError); ok { + logger.Error("read-request-validate-error", zap.Error(e)) + er := errorResponses["internal_server_error"] + return &er + } + er := errorResponses["request_validation_failed"] + er.Details = err.Error() + return &er + } + } + + return nil +} + +func makeListOptions(r *http.Request, filters []string) (postmand.RepositoryListOptions, error) { + listOptions := postmand.RepositoryListOptions{} + + if err := r.ParseForm(); err != nil { + return listOptions, err + } + + // Parse limit and offset + limit := 50 + offset := 0 + if r.Form.Get("limit") != "" { + v, err := strconv.Atoi(r.Form.Get("limit")) + if err == nil && v <= limit { + limit = v + } + } + if r.Form.Get("offset") != "" { + v, err := strconv.Atoi(r.Form.Get("offset")) + if err == nil { + offset = v + } + } + listOptions.Limit = limit + listOptions.Offset = offset + + // Parse filters + f := make(map[string]interface{}) + for _, filter := range filters { + if r.Form.Get(filter) != "" { + f[filter] = r.Form.Get(filter) + } + } + listOptions.Filters = f + + return listOptions, nil +} diff --git a/http/handler/webhook.go b/http/handler/webhook.go new file mode 100644 index 0000000..dac6531 --- /dev/null +++ b/http/handler/webhook.go @@ -0,0 +1,180 @@ +package handler + +import ( + "net/http" + + "github.com/allisson/postmand" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "go.uber.org/zap" +) + +type webhookList struct { + Webhooks []*postmand.Webhook `json:"webhooks"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// Webhook implements rest interface for webhook. +type Webhook struct { + webhookService postmand.WebhookService + logger *zap.Logger +} + +// List webhooks. +func (wh Webhook) List(w http.ResponseWriter, r *http.Request) { + listOptions, err := makeListOptions(r, []string{}) + if err != nil { + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + return + } + listOptions.OrderBy = "name" + listOptions.Order = "asc" + + // Call service + webhooks, err := wh.webhookService.List(r.Context(), listOptions) + if err != nil { + wh.logger.Error( + "service-error", + zap.String("name", "WebhookService"), + zap.String("method", "List"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + return + } + + // Return response + wl := webhookList{ + Webhooks: webhooks, + Limit: listOptions.Limit, + Offset: listOptions.Offset, + } + makeJSONResponse(w, http.StatusOK, wl, wh.logger) +} + +// Get webhook. +func (wh Webhook) Get(w http.ResponseWriter, r *http.Request) { + webhookID, err := uuid.Parse(chi.URLParam(r, "webhook_id")) + if err != nil { + er := errorResponses["invalid_id"] + makeErrorResponse(w, &er, wh.logger) + return + } + + // Call service + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": webhookID}} + webhook, err := wh.webhookService.Get(r.Context(), getOptions) + if err != nil { + if err == postmand.ErrWebhookNotFound { + er := errorResponses["webhook_not_found"] + makeErrorResponse(w, &er, wh.logger) + return + } + wh.logger.Error( + "service-error", + zap.String("name", "WebhookService"), + zap.String("method", "Get"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + return + } + + // Return response + makeJSONResponse(w, http.StatusOK, webhook, wh.logger) +} + +// Create webhook. +func (wh Webhook) Create(w http.ResponseWriter, r *http.Request) { + // Parse request + webhook := postmand.Webhook{} + if er := readBodyJSON(r, &webhook, wh.logger); er != nil { + makeErrorResponse(w, er, wh.logger) + return + } + + // Call service + if err := wh.webhookService.Create(r.Context(), &webhook); err != nil { + wh.logger.Error( + "service-error", + zap.String("name", "WebhookService"), + zap.String("method", "Create"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + } + + // Return response + makeJSONResponse(w, http.StatusCreated, webhook, wh.logger) +} + +// Update webhook. +func (wh Webhook) Update(w http.ResponseWriter, r *http.Request) { + webhookID, err := uuid.Parse(chi.URLParam(r, "webhook_id")) + if err != nil { + er := errorResponses["invalid_id"] + makeErrorResponse(w, &er, wh.logger) + return + } + + // Parse request + webhook := postmand.Webhook{} + if er := readBodyJSON(r, &webhook, wh.logger); er != nil { + makeErrorResponse(w, er, wh.logger) + return + } + webhook.ID = webhookID + + // Call service + if err := wh.webhookService.Update(r.Context(), &webhook); err != nil { + wh.logger.Error( + "service-error", + zap.String("name", "WebhookService"), + zap.String("method", "Update"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + } + + // Return response + makeJSONResponse(w, http.StatusOK, webhook, wh.logger) +} + +// Delete webhook. +func (wh Webhook) Delete(w http.ResponseWriter, r *http.Request) { + webhookID, err := uuid.Parse(chi.URLParam(r, "webhook_id")) + if err != nil { + er := errorResponses["invalid_id"] + makeErrorResponse(w, &er, wh.logger) + return + } + + // Call service + if err := wh.webhookService.Delete(r.Context(), webhookID); err != nil { + wh.logger.Error( + "service-error", + zap.String("name", "WebhookService"), + zap.String("method", "Delete"), + zap.Error(err), + ) + er := errorResponses["internal_server_error"] + makeErrorResponse(w, &er, wh.logger) + } + + // Return response + makeResponse(w, []byte(""), http.StatusNoContent, "application/json", wh.logger) +} + +// NewWebhook creates a new Webhook. +func NewWebhook(webhookService postmand.WebhookService, logger *zap.Logger) *Webhook { + return &Webhook{ + webhookService: webhookService, + logger: logger, + } +} diff --git a/http/handler/webhook_test.go b/http/handler/webhook_test.go new file mode 100644 index 0000000..b68ef3f --- /dev/null +++ b/http/handler/webhook_test.go @@ -0,0 +1,210 @@ +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/lib/pq" + "github.com/steinfletcher/apitest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" +) + +func makeWebhook() postmand.Webhook { + webhookID, _ := uuid.Parse("cd9b7318-36c6-4534-be84-fe78042aeaf2") + return postmand.Webhook{ + ID: webhookID, + Name: "Test", + URL: "https://httpbin.org/post", + ContentType: "application/json", + ValidStatusCodes: pq.Int32Array{200, 201}, + SecretToken: "", + Active: true, + MaxDeliveryAttempts: 1, + DeliveryAttemptTimeout: 1, + RetryMinBackoff: 1, + RetryMaxBackoff: 1, + } +} + +func TestWebhook(t *testing.T) { + assert.True(t, true) + logger, _ := zap.NewDevelopment() + + t.Run("List", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + listOptions := postmand.RepositoryListOptions{Filters: map[string]interface{}{}, Limit: 50, Offset: 0, OrderBy: "name", Order: "asc"} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Get("/v1/webhooks", webhookHandler.List) + + webhookService.On("List", mock.Anything, listOptions).Return([]*postmand.Webhook{{}}, nil) + apitest.New(). + Handler(router). + Get("/v1/webhooks"). + Expect(t). + Body(`{"webhooks":[{"id":"00000000-0000-0000-0000-000000000000","name":"","url":"","content_type":"","valid_status_codes":null,"secret_token":"","active":false,"max_delivery_attempts":0,"delivery_attempt_timeout":0,"retry_min_backoff":0,"retry_max_backoff":0,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"}],"limit":50,"offset":0}`). + Status(nethttp.StatusOK). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Get", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhook := makeWebhook() + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": webhook.ID}} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Get("/v1/webhooks/{webhook_id}", webhookHandler.Get) + + webhookService.On("Get", mock.Anything, getOptions).Return(&webhook, nil) + apitest.New(). + Handler(router). + Get("/v1/webhooks/cd9b7318-36c6-4534-be84-fe78042aeaf2"). + Expect(t). + Body(`{"active":true, "content_type":"application/json", "created_at":"0001-01-01T00:00:00Z", "delivery_attempt_timeout":1, "id":"cd9b7318-36c6-4534-be84-fe78042aeaf2", "max_delivery_attempts":1, "name":"Test", "retry_max_backoff":1, "retry_min_backoff":1, "secret_token":"", "updated_at":"0001-01-01T00:00:00Z", "url":"https://httpbin.org/post", "valid_status_codes":[200, 201]}`). + Status(nethttp.StatusOK). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Create with malformed request body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Post("/v1/webhooks", webhookHandler.Create) + + apitest.New(). + Handler(router). + Post("/v1/webhooks"). + JSON(`{`). + Expect(t). + Body(`{"code":3, "message":"malformed request body"}`). + Status(nethttp.StatusBadRequest). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Create with invalid body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Post("/v1/webhooks", webhookHandler.Create) + + apitest.New(). + Handler(router). + Post("/v1/webhooks"). + JSON(`{}`). + Expect(t). + Body(`{"code":4, "details":"content_type: cannot be blank; delivery_attempt_timeout: cannot be blank; max_delivery_attempts: cannot be blank; name: cannot be blank; retry_max_backoff: cannot be blank; retry_min_backoff: cannot be blank; url: cannot be blank; valid_status_codes: cannot be blank.", "message":"request validation failed"}`). + Status(nethttp.StatusBadRequest). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Create with valid body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + webhook := makeWebhook() + jsonWebhook, _ := json.Marshal(&webhook) + router := http.NewRouter(logger) + router.Post("/v1/webhooks", webhookHandler.Create) + + webhookService.On("Create", mock.Anything, &webhook).Return(nil) + apitest.New(). + Handler(router). + Post("/v1/webhooks"). + JSON(jsonWebhook). + Expect(t). + Body(`{"active":true, "content_type":"application/json", "created_at":"0001-01-01T00:00:00Z", "delivery_attempt_timeout":1, "id":"cd9b7318-36c6-4534-be84-fe78042aeaf2", "max_delivery_attempts":1, "name":"Test", "retry_max_backoff":1, "retry_min_backoff":1, "secret_token":"", "updated_at":"0001-01-01T00:00:00Z", "url":"https://httpbin.org/post", "valid_status_codes": [200, 201]}`). + Status(nethttp.StatusCreated). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Update with malformed request body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Put("/v1/webhooks/{webhook_id}", webhookHandler.Update) + + apitest.New(). + Handler(router). + Put("/v1/webhooks/cd9b7318-36c6-4534-be84-fe78042aeaf2"). + JSON(`{`). + Expect(t). + Body(`{"code":3, "message":"malformed request body"}`). + Status(nethttp.StatusBadRequest). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Update with invalid body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + router := http.NewRouter(logger) + router.Put("/v1/webhooks/{webhook_id}", webhookHandler.Update) + + apitest.New(). + Handler(router). + Put("/v1/webhooks/cd9b7318-36c6-4534-be84-fe78042aeaf2"). + JSON(`{}`). + Expect(t). + Body(`{"code":4, "details":"content_type: cannot be blank; delivery_attempt_timeout: cannot be blank; max_delivery_attempts: cannot be blank; name: cannot be blank; retry_max_backoff: cannot be blank; retry_min_backoff: cannot be blank; url: cannot be blank; valid_status_codes: cannot be blank.", "message":"request validation failed"}`). + Status(nethttp.StatusBadRequest). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Update with valid body", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + webhook := makeWebhook() + jsonWebhook, _ := json.Marshal(&webhook) + router := http.NewRouter(logger) + router.Put("/v1/webhooks/{webhook_id}", webhookHandler.Update) + + webhookService.On("Update", mock.Anything, &webhook).Return(nil) + apitest.New(). + Handler(router). + Put("/v1/webhooks/cd9b7318-36c6-4534-be84-fe78042aeaf2"). + JSON(jsonWebhook). + Expect(t). + Body(`{"active":true, "content_type":"application/json", "created_at":"0001-01-01T00:00:00Z", "delivery_attempt_timeout":1, "id":"cd9b7318-36c6-4534-be84-fe78042aeaf2", "max_delivery_attempts":1, "name":"Test", "retry_max_backoff":1, "retry_min_backoff":1, "secret_token":"", "updated_at":"0001-01-01T00:00:00Z", "url":"https://httpbin.org/post", "valid_status_codes":[200, 201]}`). + Status(nethttp.StatusOK). + End() + + webhookService.AssertExpectations(t) + }) + + t.Run("Delete", func(t *testing.T) { + webhookService := &mocks.WebhookService{} + webhookHandler := NewWebhook(webhookService, logger) + webhook := makeWebhook() + router := http.NewRouter(logger) + router.Delete("/v1/webhooks/{webhook_id}", webhookHandler.Delete) + + webhookService.On("Delete", mock.Anything, webhook.ID).Return(nil) + apitest.New(). + Handler(router). + Delete("/v1/webhooks/cd9b7318-36c6-4534-be84-fe78042aeaf2"). + Expect(t). + Status(nethttp.StatusNoContent). + End() + + webhookService.AssertExpectations(t) + }) +} diff --git a/http/middleware/logger.go b/http/middleware/logger.go new file mode 100644 index 0000000..6a40eee --- /dev/null +++ b/http/middleware/logger.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Adapted from https://github.com/766b/chi-logger/blob/master/middleware.go +type chilogger struct { + logger *zap.Logger + name string +} + +func (c chilogger) middleware(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + var requestID string + if reqID := r.Context().Value(middleware.RequestIDKey); reqID != nil { + requestID = reqID.(string) + } + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + latency := time.Since(start) + fields := []zapcore.Field{ + zap.Int("status", ww.Status()), + zap.Duration("took", latency), + zap.Int64(fmt.Sprintf("measure#%s.latency", c.name), latency.Nanoseconds()), + zap.String("remote", r.RemoteAddr), + zap.String("request", r.RequestURI), + zap.String("method", r.Method), + } + if requestID != "" { + fields = append(fields, zap.String("request-id", requestID)) + } + c.logger.Info("request-completed", fields...) + } + return http.HandlerFunc(fn) +} + +// NewZapMiddleware returns a new Zap Middleware handler. +func NewZapMiddleware(name string, logger *zap.Logger) func(next http.Handler) http.Handler { + return chilogger{ + logger: logger, + name: name, + }.middleware +} diff --git a/http/server.go b/http/server.go new file mode 100644 index 0000000..3304bfc --- /dev/null +++ b/http/server.go @@ -0,0 +1,79 @@ +package http + +import ( + "context" + "fmt" + "net/http" + nethttp "net/http" + "os" + "os/signal" + "syscall" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "go.uber.org/zap" + + mw "github.com/allisson/postmand/http/middleware" +) + +// NewRouter returns *chi.Mux with base middlewares. +func NewRouter(logger *zap.Logger) *chi.Mux { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(mw.NewZapMiddleware("router", logger)) + r.Use(middleware.AllowContentType("application/json")) + r.Use(middleware.Recoverer) + return r +} + +// Server ... +type Server struct { + mux *chi.Mux + httpPort int + logger *zap.Logger +} + +// Run ... +func (s Server) Run() { + httpServer := &nethttp.Server{Addr: fmt.Sprintf(":%d", s.httpPort), Handler: s.mux} + idleConnsClosed := make(chan struct{}) + go func() { + sigint := make(chan os.Signal, 1) + + // interrupt signal sent from terminal + signal.Notify(sigint, os.Interrupt) + // sigterm signal sent from kubernetes + signal.Notify(sigint, syscall.SIGTERM) + + <-sigint + + // We received an interrupt signal, shut down. + s.logger.Info("http-server-shutdown-started") + if err := httpServer.Shutdown(context.Background()); err != nil { + // Error from closing listeners, or context timeout: + s.logger.Error("http-server-shutdown", zap.Error(err)) + } + close(idleConnsClosed) + s.logger.Info("http-server-shutdown-finished") + }() + + s.logger.Info("http-server-listen-and-server") + if err := httpServer.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + s.logger.Error("http-server-listen-and-server-error", zap.Error(err)) + return + } + } + + <-idleConnsClosed +} + +// NewServer ... +func NewServer(mux *chi.Mux, httpPort int, logger *zap.Logger) *Server { + return &Server{ + mux: mux, + httpPort: httpPort, + logger: logger, + } +} diff --git a/local.env b/local.env index 0bfcfd8..9e3f0dd 100644 --- a/local.env +++ b/local.env @@ -3,3 +3,4 @@ POSTMAND_DATABASE_URL='postgres://user:pass@localhost:5432/postmand?sslmode=disa POSTMAND_DATABASE_MIGRATION_DIR='file://db/migrations' POSTMAND_DATABASE_MAX_OPEN_CONNS='2' POSTMAND_POLLING_INTERVAL='1000' +POSTMAND_HTTP_PORT='8000' diff --git a/mocks/WebhookService.go b/mocks/WebhookService.go new file mode 100644 index 0000000..fec381a --- /dev/null +++ b/mocks/WebhookService.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" +) + +// WebhookService is an autogenerated mock type for the WebhookService type +type WebhookService struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, webhook +func (_m *WebhookService) Create(ctx context.Context, webhook *postmand.Webhook) error { + ret := _m.Called(ctx, webhook) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *postmand.Webhook) error); ok { + r0 = rf(ctx, webhook) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Delete provides a mock function with given fields: ctx, id +func (_m *WebhookService) 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 *WebhookService) Get(ctx context.Context, getOptions postmand.RepositoryGetOptions) (*postmand.Webhook, error) { + ret := _m.Called(ctx, getOptions) + + var r0 *postmand.Webhook + if rf, ok := ret.Get(0).(func(context.Context, postmand.RepositoryGetOptions) *postmand.Webhook); ok { + r0 = rf(ctx, getOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*postmand.Webhook) + } + } + + 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 *WebhookService) List(ctx context.Context, listOptions postmand.RepositoryListOptions) ([]*postmand.Webhook, error) { + ret := _m.Called(ctx, listOptions) + + var r0 []*postmand.Webhook + if rf, ok := ret.Get(0).(func(context.Context, postmand.RepositoryListOptions) []*postmand.Webhook); ok { + r0 = rf(ctx, listOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*postmand.Webhook) + } + } + + 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, webhook +func (_m *WebhookService) Update(ctx context.Context, webhook *postmand.Webhook) error { + ret := _m.Called(ctx, webhook) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *postmand.Webhook) error); ok { + r0 = rf(ctx, webhook) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/repository/webhook.go b/repository/webhook.go index 5f309fd..c838f0f 100644 --- a/repository/webhook.go +++ b/repository/webhook.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "github.com/allisson/postmand" "github.com/jmoiron/sqlx" @@ -15,39 +16,42 @@ type Webhook struct { // Get returns postmand.Webhook by options filter. func (w Webhook) Get(ctx context.Context, getOptions postmand.RepositoryGetOptions) (*postmand.Webhook, error) { webhook := postmand.Webhook{} - sql, args := getQuery("webhooks", getOptions) - err := w.db.GetContext(ctx, &webhook, sql, args...) + query, args := getQuery("webhooks", getOptions) + err := w.db.GetContext(ctx, &webhook, query, args...) + if err == sql.ErrNoRows { + return &webhook, postmand.ErrWebhookNotFound + } return &webhook, err } // List returns a slice of postmand.Webhook by options filter. func (w Webhook) List(ctx context.Context, listOptions postmand.RepositoryListOptions) ([]*postmand.Webhook, error) { webhooks := []*postmand.Webhook{} - sql, args := listQuery("webhooks", listOptions) - err := w.db.SelectContext(ctx, &webhooks, sql, args...) + query, args := listQuery("webhooks", listOptions) + err := w.db.SelectContext(ctx, &webhooks, query, args...) return webhooks, err } // Create postmand.Webhook on database. func (w Webhook) Create(ctx context.Context, webhook *postmand.Webhook) error { - sql, args := insertQuery("webhooks", webhook) - _, err := w.db.ExecContext(ctx, sql, args...) + query, args := insertQuery("webhooks", webhook) + _, err := w.db.ExecContext(ctx, query, args...) return err } // Update postmand.Webhook on database. func (w Webhook) Update(ctx context.Context, webhook *postmand.Webhook) error { - sql, args := updateQuery("webhooks", webhook.ID, webhook) - _, err := w.db.ExecContext(ctx, sql, args...) + query, args := updateQuery("webhooks", webhook.ID, webhook) + _, err := w.db.ExecContext(ctx, query, args...) return err } // Delete postmand.Webhook on database. func (w Webhook) Delete(ctx context.Context, id postmand.ID) error { - sqlStatement := ` + query := ` DELETE FROM webhooks WHERE id = $1 ` - _, err := w.db.ExecContext(ctx, sqlStatement, id) + _, err := w.db.ExecContext(ctx, query, id) return err } diff --git a/repository/webhook_test.go b/repository/webhook_test.go index 908d608..b4fc5d3 100644 --- a/repository/webhook_test.go +++ b/repository/webhook_test.go @@ -2,7 +2,6 @@ package repository import ( "context" - "database/sql" "testing" "time" @@ -72,7 +71,7 @@ func TestTransaction(t *testing.T) { options := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": webhook.ID}} _, err = th.webhookRepository.Get(ctx, options) - assert.Equal(t, sql.ErrNoRows, err) + assert.Equal(t, postmand.ErrWebhookNotFound, err) }) t.Run("Get webhook", func(t *testing.T) { diff --git a/service.go b/service.go index efbfe23..2ea6031 100644 --- a/service.go +++ b/service.go @@ -12,3 +12,12 @@ type WorkerService interface { type MigrationService interface { Run(ctx context.Context) error } + +// WebhookService is the interface that will be used to perform operations with webhooks. +type WebhookService interface { + Get(ctx context.Context, getOptions RepositoryGetOptions) (*Webhook, error) + List(ctx context.Context, listOptions RepositoryListOptions) ([]*Webhook, error) + Create(ctx context.Context, webhook *Webhook) error + Update(ctx context.Context, webhook *Webhook) error + Delete(ctx context.Context, id ID) error +} diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 0000000..b228d6f --- /dev/null +++ b/service/webhook.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "time" + + "github.com/allisson/postmand" + "github.com/google/uuid" +) + +// Webhook implements postmand.WebhookService interface. +type Webhook struct { + webhookRepository postmand.WebhookRepository +} + +// Get returns postmand.Webhook by options filter. +func (w Webhook) Get(ctx context.Context, getOptions postmand.RepositoryGetOptions) (*postmand.Webhook, error) { + return w.webhookRepository.Get(ctx, getOptions) +} + +// List returns a slice of postmand.Webhook by options filter. +func (w Webhook) List(ctx context.Context, listOptions postmand.RepositoryListOptions) ([]*postmand.Webhook, error) { + return w.webhookRepository.List(ctx, listOptions) +} + +// Create postmand.Webhook on database. +func (w Webhook) Create(ctx context.Context, webhook *postmand.Webhook) error { + now := time.Now().UTC() + webhook.ID = uuid.New() + webhook.CreatedAt = now + webhook.UpdatedAt = now + return w.webhookRepository.Create(ctx, webhook) +} + +// Update postmand.Webhook on database. +func (w Webhook) Update(ctx context.Context, webhook *postmand.Webhook) error { + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": webhook.ID}} + storedWebhook, err := w.webhookRepository.Get(ctx, getOptions) + if err != nil { + return err + } + webhook.CreatedAt = storedWebhook.CreatedAt + webhook.UpdatedAt = time.Now().UTC() + return w.webhookRepository.Update(ctx, webhook) +} + +// Delete postmand.Webhook on database. +func (w Webhook) Delete(ctx context.Context, id postmand.ID) error { + return w.webhookRepository.Delete(ctx, id) +} + +// NewWebhook will create an implementation of postmand.WebhookService. +func NewWebhook(webhookRepository postmand.WebhookRepository) *Webhook { + return &Webhook{webhookRepository: webhookRepository} +} diff --git a/service/webhook_test.go b/service/webhook_test.go new file mode 100644 index 0000000..e74694f --- /dev/null +++ b/service/webhook_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "testing" + + "github.com/allisson/postmand" + "github.com/allisson/postmand/mocks" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestWebhook(t *testing.T) { + ctx := context.Background() + + t.Run("Get", func(t *testing.T) { + webhookRepository := &mocks.WebhookRepository{} + webhookService := NewWebhook(webhookRepository) + expectedWebhook := &postmand.Webhook{ID: uuid.New()} + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": expectedWebhook.ID}} + + webhookRepository.On("Get", mock.Anything, getOptions).Return(expectedWebhook, nil) + webhook, err := webhookService.Get(ctx, getOptions) + assert.Nil(t, err) + assert.Equal(t, expectedWebhook, webhook) + webhookRepository.AssertExpectations(t) + }) + + t.Run("List", func(t *testing.T) { + webhookRepository := &mocks.WebhookRepository{} + webhookService := NewWebhook(webhookRepository) + expectedWebhook := &postmand.Webhook{ID: uuid.New()} + listOptions := postmand.RepositoryListOptions{Filters: map[string]interface{}{"id": expectedWebhook.ID}, Limit: 1, Offset: 0} + + webhookRepository.On("List", mock.Anything, listOptions).Return([]*postmand.Webhook{expectedWebhook}, nil) + webhooks, err := webhookService.List(ctx, listOptions) + assert.Nil(t, err) + assert.Equal(t, expectedWebhook, webhooks[0]) + webhookRepository.AssertExpectations(t) + }) + + t.Run("Create", func(t *testing.T) { + webhookRepository := &mocks.WebhookRepository{} + webhookService := NewWebhook(webhookRepository) + webhook := &postmand.Webhook{ID: uuid.New()} + + webhookRepository.On("Create", mock.Anything, webhook).Return(nil) + err := webhookService.Create(ctx, webhook) + assert.Nil(t, err) + webhookRepository.AssertExpectations(t) + }) + + t.Run("Update", func(t *testing.T) { + webhookRepository := &mocks.WebhookRepository{} + webhookService := NewWebhook(webhookRepository) + webhook := &postmand.Webhook{ID: uuid.New()} + + getOptions := postmand.RepositoryGetOptions{Filters: map[string]interface{}{"id": webhook.ID}} + webhookRepository.On("Get", mock.Anything, getOptions).Return(webhook, nil) + webhookRepository.On("Update", mock.Anything, webhook).Return(nil) + err := webhookService.Update(ctx, webhook) + assert.Nil(t, err) + webhookRepository.AssertExpectations(t) + }) + + t.Run("Delete", func(t *testing.T) { + webhookRepository := &mocks.WebhookRepository{} + webhookService := NewWebhook(webhookRepository) + webhook := &postmand.Webhook{ID: uuid.New()} + + webhookRepository.On("Delete", mock.Anything, webhook.ID).Return(nil) + err := webhookService.Delete(ctx, webhook.ID) + assert.Nil(t, err) + webhookRepository.AssertExpectations(t) + }) +}