diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a32ccda --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,41 @@ +name: Verify + +on: + push: + branches: + - "**" + - "!main" + +jobs: + verify: + name: Test + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: 1.16 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Set cache + uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Get dependencies + run: go mod download + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + version: latest + args: -E gosec + + - name: Run Tests + run: go test -covermode=count -coverprofile=count.out -v ./... diff --git a/.gitignore b/.gitignore index 66fd13c..67540db 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +*.log +bin/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..45d4aa6 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +PLATFORM := $(shell uname | tr A-Z a-z) + +lint: + if [ ! -f ./bin/golangci-lint ] ; \ + then \ + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.37.1; \ + fi; + ./bin/golangci-lint run + +test: + go test -covermode=count -coverprofile=count.out -v ./... + +.PHONY: lint test diff --git a/entity.go b/entity.go new file mode 100644 index 0000000..d7b655e --- /dev/null +++ b/entity.go @@ -0,0 +1,83 @@ +package postmand + +import ( + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/google/uuid" +) + +const ( + // DeliveryStatusPending represents the delivery pending status + DeliveryStatusPending = "pending" + // DeliveryStatusFailed represents the delivery failed status + DeliveryStatusFailed = "failed" + // DeliveryStatusCompleted represents the delivery completed status + DeliveryStatusCompleted = "completed" +) + +// Webhook represents a webhook in the system. +type Webhook struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + URL string `json:"url" db:"url"` + ContentType string `json:"content_type" db:"content_type"` + SecretToken string `json:"secret_token" db:"secret_token"` + MaxDeliveryAttempts int `json:"max_delivery_attempts" db:"max_delivery_attempts"` + DeliveryAttemptTimeout int `json:"delivery_attempt_timeout" db:"delivery_attempt_timeout"` + RetryMinBackoff int `json:"retry_min_backoff" db:"retry_min_backoff"` + RetryMaxBackoff int `json:"retry_max_backoff" db:"retry_max_backoff"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// 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")), + validation.Field(&w.MaxDeliveryAttempts, validation.Required, validation.Min(1)), + validation.Field(&w.DeliveryAttemptTimeout, validation.Required, validation.Min(1)), + validation.Field(&w.RetryMinBackoff, validation.Required, validation.Min(1)), + validation.Field(&w.RetryMaxBackoff, validation.Required, validation.Min(1)), + ) +} + +// Delivery represents a payload that must be delivery using webhook context. +type Delivery struct { + ID uuid.UUID `json:"id" db:"id"` + WebhookID uuid.UUID `json:"webhook_id" db:"webhook_id"` + Payload string `json:"payload" db:"payload"` + ScheduledAt time.Time `json:"scheduled_at" db:"scheduled_at"` + DeliveryAttempts int `json:"delivery_attempts" db:"delivery_attempts"` + Status string `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// 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("pending", "completed", "failed")), + ) +} + +// DeliveryAttempt represents a delivery attempt. +type DeliveryAttempt struct { + ID uuid.UUID `json:"id" db:"id"` + WebhookID uuid.UUID `json:"destination_id" db:"destination_id"` + DeliveryID uuid.UUID `json:"delivery_id" db:"delivery_id"` + ResponseHeaders string `json:"response_headers" db:"response_headers"` + ResponseBody string `json:"response_body" db:"response_body"` + ResponseStatusCode int `json:"response_status_code" db:"response_status_code"` + ExecutionDuration int `json:"execution_duration" db:"execution_duration"` + Success bool `json:"success" db:"success"` + Error string `json:"error" db:"error"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/entity_test.go b/entity_test.go new file mode 100644 index 0000000..ded706b --- /dev/null +++ b/entity_test.go @@ -0,0 +1,93 @@ +package postmand + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestWebhook(t *testing.T) { + var tests = []struct { + kind string + request Webhook + expectedPayload string + }{ + { + "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"}`, + }, + { + "Short name", + Webhook{ID: uuid.New(), Name: "A", URL: "https://httpbin.org/post", ContentType: "application/json", MaxDeliveryAttempts: 1, DeliveryAttemptTimeout: 1, RetryMinBackoff: 1, RetryMaxBackoff: 1}, + `{"name":"the length must be between 3 and 255"}`, + }, + { + "Long name", + Webhook{ID: uuid.New(), Name: strings.Repeat("A", 300), URL: "https://httpbin.org/post", ContentType: "application/json", MaxDeliveryAttempts: 1, DeliveryAttemptTimeout: 1, RetryMinBackoff: 1, RetryMaxBackoff: 1}, + `{"name":"the length must be between 3 and 255"}`, + }, + { + "Content type invalid option", + Webhook{ID: uuid.New(), Name: "AAA", URL: "https://httpbin.org/post", ContentType: "text/html", MaxDeliveryAttempts: 1, DeliveryAttemptTimeout: 1, RetryMinBackoff: 1, RetryMaxBackoff: 1}, + `{"content_type":"must be a valid value"}`, + }, + } + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + err := tt.request.Validate() + assert.NotNil(t, err) + errorPayload, err := json.Marshal(err) + assert.Nil(t, err) + assert.Equal(t, tt.expectedPayload, string(errorPayload)) + }) + } + + webhook := Webhook{ID: uuid.New(), Name: "AAA", URL: "https://httpbin.org/post", ContentType: "application/json", MaxDeliveryAttempts: 1, DeliveryAttemptTimeout: 1, RetryMinBackoff: 1, RetryMaxBackoff: 1} + err := webhook.Validate() + assert.Nil(t, err) +} + +func TestDelivery(t *testing.T) { + var tests = []struct { + kind string + request Delivery + expectedPayload string + }{ + { + "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"}`, + }, + } + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + err := tt.request.Validate() + assert.NotNil(t, err) + errorPayload, err := json.Marshal(err) + assert.Nil(t, err) + assert.Equal(t, tt.expectedPayload, string(errorPayload)) + }) + } + + delivery := Delivery{ + ID: uuid.New(), + WebhookID: uuid.New(), + Payload: `{"success": true}`, + ScheduledAt: time.Now().UTC(), + Status: "pending", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + err := delivery.Validate() + assert.Nil(t, err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29fc8d2 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/allisson/postmand + +go 1.16 + +require ( + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 + github.com/google/uuid v1.2.0 + github.com/stretchr/testify v1.7.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d9378a --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=