1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-06-15 00:15:15 +02:00

Completed API documentation for swagger

This commit is contained in:
Lee Brown
2019-06-25 22:31:54 -08:00
parent 8328cf525b
commit d6b6b605a4
28 changed files with 4763 additions and 491 deletions

View File

@ -6,7 +6,11 @@ accelerator@geeksinthewoods.com.com
## Description ## Description
Service exposes a JSON api. Web API is a client facing API. Standard response format is JSON.
**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. This
separation should help decouple client integrations from internal application development.
## Local Installation ## Local Installation

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import (
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
) )
// Account represents the Account API method handler set. // Account represents the Account API method handler set.
@ -19,39 +20,19 @@ type Account struct {
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
} }
// List returns all the existing accounts in the system.
func (a *Account) 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")
}
var req account.AccountFindRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := account.Find(ctx, claims, a.MasterDB, req)
if err != nil {
return err
}
return web.RespondJson(ctx, w, res, http.StatusOK)
}
// Read godoc // Read godoc
// @Summary Read returns the specified account from the system. // @Summary Get account by ID
// @Description get string by ID // @Description Read returns the specified account from the system.
// @Tags account // @Tags account
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security OAuth2Password // @Security OAuth2Password
// @Param id path string true "Account ID" // @Param id path string true "Account ID"
// @Success 200 {object} account.Account // @Success 200 {object} account.AccountResponse
// @Header 200 {string} Token "qwerty" // @Failure 400 {object} web.ErrorResponse
// @Failure 400 {object} web.Error // @Failure 403 {object} web.ErrorResponse
// @Failure 403 {object} web.Error // @Failure 404 {object} web.ErrorResponse
// @Failure 404 {object} web.Error // @Failure 500 {object} web.ErrorResponse
// @Router /accounts/{id} [get] // @Router /accounts/{id} [get]
func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
@ -82,40 +63,23 @@ func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque
} }
} }
return web.RespondJson(ctx, w, res, http.StatusOK) return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK)
} }
// Create inserts a new account into the system. // Read godoc
func (a *Account) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { // @Summary Update account by ID
v, ok := ctx.Value(web.KeyValues).(*web.Values) // @Description Update updates the specified account in the system.
if !ok { // @Tags account
return web.NewShutdownError("web value missing from context") // @Accept json
} // @Produce json
// @Security OAuth2Password
claims, ok := ctx.Value(auth.Key).(auth.Claims) // @Param data body account.AccountUpdateRequest true "Update fields"
if !ok { // @Success 201
return errors.New("claims missing from context") // @Failure 400 {object} web.ErrorResponse
} // @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
var req account.AccountCreateRequest // @Failure 500 {object} web.ErrorResponse
if err := web.Decode(r, &req); err != nil { // @Router /accounts [patch]
return errors.Wrap(err, "")
}
res, err := account.Create(ctx, claims, a.MasterDB, req, v.Now)
if err != nil {
switch err {
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "User: %+v", &req)
}
}
return web.RespondJson(ctx, w, res, http.StatusCreated)
}
// Update updates the specified account in the system.
func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -129,9 +93,14 @@ func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
var req account.AccountUpdateRequest var req account.AccountUpdateRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
req.ID = params["id"]
err := account.Update(ctx, claims, a.MasterDB, req, v.Now) err := account.Update(ctx, claims, a.MasterDB, req, v.Now)
if err != nil { if err != nil {
@ -143,62 +112,14 @@ func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
case account.ErrForbidden: case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Id: %s Account: %+v", params["id"], &req) return errors.Wrapf(err, "Id: %s Account: %+v", params["id"], &req)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Archive soft-deletes the specified account from the system.
func (a *Account) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := account.Archive(ctx, claims, a.MasterDB, params["id"], v.Now)
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s", params["id"])
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Delete removes the specified account from the system.
func (a *Account) Delete(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")
}
err := account.Delete(ctx, claims, a.MasterDB, params["id"])
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s", params["id"])
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}

View File

@ -4,12 +4,14 @@ import (
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/project" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/project"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
) )
// Project represents the Project API method handler set. // Project represents the Project API method handler set.
@ -19,7 +21,23 @@ type Project struct {
// ADD OTHER STATE LIKE THE LOGGER IF NEEDED. // ADD OTHER STATE LIKE THE LOGGER IF NEEDED.
} }
// List returns all the existing projects in the system. // Find godoc
// @Summary List projects
// @Description Find returns the existing projects in the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param where query string false "Filter string, example: name = 'Moon Launch'"
// @Param order query string false "Order columns separated by comma, example: created_at desc"
// @Param limit query integer false "Limit, example: 10"
// @Param offset query integer false "Offset, example: 20"
// @Param included-archived query boolean false "Included Archived, example: false"
// @Success 200 {array} project.ProjectResponse
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /project [get]
func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
@ -27,8 +45,57 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque
} }
var req project.ProjectFindRequest var req project.ProjectFindRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") // Handle where query value if set.
if v := r.URL.Query().Get("where"); v != "" {
where, args, err := web.ExtractWhereArgs(v)
if err != nil {
return web.NewRequestError(err, http.StatusBadRequest)
}
req.Where = &where
req.Args = args
}
// Handle order query value if set.
if v := r.URL.Query().Get("order"); v != "" {
for _, o := range strings.Split(v, ",") {
o = strings.TrimSpace(o)
if o != "" {
req.Order = append(req.Order, o)
}
}
}
// Handle limit query value if set.
if v := r.URL.Query().Get("limit"); v != "" {
l, err := strconv.Atoi(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as int for limit param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
ul := uint(l)
req.Limit = &ul
}
// Handle offset query value if set.
if v := r.URL.Query().Get("offset"); v != "" {
l, err := strconv.Atoi(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as int for offset param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
ul := uint(l)
req.Limit = &ul
}
// Handle order query value if set.
if v := r.URL.Query().Get("included-archived"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as boolean for included-archived param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
req.IncludedArchived = b
} }
res, err := project.Find(ctx, claims, p.MasterDB, req) res, err := project.Find(ctx, claims, p.MasterDB, req)
@ -36,10 +103,28 @@ func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Reque
return err return err
} }
return web.RespondJson(ctx, w, res, http.StatusOK) var resp []*project.ProjectResponse
for _, m := range res {
resp = append(resp, m.Response(ctx))
}
return web.RespondJson(ctx, w, resp, http.StatusOK)
} }
// Read returns the specified project from the system. // Read godoc
// @Summary Get project by ID.
// @Description Read returns the specified project from the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param id path string true "Project ID"
// @Success 200 {object} project.ProjectResponse
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /projects/{id} [get]
func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
@ -69,10 +154,23 @@ func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Reque
} }
} }
return web.RespondJson(ctx, w, res, http.StatusOK) return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK)
} }
// Create inserts a new project into the system. // Create godoc
// @Summary Create new project.
// @Description Create inserts a new project into the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body project.ProjectCreateRequest true "Project details"
// @Success 200 {object} project.ProjectResponse
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /projects [post]
func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -86,7 +184,13 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req
var req project.ProjectCreateRequest var req project.ProjectCreateRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
res, err := project.Create(ctx, claims, p.MasterDB, req, v.Now) res, err := project.Create(ctx, claims, p.MasterDB, req, v.Now)
@ -95,14 +199,31 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req
case project.ErrForbidden: case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Project: %+v", &req) return errors.Wrapf(err, "Project: %+v", &req)
} }
} }
return web.RespondJson(ctx, w, res, http.StatusCreated) return web.RespondJson(ctx, w, res.Response(ctx), http.StatusCreated)
} }
// Update updates the specified project in the system. // Read godoc
// @Summary Update project by ID
// @Description Update updates the specified project in the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body project.ProjectUpdateRequest true "Update fields"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /projects [patch]
func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -116,9 +237,14 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
var req project.ProjectUpdateRequest var req project.ProjectUpdateRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
req.ID = params["id"]
err := project.Update(ctx, claims, p.MasterDB, req, v.Now) err := project.Update(ctx, claims, p.MasterDB, req, v.Now)
if err != nil { if err != nil {
@ -130,14 +256,31 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
case project.ErrForbidden: case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "ID: %s Update: %+v", params["id"], req) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "ID: %s Update: %+v", req.ID, req)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Archive soft-deletes the specified project from the system. // Read godoc
// @Summary Archive project by ID
// @Description Archive soft-deletes the specified project from the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body project.ProjectArchiveRequest true "Update fields"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /projects/archive [patch]
func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -149,7 +292,18 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re
return errors.New("claims missing from context") return errors.New("claims missing from context")
} }
err := project.Archive(ctx, claims, p.MasterDB, params["id"], v.Now) var req project.ProjectArchiveRequest
if err := web.Decode(r, &req); err != nil {
err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
}
err := project.Archive(ctx, claims, p.MasterDB, req, v.Now)
if err != nil { if err != nil {
switch err { switch err {
case project.ErrInvalidID: case project.ErrInvalidID:
@ -159,14 +313,32 @@ func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Re
case project.ErrForbidden: case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "Id: %s", params["id"]) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Id: %s", req.ID)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Delete removes the specified project from the system. // Delete godoc
// @Summary Delete project by ID
// @Description Delete removes the specified project from the system.
// @Tags project
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param id path string true "Project ID"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /projects/{id} [delete]
func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {

View File

@ -30,14 +30,15 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
MasterDB: masterDB, MasterDB: masterDB,
TokenGenerator: authenticator, TokenGenerator: authenticator,
} }
app.Handle("GET", "/v1/users", u.Find, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/users", u.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/v1/users/:id", u.Read, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/users/:id", u.Read, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/users/:id", u.Update, mid.Authenticate(authenticator)) app.Handle("PATCH", "/v1/users", u.Update, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/users/:id/password", u.UpdatePassword, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/users/:id/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/users/switch-account/:accountId", u.SwitchAccount, mid.Authenticate(authenticator)) app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.Authenticate(authenticator))
// This route is not authenticated // This route is not authenticated
app.Handle("POST", "/v1/oauth/token", u.Token) app.Handle("POST", "/v1/oauth/token", u.Token)
@ -46,12 +47,8 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
a := Account{ a := Account{
MasterDB: masterDB, MasterDB: masterDB,
} }
app.Handle("GET", "/v1/accounts", a.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/accounts", a.Create, mid.Authenticate(authenticator))
app.Handle("GET", "/v1/accounts/:id", a.Read, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/accounts/:id", a.Read, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/accounts/:id", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/accounts", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/accounts/:id/archive", a.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/accounts/:id", a.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
// Register signup endpoints. // Register signup endpoints.
s := Signup{ s := Signup{
@ -66,8 +63,8 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
app.Handle("GET", "/v1/projects", p.Find, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/projects", p.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/v1/projects/:id", p.Read, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/projects/:id", p.Read, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/projects/:id", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/projects", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/projects/:id/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
// Register swagger documentation. // Register swagger documentation.
@ -78,3 +75,12 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
return app return app
} }
// Types godoc
// @Summary List of types.
// @Param data body web.FieldError false "Field Error"
// @Param data body web.TimeResponse false "Time Response"
// @Param data body web.EnumResponse false "Enum Response"
// @Param data body web.EnumOption false "Enum Option"
// To support nested types not parsed by swag.
func Types() {}

View File

@ -2,13 +2,15 @@ package handlers
import ( import (
"context" "context"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/account" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/account"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/signup" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/signup"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors" "github.com/pkg/errors"
"net/http" "gopkg.in/go-playground/validator.v9"
) )
// Signup represents the Signup API method handler set. // Signup represents the Signup API method handler set.
@ -26,9 +28,9 @@ type Signup struct {
// @Produce json // @Produce json
// @Param data body signup.SignupRequest true "Signup details" // @Param data body signup.SignupRequest true "Signup details"
// @Success 200 {object} signup.SignupResponse // @Success 200 {object} signup.SignupResponse
// @Header 200 {string} Token "qwerty" // @Failure 400 {object} web.ErrorResponse
// @Failure 400 {object} web.Error // @Failure 403 {object} web.ErrorResponse
// @Failure 403 {object} web.Error // @Failure 500 {object} web.ErrorResponse
// @Router /signup [post] // @Router /signup [post]
func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
@ -41,7 +43,13 @@ func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Requ
var req signup.SignupRequest var req signup.SignupRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
res, err := signup.Signup(ctx, claims, c.MasterDB, req, v.Now) res, err := signup.Signup(ctx, claims, c.MasterDB, req, v.Now)
@ -50,7 +58,12 @@ func (c *Signup) Signup(ctx context.Context, w http.ResponseWriter, r *http.Requ
case account.ErrForbidden: case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "User: %+v", &req) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Signup: %+v", &req)
} }
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
@ -11,6 +12,7 @@ import (
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/user" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/user"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
) )
// sessionTtl defines the auth token expiration. // sessionTtl defines the auth token expiration.
@ -24,7 +26,23 @@ type User struct {
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
} }
// List returns all the existing users in the system. // Find godoc
// @Summary List users
// @Description Find returns the existing users in the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param where query string false "Filter string, example: name = 'Company Name' and email = 'gabi.may@geeksinthewoods.com'"
// @Param order query string false "Order columns separated by comma, example: created_at desc"
// @Param limit query integer false "Limit, example: 10"
// @Param offset query integer false "Offset, example: 20"
// @Param included-archived query boolean false "Included Archived, example: false"
// @Success 200 {array} user.UserResponse
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users [get]
func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
@ -32,8 +50,67 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request,
} }
var req user.UserFindRequest var req user.UserFindRequest
// Handle where query value if set.
if v := r.URL.Query().Get("where"); v != "" {
where, args, err := web.ExtractWhereArgs(v)
if err != nil {
return web.NewRequestError(err, http.StatusBadRequest)
}
req.Where = &where
req.Args = args
}
// Handle order query value if set.
if v := r.URL.Query().Get("order"); v != "" {
for _, o := range strings.Split(v, ",") {
o = strings.TrimSpace(o)
if o != "" {
req.Order = append(req.Order, o)
}
}
}
// Handle limit query value if set.
if v := r.URL.Query().Get("limit"); v != "" {
l, err := strconv.Atoi(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as int for limit param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
ul := uint(l)
req.Limit = &ul
}
// Handle offset query value if set.
if v := r.URL.Query().Get("offset"); v != "" {
l, err := strconv.Atoi(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as int for offset param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
ul := uint(l)
req.Limit = &ul
}
// Handle order query value if set.
if v := r.URL.Query().Get("included-archived"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
err = errors.WithMessagef(err, "unable to parse %s as boolean for included-archived param", v)
return web.NewRequestError(err, http.StatusBadRequest)
}
req.IncludedArchived = b
}
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
res, err := user.Find(ctx, claims, u.MasterDB, req) res, err := user.Find(ctx, claims, u.MasterDB, req)
@ -41,22 +118,27 @@ func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request,
return err return err
} }
return web.RespondJson(ctx, w, res, http.StatusOK) var resp []*user.UserResponse
for _, m := range res {
resp = append(resp, m.Response(ctx))
}
return web.RespondJson(ctx, w, resp, http.StatusOK)
} }
// Read godoc // Read godoc
// @Summary Read returns the specified user from the system. // @Summary Get user by ID
// @Description get string by ID // @Description Read returns the specified user from the system.
// @Tags user // @Tags user
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security OAuth2Password // @Security OAuth2Password
// @Param id path string true "User ID" // @Param id path string true "User ID"
// @Success 200 {object} user.User // @Success 200 {object} user.UserResponse
// @Header 200 {string} Token "qwerty" // @Failure 400 {object} web.ErrorResponse
// @Failure 400 {object} web.Error // @Failure 403 {object} web.ErrorResponse
// @Failure 403 {object} web.Error // @Failure 404 {object} web.ErrorResponse
// @Failure 404 {object} web.Error // @Failure 500 {object} web.ErrorResponse
// @Router /users/{id} [get] // @Router /users/{id} [get]
func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
@ -83,14 +165,32 @@ func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request,
case user.ErrForbidden: case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "ID: %s", params["id"]) return errors.Wrapf(err, "ID: %s", params["id"])
} }
} }
return web.RespondJson(ctx, w, res, http.StatusOK) return web.RespondJson(ctx, w, res.Response(ctx), http.StatusOK)
} }
// Create inserts a new user into the system. // Create godoc
// @Summary Create new user.
// @Description Create inserts a new user into the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body user.UserCreateRequest true "User details"
// @Success 200 {object} user.UserResponse
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users [post]
func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -104,7 +204,13 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques
var req user.UserCreateRequest var req user.UserCreateRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
res, err := user.Create(ctx, claims, u.MasterDB, req, v.Now) res, err := user.Create(ctx, claims, u.MasterDB, req, v.Now)
@ -113,14 +219,32 @@ func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Reques
case user.ErrForbidden: case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "User: %+v", &req) return errors.Wrapf(err, "User: %+v", &req)
} }
} }
return web.RespondJson(ctx, w, res, http.StatusCreated) return web.RespondJson(ctx, w, res.Response(ctx), http.StatusCreated)
} }
// Update updates the specified user in the system. // Read godoc
// @Summary Update user by ID
// @Description Update updates the specified user in the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body user.UserUpdateRequest true "Update fields"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users [patch]
func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -134,9 +258,14 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
var req user.UserUpdateRequest var req user.UserUpdateRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
req.ID = params["id"]
err := user.Update(ctx, claims, u.MasterDB, req, v.Now) err := user.Update(ctx, claims, u.MasterDB, req, v.Now)
if err != nil { if err != nil {
@ -148,14 +277,32 @@ func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Reques
case user.ErrForbidden: case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Id: %s User: %+v", req.ID, &req)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Update updates the password for a specified user in the system. // Read godoc
// @Summary Update user password by ID
// @Description Update updates the password for a specified user in the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body user.UserUpdatePasswordRequest true "Update fields"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users/password [patch]
func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -169,9 +316,14 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt
var req user.UserUpdatePasswordRequest var req user.UserUpdatePasswordRequest
if err := web.Decode(r, &req); err != nil { if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
} }
req.ID = params["id"]
err := user.UpdatePassword(ctx, claims, u.MasterDB, req, v.Now) err := user.UpdatePassword(ctx, claims, u.MasterDB, req, v.Now)
if err != nil { if err != nil {
@ -183,14 +335,32 @@ func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *htt
case user.ErrForbidden: case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Id: %s User: %+v", req.ID, &req)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Archive soft-deletes the specified user from the system. // Read godoc
// @Summary Archive user by ID
// @Description Archive soft-deletes the specified user from the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param data body user.UserArchiveRequest true "Update fields"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users/archive [patch]
func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -202,7 +372,18 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque
return errors.New("claims missing from context") return errors.New("claims missing from context")
} }
err := user.Archive(ctx, claims, u.MasterDB, params["id"], v.Now) var req user.UserArchiveRequest
if err := web.Decode(r, &req); err != nil {
err = errors.WithStack(err)
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return err
}
err := user.Archive(ctx, claims, u.MasterDB, req, v.Now)
if err != nil { if err != nil {
switch err { switch err {
case user.ErrInvalidID: case user.ErrInvalidID:
@ -212,14 +393,32 @@ func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Reque
case user.ErrForbidden: case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden) return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "Id: %s", params["id"]) _, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrapf(err, "Id: %s", req.ID)
} }
} }
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// Delete removes the specified user from the system. // Delete godoc
// @Summary Delete user by ID
// @Description Delete removes the specified user from the system.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param id path string true "User ID"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.ErrorResponse
// @Router /users/{id} [delete]
func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
@ -243,7 +442,20 @@ func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Reques
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// SwitchAccount updates the claims. // SwitchAccount godoc
// @Summary Switch account.
// @Description SwitchAccount updates the auth claims to a new account.
// @Tags user
// @Accept json
// @Produce json
// @Security OAuth2Password
// @Param account_id path int true "Account ID"
// @Success 201
// @Failure 400 {object} web.ErrorResponse
// @Failure 403 {object} web.ErrorResponse
// @Failure 404 {object} web.ErrorResponse
// @Failure 500 {object} web.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 (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values) v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok { if !ok {
@ -255,12 +467,17 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
return errors.New("claims missing from context") return errors.New("claims missing from context")
} }
tkn, err := user.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, params["accountId"], sessionTtl, v.Now) tkn, err := user.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, params["account_id"], sessionTtl, v.Now)
if err != nil { if err != nil {
switch err { switch err {
case user.ErrAuthenticationFailure: case user.ErrAuthenticationFailure:
return web.NewRequestError(err, http.StatusUnauthorized) return web.NewRequestError(err, http.StatusUnauthorized)
default: default:
_, ok := err.(validator.ValidationErrors)
if ok {
return web.NewRequestError(err, http.StatusBadRequest)
}
return errors.Wrap(err, "switch account") return errors.Wrap(err, "switch account")
} }
} }
@ -275,6 +492,7 @@ func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BasicAuth // @Security BasicAuth
// @Param scope query string false "Scope" Enums(user, admin)
// @Success 200 {object} user.Token // @Success 200 {object} user.Token
// @Header 200 {string} Token "qwerty" // @Header 200 {string} Token "qwerty"
// @Failure 400 {object} web.Error // @Failure 400 {object} web.Error
@ -293,7 +511,10 @@ func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request
return web.NewRequestError(err, http.StatusUnauthorized) return web.NewRequestError(err, http.StatusUnauthorized)
} }
tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, sessionTtl, v.Now) // Optional to include scope.
scope := r.URL.Query().Get("scope")
tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, sessionTtl, v.Now, scope)
if err != nil { if err != nil {
switch err { switch err {
case user.ErrAuthenticationFailure: case user.ErrAuthenticationFailure:

View File

@ -55,9 +55,8 @@ var service = "WEB_API"
// @securitydefinitions.oauth2.password OAuth2Password // @securitydefinitions.oauth2.password OAuth2Password
// @tokenUrl /v1/oauth/token // @tokenUrl /v1/oauth/token
// @scope.read Grants read access // @scope.user Grants basic privileges with role of user.
// @scope.write Grants write access // @scope.admin Grants administrative privileges with role of admin.
// @scope.admin Grants read and write access to administrative information
func main() { func main() {

View File

@ -36,9 +36,9 @@ require (
github.com/swaggo/swag v1.5.1 github.com/swaggo/swag v1.5.1
github.com/tinylib/msgp v1.1.0 // indirect github.com/tinylib/msgp v1.1.0 // indirect
github.com/urfave/cli v1.20.0 github.com/urfave/cli v1.20.0
github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 // indirect golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 // indirect
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 // indirect golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 // indirect
google.golang.org/appengine v1.6.0 // indirect google.golang.org/appengine v1.6.0 // indirect

View File

@ -1,4 +1,3 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@ -123,6 +122,8 @@ github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 h1:zzrxE1FKn5ryBNl9eKOeqQ58Y/Qpo3Q9QNxKHX5uzzQ=
github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2/go.mod h1:hzfGeIUDq/j97IG+FhNqkowIyEcD88LrW6fyU3K3WqY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A=
@ -131,16 +132,12 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmy
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -159,7 +156,6 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/DataDog/dd-trace-go.v1 v1.15.0 h1:2LhklnAJsRSelbnBrrE5QuRleRDkmOh2JWxOtIX6yec= gopkg.in/DataDog/dd-trace-go.v1 v1.15.0 h1:2LhklnAJsRSelbnBrrE5QuRleRDkmOh2JWxOtIX6yec=

View File

@ -481,17 +481,18 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun
return nil return nil
} }
// Archive soft deleted the account from the database. // Archive soft deleted the account by ID from the database.
func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string, now time.Time) error { func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID string, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Archive") req := AccountArchiveRequest{
defer span.Finish()
// Defines the struct to apply validation
req := struct {
ID string `validate:"required,uuid"`
}{
ID: accountID, ID: accountID,
} }
return Archive(ctx, claims, dbConn, req, now)
}
// 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 {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Archive")
defer span.Finish()
// Validate the request. // Validate the request.
err := validator.New().Struct(req) err := validator.New().Struct(req)

View File

@ -792,7 +792,7 @@ func TestCrud(t *testing.T) {
} }
// Archive (soft-delete) the account. // Archive (soft-delete) the account.
err = Archive(ctx, tt.claims(account, userId), test.MasterDB, account.ID, now) err = ArchiveById(ctx, tt.claims(account, userId), test.MasterDB, account.ID, now)
if err != nil && errors.Cause(err) != tt.updateErr { if err != nil && errors.Cause(err) != tt.updateErr {
t.Logf("\t\tGot : %+v", err) t.Logf("\t\tGot : %+v", err)
t.Logf("\t\tWant: %+v", tt.updateErr) t.Logf("\t\tWant: %+v", tt.updateErr)

View File

@ -1,8 +1,10 @@
package account package account
import ( import (
"context"
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"time" "time"
"github.com/lib/pq" "github.com/lib/pq"
@ -12,6 +14,25 @@ import (
// Account represents someone with access to our system. // Account represents someone with access to our system.
type Account struct { type Account struct {
ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name string `json:"name" validate:"required,unique" example:"Company Name"`
Address1 string `json:"address1" validate:"required" example:"221 Tatitlek Ave"`
Address2 string `json:"address2" validate:"omitempty" example:"Box #1832"`
City string `json:"city" validate:"required" example:"Valdez"`
Region string `json:"region" validate:"required" example:"AK"`
Country string `json:"country" validate:"required" example:"USA"`
Zipcode string `json:"zipcode" validate:"required" example:"99686"`
Status AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"`
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
SignupUserID *sql.NullString `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
BillingUserID *sql.NullString `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
}
// AccountResponse represents someone with access to our system that is returned for display.
type AccountResponse struct {
ID string `json:"id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` ID string `json:"id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name string `json:"name" example:"Company Name"` Name string `json:"name" example:"Company Name"`
Address1 string `json:"address1" example:"221 Tatitlek Ave"` Address1 string `json:"address1" example:"221 Tatitlek Ave"`
@ -20,13 +41,50 @@ type Account struct {
Region string `json:"region" example:"AK"` Region string `json:"region" example:"AK"`
Country string `json:"country" example:"USA"` Country string `json:"country" example:"USA"`
Zipcode string `json:"zipcode" example:"99686"` Zipcode string `json:"zipcode" example:"99686"`
Status AccountStatus `json:"status" swaggertype:"string" example:"active"` Status web.EnumResponse `json:"status"` // Status is enum with values [active, pending, disabled].
Timezone string `json:"timezone" example:"America/Anchorage"` Timezone string `json:"timezone" example:"America/Anchorage"`
SignupUserID *sql.NullString `json:"signup_user_id,omitempty" swaggertype:"string"` SignupUserID *string `json:"signup_user_id,omitempty" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
BillingUserID *sql.NullString `json:"billing_user_id,omitempty" swaggertype:"string"` BillingUserID *string `json:"billing_user_id,omitempty" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
CreatedAt time.Time `json:"created_at"` CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display.
UpdatedAt time.Time `json:"updated_at"` UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display.
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display.
}
// Response transforms Account and AccountResponse that is used for display.
// Additional filtering by context values or translations could be applied.
func (m *Account) Response(ctx context.Context) *AccountResponse {
if m == nil {
return nil
}
r := &AccountResponse{
ID: m.ID,
Name: m.Name,
Address1: m.Address1,
Address2: m.Address2,
City: m.City,
Region: m.Region,
Country: m.Country,
Zipcode: m.Zipcode,
Timezone: m.Timezone,
Status: web.NewEnumResponse(ctx, m.Status, AccountStatus_Values),
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
}
if m.SignupUserID != nil {
r.SignupUserID = &m.SignupUserID.String
}
if m.BillingUserID != nil {
r.BillingUserID = &m.BillingUserID.String
}
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
r.ArchivedAt = &at
}
return r
} }
// AccountCreateRequest contains information needed to create a new Account. // AccountCreateRequest contains information needed to create a new Account.
@ -40,8 +98,8 @@ type AccountCreateRequest struct {
Zipcode string `json:"zipcode" validate:"required" example:"99686"` Zipcode string `json:"zipcode" validate:"required" example:"99686"`
Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"` Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"active"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
} }
// AccountUpdateRequest defines what information may be provided to modify an existing // AccountUpdateRequest defines what information may be provided to modify an existing
@ -51,29 +109,35 @@ type AccountCreateRequest struct {
// we do not want to use pointers to basic types but we make exceptions around // we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling. // marshalling/unmarshalling.
type AccountUpdateRequest struct { type AccountUpdateRequest struct {
ID string `json:"id" validate:"required,uuid"` ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name *string `json:"name,omitempty" validate:"omitempty,unique"` Name *string `json:"name,omitempty" validate:"omitempty,unique" example:"Company Name"`
Address1 *string `json:"address1,omitempty" validate:"omitempty"` Address1 *string `json:"address1,omitempty" validate:"omitempty" example:"221 Tatitlek Ave"`
Address2 *string `json:"address2,omitempty" validate:"omitempty"` Address2 *string `json:"address2,omitempty" validate:"omitempty" example:"Box #1832"`
City *string `json:"city,omitempty" validate:"omitempty"` City *string `json:"city,omitempty" validate:"omitempty" example:"Valdez"`
Region *string `json:"region,omitempty" validate:"omitempty"` Region *string `json:"region,omitempty" validate:"omitempty" example:"AK"`
Country *string `json:"country,omitempty" validate:"omitempty"` Country *string `json:"country,omitempty" validate:"omitempty" example:"USA"`
Zipcode *string `json:"zipcode,omitempty" validate:"omitempty"` Zipcode *string `json:"zipcode,omitempty" validate:"omitempty" example:"99686"`
Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled"` Status *AccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active pending disabled" swaggertype:"string" enums:"active,pending,disabled" example:"disabled"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` SignupUserID *string `json:"signup_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string"` BillingUserID *string `json:"billing_user_id,omitempty" validate:"omitempty,uuid" swaggertype:"string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
}
// AccountArchiveRequest defines the information needed to archive an account. This will archive (soft-delete) the
// existing database entry.
type AccountArchiveRequest struct {
ID string `json:"id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
} }
// AccountFindRequest defines the possible options to search for accounts. By default // AccountFindRequest defines the possible options to search for accounts. By default
// archived accounts will be excluded from response. // archived accounts will be excluded from response.
type AccountFindRequest struct { type AccountFindRequest struct {
Where *string `json:"where"` Where *string `json:"where" example:"name = ? and status = ?"`
Args []interface{} `json:"args" swaggertype:"array,string"` Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,active"`
Order []string `json:"order"` Order []string `json:"order" example:"created_at desc"`
Limit *uint `json:"limit"` Limit *uint `json:"limit" example:"10"`
Offset *uint `json:"offset"` Offset *uint `json:"offset" example:"20"`
IncludedArchived bool `json:"included-archived"` IncludedArchived bool `json:"included-archived" example:"false"`
} }
// AccountStatus represents the status of an account. // AccountStatus represents the status of an account.

View File

@ -24,13 +24,15 @@ const Key ctxKey = 1
type Claims struct { type Claims struct {
AccountIds []string `json:"accounts"` AccountIds []string `json:"accounts"`
Roles []string `json:"roles"` Roles []string `json:"roles"`
Timezone string `json:"timezone"`
tz *time.Location
jwt.StandardClaims jwt.StandardClaims
} }
// NewClaims constructs a Claims value for the identified user. The Claims // NewClaims constructs a Claims value for the identified user. The Claims
// expire within a specified duration of the provided time. Additional fields // expire within a specified duration of the provided time. Additional fields
// of the Claims can be set after calling NewClaims is desired. // of the Claims can be set after calling NewClaims is desired.
func NewClaims(userId, accountId string, accountIds []string, roles []string, now time.Time, expires time.Duration) Claims { func NewClaims(userId, accountId string, accountIds []string, roles []string, userTimezone *time.Location, now time.Time, expires time.Duration) Claims {
c := Claims{ c := Claims{
AccountIds: accountIds, AccountIds: accountIds,
Roles: roles, Roles: roles,
@ -42,6 +44,10 @@ func NewClaims(userId, accountId string, accountIds []string, roles []string, no
}, },
} }
if userTimezone != nil {
c.Timezone = userTimezone.String()
}
return c return c
} }
@ -71,3 +77,10 @@ func (c Claims) HasRole(roles ...string) bool {
} }
return false return false
} }
func (c Claims) TimeLocation() *time.Location {
if c.tz == nil && c.Timezone != "" {
c.tz, _ = time.LoadLocation(c.Timezone)
}
return c.tz
}

View File

@ -0,0 +1,16 @@
package session
import (
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
)
// ctxKey represents the type of value for the context key.
type ctxKey int
// Key is used to store/retrieve a Claims value from a context.Context.
const Key ctxKey = 1
// Session represents a user with authentication.
type Session struct {
Claims auth.Claims `json:"claims"`
}

View File

@ -0,0 +1,99 @@
package web
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"github.com/dustin/go-humanize"
"strings"
"time"
)
const DatetimeFormatLocal = "Mon Jan _2 3:04PM"
const DateFormatLocal = "Mon Jan _2"
// TimeResponse is a response friendly format for displaying the value of a time.
type TimeResponse struct {
Value time.Time `json:"value" example:"2019-06-25T03:00:53.284-08:00"`
ValueUTC time.Time `json:"value_utc" example:"2019-06-25T11:00:53.284Z"`
Date string `json:"date" example:"2019-06-25"`
Time string `json:"time" example:"03:00:53"`
Kitchen string `json:"kitchen" example:"3:00AM"`
RFC1123 string `json:"rfc1123" example:"Tue, 25 Jun 2019 03:00:53 AKDT"`
Local string `json:"local" example:"Tue Jun 25 3:00AM"`
LocalDate string `json:"local_date" example:"Tue Jun 25"`
NowTime string `json:"now_time" example:"5 hours ago"`
NowRelTime string `json:"now_rel_time" example:"15 hours from now"`
Timezone string `json:"timezone" example:"America/Anchorage"`
}
// NewTimeResponse parses the time to the timezone location set in context and
// returns the display friendly format as TimeResponse.
func NewTimeResponse(ctx context.Context, t time.Time) TimeResponse {
// If the context has claims, check to see if timezone is set for the current user and
// then format the input time in that timezone if set.
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if ok && claims.TimeLocation() != nil {
t = t.In(claims.TimeLocation())
}
tr := TimeResponse{
Value: t,
ValueUTC: t.UTC(),
Date: t.Format("2006-01-02"),
Time: t.Format("15:04:05"),
Kitchen: t.Format(time.Kitchen),
RFC1123: t.Format(time.RFC1123),
Local: t.Format(DatetimeFormatLocal),
LocalDate: t.Format(DateFormatLocal),
NowTime: humanize.Time(t.UTC()),
NowRelTime: humanize.RelTime(time.Now().UTC(), t.UTC(), "ago", "from now"),
}
if t.Location() != nil {
tr.Timezone = t.Location().String()
}
return tr
}
// EnumOption represents a single value of an enum option.
type EnumOption struct {
Value string `json:"value" example:"active_etc"`
Title string `json:"title" example:"Active Etc"`
Selected bool `json:"selected" example:"true"`
}
// EnumResponse is a response friendly format for displaying an enum value that
// includes a list of all possible values.
type EnumResponse struct {
Value string `json:"value" example:"active_etc"`
Title string `json:"title" example:"Active Etc"`
Options []EnumOption `json:"options,omitempty"`
}
// NewEnumResponse returns a display friendly format for a enum field.
func NewEnumResponse(ctx context.Context, value interface{}, options ...interface{}) EnumResponse {
er := EnumResponse{
Value: fmt.Sprintf("%s", value),
Title: EnumValueTitle(fmt.Sprintf("%s", value)),
}
for _, opt := range options {
optStr := fmt.Sprintf("%s", opt)
er.Options = append(er.Options, EnumOption{
Value: optStr,
Title: EnumValueTitle(optStr),
Selected: (value == opt),
})
}
return er
}
// EnumValueTitle formats a string value for display.
func EnumValueTitle(v string) string {
v = strings.Replace(v, "_", " ", -1)
return strings.Title(v)
}

View File

@ -2,7 +2,6 @@ package web
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"reflect" "reflect"
"strings" "strings"
@ -10,6 +9,9 @@ import (
"github.com/go-playground/locales/en" "github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator" ut "github.com/go-playground/universal-translator"
"github.com/gorilla/schema" "github.com/gorilla/schema"
"github.com/pkg/errors"
"github.com/xwb1989/sqlparser"
"github.com/xwb1989/sqlparser/dependency/querypb"
"gopkg.in/go-playground/validator.v9" "gopkg.in/go-playground/validator.v9"
en_translations "gopkg.in/go-playground/validator.v9/translations/en" en_translations "gopkg.in/go-playground/validator.v9/translations/en"
) )
@ -97,3 +99,43 @@ func Decode(r *http.Request, val interface{}) error {
return nil return nil
} }
// ExtractWhereArgs extracts the sql args from where. This allows requests to accept sql queries for filters and
// then replaces the raw values with placeholders. The resulting query will then be executed with bind vars.
func ExtractWhereArgs(where string) (string, []interface{}, error) {
// Create a full select sql query.
query := "select `t` from test where " + where
// Parse the query.
stmt, err := sqlparser.Parse(query)
if err != nil {
return "", nil, errors.WithMessagef(err, "Failed to parse query - %s", where)
}
// Normalize changes the query statement to use bind values, and updates the bind vars to those values. The
// supplied prefix is used to generate the bind var names.
bindVars := make(map[string]*querypb.BindVariable)
sqlparser.Normalize(stmt, bindVars, "redacted")
// Loop through all the bind vars and append to the response args list.
var vals []interface{}
for _, bv := range bindVars {
if bv.Values != nil {
var l []interface{}
for _, v := range bv.Values {
l = append(l, string(v.Value))
}
vals = append(vals, l)
} else {
vals = append(vals, string(bv.Value))
}
}
// Update the original query to include the redacted values.
query = sqlparser.String(stmt)
// Parse out the updated where.
where = strings.Split(query, " where ")[1]
return where, vals, nil
}

View File

@ -0,0 +1,64 @@
package web
import (
"github.com/google/go-cmp/cmp"
"testing"
)
func TestExtractWhereArgs(t *testing.T) {
var queryTests = []struct {
where string
redacted string
args []interface{}
}{
{
"name = 'xxxx' or name = :test",
"name = :redacted1 or name = :test",
[]interface{}{"xxxx"},
},
{
"name = 'xxxx' or name is null",
"name = :redacted1 or name is null",
[]interface{}{"xxxx"},
},
{
"name = 'xxxx' or name in ('yyyy', 'zzzz')",
"name = :redacted1 or name in ::redacted2",
[]interface{}{"xxxx", []interface{}{"yyyy", "zzzz"}},
},
{
"id = 3232 or id in (2323, 3239, 483484)",
"id = :redacted1 or id in ::redacted2",
[]interface{}{"3232", []interface{}{"2323", "3239", "483484"}},
},
}
t.Log("Given the need to ensure values are correctly extracted from a where query string.")
{
for i, tt := range queryTests {
t.Logf("\tTest: %d\tWhen running test: #%d", i, i)
{
res, args, err := ExtractWhereArgs(tt.where)
if err != nil {
t.Log("\t\tGot :", err)
t.Fatalf("\t\tExtract failed.")
}
if res != tt.redacted {
t.Logf("\t\tGot : %+v", res)
t.Logf("\t\tWant: %+v", tt.redacted)
t.Fatalf("\t\tResulting where does not match expected.")
}
if diff := cmp.Diff(tt.args, args); diff != "" {
t.Logf("\t\tGot : %+v", args)
t.Logf("\t\tWant: %+v", tt.args)
t.Fatalf("\t\tResulting args does not match expected. Diff:\n%s", diff)
}
t.Logf("\t\tOk.")
}
}
}
}

View File

@ -1,7 +1,9 @@
package project package project
import ( import (
"context"
"database/sql/driver" "database/sql/driver"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9" "gopkg.in/go-playground/validator.v9"
@ -12,18 +14,53 @@ import (
type Project struct { type Project struct {
ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"` ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"`
AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create"` AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required" example:"Rocket Launch"`
Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` Status ProjectStatus `json:"status" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"active"`
CreatedAt time.Time `json:"created_at" truss:"api-read"` CreatedAt time.Time `json:"created_at" truss:"api-read"`
UpdatedAt time.Time `json:"updated_at" truss:"api-read"` UpdatedAt time.Time `json:"updated_at" truss:"api-read"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty" truss:"api-hide"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty" truss:"api-hide"`
} }
// ProjectResponse represents a workflow that is returned for display.
type ProjectResponse struct {
ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"`
AccountID string `json:"account_id" validate:"required,uuid" truss:"api-create" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name string `json:"name" validate:"required" example:"Rocket Launch"`
Status web.EnumResponse `json:"status"` // Status is enum with values [active, disabled].
CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display.
UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display.
ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display.
}
// Response transforms Project and ProjectResponse that is used for display.
// Additional filtering by context values or translations could be applied.
func (m *Project) Response(ctx context.Context) *ProjectResponse {
if m == nil {
return nil
}
r := &ProjectResponse{
ID: m.ID,
AccountID: m.AccountID,
Name: m.Name,
Status: web.NewEnumResponse(ctx, m.Status, ProjectStatus_Values),
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
}
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
r.ArchivedAt = &at
}
return r
}
// ProjectCreateRequest contains information needed to create a new Project. // ProjectCreateRequest contains information needed to create a new Project.
type ProjectCreateRequest struct { type ProjectCreateRequest struct {
AccountID string `json:"account_id" validate:"required,uuid"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required" example:"Rocket Launch"`
Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"active"`
} }
// ProjectUpdateRequest defines what information may be provided to modify an existing // ProjectUpdateRequest defines what information may be provided to modify an existing
@ -31,20 +68,26 @@ type ProjectCreateRequest struct {
// changed. It uses pointer fields so we can differentiate between a field that // changed. It uses pointer fields so we can differentiate between a field that
// was not provided and a field that was provided as explicitly blank. // was not provided and a field that was provided as explicitly blank.
type ProjectUpdateRequest struct { type ProjectUpdateRequest struct {
ID string `json:"id" validate:"required,uuid"` ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"`
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty" example:"Rocket Launch to Moon"`
Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string"` Status *ProjectStatus `json:"status,omitempty" validate:"omitempty,oneof=active disabled" enums:"active,disabled" swaggertype:"string" example:"disabled"`
}
// ProjectArchiveRequest defines the information needed to archive a project. This will archive (soft-delete) the
// existing database entry.
type ProjectArchiveRequest struct {
ID string `json:"id" validate:"required,uuid" example:"985f1746-1d9f-459f-a2d9-fc53ece5ae86"`
} }
// ProjectFindRequest defines the possible options to search for projects. By default // ProjectFindRequest defines the possible options to search for projects. By default
// archived project will be excluded from response. // archived project will be excluded from response.
type ProjectFindRequest struct { type ProjectFindRequest struct {
Where *string `json:"where"` Where *string `json:"where" example:"name = ? and status = ?"`
Args []interface{} `json:"args" swaggertype:"array,string"` Args []interface{} `json:"args" swaggertype:"array,string" example:"Moon Launch,active"`
Order []string `json:"order"` Order []string `json:"order" example:"created_at desc"`
Limit *uint `json:"limit"` Limit *uint `json:"limit" example:"10"`
Offset *uint `json:"offset"` Offset *uint `json:"offset" example:"20"`
IncludedArchived bool `json:"included-archived"` IncludedArchived bool `json:"included-archived" example:"false"`
} }
// ProjectStatus represents the status of project. // ProjectStatus represents the status of project.

View File

@ -358,14 +358,19 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec
return nil return nil
} }
// Archive soft deleted the project by ID from the database.
func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error {
req := ProjectArchiveRequest{
ID: id,
}
return Archive(ctx, claims, dbConn, req, now)
}
// Archive soft deleted the project from the database. // Archive soft deleted the project from the database.
func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error { func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ProjectArchiveRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Archive") span, ctx := tracer.StartSpanFromContext(ctx, "internal.project.Archive")
defer span.Finish() defer span.Finish()
// Defines the struct to apply validation
req := struct {
ID string `validate:"required,uuid"`
}{}
// Validate the request. // Validate the request.
err := validator.New().Struct(req) err := validator.New().Struct(req)
if err != nil { if err != nil {

View File

@ -3,7 +3,9 @@ package user
import ( import (
"context" "context"
"crypto/rsa" "crypto/rsa"
"database/sql"
"github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
"strings"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
@ -26,7 +28,7 @@ type TokenGenerator interface {
// Authenticate finds a user by their email and verifies their password. On success // 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 // it returns a Token that can be used to authenticate access to the application in
// the future. // the future.
func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, email, password string, expires time.Duration, now time.Time) (Token, error) { func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, email, password string, expires time.Duration, now time.Time, scopes ...string) (Token, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Authenticate") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Authenticate")
defer span.Finish() defer span.Finish()
@ -58,13 +60,13 @@ func Authenticate(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, e
} }
// The user is successfully authenticated with the supplied email and password. // The user is successfully authenticated with the supplied email and password.
return generateToken(ctx, dbConn, tknGen, auth.Claims{}, u.ID, "", expires, now) return generateToken(ctx, dbConn, tknGen, auth.Claims{}, u.ID, "", expires, now, scopes...)
} }
// Authenticate finds a user by their email and verifies their password. On success // 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 // it returns a Token that can be used to authenticate access to the application in
// the future. // the future.
func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, accountID string, expires time.Duration, now time.Time) (Token, error) { func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, claims auth.Claims, accountID string, expires time.Duration, now time.Time, scopes ...string) (Token, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.SwitchAccount") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.SwitchAccount")
defer span.Finish() defer span.Finish()
@ -86,12 +88,12 @@ 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 // 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 // in the supplied claims as well to enforce ACLs when finding the current
// list of accounts for the user. // list of accounts for the user.
return generateToken(ctx, dbConn, tknGen, claims, req.UserID, req.AccountID, expires, now) return generateToken(ctx, dbConn, tknGen, claims, req.UserID, req.AccountID, expires, now, scopes...)
} }
// generateToken generates claims for the supplied user ID and account ID and then // generateToken generates claims for the supplied user ID and account ID and then
// returns the token for the generated claims used for authentication. // 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) (Token, error) { 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) {
type userAccount struct { type userAccount struct {
AccountID string AccountID string
@ -100,13 +102,16 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator,
UserArchived pq.NullTime UserArchived pq.NullTime
AccountStatus string AccountStatus string
AccountArchived pq.NullTime AccountArchived pq.NullTime
AccountTimezone sql.NullString
UserTimezone sql.NullString
} }
// Build select statement for users_accounts table to find all the user accounts for the user // Build select statement for users_accounts table to find all the user accounts for the user
f := func() ([]userAccount, error) { f := func() ([]userAccount, error) {
query := sqlbuilder.NewSelectBuilder().Select("ua.account_id, ua.roles, ua.status as userStatus, ua.archived_at userArchived, a.status as accountStatus, a.archived_at as accountArchived"). query := sqlbuilder.NewSelectBuilder().Select("ua.account_id, ua.roles, ua.status as userStatus, ua.archived_at userArchived, a.status as accountStatus, a.archived_at, a.timezone, u.timezone as accountArchived").
From(userAccountTableName+" ua"). From(userAccountTableName+" ua").
Join(accountTableName+" a", "a.id = ua.account_id") Join(accountTableName+" a", "a.id = ua.account_id").
Join(userTableName+" u", "u.id = ua.user_id")
query.Where(query.And( query.Where(query.And(
query.Equal("ua.user_id", userID), query.Equal("ua.user_id", userID),
)) ))
@ -125,7 +130,7 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator,
var resp []userAccount var resp []userAccount
for rows.Next() { for rows.Next() {
var ua userAccount var ua userAccount
err = rows.Scan(&ua.AccountID, &ua.Roles, &ua.UserStatus, &ua.UserArchived, &ua.AccountStatus, &ua.AccountArchived) err = rows.Scan(&ua.AccountID, &ua.Roles, &ua.UserStatus, &ua.UserArchived, &ua.AccountStatus, &ua.AccountArchived, &ua.AccountTimezone, &ua.UserTimezone)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
@ -212,11 +217,64 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator,
accountIds = append(accountIds, a.AccountID) accountIds = append(accountIds, a.AccountID)
} }
// Allow the scope to be defined for the claims. This enables testing via the API when a user has the role of admin
// and would like to limit their role to user.
var roles []string
if len(scopes) > 0 && scopes[0] != "" {
// Parse scopes, handle when one value has a list of scopes
// separated by a space.
var scopeList []string
for _, vs := range scopes {
for _, v := range strings.Split(vs, " ") {
v = strings.TrimSpace(v)
if v == "" {
continue
}
scopeList = append(scopeList, v)
}
}
for _, s := range scopeList {
var scopeValid bool
for _, r := range account.Roles {
if r == s || (s == auth.RoleUser && r == auth.RoleAdmin) {
scopeValid = true
break
}
}
if scopeValid {
roles = append(roles, s)
} else {
err := errors.Errorf("invalid scope '%s'", s)
return Token{}, err
}
}
} else {
roles = account.Roles
}
if len(roles) == 0 {
err := errors.New("no roles defined for user")
return Token{}, err
}
// Set the timezone if one is specifically set on the user.
var tz *time.Location
if account.UserTimezone.Valid && account.UserTimezone.String != "" {
tz, _ = time.LoadLocation(account.UserTimezone.String)
}
// If user timezone failed to parse or none is set, check the timezone set on the account.
if tz == nil && account.AccountTimezone.Valid && account.AccountTimezone.String != "" {
tz, _ = time.LoadLocation(account.AccountTimezone.String)
}
// JWT claims requires both an audience and a subject. For this application: // JWT claims requires both an audience and a subject. For this application:
// Subject: The ID of the user authenticated. // Subject: The ID of the user authenticated.
// Audience: The ID of the account the user is accessing. A list of account IDs // Audience: The ID of the account the user is accessing. A list of account IDs
// will also be included to support the user switching between them. // will also be included to support the user switching between them.
claims = auth.NewClaims(userID, accountID, accountIds, account.Roles, now, expires) claims = auth.NewClaims(userID, accountID, accountIds, roles, tz, now, expires)
// Generate a token for the user with the defined claims. // Generate a token for the user with the defined claims.
tknStr, err := tknGen.GenerateToken(claims) tknStr, err := tknGen.GenerateToken(claims)

View File

@ -1,6 +1,7 @@
package user package user
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
@ -109,12 +110,13 @@ func TestAuthenticate(t *testing.T) {
if err != nil { if err != nil {
t.Log("\t\tGot :", err) t.Log("\t\tGot :", err)
t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed)
} else if diff := cmp.Diff(claims1, tkn1.claims); diff != "" { }
// Hack for Unhandled Exception in go-cmp@v0.3.0/cmp/options.go:229
resClaims, _ := json.Marshal(claims1)
expectClaims, _ := json.Marshal(tkn1.claims)
if diff := cmp.Diff(string(resClaims), string(expectClaims)); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff) t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff)
} else if diff := cmp.Diff(claims1.Roles, []string{account1Role}); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims roles to match user account. Diff:\n%s", tests.Failed, diff)
} else if diff := cmp.Diff(claims1.AccountIds, []string{account1Id, account2Id}); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims account IDs to match the single user account. Diff:\n%s", tests.Failed, diff)
} }
t.Logf("\t%s\tAuthenticate parse claims from token ok.", tests.Success) t.Logf("\t%s\tAuthenticate parse claims from token ok.", tests.Success)
@ -131,12 +133,13 @@ func TestAuthenticate(t *testing.T) {
if err != nil { if err != nil {
t.Log("\t\tGot :", err) t.Log("\t\tGot :", err)
t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed) t.Fatalf("\t%s\tParse claims from token failed.", tests.Failed)
} else if diff := cmp.Diff(claims2, tkn2.claims); diff != "" { }
// Hack for Unhandled Exception in go-cmp@v0.3.0/cmp/options.go:229
resClaims, _ = json.Marshal(claims2)
expectClaims, _ = json.Marshal(tkn2.claims)
if diff := cmp.Diff(string(resClaims), string(expectClaims)); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff) t.Fatalf("\t%s\tExpected parsed claims to match from token. Diff:\n%s", tests.Failed, diff)
} else if diff := cmp.Diff(claims2.Roles, []string{account2Role}); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims roles to match user account. Diff:\n%s", tests.Failed, diff)
} else if diff := cmp.Diff(claims2.AccountIds, []string{account1Id, account2Id}); diff != "" {
t.Fatalf("\t%s\tExpected parsed claims account IDs to match the single user account. Diff:\n%s", tests.Failed, diff)
} }
t.Logf("\t%s\tSwitchAccount parse claims from token ok.", tests.Success) t.Logf("\t%s\tSwitchAccount parse claims from token ok.", tests.Success)
} }

