diff --git a/apperrors/apperrors.go b/apperrors/apperrors.go new file mode 100644 index 0000000..2704665 --- /dev/null +++ b/apperrors/apperrors.go @@ -0,0 +1,68 @@ +package apperrors + +import ( + "encoding/json" + "errors" + l "github.com/sirupsen/logrus" + "net/http" +) + +// ErrorStruct - struct used to convert error messages into required JSON format +type ErrorStruct struct { + Message string `json:"message,omitempty"` //Error Message + Status int `json:"status,omitempty"` //HTTP Response status code +} + +// Error - prints out an error +func Error(appError error, msg string, triggeringError error) { + l.WithFields(l.Fields{"appError": appError, "message": msg}).Error(triggeringError) +} + +// Warn - for warnings +func Warn(appError error, msg string, triggeringError error) { + l.WithFields(l.Fields{"appError": appError, "message": msg}).Warn(triggeringError) +} + +// JSONError - This function writes out an error response with the status +// header passed in +func JSONError(rw http.ResponseWriter, status int, err error) { + + errObj := ErrorStruct{ + Message: err.Error(), + Status: status, + } + + errJSON, err := json.Marshal(&errObj) + if err != nil { + Warn(err, "Error in AppErrors marshalling JSON", err) + } + rw.WriteHeader(status) + rw.Header().Set("Content-Type", "application/json") + rw.Write(errJSON) + return +} + +// ErrRecordNotFound - for when a database record isn't found +var ErrRecordNotFound = errors.New("Database record not found") + +// ErrInvalidToken - used when a JSON Web Token ("JWT") cannot be validated +// by the JWT library +var ErrInvalidToken = errors.New("Invalid Token") + +// ErrSignedString - failed to sign the token string +var ErrSignedString = errors.New("Failed to sign token string") + +// ErrMissingAuthHeader - When the HTTP request doesn't contain an 'Authorization' header +var ErrMissingAuthHeader = errors.New("Missing Auth header") + +// ErrJSONParseFail - If json.Unmarshal or json.Marshal returns an error +var ErrJSONParseFail = errors.New("Failed to parse JSON response (likely not valid JSON)") + +// ErrNoSigningKey - there isn't a signing key defined in the app configuration +var ErrNoSigningKey = errors.New("no JWT signing key specified; cannot authenticate users. Define JWT_SECRET in application.yml and restart") + +// ErrFailedToCreate - Record Creation Failed +var ErrFailedToCreate = errors.New("Failed to create database record") + +// ErrUnknown - Generic Error For Unknown Errors +var ErrUnknown = errors.New("unknown/unexpected error has occurred") diff --git a/application.yml.default b/application.yml.default index 2dec452..67cc4c3 100644 --- a/application.yml.default +++ b/application.yml.default @@ -1,4 +1,4 @@ -APP_NAME: "samplemgr" +APP_NAME: "app" APP_PORT: "33001" # MongoDB URI @@ -6,4 +6,4 @@ APP_PORT: "33001" #DB_NAME: "dbname" # Postgres -DB_URI: "postgresql://user:password@localhost:5432/dbname?sslmode=disable" +DB_URI: "postgresql://user:password@localhost:5432/dbname?sslmode=disable" \ No newline at end of file diff --git a/config/config.go b/config/config.go index af428d1..6d6d0c0 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "strconv" @@ -9,12 +8,15 @@ import ( ) var ( - appName string - appPort int + appName string + appPort int + jwtKey string + jwtExpiryDurationHours int ) +// Load - loads all the environment variables and/or params in application.yml func Load() { - viper.SetDefault("APP_NAME", "app") + viper.SetDefault("APP_NAME", "e-commerce") viper.SetDefault("APP_PORT", "8002") viper.SetConfigName("application") @@ -24,8 +26,12 @@ func Load() { viper.AddConfigPath("./../..") viper.ReadInConfig() viper.AutomaticEnv() + // Check for the presence of JWT_KEY and JWT_EXPIRY_DURATION_HOURS + JWTKey() + JWTExpiryDurationHours() } +// AppName - returns the app name func AppName() string { if appName == "" { appName = ReadEnvString("APP_NAME") @@ -33,6 +39,7 @@ func AppName() string { return appName } +// AppPort - returns application http port func AppPort() int { if appPort == 0 { appPort = ReadEnvInt("APP_PORT") @@ -40,6 +47,17 @@ func AppPort() int { return appPort } +// JWTKey - returns the JSON Web Token key +func JWTKey() []byte { + return []byte(ReadEnvString("JWT_SECRET")) +} + +// JWTExpiryDurationHours - returns duration for jwt expiry in int +func JWTExpiryDurationHours() int { + return int(ReadEnvInt("JWT_EXPIRY_DURATION_HOURS")) +} + +// ReadEnvInt - reads an environment variable as an integer func ReadEnvInt(key string) int { checkIfSet(key) v, err := strconv.Atoi(viper.GetString(key)) @@ -49,19 +67,22 @@ func ReadEnvInt(key string) int { return v } +// ReadEnvString - reads an environment variable as a string func ReadEnvString(key string) string { checkIfSet(key) return viper.GetString(key) } +// ReadEnvBool - reads environment variable as a boolean func ReadEnvBool(key string) bool { checkIfSet(key) return viper.GetBool(key) } +//CheckIfSet checks if all the necessary keys are set func checkIfSet(key string) { if !viper.IsSet(key) { - err := errors.New(fmt.Sprintf("Key %s is not set", key)) + err := fmt.Errorf("Key %s is not set", key) panic(err) } } diff --git a/db/cart.go b/db/cart.go new file mode 100644 index 0000000..45da687 --- /dev/null +++ b/db/cart.go @@ -0,0 +1,51 @@ +package db + +import( + "context" + logger "github.com/sirupsen/logrus" +) + +func (s *pgStore) AddToCart(ctx context.Context, cartID, productID int) (rowsAffected int64, err error) { + insert := `INSERT INTO cart (id, product_id, quantity) VALUES ($1, $2, 1)` + result, err := s.db.Exec(insert, cartID, productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error adding to cart") + return + } + + rowsAffected, err = result.RowsAffected() + if err != nil { + logger.WithField("err", err.Error()).Error("Error while fetching affected rows") + } + return +} + +func (s *pgStore) DeleteFromCart(ctx context.Context, cartID, productID int) (rowsAffected int64, err error) { + delete := `DELETE FROM cart WHERE id = $1 AND product_id = $2` + result, err := s.db.Exec(delete, cartID, productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while removing from cart") + return + } + + rowsAffected, err = result.RowsAffected() + if err != nil { + logger.WithField("err", err.Error()).Error("Error while fetching affected rows") + } + return +} + +func (s *pgStore) UpdateIntoCart(ctx context.Context, quantity, cartID, productID int) (rowsAffected int64, err error) { + update := `UPDATE cart SET quantity = $1 WHERE id = $2 AND product_id = $3` + result, err := s.db.Exec(update, quantity, cartID, productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while updating into cart") + return + } + + rowsAffected, err = result.RowsAffected() + if err != nil { + logger.WithField("err", err.Error()).Error("Error while fetching affected rows") + } + return +} \ No newline at end of file diff --git a/db/cart_test.go b/db/cart_test.go new file mode 100644 index 0000000..9ae4843 --- /dev/null +++ b/db/cart_test.go @@ -0,0 +1,84 @@ +package db + +import ( + "context" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CartTestSuite struct { + suite.Suite + dbStore Storer + db *sqlx.DB + sqlmock sqlmock.Sqlmock +} + +func (suite *CartTestSuite) SetupTest() { + dbStore, dbConn, sqlmock := InitMockDB() + suite.dbStore = dbStore + suite.db = dbConn + suite.sqlmock = sqlmock +} + +func (suite *CartTestSuite) TestAddToCartSuccess() { + suite.sqlmock.ExpectExec("INSERT INTO cart (id, product_id, quantity) VALUES ($1, $2, 1)"). + WithArgs(1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + result, err := suite.dbStore.AddToCart(context.Background(), 1, 100) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), result, int64(1)) +} + +func (suite *CartTestSuite) TestAddToCartFailure() { + suite.db.Close() + suite.sqlmock.ExpectExec("INSERT INTO cart (id, product_id, quantity) VALUES ($1, $2, 1)"). + WithArgs(1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + _, err := suite.dbStore.AddToCart(context.Background(), 1, 100) + assert.NotNil(suite.T(), err) +} + +func (suite *CartTestSuite) TestDeleteFromCartSuccess() { + suite.sqlmock.ExpectExec("DELETE FROM cart WHERE id = $1 AND product_id = $2"). + WithArgs(1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + result, err := suite.dbStore.DeleteFromCart(context.Background(), 1, 100) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), result, int64(1)) +} + +func (suite *CartTestSuite) TestDeleteFromCartFailure() { + suite.db.Close() + suite.sqlmock.ExpectExec("DELETE FROM cart WHERE id = $1 AND product_id = $2"). + WithArgs(1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + _, err := suite.dbStore.DeleteFromCart(context.Background(), 1, 100) + assert.NotNil(suite.T(), err) +} + +func (suite *CartTestSuite) TestUpdateIntoCartSuccess() { + suite.sqlmock.ExpectExec("UPDATE cart SET quantity = $1 WHERE id = $2 AND product_id = $3"). + WithArgs(3,1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + result, err := suite.dbStore.UpdateIntoCart(context.Background(), 3, 1, 100) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), result, int64(1)) +} + +func (suite *CartTestSuite) TestUpdateIntoCartFailure() { + suite.db.Close() + suite.sqlmock.ExpectExec("UPDATE cart SET quantity = $1 WHERE id = $2 AND product_id = $3"). + WithArgs(3,1,100). + WillReturnResult(sqlmock.NewResult(1,1)) + + _, err := suite.dbStore.UpdateIntoCart(context.Background(), 3, 1, 100) + assert.NotNil(suite.T(), err) +} \ No newline at end of file diff --git a/db/common_test.go b/db/common_test.go new file mode 100644 index 0000000..da2a860 --- /dev/null +++ b/db/common_test.go @@ -0,0 +1,31 @@ +package db + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + logger "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" +) + +func InitMockDB() (s Storer, sqlConn *sqlx.DB, sqlmockInstance sqlmock.Sqlmock) { + // previosly sqlmock.New() which gives error : not able to match sql queries ,so adding these parameters : sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual) to sqlmock.New allows complex queries like Join to be matched + mockDB, sqlmock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + logger.WithField("err:", err).Error("error initializing mock db") + return + } + + sqlmockInstance = sqlmock + sqlxDB := sqlx.NewDb(mockDB, "sqlmock") + + var pgStoreConn pgStore + pgStoreConn.db = sqlxDB + + return &pgStoreConn, sqlxDB, sqlmockInstance +} + +func TestExampleTestSuite(t *testing.T) { + suite.Run(t, new(CartTestSuite)) +} \ No newline at end of file diff --git a/db/db.go b/db/db.go index 2d1556a..a6ed1a7 100644 --- a/db/db.go +++ b/db/db.go @@ -4,8 +4,17 @@ import ( "context" ) +// Storer - an interface we use to expose methods that do stuff to the underlying database type Storer interface { + AddToCart(context.Context, int, int) (int64, error) + DeleteFromCart(context.Context, int, int) (int64, error) + UpdateIntoCart(context.Context, int, int, int) (int64, error) + ListUsers(context.Context) ([]User, error) + AuthenticateUser(context.Context, User) (User, error) + GetUser(context.Context, int) (User, error) + CreateBlacklistedToken(context.Context, BlacklistedToken) error + CheckBlacklistedToken(context.Context, string) (bool, int) //Create(context.Context, User) error //GetUser(context.Context) (User, error) //Delete(context.Context, string) error diff --git a/db/mock.go b/db/mock.go index a8047e7..d60dc83 100644 --- a/db/mock.go +++ b/db/mock.go @@ -14,3 +14,38 @@ func (m *DBMockStore) ListUsers(ctx context.Context) (users []User, err error) { args := m.Called(ctx) return args.Get(0).([]User), args.Error(1) } + +func (m *DBMockStore) AddToCart(ctx context.Context, cartID, productID int) (rowsAffected int64, err error){ + args := m.Called(ctx, cartID, productID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *DBMockStore) DeleteFromCart(ctx context.Context, cartID int, productID int) (rowsAffected int64, err error){ + args := m.Called(ctx, cartID, productID) + return int64(args.Int(0)), args.Error(1) +} + +func (m *DBMockStore) UpdateIntoCart(ctx context.Context, quantity int, cartID int, productID int) (rowsAffected int64,err error){ + args := m.Called(ctx, cartID, productID, quantity) + return int64(args.Int(0)), args.Error(1) +} + +func (m *DBMockStore) AuthenticateUser(ctx context.Context, user User) (validUser User, err error){ + args := m.Called(ctx, user) + return args.Get(0).(User), args.Error(1) +} + +func (m *DBMockStore) CreateBlacklistedToken(ctx context.Context, token BlacklistedToken) (err error){ + args := m.Called(ctx, token) + return args.Error(0) +} + +func (m *DBMockStore) CheckBlacklistedToken(ctx context.Context,token string) (status bool,number int){ + args := m.Called(ctx, token) + return args.Bool(0), args.Int(1) +} + +func (m *DBMockStore) GetUser(ctx context.Context,userID int) (user User,err error){ + args := m.Called(ctx, userID) + return args.Get(0).(User), args.Error(1) +} diff --git a/db/pg.go b/db/pg.go index bd23fc1..eb56cf5 100644 --- a/db/pg.go +++ b/db/pg.go @@ -10,6 +10,7 @@ import ( "time" "github.com/jmoiron/sqlx" + //lib/pq internally configures with database/sql library" _ "github.com/lib/pq" "github.com/mattes/migrate" "github.com/mattes/migrate/database/postgres" diff --git a/db/user.go b/db/user.go index 17715e9..5a5fe07 100644 --- a/db/user.go +++ b/db/user.go @@ -2,17 +2,31 @@ package db import ( "context" - + "database/sql" logger "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" + ae "joshsoftware/go-e-commerce/apperrors" + "time" ) +//User Struct for declaring attributes of User type User struct { - Name string `db:"name" json:"full_name"` - Age int `db:"age" json:"age"` + ID int `db:"id" json:"id"` + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + Email string `db:"email" json:"email"` + Mobile string `db:"mobile" json:"mobile"` + Address string `db:"address" json:"address"` + Password string `db:"password" json:"password"` + Country string `db:"country" json:"country"` + State string `db:"state" json:"state"` + City string `db:"city" json:"city"` + CreatedAt time.Time `db:"created_at" json:"created_at"` } +//ListUsers function to fetch all Users From Database func (s *pgStore) ListUsers(ctx context.Context) (users []User, err error) { - err = s.db.Select(&users, "SELECT * FROM users ORDER BY name ASC") + err = s.db.Select(&users, "SELECT * FROM users ORDER BY first_name ASC") if err != nil { logger.WithField("err", err.Error()).Error("Error listing users") return @@ -20,3 +34,35 @@ func (s *pgStore) ListUsers(ctx context.Context) (users []User, err error) { return } + +//GetUser function is used to Get a Particular User +func (s *pgStore) GetUser(ctx context.Context, id int) (user User, err error) { + + err = s.db.Get(&user, "SELECT * FROM users WHERE id=$1", id) + if err != nil { + if err == sql.ErrNoRows { + err = ae.ErrRecordNotFound + } + logger.WithField("err", err.Error()).Error("Query Failed") + return + } + + return +} + +//AuthenticateUser Function checks if User has Registered before Login +// and Has Entered Correct Credentials +func (s *pgStore) AuthenticateUser(ctx context.Context, u User) (user User, err error) { + + err = s.db.Get(&user, "SELECT * FROM users where email = $1", u.Email) + if err != nil { + logger.WithField("err", err.Error()).Error("No such User Available") + return + } + + if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(u.Password)); err != nil { + // If the two passwords don't match, return a 401 status + logger.WithField("Error", err.Error()) + } + return +} diff --git a/db/user_blacklisted_tokens.go b/db/user_blacklisted_tokens.go new file mode 100644 index 0000000..99d80d6 --- /dev/null +++ b/db/user_blacklisted_tokens.go @@ -0,0 +1,48 @@ +package db + +import ( + "context" + "fmt" + "time" + + logger "github.com/sirupsen/logrus" +) + +//BlacklistedToken - struct representing a token to be blacklisted (logout) +type BlacklistedToken struct { + ID int `db:"id" json:"id"` + UserID float64 `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpirationDate time.Time `db:"expiration_date" json:"expiration_date"` +} + +const ( + insertBlacklistedToken = `INSERT INTO user_blacklisted_tokens +(user_id, token, expiration_date) +VALUES ($1, $2, $3)` +) + +//CreateBlacklistedToken function to insert the blacklisted token in database +func (s *pgStore) CreateBlacklistedToken(ctx context.Context, token BlacklistedToken) (err error) { + _, err = s.db.Exec(insertBlacklistedToken, token.UserID, token.Token, token.ExpirationDate) + + if err != nil { + errMsg := fmt.Sprintf("Error inserting the blacklisted token for user with id %v", token.UserID) + logger.WithField("err", err.Error()).Error(errMsg) + return + } + return +} + +//CheckBlacklistedToken function to check if token is blacklisted earlier +func (s *pgStore) CheckBlacklistedToken(ctx context.Context, token string) (bool, int) { + + var userID int + query1 := fmt.Sprintf("SELECT user_id FROM user_blacklisted_tokens WHERE token='%s'", token) + err := s.db.QueryRow(query1).Scan(&userID) + + if err != nil { + return false, -1 + } + return true, userID +} diff --git a/go.mod b/go.mod index bd1ae4f..ae07d8d 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,18 @@ module joshsoftware/go-e-commerce go 1.14 require ( + github.com/DATA-DOG/go-sqlmock v1.5.0 + github.com/auth0/go-jwt-middleware v0.0.0-20200810150920-a32d7af194d1 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gorilla/mux v1.8.0 github.com/jmoiron/sqlx v1.2.0 github.com/lib/pq v1.8.0 github.com/mattes/migrate v3.0.1+incompatible + github.com/rs/cors v1.7.0 github.com/sirupsen/logrus v1.6.0 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.6.1 github.com/urfave/cli v1.22.4 github.com/urfave/negroni v1.0.0 + golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 ) diff --git a/go.sum b/go.sum index 0b155a0..3ac06b2 100644 --- a/go.sum +++ b/go.sum @@ -13,18 +13,23 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/auth0/go-jwt-middleware v0.0.0-20200810150920-a32d7af194d1 h1:lnVadil6o8krZE47ms2PCxhXcki/UwoqiB0axOIV3mk= +github.com/auth0/go-jwt-middleware v0.0.0-20200810150920-a32d7af194d1/go.mod h1:mF0ip7kTEFtnhBJbd/gJe62US3jykNN+dcZoZakJCCA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -35,6 +40,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -45,6 +51,7 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -68,6 +75,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -100,6 +109,7 @@ github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhB github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -154,6 +164,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -164,6 +176,9 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -203,6 +218,7 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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= diff --git a/main.go b/main.go index e0ad81d..9f9a23a 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "fmt" + "github.com/rs/cors" "joshsoftware/go-e-commerce/config" "joshsoftware/go-e-commerce/db" "joshsoftware/go-e-commerce/service" @@ -70,6 +71,13 @@ func startApp() (err error) { return } + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"POST", "GET", "DELETE", "PUT", "PATCH", "OPTIONS"}, + AllowCredentials: true, + Debug: true, + }) + deps := service.Dependencies{ Store: store, } @@ -79,6 +87,7 @@ func startApp() (err error) { // init web server server := negroni.Classic() + server.Use(c) server.UseHandler(router) port := config.AppPort() // This can be changed to the service port number via environment variable. diff --git a/migrations/1587381324_create_users.up.sql b/migrations/1587381324_create_users.up.sql index f893282..75c0b9a 100644 --- a/migrations/1587381324_create_users.up.sql +++ b/migrations/1587381324_create_users.up.sql @@ -1,4 +1,13 @@ -CREATE TABLE users ( - name text, - age integer -); +CREATE TABLE IF NOT EXISTS users ( + id SERIAL NOT NULL PRIMARY KEY, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255), + email VARCHAR(255) NOT NULL UNIQUE, + mobile VARCHAR(20), + country VARCHAR(100), + state VARCHAR(100), + city VARCHAR(100), + address TEXT, + password TEXT, + created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') +); \ No newline at end of file diff --git a/migrations/1599504021_create_cart.down.sql b/migrations/1599504021_create_cart.down.sql new file mode 100644 index 0000000..baacbaf --- /dev/null +++ b/migrations/1599504021_create_cart.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cart; \ No newline at end of file diff --git a/migrations/1599504021_create_cart.up.sql b/migrations/1599504021_create_cart.up.sql new file mode 100644 index 0000000..277754b --- /dev/null +++ b/migrations/1599504021_create_cart.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS cart ( + id INTEGER NOT NULL, + product_id INTEGER NOT NULL REFERENCES products(id), + quantity INTEGER, + PRIMARY KEY(id, product_id) +); \ No newline at end of file diff --git a/migrations/1599589830_blacklisted_tokens.down.sql b/migrations/1599589830_blacklisted_tokens.down.sql new file mode 100644 index 0000000..fa6445e --- /dev/null +++ b/migrations/1599589830_blacklisted_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_blacklisted_tokens; \ No newline at end of file diff --git a/migrations/1599589830_blacklisted_tokens.up.sql b/migrations/1599589830_blacklisted_tokens.up.sql new file mode 100644 index 0000000..4e6dad1 --- /dev/null +++ b/migrations/1599589830_blacklisted_tokens.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS user_blacklisted_tokens( + id SERIAL NOT NULL PRIMARY KEY, + user_id BIGINT REFERENCES users(id), + token TEXT, + expiration_date TIMESTAMP +); \ No newline at end of file diff --git a/service/cart_http.go b/service/cart_http.go new file mode 100644 index 0000000..746a231 --- /dev/null +++ b/service/cart_http.go @@ -0,0 +1,174 @@ +package service + +import ( + "strings" + "strconv" + "net/http" + logger "github.com/sirupsen/logrus" +) + +func addToCartHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + authToken := req.Header.Get("Authorization") + if strings.HasPrefix(strings.ToUpper(authToken), "BEARER") { + authToken = authToken[len("BEARER "):] + } + + cartID, _, err := getDataFromToken(authToken) + if err != nil { + logger.WithField("err", err.Error()).Error("Unauthorized user") + error := errorResponse { + Error : "Unauthorized user", + } + responses(rw, http.StatusUnauthorized, error) + return + } + + productID, err := strconv.Atoi(req.URL.Query()["productID"][0]) + if err != nil { + logger.WithField("err", err.Error()).Error("product_id is missing") + error := errorResponse { + Error : "product_id missing", + } + responses(rw, http.StatusBadRequest, error) + return + } + + rowsAffected, err := deps.Store.AddToCart(req.Context(), int(cartID), productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while adding to cart") + error := errorResponse { + Error : "could not add item", + } + responses(rw, http.StatusInternalServerError, error) + return + } + + if rowsAffected != 1 { + success := successResponse { + Data : "zero rows affected", + } + responses(rw, http.StatusOK, success) + return + } + + success := successResponse{ + Data: "Item added successfully", + } + responses(rw, http.StatusOK, success) + }) +} + +func deleteFromCartHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + authToken := req.Header.Get("Authorization") + if strings.HasPrefix(strings.ToUpper(authToken), "BEARER") { + authToken = authToken[len("BEARER "):] + } + + cartID, _, err := getDataFromToken(authToken) + if err != nil { + logger.WithField("err", err.Error()).Error("Unauthorized user") + error := errorResponse { + Error : "Unauthorized user", + } + responses(rw, http.StatusUnauthorized, error) + return + } + + productID, err := strconv.Atoi(req.URL.Query()["productID"][0]) + if err != nil { + logger.WithField("err", err.Error()).Error("product_id is missing") + error := errorResponse { + Error : "product_id missing", + } + responses(rw, http.StatusBadRequest, error) + return + } + + rowsAffected, err := deps.Store.DeleteFromCart(req.Context(), int(cartID), productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while removing from cart") + error := errorResponse { + Error : "could not remove item", + } + responses(rw, http.StatusInternalServerError, error) + return + } + + if rowsAffected != 1 { + success := successResponse { + Data : "zero rows affected", + } + responses(rw, http.StatusOK, success) + return + } + + success := successResponse{ + Data: "Item removed successfully", + } + responses(rw, http.StatusOK, success) + }) +} + +func updateIntoCartHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + authToken := req.Header.Get("Authorization") + if strings.HasPrefix(strings.ToUpper(authToken), "BEARER") { + authToken = authToken[len("BEARER "):] + } + + cartID, _, err := getDataFromToken(authToken) + if err != nil { + logger.WithField("err", err.Error()).Error("Unauthorized user") + error := errorResponse { + Error : "Unauthorized user", + } + responses(rw, http.StatusUnauthorized, error) + return + } + + productID, err := strconv.Atoi(req.URL.Query()["productID"][0]) + if err != nil { + logger.WithField("err", err.Error()).Error("product_id is missing") + error := errorResponse { + Error : "product_id missing", + } + responses(rw, http.StatusBadRequest, error) + return + } + + quantity, err := strconv.Atoi(req.URL.Query()["quantity"][0]) + if err != nil { + logger.WithField("err", err.Error()).Error("quantity is missing") + error := errorResponse { + Error : "quantity missing", + } + responses(rw, http.StatusBadRequest, error) + return + } + + rowsAffected, err := deps.Store.UpdateIntoCart(req.Context(), quantity, int(cartID), productID) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while updating to cart") + error := errorResponse { + Error : "could not update quantity", + } + responses(rw, http.StatusInternalServerError, error) + return + } + + if rowsAffected != 1 { + success := successResponse { + Data : "zero rows affected", + } + responses(rw, http.StatusOK, success) + return + } + + success := successResponse { + Data : "Quantity updated successfully", + } + responses(rw, http.StatusOK, success) + }) +} \ No newline at end of file diff --git a/service/cart_http_test.go b/service/cart_http_test.go new file mode 100644 index 0000000..70d7343 --- /dev/null +++ b/service/cart_http_test.go @@ -0,0 +1,206 @@ +package service + +import( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "joshsoftware/go-e-commerce/db" + "net/http" + "errors" +) + +type CartHandlerTestSuite struct { + suite.Suite + dbMock *db.DBMockStore +} + +func (suite *CartHandlerTestSuite) SetupTest() { + suite.dbMock = &db.DBMockStore{} +} + +func (suite *CartHandlerTestSuite) TestAddToCartSuccess() { + suite.dbMock.On("AddToCart", mock.Anything, 1, 100).Return(1, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPost, + "/cart", + "/cart?productID=100", + "", + addToCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"Item added successfully"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestAddToCartProductIDMissingSuccess() { + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPost, + "/cart", + "/cart?productID=", + "", + addToCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":"product_id missing"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestAddToCartNoRowsSuccess() { + suite.dbMock.On("AddToCart", mock.Anything, 1, 100).Return(0, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPost, + "/cart", + "/cart?productID=100", + "", + addToCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"zero rows affected"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestAddToCartFailure() { + suite.dbMock.On("AddToCart", mock.Anything, 1, 100).Return(0, errors.New("Error while adding to cart")) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPost, + "/cart", + "/cart?productID=100", + "", + addToCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":"could not add item"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestDeleteFromCartSuccess() { + suite.dbMock.On("DeleteFromCart", mock.Anything, 1, 100).Return(1, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodDelete, + "/cart", + "/cart?productID=100", + "", + deleteFromCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"Item removed successfully"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestDeleteFromCartProductIDMissingSuccess() { + recorder := makeHTTPCallWithJWTMiddleware(http.MethodDelete, + "/cart", + "/cart?productID=", + "", + deleteFromCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":"product_id missing"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestDeleteFromCartNoRowsSuccess() { + suite.dbMock.On("DeleteFromCart", mock.Anything, 1, 100).Return(0, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodDelete, + "/cart", + "/cart?productID=100", + "", + deleteFromCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"zero rows affected"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestDeleteFromCartFailure() { + suite.dbMock.On("DeleteFromCart", mock.Anything, 1, 100).Return(0, errors.New("Error while removing from cart")) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodDelete, + "/cart", + "/cart?productID=100", + "", + deleteFromCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":"could not remove item"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestUpdateIntoCartSuccess() { + suite.dbMock.On("UpdateIntoCart", mock.Anything, 1, 100, 3).Return(1, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPut, + "/cart", + "/cart?productID=100&quantity=3", + "", + updateIntoCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"Quantity updated successfully"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestUpdateIntoCartProductIDMissingSuccess() { + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPut, + "/cart", + "/cart?productID=&quantity=3", + "", + updateIntoCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":"product_id missing"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestUpdateIntoCartQuantityMissingSuccess() { + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPut, + "/cart", + "/cart?productID=100&quantity=", + "", + updateIntoCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusBadRequest, recorder.Code) + assert.Equal(suite.T(), `{"error":"quantity missing"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestUpdateIntoCartNoRowsSuccess() { + suite.dbMock.On("UpdateIntoCart", mock.Anything, 1, 100, 3).Return(0, nil) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPut, + "/cart", + "/cart?productID=100&quantity=3", + "", + updateIntoCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusOK, recorder.Code) + assert.Equal(suite.T(), `{"data":"zero rows affected"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} + +func (suite *CartHandlerTestSuite) TestUpdateIntoCartFailure() { + suite.dbMock.On("UpdateIntoCart", mock.Anything, 1, 100, 3).Return(0, errors.New("Error while updating into cart")) + + recorder := makeHTTPCallWithJWTMiddleware(http.MethodPut, + "/cart", + "/cart?productID=100&quantity=3", + "", + updateIntoCartHandler(Dependencies{Store: suite.dbMock}), + ) + + assert.Equal(suite.T(), http.StatusInternalServerError, recorder.Code) + assert.Equal(suite.T(), `{"error":"could not update quantity"}`, recorder.Body.String()) + suite.dbMock.AssertExpectations(suite.T()) +} \ No newline at end of file diff --git a/service/common_http_test.go b/service/common_http_test.go new file mode 100644 index 0000000..b639729 --- /dev/null +++ b/service/common_http_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "joshsoftware/go-e-commerce/config" + "net/http" + "net/http/httptest" + "strings" + "github.com/gorilla/mux" + jwtmiddleware "github.com/auth0/go-jwt-middleware" + jwt "github.com/dgrijalva/jwt-go" + "github.com/urfave/negroni" + "testing" + "github.com/stretchr/testify/suite" +) + +func TestExampleTestSuite(t *testing.T) { + config.Load() + suite.Run(t, new(CartHandlerTestSuite)) +} + +func makeHTTPCallWithJWTMiddleware(method, path, requestURL, body string, handlerFunc http.HandlerFunc) (recorder *httptest.ResponseRecorder) { + // create jwt token with userID + JWTToken, _ := generateJwt(1) + + // create a http request using the given parameters + req, _ := http.NewRequest(method, requestURL, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer " + JWTToken) + + // test recorder created for capturing api responses + recorder = httptest.NewRecorder() + + // create a router to serve the handler in test with the prepared request + router := mux.NewRouter() + jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ + ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { + return config.JWTKey(), nil + }, + SigningMethod: jwt.SigningMethodHS256, + }) + router.Handle(path, negroni.New( + negroni.HandlerFunc(jwtMiddleware.HandlerWithNext), + negroni.Wrap(http.HandlerFunc(handlerFunc)), + )).Methods(method) + + // serve the request and write the response to recorder + router.ServeHTTP(recorder, req) + return +} \ No newline at end of file diff --git a/service/dependencies.go b/service/dependencies.go index 431979b..071be44 100644 --- a/service/dependencies.go +++ b/service/dependencies.go @@ -2,6 +2,7 @@ package service import "joshsoftware/go-e-commerce/db" +//Dependencies Structure type Dependencies struct { Store db.Storer // define other service dependencies diff --git a/service/ping_http.go b/service/ping_http.go index d30d164..3689ac6 100644 --- a/service/ping_http.go +++ b/service/ping_http.go @@ -7,6 +7,7 @@ import ( logger "github.com/sirupsen/logrus" ) +//PingResponse Struct type PingResponse struct { Message string `json:"message"` } @@ -20,6 +21,6 @@ func pingHandler(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusInternalServerError) } - rw.Header().Add("Content-Type", "application/json") + rw.Header().Set("Content-Type", "application/json") rw.Write(respBytes) } diff --git a/service/responce.go b/service/responce.go new file mode 100644 index 0000000..ea62096 --- /dev/null +++ b/service/responce.go @@ -0,0 +1,39 @@ +package service + +import ( + "encoding/json" + "net/http" + + logger "github.com/sirupsen/logrus" +) + +type successResponse struct { + Data interface{} `json:"data"` +} + +type errorResponse struct { + Error interface{} `json:"error"` +} + +type messageObject struct { + Message string `json:"message"` +} + +type errorObject struct { + Code string `json:"code"` + messageObject + Fields map[string]string `json:"fields"` +} + +func responses(rw http.ResponseWriter, status int, responseBody interface{}) { + respBytes, err := json.Marshal(responseBody) + if err != nil { + logger.WithField("err", err.Error()).Error("Error while marshaling core values data") + rw.WriteHeader(http.StatusInternalServerError) + return + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(status) + rw.Write(respBytes) +} diff --git a/service/router.go b/service/router.go index 121ad64..efc3394 100644 --- a/service/router.go +++ b/service/router.go @@ -2,18 +2,16 @@ package service import ( "fmt" - "net/http" - - "joshsoftware/go-e-commerce/config" - "github.com/gorilla/mux" + "joshsoftware/go-e-commerce/config" + "net/http" ) const ( versionHeader = "Accept" ) -/* The routing mechanism. Mux helps us define handler functions and the access methods */ +/*InitRouter is The routing mechanism. Mux helps us define handler functions and the access methods */ func InitRouter(deps Dependencies) (router *mux.Router) { router = mux.NewRouter() @@ -23,6 +21,60 @@ func InitRouter(deps Dependencies) (router *mux.Router) { // Version 1 API management v1 := fmt.Sprintf("application/vnd.%s.v1", config.AppName()) + //Route for User Login + router.HandleFunc("/login", userLoginHandler(deps)).Methods(http.MethodPost).Headers(versionHeader, v1) + + //Router for Get User from ID + router.Handle("/user", jwtMiddleWare(getUserHandler(deps), deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + + //Router for User Logout + router.Handle("/logout", jwtMiddleWare(userLogoutHandler(deps), deps)).Methods(http.MethodDelete).Headers(versionHeader, v1) + + //Router for Get All Users router.HandleFunc("/users", listUsersHandler(deps)).Methods(http.MethodGet).Headers(versionHeader, v1) + //routes for cart operations + router.Handle("/cart", jwtMiddleWare(addToCartHandler(deps), deps)).Methods(http.MethodPost).Headers(versionHeader, v1) + router.Handle("/cart", jwtMiddleWare(deleteFromCartHandler(deps), deps)).Methods(http.MethodDelete).Headers(versionHeader, v1) + router.Handle("/cart", jwtMiddleWare(updateIntoCartHandler(deps), deps)).Methods(http.MethodPut).Headers(versionHeader, v1) return } + +//jwtMiddleWare function is used to authenticate and authorize the incoming request +func jwtMiddleWare(endpoint http.Handler, deps Dependencies) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + authToken := req.Header.Get("Token") + + //Checking if token not present in header + if len(authToken) < 1 { + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Missing Authorization Token", + }, + }) + return + } + + _, _, err := getDataFromToken(authToken) + if err != nil { + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Unauthorized User", + }, + }) + return + } + + //Fetching Status of Token Being Blacklisted or Not + // Unauthorized User if Token BlackListed + if isBlacklisted, _ := deps.Store.CheckBlacklistedToken(req.Context(), authToken); isBlacklisted { + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Unauthorized User", + }, + }) + return + } + endpoint.ServeHTTP(rw, req) + }) +} diff --git a/service/session_http.go b/service/session_http.go new file mode 100644 index 0000000..24c4e24 --- /dev/null +++ b/service/session_http.go @@ -0,0 +1,168 @@ +package service + +import ( + "encoding/json" + "fmt" + ae "joshsoftware/go-e-commerce/apperrors" + "joshsoftware/go-e-commerce/config" + "joshsoftware/go-e-commerce/db" + "net/http" + "time" + + "github.com/dgrijalva/jwt-go" + logger "github.com/sirupsen/logrus" +) + +//AuthBody stores responce body for login +type authBody struct { + Message string `json:"meassage"` + Token string `json:"token"` +} + +//generateJWT function generates and return a new JWT token +func generateJwt(userID int) (tokenString string, err error) { + mySigningKey := config.JWTKey() + if mySigningKey == nil { + ae.Error(ae.ErrNoSigningKey, "Application error: No signing key configured", err) + return + } + + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["id"] = userID + claims["exp"] = time.Now().Add(time.Duration(config.JWTExpiryDurationHours()) * time.Hour).Unix() + + tokenString, err = token.SignedString(mySigningKey) + if err != nil { + ae.Error(ae.ErrSignedString, "Failed To Get Signed String", err) + return + } + return +} + +//userLoginHandler function take credentials in json +// and check if the credentials are correct +// also generate and returns a JWT token in the case of correct crendential +func userLoginHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + user := db.User{} + + //fetching the json object to get crendentials of users + err := json.NewDecoder(req.Body).Decode(&user) + if err != nil { + logger.WithField("err", err.Error()).Error("JSON Decoding Failed") + responses(rw, http.StatusBadRequest, errorResponse{ + Error: messageObject{ + Message: "JSON Decoding Failed", + }, + }) + return + } + + //TODO change no need to return user object from Authentication + //checking if the user is authenticated or not + // by passing the credentials to the AuthenticateUser function + user, err1 := deps.Store.AuthenticateUser(req.Context(), user) + if err1 != nil { + logger.WithField("err", err1.Error()).Error("Invalid Credentials") + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Invalid Credentials", + }, + }) + return + } + + //Generate new JWT token if the user is authenticated + // and return the token in request header + token, err := generateJwt(user.ID) + if err != nil { + responses(rw, http.StatusInternalServerError, errorResponse{ + Error: messageObject{ + Message: "Token Generation Failure", + }, + }) + return + } + + responses(rw, http.StatusOK, successResponse{ + Data: authBody{ + Message: "Login Successfull", + Token: token, + }, + }) + }) +} + +//userLogoutHandler function logs the user off +// and add the valid JWT token in BlacklistedToken +func userLogoutHandler(deps Dependencies) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + //fetching the token from header + authToken := req.Header.Get("Token") + + //fetching details from the token + userID, expirationTimeStamp, err := getDataFromToken(authToken) + if err != nil { + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Unauthorized User", + }, + }) + return + } + expirationDate := time.Unix(expirationTimeStamp, 0) + + //create a BlacklistedToken to add in database + // To blacklist a user valid token + userBlackListedToken := db.BlacklistedToken{ + UserID: userID, + ExpirationDate: expirationDate, + Token: authToken, + } + + err = deps.Store.CreateBlacklistedToken(req.Context(), userBlackListedToken) + if err != nil { + ae.Error(ae.ErrFailedToCreate, "Error creating blaclisted token record", err) + responses(rw, http.StatusInternalServerError, errorResponse{ + Error: messageObject{ + Message: "Internal Server Error", + }, + }) + return + } + responses(rw, http.StatusOK, successResponse{ + Data: messageObject{ + Message: "Logged Out Successfully", + }, + }) + return + }) +} + +func getDataFromToken(Token string) (userID float64, expirationTime int64, err error) { + mySigningKey := config.JWTKey() + + token, err := jwt.Parse(Token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("There was an error while parsing the token") + } + return mySigningKey, nil + }) + if err != nil { + ae.Error(ae.ErrInvalidToken, "Invalid Token", err) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + + if !ok && !token.Valid { + ae.Error(ae.ErrInvalidToken, "Invalid Token", err) + return + } + + userID = claims["id"].(float64) + expirationTime = int64(claims["exp"].(float64)) + return +} diff --git a/service/user_http.go b/service/user_http.go index c544bcd..7638e58 100644 --- a/service/user_http.go +++ b/service/user_http.go @@ -1,35 +1,62 @@ package service import ( - "encoding/json" - "net/http" - logger "github.com/sirupsen/logrus" + "joshsoftware/go-e-commerce/db" + "net/http" ) -// @Title listUsers -// @Description list all User -// @Router /users [get] -// @Accept json -// @Success 200 {object} -// @Failure 400 {object} +//listUsersHandler function fetch all users from database +// and return as json object func listUsersHandler(deps Dependencies) http.HandlerFunc { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { users, err := deps.Store.ListUsers(req.Context()) if err != nil { logger.WithField("err", err.Error()).Error("Error fetching data") - rw.WriteHeader(http.StatusInternalServerError) + responses(rw, http.StatusInternalServerError, errorResponse{ + Error: messageObject{ + Message: "Internal Server Error", + }, + }) + return + } + + responses(rw, http.StatusOK, successResponse{ + Data: users, + }) + }) +} + +//listUsersHandler function fetch specific user from database +// and return as json object +func getUserHandler(deps Dependencies) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + //fetch usedId from request + authToken := req.Header.Get("Token") + userID, _, err := getDataFromToken(authToken) + if err != nil { + responses(rw, http.StatusUnauthorized, errorResponse{ + Error: messageObject{ + Message: "Unauthorized User", + }, + }) return } - respBytes, err := json.Marshal(users) + user := db.User{} + user, err = deps.Store.GetUser(req.Context(), int(userID)) if err != nil { - logger.WithField("err", err.Error()).Error("Error marshaling users data") - rw.WriteHeader(http.StatusInternalServerError) + logger.WithField("err", err.Error()).Error("Error fetching data") + responses(rw, http.StatusInternalServerError, errorResponse{ + Error: messageObject{ + Message: "Internal Server Error", + }, + }) return } - rw.Header().Add("Content-Type", "application/json") - rw.Write(respBytes) + responses(rw, http.StatusOK, successResponse{ + Data: user, + }) }) }