From 4be04544216500f67eb3108f8ef4541ab88a1b07 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Tue, 13 Aug 2019 22:26:25 -0800 Subject: [PATCH 01/21] update gitignore for .devops --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6566d85..d1a5f45 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ aws.* local.env .DS_Store tmp +.devops.json From 3bc814a01ef6d85799224b7ecdc155972c788550 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Tue, 13 Aug 2019 23:41:06 -0800 Subject: [PATCH 02/21] WIP: not sure how to solve user_account calling account.CanModifyAccount --- cmd/web-api/handlers/routes.go | 44 +++++-- internal/user/models.go | 20 +++ internal/user/user.go | 137 +++++++++++---------- internal/user/user_test.go | 93 +++++++------- internal/user_account/models.go | 13 ++ internal/user_account/user.go | 7 +- internal/user_account/user_account.go | 8 +- internal/user_account/user_account_test.go | 8 +- 8 files changed, 198 insertions(+), 132 deletions(-) diff --git a/cmd/web-api/handlers/routes.go b/cmd/web-api/handlers/routes.go index c056074..b68460f 100644 --- a/cmd/web-api/handlers/routes.go +++ b/cmd/web-api/handlers/routes.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "geeks-accelerator/oss/saas-starter-kit/internal/user" "log" "net/http" "os" @@ -20,33 +21,52 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) + +type AppContext struct { + Log *log.Logger + Env webcontext.Env + Repo *user.Repository + MasterDB *sqlx.DB + Redis *redis.Client + Authenticator *auth.Authenticator + PreAppMiddleware []web.Middleware + PostAppMiddleware []web.Middleware +} + + // API returns a handler for a set of routes. -func API(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler { +func API(shutdown chan os.Signal, appContext *AppContext ) http.Handler { - // Define base middlewares applied to all requests. - middlewares := []web.Middleware{ - mid.Trace(), mid.Logger(log), mid.Errors(log, nil), mid.Metrics(), mid.Panics(), - } + // Include the pre middlewares first. + middlewares := appContext.PreAppMiddleware - // Append any global middlewares if they were included. - if len(globalMids) > 0 { - middlewares = append(middlewares, globalMids...) + // Define app middlewares applied to all requests. + middlewares = append(middlewares, + mid.Trace(), + mid.Logger(appContext.Log), + mid.Errors(appContext.Log, nil), + mid.Metrics(), + mid.Panics()) + + // Append any global middlewares that should be included after the app middlewares. + if len(appContext.PostAppMiddleware) > 0 { + middlewares = append(middlewares, appContext.PostAppMiddleware...) } // Construct the web.App which holds all routes as well as common Middleware. - app := web.NewApp(shutdown, log, env, middlewares...) + app := web.NewApp(shutdown, appContext.Log, appContext.Env, middlewares...) // Register health check endpoint. This route is not authenticated. check := Check{ - MasterDB: masterDB, - Redis: redis, + MasterDB: appContext.MasterDB, + Redis: appContext.Redis, } app.Handle("GET", "/v1/health", check.Health) app.Handle("GET", "/ping", check.Ping) // Register user management and authentication endpoints. u := User{ - MasterDB: masterDB, + MasterDB: appContext.MasterDB, TokenGenerator: authenticator, } app.Handle("GET", "/v1/users", u.Find, mid.AuthenticateHeader(authenticator)) diff --git a/internal/user/models.go b/internal/user/models.go index 2aca963..b4c65c5 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" "encoding/json" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sudo-suhas/symcrypto" "strconv" @@ -15,6 +17,24 @@ import ( "github.com/lib/pq" ) +// Repository defines the required dependencies for User. +type Repository struct { + DbConn *sqlx.DB + ResetUrl func(string) string + Notify notify.Email + SecretKey string +} + +// NewRepository creates a new Repository that defines dependencies for User. +func NewRepository(db *sqlx.DB, resetUrl func(string) string, notify notify.Email, secretKey string) *Repository { + return &Repository{ + DbConn: db, + ResetUrl: resetUrl, + Notify: notify, + SecretKey: secretKey, + } +} + // User represents someone with access to our system. type User struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` diff --git a/internal/user/user.go b/internal/user/user.go index 6354c19..81d036e 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -6,7 +6,6 @@ import ( "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" @@ -55,7 +54,7 @@ func mapRowsToUser(rows *sql.Rows) (*User, error) { } // CanReadUser determines if claims has the authority to access the specified user ID. -func CanReadUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { +func (repo *Repository) CanReadUser(ctx context.Context, claims auth.Claims, userID string) error { // If the request has claims from a specific user, ensure that the user // has the correct access to the user. if claims.Subject != "" && claims.Subject != userID { @@ -68,10 +67,10 @@ func CanReadUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userI query.Equal("user_id", userID), )) queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) var userAccountId string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&userAccountId) + err := repo.DbConn.QueryRowContext(ctx, queryStr, args...).Scan(&userAccountId) if err != nil && err != sql.ErrNoRows { err = errors.Wrapf(err, "query - %s", query.String()) return err @@ -88,7 +87,7 @@ func CanReadUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userI } // CanModifyUser determines if claims has the authority to modify the specified user ID. -func CanModifyUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { +func (repo *Repository) CanModifyUser(ctx context.Context, claims auth.Claims, userID string) error { // If the request has claims from a specific user, ensure that the user // has the correct role for creating a new user. if claims.Subject != "" && claims.Subject != userID { @@ -99,7 +98,7 @@ func CanModifyUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, use } } - if err := CanReadUser(ctx, claims, dbConn, userID); err != nil { + if err := repo.CanReadUser(ctx, claims, userID); err != nil { return err } @@ -118,10 +117,10 @@ func CanModifyUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, use "'"+auth.RoleAdmin+"' = ANY (roles)", )) queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) var userAccountId string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&userAccountId) + err := repo.DbConn.QueryRowContext(ctx, queryStr, args...).Scan(&userAccountId) if err != nil && err != sql.ErrNoRows { err = errors.Wrapf(err, "query - %s", query.String()) return err @@ -199,13 +198,13 @@ func findRequestQuery(req UserFindRequest) (*sqlbuilder.SelectBuilder, []interfa } // Find gets all the users from the database based on the request params. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindRequest) (Users, error) { +func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req UserFindRequest) (Users, error) { query, args := findRequestQuery(req) - return find(ctx, claims, dbConn, query, args, req.IncludeArchived) + return repo.find(ctx, claims, query, args, req.IncludeArchived) } // find internal method for getting all the users from the database using a select query. -func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) (Users, error) { +func (repo *Repository) find(ctx context.Context, claims auth.Claims, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) (Users, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Find") defer span.Finish() @@ -222,11 +221,11 @@ func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbu return nil, err } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find users failed") @@ -248,17 +247,17 @@ func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbu } // Validation an email address is unique excluding the current user ID. -func UniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bool, error) { +func (repo *Repository) UniqueEmail(ctx context.Context, email, userId string) (bool, error) { query := sqlbuilder.NewSelectBuilder().Select("id").From(userTableName) query.Where(query.And( query.Equal("email", email), query.NotEqual("id", userId), )) queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) var existingId string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId) + err := repo.DbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId) if err != nil && err != sql.ErrNoRows { err = errors.Wrapf(err, "query - %s", query.String()) return false, err @@ -273,7 +272,7 @@ func UniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bo } // Create inserts a new user into the database. -func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCreateRequest, now time.Time) (*User, error) { +func (repo *Repository) Create(ctx context.Context, claims auth.Claims, req UserCreateRequest, now time.Time) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create") defer span.Finish() @@ -284,7 +283,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr v := webcontext.Validator() // Validation email address is unique in the database. - uniq, err := UniqueEmail(ctx, dbConn, req.Email, "") + uniq, err := repo.UniqueEmail(ctx, req.Email, "") if err != nil { return nil, err } @@ -346,8 +345,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "create user failed") @@ -358,14 +357,14 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr } // Create invite inserts a new user into the database. -func CreateInvite(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCreateInviteRequest, now time.Time) (*User, error) { +func (repo *Repository) CreateInvite(ctx context.Context, claims auth.Claims, req UserCreateInviteRequest, now time.Time) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.CreateInvite") defer span.Finish() v := webcontext.Validator() // Validation email address is unique in the database. - uniq, err := UniqueEmail(ctx, dbConn, req.Email, "") + uniq, err := repo.UniqueEmail(ctx, req.Email, "") if err != nil { return nil, err } @@ -414,8 +413,8 @@ func CreateInvite(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "create user failed") @@ -426,15 +425,15 @@ func CreateInvite(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req } // ReadByID gets the specified user by ID from the database. -func ReadByID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) (*User, error) { - return Read(ctx, claims, dbConn, UserReadRequest{ +func (repo *Repository) ReadByID(ctx context.Context, claims auth.Claims, id string) (*User, error) { + return repo.Read(ctx, claims, UserReadRequest{ ID: id, IncludeArchived: false, }) } // Read gets the specified user from the database. -func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserReadRequest) (*User, error) { +func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req UserReadRequest) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Read") defer span.Finish() @@ -449,7 +448,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserRead query := selectQuery() query.Where(query.Equal("id", req.ID)) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + res, err := repo.find(ctx, claims, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -462,7 +461,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserRead } // ReadByEmail gets the specified user from the database. -func ReadByEmail(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, email string, includedArchived bool) (*User, error) { +func (repo *Repository) ReadByEmail(ctx context.Context, claims auth.Claims, email string, includedArchived bool) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ReadByEmail") defer span.Finish() @@ -470,7 +469,7 @@ func ReadByEmail(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, email query := selectQuery() query.Where(query.Equal("email", email)) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived) + res, err := repo.find(ctx, claims, query, []interface{}{}, includedArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -483,14 +482,14 @@ func ReadByEmail(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, email } // Update replaces a user in the database. -func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUpdateRequest, now time.Time) error { +func (repo *Repository) Update(ctx context.Context, claims auth.Claims, req UserUpdateRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") defer span.Finish() // Validation email address is unique in the database. if req.Email != nil { // Validation email address is unique in the database. - uniq, err := UniqueEmail(ctx, dbConn, *req.Email, req.ID) + uniq, err := repo.UniqueEmail(ctx, *req.Email, req.ID) if err != nil { return err } @@ -507,7 +506,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp } // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) + err = repo.CanModifyUser(ctx, claims, req.ID) if err != nil { return err } @@ -555,8 +554,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update user %s failed", req.ID) @@ -567,7 +566,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp } // Update changes the password for a user in the database. -func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUpdatePasswordRequest, now time.Time) error { +func (repo *Repository) UpdatePassword(ctx context.Context, claims auth.Claims, req UserUpdatePasswordRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.UpdatePassword") defer span.Finish() @@ -579,7 +578,7 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re } // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) + err = repo.CanModifyUser(ctx, claims, req.ID) if err != nil { return err } @@ -616,8 +615,8 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update password for user %s failed", req.ID) @@ -628,7 +627,7 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re } // Archive soft deleted the user from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserArchiveRequest, now time.Time) error { +func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req UserArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive") defer span.Finish() @@ -640,7 +639,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA } // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) + err = repo.CanModifyUser(ctx, claims, req.ID) if err != nil { return err } else if claims.Subject != "" && claims.Subject == req.ID && !req.force { @@ -669,8 +668,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive user %s failed", req.ID) @@ -689,8 +688,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive accounts for user %s failed", req.ID) @@ -702,7 +701,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA } // Restore undeletes the user from the database. -func Restore(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserRestoreRequest, now time.Time) error { +func (repo *Repository) Restore(ctx context.Context, claims auth.Claims, req UserRestoreRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Restore") defer span.Finish() @@ -714,7 +713,7 @@ func Restore(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserR } // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) + err = repo.CanModifyUser(ctx, claims, req.ID) if err != nil { return err } @@ -741,8 +740,8 @@ func Restore(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserR // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "unarchive user %s failed", req.ID) @@ -753,7 +752,7 @@ func Restore(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserR } // Delete removes a user from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, req UserDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete") defer span.Finish() @@ -765,7 +764,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe } // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) + err = repo.CanModifyUser(ctx, claims, req.ID) if err != nil { return err } else if claims.Subject != "" && claims.Subject == req.ID && !req.force { @@ -773,7 +772,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe } // Start a new transaction to handle rollbacks on error. - tx, err := dbConn.Begin() + tx, err := repo.DbConn.Begin() if err != nil { return errors.WithStack(err) } @@ -790,7 +789,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -808,7 +807,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -827,7 +826,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe } // ResetPassword sends en email to the user to allow them to reset their password. -func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req UserResetPasswordRequest, secretKey string, now time.Time) (string, error) { +func (repo *Repository) ResetPassword(ctx context.Context, req UserResetPasswordRequest, now time.Time) (string, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetPassword") defer span.Finish() @@ -845,7 +844,7 @@ func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) s query := selectQuery() query.Where(query.Equal("email", req.Email)) - res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false) + res, err := repo.find(ctx, auth.Claims{}, query, []interface{}{}, false) if err != nil { return "", err } else if res == nil || len(res) == 0 { @@ -876,8 +875,8 @@ func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) s // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "Update user %s failed.", u.ID) @@ -895,18 +894,18 @@ func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) s requestIp = vals.RequestIP } - encrypted, err := NewResetHash(ctx, secretKey, resetId, requestIp, req.TTL, now) + encrypted, err := NewResetHash(ctx, repo.SecretKey, resetId, requestIp, req.TTL, now) if err != nil { return "", err } data := map[string]interface{}{ "Name": u.FirstName, - "Url": resetUrl(encrypted), + "Url": repo.ResetUrl(encrypted), "Minutes": req.TTL.Minutes(), } - err = notify.Send(ctx, u.Email, "Reset your Password", "user_reset_password", data) + err = repo.Notify.Send(ctx, u.Email, "Reset your Password", "user_reset_password", data) if err != nil { err = errors.WithMessagef(err, "Send password reset email to %s failed.", u.Email) return "", err @@ -916,7 +915,7 @@ func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) s } // ResetConfirm updates the password for a user using the provided reset password ID. -func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequest, secretKey string, now time.Time) (*User, error) { +func (repo *Repository) ResetConfirm(ctx context.Context, req UserResetConfirmRequest, now time.Time) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetConfirm") defer span.Finish() @@ -928,7 +927,7 @@ func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequ return nil, err } - hash, err := ParseResetHash(ctx, secretKey, req.ResetHash, now) + hash, err := ParseResetHash(ctx, repo.SecretKey, req.ResetHash, now) if err != nil { return nil, err } @@ -939,7 +938,7 @@ func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequ query := selectQuery() query.Where(query.Equal("password_reset", hash.ResetID)) - res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false) + res, err := repo.find(ctx, auth.Claims{}, query, []interface{}{}, false) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -979,8 +978,8 @@ func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequ // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update password for user %s failed", u.ID) @@ -1000,6 +999,10 @@ type MockUserResponse struct { func MockUser(ctx context.Context, dbConn *sqlx.DB, now time.Time) (*MockUserResponse, error) { pass := uuid.NewRandom().String() + repo := &Repository{ + DbConn: dbConn, + } + req := UserCreateRequest{ FirstName: "Lee", LastName: "Brown", @@ -1007,7 +1010,7 @@ func MockUser(ctx context.Context, dbConn *sqlx.DB, now time.Time) (*MockUserRes Password: pass, PasswordConfirm: pass, } - u, err := Create(ctx, auth.Claims{}, dbConn, req, now) + u, err := repo.Create(ctx, auth.Claims{}, req, now) if err != nil { return nil, err } diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 2dc15fd..edfaf49 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -18,7 +18,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -28,6 +31,16 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + // Mock the methods needed to make a password reset. + resetUrl := func(string) string { + return "" + } + notify := ¬ify.MockEmail{} + secretKey := "6368616e676520746869732070617373" + + repo = NewRepository(test.MasterDB, resetUrl, notify, secretKey) + return m.Run() } @@ -219,7 +232,7 @@ func TestCreateValidation(t *testing.T) { { ctx := tests.Context() - res, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + res, err := repo.Create(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -272,7 +285,7 @@ func TestCreateValidationEmailUnique(t *testing.T) { Password: "akTechFr0n!ier", PasswordConfirm: "akTechFr0n!ier", } - user1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) + user1, err := repo.Create(ctx, auth.Claims{}, req1, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -286,7 +299,7 @@ func TestCreateValidationEmailUnique(t *testing.T) { PasswordConfirm: "W0rkL1fe#", } expectedErr := errors.New("Key: 'UserCreateRequest.email' Error:Field validation for 'email' failed on the 'unique' tag") - _, err = Create(ctx, auth.Claims{}, test.MasterDB, req2, now) + _, err = repo.Create(ctx, auth.Claims{}, req2, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -374,7 +387,7 @@ func TestCreateClaims(t *testing.T) { { ctx := tests.Context() - _, err := Create(ctx, tt.claims, test.MasterDB, tt.req, now) + _, err := repo.Create(ctx, tt.claims, tt.req, now) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) @@ -421,7 +434,7 @@ func TestUpdateValidation(t *testing.T) { { ctx := tests.Context() - err := Update(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + err := repo.Update(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -463,7 +476,7 @@ func TestUpdateValidationEmailUnique(t *testing.T) { Password: "akTechFr0n!ier", PasswordConfirm: "akTechFr0n!ier", } - user1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) + user1, err := repo.Create(ctx, auth.Claims{}, req1, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -476,7 +489,7 @@ func TestUpdateValidationEmailUnique(t *testing.T) { Password: "W0rkL1fe#", PasswordConfirm: "W0rkL1fe#", } - user2, err := Create(ctx, auth.Claims{}, test.MasterDB, req2, now) + user2, err := repo.Create(ctx, auth.Claims{}, req2, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -488,7 +501,7 @@ func TestUpdateValidationEmailUnique(t *testing.T) { Email: &user1.Email, } expectedErr := errors.New("Key: 'UserUpdateRequest.email' Error:Field validation for 'email' failed on the 'unique' tag") - err = Update(ctx, auth.Claims{}, test.MasterDB, updateReq, now) + err = repo.Update(ctx, auth.Claims{}, updateReq, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tUpdate failed.", tests.Failed) @@ -518,7 +531,7 @@ func TestUpdatePassword(t *testing.T) { // Create a new user for testing. initPass := uuid.NewRandom().String() - user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{ + user, err := repo.Create(ctx, auth.Claims{}, UserCreateRequest{ FirstName: "Lee", LastName: "Brown", Email: uuid.NewRandom().String() + "@geeksinthewoods.com", @@ -549,7 +562,7 @@ func TestUpdatePassword(t *testing.T) { expectedErr := errors.New("Key: 'UserUpdatePasswordRequest.id' Error:Field validation for 'id' failed on the 'required' tag\n" + "Key: 'UserUpdatePasswordRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" + "Key: 'UserUpdatePasswordRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag") - err = UpdatePassword(ctx, auth.Claims{}, test.MasterDB, UserUpdatePasswordRequest{}, now) + err = repo.UpdatePassword(ctx, auth.Claims{}, UserUpdatePasswordRequest{}, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tUpdate failed.", tests.Failed) @@ -567,7 +580,7 @@ func TestUpdatePassword(t *testing.T) { // Update the users password. newPass := uuid.NewRandom().String() - err = UpdatePassword(ctx, auth.Claims{}, test.MasterDB, UserUpdatePasswordRequest{ + err = repo.UpdatePassword(ctx, auth.Claims{}, UserUpdatePasswordRequest{ ID: user.ID, Password: newPass, PasswordConfirm: newPass, @@ -800,7 +813,7 @@ func TestCrud(t *testing.T) { // Always create the new user with empty claims, testing claims for create user // will be handled separately. - user, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, tt.create, now) + user, err := repo.Create(tests.Context(), auth.Claims{}, tt.create, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate user failed.", tests.Failed) @@ -823,7 +836,7 @@ func TestCrud(t *testing.T) { // Update the user. updateReq := tt.update(user) - err = Update(ctx, tt.claims(user, accountId), test.MasterDB, updateReq, now) + err = repo.Update(ctx, tt.claims(user, accountId), updateReq, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) @@ -832,7 +845,7 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tUpdate ok.", tests.Success) // Find the user and make sure the updates where made. - findRes, err := ReadByID(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) + findRes, err := repo.ReadByID(ctx, tt.claims(user, accountId), user.ID) if err != nil && errors.Cause(err) != tt.findErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.findErr) @@ -846,14 +859,14 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the user. - err = Archive(ctx, tt.claims(user, accountId), test.MasterDB, UserArchiveRequest{ID: user.ID, force: true}, now) + err = repo.Archive(ctx, tt.claims(user, accountId), UserArchiveRequest{ID: user.ID, force: true}, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) t.Fatalf("\t%s\tArchive failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the archived user with the includeArchived false should result in not found. - _, err = ReadByID(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) + _, err = repo.ReadByID(ctx, tt.claims(user, accountId), user.ID) if err != nil && errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -861,7 +874,7 @@ func TestCrud(t *testing.T) { } // Trying to find the archived user with the includeArchived true should result no error. - _, err = Read(ctx, tt.claims(user, accountId), test.MasterDB, + _, err = repo.Read(ctx, tt.claims(user, accountId), UserReadRequest{ID: user.ID, IncludeArchived: true}) if err != nil { t.Log("\t\tGot :", err) @@ -871,14 +884,14 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tArchive ok.", tests.Success) // Restore (un-delete) the user. - err = Restore(ctx, tt.claims(user, accountId), test.MasterDB, UserRestoreRequest{ID: user.ID}, now) + err = repo.Restore(ctx, tt.claims(user, accountId), UserRestoreRequest{ID: user.ID}, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) t.Fatalf("\t%s\tUnarchive failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the archived user with the includeArchived false should result no error. - _, err = ReadByID(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) + _, err = repo.ReadByID(ctx, tt.claims(user, accountId), user.ID) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tUnarchive Read failed.", tests.Failed) @@ -887,14 +900,14 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tUnarchive ok.", tests.Success) // Delete (hard-delete) the user. - err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, UserDeleteRequest{ID: user.ID, force: true}) + err = repo.Delete(ctx, tt.claims(user, accountId), UserDeleteRequest{ID: user.ID, force: true}) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) t.Fatalf("\t%s\tUpdate failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the deleted user with the includeArchived true should result in not found. - _, err = ReadByID(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) + _, err = repo.ReadByID(ctx, tt.claims(user, accountId), user.ID) if errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -917,7 +930,7 @@ func TestFind(t *testing.T) { var users []*User for i := 0; i <= 4; i++ { - user, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, UserCreateRequest{ + user, err := repo.Create(tests.Context(), auth.Claims{}, UserCreateRequest{ FirstName: "Lee", LastName: "Brown", Email: uuid.NewRandom().String() + "@geeksinthewoods.com", @@ -1029,7 +1042,7 @@ func TestFind(t *testing.T) { { ctx := tests.Context() - res, err := Find(ctx, auth.Claims{}, test.MasterDB, tt.req) + res, err := repo.Find(ctx, auth.Claims{}, tt.req) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) @@ -1064,7 +1077,7 @@ func TestResetPassword(t *testing.T) { // Create a new user for testing. initPass := uuid.NewRandom().String() - user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{ + user, err := repo.Create(ctx, auth.Claims{}, UserCreateRequest{ FirstName: "Lee", LastName: "Brown", Email: uuid.NewRandom().String() + "@geeksinthewoods.com", @@ -1091,18 +1104,10 @@ func TestResetPassword(t *testing.T) { t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) } - // Mock the methods needed to make a password reset. - resetUrl := func(string) string { - return "" - } - notify := ¬ify.MockEmail{} - - secretKey := "6368616e676520746869732070617373" - // Ensure validation is working by trying ResetPassword with an empty request. { expectedErr := errors.New("Key: 'UserResetPasswordRequest.email' Error:Field validation for 'email' failed on the 'required' tag") - _, err = ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{}, secretKey, now) + _, err = repo.ResetPassword(ctx, UserResetPasswordRequest{}, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tResetPassword failed.", tests.Failed) @@ -1122,10 +1127,10 @@ func TestResetPassword(t *testing.T) { ttl := time.Hour // Make the reset password request. - resetHash, err := ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{ + resetHash, err := repo.ResetPassword(ctx, UserResetPasswordRequest{ Email: user.Email, TTL: ttl, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tResetPassword failed.", tests.Failed) @@ -1133,7 +1138,7 @@ func TestResetPassword(t *testing.T) { t.Logf("\t%s\tResetPassword ok.", tests.Success) // Read the user to ensure the password_reset field was set. - user, err = ReadByID(ctx, auth.Claims{}, test.MasterDB, user.ID) + user, err = repo.ReadByID(ctx, auth.Claims{}, user.ID) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tRead failed.", tests.Failed) @@ -1146,7 +1151,7 @@ func TestResetPassword(t *testing.T) { expectedErr := errors.New("Key: 'UserResetConfirmRequest.reset_hash' Error:Field validation for 'reset_hash' failed on the 'required' tag\n" + "Key: 'UserResetConfirmRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" + "Key: 'UserResetConfirmRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag") - _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{}, secretKey, now) + _, err = repo.ResetConfirm(ctx, UserResetConfirmRequest{}, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) @@ -1166,11 +1171,11 @@ func TestResetPassword(t *testing.T) { // Ensure the TTL is enforced. { newPass := uuid.NewRandom().String() - _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + _, err = repo.ResetConfirm(ctx, UserResetConfirmRequest{ ResetHash: resetHash, Password: newPass, PasswordConfirm: newPass, - }, secretKey, now.UTC().Add(ttl*2)) + }, now.UTC().Add(ttl*2)) if errors.Cause(err) != ErrResetExpired { t.Logf("\t\tGot : %+v", errors.Cause(err)) t.Logf("\t\tWant: %+v", ErrResetExpired) @@ -1181,11 +1186,11 @@ func TestResetPassword(t *testing.T) { // Assuming we have received the email and clicked the link, we now can ensure confirm works. newPass := uuid.NewRandom().String() - reset, err := ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + reset, err := repo.ResetConfirm(ctx, UserResetConfirmRequest{ ResetHash: resetHash, Password: newPass, PasswordConfirm: newPass, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) @@ -1199,11 +1204,11 @@ func TestResetPassword(t *testing.T) { // Ensure the reset hash does not work after its used. { newPass := uuid.NewRandom().String() - _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + _, err = repo.ResetConfirm(ctx, UserResetConfirmRequest{ ResetHash: resetHash, Password: newPass, PasswordConfirm: newPass, - }, secretKey, now) + }, now) if errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", errors.Cause(err)) t.Logf("\t\tWant: %+v", ErrNotFound) diff --git a/internal/user_account/models.go b/internal/user_account/models.go index 2b63ef0..e2131a4 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -3,6 +3,7 @@ package user_account import ( "context" "database/sql/driver" + "github.com/jmoiron/sqlx" "strings" "time" @@ -13,6 +14,18 @@ import ( "gopkg.in/go-playground/validator.v9" ) +// Repository defines the required dependencies for UserAccount. +type Repository struct { + DbConn *sqlx.DB +} + +// NewRepository creates a new Repository that defines dependencies for UserAccount. +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + DbConn: db, + } +} + // UserAccount defines the one to many relationship of an user to an account. This // will enable a single user access to multiple accounts without having duplicate // users. Each association of a user to an account has a set of roles and a status diff --git a/internal/user_account/user.go b/internal/user_account/user.go index 9c63821..152bff2 100644 --- a/internal/user_account/user.go +++ b/internal/user_account/user.go @@ -6,13 +6,12 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) // UserFindByAccount lists all the users for a given account ID. -func UserFindByAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindByAccountRequest) (Users, error) { +func (repo *Repository) UserFindByAccount(ctx context.Context, claims auth.Claims, req UserFindByAccountRequest) (Users, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.UserFindByAccount") defer span.Finish() @@ -113,12 +112,12 @@ func UserFindByAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, } queryStr, moreQueryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) queryArgs = append(queryArgs, moreQueryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, queryArgs...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, queryArgs...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find users failed") diff --git a/internal/user_account/user_account.go b/internal/user_account/user_account.go index 98de009..4fde70f 100644 --- a/internal/user_account/user_account.go +++ b/internal/user_account/user_account.go @@ -49,14 +49,14 @@ func mapRowsToUserAccount(rows *sql.Rows) (*UserAccount, error) { } // CanReadAccount determines if claims has the authority to access the specified user account by user ID. -func CanReadAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string) error { - err := account.CanReadAccount(ctx, claims, dbConn, accountID) +func (repo *Repository) CanReadAccount(ctx context.Context, claims auth.Claims, accountID string) error { + err := account.CanReadAccount(ctx, claims, accountID) return mapAccountError(err) } // CanModifyAccount determines if claims has the authority to modify the specified user ID. -func CanModifyAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string) error { - err := account.CanModifyAccount(ctx, claims, dbConn, accountID) +func (repo *Repository) CanModifyAccount(ctx context.Context, claims auth.Claims, accountID string) error { + err := account.CanModifyAccount(ctx, claims, accountID) return mapAccountError(err) } diff --git a/internal/user_account/user_account_test.go b/internal/user_account/user_account_test.go index 7ff38c7..2728273 100644 --- a/internal/user_account/user_account_test.go +++ b/internal/user_account/user_account_test.go @@ -17,7 +17,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -27,6 +30,9 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + repo = NewRepository(test.MasterDB) + return m.Run() } From e45dd56149cc70f9e02c6da33c88ec8979c0f102 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Wed, 14 Aug 2019 11:40:26 -0800 Subject: [PATCH 03/21] Completed updating biz logic packages to use repository pattern --- internal/account/account.go | 70 ++++++----- .../account_preference/account_preference.go | 43 ++++--- .../account_preference_test.go | 34 ++--- internal/account/account_preference/models.go | 15 ++- internal/account/account_test.go | 44 ++++--- internal/account/models.go | 15 ++- internal/project/models.go | 16 ++- internal/project/project.go | 51 ++++---- internal/project/project_test.go | 13 +- .../project_routes.go | 20 +-- internal/signup/models.go | 21 ++++ internal/signup/signup.go | 13 +- internal/signup/signup_test.go | 27 +++- internal/user/models.go | 28 ++--- internal/user/user.go | 44 ++++--- internal/user/user_test.go | 14 +-- internal/user_account/invite/invite.go | 52 ++++---- internal/user_account/invite/invite_test.go | 55 ++++---- internal/user_account/invite/models.go | 29 +++++ internal/user_account/models.go | 4 +- internal/user_account/user_account.go | 60 ++++----- internal/user_account/user_account_test.go | 40 +++--- internal/user_auth/auth.go | 33 +++-- internal/user_auth/auth_test.go | 118 +++++++++--------- internal/user_auth/models.go | 24 ++++ 25 files changed, 530 insertions(+), 353 deletions(-) rename internal/{project-routes => project_route}/project_routes.go (64%) diff --git a/internal/account/account.go b/internal/account/account.go index 5a8dc7a..19575ee 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -64,6 +64,11 @@ func CanReadAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, ac return nil } +// CanReadAccount determines if claims has the authority to access the specified account ID. +func (repo *Repository) CanReadAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string) error { + return repo.CanReadAccount(ctx, claims, repo.DbConn, accountID) +} + // CanModifyAccount determines if claims has the authority to modify the specified account ID. func CanModifyAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string) error { // If the request has claims from a specific account, ensure that the claims @@ -105,6 +110,11 @@ func CanModifyAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, return nil } +// CanModifyAccount determines if claims has the authority to modify the specified account ID. +func (repo *Repository) CanModifyAccount(ctx context.Context, claims auth.Claims, accountID string) error { + return CanModifyAccount(ctx, claims, repo.DbConn, accountID) +} + // applyClaimsSelect applies a sub-query to the provided query to enforce ACL based on // the claims provided. // 1. All role types can access their user ID @@ -150,7 +160,7 @@ func selectQuery() *sqlbuilder.SelectBuilder { // Find gets all the accounts from the database based on the request params. // TODO: Need to figure out why can't parse the args when appending the where // to the query. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountFindRequest) (Accounts, error) { +func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req AccountFindRequest) (Accounts, error) { query := selectQuery() if req.Where != "" { @@ -166,7 +176,7 @@ func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountF query.Offset(int(*req.Offset)) } - return find(ctx, claims, dbConn, query, req.Args, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, req.Args, req.IncludeArchived) } // find internal method for getting all the accounts from the database using a select query. @@ -242,14 +252,14 @@ func UniqueName(ctx context.Context, dbConn *sqlx.DB, name, accountId string) (b } // Create inserts a new account into the database. -func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountCreateRequest, now time.Time) (*Account, error) { +func (repo *Repository) Create(ctx context.Context, claims auth.Claims, req AccountCreateRequest, now time.Time) (*Account, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Create") defer span.Finish() v := webcontext.Validator() // Validation account name is unique in the database. - uniq, err := UniqueName(ctx, dbConn, req.Name, "") + uniq, err := UniqueName(ctx, repo.DbConn, req.Name, "") if err != nil { return nil, err } @@ -310,8 +320,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "create account failed") @@ -322,15 +332,15 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun } // ReadByID gets the specified user by ID from the database. -func ReadByID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) (*Account, error) { - return Read(ctx, claims, dbConn, AccountReadRequest{ +func (repo *Repository) ReadByID(ctx context.Context, claims auth.Claims, id string) (*Account, error) { + return repo.Read(ctx, claims, AccountReadRequest{ ID: id, IncludeArchived: false, }) } // Read gets the specified account from the database. -func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountReadRequest) (*Account, error) { +func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req AccountReadRequest) (*Account, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Read") defer span.Finish() @@ -345,7 +355,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountR query := sqlbuilder.NewSelectBuilder() query.Where(query.Equal("id", req.ID)) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -358,7 +368,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountR } // Update replaces an account in the database. -func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountUpdateRequest, now time.Time) error { +func (repo *Repository) Update(ctx context.Context, claims auth.Claims, req AccountUpdateRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Update") defer span.Finish() @@ -366,7 +376,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun if req.Name != nil { // Validation account name is unique in the database. - uniq, err := UniqueName(ctx, dbConn, *req.Name, req.ID) + uniq, err := UniqueName(ctx, repo.DbConn, *req.Name, req.ID) if err != nil { return err } @@ -382,7 +392,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun } // Ensure the claims can modify the account specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.ID) + err = CanModifyAccount(ctx, claims, repo.DbConn, req.ID) if err != nil { return err } @@ -460,8 +470,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update account %s failed", req.ID) @@ -472,7 +482,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun } // Archive soft deleted the account from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountArchiveRequest, now time.Time) error { +func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req AccountArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Archive") defer span.Finish() @@ -484,7 +494,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou } // Ensure the claims can modify the account specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.ID) + err = CanModifyAccount(ctx, claims, repo.DbConn, req.ID) if err != nil { return err } @@ -511,8 +521,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive account %s failed", req.ID) @@ -531,8 +541,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive users for account %s failed", req.ID) @@ -544,7 +554,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou } // Delete removes an account from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, req AccountDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Delete") defer span.Finish() @@ -556,13 +566,13 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun } // Ensure the claims can modify the account specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.ID) + err = CanModifyAccount(ctx, claims, repo.DbConn, req.ID) if err != nil { return err } // Start a new transaction to handle rollbacks on error. - tx, err := dbConn.Begin() + tx, err := repo.DbConn.Begin() if err != nil { return errors.WithStack(err) } @@ -579,7 +589,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -602,7 +612,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -620,7 +630,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -642,6 +652,10 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun func MockAccount(ctx context.Context, dbConn *sqlx.DB, now time.Time) (*Account, error) { s := AccountStatus_Active + repo := &Repository{ + DbConn: dbConn, + } + req := AccountCreateRequest{ Name: uuid.NewRandom().String(), Address1: "103 East Main St", @@ -652,5 +666,5 @@ func MockAccount(ctx context.Context, dbConn *sqlx.DB, now time.Time) (*Account, Zipcode: "99686", Status: &s, } - return Create(ctx, auth.Claims{}, dbConn, req, now) + return repo.Create(ctx, auth.Claims{}, req, now) } diff --git a/internal/account/account_preference/account_preference.go b/internal/account/account_preference/account_preference.go index d995f75..3dd0e41 100644 --- a/internal/account/account_preference/account_preference.go +++ b/internal/account/account_preference/account_preference.go @@ -63,7 +63,7 @@ func applyClaimsSelect(ctx context.Context, claims auth.Claims, query *sqlbuilde // Find gets all the account preferences from the database based on the request params. // TODO: Need to figure out why can't parse the args when appending the where to the query. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceFindRequest) ([]*AccountPreference, error) { +func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req AccountPreferenceFindRequest) ([]*AccountPreference, error) { query := sqlbuilder.NewSelectBuilder() if req.Where != "" { query.Where(query.And(req.Where)) @@ -78,11 +78,11 @@ func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountP query.Offset(int(*req.Offset)) } - return find(ctx, claims, dbConn, query, req.Args, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, req.Args, req.IncludeArchived) } // FindByAccountID gets the specified account preferences for an account from the database. -func FindByAccountID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceFindByAccountIDRequest) ([]*AccountPreference, error) { +func (repo *Repository) FindByAccountID(ctx context.Context, claims auth.Claims, req AccountPreferenceFindByAccountIDRequest) ([]*AccountPreference, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account_preference.FindByAccountID") defer span.Finish() @@ -106,7 +106,7 @@ func FindByAccountID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r query.Offset(int(*req.Offset)) } - return find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) } // find internal method for getting all the account preferences from the database using a select query. @@ -157,7 +157,7 @@ func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbu } // Read gets the specified account preference from the database. -func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceReadRequest) (*AccountPreference, error) { +func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req AccountPreferenceReadRequest) (*AccountPreference, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account_preference.Read") defer span.Finish() @@ -173,7 +173,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountP query.Equal("account_id", req.AccountID)), query.Equal("name", req.Name)) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -263,7 +263,7 @@ func Validator() *validator.Validate { } // Set inserts a new account preference or updates an existing on. -func Set(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceSetRequest, now time.Time) error { +func (repo *Repository) Set(ctx context.Context, claims auth.Claims, req AccountPreferenceSetRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account_preference.Set") defer span.Finish() @@ -276,7 +276,7 @@ func Set(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPr } // Ensure the claims can modify the account specified in the request. - err = account.CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = account.CanModifyAccount(ctx, claims, repo.DbConn, req.AccountID) if err != nil { return err } @@ -301,11 +301,11 @@ func Set(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPr // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) sql = sql + " ON CONFLICT ON CONSTRAINT account_preferences_pkey DO UPDATE set value = EXCLUDED.value " - _, err = dbConn.ExecContext(ctx, sql, args...) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "set account preference failed") @@ -316,7 +316,7 @@ func Set(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPr } // Archive soft deleted the account preference from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceArchiveRequest, now time.Time) error { +func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req AccountPreferenceArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account_preference.Archive") defer span.Finish() @@ -328,7 +328,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou } // Ensure the claims can modify the account specified in the request. - err = account.CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = account.CanModifyAccount(ctx, claims, repo.DbConn, req.AccountID) if err != nil { return err } @@ -355,8 +355,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive account preference %s for account %s failed", req.Name, req.AccountID) @@ -367,7 +367,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou } // Delete removes an account preference from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountPreferenceDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, req AccountPreferenceDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.account_preference.Delete") defer span.Finish() @@ -379,13 +379,13 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun } // Ensure the claims can modify the account specified in the request. - err = account.CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = account.CanModifyAccount(ctx, claims, repo.DbConn, req.AccountID) if err != nil { return err } // Start a new transaction to handle rollbacks on error. - tx, err := dbConn.Begin() + tx, err := repo.DbConn.Begin() if err != nil { return errors.WithStack(err) } @@ -397,7 +397,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) + sql = repo.DbConn.Rebind(sql) _, err = tx.ExecContext(ctx, sql, args...) if err != nil { tx.Rollback() @@ -417,10 +417,15 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun // MockAccountPreference returns a fake AccountPreference for testing. func MockAccountPreference(ctx context.Context, dbConn *sqlx.DB, now time.Time) error { + + repo := &Repository{ + DbConn: dbConn, + } + req := AccountPreferenceSetRequest{ AccountID: uuid.NewRandom().String(), Name: AccountPreference_Datetime_Format, Value: AccountPreference_Datetime_Format_Default, } - return Set(ctx, auth.Claims{}, dbConn, req, now) + return repo.Set(ctx, auth.Claims{}, req, now) } diff --git a/internal/account/account_preference/account_preference_test.go b/internal/account/account_preference/account_preference_test.go index e8886f9..2a9ebdf 100644 --- a/internal/account/account_preference/account_preference_test.go +++ b/internal/account/account_preference/account_preference_test.go @@ -1,13 +1,13 @@ package account_preference import ( - "geeks-accelerator/oss/saas-starter-kit/internal/account" "math/rand" "os" "strings" "testing" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" @@ -17,7 +17,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -27,6 +30,9 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + repo = NewRepository(test.MasterDB) + return m.Run() } @@ -66,7 +72,7 @@ func TestSetValidation(t *testing.T) { { ctx := tests.Context() - err := Set(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + err := repo.Set(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -225,7 +231,7 @@ func TestCrud(t *testing.T) { { ctx := tests.Context() - err := Set(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, tt.set, now) + err := repo.Set(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), tt.set, now) if err != nil && errors.Cause(err) != tt.writeErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.writeErr) @@ -234,7 +240,7 @@ func TestCrud(t *testing.T) { // If user doesn't have access to set, create one anyways to test the other endpoints. if tt.writeErr != nil { - err := Set(ctx, auth.Claims{}, test.MasterDB, tt.set, now) + err := repo.Set(ctx, auth.Claims{}, tt.set, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -242,7 +248,7 @@ func TestCrud(t *testing.T) { } // Find the account and make sure the set where made. - readRes, err := Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceReadRequest{ + readRes, err := repo.Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceReadRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, }) @@ -266,7 +272,7 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the account. - err = Archive(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceArchiveRequest{ + err = repo.Archive(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceArchiveRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, }, now) @@ -276,7 +282,7 @@ func TestCrud(t *testing.T) { t.Fatalf("\t%s\tArchive failed.", tests.Failed) } else if tt.findErr == nil { // Trying to find the archived account with the includeArchived false should result in not found. - _, err = Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceReadRequest{ + _, err = repo.Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceReadRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, }) @@ -287,7 +293,7 @@ func TestCrud(t *testing.T) { } // Trying to find the archived account with the includeArchived true should result no error. - _, err = Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceReadRequest{ + _, err = repo.Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceReadRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, IncludeArchived: true, @@ -300,7 +306,7 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tArchive ok.", tests.Success) // Delete (hard-delete) the account. - err = Delete(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceDeleteRequest{ + err = repo.Delete(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceDeleteRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, }) @@ -310,7 +316,7 @@ func TestCrud(t *testing.T) { t.Fatalf("\t%s\tDelete failed.", tests.Failed) } else if tt.writeErr == nil { // Trying to find the deleted account with the includeArchived true should result in not found. - _, err = Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), test.MasterDB, AccountPreferenceReadRequest{ + _, err = repo.Read(ctx, tt.claims(usrAcc.AccountID, usrAcc.UserID), AccountPreferenceReadRequest{ AccountID: tt.set.AccountID, Name: tt.set.Name, IncludeArchived: true, @@ -362,14 +368,14 @@ func TestFind(t *testing.T) { var prefs []*AccountPreference for idx, req := range reqs { - err = Set(tests.Context(), auth.Claims{}, test.MasterDB, req, now.Add(time.Second*time.Duration(idx))) + err = repo.Set(tests.Context(), auth.Claims{}, req, now.Add(time.Second*time.Duration(idx))) if err != nil { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tRequest : %+v", req) t.Fatalf("\t%s\tSet failed.", tests.Failed) } - pref, err := Read(tests.Context(), auth.Claims{}, test.MasterDB, AccountPreferenceReadRequest{ + pref, err := repo.Read(tests.Context(), auth.Claims{}, AccountPreferenceReadRequest{ AccountID: req.AccountID, Name: req.Name, }) @@ -479,7 +485,7 @@ func TestFind(t *testing.T) { { ctx := tests.Context() - res, err := Find(ctx, auth.Claims{}, test.MasterDB, tt.req) + res, err := repo.Find(ctx, auth.Claims{}, tt.req) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) diff --git a/internal/account/account_preference/models.go b/internal/account/account_preference/models.go index 869da27..3d383b3 100644 --- a/internal/account/account_preference/models.go +++ b/internal/account/account_preference/models.go @@ -2,15 +2,28 @@ package account_preference import ( "context" - "github.com/pkg/errors" "time" "database/sql/driver" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/jmoiron/sqlx" "github.com/lib/pq" + "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) +// Repository defines the required dependencies for AccountPreference. +type Repository struct { + DbConn *sqlx.DB +} + +// NewRepository creates a new Repository that defines dependencies for AccountPreference. +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + DbConn: db, + } +} + // AccountPreference represents an account setting. type AccountPreference struct { AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` diff --git a/internal/account/account_test.go b/internal/account/account_test.go index 628fc54..8f14b4f 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -17,7 +17,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -27,6 +30,9 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + repo = NewRepository(test.MasterDB) + return m.Run() } @@ -184,7 +190,7 @@ func TestCreateValidation(t *testing.T) { { ctx := tests.Context() - res, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + res, err := repo.Create(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -239,7 +245,7 @@ func TestCreateValidationNameUnique(t *testing.T) { Country: "USA", Zipcode: "99686", } - account1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) + account1, err := repo.Create(ctx, auth.Claims{}, req1, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -255,7 +261,7 @@ func TestCreateValidationNameUnique(t *testing.T) { Zipcode: "99686", } expectedErr := errors.New("Key: 'AccountCreateRequest.name' Error:Field validation for 'name' failed on the 'unique' tag") - _, err = Create(ctx, auth.Claims{}, test.MasterDB, req2, now) + _, err = repo.Create(ctx, auth.Claims{}, req2, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -349,7 +355,7 @@ func TestCreateClaims(t *testing.T) { { ctx := tests.Context() - _, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + _, err := repo.Create(ctx, auth.Claims{}, tt.req, now) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) @@ -396,7 +402,7 @@ func TestUpdateValidation(t *testing.T) { { ctx := tests.Context() - err := Update(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + err := repo.Update(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -440,7 +446,7 @@ func TestUpdateValidationNameUnique(t *testing.T) { Country: "USA", Zipcode: "99686", } - account1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) + account1, err := repo.Create(ctx, auth.Claims{}, req1, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -455,7 +461,7 @@ func TestUpdateValidationNameUnique(t *testing.T) { Country: "USA", Zipcode: "99686", } - account2, err := Create(ctx, auth.Claims{}, test.MasterDB, req2, now) + account2, err := repo.Create(ctx, auth.Claims{}, req2, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -467,7 +473,7 @@ func TestUpdateValidationNameUnique(t *testing.T) { Name: &account1.Name, } expectedErr := errors.New("Key: 'AccountUpdateRequest.name' Error:Field validation for 'name' failed on the 'unique' tag") - err = Update(ctx, auth.Claims{}, test.MasterDB, updateReq, now) + err = repo.Update(ctx, auth.Claims{}, updateReq, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tUpdate failed.", tests.Failed) @@ -728,7 +734,7 @@ func TestCrud(t *testing.T) { // Always create the new account with empty claims, testing claims for create account // will be handled separately. - account, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.create, now) + account, err := repo.Create(ctx, auth.Claims{}, tt.create, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate failed.", tests.Failed) @@ -744,7 +750,7 @@ func TestCrud(t *testing.T) { // Update the account. updateReq := tt.update(account) - err = Update(ctx, tt.claims(account, userId), test.MasterDB, updateReq, now) + err = repo.Update(ctx, tt.claims(account, userId), updateReq, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) @@ -753,7 +759,7 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tUpdate ok.", tests.Success) // Find the account and make sure the updates where made. - findRes, err := ReadByID(ctx, tt.claims(account, userId), test.MasterDB, account.ID) + findRes, err := repo.ReadByID(ctx, tt.claims(account, userId), account.ID) if err != nil && errors.Cause(err) != tt.findErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.findErr) @@ -767,14 +773,14 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the account. - err = Archive(ctx, tt.claims(account, userId), test.MasterDB, AccountArchiveRequest{ID: account.ID}, now) + err = repo.Archive(ctx, tt.claims(account, userId), AccountArchiveRequest{ID: account.ID}, now) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) t.Fatalf("\t%s\tArchive failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the archived account with the includeArchived false should result in not found. - _, err = ReadByID(ctx, tt.claims(account, userId), test.MasterDB, account.ID) + _, err = repo.ReadByID(ctx, tt.claims(account, userId), account.ID) if err != nil && errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -782,7 +788,7 @@ func TestCrud(t *testing.T) { } // Trying to find the archived account with the includeArchived true should result no error. - _, err = Read(ctx, tt.claims(account, userId), test.MasterDB, + _, err = repo.Read(ctx, tt.claims(account, userId), AccountReadRequest{ID: account.ID, IncludeArchived: true}) if err != nil { t.Log("\t\tGot :", err) @@ -792,14 +798,14 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tArchive ok.", tests.Success) // Delete (hard-delete) the account. - err = Delete(ctx, tt.claims(account, userId), test.MasterDB, AccountDeleteRequest{ID: account.ID}) + err = repo.Delete(ctx, tt.claims(account, userId), AccountDeleteRequest{ID: account.ID}) if err != nil && errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.updateErr) t.Fatalf("\t%s\tUpdate failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the deleted account with the includeArchived true should result in not found. - _, err = ReadByID(ctx, tt.claims(account, userId), test.MasterDB, account.ID) + _, err = repo.ReadByID(ctx, tt.claims(account, userId), account.ID) if errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -822,7 +828,7 @@ func TestFind(t *testing.T) { var accounts []*Account for i := 0; i <= 4; i++ { - account, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, AccountCreateRequest{ + account, err := repo.Create(tests.Context(), auth.Claims{}, AccountCreateRequest{ Name: uuid.NewRandom().String(), Address1: "103 East Main St", Address2: "Unit 546", @@ -935,7 +941,7 @@ func TestFind(t *testing.T) { { ctx := tests.Context() - res, err := Find(ctx, auth.Claims{}, test.MasterDB, tt.req) + res, err := repo.Find(ctx, auth.Claims{}, tt.req) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) diff --git a/internal/account/models.go b/internal/account/models.go index 0907b36..843ce7c 100644 --- a/internal/account/models.go +++ b/internal/account/models.go @@ -5,14 +5,27 @@ import ( "database/sql" "database/sql/driver" "encoding/json" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) +// Repository defines the required dependencies for Account. +type Repository struct { + DbConn *sqlx.DB +} + +// NewRepository creates a new Repository that defines dependencies for Account. +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + DbConn: db, + } +} + // Account represents someone with access to our system. type Account struct { ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` diff --git a/internal/project/models.go b/internal/project/models.go index ba52589..eff7cfb 100644 --- a/internal/project/models.go +++ b/internal/project/models.go @@ -2,14 +2,28 @@ package project import ( "context" + "time" + "database/sql/driver" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" - "time" ) +// Repository defines the required dependencies for Project. +type Repository struct { + DbConn *sqlx.DB +} + +// NewRepository creates a new Repository that defines dependencies for Project. +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + DbConn: db, + } +} + // Project represents a workflow. type Project struct { ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` diff --git a/internal/project/project.go b/internal/project/project.go index 990fdd8..b4fe3e2 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -3,6 +3,8 @@ package project import ( "context" "database/sql" + "time" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" @@ -10,7 +12,6 @@ import ( "github.com/pborman/uuid" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" - "time" ) const ( @@ -27,7 +28,7 @@ var ( ) // CanReadProject determines if claims has the authority to access the specified project by id. -func CanReadProject(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) error { +func (repo *Repository) CanReadProject(ctx context.Context, claims auth.Claims, id string) error { // If the request has claims from a specific project, ensure that the claims // has the correct access to the project. @@ -40,9 +41,9 @@ func CanReadProject(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id )) queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) var id string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&id) + err := repo.DbConn.QueryRowContext(ctx, queryStr, args...).Scan(&id) if err != nil && err != sql.ErrNoRows { err = errors.Wrapf(err, "query - %s", query.String()) return err @@ -60,8 +61,8 @@ func CanReadProject(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id } // CanModifyProject determines if claims has the authority to modify the specified project by id. -func CanModifyProject(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) error { - err := CanReadProject(ctx, claims, dbConn, id) +func (repo *Repository) CanModifyProject(ctx context.Context, claims auth.Claims, id string) error { + err := repo.CanReadProject(ctx, claims, id) if err != nil { return err } @@ -124,9 +125,9 @@ func findRequestQuery(req ProjectFindRequest) (*sqlbuilder.SelectBuilder, []inte } // Find gets all the projects from the database based on the request params. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectFindRequest) (Projects, error) { +func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req ProjectFindRequest) (Projects, error) { query, args := findRequestQuery(req) - return find(ctx, claims, dbConn, query, args, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, args, req.IncludeArchived) } // find internal method for getting all the projects from the database using a select query. @@ -177,15 +178,15 @@ func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbu } // ReadByID gets the specified project by ID from the database. -func ReadByID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string) (*Project, error) { - return Read(ctx, claims, dbConn, ProjectReadRequest{ +func (repo *Repository) ReadByID(ctx context.Context, claims auth.Claims, id string) (*Project, error) { + return repo.Read(ctx, claims, ProjectReadRequest{ ID: id, IncludeArchived: false, }) } // Read gets the specified project from the database. -func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectReadRequest) (*Project, error) { +func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req ProjectReadRequest) (*Project, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Read") defer span.Finish() @@ -200,7 +201,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectR query := sqlbuilder.NewSelectBuilder() query.Where(query.Equal("id", req.ID)) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -213,7 +214,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectR } // Create inserts a new project into the database. -func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectCreateRequest, now time.Time) (*Project, error) { +func (repo *Repository) Create(ctx context.Context, claims auth.Claims, req ProjectCreateRequest, now time.Time) (*Project, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Create") defer span.Finish() if claims.Audience != "" { @@ -290,8 +291,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "create project failed") @@ -302,7 +303,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec } // Update replaces an project in the database. -func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectUpdateRequest, now time.Time) error { +func (repo *Repository) Update(ctx context.Context, claims auth.Claims, req ProjectUpdateRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Update") defer span.Finish() @@ -314,7 +315,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec } // Ensure the claims can modify the project specified in the request. - err = CanModifyProject(ctx, claims, dbConn, req.ID) + err = repo.CanModifyProject(ctx, claims, req.ID) if err != nil { return err } @@ -352,8 +353,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec query.Where(query.Equal("id", req.ID)) // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update project %s failed", req.ID) @@ -364,7 +365,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec } // Archive soft deleted the project from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectArchiveRequest, now time.Time) error { +func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req ProjectArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Archive") defer span.Finish() @@ -376,7 +377,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Proje } // Ensure the claims can modify the project specified in the request. - err = CanModifyProject(ctx, claims, dbConn, req.ID) + err = repo.CanModifyProject(ctx, claims, req.ID) if err != nil { return err } @@ -401,8 +402,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Proje query.Where(query.Equal("id", req.ID)) // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive project %s failed", req.ID) @@ -413,7 +414,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Proje } // Delete removes an project from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Delete") defer span.Finish() @@ -425,7 +426,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec } // Ensure the claims can modify the project specified in the request. - err = CanModifyProject(ctx, claims, dbConn, req.ID) + err = repo.CanModifyProject(ctx, claims, req.ID) if err != nil { return err } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index f097c9e..8e702aa 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1,15 +1,19 @@ package project import ( + "os" + "testing" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "github.com/google/go-cmp/cmp" "github.com/huandu/go-sqlbuilder" - "os" - "testing" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -19,6 +23,9 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + repo = NewRepository(test.MasterDB) + return m.Run() } diff --git a/internal/project-routes/project_routes.go b/internal/project_route/project_routes.go similarity index 64% rename from internal/project-routes/project_routes.go rename to internal/project_route/project_routes.go index 14ab093..2c98003 100644 --- a/internal/project-routes/project_routes.go +++ b/internal/project_route/project_routes.go @@ -1,17 +1,17 @@ -package project_routes +package project_route import ( "github.com/pkg/errors" "net/url" ) -type ProjectRoutes struct { +type ProjectRoute struct { webAppUrl url.URL webApiUrl url.URL } -func New(apiBaseUrl, appBaseUrl string) (ProjectRoutes, error) { - var r ProjectRoutes +func New(apiBaseUrl, appBaseUrl string) (ProjectRoute, error) { + var r ProjectRoute apiUrl, err := url.Parse(apiBaseUrl) if err != nil { @@ -28,37 +28,37 @@ func New(apiBaseUrl, appBaseUrl string) (ProjectRoutes, error) { return r, nil } -func (r ProjectRoutes) WebAppUrl(urlPath string) string { +func (r ProjectRoute) WebAppUrl(urlPath string) string { u := r.webAppUrl u.Path = urlPath return u.String() } -func (r ProjectRoutes) WebApiUrl(urlPath string) string { +func (r ProjectRoute) WebApiUrl(urlPath string) string { u := r.webApiUrl u.Path = urlPath return u.String() } -func (r ProjectRoutes) UserResetPassword(resetHash string) string { +func (r ProjectRoute) UserResetPassword(resetHash string) string { u := r.webAppUrl u.Path = "/user/reset-password/" + resetHash return u.String() } -func (r ProjectRoutes) UserInviteAccept(inviteHash string) string { +func (r ProjectRoute) UserInviteAccept(inviteHash string) string { u := r.webAppUrl u.Path = "/users/invite/" + inviteHash return u.String() } -func (r ProjectRoutes) ApiDocs() string { +func (r ProjectRoute) ApiDocs() string { u := r.webApiUrl u.Path = "/docs" return u.String() } -func (r ProjectRoutes) ApiDocsJson() string { +func (r ProjectRoute) ApiDocsJson() string { u := r.webApiUrl u.Path = "/docs/doc.json" return u.String() diff --git a/internal/signup/models.go b/internal/signup/models.go index 8db28ca..a84b272 100644 --- a/internal/signup/models.go +++ b/internal/signup/models.go @@ -2,10 +2,31 @@ package signup import ( "context" + "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/jmoiron/sqlx" ) +// Repository defines the required dependencies for Signup. +type Repository struct { + DbConn *sqlx.DB + User *user.Repository + UserAccount *user_account.Repository + Account *account.Repository +} + +// NewRepository creates a new Repository that defines dependencies for Signup. +func NewRepository(db *sqlx.DB, user *user.Repository, userAccount *user_account.Repository, account *account.Repository) *Repository { + return &Repository{ + DbConn: db, + User: user, + UserAccount: userAccount, + Account: account, + } +} + // SignupRequest contains information needed perform signup. type SignupRequest struct { Account SignupAccount `json:"account" validate:"required"` // Account details. diff --git a/internal/signup/signup.go b/internal/signup/signup.go index ce5e51c..4c049eb 100644 --- a/internal/signup/signup.go +++ b/internal/signup/signup.go @@ -9,25 +9,24 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" - "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) // Signup performs the steps needed to create a new account, new user and then associate // both records with a new user_account entry. -func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req SignupRequest, now time.Time) (*SignupResult, error) { +func (repo *Repository) Signup(ctx context.Context, claims auth.Claims, req SignupRequest, now time.Time) (*SignupResult, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.signup.Signup") defer span.Finish() // Validate the user email address is unique in the database. - uniqEmail, err := user.UniqueEmail(ctx, dbConn, req.User.Email, "") + uniqEmail, err := user.UniqueEmail(ctx, repo.DbConn, req.User.Email, "") if err != nil { return nil, err } ctx = webcontext.ContextAddUniqueValue(ctx, req.User, "Email", uniqEmail) // Validate the account name is unique in the database. - uniqName, err := account.UniqueName(ctx, dbConn, req.Account.Name, "") + uniqName, err := account.UniqueName(ctx, repo.DbConn, req.Account.Name, "") if err != nil { return nil, err } @@ -52,7 +51,7 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup } // Execute user creation. - resp.User, err = user.Create(ctx, claims, dbConn, userReq, now) + resp.User, err = repo.User.Create(ctx, claims, userReq, now) if err != nil { return nil, err } @@ -73,7 +72,7 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup } // Execute account creation. - resp.Account, err = account.Create(ctx, claims, dbConn, accountReq, now) + resp.Account, err = repo.Account.Create(ctx, claims, accountReq, now) if err != nil { return nil, err } @@ -87,7 +86,7 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup //Status: Use default value } - _, err = user_account.Create(ctx, claims, dbConn, ua, now) + _, err = repo.UserAccount.Create(ctx, claims, ua, now) if err != nil { return nil, err } diff --git a/internal/signup/signup_test.go b/internal/signup/signup_test.go index a8d7e95..369f114 100644 --- a/internal/signup/signup_test.go +++ b/internal/signup/signup_test.go @@ -1,19 +1,26 @@ package signup import ( - "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "os" "testing" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/google/go-cmp/cmp" "github.com/pborman/uuid" "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -23,6 +30,13 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + userRepo := user.MockRepository(test.MasterDB) + userAccRepo := user_account.NewRepository(test.MasterDB) + accRepo := account.NewRepository(test.MasterDB) + + repo = NewRepository(test.MasterDB, userRepo, userAccRepo, accRepo) + return m.Run() } @@ -63,7 +77,7 @@ func TestSignupValidation(t *testing.T) { { ctx := tests.Context() - res, err := Signup(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + res, err := repo.Signup(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -127,9 +141,12 @@ func TestSignupFull(t *testing.T) { tknGen := &auth.MockTokenGenerator{} + accPrefRepo := account_preference.NewRepository(test.MasterDB) + authRepo := user_auth.NewRepository(test.MasterDB, tknGen, repo.User, repo.UserAccount, accPrefRepo) + t.Log("Given the need to ensure signup works.") { - res, err := Signup(ctx, auth.Claims{}, test.MasterDB, req, now) + res, err := repo.Signup(ctx, auth.Claims{}, req, now) if err != nil { t.Logf("\t\tGot error : %+v", err) t.Fatalf("\t%s\tSignup failed.", tests.Failed) @@ -162,7 +179,7 @@ func TestSignupFull(t *testing.T) { t.Logf("\t%s\tSignup ok.", tests.Success) // Verify that the user can be authenticated with the updated password. - _, err = user_auth.Authenticate(ctx, test.MasterDB, tknGen, user_auth.AuthenticateRequest{ + _, err = authRepo.Authenticate(ctx, user_auth.AuthenticateRequest{ Email: res.User.Email, Password: req.User.Password, }, time.Hour, now) diff --git a/internal/user/models.go b/internal/user/models.go index b4c65c5..7860dd3 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -4,34 +4,34 @@ import ( "context" "database/sql" "encoding/json" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" - "github.com/jmoiron/sqlx" - "github.com/pkg/errors" - "github.com/sudo-suhas/symcrypto" "strconv" "strings" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/jmoiron/sqlx" "github.com/lib/pq" + "github.com/pkg/errors" + "github.com/sudo-suhas/symcrypto" ) // Repository defines the required dependencies for User. type Repository struct { - DbConn *sqlx.DB - ResetUrl func(string) string - Notify notify.Email - SecretKey string + DbConn *sqlx.DB + ResetUrl func(string) string + Notify notify.Email + secretKey string } // NewRepository creates a new Repository that defines dependencies for User. func NewRepository(db *sqlx.DB, resetUrl func(string) string, notify notify.Email, secretKey string) *Repository { return &Repository{ - DbConn: db, - ResetUrl: resetUrl, - Notify: notify, - SecretKey: secretKey, + DbConn: db, + ResetUrl: resetUrl, + Notify: notify, + secretKey: secretKey, } } diff --git a/internal/user/user.go b/internal/user/user.go index 81d036e..58d005c 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -6,6 +6,7 @@ import ( "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" @@ -200,11 +201,11 @@ func findRequestQuery(req UserFindRequest) (*sqlbuilder.SelectBuilder, []interfa // Find gets all the users from the database based on the request params. func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req UserFindRequest) (Users, error) { query, args := findRequestQuery(req) - return repo.find(ctx, claims, query, args, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, args, req.IncludeArchived) } // find internal method for getting all the users from the database using a select query. -func (repo *Repository) find(ctx context.Context, claims auth.Claims, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) (Users, error) { +func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) (Users, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Find") defer span.Finish() @@ -221,11 +222,11 @@ func (repo *Repository) find(ctx context.Context, claims auth.Claims, query *sql return nil, err } queryStr, queryArgs := query.Build() - queryStr = repo.DbConn.Rebind(queryStr) + queryStr = dbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) + rows, err := dbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find users failed") @@ -247,17 +248,17 @@ func (repo *Repository) find(ctx context.Context, claims auth.Claims, query *sql } // Validation an email address is unique excluding the current user ID. -func (repo *Repository) UniqueEmail(ctx context.Context, email, userId string) (bool, error) { +func UniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bool, error) { query := sqlbuilder.NewSelectBuilder().Select("id").From(userTableName) query.Where(query.And( query.Equal("email", email), query.NotEqual("id", userId), )) queryStr, args := query.Build() - queryStr = repo.DbConn.Rebind(queryStr) + queryStr = dbConn.Rebind(queryStr) var existingId string - err := repo.DbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId) + err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId) if err != nil && err != sql.ErrNoRows { err = errors.Wrapf(err, "query - %s", query.String()) return false, err @@ -283,7 +284,7 @@ func (repo *Repository) Create(ctx context.Context, claims auth.Claims, req User v := webcontext.Validator() // Validation email address is unique in the database. - uniq, err := repo.UniqueEmail(ctx, req.Email, "") + uniq, err := UniqueEmail(ctx, repo.DbConn, req.Email, "") if err != nil { return nil, err } @@ -364,7 +365,7 @@ func (repo *Repository) CreateInvite(ctx context.Context, claims auth.Claims, re v := webcontext.Validator() // Validation email address is unique in the database. - uniq, err := repo.UniqueEmail(ctx, req.Email, "") + uniq, err := UniqueEmail(ctx, repo.DbConn, req.Email, "") if err != nil { return nil, err } @@ -448,7 +449,7 @@ func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req UserRe query := selectQuery() query.Where(query.Equal("id", req.ID)) - res, err := repo.find(ctx, claims, query, []interface{}{}, req.IncludeArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -469,7 +470,7 @@ func (repo *Repository) ReadByEmail(ctx context.Context, claims auth.Claims, ema query := selectQuery() query.Where(query.Equal("email", email)) - res, err := repo.find(ctx, claims, query, []interface{}{}, includedArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, includedArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -489,7 +490,7 @@ func (repo *Repository) Update(ctx context.Context, claims auth.Claims, req User // Validation email address is unique in the database. if req.Email != nil { // Validation email address is unique in the database. - uniq, err := repo.UniqueEmail(ctx, *req.Email, req.ID) + uniq, err := UniqueEmail(ctx, repo.DbConn, *req.Email, req.ID) if err != nil { return err } @@ -844,7 +845,7 @@ func (repo *Repository) ResetPassword(ctx context.Context, req UserResetPassword query := selectQuery() query.Where(query.Equal("email", req.Email)) - res, err := repo.find(ctx, auth.Claims{}, query, []interface{}{}, false) + res, err := find(ctx, auth.Claims{}, repo.DbConn, query, []interface{}{}, false) if err != nil { return "", err } else if res == nil || len(res) == 0 { @@ -894,7 +895,7 @@ func (repo *Repository) ResetPassword(ctx context.Context, req UserResetPassword requestIp = vals.RequestIP } - encrypted, err := NewResetHash(ctx, repo.SecretKey, resetId, requestIp, req.TTL, now) + encrypted, err := NewResetHash(ctx, repo.secretKey, resetId, requestIp, req.TTL, now) if err != nil { return "", err } @@ -927,7 +928,7 @@ func (repo *Repository) ResetConfirm(ctx context.Context, req UserResetConfirmRe return nil, err } - hash, err := ParseResetHash(ctx, repo.SecretKey, req.ResetHash, now) + hash, err := ParseResetHash(ctx, repo.secretKey, req.ResetHash, now) if err != nil { return nil, err } @@ -938,7 +939,7 @@ func (repo *Repository) ResetConfirm(ctx context.Context, req UserResetConfirmRe query := selectQuery() query.Where(query.Equal("password_reset", hash.ResetID)) - res, err := repo.find(ctx, auth.Claims{}, query, []interface{}{}, false) + res, err := find(ctx, auth.Claims{}, repo.DbConn, query, []interface{}{}, false) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -1020,3 +1021,14 @@ func MockUser(ctx context.Context, dbConn *sqlx.DB, now time.Time) (*MockUserRes Password: pass, }, nil } + +func MockRepository(dbConn *sqlx.DB) *Repository { + // Mock the methods needed to make a password reset. + resetUrl := func(string) string { + return "" + } + notify := ¬ify.MockEmail{} + secretKey := "6368616e676520746869732070617373" + + return NewRepository(dbConn, resetUrl, notify, secretKey) +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go index edfaf49..fd40aa6 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -8,7 +8,6 @@ import ( "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "github.com/dgrijalva/jwt-go" "github.com/google/go-cmp/cmp" @@ -32,14 +31,7 @@ func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() - // Mock the methods needed to make a password reset. - resetUrl := func(string) string { - return "" - } - notify := ¬ify.MockEmail{} - secretKey := "6368616e676520746869732070617373" - - repo = NewRepository(test.MasterDB, resetUrl, notify, secretKey) + repo = MockRepository(test.MasterDB) return m.Run() } @@ -930,7 +922,7 @@ func TestFind(t *testing.T) { var users []*User for i := 0; i <= 4; i++ { - user, err := repo.Create(tests.Context(), auth.Claims{}, UserCreateRequest{ + user, err := repo.Create(tests.Context(), auth.Claims{}, UserCreateRequest{ FirstName: "Lee", LastName: "Brown", Email: uuid.NewRandom().String() + "@geeksinthewoods.com", @@ -1042,7 +1034,7 @@ func TestFind(t *testing.T) { { ctx := tests.Context() - res, err := repo.Find(ctx, auth.Claims{}, tt.req) + res, err := repo.Find(ctx, auth.Claims{}, tt.req) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) diff --git a/internal/user_account/invite/invite.go b/internal/user_account/invite/invite.go index a1fe4b1..0ee8262 100644 --- a/internal/user_account/invite/invite.go +++ b/internal/user_account/invite/invite.go @@ -8,11 +8,9 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -29,7 +27,7 @@ var ( ) // SendUserInvites sends emails to the users inviting them to join an account. -func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req SendUserInvitesRequest, secretKey string, now time.Time) ([]string, error) { +func (repo *Repository) SendUserInvites(ctx context.Context, claims auth.Claims, req SendUserInvitesRequest, now time.Time) ([]string, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.SendUserInvites") defer span.Finish() @@ -42,7 +40,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r } // Ensure the claims can modify the account specified in the request. - err = user_account.CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = account.CanModifyAccount(ctx, claims, repo.DbConn, req.AccountID) if err != nil { return nil, err } @@ -51,7 +49,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r emailUserIDs := make(map[string]string) { // Find all users without passing in claims to search all users. - users, err := user.Find(ctx, auth.Claims{}, dbConn, user.UserFindRequest{ + users, err := repo.User.Find(ctx, auth.Claims{}, user.UserFindRequest{ Where: fmt.Sprintf("email in ('%s')", strings.Join(req.Emails, "','")), }) @@ -72,7 +70,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r args = append(args, userID) } - userAccs, err := user_account.Find(ctx, claims, dbConn, user_account.UserAccountFindRequest{ + userAccs, err := repo.UserAccount.Find(ctx, claims, user_account.UserAccountFindRequest{ Where: fmt.Sprintf("user_id in ('%s') and status = '%s'", strings.Join(args, "','"), user_account.UserAccountStatus_Active.String()), @@ -99,7 +97,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r continue } - u, err := user.CreateInvite(ctx, claims, dbConn, user.UserCreateInviteRequest{ + u, err := repo.User.CreateInvite(ctx, claims, user.UserCreateInviteRequest{ Email: email, }, now) if err != nil { @@ -118,7 +116,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r } status := user_account.UserAccountStatus_Invited - _, err = user_account.Create(ctx, claims, dbConn, user_account.UserAccountCreateRequest{ + _, err = repo.UserAccount.Create(ctx, claims, user_account.UserAccountCreateRequest{ UserID: userID, AccountID: req.AccountID, Roles: req.Roles, @@ -133,12 +131,12 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r req.TTL = time.Minute * 90 } - fromUser, err := user.ReadByID(ctx, claims, dbConn, req.UserID) + fromUser, err := repo.User.ReadByID(ctx, claims, req.UserID) if err != nil { return nil, err } - account, err := account.ReadByID(ctx, claims, dbConn, req.AccountID) + account, err := repo.Account.ReadByID(ctx, claims, req.AccountID) if err != nil { return nil, err } @@ -151,7 +149,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r var inviteHashes []string for email, userID := range emailUserIDs { - hash, err := NewInviteHash(ctx, secretKey, userID, req.AccountID, requestIp, req.TTL, now) + hash, err := NewInviteHash(ctx, repo.secretKey, userID, req.AccountID, requestIp, req.TTL, now) if err != nil { return nil, err } @@ -159,13 +157,13 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r data := map[string]interface{}{ "FromUser": fromUser.Response(ctx), "Account": account.Response(ctx), - "Url": resetUrl(hash), + "Url": repo.ResetUrl(hash), "Minutes": req.TTL.Minutes(), } subject := fmt.Sprintf("%s %s has invited you to %s", fromUser.FirstName, fromUser.LastName, account.Name) - err = notify.Send(ctx, email, subject, "user_invite", data) + err = repo.Notify.Send(ctx, email, subject, "user_invite", data) if err != nil { err = errors.WithMessagef(err, "Send invite to %s failed.", email) return nil, err @@ -178,7 +176,7 @@ func SendUserInvites(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, r } // AcceptInvite updates the user using the provided invite hash. -func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, secretKey string, now time.Time) (*user_account.UserAccount, error) { +func (repo *Repository) AcceptInvite(ctx context.Context, req AcceptInviteRequest, now time.Time) (*user_account.UserAccount, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInvite") defer span.Finish() @@ -190,25 +188,25 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, return nil, err } - hash, err := ParseInviteHash(ctx, req.InviteHash, secretKey, now) + hash, err := ParseInviteHash(ctx, req.InviteHash, repo.secretKey, now) if err != nil { return nil, err } - u, err := user.Read(ctx, auth.Claims{}, dbConn, + u, err := repo.User.Read(ctx, auth.Claims{}, user.UserReadRequest{ID: hash.UserID, IncludeArchived: true}) if err != nil { return nil, err } if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { - err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now) + err = repo.User.Restore(ctx, auth.Claims{}, user.UserRestoreRequest{ID: hash.UserID}, now) if err != nil { return nil, err } } - usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{ + usrAcc, err := repo.UserAccount.Read(ctx, auth.Claims{}, user_account.UserAccountReadRequest{ UserID: hash.UserID, AccountID: hash.AccountID, }) @@ -230,7 +228,7 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, if len(u.PasswordHash) > 0 { usrAcc.Status = user_account.UserAccountStatus_Active - err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{ + err = repo.UserAccount.Update(ctx, auth.Claims{}, user_account.UserAccountUpdateRequest{ UserID: usrAcc.UserID, AccountID: usrAcc.AccountID, Status: &usrAcc.Status, @@ -244,7 +242,7 @@ func AcceptInvite(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteRequest, } // AcceptInviteUser updates the user using the provided invite hash. -func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUserRequest, secretKey string, now time.Time) (*user_account.UserAccount, error) { +func (repo *Repository) AcceptInviteUser(ctx context.Context, req AcceptInviteUserRequest, now time.Time) (*user_account.UserAccount, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.AcceptInviteUser") defer span.Finish() @@ -256,25 +254,25 @@ func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUser return nil, err } - hash, err := ParseInviteHash(ctx, req.InviteHash, secretKey, now) + hash, err := ParseInviteHash(ctx, req.InviteHash, repo.secretKey, now) if err != nil { return nil, err } - u, err := user.Read(ctx, auth.Claims{}, dbConn, + u, err := repo.User.Read(ctx, auth.Claims{}, user.UserReadRequest{ID: hash.UserID, IncludeArchived: true}) if err != nil { return nil, err } if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { - err = user.Restore(ctx, auth.Claims{}, dbConn, user.UserRestoreRequest{ID: hash.UserID}, now) + err = repo.User.Restore(ctx, auth.Claims{}, user.UserRestoreRequest{ID: hash.UserID}, now) if err != nil { return nil, err } } - usrAcc, err := user_account.Read(ctx, auth.Claims{}, dbConn, user_account.UserAccountReadRequest{ + usrAcc, err := repo.UserAccount.Read(ctx, auth.Claims{}, user_account.UserAccountReadRequest{ UserID: hash.UserID, AccountID: hash.AccountID, }) @@ -293,7 +291,7 @@ func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUser // These three calls, user.Update, user.UpdatePassword, and user_account.Update // should probably be in a transaction! - err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{ + err = repo.User.Update(ctx, auth.Claims{}, user.UserUpdateRequest{ ID: hash.UserID, Email: &req.Email, FirstName: &req.FirstName, @@ -304,7 +302,7 @@ func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUser return nil, err } - err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{ + err = repo.User.UpdatePassword(ctx, auth.Claims{}, user.UserUpdatePasswordRequest{ ID: hash.UserID, Password: req.Password, PasswordConfirm: req.PasswordConfirm, @@ -314,7 +312,7 @@ func AcceptInviteUser(ctx context.Context, dbConn *sqlx.DB, req AcceptInviteUser } usrAcc.Status = user_account.UserAccountStatus_Active - err = user_account.Update(ctx, auth.Claims{}, dbConn, user_account.UserAccountUpdateRequest{ + err = repo.UserAccount.Update(ctx, auth.Claims{}, user_account.UserAccountUpdateRequest{ UserID: usrAcc.UserID, AccountID: usrAcc.AccountID, Status: &usrAcc.Status, diff --git a/internal/user_account/invite/invite_test.go b/internal/user_account/invite/invite_test.go index 032c6e7..3018733 100644 --- a/internal/user_account/invite/invite_test.go +++ b/internal/user_account/invite/invite_test.go @@ -1,7 +1,6 @@ package invite import ( - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "os" "strings" "testing" @@ -11,6 +10,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "github.com/dgrijalva/jwt-go" @@ -18,7 +18,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -28,6 +31,20 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + userRepo := user.MockRepository(test.MasterDB) + userAccRepo := user_account.NewRepository(test.MasterDB) + accRepo := account.NewRepository(test.MasterDB) + + // Mock the methods needed to make an invite. + resetUrl := func(string) string { + return "" + } + notify := ¬ify.MockEmail{} + secretKey := "6368616e676520746869732070613434" + + repo = NewRepository(test.MasterDB, userRepo, userAccRepo, accRepo, resetUrl, notify, secretKey) + return m.Run() } @@ -42,7 +59,7 @@ func TestSendUserInvites(t *testing.T) { // Create a new user for testing. initPass := uuid.NewRandom().String() - u, err := user.Create(ctx, auth.Claims{}, test.MasterDB, user.UserCreateRequest{ + u, err := repo.User.Create(ctx, auth.Claims{}, user.UserCreateRequest{ FirstName: "Lee", LastName: "Brown", Email: uuid.NewRandom().String() + "@geeksinthewoods.com", @@ -54,7 +71,7 @@ func TestSendUserInvites(t *testing.T) { t.Fatalf("\t%s\tCreate user failed.", tests.Failed) } - a, err := account.Create(ctx, auth.Claims{}, test.MasterDB, account.AccountCreateRequest{ + a, err := repo.Account.Create(ctx, auth.Claims{}, account.AccountCreateRequest{ Name: uuid.NewRandom().String(), Address1: "101 E Main", City: "Valdez", @@ -68,7 +85,7 @@ func TestSendUserInvites(t *testing.T) { } uRoles := []user_account.UserAccountRole{user_account.UserAccountRole_Admin} - _, err = user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err = repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: u.ID, AccountID: a.ID, Roles: uRoles, @@ -91,21 +108,13 @@ func TestSendUserInvites(t *testing.T) { claims.Roles = append(claims.Roles, r.String()) } - // Mock the methods needed to make a password reset. - resetUrl := func(string) string { - return "" - } - notify := ¬ify.MockEmail{} - - secretKey := "6368616e676520746869732070617373" - // Ensure validation is working by trying ResetPassword with an empty request. { expectedErr := errors.New("Key: 'SendUserInvitesRequest.account_id' Error:Field validation for 'account_id' failed on the 'required' tag\n" + "Key: 'SendUserInvitesRequest.user_id' Error:Field validation for 'user_id' failed on the 'required' tag\n" + "Key: 'SendUserInvitesRequest.emails' Error:Field validation for 'emails' failed on the 'required' tag\n" + "Key: 'SendUserInvitesRequest.roles' Error:Field validation for 'roles' failed on the 'required' tag") - _, err = SendUserInvites(ctx, claims, test.MasterDB, resetUrl, notify, SendUserInvitesRequest{}, secretKey, now) + _, err = repo.SendUserInvites(ctx, claims, SendUserInvitesRequest{}, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tInviteUsers failed.", tests.Failed) @@ -129,13 +138,13 @@ func TestSendUserInvites(t *testing.T) { } // Make the reset password request. - inviteHashes, err := SendUserInvites(ctx, claims, test.MasterDB, resetUrl, notify, SendUserInvitesRequest{ + inviteHashes, err := repo.SendUserInvites(ctx, claims, SendUserInvitesRequest{ UserID: u.ID, AccountID: a.ID, Emails: inviteEmails, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, TTL: ttl, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tInviteUsers failed.", tests.Failed) @@ -154,7 +163,7 @@ func TestSendUserInvites(t *testing.T) { "Key: 'AcceptInviteUserRequest.last_name' Error:Field validation for 'last_name' failed on the 'required' tag\n" + "Key: 'AcceptInviteUserRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" + "Key: 'AcceptInviteUserRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag") - _, err = AcceptInviteUser(ctx, test.MasterDB, AcceptInviteUserRequest{}, secretKey, now) + _, err = repo.AcceptInviteUser(ctx, AcceptInviteUserRequest{}, now) if err == nil { t.Logf("\t\tWant: %+v", expectedErr) t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) @@ -174,14 +183,14 @@ func TestSendUserInvites(t *testing.T) { // Ensure the TTL is enforced. { newPass := uuid.NewRandom().String() - _, err = AcceptInviteUser(ctx, test.MasterDB, AcceptInviteUserRequest{ + _, err = repo.AcceptInviteUser(ctx, AcceptInviteUserRequest{ InviteHash: inviteHashes[0], Email: inviteEmails[0], FirstName: "Foo", LastName: "Bar", Password: newPass, PasswordConfirm: newPass, - }, secretKey, now.UTC().Add(ttl*2)) + }, now.UTC().Add(ttl*2)) if errors.Cause(err) != ErrInviteExpired { t.Logf("\t\tGot : %+v", errors.Cause(err)) t.Logf("\t\tWant: %+v", ErrInviteExpired) @@ -194,14 +203,14 @@ func TestSendUserInvites(t *testing.T) { for idx, inviteHash := range inviteHashes { newPass := uuid.NewRandom().String() - hash, err := AcceptInviteUser(ctx, test.MasterDB, AcceptInviteUserRequest{ + hash, err := repo.AcceptInviteUser(ctx, AcceptInviteUserRequest{ InviteHash: inviteHash, Email: inviteEmails[idx], FirstName: "Foo", LastName: "Bar", Password: newPass, PasswordConfirm: newPass, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tInviteAccept failed.", tests.Failed) @@ -227,14 +236,14 @@ func TestSendUserInvites(t *testing.T) { // Ensure the reset hash does not work after its used. { newPass := uuid.NewRandom().String() - _, err = AcceptInviteUser(ctx, test.MasterDB, AcceptInviteUserRequest{ + _, err = repo.AcceptInviteUser(ctx, AcceptInviteUserRequest{ InviteHash: inviteHashes[0], Email: inviteEmails[0], FirstName: "Foo", LastName: "Bar", Password: newPass, PasswordConfirm: newPass, - }, secretKey, now) + }, now) if errors.Cause(err) != ErrUserAccountActive { t.Logf("\t\tGot : %+v", errors.Cause(err)) t.Logf("\t\tWant: %+v", ErrUserAccountActive) diff --git a/internal/user_account/invite/models.go b/internal/user_account/invite/models.go index ca87007..5c7231c 100644 --- a/internal/user_account/invite/models.go +++ b/internal/user_account/invite/models.go @@ -6,12 +6,41 @@ import ( "strings" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sudo-suhas/symcrypto" ) +// Repository defines the required dependencies for User Invite. +type Repository struct { + DbConn *sqlx.DB + User *user.Repository + UserAccount *user_account.Repository + Account *account.Repository + ResetUrl func(string) string + Notify notify.Email + secretKey string +} + +// NewRepository creates a new Repository that defines dependencies for User Invite. +func NewRepository(db *sqlx.DB, user *user.Repository, userAccount *user_account.Repository, account *account.Repository, + resetUrl func(string) string, notify notify.Email, secretKey string) *Repository { + return &Repository{ + DbConn: db, + User: user, + UserAccount: userAccount, + Account: account, + ResetUrl: resetUrl, + Notify: notify, + secretKey: secretKey, + } +} + // SendUserInvitesRequest defines the data needed to make an invite request. type SendUserInvitesRequest struct { AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` diff --git a/internal/user_account/models.go b/internal/user_account/models.go index e2131a4..df16106 100644 --- a/internal/user_account/models.go +++ b/internal/user_account/models.go @@ -2,13 +2,13 @@ package user_account import ( "context" - "database/sql/driver" - "github.com/jmoiron/sqlx" "strings" "time" + "database/sql/driver" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" diff --git a/internal/user_account/user_account.go b/internal/user_account/user_account.go index 4fde70f..e436f6e 100644 --- a/internal/user_account/user_account.go +++ b/internal/user_account/user_account.go @@ -3,12 +3,12 @@ package user_account import ( "context" "database/sql" - "geeks-accelerator/oss/saas-starter-kit/internal/user" "time" "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/user" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pborman/uuid" @@ -50,13 +50,13 @@ func mapRowsToUserAccount(rows *sql.Rows) (*UserAccount, error) { // CanReadAccount determines if claims has the authority to access the specified user account by user ID. func (repo *Repository) CanReadAccount(ctx context.Context, claims auth.Claims, accountID string) error { - err := account.CanReadAccount(ctx, claims, accountID) + err := account.CanReadAccount(ctx, claims, repo.DbConn, accountID) return mapAccountError(err) } // CanModifyAccount determines if claims has the authority to modify the specified user ID. func (repo *Repository) CanModifyAccount(ctx context.Context, claims auth.Claims, accountID string) error { - err := account.CanModifyAccount(ctx, claims, accountID) + err := account.CanModifyAccount(ctx, claims, repo.DbConn, accountID) return mapAccountError(err) } @@ -131,9 +131,9 @@ func findRequestQuery(req UserAccountFindRequest) (*sqlbuilder.SelectBuilder, [] } // Find gets all the user accounts from the database based on the request params. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountFindRequest) (UserAccounts, error) { +func (repo *Repository) Find(ctx context.Context, claims auth.Claims, req UserAccountFindRequest) (UserAccounts, error) { query, args := findRequestQuery(req) - return find(ctx, claims, dbConn, query, args, req.IncludeArchived) + return find(ctx, claims, repo.DbConn, query, args, req.IncludeArchived) } // Find gets all the user accounts from the database based on the select query @@ -180,7 +180,7 @@ func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbu } // Retrieve gets the specified user from the database. -func FindByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, includedArchived bool) (UserAccounts, error) { +func (repo *Repository) FindByUserID(ctx context.Context, claims auth.Claims, userID string, includedArchived bool) (UserAccounts, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.FindByUserID") defer span.Finish() @@ -190,7 +190,7 @@ func FindByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, user query.OrderBy("created_at") // Execute the find accounts method. - res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, includedArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -202,7 +202,7 @@ func FindByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, user } // Create a user account for a given user with specified roles. -func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountCreateRequest, now time.Time) (*UserAccount, error) { +func (repo *Repository) Create(ctx context.Context, claims auth.Claims, req UserAccountCreateRequest, now time.Time) (*UserAccount, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Create") defer span.Finish() @@ -214,7 +214,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc } // Ensure the claims can modify the account specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = repo.CanModifyAccount(ctx, claims, req.AccountID) if err != nil { return nil, err } @@ -237,7 +237,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc existQuery.Equal("account_id", req.AccountID), existQuery.Equal("user_id", req.UserID), )) - existing, err := find(ctx, claims, dbConn, existQuery, []interface{}{}, true) + existing, err := find(ctx, claims, repo.DbConn, existQuery, []interface{}{}, true) if err != nil { return nil, err } @@ -251,7 +251,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc Roles: &req.Roles, unArchive: true, } - err = Update(ctx, claims, dbConn, upReq, now) + err = repo.Update(ctx, claims, upReq, now) if err != nil { return nil, err } @@ -285,8 +285,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "add account %s to user %s failed", req.AccountID, req.UserID) @@ -298,7 +298,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc } // Read gets the specified user account from the database. -func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountReadRequest) (*UserAccount, error) { +func (repo *Repository) Read(ctx context.Context, claims auth.Claims, req UserAccountReadRequest) (*UserAccount, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Read") defer span.Finish() @@ -315,7 +315,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAcco query.Equal("user_id", req.UserID), query.Equal("account_id", req.AccountID))) - res, err := find(ctx, claims, dbConn, query, []interface{}{}, req.IncludeArchived) + res, err := find(ctx, claims, repo.DbConn, query, []interface{}{}, req.IncludeArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -328,7 +328,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAcco } // Update replaces a user account in the database. -func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountUpdateRequest, now time.Time) error { +func (repo *Repository) Update(ctx context.Context, claims auth.Claims, req UserAccountUpdateRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Update") defer span.Finish() @@ -340,7 +340,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc } // Ensure the claims can modify the user specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = repo.CanModifyAccount(ctx, claims, req.AccountID) if err != nil { return err } @@ -389,8 +389,8 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "update account %s for user %s failed", req.AccountID, req.UserID) @@ -401,7 +401,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc } // Archive soft deleted the user account from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountArchiveRequest, now time.Time) error { +func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req UserAccountArchiveRequest, now time.Time) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Archive") defer span.Finish() @@ -413,7 +413,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA } // Ensure the claims can modify the user specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = repo.CanModifyAccount(ctx, claims, req.AccountID) if err != nil { return err } @@ -441,8 +441,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "archive account %s from user %s failed", req.AccountID, req.UserID) @@ -453,7 +453,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA } // Delete removes a user account from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, req UserAccountDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Delete") defer span.Finish() @@ -465,7 +465,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc } // Ensure the claims can modify the user specified in the request. - err = CanModifyAccount(ctx, claims, dbConn, req.AccountID) + err = repo.CanModifyAccount(ctx, claims, req.AccountID) if err != nil { return err } @@ -480,8 +480,8 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "delete account %s for user %s failed", req.AccountID, req.UserID) @@ -509,6 +509,10 @@ func MockUserAccount(ctx context.Context, dbConn *sqlx.DB, now time.Time, roles return nil, err } + repo := &Repository{ + DbConn: dbConn, + } + status := UserAccountStatus_Active req := UserAccountCreateRequest{ @@ -517,7 +521,7 @@ func MockUserAccount(ctx context.Context, dbConn *sqlx.DB, now time.Time, roles Status: &status, Roles: roles, } - ua, err := Create(ctx, auth.Claims{}, dbConn, req, now) + ua, err := repo.Create(ctx, auth.Claims{}, req, now) if err != nil { return nil, err } diff --git a/internal/user_account/user_account_test.go b/internal/user_account/user_account_test.go index 2728273..eb88466 100644 --- a/internal/user_account/user_account_test.go +++ b/internal/user_account/user_account_test.go @@ -1,7 +1,6 @@ package user_account import ( - "github.com/lib/pq" "math/rand" "os" "strings" @@ -13,6 +12,7 @@ import ( "github.com/dgrijalva/jwt-go" "github.com/google/go-cmp/cmp" "github.com/huandu/go-sqlbuilder" + "github.com/lib/pq" "github.com/pborman/uuid" "github.com/pkg/errors" ) @@ -232,7 +232,7 @@ func TestCreateValidation(t *testing.T) { t.Fatalf("\t%s\tMock account failed.", tests.Failed) } - res, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + res, err := repo.Create(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -300,7 +300,7 @@ func TestCreateExistingEntry(t *testing.T) { AccountID: accountID, Roles: []UserAccountRole{UserAccountRole_User}, } - ua1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) + ua1, err := repo.Create(ctx, auth.Claims{}, req1, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) @@ -313,7 +313,7 @@ func TestCreateExistingEntry(t *testing.T) { AccountID: req1.AccountID, Roles: []UserAccountRole{UserAccountRole_Admin}, } - ua2, err := Create(ctx, auth.Claims{}, test.MasterDB, req2, now) + ua2, err := repo.Create(ctx, auth.Claims{}, req2, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) @@ -322,7 +322,7 @@ func TestCreateExistingEntry(t *testing.T) { } // Now archive the user account to test trying to create a new entry for an archived entry - err = Archive(tests.Context(), auth.Claims{}, test.MasterDB, UserAccountArchiveRequest{ + err = repo.Archive(tests.Context(), auth.Claims{}, UserAccountArchiveRequest{ UserID: req1.UserID, AccountID: req1.AccountID, }, now) @@ -332,7 +332,7 @@ func TestCreateExistingEntry(t *testing.T) { } // Find the archived user account - arcRes, err := Read(tests.Context(), auth.Claims{}, test.MasterDB, + arcRes, err := repo.Read(tests.Context(), auth.Claims{}, UserAccountReadRequest{UserID: req1.UserID, AccountID: req1.AccountID, IncludeArchived: true}) if err != nil || arcRes == nil { t.Log("\t\tGot :", err) @@ -347,7 +347,7 @@ func TestCreateExistingEntry(t *testing.T) { AccountID: req1.AccountID, Roles: []UserAccountRole{UserAccountRole_User}, } - ua3, err := Create(ctx, auth.Claims{}, test.MasterDB, req3, now) + ua3, err := repo.Create(ctx, auth.Claims{}, req3, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) @@ -356,7 +356,7 @@ func TestCreateExistingEntry(t *testing.T) { } // Ensure the user account has archived_at empty - findRes, err := Read(tests.Context(), auth.Claims{}, test.MasterDB, + findRes, err := repo.Read(tests.Context(), auth.Claims{}, UserAccountReadRequest{UserID: req1.UserID, AccountID: req1.AccountID}) if err != nil || arcRes == nil { t.Log("\t\tGot :", err) @@ -414,7 +414,7 @@ func TestUpdateValidation(t *testing.T) { { ctx := tests.Context() - err := Update(ctx, auth.Claims{}, test.MasterDB, tt.req, now) + err := repo.Update(ctx, auth.Claims{}, tt.req, now) if err != tt.error { // TODO: need a better way to handle validation errors as they are // of type interface validator.ValidationErrorsTranslations @@ -564,7 +564,7 @@ func TestCrud(t *testing.T) { AccountID: accountID, Roles: []UserAccountRole{UserAccountRole_User}, } - ua, err := Create(tests.Context(), tt.claims(userID, accountID), test.MasterDB, createReq, now) + ua, err := repo.Create(tests.Context(), tt.claims(userID, accountID), createReq, now) if err != nil && errors.Cause(err) != tt.createErr { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.createErr) @@ -577,7 +577,7 @@ func TestCrud(t *testing.T) { } if tt.createErr == ErrForbidden { - ua, err = Create(tests.Context(), auth.Claims{}, test.MasterDB, createReq, now) + ua, err = repo.Create(tests.Context(), auth.Claims{}, createReq, now) if err != nil && errors.Cause(err) != tt.createErr { t.Logf("\t\tGot : %+v", err) t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) @@ -590,7 +590,7 @@ func TestCrud(t *testing.T) { AccountID: accountID, Roles: &UserAccountRoles{UserAccountRole_Admin}, } - err = Update(tests.Context(), tt.claims(userID, accountID), test.MasterDB, updateReq, now) + err = repo.Update(tests.Context(), tt.claims(userID, accountID), updateReq, now) if err != nil { if errors.Cause(err) != tt.updateErr { t.Logf("\t\tGot : %+v", err) @@ -604,7 +604,7 @@ func TestCrud(t *testing.T) { // Find the account for the user to verify the updates where made. There should only // be one account associated with the user for this test. - findRes, err := Find(tests.Context(), tt.claims(userID, accountID), test.MasterDB, UserAccountFindRequest{ + findRes, err := repo.Find(tests.Context(), tt.claims(userID, accountID), UserAccountFindRequest{ Where: "user_id = ? or account_id = ?", Args: []interface{}{userID, accountID}, Order: []string{"created_at"}, @@ -632,7 +632,7 @@ func TestCrud(t *testing.T) { } // Archive (soft-delete) the user account. - err = Archive(tests.Context(), tt.claims(userID, accountID), test.MasterDB, UserAccountArchiveRequest{ + err = repo.Archive(tests.Context(), tt.claims(userID, accountID), UserAccountArchiveRequest{ UserID: userID, AccountID: accountID, }, now) @@ -642,7 +642,7 @@ func TestCrud(t *testing.T) { t.Fatalf("\t%s\tArchive user account failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the archived user with the includeArchived false should result in not found. - _, err = FindByUserID(tests.Context(), tt.claims(userID, accountID), test.MasterDB, userID, false) + _, err = repo.FindByUserID(tests.Context(), tt.claims(userID, accountID), userID, false) if errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -650,7 +650,7 @@ func TestCrud(t *testing.T) { } // Trying to find the archived user with the includeArchived true should result no error. - findRes, err = FindByUserID(tests.Context(), tt.claims(userID, accountID), test.MasterDB, userID, true) + findRes, err = repo.FindByUserID(tests.Context(), tt.claims(userID, accountID), userID, true) if err != nil { t.Logf("\t\tGot : %+v", err) t.Fatalf("\t%s\tVerify archive user account failed when including archived.", tests.Failed) @@ -675,7 +675,7 @@ func TestCrud(t *testing.T) { t.Logf("\t%s\tArchive user account ok.", tests.Success) // Delete (hard-delete) the user account. - err = Delete(tests.Context(), tt.claims(userID, accountID), test.MasterDB, UserAccountDeleteRequest{ + err = repo.Delete(tests.Context(), tt.claims(userID, accountID), UserAccountDeleteRequest{ UserID: userID, AccountID: accountID, }) @@ -685,7 +685,7 @@ func TestCrud(t *testing.T) { t.Fatalf("\t%s\tDelete user account failed.", tests.Failed) } else if tt.updateErr == nil { // Trying to find the deleted user with the includeArchived true should result in not found. - _, err = FindByUserID(tests.Context(), tt.claims(userID, accountID), test.MasterDB, userID, true) + _, err = repo.FindByUserID(tests.Context(), tt.claims(userID, accountID), userID, true) if errors.Cause(err) != ErrNotFound { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", ErrNotFound) @@ -725,7 +725,7 @@ func TestFind(t *testing.T) { } // Execute Create that will associate the user with the account. - ua, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, UserAccountCreateRequest{ + ua, err := repo.Create(tests.Context(), auth.Claims{}, UserAccountCreateRequest{ UserID: userID, AccountID: accountID, Roles: []UserAccountRole{UserAccountRole_User}, @@ -836,7 +836,7 @@ func TestFind(t *testing.T) { { ctx := tests.Context() - res, err := Find(ctx, auth.Claims{}, test.MasterDB, tt.req) + res, err := repo.Find(ctx, auth.Claims{}, tt.req) if errors.Cause(err) != tt.error { t.Logf("\t\tGot : %+v", err) t.Logf("\t\tWant: %+v", tt.error) diff --git a/internal/user_auth/auth.go b/internal/user_auth/auth.go index 34bf1ca..4e90b10 100644 --- a/internal/user_auth/auth.go +++ b/internal/user_auth/auth.go @@ -3,7 +3,6 @@ package user_auth import ( "context" "database/sql" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "strings" "time" @@ -11,8 +10,8 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" @@ -40,7 +39,7 @@ const ( // Authenticate finds a user by their email and verifies their password. On success // it returns a Token that can be used to authenticate access to the application in // the future. -func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, req AuthenticateRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) Authenticate(ctx context.Context, req AuthenticateRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_auth.Authenticate") defer span.Finish() @@ -51,7 +50,7 @@ func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, r return Token{}, err } - u, err := user.ReadByEmail(ctx, auth.Claims{}, dbConn, req.Email, false) + u, err := repo.User.ReadByEmail(ctx, auth.Claims{}, req.Email, false) if err != nil { if errors.Cause(err) == user.ErrNotFound { err = errors.WithStack(ErrAuthenticationFailure) @@ -73,11 +72,11 @@ func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, r } // The user is successfully authenticated with the supplied email and password. - return generateToken(ctx, dbConn, tknGen, auth.Claims{}, u.ID, req.AccountID, expires, now, scopes...) + return repo.generateToken(ctx, auth.Claims{}, u.ID, req.AccountID, expires, now, scopes...) } // SwitchAccount allows users to switch between multiple accounts, this changes the claim audience. -func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, req SwitchAccountRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) SwitchAccount(ctx context.Context, claims auth.Claims, req SwitchAccountRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_auth.SwitchAccount") defer span.Finish() @@ -97,11 +96,11 @@ func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, // Generate a token for the user ID in supplied in claims as the Subject. Pass // in the supplied claims as well to enforce ACLs when finding the current // list of accounts for the user. - return generateToken(ctx, dbConn, tknGen, claims, claims.Subject, req.AccountID, expires, now, scopes...) + return repo.generateToken(ctx, claims, claims.Subject, req.AccountID, expires, now, scopes...) } // VirtualLogin allows users to mock being logged in as other users. -func VirtualLogin(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, req VirtualLoginRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) VirtualLogin(ctx context.Context, claims auth.Claims, req VirtualLoginRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_auth.VirtualLogin") defer span.Finish() @@ -113,7 +112,7 @@ func VirtualLogin(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, c } // Find all the accounts that the current user has access to. - usrAccs, err := user_account.FindByUserID(ctx, claims, dbConn, claims.Subject, false) + usrAccs, err := repo.UserAccount.FindByUserID(ctx, claims, claims.Subject, false) if err != nil { return Token{}, err } @@ -142,23 +141,23 @@ func VirtualLogin(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, c // Generate a token for the user ID in supplied in claims as the Subject. Pass // in the supplied claims as well to enforce ACLs when finding the current // list of accounts for the user. - return generateToken(ctx, dbConn, tknGen, claims, req.UserID, req.AccountID, expires, now, scopes...) + return repo.generateToken(ctx, claims, req.UserID, req.AccountID, expires, now, scopes...) } // VirtualLogout allows switch back to their root user/account. -func VirtualLogout(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) VirtualLogout(ctx context.Context, claims auth.Claims, expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_auth.VirtualLogout") defer span.Finish() // Generate a token for the user ID in supplied in claims as the Subject. Pass // in the supplied claims as well to enforce ACLs when finding the current // list of accounts for the user. - return generateToken(ctx, dbConn, tknGen, claims, claims.RootUserID, claims.RootAccountID, expires, now, scopes...) + return repo.generateToken(ctx, claims, claims.RootUserID, claims.RootAccountID, expires, now, scopes...) } // generateToken generates claims for the supplied user ID and account ID and then // returns the token for the generated claims used for authentication. -func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, userID, accountID string, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) generateToken(ctx context.Context, claims auth.Claims, userID, accountID string, expires time.Duration, now time.Time, scopes ...string) (Token, error) { type userAccount struct { AccountID string @@ -184,8 +183,8 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, // fetch all places from the db queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) - rows, err := dbConn.QueryContext(ctx, queryStr, queryArgs...) + queryStr = repo.DbConn.Rebind(queryStr) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, queryArgs...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) return nil, err @@ -339,7 +338,7 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, tz, _ = time.LoadLocation(account.AccountTimezone.String) } - prefs, err := account_preference.FindByAccountID(ctx, auth.Claims{}, dbConn, account_preference.AccountPreferenceFindByAccountIDRequest{ + prefs, err := repo.AccountPreference.FindByAccountID(ctx, auth.Claims{}, account_preference.AccountPreferenceFindByAccountIDRequest{ AccountID: accountID, }) if err != nil { @@ -393,7 +392,7 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, newClaims.RootUserID = claims.RootUserID // Generate a token for the user with the defined claims. - tknStr, err := tknGen.GenerateToken(newClaims) + tknStr, err := repo.TknGen.GenerateToken(newClaims) if err != nil { return Token{}, errors.Wrap(err, "generating token") } diff --git a/internal/user_auth/auth_test.go b/internal/user_auth/auth_test.go index db837b6..e7fb61a 100644 --- a/internal/user_auth/auth_test.go +++ b/internal/user_auth/auth_test.go @@ -8,8 +8,8 @@ import ( "time" "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" @@ -18,7 +18,10 @@ import ( "github.com/pkg/errors" ) -var test *tests.Test +var ( + test *tests.Test + repo *Repository +) // TestMain is the entry point for testing. func TestMain(m *testing.M) { @@ -28,6 +31,15 @@ func TestMain(m *testing.M) { func testMain(m *testing.M) int { test = tests.New() defer test.TearDown() + + tknGen := &auth.MockTokenGenerator{} + + userRepo := user.MockRepository(test.MasterDB) + userAccRepo := user_account.NewRepository(test.MasterDB) + accPrefRepo := account_preference.NewRepository(test.MasterDB) + + repo = NewRepository(test.MasterDB, tknGen, userRepo, userAccRepo, accPrefRepo) + return m.Run() } @@ -41,14 +53,12 @@ func TestAuthenticate(t *testing.T) { { ctx := tests.Context() - tknGen := &auth.MockTokenGenerator{} - // Auth tokens are valid for an our and is verified against current time. // Issue the token one hour ago. now := time.Now().Add(time.Hour * -1) // Try to authenticate an invalid user. - _, err := Authenticate(ctx, test.MasterDB, tknGen, + _, err := repo.Authenticate(ctx, AuthenticateRequest{ Email: "doesnotexist@gmail.com", Password: "xy7", @@ -82,7 +92,7 @@ func TestAuthenticate(t *testing.T) { // is always greater than the first user_account entry created so it will // be returned consistently back in the same order, last. account2Role := auth.RoleUser - _, err = user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err = repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usrAcc.UserID, AccountID: acc2.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(account2Role)}, @@ -92,7 +102,7 @@ func TestAuthenticate(t *testing.T) { now = now.Add(time.Minute * 5) // Try to authenticate valid user with invalid password. - _, err = Authenticate(ctx, test.MasterDB, tknGen, + _, err = repo.Authenticate(ctx, AuthenticateRequest{ Email: usrAcc.User.Email, Password: "xy7", @@ -106,7 +116,7 @@ func TestAuthenticate(t *testing.T) { t.Logf("\t%s\tAuthenticate user w/invalid password ok.", tests.Success) // Verify that the user can be authenticated with the created user. - tkn1, err := Authenticate(ctx, test.MasterDB, tknGen, + tkn1, err := repo.Authenticate(ctx, AuthenticateRequest{ Email: usrAcc.User.Email, Password: usrAcc.User.Password, @@ -118,7 +128,7 @@ func TestAuthenticate(t *testing.T) { t.Logf("\t%s\tAuthenticate user ok.", tests.Success) // Ensure the token string was correctly generated. - claims1, err := tknGen.ParseClaims(tkn1.AccessToken) + claims1, err := repo.TknGen.ParseClaims(tkn1.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -135,7 +145,7 @@ func TestAuthenticate(t *testing.T) { t.Logf("\t%s\tAuthenticate parse claims from token ok.", tests.Success) // Try switching to a second account using the first set of claims. - tkn2, err := SwitchAccount(ctx, test.MasterDB, tknGen, claims1, + tkn2, err := repo.SwitchAccount(ctx, claims1, SwitchAccountRequest{AccountID: acc2.ID}, time.Hour, now) if err != nil { t.Log("\t\tGot :", err) @@ -144,7 +154,7 @@ func TestAuthenticate(t *testing.T) { t.Logf("\t%s\tSwitchAccount user ok.", tests.Success) // Ensure the token string was correctly generated. - claims2, err := tknGen.ParseClaims(tkn2.AccessToken) + claims2, err := repo.TknGen.ParseClaims(tkn2.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -172,8 +182,6 @@ func TestUserUpdatePassword(t *testing.T) { now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - tknGen := &auth.MockTokenGenerator{} - // Create a new user for testing. usrAcc, err := user_account.MockUserAccount(ctx, test.MasterDB, now, user_account.UserAccountRole_User) if err != nil { @@ -183,7 +191,7 @@ func TestUserUpdatePassword(t *testing.T) { t.Logf("\t%s\tCreate user account ok.", tests.Success) // Verify that the user can be authenticated with the created user. - _, err = Authenticate(ctx, test.MasterDB, tknGen, + _, err = repo.Authenticate(ctx, AuthenticateRequest{ Email: usrAcc.User.Email, Password: usrAcc.User.Password, @@ -195,7 +203,7 @@ func TestUserUpdatePassword(t *testing.T) { // Update the users password. newPass := uuid.NewRandom().String() - err = user.UpdatePassword(ctx, auth.Claims{}, test.MasterDB, user.UserUpdatePasswordRequest{ + err = repo.User.UpdatePassword(ctx, auth.Claims{}, user.UserUpdatePasswordRequest{ ID: usrAcc.UserID, Password: newPass, PasswordConfirm: newPass, @@ -207,7 +215,7 @@ func TestUserUpdatePassword(t *testing.T) { t.Logf("\t%s\tUpdatePassword ok.", tests.Success) // Verify that the user can be authenticated with the updated password. - _, err = Authenticate(ctx, test.MasterDB, tknGen, + _, err = repo.Authenticate(ctx, AuthenticateRequest{ Email: usrAcc.User.Email, Password: newPass, @@ -229,8 +237,6 @@ func TestUserResetPassword(t *testing.T) { now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - tknGen := &auth.MockTokenGenerator{} - // Create a new user for testing. usrAcc, err := user_account.MockUserAccount(ctx, test.MasterDB, now, user_account.UserAccountRole_User) if err != nil { @@ -239,21 +245,13 @@ func TestUserResetPassword(t *testing.T) { } t.Logf("\t%s\tCreate user account ok.", tests.Success) - // Mock the methods needed to make a password reset. - resetUrl := func(string) string { - return "" - } - notify := ¬ify.MockEmail{} - - secretKey := "6368616e676520746869732070617373" - ttl := time.Hour // Make the reset password request. - resetHash, err := user.ResetPassword(ctx, test.MasterDB, resetUrl, notify, user.UserResetPasswordRequest{ + resetHash, err := repo.User.ResetPassword(ctx, user.UserResetPasswordRequest{ Email: usrAcc.User.Email, TTL: ttl, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tResetPassword failed.", tests.Failed) @@ -262,11 +260,11 @@ func TestUserResetPassword(t *testing.T) { // Assuming we have received the email and clicked the link, we now can ensure confirm works. newPass := uuid.NewRandom().String() - reset, err := user.ResetConfirm(ctx, test.MasterDB, user.UserResetConfirmRequest{ + reset, err := repo.User.ResetConfirm(ctx, user.UserResetConfirmRequest{ ResetHash: resetHash, Password: newPass, PasswordConfirm: newPass, - }, secretKey, now) + }, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) @@ -278,7 +276,7 @@ func TestUserResetPassword(t *testing.T) { t.Logf("\t%s\tResetConfirm ok.", tests.Success) // Verify that the user can be authenticated with the updated password. - _, err = Authenticate(ctx, test.MasterDB, tknGen, + _, err = repo.Authenticate(ctx, AuthenticateRequest{ Email: usrAcc.User.Email, Password: newPass, @@ -340,7 +338,7 @@ func TestSwitchAccount(t *testing.T) { } // Associate the second account with root user. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usrAcc.UserID, AccountID: acc2.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(roles[1])}, @@ -359,7 +357,7 @@ func TestSwitchAccount(t *testing.T) { } // Associate the third account with root user. - usrAcc3, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc3, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usrAcc.UserID, AccountID: acc3.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(roles[2])}, @@ -426,7 +424,7 @@ func TestSwitchAccount(t *testing.T) { } // Associate the second account with root user. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usrAcc.UserID, AccountID: acc2.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_Admin}, @@ -445,7 +443,7 @@ func TestSwitchAccount(t *testing.T) { } // Associate the third account with root user. - usrAcc3, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc3, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usrAcc.UserID, AccountID: acc3.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, @@ -472,8 +470,6 @@ func TestSwitchAccount(t *testing.T) { // Add 30 minutes to now to simulate time passing. now = now.Add(time.Minute * 5) - tknGen := &auth.MockTokenGenerator{} - t.Log("Given the need to switch accounts.") { for i, authTest := range authTests { @@ -481,7 +477,7 @@ func TestSwitchAccount(t *testing.T) { { // Verify that the user can be authenticated with the created user. var claims1 auth.Claims - tkn1, err := Authenticate(ctx, test.MasterDB, tknGen, + tkn1, err := repo.Authenticate(ctx, AuthenticateRequest{ Email: authTest.root.User.Email, Password: authTest.root.User.Password, @@ -491,7 +487,7 @@ func TestSwitchAccount(t *testing.T) { t.Fatalf("\t%s\tAuthenticate user failed.", tests.Failed) } else { // Ensure the token string was correctly generated. - claims1, err = tknGen.ParseClaims(tkn1.AccessToken) + claims1, err = repo.TknGen.ParseClaims(tkn1.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -511,7 +507,7 @@ func TestSwitchAccount(t *testing.T) { // Try to switch to account 2. var claims2 auth.Claims - tkn2, err := SwitchAccount(ctx, test.MasterDB, tknGen, claims1, authTest.switch1Req, time.Hour, now, authTest.switch1Scopes...) + tkn2, err := repo.SwitchAccount(ctx, claims1, authTest.switch1Req, time.Hour, now, authTest.switch1Scopes...) if err != authTest.switch1Err { if errors.Cause(err) != authTest.switch1Err { t.Log("\t\tExpected :", authTest.switch1Err) @@ -520,7 +516,7 @@ func TestSwitchAccount(t *testing.T) { } } else { // Ensure the token string was correctly generated. - claims2, err = tknGen.ParseClaims(tkn2.AccessToken) + claims2, err = repo.TknGen.ParseClaims(tkn2.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -549,7 +545,7 @@ func TestSwitchAccount(t *testing.T) { } // Try to switch to account 3. - tkn3, err := SwitchAccount(ctx, test.MasterDB, tknGen, claims2, authTest.switch2Req, time.Hour, now, authTest.switch2Scopes...) + tkn3, err := repo.SwitchAccount(ctx, claims2, authTest.switch2Req, time.Hour, now, authTest.switch2Scopes...) if err != authTest.switch2Err { if errors.Cause(err) != authTest.switch2Err { t.Log("\t\tExpected :", authTest.switch2Err) @@ -558,7 +554,7 @@ func TestSwitchAccount(t *testing.T) { } } else { // Ensure the token string was correctly generated. - claims3, err := tknGen.ParseClaims(tkn3.AccessToken) + claims3, err := repo.TknGen.ParseClaims(tkn3.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -610,7 +606,7 @@ func TestVirtualLogin(t *testing.T) { var authTests []authTest // Root admin -> role admin -> role admin - if true { + { // Create a new user for testing. usrAcc, err := user_account.MockUserAccount(ctx, test.MasterDB, now, user_account.UserAccountRole_Admin) if err != nil { @@ -625,7 +621,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr2.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_Admin)}, @@ -642,7 +638,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc3, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc3, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr3.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_Admin)}, @@ -687,7 +683,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr2.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_Admin)}, @@ -704,7 +700,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc3, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc3, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr3.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_User)}, @@ -749,7 +745,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr2.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_User)}, @@ -766,7 +762,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc3, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc3, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr3.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_Admin)}, @@ -811,7 +807,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr2.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_Admin)}, @@ -850,7 +846,7 @@ func TestVirtualLogin(t *testing.T) { } // Associate second user with basic role associated with the same account. - usrAcc2, err := user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + usrAcc2, err := repo.UserAccount.Create(ctx, auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: usr2.ID, AccountID: usrAcc.AccountID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole(user_account.UserAccountRole_User)}, @@ -876,8 +872,6 @@ func TestVirtualLogin(t *testing.T) { // Add 30 minutes to now to simulate time passing. now = now.Add(time.Minute * 5) - tknGen := &auth.MockTokenGenerator{} - t.Log("Given the need to virtual login.") { for i, authTest := range authTests { @@ -885,7 +879,7 @@ func TestVirtualLogin(t *testing.T) { { // Verify that the user can be authenticated with the created user. var claims1 auth.Claims - tkn1, err := Authenticate(ctx, test.MasterDB, tknGen, + tkn1, err := repo.Authenticate(ctx, AuthenticateRequest{ Email: authTest.root.User.Email, Password: authTest.root.User.Password, @@ -895,7 +889,7 @@ func TestVirtualLogin(t *testing.T) { t.Fatalf("\t%s\tAuthenticate user failed.", tests.Failed) } else { // Ensure the token string was correctly generated. - claims1, err = tknGen.ParseClaims(tkn1.AccessToken) + claims1, err = repo.TknGen.ParseClaims(tkn1.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -915,7 +909,7 @@ func TestVirtualLogin(t *testing.T) { // Try virtual login to user 2. var claims2 auth.Claims - tkn2, err := VirtualLogin(ctx, test.MasterDB, tknGen, claims1, authTest.login1Req, time.Hour, now) + tkn2, err := repo.VirtualLogin(ctx, claims1, authTest.login1Req, time.Hour, now) if err != authTest.login1Err { if errors.Cause(err) != authTest.login1Err { t.Log("\t\tExpected :", authTest.login1Err) @@ -924,7 +918,7 @@ func TestVirtualLogin(t *testing.T) { } } else { // Ensure the token string was correctly generated. - claims2, err = tknGen.ParseClaims(tkn2.AccessToken) + claims2, err = repo.TknGen.ParseClaims(tkn2.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -948,7 +942,7 @@ func TestVirtualLogin(t *testing.T) { } // Try virtual login to user 3. - tkn3, err := VirtualLogin(ctx, test.MasterDB, tknGen, claims2, authTest.login2Req, time.Hour, now) + tkn3, err := repo.VirtualLogin(ctx, claims2, authTest.login2Req, time.Hour, now) if err != authTest.login2Err { if errors.Cause(err) != authTest.login2Err { t.Log("\t\tExpected :", authTest.login2Err) @@ -957,7 +951,7 @@ func TestVirtualLogin(t *testing.T) { } } else { // Ensure the token string was correctly generated. - claims3, err := tknGen.ParseClaims(tkn3.AccessToken) + claims3, err := repo.TknGen.ParseClaims(tkn3.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) @@ -976,14 +970,14 @@ func TestVirtualLogin(t *testing.T) { t.Logf("\t%s\tVirtualLogin user 2 with role %s ok.", tests.Success, authTest.login2Role) if authTest.login2Logout { - tknOut, err := VirtualLogout(ctx, test.MasterDB, tknGen, claims2, time.Hour, now) + tknOut, err := repo.VirtualLogout(ctx, claims2, time.Hour, now) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tVirtualLogout user 2 failed.", tests.Failed) } // Ensure the token string was correctly generated. - claimsOut, err := tknGen.ParseClaims(tknOut.AccessToken) + claimsOut, err := repo.TknGen.ParseClaims(tknOut.AccessToken) if err != nil { t.Log("\t\tGot :", err) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) diff --git a/internal/user_auth/models.go b/internal/user_auth/models.go index 5990253..12e41d2 100644 --- a/internal/user_auth/models.go +++ b/internal/user_auth/models.go @@ -3,9 +3,33 @@ package user_auth import ( "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/jmoiron/sqlx" ) +// Repository defines the required dependencies for User Auth. +type Repository struct { + DbConn *sqlx.DB + TknGen TokenGenerator + User *user.Repository + UserAccount *user_account.Repository + AccountPreference *account_preference.Repository +} + +// NewRepository creates a new Repository that defines dependencies for User Auth. +func NewRepository(db *sqlx.DB, tknGen TokenGenerator, user *user.Repository, usrAcc *user_account.Repository, accPref *account_preference.Repository) *Repository { + return &Repository{ + DbConn: db, + TknGen: tknGen, + User: user, + UserAccount: usrAcc, + AccountPreference: accPref, + } +} + // AuthenticateRequest defines what information is required to authenticate a user. type AuthenticateRequest struct { Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"` From 04e73c8f4ea9235216d779be945d17586ebc6cfa Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Wed, 14 Aug 2019 12:53:40 -0800 Subject: [PATCH 04/21] completed updating web-api --- cmd/web-api/handlers/account.go | 11 +- cmd/web-api/handlers/example.go | 48 +++++++ cmd/web-api/handlers/project.go | 27 ++-- cmd/web-api/handlers/routes.go | 146 +++++++++------------ cmd/web-api/handlers/signup.go | 7 +- cmd/web-api/handlers/user.go | 41 +++--- cmd/web-api/handlers/user_account.go | 27 ++-- cmd/web-api/main.go | 138 ++++++++++++++++--- cmd/web-api/tests/project_test.go | 2 +- cmd/web-api/tests/signup_test.go | 4 +- cmd/web-api/tests/tests_test.go | 61 +++++++-- cmd/web-api/tests/user_account_test.go | 7 +- cmd/web-api/tests/user_test.go | 10 +- cmd/web-app/main.go | 105 +++++++-------- internal/platform/notify/email_disabled.go | 21 +++ internal/platform/notify/email_smtp.go | 22 ++++ internal/project/project.go | 6 +- 17 files changed, 444 insertions(+), 239 deletions(-) create mode 100644 cmd/web-api/handlers/example.go create mode 100644 internal/platform/notify/email_disabled.go diff --git a/cmd/web-api/handlers/account.go b/cmd/web-api/handlers/account.go index b26c5a1..592d962 100644 --- a/cmd/web-api/handlers/account.go +++ b/cmd/web-api/handlers/account.go @@ -10,14 +10,13 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Account represents the Account API method handler set. type Account struct { - MasterDB *sqlx.DB + *account.Repository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } @@ -35,7 +34,7 @@ type Account struct { // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /accounts/{id} [get] -func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -52,7 +51,7 @@ func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque includeArchived = b } - res, err := account.Read(ctx, claims, a.MasterDB, account.AccountReadRequest{ + res, err := h.Repository.Read(ctx, claims, account.AccountReadRequest{ ID: params["id"], IncludeArchived: includeArchived, }) @@ -82,7 +81,7 @@ func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /accounts [patch] -func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { @@ -102,7 +101,7 @@ func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req return web.RespondJsonError(ctx, w, err) } - err = account.Update(ctx, claims, a.MasterDB, req, v.Now) + err = h.Repository.Update(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { diff --git a/cmd/web-api/handlers/example.go b/cmd/web-api/handlers/example.go new file mode 100644 index 0000000..fe15ddd --- /dev/null +++ b/cmd/web-api/handlers/example.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "context" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "geeks-accelerator/oss/saas-starter-kit/internal/project" + "github.com/pkg/errors" + "net/http" +) + +// Example represents the Example API method handler set. +type Example struct { + Project *project.Repository + + // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. +} + +// ErrorResponse returns example error messages. +func (h *Example) ErrorResponse(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + v, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + if qv := r.URL.Query().Get("test-validation-error"); qv != "" { + _, err := h.Project.Create(ctx, auth.Claims{}, project.ProjectCreateRequest{}, v.Now) + return web.RespondJsonError(ctx, w, err) + } + + if qv := r.URL.Query().Get("test-web-error"); qv != "" { + terr := errors.New("Some random error") + terr = errors.WithMessage(terr, "Actual error message") + rerr := weberror.NewError(ctx, terr, http.StatusBadRequest).(*weberror.Error) + rerr.Message = "Test Web Error Message" + return web.RespondJsonError(ctx, w, rerr) + } + + if qv := r.URL.Query().Get("test-error"); qv != "" { + terr := errors.New("Test error") + terr = errors.WithMessage(terr, "Error message") + return web.RespondJsonError(ctx, w, terr) + } + + return nil +} diff --git a/cmd/web-api/handlers/project.go b/cmd/web-api/handlers/project.go index f2dbf6c..82c835f 100644 --- a/cmd/web-api/handlers/project.go +++ b/cmd/web-api/handlers/project.go @@ -11,14 +11,13 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Project represents the Project API method handler set. type Project struct { - MasterDB *sqlx.DB + *project.Repository // ADD OTHER STATE LIKE THE LOGGER IF NEEDED. } @@ -41,7 +40,7 @@ type Project struct { // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [get] -func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -108,7 +107,7 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque // return web.RespondJsonError(ctx, w, err) //} - res, err := project.Find(ctx, claims, p.MasterDB, req) + res, err := h.Repository.Find(ctx, claims, req) if err != nil { return err } @@ -134,7 +133,7 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/{id} [get] -func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -151,7 +150,7 @@ func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque includeArchived = b } - res, err := project.Read(ctx, claims, p.MasterDB, project.ProjectReadRequest{ + res, err := h.Repository.Read(ctx, claims, project.ProjectReadRequest{ ID: params["id"], IncludeArchived: includeArchived, }) @@ -182,7 +181,7 @@ func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [post] -func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -201,7 +200,7 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req return web.RespondJsonError(ctx, w, err) } - res, err := project.Create(ctx, claims, p.MasterDB, req, v.Now) + res, err := h.Repository.Create(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -232,7 +231,7 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [patch] -func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -251,7 +250,7 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req return web.RespondJsonError(ctx, w, err) } - err = project.Update(ctx, claims, p.MasterDB, req, v.Now) + err = h.Repository.Update(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -283,7 +282,7 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/archive [patch] -func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -302,7 +301,7 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re return web.RespondJsonError(ctx, w, err) } - err = project.Archive(ctx, claims, p.MasterDB, req, v.Now) + err = h.Repository.Archive(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -334,13 +333,13 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/{id} [delete] -func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, err := auth.ClaimsFromContext(ctx) if err != nil { return err } - err = project.Delete(ctx, claims, p.MasterDB, + err = h.Repository.Delete(ctx, claims, project.ProjectDeleteRequest{ID: params["id"]}) if err != nil { cause := errors.Cause(err) diff --git a/cmd/web-api/handlers/routes.go b/cmd/web-api/handlers/routes.go index b68460f..00f4704 100644 --- a/cmd/web-api/handlers/routes.go +++ b/cmd/web-api/handlers/routes.go @@ -1,122 +1,134 @@ package handlers import ( - "context" - "geeks-accelerator/oss/saas-starter-kit/internal/user" "log" "net/http" "os" + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/mid" saasSwagger "geeks-accelerator/oss/saas-starter-kit/internal/mid/saas-swagger" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" _ "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" + "geeks-accelerator/oss/saas-starter-kit/internal/signup" _ "geeks-accelerator/oss/saas-starter-kit/internal/signup" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/jmoiron/sqlx" - "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) - type AppContext struct { - Log *log.Logger - Env webcontext.Env - Repo *user.Repository - MasterDB *sqlx.DB - Redis *redis.Client - Authenticator *auth.Authenticator - PreAppMiddleware []web.Middleware + Log *log.Logger + Env webcontext.Env + MasterDB *sqlx.DB + Redis *redis.Client + UserRepo *user.Repository + UserAccountRepo *user_account.Repository + AccountRepo *account.Repository + AccountPrefRepo *account_preference.Repository + AuthRepo *user_auth.Repository + SignupRepo *signup.Repository + InviteRepo *invite.Repository + ProjectRepo *project.Repository + Authenticator *auth.Authenticator + PreAppMiddleware []web.Middleware PostAppMiddleware []web.Middleware } - // API returns a handler for a set of routes. -func API(shutdown chan os.Signal, appContext *AppContext ) http.Handler { +func API(shutdown chan os.Signal, appCtx *AppContext) http.Handler { // Include the pre middlewares first. - middlewares := appContext.PreAppMiddleware + middlewares := appCtx.PreAppMiddleware // Define app middlewares applied to all requests. middlewares = append(middlewares, mid.Trace(), - mid.Logger(appContext.Log), - mid.Errors(appContext.Log, nil), + mid.Logger(appCtx.Log), + mid.Errors(appCtx.Log, nil), mid.Metrics(), mid.Panics()) // Append any global middlewares that should be included after the app middlewares. - if len(appContext.PostAppMiddleware) > 0 { - middlewares = append(middlewares, appContext.PostAppMiddleware...) + if len(appCtx.PostAppMiddleware) > 0 { + middlewares = append(middlewares, appCtx.PostAppMiddleware...) } // Construct the web.App which holds all routes as well as common Middleware. - app := web.NewApp(shutdown, appContext.Log, appContext.Env, middlewares...) + app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...) // Register health check endpoint. This route is not authenticated. check := Check{ - MasterDB: appContext.MasterDB, - Redis: appContext.Redis, + MasterDB: appCtx.MasterDB, + Redis: appCtx.Redis, } app.Handle("GET", "/v1/health", check.Health) app.Handle("GET", "/ping", check.Ping) + // Register example endpoints. + ex := Example{ + Project: appCtx.ProjectRepo, + } + app.Handle("GET", "/v1/examples/error-response", ex.ErrorResponse) + // Register user management and authentication endpoints. u := User{ - MasterDB: appContext.MasterDB, - TokenGenerator: authenticator, + Repository: appCtx.UserRepo, + Auth: appCtx.AuthRepo, } - app.Handle("GET", "/v1/users", u.Find, mid.AuthenticateHeader(authenticator)) - app.Handle("POST", "/v1/users", u.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/users/:id", u.Read, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/users", u.Update, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateHeader(authenticator)) + app.Handle("GET", "/v1/users", u.Find, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("POST", "/v1/users", u.Create, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/users/:id", u.Read, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/users", u.Update, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateHeader(appCtx.Authenticator)) // This route is not authenticated app.Handle("POST", "/v1/oauth/token", u.Token) // Register user account management endpoints. ua := UserAccount{ - MasterDB: masterDB, + Repository: appCtx.UserAccountRepo, } - app.Handle("GET", "/v1/user_accounts", ua.Find, mid.AuthenticateHeader(authenticator)) - app.Handle("POST", "/v1/user_accounts", ua.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/user_accounts/:user_id/:account_id", ua.Read, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/user_accounts", ua.Update, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/user_accounts/archive", ua.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/user_accounts", ua.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/user_accounts", ua.Find, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("POST", "/v1/user_accounts", ua.Create, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/user_accounts/:user_id/:account_id", ua.Read, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/user_accounts", ua.Update, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/user_accounts/archive", ua.Archive, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/user_accounts", ua.Delete, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) // Register account endpoints. a := Account{ - MasterDB: masterDB, + Repository: appCtx.AccountRepo, } - app.Handle("GET", "/v1/accounts/:id", a.Read, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/accounts", a.Update, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/accounts/:id", a.Read, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/accounts", a.Update, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) // Register signup endpoints. s := Signup{ - MasterDB: masterDB, + Repository: appCtx.SignupRepo, } app.Handle("POST", "/v1/signup", s.Signup) // Register project. p := Project{ - MasterDB: masterDB, + Repository: appCtx.ProjectRepo, } - app.Handle("GET", "/v1/projects", p.Find, mid.AuthenticateHeader(authenticator)) - app.Handle("POST", "/v1/projects", p.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/projects/:id", p.Read, mid.AuthenticateHeader(authenticator)) - app.Handle("PATCH", "/v1/projects", p.Update, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) - - app.Handle("GET", "/v1/examples/error-response", ExampleErrorResponse) + app.Handle("GET", "/v1/projects", p.Find, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("POST", "/v1/projects", p.Create, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/projects/:id", p.Read, mid.AuthenticateHeader(appCtx.Authenticator)) + app.Handle("PATCH", "/v1/projects", p.Update, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) // Register swagger documentation. // TODO: Add authentication. Current authenticator requires an Authorization header @@ -127,36 +139,6 @@ func API(shutdown chan os.Signal, appContext *AppContext ) http.Handler { return app } -// ExampleErrorResponse returns example error messages. -func ExampleErrorResponse(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - v, err := webcontext.ContextValues(ctx) - if err != nil { - return err - } - - if qv := r.URL.Query().Get("test-validation-error"); qv != "" { - _, err := project.Create(ctx, auth.Claims{}, nil, project.ProjectCreateRequest{}, v.Now) - return web.RespondJsonError(ctx, w, err) - - } - - if qv := r.URL.Query().Get("test-web-error"); qv != "" { - terr := errors.New("Some random error") - terr = errors.WithMessage(terr, "Actual error message") - rerr := weberror.NewError(ctx, terr, http.StatusBadRequest).(*weberror.Error) - rerr.Message = "Test Web Error Message" - return web.RespondJsonError(ctx, w, rerr) - } - - if qv := r.URL.Query().Get("test-error"); qv != "" { - terr := errors.New("Test error") - terr = errors.WithMessage(terr, "Error message") - return web.RespondJsonError(ctx, w, terr) - } - - return nil -} - // Types godoc // @Summary List of types. // @Param data body weberror.FieldError false "Field Error" diff --git a/cmd/web-api/handlers/signup.go b/cmd/web-api/handlers/signup.go index cd0ed21..e2472a3 100644 --- a/cmd/web-api/handlers/signup.go +++ b/cmd/web-api/handlers/signup.go @@ -10,14 +10,13 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/signup" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Signup represents the Signup API method handler set. type Signup struct { - MasterDB *sqlx.DB + *signup.Repository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } @@ -33,7 +32,7 @@ type Signup struct { // @Failure 400 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /signup [post] -func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -50,7 +49,7 @@ func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Requ return web.RespondJsonError(ctx, w, err) } - res, err := signup.Signup(ctx, claims, c.MasterDB, req, v.Now) + res, err := h.Repository.Signup(ctx, claims, req, v.Now) if err != nil { switch errors.Cause(err) { case account.ErrForbidden: diff --git a/cmd/web-api/handlers/user.go b/cmd/web-api/handlers/user.go index e1b7e7f..ddbd934 100644 --- a/cmd/web-api/handlers/user.go +++ b/cmd/web-api/handlers/user.go @@ -14,7 +14,6 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/gorilla/schema" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) @@ -24,8 +23,8 @@ var sessionTtl = time.Hour * 24 // User represents the User API method handler set. type User struct { - MasterDB *sqlx.DB - TokenGenerator user_auth.TokenGenerator + *user.Repository + Auth *user_auth.Repository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } @@ -47,7 +46,7 @@ type User struct { // @Failure 400 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [get] -func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -114,7 +113,7 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, // return web.RespondJsonError(ctx, w, err) //} - res, err := user.Find(ctx, claims, u.MasterDB, req) + res, err := h.Repository.Find(ctx, claims, req) if err != nil { return err } @@ -140,7 +139,7 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/{id} [get] -func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -157,7 +156,7 @@ func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, includeArchived = b } - res, err := user.Read(ctx, claims, u.MasterDB, user.UserReadRequest{ + res, err := h.Repository.Read(ctx, claims, user.UserReadRequest{ ID: params["id"], IncludeArchived: includeArchived, }) @@ -187,7 +186,7 @@ func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [post] -func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -206,7 +205,7 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques return web.RespondJsonError(ctx, w, err) } - res, err := user.Create(ctx, claims, u.MasterDB, req, v.Now) + res, err := h.Repository.Create(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -238,7 +237,7 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [patch] -func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -257,7 +256,7 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques return web.RespondJsonError(ctx, w, err) } - err = user.Update(ctx, claims, u.MasterDB, req, v.Now) + err = h.Repository.Update(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -289,7 +288,7 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/password [patch] -func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -308,7 +307,7 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt return web.RespondJsonError(ctx, w, err) } - err = user.UpdatePassword(ctx, claims, u.MasterDB, req, v.Now) + err = h.Repository.UpdatePassword(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -342,7 +341,7 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/archive [patch] -func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -361,7 +360,7 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque return web.RespondJsonError(ctx, w, err) } - err = user.Archive(ctx, claims, u.MasterDB, req, v.Now) + err = h.Repository.Archive(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -393,13 +392,13 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/{id} [delete] -func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, err := auth.ClaimsFromContext(ctx) if err != nil { return err } - err = user.Delete(ctx, claims, u.MasterDB, + err = h.Repository.Delete(ctx, claims, user.UserDeleteRequest{ID: params["id"]}) if err != nil { cause := errors.Cause(err) @@ -432,7 +431,7 @@ func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 401 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/switch-account/{account_id} [patch] -func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -443,7 +442,7 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http return err } - tkn, err := user_auth.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, user_auth.SwitchAccountRequest{ + tkn, err := h.Auth.SwitchAccount(ctx, claims, user_auth.SwitchAccountRequest{ AccountID: params["account_id"], }, sessionTtl, v.Now) if err != nil { @@ -479,7 +478,7 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http // @Failure 401 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /oauth/token [post] -func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -534,7 +533,7 @@ func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request scopes = strings.Split(qv, ",") } - tkn, err := user_auth.Authenticate(ctx, u.MasterDB, u.TokenGenerator, authReq, sessionTtl, v.Now, scopes...) + tkn, err := h.Auth.Authenticate(ctx, authReq, sessionTtl, v.Now, scopes...) if err != nil { cause := errors.Cause(err) switch cause { diff --git a/cmd/web-api/handlers/user_account.go b/cmd/web-api/handlers/user_account.go index 344ac7b..aec3075 100644 --- a/cmd/web-api/handlers/user_account.go +++ b/cmd/web-api/handlers/user_account.go @@ -11,14 +11,13 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // UserAccount represents the UserAccount API method handler set. type UserAccount struct { - MasterDB *sqlx.DB + *user_account.Repository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } @@ -41,7 +40,7 @@ type UserAccount struct { // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts [get] -func (u *UserAccount) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -108,7 +107,7 @@ func (u *UserAccount) Find(ctx context.Context, w http.ResponseWriter, r *http.R // return web.RespondJsonError(ctx, w, err) //} - res, err := user_account.Find(ctx, claims, u.MasterDB, req) + res, err := h.Repository.Find(ctx, claims, req) if err != nil { return err } @@ -134,7 +133,7 @@ func (u *UserAccount) Find(ctx context.Context, w http.ResponseWriter, r *http.R // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts/{user_id}/{account_id} [get] -func (u *UserAccount) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -151,7 +150,7 @@ func (u *UserAccount) Read(ctx context.Context, w http.ResponseWriter, r *http.R includeArchived = b } - res, err := user_account.Read(ctx, claims, u.MasterDB, user_account.UserAccountReadRequest{ + res, err := h.Repository.Read(ctx, claims, user_account.UserAccountReadRequest{ UserID: params["user_id"], AccountID: params["account_id"], IncludeArchived: includeArchived, @@ -183,7 +182,7 @@ func (u *UserAccount) Read(ctx context.Context, w http.ResponseWriter, r *http.R // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts [post] -func (u *UserAccount) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -202,7 +201,7 @@ func (u *UserAccount) Create(ctx context.Context, w http.ResponseWriter, r *http return web.RespondJsonError(ctx, w, err) } - res, err := user_account.Create(ctx, claims, u.MasterDB, req, v.Now) + res, err := h.Repository.Create(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -234,7 +233,7 @@ func (u *UserAccount) Create(ctx context.Context, w http.ResponseWriter, r *http // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts [patch] -func (u *UserAccount) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -253,7 +252,7 @@ func (u *UserAccount) Update(ctx context.Context, w http.ResponseWriter, r *http return web.RespondJsonError(ctx, w, err) } - err = user_account.Update(ctx, claims, u.MasterDB, req, v.Now) + err = h.Repository.Update(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -285,7 +284,7 @@ func (u *UserAccount) Update(ctx context.Context, w http.ResponseWriter, r *http // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts/archive [patch] -func (u *UserAccount) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -304,7 +303,7 @@ func (u *UserAccount) Archive(ctx context.Context, w http.ResponseWriter, r *htt return web.RespondJsonError(ctx, w, err) } - err = user_account.Archive(ctx, claims, u.MasterDB, req, v.Now) + err = h.Repository.Archive(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -336,7 +335,7 @@ func (u *UserAccount) Archive(ctx context.Context, w http.ResponseWriter, r *htt // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /user_accounts [delete] -func (u *UserAccount) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserAccount) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, err := auth.ClaimsFromContext(ctx) if err != nil { return err @@ -350,7 +349,7 @@ func (u *UserAccount) Delete(ctx context.Context, w http.ResponseWriter, r *http return web.RespondJsonError(ctx, w, err) } - err = user_account.Delete(ctx, claims, u.MasterDB, req) + err = h.Repository.Delete(ctx, claims, req) if err != nil { cause := errors.Cause(err) switch cause { diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index e331ead..f64f953 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -6,7 +6,6 @@ import ( "encoding/json" "expvar" "fmt" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "log" "net" "net/http" @@ -21,18 +20,30 @@ import ( "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/docs" "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/mid" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/devops" "geeks-accelerator/oss/saas-starter-kit/internal/platform/flag" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/project" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" + "geeks-accelerator/oss/saas-starter-kit/internal/signup" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" "github.com/go-redis/redis" + "github.com/gorilla/securecookie" "github.com/kelseyhightower/envconfig" "github.com/lib/pq" + "github.com/pkg/errors" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" awstrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/aws/aws-sdk-go/aws" @@ -66,10 +77,9 @@ func main() { // ========================================================================= // Logging - log.SetFlags(log.LstdFlags|log.Lmicroseconds|log.Lshortfile) - log.SetPrefix(service+" : ") - log := log.New(os.Stdout, log.Prefix() , log.Flags()) - + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) + log.SetPrefix(service + " : ") + log := log.New(os.Stdout, log.Prefix(), log.Flags()) // ========================================================================= // Configuration @@ -87,16 +97,21 @@ func main() { DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"` } Service struct { - Name string `default:"web-api" envconfig:"NAME"` - Project string `default:"" envconfig:"PROJECT"` + Name string `default:"web-api" envconfig:"SERVICE"` BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://api.example.saasstartupkit.com"` HostNames []string `envconfig:"HOST_NAMES" example:"alternative-subdomain.example.saasstartupkit.com"` EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` - WebAppBaseUrl string `default:"http://127.0.0.1:3000" envconfig:"WEB_APP_BASE_URL" example:"www.example.saasstartupkit.com"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } + Project struct { + Name string `default:"" envconfig:"PROJECT"` + SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` + SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"` + EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` + WebAppBaseUrl string `default:"http://127.0.0.1:3000" envconfig:"WEB_APP_BASE_URL" example:"www.example.saasstartupkit.com"` + } Redis struct { Host string `default:":6379" envconfig:"HOST"` DB int `default:"1" envconfig:"DB"` @@ -185,8 +200,8 @@ func main() { // deployments and distributed to each instance of the service running. if cfg.Aws.SecretsManagerConfigPrefix == "" { var pts []string - if cfg.Service.Project != "" { - pts = append(pts, cfg.Service.Project) + if cfg.Project.Name != "" { + pts = append(pts, cfg.Project.Name) } pts = append(pts, cfg.Env, cfg.Service.Name) @@ -276,6 +291,37 @@ func main() { awsSession = awstrace.WrapSession(awsSession) } + // ========================================================================= + // Shared Secret Key used for encrypting sessions and links. + + // Set the secret key if not provided in the config. + if cfg.Project.SharedSecretKey == "" { + + // AWS secrets manager ID for storing the session key. This is optional and only will be used + // if a valid AWS session is provided. + secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "sharedSecretKey") + + // If AWS is enabled, check the Secrets Manager for the session key. + if awsSession != nil { + cfg.Project.SharedSecretKey, err = devops.SecretManagerGetString(awsSession, secretID) + if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound { + log.Fatalf("main : Session : %+v", err) + } + } + + // If the session key is still empty, generate a new key. + if cfg.Project.SharedSecretKey == "" { + cfg.Project.SharedSecretKey = string(securecookie.GenerateRandomKey(32)) + + if awsSession != nil { + err = devops.SecretManagerPutString(awsSession, secretID, cfg.Project.SharedSecretKey) + if err != nil { + log.Fatalf("main : Session : %+v", err) + } + } + } + } + // ========================================================================= // Start Redis // Ensure the eviction policy on the redis cluster is set correctly. @@ -346,6 +392,31 @@ func main() { } defer masterDb.Close() + // ========================================================================= + // Notify Email + var notifyEmail notify.Email + if awsSession != nil { + // Send emails with AWS SES. Alternative to use SMTP with notify.NewEmailSmtp. + notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Project.SharedTemplateDir, cfg.Project.EmailSender) + if err != nil { + log.Fatalf("main : Notify Email : %+v", err) + } + + err = notifyEmail.Verify() + if err != nil { + switch errors.Cause(err) { + case notify.ErrAwsSesIdentityNotVerified: + log.Printf("main : Notify Email : %s\n", err) + case notify.ErrAwsSesSendingDisabled: + log.Printf("main : Notify Email : %s\n", err) + default: + log.Fatalf("main : Notify Email Verify : %+v", err) + } + } + } else { + notifyEmail = notify.NewEmailDisabled() + } + // ========================================================================= // Init new Authenticator var authenticator *auth.Authenticator @@ -360,11 +431,41 @@ func main() { } // ========================================================================= - // Load middlewares that need to be configured specific for the service. - var serviceMiddlewares = []web.Middleware{ - mid.Translator(webcontext.UniversalTranslator()), + // Init repositories and AppContext + + projectRoute, err := project_route.New(cfg.Service.BaseUrl, cfg.Project.WebAppBaseUrl) + if err != nil { + log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) } + usrRepo := user.NewRepository(masterDb, projectRoute.UserResetPassword, notifyEmail, cfg.Project.SharedSecretKey) + usrAccRepo := user_account.NewRepository(masterDb) + accRepo := account.NewRepository(masterDb) + accPrefRepo := account_preference.NewRepository(masterDb) + authRepo := user_auth.NewRepository(masterDb, authenticator, usrRepo, usrAccRepo, accPrefRepo) + signupRepo := signup.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo) + inviteRepo := invite.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo, projectRoute.UserInviteAccept, notifyEmail, cfg.Project.SharedSecretKey) + prjRepo := project.NewRepository(masterDb) + + appCtx := &handlers.AppContext{ + Log: log, + Env: cfg.Env, + MasterDB: masterDb, + Redis: redisClient, + UserRepo: usrRepo, + UserAccountRepo: usrAccRepo, + AccountRepo: accRepo, + AccountPrefRepo: accPrefRepo, + AuthRepo: authRepo, + SignupRepo: signupRepo, + InviteRepo: inviteRepo, + ProjectRepo: prjRepo, + Authenticator: authenticator, + } + + // ========================================================================= + // Load middlewares that need to be configured specific for the service. + // Init redirect middleware to ensure all requests go to the primary domain contained in the base URL. if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" { redirect := mid.DomainNameRedirect(mid.DomainNameRedirectConfig{ @@ -380,9 +481,12 @@ func main() { DomainName: primaryServiceHost, HTTPSEnabled: cfg.Service.EnableHTTPS, }) - serviceMiddlewares = append(serviceMiddlewares, redirect) + appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, redirect) } + // Add the translator middleware for localization. + appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, mid.Translator(webcontext.UniversalTranslator())) + // ========================================================================= // Start Tracing Support th := fmt.Sprintf("%s:%d", cfg.Trace.Host, cfg.Trace.Port) @@ -443,7 +547,7 @@ func main() { if cfg.HTTP.Host != "" { api := http.Server{ Addr: cfg.HTTP.Host, - Handler: handlers.API(shutdown, log, cfg.Env, masterDb, redisClient, authenticator, serviceMiddlewares...), + Handler: handlers.API(shutdown, appCtx), ReadTimeout: cfg.HTTP.ReadTimeout, WriteTimeout: cfg.HTTP.WriteTimeout, MaxHeaderBytes: 1 << 20, @@ -460,7 +564,7 @@ func main() { if cfg.HTTPS.Host != "" { api := http.Server{ Addr: cfg.HTTPS.Host, - Handler: handlers.API(shutdown, log, cfg.Env, masterDb, redisClient, authenticator, serviceMiddlewares...), + Handler: handlers.API(shutdown, appCtx), ReadTimeout: cfg.HTTPS.ReadTimeout, WriteTimeout: cfg.HTTPS.WriteTimeout, MaxHeaderBytes: 1 << 20, diff --git a/cmd/web-api/tests/project_test.go b/cmd/web-api/tests/project_test.go index fbefebd..504cdd4 100644 --- a/cmd/web-api/tests/project_test.go +++ b/cmd/web-api/tests/project_test.go @@ -27,7 +27,7 @@ func mockProjectCreateRequest(accountID string) project.ProjectCreateRequest { // mockProject creates a new project for testing and associates it with the supplied account ID. func newMockProject(accountID string) *project.Project { req := mockProjectCreateRequest(accountID) - p, err := project.Create(tests.Context(), auth.Claims{}, test.MasterDB, req, time.Now().UTC().AddDate(-1, -1, -1)) + p, err := appCtx.ProjectRepo.Create(tests.Context(), auth.Claims{}, req, time.Now().UTC().AddDate(-1, -1, -1)) if err != nil { panic(err) } diff --git a/cmd/web-api/tests/signup_test.go b/cmd/web-api/tests/signup_test.go index 686f2a7..e1593f5 100644 --- a/cmd/web-api/tests/signup_test.go +++ b/cmd/web-api/tests/signup_test.go @@ -50,13 +50,13 @@ func mockSignupRequest() signup.SignupRequest { func newMockSignup() mockSignup { req := mockSignupRequest() now := time.Now().UTC().AddDate(-1, -1, -1) - s, err := signup.Signup(tests.Context(), auth.Claims{}, test.MasterDB, req, now) + s, err := appCtx.SignupRepo.Signup(tests.Context(), auth.Claims{}, req, now) if err != nil { panic(err) } expires := time.Now().UTC().Sub(s.User.CreatedAt) + time.Hour - tkn, err := user_auth.Authenticate(tests.Context(), test.MasterDB, authenticator, user_auth.AuthenticateRequest{ + tkn, err := appCtx.AuthRepo.Authenticate(tests.Context(), user_auth.AuthenticateRequest{ Email: req.User.Email, Password: req.User.Password, }, expires, now) diff --git a/cmd/web-api/tests/tests_test.go b/cmd/web-api/tests/tests_test.go index 83608f5..f968787 100644 --- a/cmd/web-api/tests/tests_test.go +++ b/cmd/web-api/tests/tests_test.go @@ -5,6 +5,11 @@ import ( "context" "encoding/json" "fmt" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/project" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" "io" "io/ioutil" "net/http" @@ -31,9 +36,12 @@ import ( "github.com/pkg/errors" ) -var a http.Handler -var test *tests.Test -var authenticator *auth.Authenticator +var ( + a http.Handler + test *tests.Test + authenticator *auth.Authenticator + appCtx *handlers.AppContext +) // Information about the users we have created for testing. type roleTest struct { @@ -84,18 +92,51 @@ func testMain(m *testing.M) int { log := test.Log log.SetOutput(ioutil.Discard) - a = handlers.API(shutdown, log, webcontext.Env_Dev, test.MasterDB, nil, authenticator) + + projectRoute, err := project_route.New("http://web-api.com", "http://web-app.com") + if err != nil { + panic(err) + } + + notifyEmail := notify.NewEmailDisabled() + + usrRepo := user.MockRepository(test.MasterDB) + usrAccRepo := user_account.NewRepository(test.MasterDB) + accRepo := account.NewRepository(test.MasterDB) + accPrefRepo := account_preference.NewRepository(test.MasterDB) + authRepo := user_auth.NewRepository(test.MasterDB, authenticator, usrRepo, usrAccRepo, accPrefRepo) + signupRepo := signup.NewRepository(test.MasterDB, usrRepo, usrAccRepo, accRepo) + inviteRepo := invite.NewRepository(test.MasterDB, usrRepo, usrAccRepo, accRepo, projectRoute.UserInviteAccept, notifyEmail, "6368616e676520746869732070613434") + prjRepo := project.NewRepository(test.MasterDB) + + appCtx = &handlers.AppContext{ + Log: log, + Env: webcontext.Env_Dev, + MasterDB: test.MasterDB, + Redis: nil, + UserRepo: usrRepo, + UserAccountRepo: usrAccRepo, + AccountRepo: accRepo, + AccountPrefRepo: accPrefRepo, + AuthRepo: authRepo, + SignupRepo: signupRepo, + InviteRepo: inviteRepo, + ProjectRepo: prjRepo, + Authenticator: authenticator, + } + + a = handlers.API(shutdown, appCtx) // Create a new account directly business logic. This creates an // initial account and user that we will use for admin validated endpoints. signupReq1 := mockSignupRequest() - signup1, err := signup.Signup(tests.Context(), auth.Claims{}, test.MasterDB, signupReq1, now) + signup1, err := signupRepo.Signup(tests.Context(), auth.Claims{}, signupReq1, now) if err != nil { panic(err) } expires := time.Now().UTC().Sub(signup1.User.CreatedAt) + time.Hour - adminTkn, err := user_auth.Authenticate(tests.Context(), test.MasterDB, authenticator, user_auth.AuthenticateRequest{ + adminTkn, err := authRepo.Authenticate(tests.Context(), user_auth.AuthenticateRequest{ Email: signupReq1.User.Email, Password: signupReq1.User.Password, }, expires, now) @@ -110,7 +151,7 @@ func testMain(m *testing.M) int { // Create a second account that the first account user should not have access to. signupReq2 := mockSignupRequest() - signup2, err := signup.Signup(tests.Context(), auth.Claims{}, test.MasterDB, signupReq2, now) + signup2, err := signupRepo.Signup(tests.Context(), auth.Claims{}, signupReq2, now) if err != nil { panic(err) } @@ -134,12 +175,12 @@ func testMain(m *testing.M) int { Password: "akTechFr0n!ier", PasswordConfirm: "akTechFr0n!ier", } - usr, err := user.Create(tests.Context(), adminClaims, test.MasterDB, userReq, now) + usr, err := usrRepo.Create(tests.Context(), adminClaims, userReq, now) if err != nil { panic(err) } - _, err = user_account.Create(tests.Context(), adminClaims, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err = usrAccRepo.Create(tests.Context(), adminClaims, user_account.UserAccountCreateRequest{ UserID: usr.ID, AccountID: signup1.Account.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, @@ -149,7 +190,7 @@ func testMain(m *testing.M) int { panic(err) } - userTkn, err := user_auth.Authenticate(tests.Context(), test.MasterDB, authenticator, user_auth.AuthenticateRequest{ + userTkn, err := authRepo.Authenticate(tests.Context(), user_auth.AuthenticateRequest{ Email: usr.Email, Password: userReq.Password, }, expires, now) diff --git a/cmd/web-api/tests/user_account_test.go b/cmd/web-api/tests/user_account_test.go index e1c650c..ff499be 100644 --- a/cmd/web-api/tests/user_account_test.go +++ b/cmd/web-api/tests/user_account_test.go @@ -14,7 +14,6 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "github.com/pborman/uuid" ) @@ -22,12 +21,12 @@ import ( // newMockUserAccount creates a new user user for testing and associates it with the supplied account ID. func newMockUserAccount(accountID string, role user_account.UserAccountRole) *user_account.UserAccount { req := mockUserCreateRequest() - u, err := user.Create(tests.Context(), auth.Claims{}, test.MasterDB, req, time.Now().UTC().AddDate(-1, -1, -1)) + u, err := appCtx.UserRepo.Create(tests.Context(), auth.Claims{}, req, time.Now().UTC().AddDate(-1, -1, -1)) if err != nil { panic(err) } - ua, err := user_account.Create(tests.Context(), auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + ua, err := appCtx.UserAccountRepo.Create(tests.Context(), auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: u.ID, AccountID: accountID, Roles: []user_account.UserAccountRole{role}, @@ -65,7 +64,7 @@ func TestUserAccountCRUDAdmin(t *testing.T) { } t.Logf("\tTest: %s - %s %s", rt.name, rt.method, rt.url) - newUser, err := user.Create(tests.Context(), auth.Claims{}, test.MasterDB, mockUserCreateRequest(), time.Now().UTC().AddDate(-1, -1, -1)) + newUser, err := appCtx.UserRepo.Create(tests.Context(), auth.Claims{}, mockUserCreateRequest(), time.Now().UTC().AddDate(-1, -1, -1)) if err != nil { t.Fatalf("\t%s\tCreate new user failed.", tests.Failed) } diff --git a/cmd/web-api/tests/user_test.go b/cmd/web-api/tests/user_test.go index 4b55dd4..f823c6b 100644 --- a/cmd/web-api/tests/user_test.go +++ b/cmd/web-api/tests/user_test.go @@ -38,12 +38,12 @@ func mockUserCreateRequest() user.UserCreateRequest { // mockUser creates a new user for testing and associates it with the supplied account ID. func newMockUser(accountID string, role user_account.UserAccountRole) mockUser { req := mockUserCreateRequest() - u, err := user.Create(tests.Context(), auth.Claims{}, test.MasterDB, req, time.Now().UTC().AddDate(-1, -1, -1)) + u, err := appCtx.UserRepo.Create(tests.Context(), auth.Claims{}, req, time.Now().UTC().AddDate(-1, -1, -1)) if err != nil { panic(err) } - _, err = user_account.Create(tests.Context(), auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err = appCtx.UserAccountRepo.Create(tests.Context(), auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: u.ID, AccountID: accountID, Roles: []user_account.UserAccountRole{role}, @@ -126,7 +126,7 @@ func TestUserCRUDAdmin(t *testing.T) { t.Logf("\t%s\tReceived expected result.", tests.Success) // Only for user creation do we need to do this. - _, err := user_account.Create(tests.Context(), auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err := appCtx.UserAccountRepo.Create(tests.Context(), auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: actual.ID, AccountID: tr.Account.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, @@ -401,7 +401,7 @@ func TestUserCRUDAdmin(t *testing.T) { } t.Logf("\tTest: %s - %s %s", rt.name, rt.method, rt.url) - _, err := user_account.Create(tests.Context(), auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err := appCtx.UserAccountRepo.Create(tests.Context(), auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: tr.User.ID, AccountID: newAccount.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, @@ -805,7 +805,7 @@ func TestUserCRUDUser(t *testing.T) { } t.Logf("\tTest: %s - %s %s", rt.name, rt.method, rt.url) - _, err := user_account.Create(tests.Context(), auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + _, err := appCtx.UserAccountRepo.Create(tests.Context(), auth.Claims{}, user_account.UserAccountCreateRequest{ UserID: tr.User.ID, AccountID: newAccount.ID, Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index 046b695..a89843f 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "expvar" "fmt" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" "html/template" "log" "net" @@ -88,12 +89,10 @@ func main() { } Service struct { Name string `default:"web-app" envconfig:"NAME"` - Project string `default:"" envconfig:"PROJECT"` BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://example.saasstartupkit.com"` HostNames []string `envconfig:"HOST_NAMES" example:"www.example.saasstartupkit.com"` EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` - SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` StaticFiles struct { Dir string `default:"./static" envconfig:"STATIC_DIR"` S3Enabled bool `envconfig:"S3_ENABLED"` @@ -101,13 +100,17 @@ func main() { CloudFrontEnabled bool `envconfig:"CLOUDFRONT_ENABLED"` ImgResizeEnabled bool `envconfig:"IMG_RESIZE_ENABLED"` } - WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"` - SessionKey string `default:"" envconfig:"SESSION_KEY"` SessionName string `default:"" envconfig:"SESSION_NAME"` - EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } + Project struct { + Name string `default:"" envconfig:"PROJECT"` + SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` + SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"` + EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` + WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"` + } Redis struct { Host string `default:":6379" envconfig:"HOST"` DB int `default:"1" envconfig:"DB"` @@ -145,12 +148,6 @@ func main() { UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"` KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"` } - STMP struct { - Host string `default:"localhost" envconfig:"HOST"` - Port int `default:"25" envconfig:"PORT"` - User string `default:"" envconfig:"USER"` - Pass string `default:"" envconfig:"PASS" json:"-"` // don't print - } BuildInfo struct { CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"` CiCommitShortSha string `envconfig:"CI_COMMIT_SHORT_SHA"` @@ -202,8 +199,8 @@ func main() { // deployments and distributed to each instance of the service running. if cfg.Aws.SecretsManagerConfigPrefix == "" { var pts []string - if cfg.Service.Project != "" { - pts = append(pts, cfg.Service.Project) + if cfg.Project.Name != "" { + pts = append(pts, cfg.Project.Name) } pts = append(pts, cfg.Env, cfg.Service.Name) @@ -293,6 +290,37 @@ func main() { awsSession = awstrace.WrapSession(awsSession) } + // ========================================================================= + // Shared Secret Key used for encrypting sessions and links. + + // Set the secret key if not provided in the config. + if cfg.Project.SharedSecretKey == "" { + + // AWS secrets manager ID for storing the session key. This is optional and only will be used + // if a valid AWS session is provided. + secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "sharedSecretKey") + + // If AWS is enabled, check the Secrets Manager for the session key. + if awsSession != nil { + cfg.Project.SharedSecretKey, err = devops.SecretManagerGetString(awsSession, secretID) + if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound { + log.Fatalf("main : Session : %+v", err) + } + } + + // If the session key is still empty, generate a new key. + if cfg.Project.SharedSecretKey == "" { + cfg.Project.SharedSecretKey = string(securecookie.GenerateRandomKey(32)) + + if awsSession != nil { + err = devops.SecretManagerPutString(awsSession, secretID, cfg.Service.SecretKey) + if err != nil { + log.Fatalf("main : Session : %+v", err) + } + } + } + } + // ========================================================================= // Start Redis // Ensure the eviction policy on the redis cluster is set correctly. @@ -367,6 +395,7 @@ func main() { // Notify Email var notifyEmail notify.Email if awsSession != nil { + // Send emails with AWS SES. Alternative to use SMTP with notify.NewEmailSmtp. notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) if err != nil { log.Fatalf("main : Notify Email : %+v", err) @@ -384,15 +413,7 @@ func main() { } } } else { - d := gomail.Dialer{ - Host: cfg.STMP.Host, - Port: cfg.STMP.Port, - Username: cfg.STMP.User, - Password: cfg.STMP.Pass} - notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) - if err != nil { - log.Fatalf("main : Notify Email : %+v", err) - } + notifyEmail = notify.NewEmailDisabled() } // ========================================================================= @@ -433,46 +454,18 @@ func main() { serviceMiddlewares = append(serviceMiddlewares, redirect) } + // Generate the new session store and append it to the global list of middlewares. + // Init session store if cfg.Service.SessionName == "" { cfg.Service.SessionName = fmt.Sprintf("%s-session", cfg.Service.Name) } - - // Set the session key if not provided in the config. - if cfg.Service.SessionKey == "" { - - // AWS secrets manager ID for storing the session key. This is optional and only will be used - // if a valid AWS session is provided. - secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "session") - - // If AWS is enabled, check the Secrets Manager for the session key. - if awsSession != nil { - cfg.Service.SessionKey, err = devops.SecretManagerGetString(awsSession, secretID) - if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound { - log.Fatalf("main : Session : %+v", err) - } - } - - // If the session key is still empty, generate a new key. - if cfg.Service.SessionKey == "" { - cfg.Service.SessionKey = string(securecookie.GenerateRandomKey(32)) - - if awsSession != nil { - err = devops.SecretManagerPutString(awsSession, secretID, cfg.Service.SessionKey) - if err != nil { - log.Fatalf("main : Session : %+v", err) - } - } - } - } - - // Generate the new session store and append it to the global list of middlewares. - sessionStore := sessions.NewCookieStore([]byte(cfg.Service.SessionKey)) + sessionStore := sessions.NewCookieStore([]byte(cfg.Service.SecretKey)) serviceMiddlewares = append(serviceMiddlewares, mid.Session(sessionStore, cfg.Service.SessionName)) // ========================================================================= // URL Formatter - projectRoutes, err := project_routes.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl) + projectRoutes, err := project_route.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl) if err != nil { log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) } @@ -926,7 +919,7 @@ func main() { if cfg.HTTP.Host != "" { api := http.Server{ Addr: cfg.HTTP.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SecretKey, notifyEmail, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTP.ReadTimeout, WriteTimeout: cfg.HTTP.WriteTimeout, MaxHeaderBytes: 1 << 20, @@ -943,7 +936,7 @@ func main() { if cfg.HTTPS.Host != "" { api := http.Server{ Addr: cfg.HTTPS.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SecretKey, notifyEmail, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTPS.ReadTimeout, WriteTimeout: cfg.HTTPS.WriteTimeout, MaxHeaderBytes: 1 << 20, diff --git a/internal/platform/notify/email_disabled.go b/internal/platform/notify/email_disabled.go new file mode 100644 index 0000000..85345a1 --- /dev/null +++ b/internal/platform/notify/email_disabled.go @@ -0,0 +1,21 @@ +package notify + +import "context" + +// DisableEmail defines an implementation of the email interface that doesn't send any email. +type DisableEmail struct{} + +// NewEmailDisabled disables sending any emails with an empty implementation of the email interface. +func NewEmailDisabled() *DisableEmail { + return &DisableEmail{} +} + +// Send does nothing. +func (n *DisableEmail) Send(ctx context.Context, toEmail, subject, templateName string, data map[string]interface{}) error { + return nil +} + +// Verify does nothing. +func (n *DisableEmail) Verify() error { + return nil +} diff --git a/internal/platform/notify/email_smtp.go b/internal/platform/notify/email_smtp.go index 748a7d3..ea56c4a 100644 --- a/internal/platform/notify/email_smtp.go +++ b/internal/platform/notify/email_smtp.go @@ -1,5 +1,27 @@ package notify +/* + // Alternative to use AWS SES with SMTP + import "gopkg.in/gomail.v2" + + var cfg struct { + ... + SMTP struct { + Host string `default:"localhost" envconfig:"HOST"` + Port int `default:"25" envconfig:"PORT"` + User string `default:"" envconfig:"USER"` + Pass string `default:"" envconfig:"PASS" json:"-"` // don't print + }, + } + + d := gomail.Dialer{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.User, + Password: cfg.SMTP.Pass} + notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) + */ + import ( "context" "github.com/pkg/errors" diff --git a/internal/project/project.go b/internal/project/project.go index b4fe3e2..b130702 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -414,7 +414,7 @@ func (repo *Repository) Archive(ctx context.Context, claims auth.Claims, req Pro } // Delete removes an project from the database. -func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectDeleteRequest) error { +func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, req ProjectDeleteRequest) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Delete") defer span.Finish() @@ -437,8 +437,8 @@ func (repo *Repository) Delete(ctx context.Context, claims auth.Claims, dbConn * query.Where(query.Equal("id", req.ID)) // Execute the query with the provided context. sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) + sql = repo.DbConn.Rebind(sql) + _, err = repo.DbConn.ExecContext(ctx, sql, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessagef(err, "delete project %s failed", req.ID) From 102ca821255134c8cf066479317dbb4be40e178e Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Wed, 14 Aug 2019 17:59:47 -0800 Subject: [PATCH 05/21] Completed web-api and web-app updates --- cmd/web-api/ecs-task-definition.json | 7 +- cmd/web-api/main.go | 8 +- cmd/web-app/ecs-task-definition.json | 7 +- cmd/web-app/handlers/account.go | 24 +-- cmd/web-app/handlers/projects.go | 19 ++- cmd/web-app/handlers/root.go | 18 +-- cmd/web-app/handlers/routes.go | 204 +++++++++++++++---------- cmd/web-app/handlers/signup.go | 13 +- cmd/web-app/handlers/user.go | 66 ++++---- cmd/web-app/handlers/users.go | 61 ++++---- cmd/web-app/main.go | 112 +++++++++----- internal/platform/notify/email_smtp.go | 2 +- internal/user/models.go | 5 + 13 files changed, 312 insertions(+), 234 deletions(-) diff --git a/cmd/web-api/ecs-task-definition.json b/cmd/web-api/ecs-task-definition.json index dc78985..59ffe40 100644 --- a/cmd/web-api/ecs-task-definition.json +++ b/cmd/web-api/ecs-task-definition.json @@ -34,12 +34,13 @@ {"name": "ECS_SERVICE", "value": "{ECS_SERVICE}"}, {"name": "WEB_API_HTTP_HOST", "value": "{HTTP_HOST}"}, {"name": "WEB_API_HTTPS_HOST", "value": "{HTTPS_HOST}"}, - {"name": "WEB_API_SERVICE_PROJECT", "value": "{APP_PROJECT}"}, + {"name": "WEB_API_SERVICE_SERVICE_NAME", "value": "{SERVICE}"}, {"name": "WEB_API_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"}, {"name": "WEB_API_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"}, {"name": "WEB_API_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"}, - {"name": "WEB_API_SERVICE_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, - {"name": "WEB_API_SERVICE_WEB_APP_BASE_URL", "value": "{WEB_APP_BASE_URL}"}, + {"name": "WEB_API_PROJECT_PROJECT_NAME", "value": "{APP_PROJECT}"}, + {"name": "WEB_API_PROJECT_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, + {"name": "WEB_API_PROJECT_WEB_APP_BASE_URL", "value": "{WEB_APP_BASE_URL}"}, {"name": "WEB_API_REDIS_HOST", "value": "{CACHE_HOST}"}, {"name": "WEB_API_DB_HOST", "value": "{DB_HOST}"}, {"name": "WEB_API_DB_USER", "value": "{DB_USER}"}, diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index f64f953..9a48ec4 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -97,7 +97,7 @@ func main() { DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"` } Service struct { - Name string `default:"web-api" envconfig:"SERVICE"` + Name string `default:"web-api" envconfig:"SERVICE_NAME"` BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://api.example.saasstartupkit.com"` HostNames []string `envconfig:"HOST_NAMES" example:"alternative-subdomain.example.saasstartupkit.com"` EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` @@ -106,7 +106,7 @@ func main() { ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } Project struct { - Name string `default:"" envconfig:"PROJECT"` + Name string `default:"" envconfig:"PROJECT_NAME"` SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"` EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` @@ -203,7 +203,7 @@ func main() { if cfg.Project.Name != "" { pts = append(pts, cfg.Project.Name) } - pts = append(pts, cfg.Env, cfg.Service.Name) + pts = append(pts, cfg.Env) cfg.Aws.SecretsManagerConfigPrefix = filepath.Join(pts...) } @@ -299,7 +299,7 @@ func main() { // AWS secrets manager ID for storing the session key. This is optional and only will be used // if a valid AWS session is provided. - secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "sharedSecretKey") + secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "SharedSecretKey") // If AWS is enabled, check the Secrets Manager for the session key. if awsSession != nil { diff --git a/cmd/web-app/ecs-task-definition.json b/cmd/web-app/ecs-task-definition.json index 1132f7a..88b63ef 100644 --- a/cmd/web-app/ecs-task-definition.json +++ b/cmd/web-app/ecs-task-definition.json @@ -34,7 +34,7 @@ {"name": "ECS_SERVICE", "value": "{ECS_SERVICE}"}, {"name": "WEB_APP_HTTP_HOST", "value": "{HTTP_HOST}"}, {"name": "WEB_APP_HTTPS_HOST", "value": "{HTTPS_HOST}"}, - {"name": "WEB_APP_SERVICE_PROJECT", "value": "{APP_PROJECT}"}, + {"name": "WEB_APP_SERVICE_SERVICE_NAME", "value": "{SERVICE}"}, {"name": "WEB_APP_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"}, {"name": "WEB_APP_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"}, {"name": "WEB_APP_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"}, @@ -42,8 +42,9 @@ {"name": "WEB_APP_SERVICE_STATICFILES_S3_PREFIX", "value": "{STATIC_FILES_S3_PREFIX}"}, {"name": "WEB_APP_SERVICE_STATICFILES_CLOUDFRONT_ENABLED", "value": "{STATIC_FILES_CLOUDFRONT_ENABLED}"}, {"name": "WEB_APP_SERVICE_STATICFILES_IMG_RESIZE_ENABLED", "value": "{STATIC_FILES_IMG_RESIZE_ENABLED}"}, - {"name": "WEB_APP_SERVICE_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, - {"name": "WEB_APP_SERVICE_WEB_API_BASE_URL", "value": "{WEB_API_BASE_URL}"}, + {"name": "WEB_APP_PROJECT_PROJECT_NAME", "value": "{APP_PROJECT}"}, + {"name": "WEB_APP_PROJECT_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, + {"name": "WEB_APP_PROJECT_WEB_API_BASE_URL", "value": "{WEB_API_BASE_URL}"}, {"name": "WEB_APP_REDIS_HOST", "value": "{CACHE_HOST}"}, {"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"}, {"name": "WEB_APP_DB_USER", "value": "{DB_USER}"}, diff --git a/cmd/web-app/handlers/account.go b/cmd/web-app/handlers/account.go index c2e3a0c..3ba3e0f 100644 --- a/cmd/web-app/handlers/account.go +++ b/cmd/web-app/handlers/account.go @@ -12,6 +12,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/gorilla/schema" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -19,9 +20,12 @@ import ( // Account represents the Account API method handler set. type Account struct { - MasterDB *sqlx.DB - Renderer web.Renderer - Authenticator *auth.Authenticator + AccountRepo *account.Repository + AccountPrefRepo *account_preference.Repository + AuthRepo *user_auth.Repository + Authenticator *auth.Authenticator + MasterDB *sqlx.DB + Renderer web.Renderer } // View handles displaying the current account profile. @@ -35,7 +39,7 @@ func (h *Account) View(ctx context.Context, w http.ResponseWriter, r *http.Reque return err } - acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience) + acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience) if err != nil { return err } @@ -77,7 +81,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req return false, err } - prefs, err := account_preference.FindByAccountID(ctx, claims, h.MasterDB, account_preference.AccountPreferenceFindByAccountIDRequest{ + prefs, err := h.AccountPrefRepo.FindByAccountID(ctx, claims, account_preference.AccountPreferenceFindByAccountIDRequest{ AccountID: claims.Audience, }) if err != nil { @@ -115,7 +119,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req } req.ID = claims.Audience - err = account.Update(ctx, claims, h.MasterDB, req.AccountUpdateRequest, ctxValues.Now) + err = h.AccountRepo.Update(ctx, claims, req.AccountUpdateRequest, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -135,7 +139,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req } if preferenceDatetimeFormat != req.PreferenceDatetimeFormat { - err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{ + err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{ AccountID: claims.Audience, Name: account_preference.AccountPreference_Datetime_Format, Value: req.PreferenceDatetimeFormat, @@ -156,7 +160,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req } if preferenceDateFormat != req.PreferenceDateFormat { - err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{ + err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{ AccountID: claims.Audience, Name: account_preference.AccountPreference_Date_Format, Value: req.PreferenceDateFormat, @@ -177,7 +181,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req } if preferenceTimeFormat != req.PreferenceTimeFormat { - err = account_preference.Set(ctx, claims, h.MasterDB, account_preference.AccountPreferenceSetRequest{ + err = h.AccountPrefRepo.Set(ctx, claims, account_preference.AccountPreferenceSetRequest{ AccountID: claims.Audience, Name: account_preference.AccountPreference_Time_Format, Value: req.PreferenceTimeFormat, @@ -213,7 +217,7 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req return true, web.Redirect(ctx, w, r, "/account", http.StatusFound) } - acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience) + acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience) if err != nil { return false, err } diff --git a/cmd/web-app/handlers/projects.go b/cmd/web-app/handlers/projects.go index d3bda68..8a375fe 100644 --- a/cmd/web-app/handlers/projects.go +++ b/cmd/web-app/handlers/projects.go @@ -13,16 +13,15 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" "github.com/gorilla/schema" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) // Projects represents the Projects API method handler set. type Projects struct { - MasterDB *sqlx.DB - Redis *redis.Client - Renderer web.Renderer + ProjectRepo *project.Repository + Redis *redis.Client + Renderer web.Renderer } func urlProjectsIndex() string { @@ -110,7 +109,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req } loadFunc := func(ctx context.Context, sorting string, fields []datatable.DisplayField) (resp [][]datatable.ColumnValue, err error) { - res, err := project.Find(ctx, claims, h.MasterDB, project.ProjectFindRequest{ + res, err := h.ProjectRepo.Find(ctx, claims, project.ProjectFindRequest{ Where: "account_id = ?", Args: []interface{}{claims.Audience}, Order: strings.Split(sorting, ","), @@ -186,7 +185,7 @@ func (h *Projects) Create(ctx context.Context, w http.ResponseWriter, r *http.Re } req.AccountID = claims.Audience - usr, err := project.Create(ctx, claims, h.MasterDB, *req, ctxValues.Now) + usr, err := h.ProjectRepo.Create(ctx, claims, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -251,7 +250,7 @@ func (h *Projects) View(ctx context.Context, w http.ResponseWriter, r *http.Requ switch r.PostForm.Get("action") { case "archive": - err = project.Archive(ctx, claims, h.MasterDB, project.ProjectArchiveRequest{ + err = h.ProjectRepo.Archive(ctx, claims, project.ProjectArchiveRequest{ ID: projectID, }, ctxValues.Now) if err != nil { @@ -276,7 +275,7 @@ func (h *Projects) View(ctx context.Context, w http.ResponseWriter, r *http.Requ return nil } - prj, err := project.ReadByID(ctx, claims, h.MasterDB, projectID) + prj, err := h.ProjectRepo.ReadByID(ctx, claims, projectID) if err != nil { return err } @@ -320,7 +319,7 @@ func (h *Projects) Update(ctx context.Context, w http.ResponseWriter, r *http.Re } req.ID = projectID - err = project.Update(ctx, claims, h.MasterDB, *req, ctxValues.Now) + err = h.ProjectRepo.Update(ctx, claims, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -351,7 +350,7 @@ func (h *Projects) Update(ctx context.Context, w http.ResponseWriter, r *http.Re return nil } - prj, err := project.ReadByID(ctx, claims, h.MasterDB, projectID) + prj, err := h.ProjectRepo.ReadByID(ctx, claims, projectID) if err != nil { return err } diff --git a/cmd/web-app/handlers/root.go b/cmd/web-app/handlers/root.go index e4ea3e6..dad0360 100644 --- a/cmd/web-app/handlers/root.go +++ b/cmd/web-app/handlers/root.go @@ -8,9 +8,8 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" - project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" "github.com/ikeikeikeike/go-sitemap-generator/v2/stm" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sethgrid/pester" "io/ioutil" @@ -19,10 +18,9 @@ import ( // Root represents the Root API method handler set. type Root struct { - MasterDB *sqlx.DB - Renderer web.Renderer - Sitemap *stm.Sitemap - ProjectRoutes project_routes.ProjectRoutes + Renderer web.Renderer + Sitemap *stm.Sitemap + ProjectRoute project_route.ProjectRoute } // Index determines if the user has authentication and loads the associated page. @@ -57,7 +55,7 @@ func (h *Root) SitePage(ctx context.Context, w http.ResponseWriter, r *http.Requ tmpName = "site-api.gohtml" // http://127.0.0.1:3001/docs/doc.json - swaggerJsonUrl := h.ProjectRoutes.ApiDocsJson() + swaggerJsonUrl := h.ProjectRoute.ApiDocsJson() // Load the json file from the API service. res, err := pester.Get(swaggerJsonUrl) @@ -93,8 +91,8 @@ func (h *Root) SitePage(ctx context.Context, w http.ResponseWriter, r *http.Requ return errors.WithStack(err) } - data["urlApiBaseUri"] = h.ProjectRoutes.WebApiUrl(doc.BasePath) - data["urlApiDocs"] = h.ProjectRoutes.ApiDocs() + data["urlApiBaseUri"] = h.ProjectRoute.WebApiUrl(doc.BasePath) + data["urlApiDocs"] = h.ProjectRoute.ApiDocs() case "/pricing": tmpName = "site-pricing.gohtml" @@ -123,7 +121,7 @@ func (h *Root) RobotTxt(ctx context.Context, w http.ResponseWriter, r *http.Requ return web.RespondText(ctx, w, txt, http.StatusOK) } - sitemapUrl := h.ProjectRoutes.WebAppUrl("/sitemap.xml") + sitemapUrl := h.ProjectRoute.WebAppUrl("/sitemap.xml") txt := fmt.Sprintf("User-agent: *\nDisallow: /ping\nDisallow: /status\nDisallow: /debug/\nSitemap: %s", sitemapUrl) return web.RespondText(ctx, w, txt, http.StatusOK) diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index e30a2c8..01bd4b2 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -9,13 +9,20 @@ import ( "path/filepath" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/mid" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" + "geeks-accelerator/oss/saas-starter-kit/internal/project" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" + "geeks-accelerator/oss/saas-starter-kit/internal/signup" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "github.com/ikeikeikeike/go-sitemap-generator/v2/stm" "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" @@ -27,30 +34,58 @@ const ( TmplContentErrorGeneric = "error-generic.gohtml" ) +type AppContext struct { + Log *log.Logger + Env webcontext.Env + MasterDB *sqlx.DB + Redis *redis.Client + UserRepo *user.Repository + UserAccountRepo *user_account.Repository + AccountRepo *account.Repository + AccountPrefRepo *account_preference.Repository + AuthRepo *user_auth.Repository + SignupRepo *signup.Repository + InviteRepo *invite.Repository + ProjectRepo *project.Repository + Authenticator *auth.Authenticator + StaticDir string + TemplateDir string + Renderer web.Renderer + ProjectRoute project_route.ProjectRoute + PreAppMiddleware []web.Middleware + PostAppMiddleware []web.Middleware +} + // API returns a handler for a set of routes. -func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, secretKey string, notifyEmail notify.Email, renderer web.Renderer, globalMids ...web.Middleware) http.Handler { +func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { - // Define base middlewares applied to all requests. - middlewares := []web.Middleware{ - mid.Trace(), mid.Logger(log), mid.Errors(log, renderer), mid.Metrics(), mid.Panics(), - } + // Include the pre middlewares first. + middlewares := appCtx.PreAppMiddleware - // Append any global middlewares if they were included. - if len(globalMids) > 0 { - middlewares = append(middlewares, globalMids...) + // Define app middlewares applied to all requests. + middlewares = append(middlewares, + mid.Trace(), + mid.Logger(appCtx.Log), + mid.Errors(appCtx.Log, appCtx.Renderer), + mid.Metrics(), + mid.Panics()) + + // Append any global middlewares that should be included after the app middlewares. + if len(appCtx.PostAppMiddleware) > 0 { + middlewares = append(middlewares, appCtx.PostAppMiddleware...) } // Construct the web.App which holds all routes as well as common Middleware. - app := web.NewApp(shutdown, log, env, middlewares...) + app := web.NewApp(shutdown, appCtx.Log, appCtx.Env, middlewares...) // Build a sitemap. sm := stm.NewSitemap(1) sm.SetVerbose(false) - sm.SetDefaultHost(projectRoutes.WebAppUrl("")) + sm.SetDefaultHost(appCtx.ProjectRoute.WebAppUrl("")) sm.Create() smLocAddModified := func(loc stm.URL, filename string) { - contentPath := filepath.Join(templateDir, "content", filename) + contentPath := filepath.Join(appCtx.TemplateDir, "content", filename) file, err := os.Stat(contentPath) if err != nil { @@ -64,48 +99,48 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Register project management pages. p := Projects{ - MasterDB: masterDB, - Redis: redis, - Renderer: renderer, + ProjectRepo: appCtx.ProjectRepo, + Redis: appCtx.Redis, + Renderer: appCtx.Renderer, } - app.Handle("POST", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("POST", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("POST", "/projects/create", p.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/projects/create", p.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) + app.Handle("POST", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/projects/:project_id/update", p.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/projects/:project_id", p.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("POST", "/projects/create", p.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/projects/create", p.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) // Register user management pages. us := Users{ - MasterDB: masterDB, - Redis: redis, - Renderer: renderer, - Authenticator: authenticator, - ProjectRoutes: projectRoutes, - NotifyEmail: notifyEmail, - SecretKey: secretKey, + UserRepo: appCtx.UserRepo, + UserAccountRepo: appCtx.UserAccountRepo, + AuthRepo: appCtx.AuthRepo, + InviteRepo: appCtx.InviteRepo, + MasterDB: appCtx.MasterDB, + Redis: appCtx.Redis, + Renderer: appCtx.Renderer, } - app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) + app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) app.Handle("POST", "/users/invite/:hash", us.InviteAccept) app.Handle("GET", "/users/invite/:hash", us.InviteAccept) - app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) + app.Handle("POST", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/users/invite", us.Invite, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) // Register user management and authentication endpoints. u := User{ - MasterDB: masterDB, - Renderer: renderer, - Authenticator: authenticator, - ProjectRoutes: projectRoutes, - NotifyEmail: notifyEmail, - SecretKey: secretKey, + UserRepo: appCtx.UserRepo, + UserAccountRepo: appCtx.UserAccountRepo, + AccountRepo: appCtx.AccountRepo, + AuthRepo: appCtx.AuthRepo, + MasterDB: appCtx.MasterDB, + Renderer: appCtx.Renderer, } app.Handle("POST", "/user/login", u.Login) app.Handle("GET", "/user/login", u.Login) @@ -114,35 +149,39 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm) app.Handle("POST", "/user/reset-password", u.ResetPassword) app.Handle("GET", "/user/reset-password", u.ResetPassword) - app.Handle("POST", "/user/update", u.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user/update", u.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user/account", u.Account, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user/virtual-login/:user_id", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("POST", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/user/virtual-logout", u.VirtualLogout, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("POST", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) - app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) + app.Handle("POST", "/user/update", u.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user/update", u.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user/account", u.Account, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user/virtual-login/:user_id", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/user/virtual-login", u.VirtualLogin, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/user/virtual-logout", u.VirtualLogout, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("POST", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user/switch-account", u.SwitchAccount, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) + app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) // Register account management endpoints. acc := Account{ - MasterDB: masterDB, - Renderer: renderer, - Authenticator: authenticator, + AccountRepo: appCtx.AccountRepo, + AccountPrefRepo: appCtx.AccountPrefRepo, + AuthRepo: appCtx.AuthRepo, + Authenticator: appCtx.Authenticator, + MasterDB: appCtx.MasterDB, + Renderer: appCtx.Renderer, } - app.Handle("POST", "/account/update", acc.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/account/update", acc.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/account/update", acc.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/account/update", acc.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) - // Register user management and authentication endpoints. + // Register signup endpoints. s := Signup{ - MasterDB: masterDB, - Renderer: renderer, - Authenticator: authenticator, + SignupRepo: appCtx.SignupRepo, + AuthRepo: appCtx.AuthRepo, + MasterDB: appCtx.MasterDB, + Renderer: appCtx.Renderer, } // This route is not authenticated app.Handle("POST", "/signup", s.Step1) @@ -150,16 +189,16 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Register example endpoints. ex := Examples{ - Renderer: renderer, + Renderer: appCtx.Renderer, } - app.Handle("POST", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(authenticator)) - app.Handle("GET", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(authenticator)) - app.Handle("GET", "/examples/images", ex.Images, mid.AuthenticateSessionOptional(authenticator)) + app.Handle("POST", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(appCtx.Authenticator)) + app.Handle("GET", "/examples/flash-messages", ex.FlashMessages, mid.AuthenticateSessionOptional(appCtx.Authenticator)) + app.Handle("GET", "/examples/images", ex.Images, mid.AuthenticateSessionOptional(appCtx.Authenticator)) // Register geo g := Geo{ - MasterDB: masterDB, - Redis: redis, + MasterDB: appCtx.MasterDB, + Redis: appCtx.Redis, } app.Handle("GET", "/geo/regions/autocomplete", g.RegionsAutocomplete) app.Handle("GET", "/geo/postal_codes/autocomplete", g.PostalCodesAutocomplete) @@ -168,17 +207,16 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Register root r := Root{ - MasterDB: masterDB, - Renderer: renderer, - ProjectRoutes: projectRoutes, - Sitemap: sm, + Renderer: appCtx.Renderer, + ProjectRoute: appCtx.ProjectRoute, + Sitemap: sm, } app.Handle("GET", "/api", r.SitePage) app.Handle("GET", "/pricing", r.SitePage) app.Handle("GET", "/support", r.SitePage) app.Handle("GET", "/legal/privacy", r.SitePage) app.Handle("GET", "/legal/terms", r.SitePage) - app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(authenticator)) + app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(appCtx.Authenticator)) app.Handle("GET", "/index.html", r.IndexHtml) app.Handle("GET", "/robots.txt", r.RobotTxt) app.Handle("GET", "/sitemap.xml", r.SitemapXml) @@ -193,14 +231,14 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Register health check endpoint. This route is not authenticated. check := Check{ - MasterDB: masterDB, - Redis: redis, + MasterDB: appCtx.MasterDB, + Redis: appCtx.Redis, } app.Handle("GET", "/v1/health", check.Health) // Handle static files/pages. Render a custom 404 page when file not found. static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - err := web.StaticHandler(ctx, w, r, params, staticDir, "") + err := web.StaticHandler(ctx, w, r, params, appCtx.StaticDir, "") if err != nil { if os.IsNotExist(err) { rmsg := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI) @@ -209,7 +247,7 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir err = weberror.NewError(ctx, err, http.StatusInternalServerError) } - return web.RenderError(ctx, w, r, err, renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) + return web.RenderError(ctx, w, r, err, appCtx.Renderer, TmplLayoutBase, TmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) } return nil diff --git a/cmd/web-app/handlers/signup.go b/cmd/web-app/handlers/signup.go index f24a175..cb23c48 100644 --- a/cmd/web-app/handlers/signup.go +++ b/cmd/web-app/handlers/signup.go @@ -20,9 +20,10 @@ import ( // Signup represents the Signup API method handler set. type Signup struct { - MasterDB *sqlx.DB - Renderer web.Renderer - Authenticator *auth.Authenticator + SignupRepo *signup.Repository + AuthRepo *user_auth.Repository + MasterDB *sqlx.DB + Renderer web.Renderer } // Step1 handles collecting the first detailed needed to create a new account. @@ -52,7 +53,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque } // Execute the account / user signup. - _, err = signup.Signup(ctx, claims, h.MasterDB, *req, ctxValues.Now) + _, err = h.SignupRepo.Signup(ctx, claims, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { case account.ErrForbidden: @@ -68,7 +69,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque } // Authenticated the new user. - token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{ + token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{ Email: req.User.Email, Password: req.User.Password, }, time.Hour, ctxValues.Now) @@ -77,7 +78,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque } // Add the token to the users session. - err = handleSessionToken(ctx, h.MasterDB, w, r, token) + err = handleSessionToken(ctx, w, r, token) if err != nil { return false, err } diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index c91a3d3..b47b4c9 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -11,11 +11,9 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/geonames" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" @@ -27,12 +25,12 @@ import ( // User represents the User API method handler set. type User struct { - MasterDB *sqlx.DB - Renderer web.Renderer - Authenticator *auth.Authenticator - ProjectRoutes project_routes.ProjectRoutes - NotifyEmail notify.Email - SecretKey string + UserRepo *user.Repository + AuthRepo *user_auth.Repository + UserAccountRepo *user_account.Repository + AccountRepo *account.Repository + MasterDB *sqlx.DB + Renderer web.Renderer } func urlUserVirtualLogin(userID string) string { @@ -75,7 +73,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request } // Authenticated the user. - token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{ + token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{ Email: req.Email, Password: req.Password, }, sessionTTL, ctxValues.Now) @@ -97,7 +95,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request } // Add the token to the users session. - err = handleSessionToken(ctx, h.MasterDB, w, r, token) + err = handleSessionToken(ctx, w, r, token) if err != nil { return false, err } @@ -173,7 +171,7 @@ func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http return err } - _, err = user.ResetPassword(ctx, h.MasterDB, h.ProjectRoutes.UserResetPassword, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now) + _, err = h.UserRepo.ResetPassword(ctx, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -238,7 +236,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. // Append the query param value to the request. req.ResetHash = resetHash - u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now) + u, err := h.UserRepo.ResetConfirm(ctx, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { case user.ErrResetExpired: @@ -257,7 +255,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. } // Authenticated the user. Probably should use the default session TTL from UserLogin. - token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{ + token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{ Email: u.Email, Password: req.Password, }, time.Hour, ctxValues.Now) @@ -271,7 +269,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. } // Add the token to the users session. - err = handleSessionToken(ctx, h.MasterDB, w, r, token) + err = handleSessionToken(ctx, w, r, token) if err != nil { return false, err } @@ -280,7 +278,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. return true, web.Redirect(ctx, w, r, "/", http.StatusFound) } - _, err = user.ParseResetHash(ctx, h.SecretKey, resetHash, ctxValues.Now) + _, err = h.UserRepo.ParseResetHash(ctx, resetHash, ctxValues.Now) if err != nil { switch errors.Cause(err) { case user.ErrResetExpired: @@ -328,14 +326,14 @@ func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request, return err } - usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject) + usr, err := h.UserRepo.ReadByID(ctx, claims, claims.Subject) if err != nil { return err } data["user"] = usr.Response(ctx) - usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, claims.Subject, false) + usrAccs, err := h.UserAccountRepo.FindByUserID(ctx, claims, claims.Subject, false) if err != nil { return err } @@ -388,7 +386,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques } req.ID = claims.Subject - err = user.Update(ctx, claims, h.MasterDB, *req, ctxValues.Now) + err = h.UserRepo.Update(ctx, claims, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -409,7 +407,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques } pwdReq.ID = claims.Subject - err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, ctxValues.Now) + err = h.UserRepo.UpdatePassword(ctx, claims, *pwdReq, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -441,7 +439,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques return nil } - usr, err := user.ReadByID(ctx, claims, h.MasterDB, claims.Subject) + usr, err := h.UserRepo.ReadByID(ctx, claims, claims.Subject) if err != nil { return err } @@ -484,7 +482,7 @@ func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Reque return err } - acc, err := account.ReadByID(ctx, claims, h.MasterDB, claims.Audience) + acc, err := h.AccountRepo.ReadByID(ctx, claims, claims.Audience) if err != nil { return err } @@ -551,7 +549,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http. } // Perform the account switch. - tkn, err := user_auth.VirtualLogin(ctx, h.MasterDB, h.Authenticator, claims, *req, expires, ctxValues.Now) + tkn, err := h.AuthRepo.VirtualLogin(ctx, claims, *req, expires, ctxValues.Now) if err != nil { if verr, ok := weberror.NewValidationError(ctx, err); ok { data["validationErrors"] = verr.(*weberror.Error) @@ -565,7 +563,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http. sess = webcontext.SessionUpdateAccessToken(sess, tkn.AccessToken) // Read the account for a flash message. - usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID) + usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID) if err != nil { return false, err } @@ -588,7 +586,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http. return nil } - usrAccs, err := user_account.Find(ctx, claims, h.MasterDB, user_account.UserAccountFindRequest{ + usrAccs, err := h.UserAccountRepo.Find(ctx, claims, user_account.UserAccountFindRequest{ Where: "account_id = ?", Args: []interface{}{claims.Audience}, }) @@ -612,7 +610,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http. userPhs = append(userPhs, "?") } - users, err := user.Find(ctx, claims, h.MasterDB, user.UserFindRequest{ + users, err := h.UserRepo.Find(ctx, claims, user.UserFindRequest{ Where: fmt.Sprintf("id IN (%s)", strings.Join(userPhs, ", ")), Args: userIDs, @@ -657,7 +655,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http expires = time.Hour } - tkn, err := user_auth.VirtualLogout(ctx, h.MasterDB, h.Authenticator, claims, expires, ctxValues.Now) + tkn, err := h.AuthRepo.VirtualLogout(ctx, claims, expires, ctxValues.Now) if err != nil { return err } @@ -667,11 +665,11 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http // Display a success message to verify the user has switched contexts. if claims.Subject != tkn.UserID && claims.Audience != tkn.AccountID { - usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID) + usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID) if err != nil { return err } - acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID) + acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID) if err != nil { return err } @@ -680,7 +678,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http fmt.Sprintf("You are now virtually logged back into account %s user %s.", acc.Response(ctx).Name, usr.Response(ctx).Name)) } else if claims.Audience != tkn.AccountID { - acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID) + acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID) if err != nil { return err } @@ -689,7 +687,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http fmt.Sprintf("You are now virtually logged back into account %s.", acc.Response(ctx).Name)) } else { - usr, err := user.ReadByID(ctx, claims, h.MasterDB, tkn.UserID) + usr, err := h.UserRepo.ReadByID(ctx, claims, tkn.UserID) if err != nil { return err } @@ -757,7 +755,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http } // Perform the account switch. - tkn, err := user_auth.SwitchAccount(ctx, h.MasterDB, h.Authenticator, claims, *req, expires, ctxValues.Now) + tkn, err := h.AuthRepo.SwitchAccount(ctx, claims, *req, expires, ctxValues.Now) if err != nil { if verr, ok := weberror.NewValidationError(ctx, err); ok { data["validationErrors"] = verr.(*weberror.Error) @@ -771,7 +769,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http sess = webcontext.SessionUpdateAccessToken(sess, tkn.AccessToken) // Read the account for a flash message. - acc, err := account.ReadByID(ctx, claims, h.MasterDB, tkn.AccountID) + acc, err := h.AccountRepo.ReadByID(ctx, claims, tkn.AccountID) if err != nil { return false, err } @@ -794,7 +792,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http return nil } - accounts, err := account.Find(ctx, claims, h.MasterDB, account.AccountFindRequest{ + accounts, err := h.AccountRepo.Find(ctx, claims, account.AccountFindRequest{ Order: []string{"name"}, }) if err != nil { @@ -816,7 +814,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http } // handleSessionToken persists the access token to the session for request authentication. -func handleSessionToken(ctx context.Context, db *sqlx.DB, w http.ResponseWriter, r *http.Request, token user_auth.Token) error { +func handleSessionToken(ctx context.Context, w http.ResponseWriter, r *http.Request, token user_auth.Token) error { if token.AccessToken == "" { return errors.New("accessToken is required.") } diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index e0b9526..86e5f8b 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -3,14 +3,16 @@ package handlers import ( "context" "fmt" + "net/http" + "strings" + "time" + "geeks-accelerator/oss/saas-starter-kit/internal/geonames" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/datatable" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" @@ -20,20 +22,17 @@ import ( "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" - "net/http" - "strings" - "time" ) // Users represents the Users API method handler set. type Users struct { - MasterDB *sqlx.DB - Redis *redis.Client - Renderer web.Renderer - Authenticator *auth.Authenticator - ProjectRoutes project_routes.ProjectRoutes - NotifyEmail notify.Email - SecretKey string + UserRepo *user.Repository + UserAccountRepo *user_account.Repository + AuthRepo *user_auth.Repository + InviteRepo *invite.Repository + MasterDB *sqlx.DB + Redis *redis.Client + Renderer web.Renderer } func urlUsersIndex() string { @@ -144,7 +143,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques } loadFunc := func(ctx context.Context, sorting string, fields []datatable.DisplayField) (resp [][]datatable.ColumnValue, err error) { - res, err := user_account.UserFindByAccount(ctx, claims, h.MasterDB, user_account.UserFindByAccountRequest{ + res, err := h.UserAccountRepo.UserFindByAccount(ctx, claims, user_account.UserFindByAccountRequest{ AccountID: claims.Audience, Order: strings.Split(sorting, ","), }) @@ -232,7 +231,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque } } - usr, err := user.Create(ctx, claims, h.MasterDB, req.UserCreateRequest, ctxValues.Now) + usr, err := h.UserRepo.Create(ctx, claims, req.UserCreateRequest, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -246,7 +245,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque } uaStatus := user_account.UserAccountStatus_Active - _, err = user_account.Create(ctx, claims, h.MasterDB, user_account.UserAccountCreateRequest{ + _, err = h.UserAccountRepo.Create(ctx, claims, user_account.UserAccountCreateRequest{ UserID: usr.ID, AccountID: claims.Audience, Roles: req.Roles, @@ -327,7 +326,7 @@ func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request switch r.PostForm.Get("action") { case "archive": - err = user.Archive(ctx, claims, h.MasterDB, user.UserArchiveRequest{ + err = h.UserRepo.Archive(ctx, claims, user.UserArchiveRequest{ ID: userID, }, ctxValues.Now) if err != nil { @@ -352,14 +351,14 @@ func (h *Users) View(ctx context.Context, w http.ResponseWriter, r *http.Request return nil } - usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID) + usr, err := h.UserRepo.ReadByID(ctx, claims, userID) if err != nil { return err } data["user"] = usr.Response(ctx) - usrAccs, err := user_account.FindByUserID(ctx, claims, h.MasterDB, userID, false) + usrAccs, err := h.UserAccountRepo.FindByUserID(ctx, claims, userID, false) if err != nil { return err } @@ -425,7 +424,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque } } - err = user.Update(ctx, claims, h.MasterDB, req.UserUpdateRequest, ctxValues.Now) + err = h.UserRepo.Update(ctx, claims, req.UserUpdateRequest, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -439,7 +438,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque } if req.Roles != nil { - err = user_account.Update(ctx, claims, h.MasterDB, user_account.UserAccountUpdateRequest{ + err = h.UserAccountRepo.Update(ctx, claims, user_account.UserAccountUpdateRequest{ UserID: userID, AccountID: claims.Audience, Roles: &req.Roles, @@ -465,7 +464,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque } pwdReq.ID = userID - err = user.UpdatePassword(ctx, claims, h.MasterDB, *pwdReq, ctxValues.Now) + err = h.UserRepo.UpdatePassword(ctx, claims, *pwdReq, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -497,12 +496,12 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque return nil } - usr, err := user.ReadByID(ctx, claims, h.MasterDB, userID) + usr, err := h.UserRepo.ReadByID(ctx, claims, userID) if err != nil { return err } - usrAcc, err := user_account.Read(ctx, claims, h.MasterDB, user_account.UserAccountReadRequest{ + usrAcc, err := h.UserAccountRepo.Read(ctx, claims, user_account.UserAccountReadRequest{ UserID: userID, AccountID: claims.Audience, }) @@ -577,7 +576,7 @@ func (h *Users) Invite(ctx context.Context, w http.ResponseWriter, r *http.Reque req.UserID = claims.Subject req.AccountID = claims.Audience - res, err := invite.SendUserInvites(ctx, claims, h.MasterDB, h.ProjectRoutes.UserInviteAccept, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now) + res, err := h.InviteRepo.SendUserInvites(ctx, claims, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { default: @@ -661,7 +660,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http // Append the query param value to the request. req.InviteHash = inviteHash - hash, err := invite.AcceptInviteUser(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now) + hash, err := h.InviteRepo.AcceptInviteUser(ctx, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { case invite.ErrInviteExpired: @@ -699,13 +698,13 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http } // Load the user without any claims applied. - usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, hash.UserID) + usr, err := h.UserRepo.ReadByID(ctx, auth.Claims{}, hash.UserID) if err != nil { return false, err } // Authenticated the user. Probably should use the default session TTL from UserLogin. - token, err := user_auth.Authenticate(ctx, h.MasterDB, h.Authenticator, user_auth.AuthenticateRequest{ + token, err := h.AuthRepo.Authenticate(ctx, user_auth.AuthenticateRequest{ Email: usr.Email, Password: req.Password, AccountID: hash.AccountID, @@ -720,7 +719,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http } // Add the token to the users session. - err = handleSessionToken(ctx, h.MasterDB, w, r, token) + err = handleSessionToken(ctx, w, r, token) if err != nil { return false, err } @@ -729,9 +728,9 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http return true, web.Redirect(ctx, w, r, "/", http.StatusFound) } - usrAcc, err := invite.AcceptInvite(ctx, h.MasterDB, invite.AcceptInviteRequest{ + usrAcc, err := h.InviteRepo.AcceptInvite(ctx, invite.AcceptInviteRequest{ InviteHash: inviteHash, - }, h.SecretKey, ctxValues.Now) + }, ctxValues.Now) if err != nil { switch errors.Cause(err) { @@ -776,7 +775,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http } // Read user by ID with no claims. - usr, err := user.ReadByID(ctx, auth.Claims{}, h.MasterDB, usrAcc.UserID) + usr, err := h.UserRepo.ReadByID(ctx, auth.Claims{}, usrAcc.UserID) if err != nil { return false, err } diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index a89843f..659d576 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -6,7 +6,12 @@ import ( "encoding/json" "expvar" "fmt" - "geeks-accelerator/oss/saas-starter-kit/internal/project_route" + "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" + "geeks-accelerator/oss/saas-starter-kit/internal/project" + "geeks-accelerator/oss/saas-starter-kit/internal/signup" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" "html/template" "log" "net" @@ -33,7 +38,7 @@ import ( template_renderer "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/template-renderer" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" + "geeks-accelerator/oss/saas-starter-kit/internal/project_route" "geeks-accelerator/oss/saas-starter-kit/internal/user" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" @@ -52,7 +57,6 @@ import ( redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" - "gopkg.in/gomail.v2" ) // build is the git version of this program. It is set using build flags in the makefile. @@ -67,10 +71,9 @@ func main() { // ========================================================================= // Logging - log.SetFlags(log.LstdFlags|log.Lmicroseconds|log.Lshortfile) - log.SetPrefix(service+" : ") - log := log.New(os.Stdout, log.Prefix() , log.Flags()) - + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) + log.SetPrefix(service + " : ") + log := log.New(os.Stdout, log.Prefix(), log.Flags()) // ========================================================================= // Configuration @@ -88,12 +91,12 @@ func main() { DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"` } Service struct { - Name string `default:"web-app" envconfig:"NAME"` - BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://example.saasstartupkit.com"` - HostNames []string `envconfig:"HOST_NAMES" example:"www.example.saasstartupkit.com"` - EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` - TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` - StaticFiles struct { + Name string `default:"web-app" envconfig:"SERVICE_NAME"` + BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://example.saasstartupkit.com"` + HostNames []string `envconfig:"HOST_NAMES" example:"www.example.saasstartupkit.com"` + EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` + TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` + StaticFiles struct { Dir string `default:"./static" envconfig:"STATIC_DIR"` S3Enabled bool `envconfig:"S3_ENABLED"` S3Prefix string `default:"public/web_app/static" envconfig:"S3_PREFIX"` @@ -105,11 +108,11 @@ func main() { ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } Project struct { - Name string `default:"" envconfig:"PROJECT"` - SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` - SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"` - EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` - WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"` + Name string `default:"" envconfig:"PROJECT_NAME"` + SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` + SharedSecretKey string `default:"" envconfig:"SHARED_SECRET_KEY"` + EmailSender string `default:"test@example.saasstartupkit.com" envconfig:"EMAIL_SENDER"` + WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.example.saasstartupkit.com"` } Redis struct { Host string `default:":6379" envconfig:"HOST"` @@ -202,7 +205,7 @@ func main() { if cfg.Project.Name != "" { pts = append(pts, cfg.Project.Name) } - pts = append(pts, cfg.Env, cfg.Service.Name) + pts = append(pts, cfg.Env) cfg.Aws.SecretsManagerConfigPrefix = filepath.Join(pts...) } @@ -298,7 +301,7 @@ func main() { // AWS secrets manager ID for storing the session key. This is optional and only will be used // if a valid AWS session is provided. - secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "sharedSecretKey") + secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "SharedSecretKey") // If AWS is enabled, check the Secrets Manager for the session key. if awsSession != nil { @@ -310,10 +313,10 @@ func main() { // If the session key is still empty, generate a new key. if cfg.Project.SharedSecretKey == "" { - cfg.Project.SharedSecretKey = string(securecookie.GenerateRandomKey(32)) + cfg.Project.SharedSecretKey = string(securecookie.GenerateRandomKey(32)) if awsSession != nil { - err = devops.SecretManagerPutString(awsSession, secretID, cfg.Service.SecretKey) + err = devops.SecretManagerPutString(awsSession, secretID, cfg.Project.SharedSecretKey) if err != nil { log.Fatalf("main : Session : %+v", err) } @@ -396,7 +399,7 @@ func main() { var notifyEmail notify.Email if awsSession != nil { // Send emails with AWS SES. Alternative to use SMTP with notify.NewEmailSmtp. - notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) + notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Project.SharedTemplateDir, cfg.Project.EmailSender) if err != nil { log.Fatalf("main : Notify Email : %+v", err) } @@ -430,12 +433,44 @@ func main() { } // ========================================================================= - // Load middlewares that need to be configured specific for the service. + // Init repositories and AppContext - var serviceMiddlewares = []web.Middleware{ - mid.Translator(webcontext.UniversalTranslator()), + projectRoute, err := project_route.New(cfg.Project.WebApiBaseUrl, cfg.Service.BaseUrl) + if err != nil { + log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) } + usrRepo := user.NewRepository(masterDb, projectRoute.UserResetPassword, notifyEmail, cfg.Project.SharedSecretKey) + usrAccRepo := user_account.NewRepository(masterDb) + accRepo := account.NewRepository(masterDb) + accPrefRepo := account_preference.NewRepository(masterDb) + authRepo := user_auth.NewRepository(masterDb, authenticator, usrRepo, usrAccRepo, accPrefRepo) + signupRepo := signup.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo) + inviteRepo := invite.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo, projectRoute.UserInviteAccept, notifyEmail, cfg.Project.SharedSecretKey) + prjRepo := project.NewRepository(masterDb) + + appCtx := &handlers.AppContext{ + Log: log, + Env: cfg.Env, + MasterDB: masterDb, + Redis: redisClient, + TemplateDir: cfg.Service.TemplateDir, + StaticDir: cfg.Service.StaticFiles.Dir, + ProjectRoute: projectRoute, + UserRepo: usrRepo, + UserAccountRepo: usrAccRepo, + AccountRepo: accRepo, + AccountPrefRepo: accPrefRepo, + AuthRepo: authRepo, + SignupRepo: signupRepo, + InviteRepo: inviteRepo, + ProjectRepo: prjRepo, + Authenticator: authenticator, + } + + // ========================================================================= + // Load middlewares that need to be configured specific for the service. + // Init redirect middleware to ensure all requests go to the primary domain contained in the base URL. if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" { redirect := mid.DomainNameRedirect(mid.DomainNameRedirectConfig{ @@ -451,24 +486,23 @@ func main() { DomainName: primaryServiceHost, HTTPSEnabled: cfg.Service.EnableHTTPS, }) - serviceMiddlewares = append(serviceMiddlewares, redirect) + appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, redirect) } + // Add the translator middleware for localization. + appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, mid.Translator(webcontext.UniversalTranslator())) + // Generate the new session store and append it to the global list of middlewares. // Init session store if cfg.Service.SessionName == "" { cfg.Service.SessionName = fmt.Sprintf("%s-session", cfg.Service.Name) } - sessionStore := sessions.NewCookieStore([]byte(cfg.Service.SecretKey)) - serviceMiddlewares = append(serviceMiddlewares, mid.Session(sessionStore, cfg.Service.SessionName)) + sessionStore := sessions.NewCookieStore([]byte(cfg.Project.SharedSecretKey)) + appCtx.PostAppMiddleware = append(appCtx.PostAppMiddleware, mid.Session(sessionStore, cfg.Service.SessionName)) // ========================================================================= // URL Formatter - projectRoutes, err := project_route.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl) - if err != nil { - log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) - } // s3UrlFormatter is a help function used by to convert an s3 key to // a publicly available image URL. @@ -488,7 +522,7 @@ func main() { return s3UrlFormatter(p) } } else { - staticS3UrlFormatter = projectRoutes.WebAppUrl + staticS3UrlFormatter = projectRoute.WebAppUrl } // staticUrlFormatter is a help function used by template functions defined below. @@ -691,7 +725,7 @@ func main() { return nil } - usr, err := user.ReadByID(ctx, auth.Claims{}, masterDb, claims.Subject) + usr, err := usrRepo.ReadByID(ctx, auth.Claims{}, claims.Subject) if err != nil { return nil } @@ -726,7 +760,7 @@ func main() { return nil } - acc, err := account.ReadByID(ctx, auth.Claims{}, masterDb, claims.Audience) + acc, err := accRepo.ReadByID(ctx, auth.Claims{}, claims.Audience) if err != nil { return nil } @@ -867,7 +901,7 @@ func main() { enableHotReload := cfg.Env == "dev" // Template Renderer used to generate HTML response for web experience. - renderer, err := template_renderer.NewTemplateRenderer(cfg.Service.TemplateDir, enableHotReload, gvd, t, eh) + appCtx.Renderer, err = template_renderer.NewTemplateRenderer(cfg.Service.TemplateDir, enableHotReload, gvd, t, eh) if err != nil { log.Fatalf("main : Marshalling Config to JSON : %+v", err) } @@ -919,7 +953,7 @@ func main() { if cfg.HTTP.Host != "" { api := http.Server{ Addr: cfg.HTTP.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SecretKey, notifyEmail, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, appCtx), ReadTimeout: cfg.HTTP.ReadTimeout, WriteTimeout: cfg.HTTP.WriteTimeout, MaxHeaderBytes: 1 << 20, @@ -936,7 +970,7 @@ func main() { if cfg.HTTPS.Host != "" { api := http.Server{ Addr: cfg.HTTPS.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SecretKey, notifyEmail, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, appCtx), ReadTimeout: cfg.HTTPS.ReadTimeout, WriteTimeout: cfg.HTTPS.WriteTimeout, MaxHeaderBytes: 1 << 20, diff --git a/internal/platform/notify/email_smtp.go b/internal/platform/notify/email_smtp.go index ea56c4a..9be78c8 100644 --- a/internal/platform/notify/email_smtp.go +++ b/internal/platform/notify/email_smtp.go @@ -20,7 +20,7 @@ package notify Username: cfg.SMTP.User, Password: cfg.SMTP.Pass} notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) - */ +*/ import ( "context" diff --git a/internal/user/models.go b/internal/user/models.go index 7860dd3..b0c3cb7 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -272,3 +272,8 @@ func ParseResetHash(ctx context.Context, secretKey string, str string, now time. return &hash, nil } + +// ParseResetHash extracts the details encrypted in the hash string. +func (repo *Repository) ParseResetHash(ctx context.Context, str string, now time.Time) (*ResetHash, error) { + return ParseResetHash(ctx, repo.secretKey, str, now) +} From 9dad6f593510c0ac1594fac6c6c5832fd3a9deff Mon Sep 17 00:00:00 2001 From: Zaq? Wiedmann Date: Thu, 15 Aug 2019 02:24:00 +0000 Subject: [PATCH 06/21] Merge request to expose review features on these files --- README.md | 2 +- cmd/web-api/README.md | 6 +----- cmd/web-app/README.md | 5 +---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c5fb683..75e88ec 100644 --- a/README.md +++ b/README.md @@ -614,4 +614,4 @@ documentation or just send us your feedback and suggestions ; ) ## Join us on Gopher Slack If you are having problems installing, troubles getting the project running or would like to contribute, join the -channel #saas-starter-kit on [Gopher Slack](http://invite.slack.golangbridge.org/) +channel #saas-starter-kit on [Gopher Slack](http://invite.slack.golangbridge.org/) \ No newline at end of file diff --git a/cmd/web-api/README.md b/cmd/web-api/README.md index 31eeb01..3f3007b 100644 --- a/cmd/web-api/README.md +++ b/cmd/web-api/README.md @@ -295,8 +295,4 @@ Ensure the `pkg` directory used for go module cache has the correct permissions. ```bash sudo chown -R $(whoami):staff ${HOME}/go/pkg sudo chmod -R 755 ${HOME}/go/pkg -``` - - - - +``` \ No newline at end of file diff --git a/cmd/web-app/README.md b/cmd/web-app/README.md index 83f75b8..2cdc047 100644 --- a/cmd/web-app/README.md +++ b/cmd/web-app/README.md @@ -111,7 +111,4 @@ This web-app service eventually will include the following: - project items (tasks) - view item - create item (adds task to checklist) - - update item - - - + - update item \ No newline at end of file From 8c28261fee80292826f0b87d7b9a74692c76b5b7 Mon Sep 17 00:00:00 2001 From: huyng Date: Thu, 15 Aug 2019 14:27:05 +0700 Subject: [PATCH 07/21] Update GetGeoNames and Migration functions. --- internal/geonames/geonames.go | 134 ++++++++++++++++++++++++++++++++++ internal/schema/migrations.go | 98 ++++++++++++++++++++----- 2 files changed, 214 insertions(+), 18 deletions(-) diff --git a/internal/geonames/geonames.go b/internal/geonames/geonames.go index 47a4e48..f184425 100644 --- a/internal/geonames/geonames.go +++ b/internal/geonames/geonames.go @@ -8,10 +8,13 @@ import ( "encoding/csv" "fmt" "io" + "net/http" "strconv" "strings" + "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -325,3 +328,134 @@ func loadGeonameCountry(ctx context.Context, rr chan<- interface{}, country stri } } } + +// GetGeonameCountry downloads geoname data for the country. +// Parses data and returns slice of Geoname +func GetGeonameCountry(ctx context.Context, country string) ([]Geoname, error) { + res := make([]Geoname, 0) + var err error + var resp *http.Response + + u := fmt.Sprintf("http://download.geonames.org/export/zip/%s.zip", country) + resp, err = pester.Get(u) + if err != nil { + for i := 0; i < 3; i++ { + resp, err = pester.Get(u) + if err == nil { + break + } + time.Sleep(time.Second * 1) + } + if err != nil { + err = errors.WithMessagef(err, "Failed to read countries from '%s'", u) + return res, err + } + } + defer resp.Body.Close() + + br := bufio.NewReader(resp.Body) + + buff := bytes.NewBuffer([]byte{}) + size, err := io.Copy(buff, br) + if err != nil { + err = errors.WithStack(err) + return res, err + } + + b := bytes.NewReader(buff.Bytes()) + zr, err := zip.NewReader(b, size) + if err != nil { + err = errors.WithStack(err) + return res, err + } + + for _, f := range zr.File { + if f.Name == "readme.txt" { + continue + } + + fh, err := f.Open() + if err != nil { + err = errors.WithStack(err) + return res, err + } + + scanner := bufio.NewScanner(fh) + for scanner.Scan() { + line := scanner.Text() + + if strings.Contains(line, "\"") { + line = strings.Replace(line, "\"", "\\\"", -1) + } + + r := csv.NewReader(strings.NewReader(line)) + r.Comma = '\t' // Use tab-delimited instead of comma <---- here! + r.LazyQuotes = true + r.FieldsPerRecord = -1 + + lines, err := r.ReadAll() + if err != nil { + err = errors.WithStack(err) + continue + } + + for _, row := range lines { + + /* + fmt.Println("CountryCode: row[0]", row[0]) + fmt.Println("PostalCode: row[1]", row[1]) + fmt.Println("PlaceName: row[2]", row[2]) + fmt.Println("StateName: row[3]", row[3]) + fmt.Println("StateCode : row[4]", row[4]) + fmt.Println("CountyName: row[5]", row[5]) + fmt.Println("CountyCode : row[6]", row[6]) + fmt.Println("CommunityName: row[7]", row[7]) + fmt.Println("CommunityCode: row[8]", row[8]) + fmt.Println("Latitude: row[9]", row[9]) + fmt.Println("Longitude: row[10]", row[10]) + fmt.Println("Accuracy: row[11]", row[11]) + */ + + gn := Geoname{ + CountryCode: row[0], + PostalCode: row[1], + PlaceName: row[2], + StateName: row[3], + StateCode: row[4], + CountyName: row[5], + CountyCode: row[6], + CommunityName: row[7], + CommunityCode: row[8], + } + if row[9] != "" { + gn.Latitude, err = decimal.NewFromString(row[9]) + if err != nil { + err = errors.WithStack(err) + } + } + + if row[10] != "" { + gn.Longitude, err = decimal.NewFromString(row[10]) + if err != nil { + err = errors.WithStack(err) + } + } + + if row[11] != "" { + gn.Accuracy, err = strconv.Atoi(row[11]) + if err != nil { + err = errors.WithStack(err) + } + } + + res = append(res, gn) + } + } + + if err := scanner.Err(); err != nil { + err = errors.WithStack(err) + } + } + + return res, err +} diff --git a/internal/schema/migrations.go b/internal/schema/migrations.go index fe6a3c9..4523de1 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -9,6 +9,10 @@ import ( "strings" "geeks-accelerator/oss/saas-starter-kit/internal/geonames" + + "fmt" + "time" + "github.com/geeks-accelerator/sqlxmigrate" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -240,33 +244,91 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } } - q := "insert into geonames " + - "(country_code,postal_code,place_name,state_name,state_code,county_name,county_code,community_name,community_code,latitude,longitude,accuracy) " + - "values(?,?,?,?,?,?,?,?,?,?,?,?)" - q = db.Rebind(q) - stmt, err := db.Prepare(q) - if err != nil { - return errors.WithMessagef(err, "Failed to prepare sql query '%s'", q) - } - + countries := geonames.ValidGeonameCountries(context.Background()) if isUnittest { - } else { - resChan := make(chan interface{}) - go geonames.LoadGeonames(ctx, resChan) + } - for r := range resChan { - switch v := r.(type) { - case geonames.Geoname: - _, err = stmt.Exec(v.CountryCode, v.PostalCode, v.PlaceName, v.StateName, v.StateCode, v.CountyName, v.CountyCode, v.CommunityName, v.CommunityCode, v.Latitude, v.Longitude, v.Accuracy) + ncol := 12 + fn := func(geoNames []geonames.Geoname) error { + valueStrings := make([]string, 0, len(geoNames)) + valueArgs := make([]interface{}, 0, len(geoNames)*ncol) + for _, geoname := range geoNames { + valueStrings = append(valueStrings, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + + valueArgs = append(valueArgs, geoname.CountryCode) + valueArgs = append(valueArgs, geoname.PostalCode) + valueArgs = append(valueArgs, geoname.PlaceName) + + valueArgs = append(valueArgs, geoname.StateName) + valueArgs = append(valueArgs, geoname.StateCode) + valueArgs = append(valueArgs, geoname.CountyName) + + valueArgs = append(valueArgs, geoname.CountyCode) + valueArgs = append(valueArgs, geoname.CommunityName) + valueArgs = append(valueArgs, geoname.CommunityCode) + + valueArgs = append(valueArgs, geoname.Latitude) + valueArgs = append(valueArgs, geoname.Longitude) + valueArgs = append(valueArgs, geoname.Accuracy) + } + insertStmt := fmt.Sprintf("insert into geonames "+ + "(country_code,postal_code,place_name,state_name,state_code,county_name,county_code,community_name,community_code,latitude,longitude,accuracy) "+ + "VALUES %s", strings.Join(valueStrings, ",")) + insertStmt = db.Rebind(insertStmt) + + stmt, err := db.Prepare(insertStmt) + if err != nil { + return errors.WithMessagef(err, "Failed to prepare sql query '%s'", insertStmt) + } + + _, err = stmt.Exec(valueArgs...) + return err + } + start := time.Now() + for _, country := range countries { + //fmt.Println("LoadGeonames: start country: ", country) + v, err := geonames.GetGeonameCountry(context.Background(), country) + if err != nil { + return errors.WithStack(err) + } + //fmt.Println("Geoname records: ", len(v)) + + batch := 4500 + n := len(v) / batch + + //fmt.Println("Number of batch: ", n) + + if n == 0 { + err := fn(v) + if err != nil { + return errors.WithStack(err) + } + } else { + for i := 0; i < n; i++ { + vn := v[i*batch : (i+1)*batch] + err := fn(vn) + if err != nil { + return errors.WithStack(err) + } + if n > 0 && n%25 == 0 { + time.Sleep(200) + } + } + if len(v)%batch > 0 { + fmt.Println("Remain part: ", len(v)-n*batch) + vn := v[n*batch:] + err := fn(vn) if err != nil { return errors.WithStack(err) } - case error: - return v } } + + //fmt.Println("Insert Geoname took: ", time.Since(start)) + //fmt.Println("LoadGeonames: end country: ", country) } + fmt.Println("Total Geonames population took: ", time.Since(start)) queries := []string{ `create index idx_geonames_country_code on geonames (country_code)`, From 71713280729d79ee4207a3771cc96d574daa30de Mon Sep 17 00:00:00 2001 From: huyng Date: Thu, 15 Aug 2019 14:40:22 +0700 Subject: [PATCH 08/21] Use ctx param from outer function --- internal/schema/migrations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/schema/migrations.go b/internal/schema/migrations.go index 4523de1..bab4c17 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -244,7 +244,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } } - countries := geonames.ValidGeonameCountries(context.Background()) + countries := geonames.ValidGeonameCountries(ctx) if isUnittest { } From c61a934279b03fb77c87a2afdbff9fe7f7e46d79 Mon Sep 17 00:00:00 2001 From: huyng Date: Thu, 15 Aug 2019 14:46:44 +0700 Subject: [PATCH 09/21] Add more comment --- internal/geonames/geonames.go | 2 ++ internal/schema/migrations.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/geonames/geonames.go b/internal/geonames/geonames.go index f184425..ae1c9c8 100644 --- a/internal/geonames/geonames.go +++ b/internal/geonames/geonames.go @@ -339,6 +339,8 @@ func GetGeonameCountry(ctx context.Context, country string) ([]Geoname, error) { u := fmt.Sprintf("http://download.geonames.org/export/zip/%s.zip", country) resp, err = pester.Get(u) if err != nil { + // Add re-try three times after failing first time + // This reduces the risk when network is lagy, we still have chance to re-try. for i := 0; i < 3; i++ { resp, err = pester.Get(u) if err == nil { diff --git a/internal/schema/migrations.go b/internal/schema/migrations.go index bab4c17..2ec3bec 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -293,7 +293,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest return errors.WithStack(err) } //fmt.Println("Geoname records: ", len(v)) - + // Max argument values of Postgres is about 54460. So the batch size for bulk insert is selected 4500*12 (ncol) batch := 4500 n := len(v) / batch From 83118e85ca67aac4d5858f2f276437a616c310e3 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Fri, 16 Aug 2019 14:52:57 -0800 Subject: [PATCH 10/21] Remove POD architecture from readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5fb683..fc58cd3 100644 --- a/README.md +++ b/README.md @@ -489,7 +489,7 @@ For more details on this service, read [web-app readme](https://gitlab.com/geeks Schema is a minimalistic database migration helper that can manually be invoked via CLI. It provides schema versioning and migration rollback. -To support POD architecture, the schema for the entire project is defined globally and is located inside internal: +The schema for the entire project is defined globally and is located inside internal: [internal/schema](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/tree/master/internal/schema) Keeping a global schema helps ensure business logic can be decoupled across multiple packages. It is a firm belief that From d277b0ec254797b66d23e0a291457ac60fa24367 Mon Sep 17 00:00:00 2001 From: huyng Date: Sat, 17 Aug 2019 11:03:48 +0700 Subject: [PATCH 11/21] Use interface in the handlers of web-api/web-app --- cmd/web-api/handlers/account.go | 30 ++++++-- cmd/web-api/handlers/example.go | 5 +- cmd/web-api/handlers/project.go | 28 +++++--- cmd/web-api/handlers/routes.go | 35 ++++------ cmd/web-api/handlers/signup.go | 8 ++- cmd/web-api/handlers/user.go | 70 +++++++++++++------ cmd/web-api/handlers/user_account.go | 25 ++++++- cmd/web-api/main.go | 3 +- cmd/web-api/tests/account_test.go | 1 + cmd/web-app/handlers/account.go | 22 +++--- cmd/web-app/handlers/api_geo.go | 25 +++++-- cmd/web-app/handlers/projects.go | 4 +- cmd/web-app/handlers/routes.go | 50 +++++++------ cmd/web-app/handlers/signup.go | 9 ++- cmd/web-app/handlers/user.go | 40 ++++++----- cmd/web-app/handlers/users.go | 19 ++--- cmd/web-app/main.go | 10 ++- docker-compose.yaml | 4 +- .../account_preference/account_preference.go | 1 + internal/geonames/countries.go | 8 +-- internal/geonames/country_timezones.go | 12 ++-- internal/geonames/geonames.go | 23 +++--- internal/geonames/models.go | 12 ++++ internal/schema/migrations.go | 4 +- internal/user_account/invite/invite.go | 5 +- internal/user_auth/auth.go | 4 +- 26 files changed, 294 insertions(+), 163 deletions(-) diff --git a/cmd/web-api/handlers/account.go b/cmd/web-api/handlers/account.go index 592d962..f81bba0 100644 --- a/cmd/web-api/handlers/account.go +++ b/cmd/web-api/handlers/account.go @@ -4,23 +4,45 @@ import ( "context" "net/http" "strconv" + "time" "geeks-accelerator/oss/saas-starter-kit/internal/account" + accountref "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Account represents the Account API method handler set. -type Account struct { - *account.Repository +type Accounts struct { + Repository AccountRepository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } +type AccountRepository interface { + //CanReadAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string) error + Find(ctx context.Context, claims auth.Claims, req account.AccountFindRequest) (account.Accounts, error) + Create(ctx context.Context, claims auth.Claims, req account.AccountCreateRequest, now time.Time) (*account.Account, error) + ReadByID(ctx context.Context, claims auth.Claims, id string) (*account.Account, error) + Read(ctx context.Context, claims auth.Claims, req account.AccountReadRequest) (*account.Account, error) + Update(ctx context.Context, claims auth.Claims, req account.AccountUpdateRequest, now time.Time) error + Archive(ctx context.Context, claims auth.Claims, req account.AccountArchiveRequest, now time.Time) error + Delete(ctx context.Context, claims auth.Claims, req account.AccountDeleteRequest) error +} +type AccountPrefRepository interface { + Find(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceFindRequest) ([]*accountref.AccountPreference, error) + FindByAccountID(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceFindByAccountIDRequest) ([]*accountref.AccountPreference, error) + Read(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceReadRequest) (*accountref.AccountPreference, error) + Set(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceSetRequest, now time.Time) error + Archive(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceArchiveRequest, now time.Time) error + Delete(ctx context.Context, claims auth.Claims, req accountref.AccountPreferenceDeleteRequest) error +} + // Read godoc // @Summary Get account by ID // @Description Read returns the specified account from the system. @@ -34,7 +56,7 @@ type Account struct { // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /accounts/{id} [get] -func (h *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Accounts) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -81,7 +103,7 @@ func (h *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /accounts [patch] -func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Accounts) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { diff --git a/cmd/web-api/handlers/example.go b/cmd/web-api/handlers/example.go index fe15ddd..b866eff 100644 --- a/cmd/web-api/handlers/example.go +++ b/cmd/web-api/handlers/example.go @@ -7,13 +7,14 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" - "github.com/pkg/errors" "net/http" + + "github.com/pkg/errors" ) // Example represents the Example API method handler set. type Example struct { - Project *project.Repository + Project ProjectRepository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } diff --git a/cmd/web-api/handlers/project.go b/cmd/web-api/handlers/project.go index 82c835f..b0cd946 100644 --- a/cmd/web-api/handlers/project.go +++ b/cmd/web-api/handlers/project.go @@ -5,23 +5,35 @@ import ( "net/http" "strconv" "strings" + "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" + "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Project represents the Project API method handler set. -type Project struct { - *project.Repository +type Projects struct { + Repository ProjectRepository // ADD OTHER STATE LIKE THE LOGGER IF NEEDED. } +type ProjectRepository interface { + ReadByID(ctx context.Context, claims auth.Claims, id string) (*project.Project, error) + Find(ctx context.Context, claims auth.Claims, req project.ProjectFindRequest) (project.Projects, error) + Read(ctx context.Context, claims auth.Claims, req project.ProjectReadRequest) (*project.Project, error) + Create(ctx context.Context, claims auth.Claims, req project.ProjectCreateRequest, now time.Time) (*project.Project, error) + Update(ctx context.Context, claims auth.Claims, req project.ProjectUpdateRequest, now time.Time) error + Archive(ctx context.Context, claims auth.Claims, req project.ProjectArchiveRequest, now time.Time) error + Delete(ctx context.Context, claims auth.Claims, req project.ProjectDeleteRequest) error +} + // Find godoc // TODO: Need to implement unittests on projects/find endpoint. There are none. // @Summary List projects @@ -40,7 +52,7 @@ type Project struct { // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [get] -func (h *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -133,7 +145,7 @@ func (h *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/{id} [get] -func (h *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -181,7 +193,7 @@ func (h *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [post] -func (h *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -231,7 +243,7 @@ func (h *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects [patch] -func (h *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -282,7 +294,7 @@ func (h *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/archive [patch] -func (h *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -333,7 +345,7 @@ func (h *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /projects/{id} [delete] -func (h *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Projects) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, err := auth.ClaimsFromContext(ctx) if err != nil { return err diff --git a/cmd/web-api/handlers/routes.go b/cmd/web-api/handlers/routes.go index 00f4704..cfec550 100644 --- a/cmd/web-api/handlers/routes.go +++ b/cmd/web-api/handlers/routes.go @@ -5,21 +5,14 @@ import ( "net/http" "os" - "geeks-accelerator/oss/saas-starter-kit/internal/account" - "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/mid" saasSwagger "geeks-accelerator/oss/saas-starter-kit/internal/mid/saas-swagger" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" _ "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - "geeks-accelerator/oss/saas-starter-kit/internal/project" - "geeks-accelerator/oss/saas-starter-kit/internal/signup" _ "geeks-accelerator/oss/saas-starter-kit/internal/signup" - "geeks-accelerator/oss/saas-starter-kit/internal/user" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" - "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) @@ -29,14 +22,14 @@ type AppContext struct { Env webcontext.Env MasterDB *sqlx.DB Redis *redis.Client - UserRepo *user.Repository - UserAccountRepo *user_account.Repository - AccountRepo *account.Repository - AccountPrefRepo *account_preference.Repository - AuthRepo *user_auth.Repository - SignupRepo *signup.Repository - InviteRepo *invite.Repository - ProjectRepo *project.Repository + UserRepo UserRepository + UserAccountRepo UserAccountRepository + AccountRepo AccountRepository + AccountPrefRepo AccountPrefRepository + AuthRepo UserAuthRepository + SignupRepo SignupRepository + InviteRepo UserInviteRepository + ProjectRepo ProjectRepository Authenticator *auth.Authenticator PreAppMiddleware []web.Middleware PostAppMiddleware []web.Middleware @@ -79,9 +72,9 @@ func API(shutdown chan os.Signal, appCtx *AppContext) http.Handler { app.Handle("GET", "/v1/examples/error-response", ex.ErrorResponse) // Register user management and authentication endpoints. - u := User{ - Repository: appCtx.UserRepo, - Auth: appCtx.AuthRepo, + u := Users{ + UserRepo: appCtx.UserRepo, + AuthRepo: appCtx.AuthRepo, } app.Handle("GET", "/v1/users", u.Find, mid.AuthenticateHeader(appCtx.Authenticator)) app.Handle("POST", "/v1/users", u.Create, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) @@ -107,7 +100,7 @@ func API(shutdown chan os.Signal, appCtx *AppContext) http.Handler { app.Handle("DELETE", "/v1/user_accounts", ua.Delete, mid.AuthenticateHeader(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) // Register account endpoints. - a := Account{ + a := Accounts{ Repository: appCtx.AccountRepo, } app.Handle("GET", "/v1/accounts/:id", a.Read, mid.AuthenticateHeader(appCtx.Authenticator)) @@ -120,7 +113,7 @@ func API(shutdown chan os.Signal, appCtx *AppContext) http.Handler { app.Handle("POST", "/v1/signup", s.Signup) // Register project. - p := Project{ + p := Projects{ Repository: appCtx.ProjectRepo, } app.Handle("GET", "/v1/projects", p.Find, mid.AuthenticateHeader(appCtx.Authenticator)) diff --git a/cmd/web-api/handlers/signup.go b/cmd/web-api/handlers/signup.go index e2472a3..a29afe2 100644 --- a/cmd/web-api/handlers/signup.go +++ b/cmd/web-api/handlers/signup.go @@ -3,6 +3,7 @@ package handlers import ( "context" "net/http" + "time" "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" @@ -10,17 +11,22 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/signup" + "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // Signup represents the Signup API method handler set. type Signup struct { - *signup.Repository + Repository SignupRepository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } +type SignupRepository interface { + Signup(ctx context.Context, claims auth.Claims, req signup.SignupRequest, now time.Time) (*signup.SignupResult, error) +} + // Signup godoc // @Summary Signup handles new account creation. // @Description Signup creates a new account and user in the system. diff --git a/cmd/web-api/handlers/user.go b/cmd/web-api/handlers/user.go index ddbd934..93551fe 100644 --- a/cmd/web-api/handlers/user.go +++ b/cmd/web-api/handlers/user.go @@ -13,6 +13,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/gorilla/schema" "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" @@ -22,13 +23,36 @@ import ( var sessionTtl = time.Hour * 24 // User represents the User API method handler set. -type User struct { - *user.Repository - Auth *user_auth.Repository - +type Users struct { + AuthRepo UserAuthRepository + UserRepo UserRepository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } +type UserAuthRepository interface { + SwitchAccount(ctx context.Context, claims auth.Claims, req user_auth.SwitchAccountRequest, expires time.Duration, + now time.Time, scopes ...string) (user_auth.Token, error) + Authenticate(ctx context.Context, req user_auth.AuthenticateRequest, expires time.Duration, now time.Time, scopes ...string) (user_auth.Token, error) + VirtualLogin(ctx context.Context, claims auth.Claims, req user_auth.VirtualLoginRequest, + expires time.Duration, now time.Time, scopes ...string) (user_auth.Token, error) + VirtualLogout(ctx context.Context, claims auth.Claims, expires time.Duration, now time.Time, scopes ...string) (user_auth.Token, error) +} + +type UserRepository interface { + Find(ctx context.Context, claims auth.Claims, req user.UserFindRequest) (user.Users, error) + //FindByAccount(ctx context.Context, claims auth.Claims, req user.UserFindByAccountRequest) (user.Users, error) + Read(ctx context.Context, claims auth.Claims, req user.UserReadRequest) (*user.User, error) + ReadByID(ctx context.Context, claims auth.Claims, id string) (*user.User, error) + Create(ctx context.Context, claims auth.Claims, req user.UserCreateRequest, now time.Time) (*user.User, error) + Update(ctx context.Context, claims auth.Claims, req user.UserUpdateRequest, now time.Time) error + UpdatePassword(ctx context.Context, claims auth.Claims, req user.UserUpdatePasswordRequest, now time.Time) error + Archive(ctx context.Context, claims auth.Claims, req user.UserArchiveRequest, now time.Time) error + Restore(ctx context.Context, claims auth.Claims, req user.UserRestoreRequest, now time.Time) error + Delete(ctx context.Context, claims auth.Claims, req user.UserDeleteRequest) error + ResetPassword(ctx context.Context, req user.UserResetPasswordRequest, now time.Time) (string, error) + ResetConfirm(ctx context.Context, req user.UserResetConfirmRequest, now time.Time) (*user.User, error) +} + // Find godoc // TODO: Need to implement unittests on users/find endpoint. There are none. // @Summary List users @@ -46,7 +70,7 @@ type User struct { // @Failure 400 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [get] -func (h *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -113,7 +137,7 @@ func (h *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, // return web.RespondJsonError(ctx, w, err) //} - res, err := h.Repository.Find(ctx, claims, req) + res, err := h.UserRepo.Find(ctx, claims, req) if err != nil { return err } @@ -139,7 +163,7 @@ func (h *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, // @Failure 404 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/{id} [get] -func (h *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, ok := ctx.Value(auth.Key).(auth.Claims) if !ok { return errors.New("claims missing from context") @@ -156,7 +180,7 @@ func (h *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, includeArchived = b } - res, err := h.Repository.Read(ctx, claims, user.UserReadRequest{ + res, err := h.UserRepo.Read(ctx, claims, user.UserReadRequest{ ID: params["id"], IncludeArchived: includeArchived, }) @@ -186,7 +210,7 @@ func (h *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [post] -func (h *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -205,7 +229,7 @@ func (h *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques return web.RespondJsonError(ctx, w, err) } - res, err := h.Repository.Create(ctx, claims, req, v.Now) + usr, err := h.UserRepo.Create(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -221,7 +245,7 @@ func (h *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques } } - return web.RespondJson(ctx, w, res.Response(ctx), http.StatusCreated) + return web.RespondJson(ctx, w, usr.Response(ctx), http.StatusCreated) } // Read godoc @@ -237,7 +261,7 @@ func (h *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users [patch] -func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -256,7 +280,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques return web.RespondJsonError(ctx, w, err) } - err = h.Repository.Update(ctx, claims, req, v.Now) + err = h.UserRepo.Update(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -288,7 +312,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/password [patch] -func (h *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -307,7 +331,7 @@ func (h *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt return web.RespondJsonError(ctx, w, err) } - err = h.Repository.UpdatePassword(ctx, claims, req, v.Now) + err = h.UserRepo.UpdatePassword(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -341,7 +365,7 @@ func (h *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/archive [patch] -func (h *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -360,7 +384,7 @@ func (h *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque return web.RespondJsonError(ctx, w, err) } - err = h.Repository.Archive(ctx, claims, req, v.Now) + err = h.UserRepo.Archive(ctx, claims, req, v.Now) if err != nil { cause := errors.Cause(err) switch cause { @@ -392,13 +416,13 @@ func (h *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque // @Failure 403 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/{id} [delete] -func (h *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { claims, err := auth.ClaimsFromContext(ctx) if err != nil { return err } - err = h.Repository.Delete(ctx, claims, + err = h.UserRepo.Delete(ctx, claims, user.UserDeleteRequest{ID: params["id"]}) if err != nil { cause := errors.Cause(err) @@ -431,7 +455,7 @@ func (h *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Reques // @Failure 401 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /users/switch-account/{account_id} [patch] -func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -442,7 +466,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http return err } - tkn, err := h.Auth.SwitchAccount(ctx, claims, user_auth.SwitchAccountRequest{ + tkn, err := h.AuthRepo.SwitchAccount(ctx, claims, user_auth.SwitchAccountRequest{ AccountID: params["account_id"], }, sessionTtl, v.Now) if err != nil { @@ -478,7 +502,7 @@ func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http // @Failure 401 {object} weberror.ErrorResponse // @Failure 500 {object} weberror.ErrorResponse // @Router /oauth/token [post] -func (h *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *Users) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { v, err := webcontext.ContextValues(ctx) if err != nil { return err @@ -533,7 +557,7 @@ func (h *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request scopes = strings.Split(qv, ",") } - tkn, err := h.Auth.Authenticate(ctx, authReq, sessionTtl, v.Now, scopes...) + tkn, err := h.AuthRepo.Authenticate(ctx, authReq, sessionTtl, v.Now, scopes...) if err != nil { cause := errors.Cause(err) switch cause { diff --git a/cmd/web-api/handlers/user_account.go b/cmd/web-api/handlers/user_account.go index aec3075..57c0890 100644 --- a/cmd/web-api/handlers/user_account.go +++ b/cmd/web-api/handlers/user_account.go @@ -5,23 +5,44 @@ import ( "net/http" "strconv" "strings" + "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + "github.com/pkg/errors" "gopkg.in/go-playground/validator.v9" ) // UserAccount represents the UserAccount API method handler set. type UserAccount struct { - *user_account.Repository - + UserInvite UserInviteRepository + Repository UserAccountRepository // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. } +type UserAccountRepository interface { + Find(ctx context.Context, claims auth.Claims, req user_account.UserAccountFindRequest) (user_account.UserAccounts, error) + FindByUserID(ctx context.Context, claims auth.Claims, userID string, includedArchived bool) (user_account.UserAccounts, error) + UserFindByAccount(ctx context.Context, claims auth.Claims, req user_account.UserFindByAccountRequest) (user_account.Users, error) + Create(ctx context.Context, claims auth.Claims, req user_account.UserAccountCreateRequest, now time.Time) (*user_account.UserAccount, error) + Read(ctx context.Context, claims auth.Claims, req user_account.UserAccountReadRequest) (*user_account.UserAccount, error) + Update(ctx context.Context, claims auth.Claims, req user_account.UserAccountUpdateRequest, now time.Time) error + Archive(ctx context.Context, claims auth.Claims, req user_account.UserAccountArchiveRequest, now time.Time) error + Delete(ctx context.Context, claims auth.Claims, req user_account.UserAccountDeleteRequest) error +} + +type UserInviteRepository interface { + SendUserInvites(ctx context.Context, claims auth.Claims, req invite.SendUserInvitesRequest, now time.Time) ([]string, error) + AcceptInvite(ctx context.Context, req invite.AcceptInviteRequest, now time.Time) (*user_account.UserAccount, error) + AcceptInviteUser(ctx context.Context, req invite.AcceptInviteUserRequest, now time.Time) (*user_account.UserAccount, error) +} + // Find godoc // TODO: Need to implement unittests on user_accounts/find endpoint. There are none. // @Summary List user accounts diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index 9a48ec4..ede15da 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -35,6 +35,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/ec2metadata" @@ -435,7 +436,7 @@ func main() { projectRoute, err := project_route.New(cfg.Service.BaseUrl, cfg.Project.WebAppBaseUrl) if err != nil { - log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) + log.Fatalf("main : project routes : %s: %+v", cfg.Service.BaseUrl, err) } usrRepo := user.NewRepository(masterDb, projectRoute.UserResetPassword, notifyEmail, cfg.Project.SharedSecretKey) diff --git a/cmd/web-api/tests/account_test.go b/cmd/web-api/tests/account_test.go index 918aa6c..f7fecfd 100644 --- a/cmd/web-api/tests/account_test.go +++ b/cmd/web-api/tests/account_test.go @@ -13,6 +13,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "github.com/pborman/uuid" ) diff --git a/cmd/web-app/handlers/account.go b/cmd/web-app/handlers/account.go index 3ba3e0f..0656fb0 100644 --- a/cmd/web-app/handlers/account.go +++ b/cmd/web-app/handlers/account.go @@ -2,9 +2,7 @@ package handlers import ( "context" - "net/http" - "time" - + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/geonames" @@ -12,19 +10,21 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + + "net/http" + "time" + "github.com/gorilla/schema" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) // Account represents the Account API method handler set. type Account struct { - AccountRepo *account.Repository - AccountPrefRepo *account_preference.Repository - AuthRepo *user_auth.Repository + AccountRepo handlers.AccountRepository + AccountPrefRepo handlers.AccountPrefRepository + AuthRepo handlers.UserAuthRepository + GeoRepo GeoRepository Authenticator *auth.Authenticator - MasterDB *sqlx.DB Renderer web.Renderer } @@ -248,14 +248,14 @@ func (h *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req data["account"] = acc.Response(ctx) - data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + data["timezones"], err = h.GeoRepo.ListTimezones(ctx) if err != nil { return false, err } data["geonameCountries"] = geonames.ValidGeonameCountries(ctx) - data["countries"], err = geonames.FindCountries(ctx, h.MasterDB, "name", "") + data["countries"], err = h.GeoRepo.FindCountries(ctx, "name", "") if err != nil { return false, err } diff --git a/cmd/web-app/handlers/api_geo.go b/cmd/web-app/handlers/api_geo.go index 3e4c9af..dfc3ad6 100644 --- a/cmd/web-app/handlers/api_geo.go +++ b/cmd/web-app/handlers/api_geo.go @@ -8,14 +8,25 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/geonames" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" - "github.com/jmoiron/sqlx" + + //"github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) // Check provides support for orchestration geo endpoints. type Geo struct { - MasterDB *sqlx.DB - Redis *redis.Client + Redis *redis.Client + GeoRepo GeoRepository +} + +type GeoRepository interface { + FindGeonames(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Geoname, error) + FindGeonamePostalCodes(ctx context.Context, where string, args ...interface{}) ([]string, error) + FindGeonameRegions(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Region, error) + FindCountries(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.Country, error) + FindCountryTimezones(ctx context.Context, orderBy, where string, args ...interface{}) ([]*geonames.CountryTimezone, error) + ListTimezones(ctx context.Context) ([]string, error) + LoadGeonames(ctx context.Context, rr chan<- interface{}, countries ...string) } // GeonameByPostalCode... @@ -39,7 +50,7 @@ func (h *Geo) GeonameByPostalCode(ctx context.Context, w http.ResponseWriter, r where := strings.Join(filters, " AND ") - res, err := geonames.FindGeonames(ctx, h.MasterDB, "postal_code", where, args...) + res, err := h.GeoRepo.FindGeonames(ctx, "postal_code", where, args...) if err != nil { fmt.Printf("%+v", err) return web.RespondJsonError(ctx, w, err) @@ -74,7 +85,7 @@ func (h *Geo) PostalCodesAutocomplete(ctx context.Context, w http.ResponseWriter where := strings.Join(filters, " AND ") - res, err := geonames.FindGeonamePostalCodes(ctx, h.MasterDB, where, args...) + res, err := h.GeoRepo.FindGeonamePostalCodes(ctx, where, args...) if err != nil { return web.RespondJsonError(ctx, w, err) } @@ -101,7 +112,7 @@ func (h *Geo) RegionsAutocomplete(ctx context.Context, w http.ResponseWriter, r where := strings.Join(filters, " AND ") - res, err := geonames.FindGeonameRegions(ctx, h.MasterDB, "state_name", where, args...) + res, err := h.GeoRepo.FindGeonameRegions(ctx, "state_name", where, args...) if err != nil { fmt.Printf("%+v", err) return web.RespondJsonError(ctx, w, err) @@ -144,7 +155,7 @@ func (h *Geo) CountryTimezones(ctx context.Context, w http.ResponseWriter, r *ht where := strings.Join(filters, " AND ") - res, err := geonames.FindCountryTimezones(ctx, h.MasterDB, "timezone_id", where, args...) + res, err := h.GeoRepo.FindCountryTimezones(ctx, "timezone_id", where, args...) if err != nil { return web.RespondJsonError(ctx, w, err) } diff --git a/cmd/web-app/handlers/projects.go b/cmd/web-app/handlers/projects.go index 8a375fe..9c54fa1 100644 --- a/cmd/web-app/handlers/projects.go +++ b/cmd/web-app/handlers/projects.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" "net/http" "strings" @@ -12,6 +13,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project" + "github.com/gorilla/schema" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" @@ -19,7 +21,7 @@ import ( // Projects represents the Projects API method handler set. type Projects struct { - ProjectRepo *project.Repository + ProjectRepo handlers.ProjectRepository Redis *redis.Client Renderer web.Renderer } diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index 01bd4b2..0aee9d7 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -9,20 +9,23 @@ import ( "path/filepath" "time" - "geeks-accelerator/oss/saas-starter-kit/internal/account" - "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" + //"geeks-accelerator/oss/saas-starter-kit/internal/account" + //"geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" "geeks-accelerator/oss/saas-starter-kit/internal/mid" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" - "geeks-accelerator/oss/saas-starter-kit/internal/project" + + //"geeks-accelerator/oss/saas-starter-kit/internal/project" "geeks-accelerator/oss/saas-starter-kit/internal/project_route" - "geeks-accelerator/oss/saas-starter-kit/internal/signup" - "geeks-accelerator/oss/saas-starter-kit/internal/user" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account" - "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" - "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + // "geeks-accelerator/oss/saas-starter-kit/internal/signup" + // "geeks-accelerator/oss/saas-starter-kit/internal/user" + // "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + // "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" + // "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/ikeikeikeike/go-sitemap-generator/v2/stm" "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" @@ -39,14 +42,15 @@ type AppContext struct { Env webcontext.Env MasterDB *sqlx.DB Redis *redis.Client - UserRepo *user.Repository - UserAccountRepo *user_account.Repository - AccountRepo *account.Repository - AccountPrefRepo *account_preference.Repository - AuthRepo *user_auth.Repository - SignupRepo *signup.Repository - InviteRepo *invite.Repository - ProjectRepo *project.Repository + UserRepo handlers.UserRepository + UserAccountRepo handlers.UserAccountRepository + AccountRepo handlers.AccountRepository + AccountPrefRepo handlers.AccountPrefRepository + AuthRepo handlers.UserAuthRepository + SignupRepo handlers.SignupRepository + InviteRepo handlers.UserInviteRepository + ProjectRepo handlers.ProjectRepository + GeoRepo GeoRepository Authenticator *auth.Authenticator StaticDir string TemplateDir string @@ -117,7 +121,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { UserAccountRepo: appCtx.UserAccountRepo, AuthRepo: appCtx.AuthRepo, InviteRepo: appCtx.InviteRepo, - MasterDB: appCtx.MasterDB, + GeoRepo: appCtx.GeoRepo, Redis: appCtx.Redis, Renderer: appCtx.Renderer, } @@ -134,12 +138,12 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasAuth()) // Register user management and authentication endpoints. - u := User{ + u := UserRepos{ UserRepo: appCtx.UserRepo, UserAccountRepo: appCtx.UserAccountRepo, AccountRepo: appCtx.AccountRepo, AuthRepo: appCtx.AuthRepo, - MasterDB: appCtx.MasterDB, + GeoRepo: appCtx.GeoRepo, Renderer: appCtx.Renderer, } app.Handle("POST", "/user/login", u.Login) @@ -168,7 +172,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { AccountPrefRepo: appCtx.AccountPrefRepo, AuthRepo: appCtx.AuthRepo, Authenticator: appCtx.Authenticator, - MasterDB: appCtx.MasterDB, + GeoRepo: appCtx.GeoRepo, Renderer: appCtx.Renderer, } app.Handle("POST", "/account/update", acc.Update, mid.AuthenticateSessionRequired(appCtx.Authenticator), mid.HasRole(auth.RoleAdmin)) @@ -180,7 +184,7 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { s := Signup{ SignupRepo: appCtx.SignupRepo, AuthRepo: appCtx.AuthRepo, - MasterDB: appCtx.MasterDB, + GeoRepo: appCtx.GeoRepo, Renderer: appCtx.Renderer, } // This route is not authenticated @@ -197,8 +201,8 @@ func APP(shutdown chan os.Signal, appCtx *AppContext) http.Handler { // Register geo g := Geo{ - MasterDB: appCtx.MasterDB, - Redis: appCtx.Redis, + GeoRepo: appCtx.GeoRepo, + Redis: appCtx.Redis, } app.Handle("GET", "/geo/regions/autocomplete", g.RegionsAutocomplete) app.Handle("GET", "/geo/postal_codes/autocomplete", g.PostalCodesAutocomplete) diff --git a/cmd/web-app/handlers/signup.go b/cmd/web-app/handlers/signup.go index cb23c48..964d90c 100644 --- a/cmd/web-app/handlers/signup.go +++ b/cmd/web-app/handlers/signup.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" "net/http" "time" @@ -13,6 +14,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/signup" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/gorilla/schema" "github.com/jmoiron/sqlx" "github.com/pkg/errors" @@ -20,8 +22,9 @@ import ( // Signup represents the Signup API method handler set. type Signup struct { - SignupRepo *signup.Repository - AuthRepo *user_auth.Repository + SignupRepo handlers.SignupRepository + AuthRepo handlers.UserAuthRepository + GeoRepo GeoRepository MasterDB *sqlx.DB Renderer web.Renderer } @@ -108,7 +111,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque data["geonameCountries"] = geonames.ValidGeonameCountries(ctx) - data["countries"], err = geonames.FindCountries(ctx, h.MasterDB, "name", "") + data["countries"], err = h.GeoRepo.FindCountries(ctx, "name", "") if err != nil { return err } diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index b47b4c9..57b92f4 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -8,8 +8,9 @@ import ( "strings" "time" + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" "geeks-accelerator/oss/saas-starter-kit/internal/account" - "geeks-accelerator/oss/saas-starter-kit/internal/geonames" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" @@ -17,6 +18,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/gorilla/schema" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" @@ -24,13 +26,15 @@ import ( ) // User represents the User API method handler set. -type User struct { - UserRepo *user.Repository - AuthRepo *user_auth.Repository - UserAccountRepo *user_account.Repository - AccountRepo *account.Repository +type UserRepos struct { + UserRepo handlers.UserRepository + AuthRepo handlers.UserAuthRepository + UserAccountRepo handlers.UserAccountRepository + AccountRepo handlers.AccountRepository + GeoRepo GeoRepository MasterDB *sqlx.DB Renderer web.Renderer + SecretKey string } func urlUserVirtualLogin(userID string) string { @@ -44,7 +48,7 @@ type UserLoginRequest struct { } // Login handles authenticating a user into the system. -func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h UserRepos) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { @@ -132,7 +136,7 @@ func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request } // Logout handles removing authentication for the user. -func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { sess := webcontext.ContextSession(ctx) @@ -148,7 +152,7 @@ func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Reques } // ResetPassword allows a user to perform forgot password. -func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { @@ -208,7 +212,7 @@ func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http } // ResetConfirm handles changing a users password after they have clicked on the link emailed. -func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { resetHash := params["hash"] @@ -278,7 +282,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. return true, web.Redirect(ctx, w, r, "/", http.StatusFound) } - _, err = h.UserRepo.ParseResetHash(ctx, resetHash, ctxValues.Now) + _, err = user.ParseResetHash(ctx, h.SecretKey, resetHash, ctxValues.Now) if err != nil { switch errors.Cause(err) { case user.ErrResetExpired: @@ -316,7 +320,7 @@ func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http. } // View handles displaying the current user profile. -func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) View(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { data := make(map[string]interface{}) f := func() error { @@ -356,7 +360,7 @@ func (h *User) View(ctx context.Context, w http.ResponseWriter, r *http.Request, } // Update handles allowing the current user to update their profile. -func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { @@ -453,7 +457,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques data["user"] = usr.Response(ctx) - data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + data["timezones"], err = h.GeoRepo.ListTimezones(ctx) if err != nil { return err } @@ -472,7 +476,7 @@ func (h *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques } // Account handles displaying the Account for the current user. -func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) Account(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { data := make(map[string]interface{}) f := func() error { @@ -499,7 +503,7 @@ func (h *User) Account(ctx context.Context, w http.ResponseWriter, r *http.Reque } // VirtualLogin handles switching the scope of the context to another user. -func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { @@ -634,7 +638,7 @@ func (h *User) VirtualLogin(ctx context.Context, w http.ResponseWriter, r *http. } // VirtualLogout handles switching the scope back to the user who initiated the virtual login. -func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { @@ -708,7 +712,7 @@ func (h *User) VirtualLogout(ctx context.Context, w http.ResponseWriter, r *http } // VirtualLogin handles switching the scope of the context to another user. -func (h *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *UserRepos) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { ctxValues, err := webcontext.ContextValues(ctx) if err != nil { diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index 86e5f8b..8b322e7 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -3,11 +3,11 @@ package handlers import ( "context" "fmt" + "geeks-accelerator/oss/saas-starter-kit/cmd/web-api/handlers" "net/http" "strings" "time" - "geeks-accelerator/oss/saas-starter-kit/internal/geonames" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/datatable" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" @@ -17,6 +17,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/user_account" "geeks-accelerator/oss/saas-starter-kit/internal/user_account/invite" "geeks-accelerator/oss/saas-starter-kit/internal/user_auth" + "github.com/dustin/go-humanize/english" "github.com/gorilla/schema" "github.com/jmoiron/sqlx" @@ -26,10 +27,12 @@ import ( // Users represents the Users API method handler set. type Users struct { - UserRepo *user.Repository - UserAccountRepo *user_account.Repository - AuthRepo *user_auth.Repository - InviteRepo *invite.Repository + UserRepo handlers.UserRepository + AccountRepo handlers.AccountRepository + UserAccountRepo handlers.UserAccountRepository + AuthRepo handlers.UserAuthRepository + InviteRepo handlers.UserInviteRepository + GeoRepo GeoRepository MasterDB *sqlx.DB Redis *redis.Client Renderer web.Renderer @@ -281,7 +284,7 @@ func (h *Users) Create(ctx context.Context, w http.ResponseWriter, r *http.Reque return nil } - data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + data["timezones"], err = h.GeoRepo.ListTimezones(ctx) if err != nil { return err } @@ -519,7 +522,7 @@ func (h *Users) Update(ctx context.Context, w http.ResponseWriter, r *http.Reque data["user"] = usr.Response(ctx) - data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + data["timezones"], err = h.GeoRepo.ListTimezones(ctx) if err != nil { return err } @@ -798,7 +801,7 @@ func (h *Users) InviteAccept(ctx context.Context, w http.ResponseWriter, r *http return nil } - data["timezones"], err = geonames.ListTimezones(ctx, h.MasterDB) + data["timezones"], err = h.GeoRepo.ListTimezones(ctx) if err != nil { return err } diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index 659d576..8fe14f4 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -7,6 +7,7 @@ import ( "expvar" "fmt" "geeks-accelerator/oss/saas-starter-kit/internal/account/account_preference" + "geeks-accelerator/oss/saas-starter-kit/internal/geonames" "geeks-accelerator/oss/saas-starter-kit/internal/project" "geeks-accelerator/oss/saas-starter-kit/internal/signup" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" @@ -40,6 +41,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "geeks-accelerator/oss/saas-starter-kit/internal/project_route" "geeks-accelerator/oss/saas-starter-kit/internal/user" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/ec2metadata" @@ -443,6 +445,7 @@ func main() { usrRepo := user.NewRepository(masterDb, projectRoute.UserResetPassword, notifyEmail, cfg.Project.SharedSecretKey) usrAccRepo := user_account.NewRepository(masterDb) accRepo := account.NewRepository(masterDb) + geoRepo := geonames.NewRepository(masterDb) accPrefRepo := account_preference.NewRepository(masterDb) authRepo := user_auth.NewRepository(masterDb, authenticator, usrRepo, usrAccRepo, accPrefRepo) signupRepo := signup.NewRepository(masterDb, usrRepo, usrAccRepo, accRepo) @@ -450,9 +453,9 @@ func main() { prjRepo := project.NewRepository(masterDb) appCtx := &handlers.AppContext{ - Log: log, - Env: cfg.Env, - MasterDB: masterDb, + Log: log, + Env: cfg.Env, + //MasterDB: masterDb, Redis: redisClient, TemplateDir: cfg.Service.TemplateDir, StaticDir: cfg.Service.StaticFiles.Dir, @@ -462,6 +465,7 @@ func main() { AccountRepo: accRepo, AccountPrefRepo: accPrefRepo, AuthRepo: authRepo, + GeoRepo: geoRepo, SignupRepo: signupRepo, InviteRepo: inviteRepo, ProjectRepo: prjRepo, diff --git a/docker-compose.yaml b/docker-compose.yaml index 7c538bb..128c059 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,9 +26,9 @@ services: redis: image: redis:latest expose: - - "6379" + - "6378" ports: - - "6379:6379" + - "6378:6379" networks: main: aliases: diff --git a/internal/account/account_preference/account_preference.go b/internal/account/account_preference/account_preference.go index 3dd0e41..6fb3bad 100644 --- a/internal/account/account_preference/account_preference.go +++ b/internal/account/account_preference/account_preference.go @@ -7,6 +7,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pborman/uuid" diff --git a/internal/geonames/countries.go b/internal/geonames/countries.go index 4eaaf65..342ef5e 100644 --- a/internal/geonames/countries.go +++ b/internal/geonames/countries.go @@ -2,8 +2,8 @@ package geonames import ( "context" + "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -14,7 +14,7 @@ const ( ) // FindCountries .... -func FindCountries(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, args ...interface{}) ([]*Country, error) { +func (repo *Repository) FindCountries(ctx context.Context, orderBy, where string, args ...interface{}) ([]*Country, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.geonames.FindCountries") defer span.Finish() @@ -32,11 +32,11 @@ func FindCountries(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find countries failed") diff --git a/internal/geonames/country_timezones.go b/internal/geonames/country_timezones.go index d16d92d..1e4546a 100644 --- a/internal/geonames/country_timezones.go +++ b/internal/geonames/country_timezones.go @@ -2,8 +2,8 @@ package geonames import ( "context" + "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -14,7 +14,7 @@ const ( ) // FindCountryTimezones .... -func FindCountryTimezones(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, args ...interface{}) ([]*CountryTimezone, error) { +func (repo *Repository) FindCountryTimezones(ctx context.Context, orderBy, where string, args ...interface{}) ([]*CountryTimezone, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.geonames.FindCountryTimezones") defer span.Finish() @@ -32,11 +32,11 @@ func FindCountryTimezones(ctx context.Context, dbConn *sqlx.DB, orderBy, where s } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // Fetch all country timezones from the db. - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find country timezones failed") @@ -64,8 +64,8 @@ func FindCountryTimezones(ctx context.Context, dbConn *sqlx.DB, orderBy, where s return resp, nil } -func ListTimezones(ctx context.Context, dbConn *sqlx.DB) ([]string, error) { - res, err := FindCountryTimezones(ctx, dbConn, "timezone_id", "") +func (repo *Repository) ListTimezones(ctx context.Context) ([]string, error) { + res, err := repo.FindCountryTimezones(ctx, "timezone_id", "") if err != nil { return nil, err } diff --git a/internal/geonames/geonames.go b/internal/geonames/geonames.go index 47a4e48..9fba47c 100644 --- a/internal/geonames/geonames.go +++ b/internal/geonames/geonames.go @@ -12,8 +12,9 @@ import ( "strings" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" + // "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sethgrid/pester" "github.com/shopspring/decimal" @@ -43,7 +44,7 @@ func ValidGeonameCountries(ctx context.Context) []string { } // FindGeonames .... -func FindGeonames(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, args ...interface{}) ([]*Geoname, error) { +func (repo *Repository) FindGeonames(ctx context.Context, orderBy, where string, args ...interface{}) ([]*Geoname, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.geonames.FindGeonames") defer span.Finish() @@ -61,11 +62,11 @@ func FindGeonames(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, a } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find regions failed") @@ -93,7 +94,7 @@ func FindGeonames(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, a } // FindGeonamePostalCodes .... -func FindGeonamePostalCodes(ctx context.Context, dbConn *sqlx.DB, where string, args ...interface{}) ([]string, error) { +func (repo *Repository) FindGeonamePostalCodes(ctx context.Context, where string, args ...interface{}) ([]string, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.geonames.FindGeonamePostalCodes") defer span.Finish() @@ -106,11 +107,11 @@ func FindGeonamePostalCodes(ctx context.Context, dbConn *sqlx.DB, where string, } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find regions failed") @@ -138,7 +139,7 @@ func FindGeonamePostalCodes(ctx context.Context, dbConn *sqlx.DB, where string, } // FindGeonameRegions .... -func FindGeonameRegions(ctx context.Context, dbConn *sqlx.DB, orderBy, where string, args ...interface{}) ([]*Region, error) { +func (repo *Repository) FindGeonameRegions(ctx context.Context, orderBy, where string, args ...interface{}) ([]*Region, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.geonames.FindGeonameRegions") defer span.Finish() @@ -156,11 +157,11 @@ func FindGeonameRegions(ctx context.Context, dbConn *sqlx.DB, orderBy, where str } queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) + queryStr = repo.DbConn.Rebind(queryStr) args = append(args, queryArgs...) // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) + rows, err := repo.DbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) err = errors.WithMessage(err, "find regions failed") @@ -194,7 +195,7 @@ func FindGeonameRegions(ctx context.Context, dbConn *sqlx.DB, orderBy, where str // Possible types sent to the channel are limited to: // - error // - GeoName -func LoadGeonames(ctx context.Context, rr chan<- interface{}, countries ...string) { +func (repo *Repository) LoadGeonames(ctx context.Context, rr chan<- interface{}, countries ...string) { defer close(rr) if len(countries) == 0 { diff --git a/internal/geonames/models.go b/internal/geonames/models.go index a70274d..68204e8 100644 --- a/internal/geonames/models.go +++ b/internal/geonames/models.go @@ -1,6 +1,18 @@ package geonames import "github.com/shopspring/decimal" +import "github.com/jmoiron/sqlx" + +type Repository struct { + DbConn *sqlx.DB +} + +// NewRepository creates a new Repository that defines dependencies for Project. +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + DbConn: db, + } +} type Geoname struct { CountryCode string // US diff --git a/internal/schema/migrations.go b/internal/schema/migrations.go index fe6a3c9..14485c6 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -9,6 +9,7 @@ import ( "strings" "geeks-accelerator/oss/saas-starter-kit/internal/geonames" + "github.com/geeks-accelerator/sqlxmigrate" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -19,6 +20,7 @@ import ( // migrationList returns a list of migrations to be executed. If the id of the // migration already exists in the migrations table it will be skipped. func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest bool) []*sqlxmigrate.Migration { + geoRepo := geonames.NewRepository(db) return []*sqlxmigrate.Migration{ // Create table users. { @@ -253,7 +255,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } else { resChan := make(chan interface{}) - go geonames.LoadGeonames(ctx, resChan) + go geoRepo.LoadGeonames(ctx, resChan) for r := range resChan { switch v := r.(type) { diff --git a/internal/user_account/invite/invite.go b/internal/user_account/invite/invite.go index 0ee8262..274b441 100644 --- a/internal/user_account/invite/invite.go +++ b/internal/user_account/invite/invite.go @@ -6,11 +6,12 @@ import ( "strings" "time" - "geeks-accelerator/oss/saas-starter-kit/internal/account" + //"geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -40,7 +41,7 @@ func (repo *Repository) SendUserInvites(ctx context.Context, claims auth.Claims, } // Ensure the claims can modify the account specified in the request. - err = account.CanModifyAccount(ctx, claims, repo.DbConn, req.AccountID) + err = repo.Account.CanModifyAccount(ctx, claims, req.AccountID) if err != nil { return nil, err } diff --git a/internal/user_auth/auth.go b/internal/user_auth/auth.go index 4e90b10..dcb9dce 100644 --- a/internal/user_auth/auth.go +++ b/internal/user_auth/auth.go @@ -11,6 +11,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/user" "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/huandu/go-sqlbuilder" "github.com/lib/pq" "github.com/pkg/errors" @@ -100,7 +101,8 @@ func (repo *Repository) SwitchAccount(ctx context.Context, claims auth.Claims, r } // VirtualLogin allows users to mock being logged in as other users. -func (repo *Repository) VirtualLogin(ctx context.Context, claims auth.Claims, req VirtualLoginRequest, expires time.Duration, now time.Time, scopes ...string) (Token, error) { +func (repo *Repository) VirtualLogin(ctx context.Context, claims auth.Claims, req VirtualLoginRequest, + expires time.Duration, now time.Time, scopes ...string) (Token, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_auth.VirtualLogin") defer span.Finish() From c7106f089fae9638d91815244e2ac6299c91e757 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Fri, 16 Aug 2019 20:40:48 -0800 Subject: [PATCH 12/21] Copper Valley was here --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fc58cd3..bfd2bdb 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Copyright 2019, Geeks Accelerator twins@geeksaccelerator.com +Sponsored by Copper Valley Telecom The SaaS Starter Kit is a set of libraries for building scalable software-as-a-service (SaaS) applications that helps preventing both misuse and fraud. The goal of this project is to provide a proven starting point for new From 666eafceec47f4bd92897ec3f3bf9c0afbfb8da6 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Sat, 17 Aug 2019 10:58:45 -0800 Subject: [PATCH 13/21] Fix random errors from tests --- cmd/web-api/main.go | 7 +++---- cmd/web-app/handlers/projects.go | 2 +- cmd/web-app/handlers/users.go | 2 +- cmd/web-app/main.go | 9 ++++----- internal/mid/saas-swagger/example/main.go | 3 ++- internal/mid/saas-swagger/swagger_test.go | 3 ++- internal/platform/logger/log.go | 5 +++-- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index e331ead..08b3f51 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -66,10 +66,9 @@ func main() { // ========================================================================= // Logging - log.SetFlags(log.LstdFlags|log.Lmicroseconds|log.Lshortfile) - log.SetPrefix(service+" : ") - log := log.New(os.Stdout, log.Prefix() , log.Flags()) - + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) + log.SetPrefix(service + " : ") + log := log.New(os.Stdout, log.Prefix(), log.Flags()) // ========================================================================= // Configuration diff --git a/cmd/web-app/handlers/projects.go b/cmd/web-app/handlers/projects.go index d3bda68..b10ed69 100644 --- a/cmd/web-app/handlers/projects.go +++ b/cmd/web-app/handlers/projects.go @@ -73,7 +73,7 @@ func (h *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Req var v datatable.ColumnValue switch col.Field { case "id": - v.Value = fmt.Sprintf("%d", q.ID) + v.Value = fmt.Sprintf("%s", q.ID) case "name": v.Value = q.Name v.Formatted = fmt.Sprintf("%s", urlProjectsView(q.ID), v.Value) diff --git a/cmd/web-app/handlers/users.go b/cmd/web-app/handlers/users.go index e0b9526..637e16a 100644 --- a/cmd/web-app/handlers/users.go +++ b/cmd/web-app/handlers/users.go @@ -100,7 +100,7 @@ func (h *Users) Index(ctx context.Context, w http.ResponseWriter, r *http.Reques var v datatable.ColumnValue switch col.Field { case "id": - v.Value = fmt.Sprintf("%d", q.ID) + v.Value = fmt.Sprintf("%s", q.ID) case "name": if strings.TrimSpace(q.Name) == "" { v.Value = q.Email diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index 046b695..9b4e215 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -66,10 +66,9 @@ func main() { // ========================================================================= // Logging - log.SetFlags(log.LstdFlags|log.Lmicroseconds|log.Lshortfile) - log.SetPrefix(service+" : ") - log := log.New(os.Stdout, log.Prefix() , log.Flags()) - + log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Lshortfile) + log.SetPrefix(service + " : ") + log := log.New(os.Stdout, log.Prefix(), log.Flags()) // ========================================================================= // Configuration @@ -474,7 +473,7 @@ func main() { // URL Formatter projectRoutes, err := project_routes.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl) if err != nil { - log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) + log.Fatalf("main : project routes : %s : %+v", cfg.Service.BaseUrl, err) } // s3UrlFormatter is a help function used by to convert an s3 key to diff --git a/internal/mid/saas-swagger/example/main.go b/internal/mid/saas-swagger/example/main.go index 5f9f2ae..70e9625 100644 --- a/internal/mid/saas-swagger/example/main.go +++ b/internal/mid/saas-swagger/example/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "log" "net/http" "os" @@ -135,7 +136,7 @@ func main() { func API(shutdown chan os.Signal, log *log.Logger) http.Handler { // Construct the web.App which holds all routes as well as common Middleware. - app := web.NewApp(shutdown, log, mid.Trace(), mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics()) + app := web.NewApp(shutdown, log, webcontext.Env_Dev, mid.Logger(log)) app.Handle("GET", "/swagger/", saasSwagger.WrapHandler) app.Handle("GET", "/swagger/*", saasSwagger.WrapHandler) diff --git a/internal/mid/saas-swagger/swagger_test.go b/internal/mid/saas-swagger/swagger_test.go index e533de0..ea2e037 100644 --- a/internal/mid/saas-swagger/swagger_test.go +++ b/internal/mid/saas-swagger/swagger_test.go @@ -9,6 +9,7 @@ import ( _ "geeks-accelerator/oss/saas-starter-kit/internal/mid/saas-swagger/example/docs" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/stretchr/testify/assert" ) @@ -17,7 +18,7 @@ func TestWrapHandler(t *testing.T) { log := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) log.SetOutput(ioutil.Discard) - app := web.NewApp(nil, log) + app := web.NewApp(nil, log, webcontext.Env_Dev) app.Handle("GET", "/swagger/*", WrapHandler) w1 := performRequest("GET", "/swagger/index.html", app) diff --git a/internal/platform/logger/log.go b/internal/platform/logger/log.go index 58a7fb5..ba5b8e5 100644 --- a/internal/platform/logger/log.go +++ b/internal/platform/logger/log.go @@ -3,12 +3,13 @@ package logger import ( "context" "fmt" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" ) // WithContext manual injects context values to log message including Trace ID func WithContext(ctx context.Context, msg string) string { - v, ok := ctx.Value(web.KeyValues).(*web.Values) + v, ok := ctx.Value(webcontext.KeyValues).(*webcontext.Values) if !ok { return msg } From 295e46a885eed2328b5a0b8f00d83cdb7d281d5a Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Sat, 17 Aug 2019 11:15:45 -0800 Subject: [PATCH 14/21] Cache geonames download --- internal/geonames/geonames.go | 218 ++++++++-------------------------- internal/schema/migrations.go | 12 +- 2 files changed, 51 insertions(+), 179 deletions(-) diff --git a/internal/geonames/geonames.go b/internal/geonames/geonames.go index ae1c9c8..c7a7bc9 100644 --- a/internal/geonames/geonames.go +++ b/internal/geonames/geonames.go @@ -5,10 +5,13 @@ import ( "bufio" "bytes" "context" + "crypto/md5" "encoding/csv" "fmt" "io" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -191,144 +194,6 @@ func FindGeonameRegions(ctx context.Context, dbConn *sqlx.DB, orderBy, where str return resp, nil } -// LoadGeonames enables streaming retrieval of GeoNames. The downloaded results -// will be written to the interface{} resultReceiver channel enabling processing the results while -// they're still being fetched. After all pages have been processed the channel is closed. -// Possible types sent to the channel are limited to: -// - error -// - GeoName -func LoadGeonames(ctx context.Context, rr chan<- interface{}, countries ...string) { - defer close(rr) - - if len(countries) == 0 { - countries = ValidGeonameCountries(ctx) - } - - for _, country := range countries { - loadGeonameCountry(ctx, rr, country) - } -} - -// loadGeonameCountry enables streaming retrieval of GeoNames. The downloaded results -// will be written to the interface{} resultReceiver channel enabling processing the results while -// they're still being fetched. -// Possible types sent to the channel are limited to: -// - error -// - GeoName -func loadGeonameCountry(ctx context.Context, rr chan<- interface{}, country string) { - u := fmt.Sprintf("http://download.geonames.org/export/zip/%s.zip", country) - resp, err := pester.Get(u) - if err != nil { - rr <- errors.WithMessagef(err, "Failed to read countries from '%s'", u) - return - } - defer resp.Body.Close() - - br := bufio.NewReader(resp.Body) - - buff := bytes.NewBuffer([]byte{}) - size, err := io.Copy(buff, br) - if err != nil { - rr <- errors.WithStack(err) - return - } - - b := bytes.NewReader(buff.Bytes()) - zr, err := zip.NewReader(b, size) - if err != nil { - rr <- errors.WithStack(err) - return - } - - for _, f := range zr.File { - if f.Name == "readme.txt" { - continue - } - - fh, err := f.Open() - if err != nil { - rr <- errors.WithStack(err) - return - } - - scanner := bufio.NewScanner(fh) - for scanner.Scan() { - line := scanner.Text() - - if strings.Contains(line, "\"") { - line = strings.Replace(line, "\"", "\\\"", -1) - } - - r := csv.NewReader(strings.NewReader(line)) - r.Comma = '\t' // Use tab-delimited instead of comma <---- here! - r.LazyQuotes = true - r.FieldsPerRecord = -1 - - lines, err := r.ReadAll() - if err != nil { - rr <- errors.WithStack(err) - continue - } - - for _, row := range lines { - - /* - fmt.Println("CountryCode: row[0]", row[0]) - fmt.Println("PostalCode: row[1]", row[1]) - fmt.Println("PlaceName: row[2]", row[2]) - fmt.Println("StateName: row[3]", row[3]) - fmt.Println("StateCode : row[4]", row[4]) - fmt.Println("CountyName: row[5]", row[5]) - fmt.Println("CountyCode : row[6]", row[6]) - fmt.Println("CommunityName: row[7]", row[7]) - fmt.Println("CommunityCode: row[8]", row[8]) - fmt.Println("Latitude: row[9]", row[9]) - fmt.Println("Longitude: row[10]", row[10]) - fmt.Println("Accuracy: row[11]", row[11]) - */ - - gn := Geoname{ - CountryCode: row[0], - PostalCode: row[1], - PlaceName: row[2], - StateName: row[3], - StateCode: row[4], - CountyName: row[5], - CountyCode: row[6], - CommunityName: row[7], - CommunityCode: row[8], - } - if row[9] != "" { - gn.Latitude, err = decimal.NewFromString(row[9]) - if err != nil { - rr <- errors.WithStack(err) - } - } - - if row[10] != "" { - gn.Longitude, err = decimal.NewFromString(row[10]) - if err != nil { - rr <- errors.WithStack(err) - } - } - - if row[11] != "" { - gn.Accuracy, err = strconv.Atoi(row[11]) - if err != nil { - rr <- errors.WithStack(err) - } - } - - rr <- gn - } - } - - if err := scanner.Err(); err != nil { - rr <- errors.WithStack(err) - } - } -} - // GetGeonameCountry downloads geoname data for the country. // Parses data and returns slice of Geoname func GetGeonameCountry(ctx context.Context, country string) ([]Geoname, error) { @@ -337,25 +202,51 @@ func GetGeonameCountry(ctx context.Context, country string) ([]Geoname, error) { var resp *http.Response u := fmt.Sprintf("http://download.geonames.org/export/zip/%s.zip", country) - resp, err = pester.Get(u) - if err != nil { - // Add re-try three times after failing first time - // This reduces the risk when network is lagy, we still have chance to re-try. - for i := 0; i < 3; i++ { - resp, err = pester.Get(u) - if err == nil { - break - } - time.Sleep(time.Second * 1) - } - if err != nil { - err = errors.WithMessagef(err, "Failed to read countries from '%s'", u) - return res, err - } - } - defer resp.Body.Close() - br := bufio.NewReader(resp.Body) + h := fmt.Sprintf("%x", md5.Sum([]byte(u))) + cp := filepath.Join(os.TempDir(), h+".zip") + + if _, err := os.Stat(cp); err != nil { + resp, err = pester.Get(u) + if err != nil { + // Add re-try three times after failing first time + // This reduces the risk when network is lagy, we still have chance to re-try. + for i := 0; i < 3; i++ { + resp, err = pester.Get(u) + if err == nil { + break + } + time.Sleep(time.Second * 1) + } + if err != nil { + err = errors.WithMessagef(err, "Failed to read countries from '%s'", u) + return res, err + } + } + defer resp.Body.Close() + + // Create the file + out, err := os.Create(cp) + if err != nil { + return nil, err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return nil, err + } + + out.Close() + } + + f, err := os.Open(cp) + if err != nil { + return nil, err + } + defer f.Close() + br := bufio.NewReader(f) buff := bytes.NewBuffer([]byte{}) size, err := io.Copy(buff, br) @@ -403,21 +294,6 @@ func GetGeonameCountry(ctx context.Context, country string) ([]Geoname, error) { for _, row := range lines { - /* - fmt.Println("CountryCode: row[0]", row[0]) - fmt.Println("PostalCode: row[1]", row[1]) - fmt.Println("PlaceName: row[2]", row[2]) - fmt.Println("StateName: row[3]", row[3]) - fmt.Println("StateCode : row[4]", row[4]) - fmt.Println("CountyName: row[5]", row[5]) - fmt.Println("CountyCode : row[6]", row[6]) - fmt.Println("CommunityName: row[7]", row[7]) - fmt.Println("CommunityCode: row[8]", row[8]) - fmt.Println("Latitude: row[9]", row[9]) - fmt.Println("Longitude: row[10]", row[10]) - fmt.Println("Accuracy: row[11]", row[11]) - */ - gn := Geoname{ CountryCode: row[0], PostalCode: row[1], diff --git a/internal/schema/migrations.go b/internal/schema/migrations.go index 2ec3bec..3fa9e87 100644 --- a/internal/schema/migrations.go +++ b/internal/schema/migrations.go @@ -217,7 +217,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest }, // Load new geonames table. { - ID: "20190731-02h", + ID: "20190731-02l", Migrate: func(tx *sql.Tx) error { schemas := []string{ @@ -246,7 +246,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest countries := geonames.ValidGeonameCountries(ctx) if isUnittest { - + countries = []string{"US"} } ncol := 12 @@ -287,7 +287,6 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } start := time.Now() for _, country := range countries { - //fmt.Println("LoadGeonames: start country: ", country) v, err := geonames.GetGeonameCountry(context.Background(), country) if err != nil { return errors.WithStack(err) @@ -316,7 +315,7 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } } if len(v)%batch > 0 { - fmt.Println("Remain part: ", len(v)-n*batch) + log.Println("Remain part: ", len(v)-n*batch) vn := v[n*batch:] err := fn(vn) if err != nil { @@ -324,11 +323,8 @@ func migrationList(ctx context.Context, db *sqlx.DB, log *log.Logger, isUnittest } } } - - //fmt.Println("Insert Geoname took: ", time.Since(start)) - //fmt.Println("LoadGeonames: end country: ", country) } - fmt.Println("Total Geonames population took: ", time.Since(start)) + log.Println("Total Geonames population took: ", time.Since(start)) queries := []string{ `create index idx_geonames_country_code on geonames (country_code)`, From 55627248f32653756a207919cd2ec18c857c151a Mon Sep 17 00:00:00 2001 From: Lucas Brown Date: Sun, 18 Aug 2019 00:47:07 -0800 Subject: [PATCH 15/21] Updates to main readme, linking to new saasstartupkit.com. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c5fb683..df906e0 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Copyright 2019, Geeks Accelerator twins@geeksaccelerator.com -The SaaS Starter Kit is a set of libraries for building scalable software-as-a-service (SaaS) applications that helps -preventing both misuse and fraud. The goal of this project is to provide a proven starting point for new +The [SaaS Starter Kit](https://saasstartupkit.com/) is a set of libraries in Go and boilerplate Golang code for building +scalable software-as-a-service (SaaS) applications. The goal of this project is to provide a proven starting point for new projects that reduces the repetitive tasks in getting a new project launched to production that can easily be scaled and ready to onboard enterprise clients. It uses minimal dependencies, implements idiomatic code and follows Golang best practices. Collectively, the toolkit lays out everything logically to minimize guess work and enable engineers to @@ -24,7 +24,7 @@ https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pk *You are welcome to add comments to the Google Slides.* -[![Google Slides of Screen Captures for SaaS Starter Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pkejM/edit#slide=id.p) +[![Google Slides of Screen Captures for SaaS Starter Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://saasstartupkit.com/) ## Motivation From 0679a1e4ba4d24313f0ae21eca33ea31fc435a96 Mon Sep 17 00:00:00 2001 From: Lucas Brown Date: Sun, 18 Aug 2019 02:42:10 -0800 Subject: [PATCH 16/21] Enhancements to the readme files. Main change was updating references to status of the web app: instead of the readmes for the web app saying it was still in development with limited functionality, updated references for the web app to say it is fully functioning and provided details. --- README.md | 89 ++++++---- cmd/web-api/README.md | 28 ++- cmd/web-app/README.md | 165 +++++++++++++----- ...saas-starter-kit-go-rest-api-endpoints.png | Bin 0 -> 89160 bytes .../saas-starter-kit-go-web-app-pages.png | Bin 0 -> 84248 bytes 5 files changed, 191 insertions(+), 91 deletions(-) create mode 100644 resources/images/saas-starter-kit-go-rest-api-endpoints.png create mode 100644 resources/images/saas-starter-kit-go-web-app-pages.png diff --git a/README.md b/README.md index 60f75c2..143446f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ -# SaaS Starter Kit +# SaaS Startup Kit Copyright 2019, Geeks Accelerator twins@geeksaccelerator.com -Sponsored by Copper Valley Telecom - -The [SaaS Starter Kit](https://saasstartupkit.com/) is a set of libraries in Go and boilerplate Golang code for building +The [SaaS Startup Kit](https://saasstartupkit.com/) is a set of libraries in Go and boilerplate Golang code for building scalable software-as-a-service (SaaS) applications. The goal of this project is to provide a proven starting point for new projects that reduces the repetitive tasks in getting a new project launched to production that can easily be scaled and ready to onboard enterprise clients. It uses minimal dependencies, implements idiomatic code and follows Golang @@ -25,7 +23,7 @@ https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pk *You are welcome to add comments to the Google Slides.* -[![Google Slides of Screen Captures for SaaS Starter Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://saasstartupkit.com/) +[![Google Slides of Screen Captures for SaaS Startup Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://saasstartupkit.com/) ## Motivation @@ -69,11 +67,11 @@ facilitate exposing metrics, logs and request tracing to obverse and validate yo ## Description -The example project is a complete starter kit for building SasS with GoLang. It provides two example services: +The example project is a complete startup kit for building SasS with GoLang. It provides two example services: * Web App - Responsive web application to provide service to clients. Includes user signup and user authentication for direct client interaction via their web browsers. -* Web API - REST API with JWT authentication that renders results as JSON. This allows clients and other third-party companies to develop deep -integrations with the project. +* Web API - REST API with JWT authentication that renders results as JSON. This allows clients and other third-party +companies to develop deep integrations with the project. The example project also provides these tools: * Schema - Creating, initializing tables of Postgres database and handles schema migration. @@ -99,29 +97,29 @@ It contains the following features: * Integration with GitLab for enterprise-level CI/CD. Accordingly, the project architecture is illustrated with the following diagram. -![alt text](resources/images/saas-stater-kit-diagram.png "SaaS Starter Kit diagram") +![SaaS Startup Kit diagram](resources/images/saas-stater-kit-diagram.png) ### Example project -With SaaS, a client subscribes to an online service you provide them. The example project provides functionality for -clients to subscribe and then once subscribed they can interact with your software service. +With SaaS, a customer subscribes to an online service you provide them. The example project provides functionality for +customers to subscribe. Once subscribed, they can interact with your software service. -The initial contributors to this project are building this saas-starter-kit based on their years of experience building enterprise B2B SaaS. Particularly, this saas-starter-kit is based on their most recent experience building the -B2B SaaS for [standard operating procedure software](https://keeni.space) (written entirely in Golang). Please refer to the Keeni.Space website, -its [SOP software pricing](https://keeni.space/pricing) and its signup process. The SaaS web app is then available at -[app.keeni.space](https://app.keeni.space). They plan on leveraging this experience and build it into a simplified set -example services for both a web API and a web app for SaaS businesses. +The initial contributors to this project are building this SaaS Startup Kit based on their years of experience building +enterprise B2B SaaS. Particularly, this SaaS Startup Kit is based on their most recent experience building the +B2B SaaS for [standard operating procedure software](https://keeni.space) (written entirely in Golang). Please refer +to the Keeni.Space website, its [SOP software pricing](https://keeni.space/pricing) and its signup process. The SaaS web +app is then available at [app.keeni.space](https://app.keeni.space). They are leveraging this most recent experience to +build a simplified set example services for both a web API and a web app for SaaS businesses. -For this example, *projects* -will be the single business logic package that will be exposed to users for management based on their role. Additional -business logic packages can be added to support your project. It’s important at the beginning to minimize the connection -between business logic packages on the same horizontal level. +For this example, *projects* will be the single business logic package that will be exposed to users for management +based on their role. Additional business logic packages can be added to support your project. It's important at the +beginning to minimize the connection between business logic packages on the same horizontal level. This project provides the following functionality to users: -New clients can sign up which creates an account and a user with role of admin. +New customers can sign up which creates an account and a user with role of admin. * Users with the role of admin can manage users for their account. * Authenticated users can manage their projects based on RBAC. @@ -219,7 +217,7 @@ following services will run: ### Running the project -Use the `docker-compose.yaml` to run all of the services, including the 3rd party services. The first time to run this +Use the `docker-compose.yaml` to run all of the services, including the third-party services. The first time to run this command, Docker will download the required images for the 3rd party services. ```bash @@ -264,7 +262,7 @@ $ docker-compose down Running `docker-compose down` will properly stop and terminate the Docker Compose session. Note: None of the containers are setup by default with volumes and all data will be lost with `docker-compose down`. -This is specifically important to remember regarding the postgres container. If you would like data to be persisted across +This is specifically important to remember regarding the Postgres container. If you would like data to be persisted across builds locally, update `docker-compose.yaml` to define a volume. @@ -452,19 +450,24 @@ internally development of the web-app service to the same functionality exposed This separate web-api service can be exposed to clients and be maintained in a more rigid/structured process to manage client expectations. -The web-app will have its own internal API, similar to this external web-api service, but not exposed for third-party +The web-app has its own internal API, similar to this external web-api service, but not exposed for third-party integrations. It is believed that in the beginning, having to define an additional API for internal purposes is worth -for the additional effort as the internal API can handle more flexible updates. +for the additional effort as the internal API can support increased release velocity and handle more flexible updates. For more details on this service, read [web-api readme](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/blob/master/cmd/web-api/README.md) ### API Documentation -Documentation for this API service is automatically generated using [swag](https://github.com/geeks-accelerator/swag). Once this -web-api service is running, it can be accessed at /docs +Documentation for this API service is automatically generated using [swag](https://github.com/geeks-accelerator/swag). +Once the web-api service is running, it can be accessed at /docs http://127.0.0.1:3001/docs/ +You can see an example of this Golang web-api service and the API documentation running here: +https://api.example.saasstartupkit.com/docs/ + +[![Example Golang web app deployed](https://dzuyel7n94hma.cloudfront.net/saasstartupkit/assets/images/responsive/img/saas-startup-example-golang-project-web-api-documentation-swagger-ui.png/9334f34bf028e0656f73aeb9d931e726/saas-startup-example-golang-project-web-api-documentation-swagger-ui-320w-480w-800w.png)](https://api.example.saasstartupkit.com/docs/) + ## Web App [cmd/web-app](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/tree/master/cmd/web-app) @@ -477,9 +480,16 @@ for internal requests. Once the web-app service is running it will be available on port 3000. http://127.0.0.1:3000/ -While the web-api service is rocking, this web-app service is still in development. Only the signup functionality works -in order for a user to create the initial user with role of admin and a corresponding account for their organization. -If you would like to help, please email twins@geeksinthewoods.com. +The web-app service is a fully functioning example. You can see an example of this Golang web-app service running here: +https://example.saasstartupkit.com + +[![Example Golang web app deployed](https://dzuyel7n94hma.cloudfront.net/saasstartupkit/assets/images/responsive/img/saas-startup-example-golang-project-webapp-projects.png/e3686cbd2515887375535a64cf101184/saas-startup-example-golang-project-webapp-projects-320w-480w-800w.png)](https://example.saasstartupkit.com) + + +The example web-app service includes complete working example of a responsible mobile-first web app for +software-as-a-service and example business logic Go packages facilitating create, read, update and delete operations. +It also includes signup for customers to subscribe to your SaaS, user auth for login/logout, and admin functionality +of user management. For more details on this service, read [web-app readme](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/blob/master/cmd/web-app/README.md) @@ -499,13 +509,17 @@ code dependencies. Structs for the same database table can be defined by package dependencies. The example schema package provides two separate methods for handling schema migration: + * [Migrations](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/blob/master/internal/schema/migrations.go) - List of direct SQL statements for each migration with defined version ID. A database table is created to persist executed migrations. Upon run of each schema migration run, the migraction logic checks the migration database table to -check if it’s already been executed. Thus, schema migrations are only ever executed once. Migrations are defined as a function to enable complex migrations so results from query manipulated before being piped to the next query. +check if it’s already been executed. Thus, schema migrations are only ever executed once. Migrations are defined as a +function to enable complex migrations so results from query manipulated before being piped to the next query. + * [Init Schema](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/blob/master/internal/schema/init_schema.go) - If you have a lot of migrations, it can be a pain to run all them. For example, when you are deploying a new instance of -the app into a clean database. To prevent this, use the initSchema function that will run as-if no migration was run before (in a new clean database). +the app into a clean database. To prevent this, use the initSchema function that will run as-if no migration was run +before (in a new clean database). Another bonus with the globally defined schema is that it enables the testing package to spin up database containers on-demand and automatically include all the migrations. This allows the testing package to programmatically execute @@ -514,7 +528,7 @@ schema migrations before running any unit tests. ### Accessing Postgres -To login to the local Postgres container, use the following command: +To login to the local Postgres container and query the database tables, use the following command: ```bash docker exec -it saas-starter-kit_postgres_1 /bin/bash bash-5.0# psql -U postgres shared @@ -538,10 +552,17 @@ shared=# \dt ## Deployment -This project includes a complete build pipeline that relies on AWS and GitLab. The presentation "[SaaS Starter Kit - Setup GitLab CI / CD](https://docs.google.com/presentation/d/1sRFQwipziZlxBtN7xuF-ol8vtUqD55l_4GE-4_ns-qM/edit#slide=id.p)" +This project includes a complete build pipeline that relies on AWS and GitLab. The presentation +"[SaaS Startup Kit - Setup GitLab CI / CD](https://docs.google.com/presentation/d/1sRFQwipziZlxBtN7xuF-ol8vtUqD55l_4GE-4_ns-qM/edit#slide=id.p)" has been made available on Google Docs that provides a step by step guide to setting up a build pipeline using your own AWS and GitLab accounts. +Google Slides on Setting Up Gitlab CI/CD for SaaS Startup Kit: +https://docs.google.com/presentation/d/1sRFQwipziZlxBtN7xuF-ol8vtUqD55l_4GE-4_ns-qM/edit#slide=id.p + +*You are welcome to add comments to the Google Slides.* + + The `.gitlab-ci.yaml` file includes the following build stages: ```yaml diff --git a/cmd/web-api/README.md b/cmd/web-api/README.md index 31eeb01..92d54b6 100644 --- a/cmd/web-api/README.md +++ b/cmd/web-api/README.md @@ -1,21 +1,25 @@ # SaaS Web API Copyright 2019, Geeks Accelerator -accelerator@geeksinthewoods.com.com +twins@geeksaccelerator.com ## Description -Web API is a client facing API. Standard response format is JSON. +Web API is a client facing API. Standard response format is JSON. The example web-api service includes API documentation. -While the web app is meant for humans to experience and requires -a friendly UI, the web API is meant for customers or third-party partners of your SaaS to programmatically integrate. To -help show the similarities and differences between the pages in the web app and similar endpoints in the web API, we -have created this diagram below. Since it is very detailed, you can click on the image to see the larger version. + +While the web app is meant for humans to experience and requires a friendly UI, the web API is meant for customers or +third-party partners of your SaaS to programmatically integrate. To help show the similarities and differences between +the pages in the web app and similar endpoints in the web API, we have created this diagram below. Since it is +very detailed, you can click on the image to see the larger version. [![Diagram of pages in web app and endpoints in web API](resources/images/saas-starter-kit-pages-and-endpoints-800x600.png)](https://gitlab.com/geeks-accelerator/oss/saas-starter-kit/tree/master/resources/images/saas-starter-kit-pages-and-endpoints-800x600.png) - +This web-api service is not directly used by the web-app service to prevent locking the functionally required for +internally development of the web-app service to the same functionality exposed to clients via this web-api service. +This separate web-api service can be exposed to clients and be maintained in a more rigid/structured process to manage +client expectations. **Not all CRUD methods are exposed as endpoints.** Only endpoints that clients may need should be exposed. Internal services should communicate directly with the business logic packages or a new API should be created to support it. This @@ -36,11 +40,17 @@ initial admin user must first be created. The initial admin user can easily be c ## API Documentation -Documentation for this API service is automatically generated using [swag](https://github.com/geeks-accelerator/swag). Once this -web-api service is running, it can be accessed at /docs +Documentation for this API service is automatically generated using [swag](https://github.com/geeks-accelerator/swag). +The Swag Go project also provides a web UI to allow you and your customers of your SaaS to explore your API - its exposed +business logic - as well as easily try our that exposed functionality. +Once this web-api service is running, the Swagger API documentation for the service can be accessed at /docs: http://127.0.0.1:3001/docs/ +You can refer to the example of the API documentation that we have deployed on production for you here: +https://api.example.saasstartupkit.com/docs/ + +[![Example Golang web app deployed](https://dzuyel7n94hma.cloudfront.net/saasstartupkit/assets/images/responsive/img/saas-startup-example-golang-project-web-api-documentation-swagger-ui.png/9334f34bf028e0656f73aeb9d931e726/saas-startup-example-golang-project-web-api-documentation-swagger-ui-320w-480w-800w.png)](https://api.example.saasstartupkit.com/docs/) diff --git a/cmd/web-app/README.md b/cmd/web-app/README.md index 83f75b8..a274375 100644 --- a/cmd/web-app/README.md +++ b/cmd/web-app/README.md @@ -1,20 +1,25 @@ # SaaS Web App Copyright 2019, Geeks Accelerator -accelerator@geeksinthewoods.com +twins@geeksaccelerator.com ## Description Responsive web application that renders HTML using the `html/template` package from the standard library to enable direct interaction with clients and their users. It allows clients to sign up new accounts and provides user -authentication with HTTP sessions. To see screen captures of the web app, check out this Google Slides deck: +authentication with HTTP sessions. + +The web-app service is a fully functioning example. To see screen captures of the Golang web app, check out this Google +Slides deck: https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pkejM/edit#slide=id.p *You are welcome to add comments to the Google Slides.* -[![Google Slides of Screen Captures for SaaS Starter Kit web app](resources/images/saas-webapp-screencapture-01.jpg)](https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pkejM/edit#slide=id.p) +We have also deployed this example Go web app to production here: +https://example.saasstartupkit.com +[![Example Golang web app deployed](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)](https://example.saasstartupkit.com) The web app relies on the Golang business logic packages developed to provide an API for internal requests. @@ -22,11 +27,116 @@ Once the web-app service is running it will be available on port 3000. http://127.0.0.1:3000/ -While the web-api service has -significant functionality, this web-app service is still in development. Currently this web-app services only resizes -an image and displays resized versions of it on the index page. See section below on Future Functionality. -If you would like to help, please email twins@geeksinthewoods.com. +## Web App functionality + + +This example Web App allows customers and their users to manage projects. Users with role of admin will be allowed to +create new projects. Users with access to the project can perform CRUD operations on the record. + + +This web-app service includes the following pages and corresponding functionality: + +[![Example Golang web app deployed](../../resources/images/saas-starter-kit-go-web-app-pages.png)](../../resources/images/saas-starter-kit-go-web-app-pages.png) + + + + +### landing pages + +The example web-app service in the SaaS Startup Kit includes typical pages for new customers to learn about your +service. It allows new customers to review a pricing page as well as signup. Existing customers of your SaaS can login +or connect with your support resources. The static web page for your SaaS website also includes a page for your web API +service. + +[![Golang landing page of Go pricing page](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png) + + +### Signup + +In order for your SaaS offering to deliver its value to your customer, they need to subscribe first. Users can subscribe +using this signup page. + +[![Golang web app signup and Go SaaS app sign up](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-signup.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-signup.png) + +The signup page creates an account and a user associated with the new account. This signup page +also uses some cool inline validation. + +### authentication + +Software-as-a-Service usually provides its service after a user has created an account and authenticated. After a user +has an account, they can login to your web app. Once logged in they will have access to all pages that require +authentication. This login page also uses some cool inline validation. + +[![Golang web app authentication and Go web app login](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-login.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-login.png) + +The GO web app implements Role-based access control (RBAC). The example web app has two basic roles for users: admin +and user. +* The role of admin provides the ability to perform all CRUD actions on projects and users. +* The role of user limits users to only view projects and users. + +Once a user is logged in, then RBAC is enforced and users only can access projects they have access to. + +The web-app service also includes functionality for logout and forgot password. + + +### projects + +The example code for the web app service in the SaaS Startup Kit exposes business value to authenticated users. The example +web app show how the SaaS Starter Kit provides Go boilerplate code to perform CRUD operations on an object. + +One example business logic package is the one to create and manage Projects. In the SaaS Startup Kit, projects represent +the highest level of business value. Users can perform CRUD on project records. + +The web app includes this index page that lists all records. This index page then allows users to view, update and delete an object. + +[![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png) + +From the projects index page, users can click the button to create a new record. This create page demonstrates how a new record can be created for projects and also demonstrates inline validation. + +The view page for an object displays the fields for the object as read-only. The page then includes links to edit or archive the object. The archive functionality demonstrates how a soft-delete can be performed. While the web app does not expose functionality to delete a record, the internal API does support the delete operation. + +[![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png) + +You can easily modify the projects package to support your own requirements. If you were providing a software-as-a-service similar to Github, Projects could be changed to be 'repositories'. If you were providing software-as-a-service similar to Slack, Projects could be modified to be 'channels', etc. + + +### user (profile) + +After users authenticate with the web app, there is example code for them to view their user details - view their profile. + +[![Golang web app authentication and Go web app login](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png) + +A user can then update the details for the record of their user as another example demonstration the update operation. There +is also functionality for the user to change their password. + + +### account (management) + +Once a user signups to your SaaS via the web app, an account is created. Authenticated users can then view the details +of their account (demonstrating the read operation of CRUD). + +[![Golang app account management and Go web app update account](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png) + +Users with role of admin can view and update the details of their account, while non-admins can only view the details of their account. + + +### users (management) +Users with role of admin have access to functionality that allows them to manage the users associated with their account. +This index page uses Datatables to demonstrate providing advanced interactivity to HTML tables. + +[![Golang app users management and Go web app update user](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users.png) + +From the users index page, users can access the functionality to create a new record. This create page demonstrates how +a new record can be created for users. The create functionality also allows one or more roles to be applied for ACLs. + +[![Golang app create user and Go web app create user](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png) + +If the admin would rather the new users provide their own user details, there is Go code demonstrating how users can be invited. The invite functionality allows users to specifiy one or more email addresses. Once submitted, the web app will send email invites to allow the users to activate their user. + +From the users index page, admins for an account can view users details. This page also provides access to update the user as well as archive it. + +A user can then update the details for the record of their user as another example demonstration the update operation. As part of ACL, the roles for a user can be added or removed. ## Local Installation @@ -40,7 +150,6 @@ go build . To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory. - ```bash docker build -f cmd/web-app/Dockerfile -t saas-web-app . ``` @@ -73,45 +182,5 @@ Test a specific language by appending the locale to the request URL. ### Future Functionality -This example Web App is going to allow users to manage checklists. Users with role of admin will be allowed to -create new checklists (projects). Each checklist will have tasks (items) associated with it. Tasks can be assigned to -users with access to the checklist. Users can then update the status of a task. - -We are referring to "checklists" as "projects" and "tasks" as "items" so this example web-app service will be generic -enough for you to leverage and build upon without lots of renaming. - -The initial contributors to this project created a similar service like this: [standard operating procedure software](https://keeni.space/procedures/software) -for Keeni.Space. Its' Golang web app for [standard operating procedures software](https://keeni.space/procedures/software) is available at [app.keeni.space](https://app.keeni.space) They plan on leveraging this experience and boil it down into a simplified set of functionality -and corresponding web pages that will be a solid examples for building enterprise SaaS web apps with Golang. - -This web-app service eventually will include the following: -- authentication - - signup (creates user and account records) - - login - - with role-based access - - logout - - forgot password - - user management - - update user and password - - account management - - update account - - manage user - - view user - - create and invite user - - update user -- projects (checklists) - - index of projects - - browse, filter, search - - manage projects - - view project - - with project items - - create project - - update project - - user access - - project items (tasks) - - view item - - create item (adds task to checklist) - - update item - diff --git a/resources/images/saas-starter-kit-go-rest-api-endpoints.png b/resources/images/saas-starter-kit-go-rest-api-endpoints.png new file mode 100644 index 0000000000000000000000000000000000000000..3b751a3dfb3f2760197958c07c2a71da6c81ea98 GIT binary patch literal 89160 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 z z+_tOlF3u-@x9RMJ>8@?ZyWw%G=?Po@!)X8@)LRpnkio#6dU+wa#Xy zz@1+|`2O4Cb@eXM3byd!v1bQ}OBH-Vau1{La;}nA{I{9S^Y6MdlW&lKtNfa0@pbk2 zKd}HJ(^|&z*V|7_njU33vZv4Fz?hwd34h*vMIK5iGzgm zFL%D3zouTI*eG&%4>4;#X3X-i+>jAJ*>OAice1Da5q+AoQ6!gMt4khB*ZU8J1SWb2 z$1d0})ql`&0Tv8k|5R4M{)M=*><}P!A9fh4*Ec_>ZJZ|Ouy45+NByX#5#_I|Q0d;9 z-K{ZUOn&<}Al%isH*Q`tXU}CJ=Af)J;v-pf&HupjpCUYe+ClolW&j5hMn1XE`7x#9 zv>U()KM%ciZ@5y}%1{0{>IpKA98HAb4Rz zv&R6_L0Lpr9UE{o4IPjKQ_R6Q!PJd!8Q$j)Z_RA>L9Cw@h;&Rs7`Y<;@&K)=P38`Z zZ7ytc!8#(^paaMOX+;CFhR$1-hmJMo++b;}^6Y2X;$LteLt-Ke(%1UEuO*B^a<~NA zp};!;+K9aF6Fc{CJHvVW9JxsCZK1Q`RnXfjZXt?sN1@Fu+ge)h1?7t&sHU6@*5mzd zq4KMtPB+-0w-lH?S3o!f(iu*z-=7Phip~04;hfGCfFQy5v&Ga98og(8e#nqJaU|`S zwNvG#aBR*)vRZK9YI&~3;%-ja_lB0&FHezzKY~`p1{Q`7a8B;ecK{K-CGiz-B)rmR z*CFq1lUqadpHc6Gu~gY_CcC=VyBO0SlKY=Z_iO@`$!%(-fOVopG4Xn_C=)9-cn zfv+)1gT%p5REPwKl0mUoF0Sqr+N#Wk!)?xP&Hdyz+XtGnc{alu;h909sCA3SDRz=c zK%cVBZ@I~GlLvV|mHs@Bv9eh6@}wYY7bC1jLW~Or=y)s8PIKzqABb!=Ey6(&;sEGJ zVI-&A2l&MY%3vBfBi6?-vMwJAn}$esIL!&Eh79dmF{2Q0j-b z@B}T>e!U~a-&Clki@#Y~L_WYRe}TK^>&?5jdm21g@{syY$?$2V(Z9F=;3REi-@ght zMaeJsDwBzY{z4~0;6u(;=L2Da&M8-g3LVLN22R(|j-1IA1E=N!mfGiFgj)uKZtlf{ z;jxv=xq#x1y&Oa)&kd0W?16G}hlRDr z*p|Uw%Q!+7a#pR7E&h2Hc0iyI7VFk4r42wCbojRBi%oZ!eLoX(#%38JDJzH%}O<(0{W7PJJS{!+( z%-6SV5~Pshb;XDlCC0nS`Efn6&__FSNopl@5jEgFzEbP`SXVaJ`he7>CMKg0KsURT z{{HezSfzTlO=C~Y@nU#KM`4*?L4I`0GhHoop$O~9*XTV;N7|o2h>`ziB6|;BZLVU1 z9NWsF6Cq>9Y?DgF*1i3Tb2cULLQk-rNnEt{Pq6g6Vg^j9f!4$LzlokMVAAI@8HoDg z=6~$HICqd<9?_jxelT&b4;+DCQ>OaG4>CGb3j8-%a1wi7c*%Wbul;y#TG*4&TOsGp z5a|a#GOZ;VOL7S0_BUg9lcT?s)%a6Le0*tqyY4muX>*!*RM3+GWD#io`Jjc(7+d1@ z2lZoM`C9zq2$HzEnn|v+Iap$Aa-|h(6#7*5$!{B zOk?{yvffYf7Vu@`D_WA$a6S=Nv1QOW_d_9T8Bw2?w&g3s<5Y?~XD>{THo3dM!relS zH2UKH^wQUrxLL@?-D}4jQ0b(r;qz8@u5-PZ0N~D5XSpZuE{697(XmifbPhaE^>bt& zDuSK8f9p=p;h?vUcjt(kocp@du*LoW-bUUF)H6L_i^!aRjX8By@@!2oK8u~^KX^Mp z?D!j7-KNtb^n%p+cc;a|#p);HiA(rUu2lNGbAc(0%%Ay}+=I3e#?3ujC!%a{1Xibc zJ%A|gehj#SWvzZ;zggk}Zn**EtHyHoxI?}J$*%ypNy)u@q83d{{_oW4|P#m;7)p7RPIDC7} zI*YWC=CD6VN~y{~J^fzti|OY}_8$JWTo*_)xUYQQfKC!$nxAD+;PTgG+ob@6?M?Pf)e$B+@7#fwnEOh!DGsbFZIcjrDNe zV|!SvhGW3*c7iR_DYd7&^hmOWjY0_$){hUR9G zV*oufR1O@tE%ubLm#t3m+eP0V=f(CiL7LO=6?qT z&igvv_R9D;1Wmb7ud{Q;RmF;s878@-B!%JK^RNO;Q~`h_*^7t?~~5X)doP|WP@?`CV99WgHZskLe%?;f_h&4o_V3DL#~ z+8#U;aT3cDH~#!K;&fV7$oz1R=bq6k-8<$@$u1@1k;TVBt1a~HrPi<(Hhdcif9`k+J0GDP z{bdv3;aOKxaxwj}~d$>jZFgfoehMX(%m0vFZ#0Gd^hXvVbu6tNn{fkHP$p z3Bp%GuL^Y~S4}1Jn2}JqBpFcj!*;FVK5SN`uf(>YSH?*M_9orV(>kk^TSll-@(S~? z=l%;>DLRtyLb+fjAkc!dOa^M`>L;LbJjLN)nIAWn+LLX^?xd% zfx*lFR1p8KRS-e!{UQ9c^s2J*_Z@qf;#CDu74+K$&EQ*e#G_P$FdQpCD(D`On!Ky| zmF53XAO4K764{@niMS9@aTJnXR_@$_0(XkCGEE92U_ro`pi1q3QlSCWAr3{d|3h{7 z9~RR8u#o61?rGJ0%TgmOMSj@fqclD-g^P}sx)t)zJ zh(cI0b288D>q+-KmakHVbcAzcsFuh*diLzu<*F^yy6#HXp~6Iko&+!lsWF~+iv(x> z1L*?hDsA?+jCoX9_4?*Ta8;}Yyfdrt1j>>#BNd%C;mb3VZJuH$z2sf+*J*= z%p`cQ5_Dt~Su7Tfcntpjrti8J(MAp~84xZ1ZqH*`meOZL^vX?~E`^JE!e^70^WYF< z!m5v-I&BtPE8xSkqQwUp5c(u%D11A|vH1kp)ct*-Sl&z={?J(%TqJ^I0c;XPkisB> z{J$AofV3643giHnP2E@&h6d#nNKL0PwnyxkXv4%b!JL6-kOv%* z=NTu$El2=L^_@B3a_R4{TGIN>D_}~V4++UcJ|;o|^8vlU>v3_yQzlYkcb1zrj28(yRXq(Rkh0Icg5=FWBM zXIJWdyEd6*KLH#^Ar<-)8 zde#fXN{M2o7r!Cf%Llm|kcGN_c-_VcoIxNTkkP{}REPZmLz@F1BruG~JyHrx5`k`O zR;Ui>4JL_eW2~N+8AU>;XD0F_>l!5jj7x^*kZ})k`~f2$>p1PBq2Y`sm*Wkb;mK$S zE6Pgy1D9LyH8lMfi^Unx4d~-TqzwWWYT*I&ziXISuc4RdMV;voUISM2hMV*m=&Liy zeFt?PoCKwq!k0(4%`deApXD-81PTFg=^HxBRU-^VdSXnTCF!f`aXwC9HK9(9D}XZ& zpiG<7kw^o^)@JKHf54_J%8Z~{V;O*`EH!W9ig*`%tRQLo2X+@c{@C zHJ%ue1<46ds03~ku@?jzkF-saEIBY>(>wTKAlBX8UeJ1EDjMCzb zhk!WlR-*yVt?Tx54p8F`J5V;=m5EWk3o60JoSP^iN4*DD0Q4y>*XVUtI(+|epa6ic1Rg1pC#LE+=UW9!qC*mmO;9ANxw7)12q^w;^uxcfPHW@Fj}lY zHc|E^lF^3int+m(`n{#Z&-oO;=_SxOc?@WUrm2*pP6@9(r`mMSsSG3GjtjE30vWN; zE509yypUz)gJhXGCcSibij~?bbHQi?4Ou!%M704Rfwl?%-pBaYk0S`SN;W=I; zyUK5R1+hfjqJT=cO-8=o7uvJ!i;_A$2#l$bnJAUdnMSXD&R!H&D!2+=_*@%rgQSM~ z>xz(fjdh537F0yC#=Wc}5#eSY4acEU?|eYV`sjXB z{LNL4@s6=>)UA#LRjmR{E>|7-1ID}T>$}7^3#GHD34{-36to`GbAqFM^jy7KbDvQA zkUb}sZXscBmfC@G*fkEi41>0JYNidpOLi6)*m+*_v# z+&N;R1)?qjec=(#ubShUY&w^MkC(1c58frDYIXSbp3;}c(u|ot0v<260j6|j+m(pY zoDONT6uVPLph6+x(jr#mb$SE|Ub&;d!%S8vIjf=zM*_j=BhS2cXf|l{88+bR&=BHw zk8IxM6#LZcgKVEo?2_s?Ey~0(p8-5K>eLW#e8IZs+ihDup}Mm`#93ZD>p2D)vkSJe zc>XjsaWpz9JrFJM#M*REA~^eQ?8AM4zZcB@u`ESdSx!E&+2aOSQA zad8!8Jjw6GzoxqGa4p>saARDc+~7HWU2g~8znt~<7sVTh0m*LCxU$3YlHQ^6?eGJV==hi7%s;qvhi`PAN zrTkqX4|u)_G~ap2!E8ox4@_@_UNQpd&9!3)C9QgHFGT@v&uNZ<4^6!5Na8N90-z&zT9VCYr2U# zU|9I{)u(7ILo47C&n2e$QlI($(t`-*P8Kc`<5Ay2*u7J;^2eoP3> zjbZ91Lhpk4TmSYsswFqE*i-ACMt5B_yw*ZTnpk5pxe&3F@p^81XlX1c1-7P+bg%oI z2Wpf1z~h`M_FjD31pf^CSBB&>`a__fn_#0?7bP-v85RF7?ByVh+?e{q<NuDk__}fq*yCGex9!@=5+dcM4`epWRgWkeJZZdj z-Ak4Rm?IIBSD)bd9qKKURFk%B!s%!i?>lrMML=<0Q1+Wq58@t}0Osp9TZLUym+{~O z;a2A!QF1@u%$VkNb4oLxfaL2jy-=1 zONXqN^~k)PBgSliOLK~}J+$%PHqcD*P%-AW@SA>?#VB#ri&!vfUS@;gsTn~ycd2T^ zhC8)yS7Z0!aBDQq6Y4633ie^dN}#5Od(!Ax21xjEu+D=0eQQVx(QEhAuiVQ~-}8IZ z@*vWb7n1#TM!T(L^fHh0&XMwR8#AxNUq@b@`o@60b!#UhNm7^8CS%h!TAKtBoPTGw zqi+i5DrgfL`f>WRPqUru_S_fF=yEmKZ?81R6x)CZ-{1@6jHQi2^y9TcW-aHSda7tp z8JgDw!{n&fuI#|WZGQ=VBLqfdCkV+J_i{&q56HVm*G*9<<-Ghk$<*7X%{!QL-wpVb zJp7JY|2g}*EE&3g>TfA|QV&%Impx?*%v+-o6=QSAMan}TP44cjGR!;IQgijl-&a02 z8c#$YQl2d65_IXvx}TUW@t>7dh|BN^Tanb~Y1ds8^xi1H%IL5oErrk3{YL!jx^M}% zDtEuog^eCbu_VO-m+RMebdW$Sx~C(Uy2`Fw{v*)91$&0m(JGxa`usx{>82G+8nyGm zcyW)W?rN9%BU2&oh$QbL`CILLG-Hp>(#+g@`JxV8#FOVsDOW0)uMP_Cx|6Vhwf}1U z#`w|j;040NmOkZ~`ca%3W6(SoJqr?_pM<9jdd;n`1l`FX!IHXLW#r^?z{M!{cCRMe z$*@+*xCfu%L|CT#0M!sEJSP3K@c7bj_;!PXRC33=h6jCzRf8%^xh9__pW%g81q=7$0ywL`L&P72cqe%_K$z(h)G!jPFtw54jNqJ?UfHq z6RTnSyqt64KqpW{88EMHKsrI><--)>NaQCcCf<1O4pL$a_M@Ll+I#jH2_G|-`udM+ zV7KAy(_nU?wZCgI?|p-lbei&q2!!bTzrBtvB^TTe_JtNViCEz&+Hmg8eg5#_Lyg{6 z&99&fW9JztpAaQWTvg0oLF|!8Wh1ErCMr+*7P`}iE8XpazX*|E?|5|OjcSyCzyA^M z<*8fmq!l~;0+YV3P`R(Ot(|a-xOZocD4<=c4l*)0Aa20b+7^CZ{R9P0dujqRq}0z| zbZydJD7ue~u{;t(yn%~4pfx>ZH?1kRU^3JCj&@Mi^41RK>ZxCXQv88e%dwSf8x}r< z8x10-fSY)(&1WU9ihbMHK+%K$ov1@#N>N_htrVnaiF)jv0#_lg`$;9|qx|hkk7hE^ zJ*;NO`ER>iba&phFZ9y&%wzZ^~Q%MBkSv`$u`Az1kLAf4EzY9`#M*qHJ#>bd8us&V+Ip&ns4MEM{nPTKcO z<&}Uq<-bZ^h`a=Ho1Ki=sXEJ%tT52PN@*q74qiF3Wea1X2Jw_W@Mz9owaXLRB+-b) z4QW0a_$P^`N`kBjgq(*~pHw~L{yWEqa!U;p%QXQ6lp3X){KTv{%vg4-s zK1j$o!@;7scj!ArhRFKT*?-|}Zs?7>oW3VH)%&e3xFD9z(*~Q_pDXiDknp+J z&uVpr9^mo&!smPQv6xu=^uH{B4k&bF8c87D1GP2#;M;%HXOu6n96G!t$!_L9Uud!I zzZeJY{{hwVUN~}H!bS7clNZDK9xrY0>MwjLkXiWgjt@^6PSF;hgB#0uV@UV%^pRb5{}R^|EuMMDL`i~rdih}5Qtn2U-aAyA$5 zMHOP`h4&>}!%U>huKz1)X?HNe{Qs%!+~c9j`#3JfM7GSBvWvkq7;?X*L9ERY6^h5! z5XG2s*-?#QmaH;zn{*+$@v^E|dD1$MbPnfm%b8ob}Snu218qCkxRg5)) z!(s*iJ7EH~WI26>35FsCv-sJ0V{up&puhFt>JS15cwDS@=w&r{Z_s*KvEj$jC3NB+ zFlxOP^4-HH+fnn6@H{$fjBe{~9Qx8J>X)*T8;9?ox;Z!%2Y*ED@?6Bl%X?SILSA|5 z8!SGMBY8x+1tx-ZbH*bBnjjN2yE=bMeJE8BpNhJNa1UuIUzE@}I-jC%a}3r!_L>R% zaZLLnbkhg3UVn4~>exOHwf|7ZkOD7Bpd^BEyPmCx&WOtaG3JJS;sy?MftyTWYk2xK z4Qih5y3nAhORTSBhE$VW`vs^>q+h;)A>O87*%^ zVKig=ucj|sJyj%)l@{CN>LAg=2q!)HG26}^1ZyUI$}eli&^Js8#$n`?8%1cMgmv+uA>(?OV9tnpJAg zX^{6(^1|E$^pbW9gDY<4+AG(uIo`aGU<1{u*bA|Z6Oizhd*Vo`xtBfI)%E zJGMsxcp{nG^E2RoRjwkjL|_5+2IU5qm*qa)`|EDw=So%Hj(Iel)=C%iKE$wJDTkZZ z6CmPvLl(#IWOgX5h~R+VP6#r+g0~E|NNu3u)pN+fC6_|)??#JmWtnd_{~P!9R2=Cu#tOGe0Lg^i%=y(_!v~R z#V$+NfzeU!CF-+HSVXeueJt_&}x?y_eq*N72-d!~Ev$A`F_STZXtD4UGgs{E0<)0LxeC zf;|(Wo^;%K1=qD&biVo?(Cc@*)ZW7%(cT+$Bx)?<;ITK|M<#bEt$Kt8PH>WoAxzWQ zrIzM^Ah}`nH{DJn2qi`If|u)`gB@|z$+RN3Zx&ZCo-^iarkr$3JhQ>7)8&sqGgsT* zZG#HoZJ1QkGO3VtY`Pxg-6L75X$Ky3pg<$paOzl+By+BK1FVs|s07)|O8h!jd!!hz zg_D|C`?|ExHgvQ?mD1SKB~yn7I}s}2sra7@z!<1riieL*TJi z5(3rOvDkN@3^nk^V5sU#@}4Xwas)oo{?}17b|Chb{<1G+J8N8yKqjXllptW+c;Kg9 zZQgn0fG(F;^yu2{`R^$aRH64?Rn|(^)m>VX5`{#J#_-QRH3oz0 z&XYB-?m7ytC1+>a^}&>*MhN*_O>X9w*-J#h!^2Cu1vJ<@N=q^;t%^Q{wt|r;-g6D* zE7b`r2{9La_#y}v&gR~nd~vIX-%fgr&r7_8dfgfgAUhKP3nSWYqHVuO-#_g{Ij_Ii zxzJlMTi^pO$9sC2weM&cKE+p_*6EvU>S{VW&1SA$PAyej?6_X@vt1sIv>W!+ z?{YPtli1gT6zr7TB;7)aHR`-STmbAj@KU&D-ADH^7Y-AV(B_ysoozVALxa<$kF-b%vpvMtrsdwVL@NOrm z1+5=gNIEJ&0~Qz2ufJZiM=8P zKZ5d_1u6kQhh74{~;?_vM0F{5Doji4smF&OkfnF+w>AC)5B-0+^TTX;_bMQPBCj}x{LohQZ zEGbeiASQ_+SUI&^t+NIQqbY1v26GcDFsB^+HJdd&p$?yG3sLcGI0|A?{99LmQ#>sQbrCaFiEi*dF zvX5tJG6n&O5Z$dKb#G`|hU9BwgR^lJx%^6emaq81R${tBy&Ew|0pfw5q?dLuNnoA| zB7cZoO7;XCE~X6lVKxC>Woid=t~w?$2&Jj;neuPW0S3ys+EGkXxm& zIa)m(x^1P`!tzjg4s0t0g+=-vLJd-fO;mJtt)f=&og{a^R$q|LE0eC&B9W2uBauJl zGqn51&y?Snsngh(#@D_u05{A5RU;ptY}Bf)`gtGi1Y4>4&_-J|NP0|(%Do91q^5L< z;J;dsP8o1!Oa%DI{!uLPHP%>mtNC5m(z6r?z1cEQi&-PQiBVhs{~iZ9hN=8DLSB3I zfi5zR0M(rdcYOdrq5r$zi33GLb3QWeO2&6eAfU{_l z9EBQ>>lj)cHU>7uCo@QJRrp1l|7r|(0c#~w;`l$Q4gY%__^wCINY;JSWnE7U9W+)W z$}JHSAP^FkfA1!fCsO%et1Y?je?x{wd*jDFw|`fM>-jPtE99ZRGkj7GU&8D=UK8%s koD4DG==^>KZno^FuM2K;8o4z34@tmhmxC)(xx+X1-)1OH7XSbN literal 0 HcmV?d00001 diff --git a/resources/images/saas-starter-kit-go-web-app-pages.png b/resources/images/saas-starter-kit-go-web-app-pages.png new file mode 100644 index 0000000000000000000000000000000000000000..91c456cd1e6d8107ae5cbaf7481b7fa66c99f597 GIT binary patch literal 84248 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 literal 0 HcmV?d00001 From 1b9f7eec7bcbf8ea2781586a493bbd315935ea72 Mon Sep 17 00:00:00 2001 From: Lucas Brown Date: Sun, 18 Aug 2019 12:57:33 -0800 Subject: [PATCH 17/21] Updates to the web-app service readme. --- cmd/web-app/README.md | 62 ++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/cmd/web-app/README.md b/cmd/web-app/README.md index a274375..6022614 100644 --- a/cmd/web-app/README.md +++ b/cmd/web-app/README.md @@ -30,29 +30,26 @@ http://127.0.0.1:3000/ ## Web App functionality - -This example Web App allows customers and their users to manage projects. Users with role of admin will be allowed to -create new projects. Users with access to the project can perform CRUD operations on the record. - +This example web app allows customers to subscribe to the SaaS. Once subscribed they can authenticate with the web app +and the business value can be delivered as a service. The business value of the example web app allows users to manage +projects. Users with access to the project can perform CRUD operations on the record. This web-app service includes the following pages and corresponding functionality: [![Example Golang web app deployed](../../resources/images/saas-starter-kit-go-web-app-pages.png)](../../resources/images/saas-starter-kit-go-web-app-pages.png) - - ### landing pages The example web-app service in the SaaS Startup Kit includes typical pages for new customers to learn about your service. It allows new customers to review a pricing page as well as signup. Existing customers of your SaaS can login or connect with your support resources. The static web page for your SaaS website also includes a page for your web API -service. +service. These are working example pages that a typical SaaS product usually include. [![Golang landing page of Go pricing page](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-website-pricing.png) -### Signup +### signup In order for your SaaS offering to deliver its value to your customer, they need to subscribe first. Users can subscribe using this signup page. @@ -62,6 +59,7 @@ using this signup page. The signup page creates an account and a user associated with the new account. This signup page also uses some cool inline validation. + ### authentication Software-as-a-Service usually provides its service after a user has created an account and authenticated. After a user @@ -77,51 +75,58 @@ and user. Once a user is logged in, then RBAC is enforced and users only can access projects they have access to. -The web-app service also includes functionality for logout and forgot password. +The web-app service also includes functionality for logout and forgot password. The forgot password functionality +send an email to the user with a link to web page that allows them to change their password. ### projects -The example code for the web app service in the SaaS Startup Kit exposes business value to authenticated users. The example -web app show how the SaaS Starter Kit provides Go boilerplate code to perform CRUD operations on an object. +The example code for the web-app service exposes business value to authenticated users. This business value is coded into +various business logic packages. One example business logic package is the one to create and manage Projects. In the +SaaS Startup Kit, projects represent the highest level of business value. Users can perform CRUD on project records. -One example business logic package is the one to create and manage Projects. In the SaaS Startup Kit, projects represent -the highest level of business value. Users can perform CRUD on project records. - -The web app includes this index page that lists all records. This index page then allows users to view, update and delete an object. +The web app includes this index page that lists all records for projects. This index page uses Datatables to demonstrate +providing advanced interactivity to HTML tables. This index page then allows users to view, update and delete an object. [![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-projects.png) -From the projects index page, users can click the button to create a new record. This create page demonstrates how a new record can be created for projects and also demonstrates inline validation. +From the projects index page, users can click the button to create a new record. This create page demonstrates how a new +record can be created for projects and also demonstrates inline validation. -The view page for an object displays the fields for the object as read-only. The page then includes links to edit or archive the object. The archive functionality demonstrates how a soft-delete can be performed. While the web app does not expose functionality to delete a record, the internal API does support the delete operation. +The view page for an object displays the fields for the object as read-only. The page then includes links to edit or +archive the object. The archive functionality demonstrates how a soft-delete can be performed. While the web app does +not expose functionality to delete a record, the internal API does support the delete operation. [![Golang web app object list and Go app object search](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-project-view.png) -You can easily modify the projects package to support your own requirements. If you were providing a software-as-a-service similar to Github, Projects could be changed to be 'repositories'. If you were providing software-as-a-service similar to Slack, Projects could be modified to be 'channels', etc. +You can easily modify the projects package to support your own requirements. If you were providing a software-as-a-service +similar to Github, Projects could be changed to be 'repositories'. If you were providing software-as-a-service similar +to Slack, Projects could be modified to be 'channels', etc. ### user (profile) -After users authenticate with the web app, there is example code for them to view their user details - view their profile. +After users authenticate with the web app, there is example code for them to view their user details (view their profile). [![Golang web app authentication and Go web app login](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-profile-view2.png) -A user can then update the details for the record of their user as another example demonstration the update operation. There -is also functionality for the user to change their password. +A user can then update the details for the record of their user. This another example demonstration the update operation. +There is also functionality for the user to change their password. ### account (management) -Once a user signups to your SaaS via the web app, an account is created. Authenticated users can then view the details -of their account (demonstrating the read operation of CRUD). +When a user signups to your SaaS via the web app, an account is created. Authenticated users can then view the details +of their account. [![Golang app account management and Go web app update account](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-account-update2.png) -Users with role of admin can view and update the details of their account, while non-admins can only view the details of their account. +Users with role of admin can view and update the details of their account, while non-admins can only view the details +of their account. ### users (management) + Users with role of admin have access to functionality that allows them to manage the users associated with their account. This index page uses Datatables to demonstrate providing advanced interactivity to HTML tables. @@ -132,11 +137,12 @@ a new record can be created for users. The create functionality also allows one [![Golang app create user and Go web app create user](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png)](https://dzuyel7n94hma.cloudfront.net/img/saas-startup-example-golang-project-webapp-users-create.png) -If the admin would rather the new users provide their own user details, there is Go code demonstrating how users can be invited. The invite functionality allows users to specifiy one or more email addresses. Once submitted, the web app will send email invites to allow the users to activate their user. +If the admin would rather the new users provide their own user details, there is Go code demonstrating how users can be +invited. The invite functionality allows users to specifiy one or more email addresses. Once submitted, the web app will +send email invites to allow the users to activate their user. -From the users index page, admins for an account can view users details. This page also provides access to update the user as well as archive it. - -A user can then update the details for the record of their user as another example demonstration the update operation. As part of ACL, the roles for a user can be added or removed. +From the users index page, admins for an account can view users details. This page also provides access to update the +user as well as archive it. ## Local Installation From f64fee69eb229bd172888fabe7c0b172995520ef Mon Sep 17 00:00:00 2001 From: jsign Date: Fri, 16 Aug 2019 16:44:35 -0300 Subject: [PATCH 18/21] web-app: README minor syntax improvements --- cmd/web-app/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/web-app/README.md b/cmd/web-app/README.md index 6022614..571446a 100644 --- a/cmd/web-app/README.md +++ b/cmd/web-app/README.md @@ -23,7 +23,7 @@ https://example.saasstartupkit.com The web app relies on the Golang business logic packages developed to provide an API for internal requests. -Once the web-app service is running it will be available on port 3000. +Once the web-app service is running, it will be available on port 3000. http://127.0.0.1:3000/ @@ -154,7 +154,8 @@ go build . ### Docker -To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory. +To build using the docker file, you need to be in the project root directory since the `Dockerfile` references +Go Modules that are located there. ```bash docker build -f cmd/web-app/Dockerfile -t saas-web-app . @@ -175,7 +176,7 @@ http://127.0.0.1:3000/signup?test-web-error=1 ### Localization Test a specific language by appending the locale to the request URL. -127.0.0.1:3000/signup?local=fr +http://127.0.0.1:3000/signup?local=fr [github.com/go-playground/validator](https://github.com/go-playground/validator) supports the following languages. From 248b84f97aec7c670a87c98fed172da5cc1840bd Mon Sep 17 00:00:00 2001 From: jsign Date: Sat, 17 Aug 2019 18:44:46 -0300 Subject: [PATCH 19/21] web-app: add information about Middleware and Routes in README.md --- cmd/web-app/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cmd/web-app/README.md b/cmd/web-app/README.md index 571446a..2c2a12a 100644 --- a/cmd/web-app/README.md +++ b/cmd/web-app/README.md @@ -187,6 +187,25 @@ http://127.0.0.1:3000/signup?local=fr - nl - Dutch - zh - Chinese +### HTTP Pipeline (Middleware) +In any production ready web application there're many concerns that should be handle it correctly such as: +* logging +* tracing +* error handling +* observability metrics +* security + +All these responsabilities are orthogonal between each other, and in particular, to the business logic. In `saas-starter-kit` these responsabilities are handeled in a chained set of middlewares which allow a clear separation of concerns and it avoids polluting business-rule code. + +We can separate existing middlewares in two dimensions: cross-cutting application middlewares, and middlewares for particular routes. Middlewares such as tracing, error handling, and metrics belong to the former category, whereas authentication/authorization to the latter. + +If you want to dig into the details regarding these configurations, refer to `handlers/routes.go` where you can find the application middleware chaining, and the particular middlewares per route when adding handlers with `app.Handle(...)`. + +### Routes +Every valid URL route can be found in `handlers/route.go`. + +Notice that every handler is grouped by business-context (`Projects`, `Users`, `Account`) compared to sharing a single big struct. This allows to limit the scope of action of handlers regarding other actions that are far from its reponsability, and facilitates testing since less mockups will be necessary to test the handlers. + ### Future Functionality From a990922eccad192c5a6ad676beff1185c4500992 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Tue, 20 Aug 2019 04:17:31 +0000 Subject: [PATCH 20/21] Apply suggestion to README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c8012e..4aac250 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,7 @@ business logic (business value) be developed in the same codebase - using the sa development in the same single repository. We believe this for two main reasons: 1. Lower barrier for and accelerate onboarding of new engineers developing the SaaS by making it easy for them to load a complete mental model of the codebase. -2. Minimize potential bottlenecks and eliminate complexities of coordinating development across repositories, with -potentially different teams responsible for the different repositories. +2. Minimize cross project/team coordination Once the SaaS product has gained market traction and the core set of functionality has been identified to achieve product-market fit, the functionality could be re-written with a language that would improve user experience or From ee3db2686a9ac908f19124ed9e573db558c8b803 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Mon, 19 Aug 2019 20:54:45 -0800 Subject: [PATCH 21/21] updated project readme --- README.md | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4aac250..390859b 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,8 @@ https://docs.google.com/presentation/d/1WGYqMZ-YUOaNxlZBfU4srpN8i86MU0ppWWSBb3pk When getting started building SaaS, we believe that is important for both the frontend web experience and the backend business logic (business value) be developed in the same codebase - using the same language for the frontend and backend development in the same single repository. We believe this for two main reasons: -1. Lower barrier for and accelerate onboarding of new engineers developing the SaaS by making it easy for them -to load a complete mental model of the codebase. -2. Minimize cross project/team coordination +1. Keeps the product codebase simple and thus easy to load complete mental model. +2. Minimize cross project/team coordination Once the SaaS product has gained market traction and the core set of functionality has been identified to achieve product-market fit, the functionality could be re-written with a language that would improve user experience or @@ -48,17 +47,17 @@ There are five areas of expertise that an engineer or engineering team must do f Based on our experience, a few core decisions were made for each of these areas that help you focus initially on building the business logic. 1. Micro level - The semantics that cover how data is defined, the relationships and how the data is being captured. This -project tries to minimize the connection between packages on the same horizontally later. Data models should not be part -of feature functionality. Hopefully these micro level decisions help prevent cases where 30K lines of code rely on a -single data model which makes simple one line changes potentially high risk. +project aims for packages to be developed distinct levels that are loosely coupled and highly cohesive. Data models +should not be part of feature functionality. It's easy for early products to be overly dependent on single models that +starts to introduce significant risk to product stability and slows development considerably. We want to avoid +situations were a 1 change can affect 30k lines of code. 2. Macro level - The architecture and its design provides basic project structure and the foundation for development. -This project provides a good set of examples that demonstrate where different types of code can reside. +This project provides a good set of examples for a variety of common product needs. 3. Business logic - The code for the business logic facilitates value generating activities for the business. This -project provides an example Golang package that helps illustrate the implementation of business logic and how it can be +project provides an example Golang package that helps illustrate how business logic can be implemented and delivered delivered to clients. -4. Deployment and Operations - Get the code to production! This sometimes can be a challenging task as it requires -a knowledge of a completely different expertise - DevOps. This project provides a complete continuous build pipeline that -will push the code to production with minimal effort using serverless deployments to AWS Fargate with GitLab CI/CD. +4. Deployment and Operations - Get the code to production! This usually requires an entirely separate expertise. +Instead a comprehensive CI pipeline is provided to create scaleable serverless infrastructure. 5. Observability - Ensure the code is running as expected in a remote environment. This project implements Datadog to facilitate exposing metrics, logs and request tracing to obverse and validate your services are stable and responsive for your clients (hopefully paying clients). @@ -147,7 +146,7 @@ have created this diagram below. Since it is very detailed, you can click on the ## Local Installation -Docker is required to run this project on your local machine. This project uses multiple third-party services that will +Docker is required to run this project on your local machine. This project uses multiple open-source services that will be hosted locally via Docker. * Postgres - Transactional database to handle persistence of all data. * Redis - Key / value storage for sessions and other data. Used only as ephemeral storage. @@ -211,7 +210,6 @@ following services will run: - web-api - web-app - postgres -- mysql ### Running the project @@ -296,15 +294,15 @@ docker-compose up --build -d web-app 2. Update references. ```bash flist=`grep -r "geeks-accelerator/oss/saas-starter-kit" * | awk -F ':' '{print $1}' | sort | uniq` -for f in $flist; do echo $f; sed -i "" -e "s#geeks-accelerator/oss/saas-starter-kit#geeks-accelerator/oss/aurora-cam#g" $f; done +for f in $flist; do echo $f; sed -i "" -e "s|geeks-accelerator/oss/saas-starter-kit|geeks-accelerator/oss/aurora-cam|g" $f; done flist=`grep -r "saas-starter-kit" * | awk -F ':' '{print $1}' | sort | uniq` -for f in $flist; do echo $f; sed -i "" -e "s#saas-starter-kit#aurora-cam#g" $f; done +for f in $flist; do echo $f; sed -i "" -e "s|saas-starter-kit|aurora-cam|g" $f; done flist=`grep -r "example-project" * | awk -F ':' '{print $1}' | sort | uniq` -for f in $flist; do echo $f; sed -i "" -e "s#example-project#aurora-cam#g" $f; done +for f in $flist; do echo $f; sed -i "" -e "s|example-project|aurora-cam|g" $f; done ``` @@ -546,7 +544,10 @@ shared=# \dt public | users | table | postgres public | users_accounts | table | postgres (5 rows) -``` +``` + +An alternative option would be to install [pgcli](https://www.pgcli.com/) locally on your machine and connect to the +database running inside the docker container. ## Deployment @@ -607,11 +608,6 @@ can set a single env variable. DD_EXPVAR=service_name=web-app env=dev url=http://web-app:4000/debug/vars|service_name=web-api env=dev url=http://web-api:4001/debug/vars ``` -### Postgres and future MySQL support - -Postgres is only supported based on its dependency of sqlxmigrate. MySQL should be easy to add to sqlxmigrate after -determining a better method for abstracting the create table and other SQL statements from the main testing logic. - ### SQLx bindvars When making new packages that use sqlx, bind vars for mysql are `?` where as postgres is `$1`. @@ -629,7 +625,7 @@ For additional details refer to [bindvars](https://jmoiron.github.io/sqlx/#bindv ## What's Next We are in the process of writing more documentation about this code. We welcome you to make enhancements to this -documentation or just send us your feedback and suggestions ; ) +documentation or just send us your feedback and suggestions :wink: ## Join us on Gopher Slack