View File

@ -1,8 +1,10 @@
package user package user
import ( import (
"context"
"database/sql" "database/sql"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"time" "time"
"github.com/lib/pq" "github.com/lib/pq"
@ -10,21 +12,53 @@ import (
// User represents someone with access to our system. // User represents someone with access to our system.
type User struct { type User struct {
ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" validate:"required" example:"Gabi May"` Name string `json:"name" validate:"required" example:"Gabi May"`
Email string `json:"email" example:"gabi@geeksinthewoods.com"` Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
PasswordSalt string `json:"-" validate:"required"`
PasswordSalt string `json:"-"` PasswordHash []byte `json:"-" validate:"required"`
PasswordHash []byte `json:"-"`
PasswordReset *sql.NullString `json:"-"` PasswordReset *sql.NullString `json:"-"`
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
Timezone string `json:"timezone" example:"America/Anchorage"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
} }
// UserResponse represents someone with access to our system that is returned for display.
type UserResponse struct {
ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" example:"Gabi May"`
Email string `json:"email" example:"gabi@geeksinthewoods.com"`
Timezone string `json:"timezone" example:"America/Anchorage"`
CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display.
UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display.
ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display.
}
// Response transforms User and UserResponse that is used for display.
// Additional filtering by context values or translations could be applied.
func (m *User) Response(ctx context.Context) *UserResponse {
if m == nil {
return nil
}
r := &UserResponse{
ID: m.ID,
Name: m.Name,
Email: m.Email,
Timezone: m.Timezone,
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
}
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
r.ArchivedAt = &at
}
return r
}
// UserCreateRequest contains information needed to create a new User. // UserCreateRequest contains information needed to create a new User.
type UserCreateRequest struct { type UserCreateRequest struct {
Name string `json:"name" validate:"required" example:"Gabi May"` Name string `json:"name" validate:"required" example:"Gabi May"`
@ -41,28 +75,34 @@ type UserCreateRequest struct {
// we do not want to use pointers to basic types but we make exceptions around // we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling. // marshalling/unmarshalling.
type UserUpdateRequest struct { type UserUpdateRequest struct {
ID string `json:"id" validate:"required,uuid"` ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty" example:"Gabi May Not"`
Email *string `json:"email,omitempty" validate:"omitempty,email,unique"` Email *string `json:"email,omitempty" validate:"omitempty,email,unique" example:"gabi.may@geeksinthewoods.com"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
} }
// UserUpdatePasswordRequest defines what information is required to update a user password. // UserUpdatePasswordRequest defines what information is required to update a user password.
type UserUpdatePasswordRequest struct { type UserUpdatePasswordRequest struct {
ID string `json:"id" validate:"required,uuid"` ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required" example:"NeverTellSecret"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"` PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password" example:"NeverTellSecret"`
}
// UserArchiveRequest defines the information needed to archive an user. This will archive (soft-delete) the
// existing database entry.
type UserArchiveRequest struct {
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
} }
// UserFindRequest defines the possible options to search for users. By default // UserFindRequest defines the possible options to search for users. By default
// archived users will be excluded from response. // archived users will be excluded from response.
type UserFindRequest struct { type UserFindRequest struct {
Where *string `json:"where"` Where *string `json:"where" example:"name = ? and email = ?"`
Args []interface{} `json:"args" swaggertype:"array,string"` Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,gabi.may@geeksinthewoods.com"`
Order []string `json:"order"` Order []string `json:"order" example:"created_at desc"`
Limit *uint `json:"limit"` Limit *uint `json:"limit" example:"10"`
Offset *uint `json:"offset"` Offset *uint `json:"offset" example:"20"`
IncludedArchived bool `json:"included-archived"` IncludedArchived bool `json:"included-archived" example:"false"`
} }
// Token is the payload we deliver to users when they authenticate. // Token is the payload we deliver to users when they authenticate.

View File

@ -512,18 +512,19 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re
return nil return nil
} }
// Archive soft deleted the user by ID from the database.
func ArchiveById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, now time.Time) error {
req := UserArchiveRequest{
ID: id,
}
return Archive(ctx, claims, dbConn, req, now)
}
// Archive soft deleted the user from the database. // Archive soft deleted the user from the database.
func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, now time.Time) error { func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserArchiveRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive")
defer span.Finish() defer span.Finish()
// Defines the struct to apply validation
req := struct {
ID string `validate:"required,uuid"`
}{
ID: userID,
}
// Validate the request. // Validate the request.
err := validator.New().Struct(req) err := validator.New().Struct(req)
if err != nil { if err != nil {

View File

@ -827,7 +827,7 @@ func TestCrud(t *testing.T) {
} }
// Archive (soft-delete) the user. // Archive (soft-delete) the user.
err = Archive(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, now) err = ArchiveById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, now)
if err != nil && errors.Cause(err) != tt.updateErr { if err != nil && errors.Cause(err) != tt.updateErr {
t.Logf("\t\tGot : %+v", err) t.Logf("\t\tGot : %+v", err)
t.Logf("\t\tWant: %+v", tt.updateErr) t.Logf("\t\tWant: %+v", tt.updateErr)

View File

@ -1,7 +1,9 @@
package user_account package user_account
import ( import (
"context"
"database/sql/driver" "database/sql/driver"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
@ -17,16 +19,53 @@ import (
// application. The status will allow users to be managed on by account with users // application. The status will allow users to be managed on by account with users
// being global to the application. // being global to the application.
type UserAccount struct { type UserAccount struct {
ID string `json:"id" example:"72938896-a998-4258-a17b-6418dcdb80e3"` ID string `json:"id" validate:"required,uuid" example:"72938896-a998-4258-a17b-6418dcdb80e3"`
UserID string `json:"user_id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" swaggertype:"array,string" enums:"admin,user" example:"admin"` Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status UserAccountStatus `json:"status" swaggertype:"string" enums:"active,invited,disabled" example:"active"` Status UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
} }
// UserAccountResponse defines the one to many relationship of an user to an account that is returned for display.
type UserAccountResponse struct {
ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
UserID string `json:"user_id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled].
CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display.
UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display.
ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display.
}
// Response transforms UserAccount and UserAccountResponse that is used for display.
// Additional filtering by context values or translations could be applied.
func (m *UserAccount) Response(ctx context.Context) *UserAccountResponse {
if m == nil {
return nil
}
r := &UserAccountResponse{
ID: m.ID,
UserID: m.UserID,
AccountID: m.AccountID,
Roles: m.Roles,
Status: web.NewEnumResponse(ctx, m.Status, UserAccountRole_Values),
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt),
}
if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() {
at := web.NewTimeResponse(ctx, m.ArchivedAt.Time)
r.ArchivedAt = &at
}
return r
}
// CreateUserAccountRequest defines the information is needed to associate a user to an // CreateUserAccountRequest defines the information is needed to associate a user to an
// account. Users are global to the application and each users access can be managed // account. Users are global to the application and each users access can be managed
// on an account level. If a current entry exists in the database but is archived, // on an account level. If a current entry exists in the database but is archived,
@ -41,8 +80,8 @@ type CreateUserAccountRequest struct {
// UpdateUserAccountRequest defines the information needed to update the roles or the // UpdateUserAccountRequest defines the information needed to update the roles or the
// status for an existing user account. // status for an existing user account.
type UpdateUserAccountRequest struct { type UpdateUserAccountRequest struct {
UserID string `json:"user_id" validate:"required,uuid"` UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" validate:"required,uuid"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles *UserAccountRoles `json:"roles,omitempty" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"user"` Roles *UserAccountRoles `json:"roles,omitempty" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"user"`
Status *UserAccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"disabled"` Status *UserAccountStatus `json:"status,omitempty" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"disabled"`
unArchive bool `json:"-"` // Internal use only. unArchive bool `json:"-"` // Internal use only.
@ -51,26 +90,26 @@ type UpdateUserAccountRequest struct {
// ArchiveUserAccountRequest defines the information needed to remove an existing account // ArchiveUserAccountRequest defines the information needed to remove an existing account
// for a user. This will archive (soft-delete) the existing database entry. // for a user. This will archive (soft-delete) the existing database entry.
type ArchiveUserAccountRequest struct { type ArchiveUserAccountRequest struct {
UserID string `json:"user_id" validate:"required,uuid"` UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" validate:"required,uuid"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
} }
// DeleteUserAccountRequest defines the information needed to delete an existing account // DeleteUserAccountRequest defines the information needed to delete an existing account
// for a user. This will hard delete the existing database entry. // for a user. This will hard delete the existing database entry.
type DeleteUserAccountRequest struct { type DeleteUserAccountRequest struct {
UserID string `json:"user_id" validate:"required,uuid"` UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
AccountID string `json:"account_id" validate:"required,uuid"` AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
} }
// UserAccountFindRequest defines the possible options to search for users accounts. // UserAccountFindRequest defines the possible options to search for users accounts.
// By default archived user accounts will be excluded from response. // By default archived user accounts will be excluded from response.
type UserAccountFindRequest struct { type UserAccountFindRequest struct {
Where *string `json:"where"` Where *string `json:"where" example:"user_id = ? and account_id = ?"`
Args []interface{} `json:"args" swaggertype:"array,string"` Args []interface{} `json:"args" swaggertype:"array,string" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2,c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Order []string `json:"order"` Order []string `json:"order" example:"created_at desc"`
Limit *uint `json:"limit"` Limit *uint `json:"limit" example:"10"`
Offset *uint `json:"offset"` Offset *uint `json:"offset" example:"20"`
IncludedArchived bool `json:"included-archived"` IncludedArchived bool `json:"included-archived" example:"false"`
} }
// UserAccountStatus represents the status of a user for an account. // UserAccountStatus represents the status of a user for an account.