diff --git a/README.md b/README.md index c90d4d3..5f21f83 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ WebhookX is an open-source webhooks gateway for message receiving, processing, a - `webhookx-signature`: Sign outbound requests with HMAC(SHA-256) by adding `Webhookx-Signature` and `Webhookx-Timestamp` headers. - `wasm`: Transform outbound requests using high-level languages such as AssemblyScript, Rust or TinyGo. See [plugin/wasm](plugins/wasm). - `function`: Customize inbound behavior with JavaScript, e.g. signature verification or request body transformation. + - `jsonschema-validator`: Validate event payloads against JSONSchema definitions. Up to Draft v6 is supported. - **Observability:** OpenTelemetry metrics and tracing for monitoring and troubleshooting. diff --git a/plugins/jsonschema_validator/jsonschema/jsonschema.go b/plugins/jsonschema_validator/jsonschema/jsonschema.go new file mode 100644 index 0000000..e85d338 --- /dev/null +++ b/plugins/jsonschema_validator/jsonschema/jsonschema.go @@ -0,0 +1,40 @@ +package jsonschema + +import ( + "github.com/getkin/kin-openapi/openapi3" + lru "github.com/hashicorp/golang-lru/v2" + "github.com/webhookx-io/webhookx/pkg/openapi" + "github.com/webhookx-io/webhookx/utils" +) + +type JSONSchema struct { + schemaDef string + hex string +} + +func New(schemaDef []byte) *JSONSchema { + return &JSONSchema{ + schemaDef: string(schemaDef), + hex: utils.Sha256(string(schemaDef)), + } +} + +var cache, _ = lru.New[string, *openapi3.Schema](128) + +func (s *JSONSchema) Validate(ctx *ValidatorContext) error { + schema, ok := cache.Get(s.hex) + if !ok { + schema = &openapi3.Schema{} + err := schema.UnmarshalJSON([]byte(s.schemaDef)) + if err != nil { + return err + } + cache.Add(s.hex, schema) + } + + err := openapi.Validate(schema, ctx.HTTPRequest.Data) + if err != nil { + return err + } + return nil +} diff --git a/plugins/jsonschema_validator/jsonschema/jsonschema_test.go b/plugins/jsonschema_validator/jsonschema/jsonschema_test.go new file mode 100644 index 0000000..c5968aa --- /dev/null +++ b/plugins/jsonschema_validator/jsonschema/jsonschema_test.go @@ -0,0 +1,66 @@ +package jsonschema + +import ( + "encoding/json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "testing" +) + +func TestJSONSchema(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Schema Validator Suite") +} + +var _ = Describe("Schema Validator Plugin", func() { + + Context("JSONSchema Validator", func() { + It("should validate valid JSON data against the schema", func() { + schemaDef := `{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name", "age"] + }` + + validator := New([]byte(schemaDef)) + + validData := map[string]interface{}{"name": "John Doe", "age": 30} + ctx := &ValidatorContext{ + HTTPRequest: &HTTPRequest{ + Data: validData, + }, + } + + err := validator.Validate(ctx) + Expect(err).To(BeNil()) + }) + + It("should return an error for invalid JSON data against the schema", func() { + schemaDef := `{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name", "age"] + }` + + validator := New([]byte(schemaDef)) + + invalidData := map[string]interface{}{"name": "John Doe", "age": -5} + ctx := &ValidatorContext{ + HTTPRequest: &HTTPRequest{ + Data: invalidData, + }, + } + + err := validator.Validate(ctx) + Expect(err).ToNot(BeNil()) + b, _ := json.Marshal(err) + Expect(string(b)).To(Equal(`{"message":"request validation","fields":{"age":"number must be at least 0"}}`)) + }) + }) +}) diff --git a/plugins/jsonschema_validator/jsonschema/validator.go b/plugins/jsonschema_validator/jsonschema/validator.go new file mode 100644 index 0000000..7fa6b8f --- /dev/null +++ b/plugins/jsonschema_validator/jsonschema/validator.go @@ -0,0 +1,18 @@ +package jsonschema + +import ( + "net/http" +) + +type Validator interface { + Validate(ctx *ValidatorContext) error +} + +type ValidatorContext struct { + HTTPRequest *HTTPRequest +} + +type HTTPRequest struct { + R *http.Request + Data map[string]any +} diff --git a/plugins/jsonschema_validator/plugin.go b/plugins/jsonschema_validator/plugin.go new file mode 100644 index 0000000..dbb20d9 --- /dev/null +++ b/plugins/jsonschema_validator/plugin.go @@ -0,0 +1,149 @@ +package jsonschema_validator + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/getkin/kin-openapi/openapi3" + "github.com/webhookx-io/webhookx/pkg/errs" + "github.com/webhookx-io/webhookx/pkg/http/response" + "github.com/webhookx-io/webhookx/pkg/plugin" + "github.com/webhookx-io/webhookx/pkg/types" + "github.com/webhookx-io/webhookx/plugins/jsonschema_validator/jsonschema" + "github.com/webhookx-io/webhookx/utils" +) + +type Config struct { + Draft string `json:"draft" validate:"required,oneof=6 default:6"` + DefaultSchema string `json:"default_schema" validate:"omitempty,json,max=1048576"` + Schemas map[string]*Schema `json:"schemas" validate:"dive"` +} + +type Schema struct { + Schema string `json:"schema" validate:"omitempty,json,max=1048576"` +} + +type SchemaValidatorPlugin struct { + plugin.BasePlugin[Config] +} + +func New(config []byte) (plugin.Plugin, error) { + p := &SchemaValidatorPlugin{} + p.Name = "jsonschema-validator" + + if config != nil { + if err := p.UnmarshalConfig(config); err != nil { + return nil, err + } + } + return p, nil +} + +func unmarshalAndValidateSchema(schema string) (*openapi3.Schema, error) { + openapiSchema := &openapi3.Schema{} + err := openapiSchema.UnmarshalJSON([]byte(schema)) + if err != nil { + return nil, fmt.Errorf("value must be a valid jsonschema") + } + err = openapiSchema.Validate(context.Background(), openapi3.EnableSchemaFormatValidation()) + if err != nil { + return openapiSchema, err + } + return openapiSchema, nil +} + +func (p *SchemaValidatorPlugin) ValidateConfig() error { + err := utils.Validate(p.Config) + if err != nil { + return err + } + + e := errs.NewValidateError(errors.New("request validation")) + + var defaultErr error + if p.Config.DefaultSchema != "" { + _, err := unmarshalAndValidateSchema(p.Config.DefaultSchema) + if err != nil { + defaultErr = err + e.Fields = map[string]interface{}{ + "default_schema": err.Error(), + } + } + } + + for event, schema := range p.Config.Schemas { + field := fmt.Sprintf("schemas[%s]", event) + if schema == nil || schema.Schema == "" { + if defaultErr != nil { + e.Fields[field] = map[string]string{ + "schema": "invalid due to reusing the default_schema definition", + } + } + } else { + _, err = unmarshalAndValidateSchema(schema.Schema) + if err != nil { + e.Fields[field] = map[string]string{ + "schema": err.Error(), + } + } + } + } + if len(e.Fields) > 0 { + return e + } + return nil +} + +func (p *SchemaValidatorPlugin) ExecuteInbound(inbound *plugin.Inbound) (res plugin.InboundResult, err error) { + var event map[string]any + body := inbound.RawBody + if err = json.Unmarshal(body, &event); err != nil { + return + } + + eventType, ok := event["event_type"].(string) + if !ok || eventType == "" { + res.Payload = body + return + } + + data := event["data"] + if data == nil { + res.Payload = body + return + } + + schema, ok := p.Config.Schemas[eventType] + if !ok { + res.Payload = body + return + } + if schema == nil || schema.Schema == "" { + if p.Config.DefaultSchema == "" { + res.Payload = body + return + } + schema = &Schema{ + Schema: p.Config.DefaultSchema, + } + } + + validator := jsonschema.New([]byte(schema.Schema)) + e := validator.Validate(&jsonschema.ValidatorContext{ + HTTPRequest: &jsonschema.HTTPRequest{ + R: inbound.Request, + Data: data.(map[string]any), + }, + }) + if e != nil { + response.JSON(inbound.Response, 400, types.ErrorResponse{ + Message: "Request Validation", + Error: e, + }) + res.Terminated = true + return + } + res.Payload = body + return +} diff --git a/plugins/plugins.go b/plugins/plugins.go index df737e9..7bddbab 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -3,12 +3,14 @@ package plugins import ( "github.com/webhookx-io/webhookx/pkg/plugin" "github.com/webhookx-io/webhookx/plugins/function" + "github.com/webhookx-io/webhookx/plugins/jsonschema_validator" "github.com/webhookx-io/webhookx/plugins/wasm" "github.com/webhookx-io/webhookx/plugins/webhookx_signature" ) func LoadPlugins() { plugin.RegisterPlugin(plugin.TypeInbound, "function", function.New) + plugin.RegisterPlugin(plugin.TypeInbound, "jsonschema-validator", jsonschema_validator.New) plugin.RegisterPlugin(plugin.TypeOutbound, "wasm", wasm.New) plugin.RegisterPlugin(plugin.TypeOutbound, "webhookx-signature", webhookx_signature.New) } diff --git a/proxy/gateway.go b/proxy/gateway.go index aea0c8c..691b06c 100644 --- a/proxy/gateway.go +++ b/proxy/gateway.go @@ -260,6 +260,7 @@ func (gw *Gateway) handle(w http.ResponseWriter, r *http.Request) bool { response.JSON(w, 500, types.ErrorResponse{Message: "internal error"}) return false } + if result.Terminated { return false } diff --git a/test/cmd/admin_test.go b/test/cmd/admin_test.go index f3c0670..01957dc 100644 --- a/test/cmd/admin_test.go +++ b/test/cmd/admin_test.go @@ -95,10 +95,13 @@ var _ = Describe("admin", Ordered, func() { plugins, err = db.Plugins.ListSourcePlugin(context.TODO(), source.ID) assert.NoError(GinkgoT(), err) - assert.Equal(GinkgoT(), 1, len(plugins)) + assert.Equal(GinkgoT(), 2, len(plugins)) assert.Equal(GinkgoT(), "function", plugins[0].Name) assert.Equal(GinkgoT(), true, plugins[0].Enabled) assert.Equal(GinkgoT(), `{"function": "function handle() {}"}`, string(plugins[0].Config)) + assert.Equal(GinkgoT(), `jsonschema-validator`, plugins[1].Name) + assert.Equal(GinkgoT(), true, plugins[1].Enabled) + assert.Equal(GinkgoT(), `{"draft": "6", "schemas": {"charge.succeeded": {"schema": "{\n \"type\": \"object\",\n \"properties\": {\n \"id\": { \"type\": \"string\" },\n \"amount\": { \"type\": \"integer\", \"minimum\": 1 },\n \"currency\": { \"type\": \"string\", \"minLength\": 3, \"maxLength\": 6 }\n },\n \"required\": [\"id\", \"amount\", \"currency\"]\n}\n"}}, "default_schema": "{\n \"type\": \"object\",\n \"properties\": {\n \"id\": { \"type\": \"string\" }\n },\n \"required\": [\"id\"]\n}\n"}`, string(plugins[1].Config)) }) It("entities not defined in the declarative configuration should be deleted", func() { diff --git a/test/declarative/declarative_test.go b/test/declarative/declarative_test.go index 5a24445..c4910e9 100644 --- a/test/declarative/declarative_test.go +++ b/test/declarative/declarative_test.go @@ -1,6 +1,7 @@ package declarative import ( + "fmt" "github.com/go-resty/resty/v2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,6 +40,40 @@ endpoints: plugins: - name: foo ` + + invalidSourcePluginJSONSchemaConfigYAML = ` +sources: + - name: default-source + path: / + methods: ["POST"] + plugins: + - name: "jsonschema-validator" + config: + default_schema: | + %s + schemas: + charge.succeed: + schema: | + %s +` + + invalidSourcePluginJSONSchemaJSONYAML = ` +sources: + - name: default-source + path: / + methods: ["POST"] + plugins: + - name: "jsonschema-validator" + config: + draft: "6" + default_schema: | + %s + schemas: + charge.succeed: + schema: | + %s + reuse.default_schema: +` ) var _ = Describe("Declarative", Ordered, func() { @@ -102,6 +137,33 @@ var _ = Describe("Declarative", Ordered, func() { assert.Equal(GinkgoT(), 400, resp.StatusCode()) assert.Equal(GinkgoT(), `{"message":"Request Validation","error":{"message":"request validation","fields":{"name":"unknown plugin name 'foo'"}}}`, string(resp.Body())) }) + + It("should return 400 for invalid jsonschema-validator plugin config", func() { + resp, err := adminClient.R(). + SetBody( + fmt.Sprintf(invalidSourcePluginJSONSchemaConfigYAML, "invalid jsonschema", "invalid jsonschema"), + ). + Post("/workspaces/default/config/sync") + + assert.Nil(GinkgoT(), err) + assert.Equal(GinkgoT(), 400, resp.StatusCode()) + assert.Equal(GinkgoT(), + `{"message":"Request Validation","error":{"message":"request validation","fields":{"config":{"default_schema":"value must be a valid json string","draft":"required field missing","schemas[charge.succeed]":{"schema":"value must be a valid json string"}}}}}`, + string(resp.Body())) + }) + + It("should return 400 for invalid jsonschema-validator plugin config jsonschema string", func() { + resp, err := adminClient.R(). + SetBody(fmt.Sprintf(invalidSourcePluginJSONSchemaJSONYAML, + `{"type": "invlidObject","properties": {"id": { "type": "string"}}}`, + `{"type": "object","properties": {"id": { "type": "number", "format":"invalid"}}}`)). + Post("/workspaces/default/config/sync") + assert.Nil(GinkgoT(), err) + assert.Equal(GinkgoT(), 400, resp.StatusCode()) + assert.Equal(GinkgoT(), + `{"message":"Request Validation","error":{"message":"request validation","fields":{"config":{"default_schema":"unsupported 'type' value \"invlidObject\"","schemas[charge.succeed]":{"schema":"unsupported 'format' value \"invalid\""},"schemas[reuse.default_schema]":{"schema":"invalid due to reusing the default_schema definition"}}}}}`, + string(resp.Body())) + }) }) }) }) diff --git a/test/fixtures/jsonschema/charge.succeed.json b/test/fixtures/jsonschema/charge.succeed.json new file mode 100644 index 0000000..1a0f5f6 --- /dev/null +++ b/test/fixtures/jsonschema/charge.succeed.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "amount": { + "type": "integer", + "minimum": 1 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 6 + } + }, + "required": [ + "id", + "amount", + "currency" + ] +} \ No newline at end of file diff --git a/test/fixtures/jsonschema/invalid.json b/test/fixtures/jsonschema/invalid.json new file mode 100644 index 0000000..9f9837e --- /dev/null +++ b/test/fixtures/jsonschema/invalid.json @@ -0,0 +1 @@ +"invalid jsonschema" \ No newline at end of file diff --git a/test/fixtures/webhookx.yml b/test/fixtures/webhookx.yml index 80bee5b..65da523 100644 --- a/test/fixtures/webhookx.yml +++ b/test/fixtures/webhookx.yml @@ -10,7 +10,7 @@ endpoints: strategy: fixed config: attempts: [0, 3600, 3600] - events: [ "charge.succeeded" ] + events: ["charge.succeeded"] metadata: key1: value1 key2: value2 @@ -28,7 +28,7 @@ endpoints: sources: - name: default-source path: / - methods: [ "POST" ] + methods: ["POST"] response: code: 200 content_type: application/json @@ -37,3 +37,26 @@ sources: - name: function config: function: "function handle() {}" + - name: "jsonschema-validator" + config: + draft: "6" + default_schema: | + { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + schemas: + charge.succeeded: + schema: | + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "amount": { "type": "integer", "minimum": 1 }, + "currency": { "type": "string", "minLength": 3, "maxLength": 6 } + }, + "required": ["id", "amount", "currency"] + } diff --git a/test/plugins/jsonschema_validator_test.go b/test/plugins/jsonschema_validator_test.go new file mode 100644 index 0000000..fc08900 --- /dev/null +++ b/test/plugins/jsonschema_validator_test.go @@ -0,0 +1,195 @@ +package plugins + +import ( + "context" + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/webhookx-io/webhookx/app" + "github.com/webhookx-io/webhookx/db" + "github.com/webhookx-io/webhookx/db/entities" + "github.com/webhookx-io/webhookx/db/query" + "github.com/webhookx-io/webhookx/plugins/jsonschema_validator" + "github.com/webhookx-io/webhookx/test/helper" + "github.com/webhookx-io/webhookx/test/helper/factory" + "github.com/webhookx-io/webhookx/utils" + "time" +) + +var jsonString = `{ + "type": "object", + "required": ["id", "amount", "currency"], + "properties": { + "id": { + "type": "string" + }, + "amount": { + "type": "integer", + "minimum": 1 + }, + "currency": { + "type": "string", + "maxLength": 6, + "minLength": 3 + } + } +}` + +var _ = Describe("jsonschema-validator", Ordered, func() { + + Context("schema string", func() { + var proxyClient *resty.Client + + var app *app.Application + var db *db.DB + + entitiesConfig := helper.EntitiesConfig{ + Endpoints: []*entities.Endpoint{factory.EndpointP()}, + Sources: []*entities.Source{factory.SourceP()}, + } + entitiesConfig.Plugins = []*entities.Plugin{ + factory.PluginP( + factory.WithPluginSourceID(entitiesConfig.Sources[0].ID), + factory.WithPluginName("jsonschema-validator"), + factory.WithPluginConfig(jsonschema_validator.Config{ + Draft: "6", + DefaultSchema: jsonString, + Schemas: map[string]*jsonschema_validator.Schema{ + "charge.succeeded": { + Schema: jsonString, + }, + "reuse.default_schema": nil, + }, + }), + ), + } + + BeforeAll(func() { + db = helper.InitDB(true, &entitiesConfig) + proxyClient = helper.ProxyClient() + + app = utils.Must(helper.Start(map[string]string{ + "WEBHOOKX_ADMIN_LISTEN": "0.0.0.0:8080", + "WEBHOOKX_PROXY_LISTEN": "0.0.0.0:8081", + "WEBHOOKX_WORKER_ENABLED": "true", + })) + }) + + AfterAll(func() { + app.Stop() + }) + + It("sanity", func() { + body := `{"event_type": "charge.succeeded","data": {"id": "ch_1234567890","amount": 1000,"currency": "usd"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(200)) + // get event from db + var event *entities.Event + assert.Eventually(GinkgoT(), func() bool { + list, err := db.Events.List(context.TODO(), &query.EventQuery{}) + if err != nil || len(list) != 1 { + return false + } + event = list[0] + return true + }, time.Second*5, time.Second) + assert.Equal(GinkgoT(), "charge.succeeded", event.EventType) + assert.JSONEq(GinkgoT(), `{"id": "ch_1234567890","amount": 1000,"currency": "usd"}`, string(event.Data)) + }) + + It("sanity if undeclared event type", func() { + body := `{"event_type": "unknown.event", "data":{"foo": "bar"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(200)) + + // get event from db + var event *entities.Event + assert.Eventually(GinkgoT(), func() bool { + list, err := db.Events.List(context.TODO(), &query.EventQuery{}) + if err != nil || len(list) == 0 { + return false + } + for _, item := range list { + if item.EventType == "unknown.event" { + event = item + return true + } + } + return false + }, time.Second*5, time.Second) + assert.Equal(GinkgoT(), "unknown.event", event.EventType) + assert.JSONEq(GinkgoT(), `{"foo": "bar"}`, string(event.Data)) + }) + + It("sanity if reuse default_schema", func() { + body := `{"event_type": "reuse.default_schema","data": {"id": "ch_1234567890","amount": 1000,"currency": "usd"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(200)) + // get event from db + var event *entities.Event + assert.Eventually(GinkgoT(), func() bool { + list, err := db.Events.List(context.TODO(), &query.EventQuery{}) + if err != nil || len(list) == 0 { + return false + } + for _, item := range list { + if item.EventType == "reuse.default_schema" { + event = item + return true + } + } + return true + }, time.Second*5, time.Second) + assert.Equal(GinkgoT(), "reuse.default_schema", event.EventType) + assert.JSONEq(GinkgoT(), `{"id": "ch_1234567890","amount": 1000,"currency": "usd"}`, string(event.Data)) + }) + + It("invalid event - missing required field", func() { + body := `{"event_type": "charge.succeeded","data": {"amount": 1000,"currency": "usd"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(400)) + Expect(string(resp.Body())).To(Equal(`{"message":"Request Validation","error":{"message":"request validation","fields":{"id":"required field missing"}}}`)) + }) + + It("invalid event - field type mismatch", func() { + body := `{"event_type": "charge.succeeded","data": {"id": "ch_1234567890","amount": "1000","currency": "usd"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(400)) + Expect(string(resp.Body())).To(Equal(`{"message":"Request Validation","error":{"message":"request validation","fields":{"amount":"value must be an integer"}}}`)) + }) + + It("invalid event - reuse default schema", func() { + body := `{"event_type": "reuse.default_schema","data": {"id": "ch_1234567890","amount": "1000","currency": "usd"}}` + resp, err := proxyClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(body). + Post("/") + + Expect(err).To(BeNil()) + Expect(resp.StatusCode()).To(Equal(400)) + Expect(string(resp.Body())).To(Equal(`{"message":"Request Validation","error":{"message":"request validation","fields":{"amount":"value must be an integer"}}}`)) + }) + }) +}) diff --git a/utils/hash.go b/utils/hash.go new file mode 100644 index 0000000..c4590b8 --- /dev/null +++ b/utils/hash.go @@ -0,0 +1,11 @@ +package utils + +import ( + "crypto/sha256" + "encoding/hex" +) + +func Sha256(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} diff --git a/utils/validate.go b/utils/validate.go index d0477f7..2953c3b 100644 --- a/utils/validate.go +++ b/utils/validate.go @@ -5,6 +5,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/webhookx-io/webhookx/pkg/errs" "reflect" + "regexp" "strings" "sync" ) @@ -49,6 +50,9 @@ func init() { RegisterFormatter("max", func(fe validator.FieldError) string { return fmt.Sprintf("length must be at most %s", fe.Param()) }) + RegisterFormatter("json", func(fe validator.FieldError) string { + return "value must be a valid json string" + }) } func RegisterValidation(tag string, fn validator.Func) { @@ -64,15 +68,31 @@ func RegisterFormatter(tag string, fn func(fe validator.FieldError) string) { formatters[tag] = fn } +const fieldPlacehoder = "#field%d" + func Validate(v interface{}) error { err := validate.Struct(v) if err != nil { validateErr := errs.NewValidateError(errs.ErrRequestValidation) for _, e := range err.(validator.ValidationErrors) { - fields := strings.Split(e.Namespace(), ".") + namespace := e.Namespace() + placeholders := make(map[string]string) + if strings.ContainsAny(namespace, "[]") { + re := regexp.MustCompile(`\w+\[[^\]]+\]`) + matches := re.FindAllString(namespace, -1) + for i, field := range matches { + idx := fmt.Sprintf(fieldPlacehoder, i) + placeholders[idx] = field + namespace = strings.Replace(namespace, field, idx, 1) + } + } + fields := strings.Split(namespace, ".") node := validateErr.Fields for i := 1; i < len(fields); i++ { fieldName := fields[i] + if actualField, ok := placeholders[fieldName]; ok { + fieldName = actualField + } if i < len(fields)-1 { if node[fieldName] == nil { node[fieldName] = make(map[string]interface{}) diff --git a/webhookx.sample.yml b/webhookx.sample.yml index 28efccd..ed5378d 100644 --- a/webhookx.sample.yml +++ b/webhookx.sample.yml @@ -10,7 +10,7 @@ endpoints: strategy: fixed config: attempts: [0, 3600, 3600] - events: [ "charge.succeeded" ] + events: ["charge.succeeded"] plugins: - name: webhookx-signature config: @@ -25,8 +25,24 @@ endpoints: sources: - name: default-source path: / - methods: [ "POST" ] + methods: ["POST"] response: code: 200 content_type: application/json body: '{"message": "OK"}' + plugins: + - name: "jsonschema-validator" + config: + draft: "6" + schemas: + charge.succeeded: + schema: | + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "amount": { "type": "integer", "minimum": 1 }, + "currency": { "type": "string", "minLength": 3, "maxLength": 6 } + }, + "required": ["id", "amount", "currency"] + }