From 708a87080b0d6dc3057eafc3207ac5178535da65 Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Wed, 5 Jun 2024 11:27:22 -0300 Subject: [PATCH 1/7] feat: add validators --- go.mod | 12 +- go.sum | 26 ++- internal/serve/httperror/errors.go | 30 ++-- internal/serve/httperror/errors_test.go | 22 +-- .../serve/httphandler/payments_handler.go | 22 ++- .../httphandler/payments_handler_test.go | 45 ++++- .../httphandler/request_body_validator.go | 21 +++ internal/serve/middleware/middleware.go | 6 +- internal/validators/validate.go | 80 +++++++++ internal/validators/validate_test.go | 162 ++++++++++++++++++ 10 files changed, 381 insertions(+), 45 deletions(-) create mode 100644 internal/serve/httphandler/request_body_validator.go create mode 100644 internal/validators/validate.go create mode 100644 internal/validators/validate_test.go diff --git a/go.mod b/go.mod index 7656a48..699aec5 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.0 require ( github.com/go-chi/chi v4.1.2+incompatible + github.com/go-playground/validator/v10 v10.20.0 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/rubenv/sql-migrate v1.6.1 @@ -32,10 +33,13 @@ require ( github.com/djherbis/fscache v0.10.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/s2a-go v0.1.7 // indirect @@ -49,7 +53,9 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect @@ -78,13 +84,13 @@ require ( go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.18.0 // indirect + golang.org/x/crypto v0.19.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.13.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.14.0 // indirect diff --git a/go.sum b/go.sum index db21dc2..8a96699 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 h1:gmtGRvSexPU4B1T/yYo0sLOKzER1YT+b4kPxPpm0Ty4= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= @@ -117,6 +119,14 @@ github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -237,11 +247,15 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 h1:ykXz+pRRTibcSjG1yRhpdSHInF8yZY/mfn+Rz2Nd1rE= +github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739/go.mod h1:zUx1mhth20V3VKgL5jbd1BSQcW4Fy6Qs4PZvQwRFwzM= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -382,8 +396,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -457,8 +471,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -523,8 +537,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/serve/httperror/errors.go b/internal/serve/httperror/errors.go index 06a7e16..ad15a08 100644 --- a/internal/serve/httperror/errors.go +++ b/internal/serve/httperror/errors.go @@ -8,61 +8,65 @@ import ( "github.com/stellar/go/support/render/httpjson" ) -type errorResponse struct { - Status int `json:"-"` - Error string `json:"error"` +type ErrorResponse struct { + Status int `json:"-"` + Error string `json:"error"` + Extras map[string]interface{} `json:"extras,omitempty"` } -func (e errorResponse) Render(w http.ResponseWriter) { +func (e ErrorResponse) Render(w http.ResponseWriter) { httpjson.RenderStatus(w, e.Status, e, httpjson.JSON) } type ErrorHandler struct { - Error errorResponse + Error ErrorResponse } func (h ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Error.Render(w) } -var NotFound = errorResponse{ +var NotFound = ErrorResponse{ Status: http.StatusNotFound, Error: "The resource at the url requested was not found.", } -var MethodNotAllowed = errorResponse{ +var MethodNotAllowed = ErrorResponse{ Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested.", } -func BadRequest(message string) errorResponse { +func BadRequest(message string, extras map[string]interface{}) *ErrorResponse { if message == "" { message = "Invalid request" } - return errorResponse{ + return &ErrorResponse{ Status: http.StatusBadRequest, Error: message, + Extras: extras, } } -func Unauthorized(message string) errorResponse { +func Unauthorized(message string, extras map[string]interface{}) ErrorResponse { if message == "" { message = "Not authorized." } - return errorResponse{ + return ErrorResponse{ Status: http.StatusUnauthorized, Error: message, + Extras: extras, } } -func InternalServerError(ctx context.Context, message string, err error) errorResponse { +func InternalServerError(ctx context.Context, message string, err error, extras map[string]interface{}) *ErrorResponse { // TODO: track error in Sentry log.Ctx(ctx).Error(err) - return errorResponse{ + return &ErrorResponse{ Status: http.StatusInternalServerError, Error: "An error occurred while processing this request.", + Extras: extras, } } diff --git a/internal/serve/httperror/errors_test.go b/internal/serve/httperror/errors_test.go index a353494..95d01ae 100644 --- a/internal/serve/httperror/errors_test.go +++ b/internal/serve/httperror/errors_test.go @@ -14,20 +14,20 @@ import ( func TestErrorResponseRender(t *testing.T) { testCases := []struct { - in errorResponse - want errorResponse + in ErrorResponse + want ErrorResponse }{ { - in: InternalServerError(context.Background(), "", nil), - want: errorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, + in: *InternalServerError(context.Background(), "", nil, nil), + want: ErrorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, }, { in: NotFound, - want: errorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, + want: ErrorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, }, { in: MethodNotAllowed, - want: errorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, + want: ErrorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, }, } @@ -47,19 +47,19 @@ func TestErrorResponseRender(t *testing.T) { func TestErrorHandler(t *testing.T) { testCases := []struct { in ErrorHandler - want errorResponse + want ErrorResponse }{ { - in: ErrorHandler{InternalServerError(context.Background(), "", nil)}, - want: errorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, + in: ErrorHandler{*InternalServerError(context.Background(), "", nil, nil)}, + want: ErrorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, }, { in: ErrorHandler{NotFound}, - want: errorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, + want: ErrorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, }, { in: ErrorHandler{MethodNotAllowed}, - want: errorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, + want: ErrorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, }, } diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index 9aee57d..defa15e 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -13,7 +13,7 @@ type PaymentsHandler struct { } type PaymentsSubscribeRequest struct { - Address string `json:"address"` + Address string `json:"address" validate:"required,public_key"` } func (h PaymentsHandler) SubscribeAddress(w http.ResponseWriter, r *http.Request) { @@ -22,13 +22,19 @@ func (h PaymentsHandler) SubscribeAddress(w http.ResponseWriter, r *http.Request var reqBody PaymentsSubscribeRequest err := httpdecode.DecodeJSON(r, &reqBody) if err != nil { - httperror.BadRequest("Invalid request body").Render(w) + httperror.BadRequest("Invalid request body", nil).Render(w) + return + } + + httpErr := ValidateRequestBody(ctx, reqBody) + if httpErr != nil { + httpErr.Render(w) return } err = h.PaymentModel.SubscribeAddress(ctx, reqBody.Address) if err != nil { - httperror.InternalServerError(ctx, "", err).Render(w) + httperror.InternalServerError(ctx, "", err, nil).Render(w) return } } @@ -39,13 +45,19 @@ func (h PaymentsHandler) UnsubscribeAddress(w http.ResponseWriter, r *http.Reque var reqBody PaymentsSubscribeRequest err := httpdecode.DecodeJSON(r, &reqBody) if err != nil { - httperror.BadRequest("Invalid request body").Render(w) + httperror.BadRequest("Invalid request body", nil).Render(w) + return + } + + httpErr := ValidateRequestBody(ctx, reqBody) + if httpErr != nil { + httpErr.Render(w) return } err = h.PaymentModel.UnsubscribeAddress(ctx, reqBody.Address) if err != nil { - httperror.InternalServerError(ctx, "", err).Render(w) + httperror.InternalServerError(ctx, "", err, nil).Render(w) return } } diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index 4c97fa7..4f3b48c 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -44,7 +45,7 @@ func TestSubscribeAddress(t *testing.T) { t.Run("success_happy_path", func(t *testing.T) { // Prepare request address := keypair.MustRandom().Address() - payload := fmt.Sprintf(`{ "address": "%s" }`, address) + payload := fmt.Sprintf(`{ "address": %q }`, address) req, err := http.NewRequest(http.MethodPost, "/payments/subscribe", strings.NewReader(payload)) require.NoError(t, err) @@ -76,7 +77,7 @@ func TestSubscribeAddress(t *testing.T) { require.NoError(t, err) // Prepare request - payload := fmt.Sprintf(`{ "address": "%s" }`, address) + payload := fmt.Sprintf(`{ "address": %q }`, address) req, err := http.NewRequest(http.MethodPost, "/payments/subscribe", strings.NewReader(payload)) require.NoError(t, err) @@ -97,6 +98,24 @@ func TestSubscribeAddress(t *testing.T) { clearAccounts(ctx) }) + + t.Run("invalid_address", func(t *testing.T) { + // Prepare request + payload := fmt.Sprintf(`{ "address": %q }`, "invalid") + req, err := http.NewRequest(http.MethodPost, "/payments/subscribe", strings.NewReader(payload)) + require.NoError(t, err) + + // Serve request + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + resp := rr.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.JSONEq(t, `{"error":"Validation error.", "extras": {"address":"Invalid public key provided"}}`, string(respBody)) + }) } func TestUnsubscribeAddress(t *testing.T) { @@ -126,7 +145,7 @@ func TestUnsubscribeAddress(t *testing.T) { require.NoError(t, err) // Prepare request - payload := fmt.Sprintf(`{ "address": "%s" }`, address) + payload := fmt.Sprintf(`{ "address": %q }`, address) req, err := http.NewRequest(http.MethodPost, "/payments/unsubscribe", strings.NewReader(payload)) require.NoError(t, err) @@ -152,7 +171,7 @@ func TestUnsubscribeAddress(t *testing.T) { require.NoError(t, err) // Prepare request - payload := fmt.Sprintf(`{ "address": "%s" }`, address) + payload := fmt.Sprintf(`{ "address": %q }`, address) req, err := http.NewRequest(http.MethodPost, "/payments/unsubscribe", strings.NewReader(payload)) require.NoError(t, err) @@ -163,4 +182,22 @@ func TestUnsubscribeAddress(t *testing.T) { // Assert 200 response assert.Equal(t, http.StatusOK, rr.Code) }) + + t.Run("invalid_address", func(t *testing.T) { + // Prepare request + payload := fmt.Sprintf(`{ "address": %q }`, "invalid") + req, err := http.NewRequest(http.MethodPost, "/payments/unsubscribe", strings.NewReader(payload)) + require.NoError(t, err) + + // Serve request + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + resp := rr.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.JSONEq(t, `{"error":"Validation error.", "extras": {"address":"Invalid public key provided"}}`, string(respBody)) + }) } diff --git a/internal/serve/httphandler/request_body_validator.go b/internal/serve/httphandler/request_body_validator.go new file mode 100644 index 0000000..0c14b22 --- /dev/null +++ b/internal/serve/httphandler/request_body_validator.go @@ -0,0 +1,21 @@ +package httphandler + +import ( + "context" + + "github.com/go-playground/validator/v10" + "github.com/stellar/wallet-backend/internal/serve/httperror" + "github.com/stellar/wallet-backend/internal/validators" +) + +func ValidateRequestBody[T any](ctx context.Context, reqBody T) *httperror.ErrorResponse { + val := validators.NewValidator() + if err := val.StructCtx(ctx, reqBody); err != nil { + if vErrs, ok := err.(validator.ValidationErrors); ok { + extras := validators.ParseValidationError(vErrs) + return httperror.BadRequest("Validation error.", extras) + } + return httperror.InternalServerError(ctx, "", err, nil) + } + return nil +} diff --git a/internal/serve/middleware/middleware.go b/internal/serve/middleware/middleware.go index 96b3346..7f6bb1a 100644 --- a/internal/serve/middleware/middleware.go +++ b/internal/serve/middleware/middleware.go @@ -20,7 +20,7 @@ func SignatureMiddleware(signatureVerifier auth.SignatureVerifier) func(next htt if sig == "" { sig = req.Header.Get("X-Stellar-Signature") if sig == "" { - httperror.Unauthorized("").Render(rw) + httperror.Unauthorized("", nil).Render(rw) return } } @@ -30,7 +30,7 @@ func SignatureMiddleware(signatureVerifier auth.SignatureVerifier) func(next htt reqBody, err := io.ReadAll(io.LimitReader(req.Body, MaxBodySize)) if err != nil { err = fmt.Errorf("reading request body: %w", err) - httperror.InternalServerError(ctx, "", err).Render(rw) + httperror.InternalServerError(ctx, "", err, nil).Render(rw) return } @@ -38,7 +38,7 @@ func SignatureMiddleware(signatureVerifier auth.SignatureVerifier) func(next htt if err != nil { err = fmt.Errorf("checking request signature: %w", err) log.Ctx(ctx).Error(err) - httperror.Unauthorized("").Render(rw) + httperror.Unauthorized("", nil).Render(rw) return } diff --git a/internal/validators/validate.go b/internal/validators/validate.go new file mode 100644 index 0000000..0413376 --- /dev/null +++ b/internal/validators/validate.go @@ -0,0 +1,80 @@ +package validators + +import ( + "fmt" + "reflect" + "strings" + "unicode" + + "github.com/go-playground/validator/v10" + "github.com/stellar/go/strkey" +) + +func NewValidator() *validator.Validate { + validate := validator.New() + validate.RegisterValidation("public_key", publicKeyValidation) + validate.RegisterAlias("not_empty", "required") + return validate +} + +func publicKeyValidation(fl validator.FieldLevel) bool { + addr := fl.Field().String() + return strkey.IsValidEd25519PublicKey(addr) || strkey.IsValidMuxedAccountEd25519PublicKey(addr) +} + +func ParseValidationError(errors validator.ValidationErrors) map[string]interface{} { + fieldErrors := make(map[string]interface{}) + for _, err := range errors { + fieldErrors[getFieldName(err)] = msgForFieldError(err) + } + return fieldErrors +} + +// msgForFieldError gets the message for the given validation error (tag). +func msgForFieldError(fieldError validator.FieldError) string { + switch fieldError.Tag() { + case "required": + return "This field is required" + case "not_empty": + return "This field cannot be empty" + case "public_key": + return "Invalid public key provided" + case "oneof": + params := strings.Join(strings.Split(fieldError.Param(), " "), ", ") + return fmt.Sprintf("Unexpected value %q. Expected one of the following values: %s", fieldError.Value(), params) + case "gt": + if fieldError.Kind() == reflect.Slice || fieldError.Kind() == reflect.Array { + return "Should have at least 1 element" + } + return fmt.Sprintf("Should be greater than %s", fieldError.Param()) + case "gte": + return fmt.Sprintf("Should be greater than or equal %s", fieldError.Param()) + default: + return "Invalid value" + } +} + +func getFieldName(fieldError validator.FieldError) string { + // Ex.: structName.FieldName, structName.nestedStructName.nestedStructFieldName, structName.nestedStructName.nestedStructName.... + namespace := strings.Split(fieldError.StructNamespace(), ".") + length := len(namespace) + if length == 2 { + return lcFirst(namespace[1]) + } + + if length > 2 { + return fmt.Sprintf("%s.%s", lcFirst(namespace[length-2]), lcFirst(namespace[length-1])) + } + + return lcFirst(namespace[0]) +} + +// lcFirst lowers the case of the first letter of the given string. +// +// Example: Address -> address +func lcFirst(str string) string { + for index, letter := range str { + return string(unicode.ToLower(letter)) + str[index+1:] + } + return "" +} diff --git a/internal/validators/validate_test.go b/internal/validators/validate_test.go new file mode 100644 index 0000000..dbf3850 --- /dev/null +++ b/internal/validators/validate_test.go @@ -0,0 +1,162 @@ +package validators + +import ( + "testing" + + "github.com/go-playground/validator/v10" + "github.com/stellar/go/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseValidationError(t *testing.T) { + type testStructNested struct { + NestedRequiredField string `validate:"required"` + NestedEnumField string `validate:"oneof=foo bar"` + } + + type testStruct struct { + RequiredField string `validate:"required"` + RequiredArrayField []string `validate:"required,gt=0,dive,not_empty"` + EnumField string `validate:"oneof=foo bar"` + PublicKeyField string `validate:"public_key"` + UnknownTagField int `validate:"lte=1"` + NestedField []testStructNested `validate:"dive"` + } + + testCases := []struct { + stc *testStruct + expectedFieldErrors map[string]interface{} + }{ + { + stc: &testStruct{ + RequiredField: "", + RequiredArrayField: []string{}, + EnumField: "invalid", + PublicKeyField: "invalid", + UnknownTagField: 1, + }, + expectedFieldErrors: map[string]interface{}{ + "requiredField": "This field is required", + "requiredArrayField": "Should have at least 1 element", + "enumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, + "publicKeyField": "Invalid public key provided", + }, + }, + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar", ""}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 1, + }, + expectedFieldErrors: map[string]interface{}{ + "requiredArrayField[1]": "This field cannot be empty e", + }, + }, + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar"}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 2, + }, + expectedFieldErrors: map[string]interface{}{ + "unknownTagField": "Invalid value", + }, + }, + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar"}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 1, + NestedField: []testStructNested{ + { + NestedRequiredField: "", + NestedEnumField: "invalid", + }, + }, + }, + expectedFieldErrors: map[string]interface{}{ + "nestedField[0].nestedRequiredField": "This field is required", + "nestedField[0].nestedEnumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, + }, + }, + } + + val := NewValidator() + for _, tc := range testCases { + err := val.Struct(tc.stc) + require.Error(t, err) + vErrs, ok := err.(validator.ValidationErrors) + require.True(t, ok) + fieldErrors := ParseValidationError(vErrs) + assert.Equal(t, tc.expectedFieldErrors, fieldErrors) + } +} + +func TestGetFieldName(t *testing.T) { + type testStructNested struct { + Name string `validate:"not_empty"` + Children []testStructNested `validate:"dive"` + } + + type testStruct struct { + PublicKey string `validate:"public_key"` + NestedField []testStructNested `validate:"required,dive"` + } + + stc := &testStruct{ + PublicKey: "", + NestedField: []testStructNested{ + { + Name: "first", + Children: []testStructNested{ + { + Name: "second", + Children: []testStructNested{ + { + Name: "children1", + Children: []testStructNested{}, + }, + { + Name: "children2", + Children: []testStructNested{ + { + Name: "", + Children: []testStructNested{}, + }, + }, + }, + }, + }, + }, + }, + }, + } + val := NewValidator() + err := val.Struct(stc) + require.Error(t, err) + + vErrs, ok := err.(validator.ValidationErrors) + require.True(t, ok) + require.Len(t, vErrs, 2) + + assert.Equal(t, "publicKey", getFieldName(vErrs[0])) + assert.Equal(t, "children[0].name", getFieldName(vErrs[1])) +} + +func TestLCFist(t *testing.T) { + got := lcFirst("Address") + assert.Equal(t, "address", got) + got = lcFirst("PublicKey") + assert.Equal(t, "publicKey", got) + got = lcFirst("A") + assert.Equal(t, "a", got) + got = lcFirst("") + assert.Equal(t, "", got) +} From bd9ffac8a4f05b86cd4ad4845cdd46749c262d2a Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Wed, 5 Jun 2024 12:02:20 -0300 Subject: [PATCH 2/7] tests: fix tests errors --- internal/validators/validate.go | 2 +- internal/validators/validate_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/validators/validate.go b/internal/validators/validate.go index 0413376..583e4b5 100644 --- a/internal/validators/validate.go +++ b/internal/validators/validate.go @@ -12,7 +12,7 @@ import ( func NewValidator() *validator.Validate { validate := validator.New() - validate.RegisterValidation("public_key", publicKeyValidation) + _ = validate.RegisterValidation("public_key", publicKeyValidation) validate.RegisterAlias("not_empty", "required") return validate } diff --git a/internal/validators/validate_test.go b/internal/validators/validate_test.go index dbf3850..5ddfd90 100644 --- a/internal/validators/validate_test.go +++ b/internal/validators/validate_test.go @@ -52,7 +52,7 @@ func TestParseValidationError(t *testing.T) { UnknownTagField: 1, }, expectedFieldErrors: map[string]interface{}{ - "requiredArrayField[1]": "This field cannot be empty e", + "requiredArrayField[1]": "This field cannot be empty", }, }, { From ac988909e9a291aacf79b84b2bb6bec393560087 Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Wed, 5 Jun 2024 12:05:43 -0300 Subject: [PATCH 3/7] deps: run go mod tidy --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 699aec5..ba15294 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,6 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect diff --git a/go.sum b/go.sum index 8a96699..7cf1d01 100644 --- a/go.sum +++ b/go.sum @@ -254,8 +254,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739 h1:ykXz+pRRTibcSjG1yRhpdSHInF8yZY/mfn+Rz2Nd1rE= -github.com/manucorporat/sse v0.0.0-20160126180136-ee05b128a739/go.mod h1:zUx1mhth20V3VKgL5jbd1BSQcW4Fy6Qs4PZvQwRFwzM= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= From 5f36d2c3c3d3f7493b2ed40072841a0491882715 Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Thu, 6 Jun 2024 14:08:11 -0300 Subject: [PATCH 4/7] chore: address PR comments --- internal/serve/httperror/errors.go | 4 +-- .../serve/httphandler/payments_handler.go | 21 +++----------- .../httphandler/request_body_validator.go | 11 ++++++++ internal/validators/validate.go | 28 +++++++++++++------ 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/internal/serve/httperror/errors.go b/internal/serve/httperror/errors.go index ad15a08..ae9d206 100644 --- a/internal/serve/httperror/errors.go +++ b/internal/serve/httperror/errors.go @@ -48,12 +48,12 @@ func BadRequest(message string, extras map[string]interface{}) *ErrorResponse { } } -func Unauthorized(message string, extras map[string]interface{}) ErrorResponse { +func Unauthorized(message string, extras map[string]interface{}) *ErrorResponse { if message == "" { message = "Not authorized." } - return ErrorResponse{ + return &ErrorResponse{ Status: http.StatusUnauthorized, Error: message, Extras: extras, diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index defa15e..b2d3d94 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -3,7 +3,6 @@ package httphandler import ( "net/http" - "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/wallet-backend/internal/data" "github.com/stellar/wallet-backend/internal/serve/httperror" ) @@ -20,19 +19,13 @@ func (h PaymentsHandler) SubscribeAddress(w http.ResponseWriter, r *http.Request ctx := r.Context() var reqBody PaymentsSubscribeRequest - err := httpdecode.DecodeJSON(r, &reqBody) - if err != nil { - httperror.BadRequest("Invalid request body", nil).Render(w) - return - } - - httpErr := ValidateRequestBody(ctx, reqBody) + httpErr := DecodeJSONAndValidate(ctx, r, &reqBody) if httpErr != nil { httpErr.Render(w) return } - err = h.PaymentModel.SubscribeAddress(ctx, reqBody.Address) + err := h.PaymentModel.SubscribeAddress(ctx, reqBody.Address) if err != nil { httperror.InternalServerError(ctx, "", err, nil).Render(w) return @@ -43,19 +36,13 @@ func (h PaymentsHandler) UnsubscribeAddress(w http.ResponseWriter, r *http.Reque ctx := r.Context() var reqBody PaymentsSubscribeRequest - err := httpdecode.DecodeJSON(r, &reqBody) - if err != nil { - httperror.BadRequest("Invalid request body", nil).Render(w) - return - } - - httpErr := ValidateRequestBody(ctx, reqBody) + httpErr := DecodeJSONAndValidate(ctx, r, &reqBody) if httpErr != nil { httpErr.Render(w) return } - err = h.PaymentModel.UnsubscribeAddress(ctx, reqBody.Address) + err := h.PaymentModel.UnsubscribeAddress(ctx, reqBody.Address) if err != nil { httperror.InternalServerError(ctx, "", err, nil).Render(w) return diff --git a/internal/serve/httphandler/request_body_validator.go b/internal/serve/httphandler/request_body_validator.go index 0c14b22..3cb8421 100644 --- a/internal/serve/httphandler/request_body_validator.go +++ b/internal/serve/httphandler/request_body_validator.go @@ -2,12 +2,23 @@ package httphandler import ( "context" + "net/http" "github.com/go-playground/validator/v10" + "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/wallet-backend/internal/serve/httperror" "github.com/stellar/wallet-backend/internal/validators" ) +func DecodeJSONAndValidate(ctx context.Context, req *http.Request, reqBody interface{}) *httperror.ErrorResponse { + err := httpdecode.DecodeJSON(req, reqBody) + if err != nil { + return httperror.BadRequest("Invalid request body.", nil) + } + + return ValidateRequestBody(ctx, reqBody) +} + func ValidateRequestBody[T any](ctx context.Context, reqBody T) *httperror.ErrorResponse { val := validators.NewValidator() if err := val.StructCtx(ctx, reqBody); err != nil { diff --git a/internal/validators/validate.go b/internal/validators/validate.go index 583e4b5..16f1725 100644 --- a/internal/validators/validate.go +++ b/internal/validators/validate.go @@ -3,11 +3,13 @@ package validators import ( "fmt" "reflect" + "strconv" "strings" "unicode" "github.com/go-playground/validator/v10" "github.com/stellar/go/strkey" + "github.com/stellar/go/support/log" ) func NewValidator() *validator.Validate { @@ -44,10 +46,23 @@ func msgForFieldError(fieldError validator.FieldError) string { return fmt.Sprintf("Unexpected value %q. Expected one of the following values: %s", fieldError.Value(), params) case "gt": if fieldError.Kind() == reflect.Slice || fieldError.Kind() == reflect.Array { - return "Should have at least 1 element" + v, err := strconv.Atoi(fieldError.Param()) + if err != nil { + log.Errorf(`Error parsing "gt" param %q to integer: %s`, fieldError.Param(), err.Error()) + return "Should have at least 1 element" // Fallback to this error message + } + return fmt.Sprintf("Should have at least %d element", v+1) // For instance, if "gt" is 0 (zero) then it should have at least 1 element } return fmt.Sprintf("Should be greater than %s", fieldError.Param()) case "gte": + if fieldError.Kind() == reflect.Slice || fieldError.Kind() == reflect.Array { + v, err := strconv.Atoi(fieldError.Param()) + if err != nil { + log.Errorf(`Error parsing "gte" param %q to integer: %s`, fieldError.Param(), err.Error()) + return "Should have at least 1 element" // Fallback to this error message + } + return fmt.Sprintf("Should have at least %d element", v) + } return fmt.Sprintf("Should be greater than or equal %s", fieldError.Param()) default: return "Invalid value" @@ -58,15 +73,10 @@ func getFieldName(fieldError validator.FieldError) string { // Ex.: structName.FieldName, structName.nestedStructName.nestedStructFieldName, structName.nestedStructName.nestedStructName.... namespace := strings.Split(fieldError.StructNamespace(), ".") length := len(namespace) - if length == 2 { - return lcFirst(namespace[1]) + if length <= 2 { + return lcFirst(namespace[length-1]) } - - if length > 2 { - return fmt.Sprintf("%s.%s", lcFirst(namespace[length-2]), lcFirst(namespace[length-1])) - } - - return lcFirst(namespace[0]) + return fmt.Sprintf("%s.%s", lcFirst(namespace[length-2]), lcFirst(namespace[length-1])) } // lcFirst lowers the case of the first letter of the given string. From db9b16252ab091dc6261ab5d1dca8f6749ca7a92 Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Fri, 7 Jun 2024 16:16:29 -0300 Subject: [PATCH 5/7] chore: address PR comments --- internal/serve/httperror/errors_test.go | 27 ++- .../httphandler/request_body_validator.go | 2 +- internal/validators/validate.go | 10 +- internal/validators/validate_test.go | 193 +++++++++++------- 4 files changed, 145 insertions(+), 87 deletions(-) diff --git a/internal/serve/httperror/errors_test.go b/internal/serve/httperror/errors_test.go index 95d01ae..52270b6 100644 --- a/internal/serve/httperror/errors_test.go +++ b/internal/serve/httperror/errors_test.go @@ -14,20 +14,29 @@ import ( func TestErrorResponseRender(t *testing.T) { testCases := []struct { - in ErrorResponse - want ErrorResponse + in ErrorResponse + want ErrorResponse + expectedResponseBody string }{ { - in: *InternalServerError(context.Background(), "", nil, nil), - want: ErrorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, + in: *InternalServerError(context.Background(), "", nil, nil), + want: ErrorResponse{Status: http.StatusInternalServerError, Error: "An error occurred while processing this request."}, + expectedResponseBody: `{"error": "An error occurred while processing this request."}`, }, { - in: NotFound, - want: ErrorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, + in: NotFound, + want: ErrorResponse{Status: http.StatusNotFound, Error: "The resource at the url requested was not found."}, + expectedResponseBody: `{"error": "The resource at the url requested was not found."}`, }, { - in: MethodNotAllowed, - want: ErrorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, + in: MethodNotAllowed, + want: ErrorResponse{Status: http.StatusMethodNotAllowed, Error: "The method is not allowed for resource at the url requested."}, + expectedResponseBody: `{"error": "The method is not allowed for resource at the url requested."}`, + }, + { + in: *BadRequest("Validation error.", map[string]interface{}{"field": "field error"}), + want: ErrorResponse{Status: http.StatusBadRequest, Error: "Validation error."}, + expectedResponseBody: `{"error": "Validation error.", "extras": {"field": "field error"}}`, }, } @@ -39,7 +48,7 @@ func TestErrorResponseRender(t *testing.T) { assert.Equal(t, tc.want.Status, resp.StatusCode) body, err := io.ReadAll(resp.Body) require.NoError(t, err) - assert.JSONEq(t, fmt.Sprintf(`{"error":%q}`, tc.want.Error), string(body)) + assert.JSONEq(t, tc.expectedResponseBody, string(body)) }) } } diff --git a/internal/serve/httphandler/request_body_validator.go b/internal/serve/httphandler/request_body_validator.go index 3cb8421..07a9a95 100644 --- a/internal/serve/httphandler/request_body_validator.go +++ b/internal/serve/httphandler/request_body_validator.go @@ -19,7 +19,7 @@ func DecodeJSONAndValidate(ctx context.Context, req *http.Request, reqBody inter return ValidateRequestBody(ctx, reqBody) } -func ValidateRequestBody[T any](ctx context.Context, reqBody T) *httperror.ErrorResponse { +func ValidateRequestBody(ctx context.Context, reqBody interface{}) *httperror.ErrorResponse { val := validators.NewValidator() if err := val.StructCtx(ctx, reqBody); err != nil { if vErrs, ok := err.(validator.ValidationErrors); ok { diff --git a/internal/validators/validate.go b/internal/validators/validate.go index 16f1725..00c6120 100644 --- a/internal/validators/validate.go +++ b/internal/validators/validate.go @@ -63,7 +63,7 @@ func msgForFieldError(fieldError validator.FieldError) string { } return fmt.Sprintf("Should have at least %d element", v) } - return fmt.Sprintf("Should be greater than or equal %s", fieldError.Param()) + return fmt.Sprintf("Should be greater than or equal to %s", fieldError.Param()) default: return "Invalid value" } @@ -76,7 +76,13 @@ func getFieldName(fieldError validator.FieldError) string { if length <= 2 { return lcFirst(namespace[length-1]) } - return fmt.Sprintf("%s.%s", lcFirst(namespace[length-2]), lcFirst(namespace[length-1])) + + // we remove the root struct name + relevantNamespace := namespace[1:] + for i := range relevantNamespace { + relevantNamespace[i] = lcFirst(relevantNamespace[i]) + } + return strings.Join(relevantNamespace, ".") } // lcFirst lowers the case of the first letter of the given string. diff --git a/internal/validators/validate_test.go b/internal/validators/validate_test.go index 5ddfd90..443308d 100644 --- a/internal/validators/validate_test.go +++ b/internal/validators/validate_test.go @@ -10,93 +10,136 @@ import ( ) func TestParseValidationError(t *testing.T) { - type testStructNested struct { - NestedRequiredField string `validate:"required"` - NestedEnumField string `validate:"oneof=foo bar"` - } - type testStruct struct { - RequiredField string `validate:"required"` - RequiredArrayField []string `validate:"required,gt=0,dive,not_empty"` - EnumField string `validate:"oneof=foo bar"` - PublicKeyField string `validate:"public_key"` - UnknownTagField int `validate:"lte=1"` - NestedField []testStructNested `validate:"dive"` - } + t.Run("general_tests", func(t *testing.T) { + type testStructNested struct { + NestedRequiredField string `validate:"required"` + NestedEnumField string `validate:"oneof=foo bar"` + } - testCases := []struct { - stc *testStruct - expectedFieldErrors map[string]interface{} - }{ - { - stc: &testStruct{ - RequiredField: "", - RequiredArrayField: []string{}, - EnumField: "invalid", - PublicKeyField: "invalid", - UnknownTagField: 1, - }, - expectedFieldErrors: map[string]interface{}{ - "requiredField": "This field is required", - "requiredArrayField": "Should have at least 1 element", - "enumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, - "publicKeyField": "Invalid public key provided", - }, - }, - { - stc: &testStruct{ - RequiredField: "foo", - RequiredArrayField: []string{"bar", ""}, - EnumField: "bar", - PublicKeyField: keypair.MustRandom().Address(), - UnknownTagField: 1, - }, - expectedFieldErrors: map[string]interface{}{ - "requiredArrayField[1]": "This field cannot be empty", + type testStruct struct { + RequiredField string `validate:"required"` + RequiredArrayField []string `validate:"required,gt=0,dive,not_empty"` + EnumField string `validate:"oneof=foo bar"` + PublicKeyField string `validate:"public_key"` + UnknownTagField int `validate:"lte=1"` + NestedField []testStructNested `validate:"dive"` + } + + testCases := []struct { + stc *testStruct + expectedFieldErrors map[string]interface{} + }{ + { + stc: &testStruct{ + RequiredField: "", + RequiredArrayField: []string{}, + EnumField: "invalid", + PublicKeyField: "invalid", + UnknownTagField: 1, + }, + expectedFieldErrors: map[string]interface{}{ + "requiredField": "This field is required", + "requiredArrayField": "Should have at least 1 element", + "enumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, + "publicKeyField": "Invalid public key provided", + }, }, - }, - { - stc: &testStruct{ - RequiredField: "foo", - RequiredArrayField: []string{"bar"}, - EnumField: "bar", - PublicKeyField: keypair.MustRandom().Address(), - UnknownTagField: 2, + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar", ""}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 1, + }, + expectedFieldErrors: map[string]interface{}{ + "requiredArrayField[1]": "This field cannot be empty", + }, }, - expectedFieldErrors: map[string]interface{}{ - "unknownTagField": "Invalid value", + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar"}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 2, + }, + expectedFieldErrors: map[string]interface{}{ + "unknownTagField": "Invalid value", + }, }, - }, - { - stc: &testStruct{ - RequiredField: "foo", - RequiredArrayField: []string{"bar"}, - EnumField: "bar", - PublicKeyField: keypair.MustRandom().Address(), - UnknownTagField: 1, - NestedField: []testStructNested{ - { - NestedRequiredField: "", - NestedEnumField: "invalid", + { + stc: &testStruct{ + RequiredField: "foo", + RequiredArrayField: []string{"bar"}, + EnumField: "bar", + PublicKeyField: keypair.MustRandom().Address(), + UnknownTagField: 1, + NestedField: []testStructNested{ + { + NestedRequiredField: "", + NestedEnumField: "invalid", + }, }, }, + expectedFieldErrors: map[string]interface{}{ + "nestedField[0].nestedRequiredField": "This field is required", + "nestedField[0].nestedEnumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, + }, }, - expectedFieldErrors: map[string]interface{}{ - "nestedField[0].nestedRequiredField": "This field is required", - "nestedField[0].nestedEnumField": `Unexpected value "invalid". Expected one of the following values: foo, bar`, - }, - }, - } + } - val := NewValidator() - for _, tc := range testCases { - err := val.Struct(tc.stc) + val := NewValidator() + for _, tc := range testCases { + err := val.Struct(tc.stc) + require.Error(t, err) + vErrs, ok := err.(validator.ValidationErrors) + require.True(t, ok) + fieldErrors := ParseValidationError(vErrs) + assert.Equal(t, tc.expectedFieldErrors, fieldErrors) + } + }) + + t.Run("gt_and_gte", func(t *testing.T) { + type testStruct struct { + AmountGT int64 `validate:"gt=10"` + AmountGTE int64 `validate:"gte=11"` + SliceGT []string `validate:"gt=2"` + SliceGTE []string `validate:"gte=2"` + } + + stc := testStruct{ + AmountGT: 10, + AmountGTE: 10, + SliceGT: []string{"a", "b"}, + SliceGTE: []string{"a"}, + } + val := NewValidator() + err := val.Struct(stc) require.Error(t, err) vErrs, ok := err.(validator.ValidationErrors) require.True(t, ok) fieldErrors := ParseValidationError(vErrs) - assert.Equal(t, tc.expectedFieldErrors, fieldErrors) - } + assert.Equal(t, map[string]interface{}{ + "amountGT": "Should be greater than 10", + "amountGTE": "Should be greater than or equal to 11", + "sliceGT": "Should have at least 3 element", + "sliceGTE": "Should have at least 2 element", + }, fieldErrors) + + type testStructInvalid struct { + InvalidGT int64 `validate:"gt=a"` + InvalidGTE int64 `validate:"gte=a"` + } + stcInvalid := testStructInvalid{ + InvalidGT: 0, + InvalidGTE: 0, + } + assert.Panics(t, func() { + val.Struct(stcInvalid) + }) + }) } func TestGetFieldName(t *testing.T) { @@ -147,7 +190,7 @@ func TestGetFieldName(t *testing.T) { require.Len(t, vErrs, 2) assert.Equal(t, "publicKey", getFieldName(vErrs[0])) - assert.Equal(t, "children[0].name", getFieldName(vErrs[1])) + assert.Equal(t, "nestedField[0].children[0].children[1].children[0].name", getFieldName(vErrs[1])) } func TestLCFist(t *testing.T) { From aaab916ad7383bd9e27cb1cda9dc2b0979547fe9 Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Fri, 7 Jun 2024 16:19:21 -0300 Subject: [PATCH 6/7] lint: fix lint error --- internal/validators/validate_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/validators/validate_test.go b/internal/validators/validate_test.go index 443308d..06e1527 100644 --- a/internal/validators/validate_test.go +++ b/internal/validators/validate_test.go @@ -137,7 +137,8 @@ func TestParseValidationError(t *testing.T) { InvalidGTE: 0, } assert.Panics(t, func() { - val.Struct(stcInvalid) + err := val.Struct(stcInvalid) + require.Error(t, err) }) }) } From 17fce5637df4cfc4e7c791467991d70e98b723bb Mon Sep 17 00:00:00 2001 From: Caio Teixeira Date: Fri, 7 Jun 2024 16:34:19 -0300 Subject: [PATCH 7/7] refactor: remove unnecessary validation --- internal/validators/validate.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/validators/validate.go b/internal/validators/validate.go index 00c6120..506c6e1 100644 --- a/internal/validators/validate.go +++ b/internal/validators/validate.go @@ -72,10 +72,6 @@ func msgForFieldError(fieldError validator.FieldError) string { func getFieldName(fieldError validator.FieldError) string { // Ex.: structName.FieldName, structName.nestedStructName.nestedStructFieldName, structName.nestedStructName.nestedStructName.... namespace := strings.Split(fieldError.StructNamespace(), ".") - length := len(namespace) - if length <= 2 { - return lcFirst(namespace[length-1]) - } // we remove the root struct name relevantNamespace := namespace[1:]