From 8dafed34507ed1731a3b000c28c2fcc9f29b99e7 Mon Sep 17 00:00:00 2001 From: Rafael Piovesan Date: Fri, 24 Dec 2021 17:33:52 -0300 Subject: [PATCH] feat(http): add http port --- api/http/handler/handler.go | 51 +++++++ api/http/handler/ride.go | 80 +++++++++++ api/http/handler/ride_test.go | 252 ++++++++++++++++++++++++++++++++++ api/http/middleware/user.go | 55 ++++++++ api/http/server.go | 66 +++++++++ entity/errors.go | 1 + 6 files changed, 505 insertions(+) create mode 100644 api/http/handler/handler.go create mode 100644 api/http/handler/ride.go create mode 100644 api/http/handler/ride_test.go create mode 100644 api/http/middleware/user.go create mode 100644 api/http/server.go diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go new file mode 100644 index 0000000..03a953c --- /dev/null +++ b/api/http/handler/handler.go @@ -0,0 +1,51 @@ +package handler + +import ( + "net/http" + + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/rafael-piovesan/go-rocket-ride/entity" +) + +type Handler struct { + binder *echo.DefaultBinder + validate *validator.Validate +} + +func New() *Handler { + return &Handler{ + binder: &echo.DefaultBinder{}, + validate: validator.New(), + } +} + +// BindAndValidate binds and validates the data struct pointed to by 'i'. +// It expects a stuct pointer as parameter, since it needs to populate its +// fields, otherwise it'll panic. +func (h *Handler) BindAndValidate(c echo.Context, i interface{}) error { + if i == nil { + return nil + } + + if err := h.binder.BindHeaders(c, i); err != nil { + return err + } + + if err := h.binder.BindBody(c, i); err != nil { + return err + } + + if err := h.validate.Struct(i); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return nil +} + +func (h *Handler) GetUserID(c echo.Context) (uid int64, err error) { + uid, ok := c.Get("user-id").(int64) + if !ok { + err = echo.NewHTTPError(http.StatusUnauthorized, entity.ErrPermissionDenied.Error()) + } + return +} diff --git a/api/http/handler/ride.go b/api/http/handler/ride.go new file mode 100644 index 0000000..0de4a39 --- /dev/null +++ b/api/http/handler/ride.go @@ -0,0 +1,80 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/labstack/echo/v4" + rocketride "github.com/rafael-piovesan/go-rocket-ride" + "github.com/rafael-piovesan/go-rocket-ride/entity" +) + +type createRequest struct { + IdemKey string `json:"idem_key" header:"idempotency-key" validate:"required,max=100"` + OrigLat float64 `json:"origin_lat" validate:"min=-90,max=90"` + OrigLon float64 `json:"origin_lon" validate:"min=-180,max=180"` + TgtLat float64 `json:"target_lat" validate:"min=-90,max=90"` + TgtLon float64 `json:"target_lon" validate:"min=-180,max=180"` +} + +type RideHanlder struct { + *Handler + uc rocketride.RideUseCase +} + +func NewRideHandler(uc rocketride.RideUseCase) *RideHanlder { + return &RideHanlder{ + Handler: New(), + uc: uc, + } +} + +func (r *RideHanlder) Create(c echo.Context) error { + userID, err := r.GetUserID(c) + if err != nil { + return err + } + + cr := createRequest{} + if err := r.BindAndValidate(c, &cr); err != nil { + return err + } + + rd := &entity.Ride{ + OriginLat: cr.OrigLat, + OriginLon: cr.OrigLon, + TargetLat: cr.TgtLat, + TargetLon: cr.TgtLon, + } + + rp, _ := json.Marshal(rd) + + ik := &entity.IdempotencyKey{ + CreatedAt: time.Now().UTC(), + IdempotencyKey: cr.IdemKey, + UserID: userID, + RequestMethod: c.Request().Method, + RequestPath: c.Request().RequestURI, + RequestParams: rp, + } + + ik, err = r.uc.Create(c.Request().Context(), ik, rd) + + if errors.Is(err, entity.ErrIdemKeyParamsMismatch) || errors.Is(err, entity.ErrIdemKeyRequestInProgress) { + return c.String(http.StatusConflict, err.Error()) + } + + if err != nil || ik.ResponseCode == nil || ik.ResponseBody == nil { + return c.String(http.StatusInternalServerError, "internal error") + } + + rCode := int(*ik.ResponseCode) + rBody, err := json.Marshal(*ik.ResponseBody) + if err != nil { + return c.String(http.StatusInternalServerError, "internal error") + } + + return c.JSONBlob(rCode, rBody) +} diff --git a/api/http/handler/ride_test.go b/api/http/handler/ride_test.go new file mode 100644 index 0000000..6552c45 --- /dev/null +++ b/api/http/handler/ride_test.go @@ -0,0 +1,252 @@ +//go:build unit +// +build unit + +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/labstack/echo/v4" + "github.com/rafael-piovesan/go-rocket-ride/entity" + "github.com/rafael-piovesan/go-rocket-ride/entity/idempotency" + "github.com/rafael-piovesan/go-rocket-ride/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreate(t *testing.T) { + e := echo.New() + uc := &mocks.RideUseCase{} + handler := NewRideHandler(uc) + + callArgs := []interface{}{ + mock.Anything, + mock.AnythingOfType("*entity.IdempotencyKey"), + mock.AnythingOfType("*entity.Ride"), + } + + t.Run("User info not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + // context is missing the "user-id" key + c := e.NewContext(req, rec) + + err := handler.Create(c) + + if assert.Error(t, err) && assert.IsType(t, (&echo.HTTPError{}), err) { + he := (err).(*echo.HTTPError) + assert.Equal(t, http.StatusUnauthorized, he.Code) + assert.Equal(t, entity.ErrPermissionDenied.Error(), he.Message) + } + }) + + t.Run("Header input validation", func(t *testing.T) { + payload := `{"origin_lat": 0.0, "origin_lon": 0.0, "target_lat": 0.0, "target_lon": 0.0}` + tests := []struct { + wantErr bool + header string + value string + }{ + {wantErr: true, header: gofakeit.AnimalType(), value: gofakeit.LetterN(uint(gofakeit.Number(101, 1000)))}, + {wantErr: true, header: gofakeit.AnimalType(), value: gofakeit.LetterN(uint(gofakeit.Number(0, 100)))}, + {wantErr: true, header: "idempotency-key", value: gofakeit.LetterN(uint(gofakeit.Number(101, 1000)))}, + {wantErr: false, header: "idempotency-key", value: gofakeit.LetterN(uint(gofakeit.Number(0, 100)))}, + } + for _, tc := range tests { + if !tc.wantErr { + rCode := idempotency.ResponseCodeOK + rBody := idempotency.ResponseBody{Message: "ok"} + + ik := &entity.IdempotencyKey{ + ResponseCode: &rCode, + ResponseBody: &rBody, + } + uc.On("Create", callArgs...).Once().Return(ik, nil) + } + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + // testing different header values + req.Header.Set(tc.header, tc.value) + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user-id", int64(0)) + + err := handler.Create(c) + + if tc.wantErr { + if assert.Error(t, err) && assert.IsType(t, (&echo.HTTPError{}), err) { + he := (err).(*echo.HTTPError) + assert.Equal(t, http.StatusBadRequest, he.Code) + } + } else { + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "{\"message\":\"ok\"}", rec.Body.String()) + } + } + } + }) + + t.Run("Body input validation", func(t *testing.T) { + tpl := `{"origin_lat": %v, "origin_lon": %v, "target_lat": %v, "target_lon": %v}` + tests := []struct { + wantErr bool + payload string + }{ + {wantErr: true, payload: fmt.Sprintf(tpl, -90.0000000001, 0.0, 0.0, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 90.0000000001, 0.0, 0.0, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, -180.0000000001, 0.0, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, 180.0000000001, 0.0, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, 0.0, -90.0000000001, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, 0.0, 90.0000000001, 0.0)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, 0.0, 0.0, -180.0000000001)}, + {wantErr: true, payload: fmt.Sprintf(tpl, 0.0, 0.0, 0.0, 180.0000000001)}, + + {wantErr: false, payload: fmt.Sprintf(tpl, -90.0, 0.0, 0.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 90.0, 0.0, 0.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, -180.0, 0.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, 180.0, 0.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, 0.0, -90.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, 0.0, 90.0, 0.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, 0.0, 0.0, -180.0)}, + {wantErr: false, payload: fmt.Sprintf(tpl, 0.0, 0.0, 0.0, 180.0)}, + } + for _, tc := range tests { + if !tc.wantErr { + rCode := idempotency.ResponseCodeOK + rBody := idempotency.ResponseBody{Message: "ok"} + + ik := &entity.IdempotencyKey{ + ResponseCode: &rCode, + ResponseBody: &rBody, + } + uc.On("Create", callArgs...).Once().Return(ik, nil) + } + + // testing different payload values + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.payload)) + + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("idempotency-key", gofakeit.UUID()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user-id", int64(0)) + + err := handler.Create(c) + + if tc.wantErr { + if assert.Error(t, err) && assert.IsType(t, (&echo.HTTPError{}), err) { + he := (err).(*echo.HTTPError) + assert.Equal(t, http.StatusBadRequest, he.Code) + } + } else { + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "{\"message\":\"ok\"}", rec.Body.String()) + } + } + } + }) + + t.Run("Error on create ride", func(t *testing.T) { + payload := `{"origin_lat": 0.0, "origin_lon": 0.0, "target_lat": 0.0, "target_lon": 0.0}` + rCode := idempotency.ResponseCodeOK + rBody := idempotency.ResponseBody{Message: "filled"} + tests := []struct { + desc string + retArgs []interface{} + retCode int + retMsg string + }{ + { + desc: "error on idempotency key params mismatch", + retArgs: []interface{}{nil, entity.ErrIdemKeyParamsMismatch}, + retCode: http.StatusConflict, + retMsg: entity.ErrIdemKeyParamsMismatch.Error(), + }, + { + desc: "error on idempotency key request in progress", + retArgs: []interface{}{nil, entity.ErrIdemKeyRequestInProgress}, + retCode: http.StatusConflict, + retMsg: entity.ErrIdemKeyRequestInProgress.Error(), + }, + { + desc: "generic error on create ride uc", + retArgs: []interface{}{nil, errors.New("error CreateRide")}, + retCode: http.StatusInternalServerError, + retMsg: "internal error", + }, + { + desc: "empty idempotency key response code", + retArgs: []interface{}{&entity.IdempotencyKey{ResponseBody: &rBody}, nil}, + retCode: http.StatusInternalServerError, + retMsg: "internal error", + }, + { + desc: "empty idempotency key response body", + retArgs: []interface{}{&entity.IdempotencyKey{ResponseCode: &rCode}, nil}, + retCode: http.StatusInternalServerError, + retMsg: "internal error", + }, + } + + for _, tc := range tests { + uc.On("Create", callArgs...).Once().Return(tc.retArgs...) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("idempotency-key", gofakeit.UUID()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user-id", int64(0)) + + err := handler.Create(c) + + if assert.NoError(t, err, tc.desc) { + assert.Equal(t, tc.retCode, rec.Code, tc.desc) + assert.Equal(t, tc.retMsg, rec.Body.String(), tc.desc) + } + } + }) + + t.Run("Success on create ride", func(t *testing.T) { + payload := `{"origin_lat": 0.0, "origin_lon": 0.0, "target_lat": 0.0, "target_lon": 0.0}` + rCode := idempotency.ResponseCodeOK + rBody := idempotency.ResponseBody{Message: gofakeit.UUID()} + body, err := json.Marshal(rBody) + require.NoError(t, err) + + ik := &entity.IdempotencyKey{ + ResponseCode: &rCode, + ResponseBody: &rBody, + } + uc.On("Create", callArgs...).Once().Return(ik, nil) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("idempotency-key", gofakeit.UUID()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.Set("user-id", int64(0)) + + err = handler.Create(c) + + if assert.NoError(t, err) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, body, rec.Body.Bytes()) + } + }) +} diff --git a/api/http/middleware/user.go b/api/http/middleware/user.go new file mode 100644 index 0000000..820986c --- /dev/null +++ b/api/http/middleware/user.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + rocketride "github.com/rafael-piovesan/go-rocket-ride" + "github.com/rafael-piovesan/go-rocket-ride/entity" +) + +type userRequest struct { + UserKey string `header:"http-authorization" validate:"required"` +} + +type UserMiddleware struct { + binder *echo.DefaultBinder + validate *validator.Validate + store rocketride.Datastore +} + +func NewUserMiddleware(ds rocketride.Datastore) *UserMiddleware { + return &UserMiddleware{ + binder: &echo.DefaultBinder{}, + validate: validator.New(), + store: ds, + } +} + +func (u *UserMiddleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ur := userRequest{} + if err := u.binder.BindHeaders(c, &ur); err != nil { + return err + } + + if err := u.validate.Struct(&ur); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // This is obviously something you shouldn't do in a real application, but for + // now we're just going to trust that the user is whoever they said they were + // from an email in the `Authorization` header. + user, err := u.store.GetUserByEmail(c.Request().Context(), ur.UserKey) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, entity.ErrPermissionDenied.Error()) + } + + // FIXME: probably it's not ideal to leave the key fixed. Find a place to set + // it as a constant. + c.Set("user-id", user.ID) + + return next(c) + } +} diff --git a/api/http/server.go b/api/http/server.go new file mode 100644 index 0000000..eb6bd50 --- /dev/null +++ b/api/http/server.go @@ -0,0 +1,66 @@ +package http + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + rocketride "github.com/rafael-piovesan/go-rocket-ride" + "github.com/rafael-piovesan/go-rocket-ride/api/http/handler" + cstmiddleware "github.com/rafael-piovesan/go-rocket-ride/api/http/middleware" + "github.com/rafael-piovesan/go-rocket-ride/usecase" +) + +type Server struct { + e *echo.Echo + cfg rocketride.Config +} + +func NewServer(cfg rocketride.Config, store rocketride.Datastore) *Server { + e := echo.New() + + // Middleware + um := cstmiddleware.NewUserMiddleware(store) + e.Use(um.Handle) + + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Handlers + ride := usecase.NewRideUseCase(cfg, store) + rideHandler := handler.NewRideHandler(ride) + + // Routes + e.POST("/", rideHandler.Create) + + return &Server{ + e: e, + cfg: cfg, + } +} + +func (s *Server) Start() { + // Start server + go func() { + if err := s.e.Start(s.cfg.ServerAddress); err != nil && err != http.ErrServerClosed { + s.e.Logger.Fatal("shutting down the server") + } + }() + + // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. + // Use a buffered channel to avoid missing signals as recommended for signal.Notify + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + signal.Notify(quit, syscall.SIGTERM) + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.e.Shutdown(ctx); err != nil { + s.e.Logger.Fatal(err) + } +} diff --git a/entity/errors.go b/entity/errors.go index 81c2b58..4087c74 100644 --- a/entity/errors.go +++ b/entity/errors.go @@ -3,6 +3,7 @@ package entity import "errors" var ( + ErrPermissionDenied = errors.New("permission denied") ErrNotFound = errors.New("entity not found") ErrIdemKeyParamsMismatch = errors.New("params mismatch") ErrIdemKeyRequestInProgress = errors.New("request in progress")