1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-08-08 22:36:41 +02:00

issue#16 web-app account signup

Account signup works with validation and translations.
This commit is contained in:
Lee Brown
2019-07-31 13:47:30 -08:00
parent a225f9f24e
commit 227af02f31
50 changed files with 1976 additions and 427 deletions

View File

@@ -15,7 +15,7 @@ import (
)
// API returns a handler for a set of routes.
func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler {
func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler {
// Define base middlewares applied to all requests.
middlewares := []web.Middleware{
@@ -28,7 +28,7 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *red
}
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, middlewares...)
app := web.NewApp(shutdown, log, env, middlewares...)
// Register health check endpoint. This route is not authenticated.
check := Check{

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"expvar"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"log"
"net"
"net/http"
@@ -359,8 +360,9 @@ func main() {
// =========================================================================
// Load middlewares that need to be configured specific for the service.
var serviceMiddlewares []web.Middleware
var serviceMiddlewares = []web.Middleware{
mid.Translator(webcontext.UniversalTranslator()),
}
// Init redirect middleware to ensure all requests go to the primary domain contained in the base URL.
if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" {

View File

@@ -22,6 +22,48 @@ an image and displays resvised versions of it on the index page. See section bel
If you would like to help, please email twins@geeksinthewoods.com.
## Local Installation
### Build
```bash
go build .
```
### Docker
To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory.
```bash
docker build -f cmd/web-app/Dockerfile -t saas-web-app .
```
## Getting Started
### Errors
- **validation error** - Test by appending `test-validation-error=1` to the request URL.
http://127.0.0.1:3000/signup?test-validation-error=1
- **web error** - Test by appending `test-web-error=1` to the request URL.
http://127.0.0.1:3000/signup?test-web-error=1
### Localization
Test a specific language by appending the locale to the request URL.
127.0.0.1:3000/signup?local=fr
[github.com/go-playground/validator](https://github.com/go-playground/validator) supports the following languages.
- en - English
- fr - French
- id - Indonesian
- ja - Japanese
- nl - Dutch
- zh - Chinese
### Future Functionality
This example Web App is going to allow users to manage checklists. Users with role of admin will be allowed to
@@ -66,20 +108,3 @@ This web-app service eventually will include the following:
## Local Installation
### Build
```bash
go build .
```
### Docker
To build using the docker file, need to be in the project root directory. `Dockerfile` references go.mod in root directory.
```bash
docker build -f cmd/web-app/Dockerfile -t saas-web-app .
```

View File

@@ -38,7 +38,7 @@ func (c *Check) Health(ctx context.Context, w http.ResponseWriter, r *http.Reque
"Status": "ok",
}
return c.Renderer.Render(ctx, w, r, baseLayoutTmpl, "health.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
return web.RespondJson(ctx, w, data, http.StatusOK)
}
// Ping validates the service is ready to accept requests.

View File

@@ -0,0 +1,21 @@
package handlers
import (
"context"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"github.com/jmoiron/sqlx"
)
// User represents the User API method handler set.
type Projects struct {
MasterDB *sqlx.DB
Renderer web.Renderer
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
}
// List returns all the existing users in the system.
func (p *Projects) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return p.Renderer.Render(ctx, w, r, tmplLayoutBase, "projects-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
@@ -17,9 +18,16 @@ type Root struct {
// List returns all the existing users in the system.
func (u *Root) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
// Force users to login to access the index page.
if claims, err := auth.ClaimsFromContext(ctx); err != nil || !claims.HasAuth() {
http.Redirect(w, r, "/user/login", http.StatusFound)
return nil
}
data := map[string]interface{}{
"imgSizes": []int{100, 200, 300, 400, 500},
}
return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}

View File

@@ -1,20 +1,28 @@
package handlers
import (
"context"
"fmt"
"log"
"net/http"
"os"
"geeks-accelerator/oss/saas-starter-kit/internal/mid"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
)
const baseLayoutTmpl = "base.tmpl"
const (
tmplLayoutBase = "base.tmpl"
tmplContentErrorGeneric = "error-generic.gohtml"
)
// API returns a handler for a set of routes.
func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
// Define base middlewares applied to all requests.
middlewares := []web.Middleware{
@@ -27,25 +35,37 @@ func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string
}
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, middlewares...)
app := web.NewApp(shutdown, log, env, middlewares...)
// Register health check endpoint. This route is not authenticated.
check := Check{
// Register project management pages.
p := Projects{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
}
app.Handle("GET", "/v1/health", check.Health)
app.Handle("GET", "/projects", p.Index, mid.HasAuth())
// Register user management and authentication endpoints.
u := User{
MasterDB: masterDB,
Renderer: renderer,
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
}
// This route is not authenticated
app.Handle("POST", "/users/login", u.Login)
app.Handle("GET", "/users/login", u.Login)
app.Handle("POST", "/user/login", u.Login)
app.Handle("GET", "/user/login", u.Login)
app.Handle("GET", "/user/logout", u.Logout)
app.Handle("POST", "/user/forgot-password", u.ForgotPassword)
app.Handle("GET", "/user/forgot-password", u.ForgotPassword)
// Register user management and authentication endpoints.
s := Signup{
MasterDB: masterDB,
Renderer: renderer,
Authenticator: authenticator,
}
// This route is not authenticated
app.Handle("POST", "/signup", s.Step1)
app.Handle("GET", "/signup", s.Step1)
// Register root
r := Root{
@@ -56,8 +76,32 @@ func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string
app.Handle("GET", "/index.html", r.Index)
app.Handle("GET", "/", r.Index)
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
}
app.Handle("GET", "/v1/health", check.Health)
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := web.StaticHandler(ctx, w, r, params, staticDir, "")
if err != nil {
if os.IsNotExist(err) {
rmsg := fmt.Sprintf("%s %s not found", r.Method, r.RequestURI)
err = weberror.NewErrorMessage(ctx, err, http.StatusNotFound, rmsg)
} else {
err = weberror.NewError(ctx, err, http.StatusInternalServerError)
}
return web.RenderError(ctx, w, r, err, renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
return nil
}
// Static file server
app.Handle("GET", "/*", web.Static(staticDir, ""))
app.Handle("GET", "/*", static)
return app
}

View File

@@ -0,0 +1,88 @@
package handlers
import (
"context"
"net/http"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"geeks-accelerator/oss/saas-starter-kit/internal/signup"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"github.com/gorilla/schema"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
// Signup represents the Signup API method handler set.
type Signup struct {
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
}
// Step1 handles collecting the first detailed needed to create a new account.
func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
//
req := new(signup.SignupRequest)
data := make(map[string]interface{})
f := func() error {
claims, _ := auth.ClaimsFromContext(ctx)
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
return err
}
decoder := schema.NewDecoder()
if err := decoder.Decode(req, r.PostForm); err != nil {
return err
}
// Execute the account / user signup.
res, err := signup.Signup(ctx, claims, h.MasterDB, *req, time.Now())
if err != nil {
switch errors.Cause(err) {
case account.ErrForbidden:
return web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
default:
if verr, ok := weberror.NewValidationError(ctx, err); ok {
data["validationErrors"] = verr.(*weberror.Error)
} else {
return err
}
}
} else {
// Authenticated the new user.
userAuth, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, res.User.Email, req.User.Password, time.Hour, time.Now())
if err != nil {
return err
}
_ = userAuth.Expiry
_ = userAuth.AccessToken
}
}
return nil
}
if err := f(); err != nil {
return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
}
data["form"] = req
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(signup.SignupRequest{})); ok {
data["validationDefaults"] = verr.(*weberror.Error)
}
return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "signup-step1.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
@@ -10,13 +11,25 @@ import (
// User represents the User API method handler set.
type User struct {
MasterDB *sqlx.DB
Renderer web.Renderer
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
MasterDB *sqlx.DB
Renderer web.Renderer
Authenticator *auth.Authenticator
}
// List returns all the existing users in the system.
func (u *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
// List returns all the existing users in the system.
func (u *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-logout.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}
// List returns all the existing users in the system.
func (u *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
}

View File

@@ -6,6 +6,9 @@ import (
"encoding/json"
"expvar"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"html/template"
"log"
"net"
@@ -341,10 +344,25 @@ func main() {
}
defer masterDb.Close()
// =========================================================================
// Init new Authenticator
var authenticator *auth.Authenticator
if cfg.Auth.UseAwsSecretManager {
secretName := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "authenticator")
authenticator, err = auth.NewAuthenticatorAws(awsSession, secretName, time.Now().UTC(), cfg.Auth.KeyExpiration)
} else {
authenticator, err = auth.NewAuthenticatorFile("", time.Now().UTC(), cfg.Auth.KeyExpiration)
}
if err != nil {
log.Fatalf("main : Constructing authenticator : %+v", err)
}
// =========================================================================
// Load middlewares that need to be configured specific for the service.
var serviceMiddlewares []web.Middleware
var serviceMiddlewares = []web.Middleware{
mid.Translator(webcontext.UniversalTranslator()),
}
// Init redirect middleware to ensure all requests go to the primary domain contained in the base URL.
if primaryServiceHost != "127.0.0.1" && primaryServiceHost != "localhost" {
@@ -402,16 +420,6 @@ func main() {
var staticUrlFormatter func(string) string
if cfg.Service.StaticFiles.S3Enabled || cfg.Service.StaticFiles.CloudFrontEnabled {
staticUrlFormatter = staticS3UrlFormatter
} else {
baseUrl, err := url.Parse(cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : url Parse(%s) : %+v", cfg.Service.BaseUrl, err)
}
staticUrlFormatter = func(p string) string {
baseUrl.Path = p
return baseUrl.String()
}
}
// =========================================================================
@@ -504,6 +512,98 @@ func main() {
}
return u
},
"ValidationErrorHasField": func(err interface{}, fieldName string) bool {
if err == nil {
return false
}
verr, ok := err.(*weberror.Error)
if !ok {
return false
}
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
return true
}
}
return false
},
"ValidationFieldErrors": func(err interface{}, fieldName string) []weberror.FieldError {
if err == nil {
return []weberror.FieldError{}
}
verr, ok := err.(*weberror.Error)
if !ok {
return []weberror.FieldError{}
}
var l []weberror.FieldError
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
l = append(l, e)
}
}
return l
},
"ValidationFieldClass": func(err interface{}, fieldName string) string {
if err == nil {
return ""
}
verr, ok := err.(*weberror.Error)
if !ok || len(verr.Fields) == 0 {
return ""
}
for _, e := range verr.Fields {
if e.Field == fieldName || e.FormField == fieldName {
return "is-invalid"
}
}
return "is-valid"
},
"ErrorMessage": func(ctx context.Context, err error) string {
werr, ok := err.(*weberror.Error)
if ok {
if werr.Message != "" {
return werr.Message
}
return werr.Error()
}
return fmt.Sprintf("%s", err)
},
"ErrorDetails": func(ctx context.Context, err error) string {
var displayFullError bool
switch webcontext.ContextEnv(ctx) {
case webcontext.Env_Dev, webcontext.Env_Stage:
displayFullError = true
}
if !displayFullError {
return ""
}
werr, ok := err.(*weberror.Error)
if ok {
if werr.Cause != nil {
return fmt.Sprintf("%s\n%+v", werr.Error(), werr.Cause)
}
return fmt.Sprintf("%+v", werr.Error())
}
return fmt.Sprintf("%+v", err)
},
}
imgUrlFormatter := staticUrlFormatter
if imgUrlFormatter == nil {
baseUrl, err := url.Parse(cfg.Service.BaseUrl)
if err != nil {
log.Fatalf("main : url Parse(%s) : %+v", cfg.Service.BaseUrl, err)
}
imgUrlFormatter = func(p string) string {
baseUrl.Path = p
return baseUrl.String()
}
}
// Image Formatter - additional functions exposed to templates for resizing images
@@ -511,7 +611,7 @@ func main() {
imgResizeS3KeyPrefix := filepath.Join(cfg.Service.StaticFiles.S3Prefix, "images/responsive")
imgSrcAttr := func(ctx context.Context, p string, sizes []int, includeOrig bool) template.HTMLAttr {
u := staticUrlFormatter(p)
u := imgUrlFormatter(p)
var srcAttr string
if cfg.Service.StaticFiles.ImgResizeEnabled {
srcAttr, _ = img_resize.S3ImgSrc(ctx, redisClient, staticS3UrlFormatter, awsSession, cfg.Aws.S3BucketPublic, imgResizeS3KeyPrefix, u, sizes, includeOrig)
@@ -543,7 +643,7 @@ func main() {
return imgSrcAttr(ctx, p, sizes, true)
}
tmplFuncs["S3ImgUrl"] = func(ctx context.Context, p string, size int) string {
imgUrl := staticUrlFormatter(p)
imgUrl := imgUrlFormatter(p)
if cfg.Service.StaticFiles.ImgResizeEnabled {
imgUrl, _ = img_resize.S3ImgUrl(ctx, redisClient, staticS3UrlFormatter, awsSession, cfg.Aws.S3BucketPublic, imgResizeS3KeyPrefix, imgUrl, size)
}
@@ -635,7 +735,7 @@ func main() {
if cfg.HTTP.Host != "" {
api := http.Server{
Addr: cfg.HTTP.Host,
Handler: handlers.APP(shutdown, log, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, renderer, serviceMiddlewares...),
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20,
@@ -652,7 +752,7 @@ func main() {
if cfg.HTTPS.Host != "" {
api := http.Server{
Addr: cfg.HTTPS.Host,
Handler: handlers.APP(shutdown, log, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, renderer, serviceMiddlewares...),
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, renderer, serviceMiddlewares...),
ReadTimeout: cfg.HTTPS.ReadTimeout,
WriteTimeout: cfg.HTTPS.WriteTimeout,
MaxHeaderBytes: 1 << 20,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,18 @@
{{define "title"}}Error {{ .statusCode }}{{end}}
{{define "style"}}
{{end}}
{{ define "partials/page-wrapper" }}
<div class="container-fluid">
<!-- Error Text -->
<div class="text-center">
<div class="error mx-auto" data-text="{{ .statusCode }}">{{ .statusCode }}</div>
<p class="lead text-gray-800 mb-5">{{ .errorMessage }}</p>
{{ if .fullError }}
<p class="text-gray-500 mb-0">{{ .fullError }}</p>
{{ end }}
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,116 @@
{{define "title"}}Crean an Account{{end}}
{{define "style"}}
{{end}}
{{ define "partials/page-wrapper" }}
<div class="container">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-5 d-none d-lg-block bg-register-image"></div>
<div class="col-lg-7">
<div class="p-5">
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Create an Account!</h1>
</div>
{{ template "top-error" . }}
<form class="user" method="post" novalidate>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Name" }}" name="Account.Name" value="{{ $.form.Account.Name }}" placeholder="Company Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Name" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Address1" }}" name="Account.Address1" value="{{ $.form.Account.Address1 }}" placeholder="Address Line 1" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Address1" }}
</div>
<div class="col-sm-6">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Address2" }}" name="Account.Address2" value="{{ $.form.Account.Address2 }}" placeholder="Address Line 2">
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Address2" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Zipcode" }}" name="Account.Zipcode" value="{{ $.form.Account.Zipcode }}" placeholder="Zipcode" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Zipcode" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Country" }}" name="Account.Country" value="{{ $.form.Account.Country }}" placeholder="Country" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Country" }}
</div>
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.Region" }}" name="Account.Region" value="{{ $.form.Account.Region }}" placeholder="Region" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.Region" }}
</div>
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Account.City" }}" name="Account.City" value="{{ $.form.Account.City }}" placeholder="City" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Account.City" }}
</div>
</div>
<hr>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.FirstName" }}" name="User.FirstName" value="{{ $.form.User.FirstName }}" placeholder="First Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.FirstName" }}
</div>
<div class="col-sm-6">
<input type="text" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.LastName" }}" name="User.LastName" value="{{ $.form.User.LastName }}" placeholder="Last Name" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.LastName" }}
</div>
</div>
<div class="form-group">
<input type="email" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.Email" }}" name="User.Email" value="{{ $.form.User.Email }}" placeholder="Email Address" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.Email" }}
</div>
<div class="form-group row">
<div class="col-sm-6 mb-3 mb-sm-0">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.Password" }}" name="User.Password" value="{{ $.form.User.Password }}" placeholder="Password" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.Password" }}
</div>
<div class="col-sm-6">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "User.PasswordConfirm" }}" name="User.PasswordConfirm" value="{{ $.form.User.PasswordConfirm }}" placeholder="Repeat Password" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "User.PasswordConfirm" }}
</div>
</div>
<button class="btn btn-primary btn-user btn-block">
Register Account
</button>
</form>
<hr>
<div class="text-center">
<a class="small" href="/user/forgot-password">Forgot Password?</a>
</div>
<div class="text-center">
<a class="small" href="/user/login">Already have an account? Login!</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "js"}}
<script>
$(document).ready(function() {
$(document).find('body').addClass('bg-gradient-primary');
// console.log(($('input[name=\'Account\.Name\']').parent().find('.invalid-feedback').show()));
});
</script>
{{end}}

View File

@@ -2,9 +2,65 @@
{{define "style"}}
{{end}}
{{define "content"}}
Login to this amazing web app
{{ define "partials/page-wrapper" }}
<div class="container">
<!-- Outer Row -->
<div class="row justify-content-center">
<div class="col-xl-10 col-lg-12 col-md-9">
<div class="card o-hidden border-0 shadow-lg my-5">
<div class="card-body p-0">
<!-- Nested Row within Card Body -->
<div class="row">
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
<div class="col-lg-6">
<div class="p-5">
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Welcome Back!</h1>
</div>
<form class="user">
<div class="form-group">
<input type="email" class="form-control form-control-user" id="loginEmail" aria-describedby="emailHelp" placeholder="Enter Email Address...">
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user" id="loginPassword" placeholder="Password">
</div>
<div class="form-group">
<div class="custom-control custom-checkbox small">
<input type="checkbox" class="custom-control-input" id="customCheck">
<label class="custom-control-label" for="customCheck">Remember Me</label>
</div>
</div>
<a href="index.html" class="btn btn-primary btn-user btn-block">
Login
</a>
<hr>
</form>
<hr>
<div class="text-center">
<a class="small" href="/user/forgot-password">Forgot Password?</a>
</div>
<div class="text-center">
<a class="small" href="/signup">Create an Account!</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "js"}}
{{end}}
<script>
$(document).ready(function() {
$(document).find('body').addClass('bg-gradient-primary');
});
</script>
{{end}}

View File

@@ -5,44 +5,126 @@
<title>
{{block "title" .}}{{end}} Web App
</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="{{block "description" .}}{{end}} ">
<meta name="author" content="{{block "author" .}}{{end}}">
<meta charset="utf-8">
<link rel="icon" type="image/png" sizes="16x16" href="{{ SiteAssetUrl "/assets/images/favicon.png" }}">
<!-- ============================================================== -->
<!-- CSS -->
<!-- Custom fonts for this template -->
<!-- ============================================================== -->
<link href="{{ SiteAssetUrl "/assets/css/base.css" }}" id="theme" rel="stylesheet">
<link href="{{ SiteAssetUrl "/assets/vendor/fontawesome-free/css/all.min.css" }}" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i" rel="stylesheet">
<!-- ============================================================== -->
<!-- Page specific CSS -->
<!-- Base styles for Start Bootstrap template SB Admin 2 -->
<!-- ============================================================== -->
{{ if eq $._Service.ENV "dev" }}
<link href="{{ SiteAssetUrl "/assets/css/sb-admin-2.css" }}" rel="stylesheet">
{{ else }}
<link href="{{ SiteAssetUrl "/assets/css/sb-admin-2.min.css" }}" rel="stylesheet">
{{ end }}
<!-- ============================================================== -->
<!-- Custom styles for this service applied to all pages -->
<!-- ============================================================== -->
<link href="{{ SiteAssetUrl "/assets/css/custom.css" }}" id="theme" rel="stylesheet">
<!-- ============================================================== -->
<!-- Page specific CSS -->
<!-- ============================================================== -->
{{block "style" .}} {{end}}
</head>
<body>
<!-- ============================================================== -->
<!-- Page content -->
<!-- ============================================================== -->
{{ template "content" . }}
<body id="page-top">
{{ template "partials/page-wrapper" . }}
<!-- ============================================================== -->
<!-- footer -->
<!-- Logout Modal -->
<!-- ============================================================== -->
<footer class="footer">
© 2019 Geeks Accelerator<br/>
{{ template "partials/buildinfo" . }}
</footer>
{{ if HasAuth $._Ctx }}
<div class="modal fade" id="logoutModal" tabindex="-1" role="dialog" aria-labelledby="logoutModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logoutModalLabel">Ready to Leave?</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">Select "Logout" below if you are ready to end your current session.</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
<a class="btn btn-primary" href="/logout">Logout</a>
</div>
</div>
</div>
</div>
{{ end }}
<!-- ============================================================== -->
<!-- Javascript -->
<!-- Javascript Bootstrap core JavaScript -->
<!-- ============================================================== -->
<script src="{{ SiteAssetUrl "/js/base.js" }}"></script>
<script src="{{ SiteAssetUrl "/assets/vendor/jquery/jquery.min.js" }}"></script>
<script src="{{ SiteAssetUrl "/assets/vendor/bootstrap/js/bootstrap.bundle.min.js" }}"></script>
<!-- ============================================================== -->
<!-- Page specific Javascript -->
<!-- Core plugin JavaScript -->
<!-- ============================================================== -->
<script src="{{ SiteAssetUrl "/assets/vendor/jquery-easing/jquery.easing.min.js" }}"></script>
<!-- ============================================================== -->
<!-- Javascript for Start Bootstrap template SB Admin 2 -->
<!-- ============================================================== -->
{{ if eq $._Service.ENV "dev" }}
<script src="{{ SiteAssetUrl "/assets/js/sb-admin-2.js" }}"></script>
{{ else }}
<script src="{{ SiteAssetUrl "/assets/js/sb-admin-2.min.js" }}"></script>
{{ end }}
<!-- ============================================================== -->
<!-- Custom Javascript for this service applied to all pages -->
<!-- ============================================================== -->
<script src="{{ SiteAssetUrl "/assets/js/custom.js" }}"></script>
<!-- ============================================================== -->
<!-- Page specific Javascript -->
<!-- ============================================================== -->
{{block "js" .}} {{end}}
</body>
</html>
{{end}}
{{end}}
{{ define "invalid-feedback" }}
<div class="invalid-feedback">
{{ if ValidationErrorHasField .validationErrors .fieldName }}
{{ range $verr := (ValidationFieldErrors .validationErrors .fieldName) }}{{ $verr.Display }}<br/>{{ end }}
{{ else }}
{{ range $verr := (ValidationFieldErrors .validationDefaults .fieldName) }}{{ $verr.Display }}<br/>{{ end }}
{{ end }}
</div>
{{ end }}
{{ define "top-error" }}
{{ if .error }}
{{ $errMsg := (ErrorMessage $._Ctx .error) }}
{{ $errDetails := (ErrorDetails $._Ctx .error) }}
{{ if or ($errMsg) ($errDetails) }}
<div class="alert alert-danger" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button> {{ if $errMsg }}<h3>{{ $errMsg }}</h3> {{end}}
{{ if .error.Fields }}
<ul>
{{ range $i := .error.Fields }}
<li>{{ if $i.Display }}{{ $i.Display }}{{ else }}{{ $i.Error }}{{ end }}</li>
{{end}}
</ul>
{{ end }}
{{ if $errDetails }}
<p><small>{{ $errDetails }}</small></p>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,66 @@
{{ define "partials/page-wrapper" }}
<!-- ============================================================== -->
<!-- Page Wrapper -->
<!-- ============================================================== -->
<div id="wrapper">
{{ template "partials/sidebar" . }}
<!-- ============================================================== -->
<!-- Content Wrapper -->
<!-- ============================================================== -->
<div id="content-wrapper" class="d-flex flex-column">
<!-- ============================================================== -->
<!-- Main Content -->
<!-- ============================================================== -->
<div id="content">
<!-- ============================================================== -->
<!-- Topbar -->
<!-- ============================================================== -->
{{ template "partials/topbar" . }}
<!-- End of Topbar -->
{{ template "top-error" . }}
<!-- ============================================================== -->
<!-- Page Content -->
<!-- ============================================================== -->
<div class="container-fluid">
{{ template "content" . }}
</div>
<!-- End Page Content -->
</div>
<!-- End of Main Content -->
<!-- ============================================================== -->
<!-- Footer -->
<!-- ============================================================== -->
<footer class="sticky-footer bg-white">
<div class="container my-auto">
<div class="copyright text-center my-auto">
<span>
Copyright &copy; Geeks Accelerator 2019<br/>
{{ template "partials/buildinfo" . }}
</span>
</div>
</div>
</footer>
<!-- End of Footer -->
</div>
<!-- End of Content Wrapper -->
</div>
<!-- End of Page Wrapper -->
<!-- Scroll to Top Button-->
<a class="scroll-to-top rounded" href="#page-top">
<i class="fas fa-angle-up"></i>
</a>
{{ end }}

View File

@@ -0,0 +1,68 @@
{{ define "partials/sidebar" }}
<!-- Sidebar -->
<ul class="navbar-nav bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">
<!-- Sidebar - Brand -->
<a class="sidebar-brand d-flex align-items-center justify-content-center" href="/">
<div class="sidebar-brand-icon rotate-n-15">
<i class="fas fa-dragon"></i>
</div>
<div class="sidebar-brand-text mx-3">Example Project</div>
</a>
<!-- Divider -->
<hr class="sidebar-divider my-0">
<!-- Nav Item - Dashboard -->
<li class="nav-item">
<a class="nav-link" href="/">
<i class="fas fa-fw fa-tachometer-alt"></i>
<span>Dashboard</span></a>
</li>
<!-- Divider -->
<hr class="sidebar-divider">
<!-- Heading -->
<div class="sidebar-heading">
Interface
</div>
<!-- Nav Item - Pages Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#navSectionProjects" aria-expanded="true" aria-controls="navSectionProjects">
<i class="fas fa-fw fa-cog"></i>
<span>Projects</span>
</a>
<div id="navSectionProjects" class="collapse" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<a class="collapse-item" href="buttons.html">Buttons</a>
<a class="collapse-item" href="cards.html">Cards</a>
</div>
</div>
</li>
<!-- Nav Item - Utilities Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#navSectionUsers" aria-expanded="true" aria-controls="navSectionUsers">
<i class="fas fa-fw fa-wrench"></i>
<span>Users</span>
</a>
<div id="navSectionUsers" class="collapse" data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<a class="collapse-item" href="/users">Users</a>
</div>
</div>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
<!-- Sidebar Toggler (Sidebar) -->
<div class="text-center d-none d-md-inline">
<button class="rounded-circle border-0" id="sidebarToggle"></button>
</div>
</ul>
<!-- End of Sidebar -->
{{ end }}

View File

@@ -0,0 +1,184 @@
{{ define "partials/topbar" }}
<!-- Topbar -->
<nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">
<!-- Sidebar Toggle (Topbar) -->
<button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
<i class="fa fa-bars"></i>
</button>
<!-- Topbar Search -->
<form class="d-none d-sm-inline-block form-inline mr-auto ml-md-3 my-2 my-md-0 mw-100 navbar-search">
<div class="input-group">
<input type="text" class="form-control bg-light border-0 small" placeholder="Search for..." aria-label="Search" aria-describedby="basic-addon2">
<div class="input-group-append">
<button class="btn btn-primary" type="button">
<i class="fas fa-search fa-sm"></i>
</button>
</div>
</div>
</form>
<!-- Topbar Navbar -->
<ul class="navbar-nav ml-auto">
<!-- Nav Item - Search Dropdown (Visible Only XS) -->
<li class="nav-item dropdown no-arrow d-sm-none">
<a class="nav-link dropdown-toggle" href="#" id="searchDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-search fa-fw"></i>
</a>
<!-- Dropdown - Messages -->
<div class="dropdown-menu dropdown-menu-right p-3 shadow animated--grow-in" aria-labelledby="searchDropdown">
<form class="form-inline mr-auto w-100 navbar-search">
<div class="input-group">
<input type="text" class="form-control bg-light border-0 small" placeholder="Search for..." aria-label="Search" aria-describedby="basic-addon2">
<div class="input-group-append">
<button class="btn btn-primary" type="button">
<i class="fas fa-search fa-sm"></i>
</button>
</div>
</div>
</form>
</div>
</li>
<!-- Nav Item - Alerts -->
<li class="nav-item dropdown no-arrow mx-1">
<a class="nav-link dropdown-toggle" href="#" id="alertsDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-bell fa-fw"></i>
<!-- Counter - Alerts -->
<span class="badge badge-danger badge-counter">3+</span>
</a>
<!-- Dropdown - Alerts -->
<div class="dropdown-list dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="alertsDropdown">
<h6 class="dropdown-header">
Alerts Center
</h6>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="mr-3">
<div class="icon-circle bg-primary">
<i class="fas fa-file-alt text-white"></i>
</div>
</div>
<div>
<div class="small text-gray-500">December 12, 2019</div>
<span class="font-weight-bold">A new monthly report is ready to download!</span>
</div>
</a>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="mr-3">
<div class="icon-circle bg-success">
<i class="fas fa-donate text-white"></i>
</div>
</div>
<div>
<div class="small text-gray-500">December 7, 2019</div>
$290.29 has been deposited into your account!
</div>
</a>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="mr-3">
<div class="icon-circle bg-warning">
<i class="fas fa-exclamation-triangle text-white"></i>
</div>
</div>
<div>
<div class="small text-gray-500">December 2, 2019</div>
Spending Alert: We've noticed unusually high spending for your account.
</div>
</a>
<a class="dropdown-item text-center small text-gray-500" href="#">Show All Alerts</a>
</div>
</li>
<!-- Nav Item - Messages -->
<li class="nav-item dropdown no-arrow mx-1">
<a class="nav-link dropdown-toggle" href="#" id="messagesDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-envelope fa-fw"></i>
<!-- Counter - Messages -->
<span class="badge badge-danger badge-counter">7</span>
</a>
<!-- Dropdown - Messages -->
<div class="dropdown-list dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="messagesDropdown">
<h6 class="dropdown-header">
Message Center
</h6>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="dropdown-list-image mr-3">
<img class="rounded-circle" src="https://source.unsplash.com/fn_BT9fwg_E/60x60" alt="">
<div class="status-indicator bg-success"></div>
</div>
<div class="font-weight-bold">
<div class="text-truncate">Hi there! I am wondering if you can help me with a problem I've been having.</div>
<div class="small text-gray-500">Emily Fowler · 58m</div>
</div>
</a>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="dropdown-list-image mr-3">
<img class="rounded-circle" src="https://source.unsplash.com/AU4VPcFN4LE/60x60" alt="">
<div class="status-indicator"></div>
</div>
<div>
<div class="text-truncate">I have the photos that you ordered last month, how would you like them sent to you?</div>
<div class="small text-gray-500">Jae Chun · 1d</div>
</div>
</a>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="dropdown-list-image mr-3">
<img class="rounded-circle" src="https://source.unsplash.com/CS2uCrpNzJY/60x60" alt="">
<div class="status-indicator bg-warning"></div>
</div>
<div>
<div class="text-truncate">Last month's report looks great, I am very happy with the progress so far, keep up the good work!</div>
<div class="small text-gray-500">Morgan Alvarez · 2d</div>
</div>
</a>
<a class="dropdown-item d-flex align-items-center" href="#">
<div class="dropdown-list-image mr-3">
<img class="rounded-circle" src="https://source.unsplash.com/Mv9hjnEUHR4/60x60" alt="">
<div class="status-indicator bg-success"></div>
</div>
<div>
<div class="text-truncate">Am I a good boy? The reason I ask is because someone told me that people say this to all dogs, even if they aren't good...</div>
<div class="small text-gray-500">Chicken the Dog · 2w</div>
</div>
</a>
<a class="dropdown-item text-center small text-gray-500" href="#">Read More Messages</a>
</div>
</li>
<div class="topbar-divider d-none d-sm-block"></div>
<!-- Nav Item - User Information -->
<li class="nav-item dropdown no-arrow">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mr-2 d-none d-lg-inline text-gray-600 small">Valerie Luna</span>
<img class="img-profile rounded-circle" src="https://source.unsplash.com/QAB-WJcbgJk/60x60">
</a>
<!-- Dropdown - User Information -->
<div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
<a class="dropdown-item" href="#">
<i class="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
Profile
</a>
<a class="dropdown-item" href="#">
<i class="fas fa-cogs fa-sm fa-fw mr-2 text-gray-400"></i>
Settings
</a>
<a class="dropdown-item" href="#">
<i class="fas fa-list fa-sm fa-fw mr-2 text-gray-400"></i>
Activity Log
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
<i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
Logout
</a>
</div>
</li>
</ul>
</nav>
<!-- End of Topbar -->
{{ end }}

27
go.mod
View File

@@ -1,31 +1,33 @@
module geeks-accelerator/oss/saas-starter-kit
require (
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc
github.com/aws/aws-sdk-go v1.20.16
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/aws/aws-sdk-go v1.21.8
github.com/bobesa/go-domain-util v0.0.0-20180815122459-1d708c097a6a
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dimfeld/httptreemux v5.0.1+incompatible
github.com/dustin/go-humanize v1.0.0
github.com/fatih/camelcase v1.0.0
github.com/fatih/structtag v1.0.0
github.com/fsnotify/fsnotify v1.4.7
github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db
github.com/go-openapi/spec v0.19.2 // indirect
github.com/go-openapi/swag v0.19.4 // indirect
github.com/go-playground/locales v0.12.1
github.com/go-playground/pkg v0.0.0-20190522230805-792a755e6910
github.com/go-playground/universal-translator v0.16.0
github.com/go-redis/redis v6.15.2+incompatible
github.com/golang/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.3.0
github.com/google/uuid v1.1.1 // indirect
github.com/gorilla/schema v1.1.0
github.com/huandu/go-sqlbuilder v1.4.0
github.com/huandu/go-sqlbuilder v1.4.1
github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365
github.com/jmoiron/sqlx v1.2.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/leodido/go-urn v1.1.0 // indirect
github.com/lib/pq v1.1.1
github.com/lib/pq v1.2.0
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e // indirect
github.com/mattn/go-sqlite3 v1.11.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/onsi/ginkgo v1.8.0 // indirect
github.com/onsi/gomega v1.5.0
@@ -37,18 +39,19 @@ require (
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
github.com/stretchr/testify v1.3.0
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
github.com/swaggo/swag v1.6.1
github.com/swaggo/swag v1.6.2
github.com/tinylib/msgp v1.1.0 // indirect
github.com/urfave/cli v1.20.0
github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb // indirect
golang.org/x/tools v0.0.0-20190708203411-c8855242db9c // indirect
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 // indirect
golang.org/x/sys v0.0.0-20190730183949-1393eb018365 // indirect
golang.org/x/text v0.3.2
golang.org/x/tools v0.0.0-20190730205120-7deaedd405c4 // indirect
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect
google.golang.org/appengine v1.6.1 // indirect
gopkg.in/DataDog/dd-trace-go.v1 v1.15.0
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v9 v9.29.0
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
gopkg.in/go-playground/validator.v9 v9.29.1
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
gotest.tools v2.2.0+incompatible // indirect
)

50
go.sum
View File

@@ -5,10 +5,10 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/aws/aws-sdk-go v1.20.16 h1:Dq68fBH39XnSjjb2hX/iW6mui8JtXcVAuhRYGSRiisY=
github.com/aws/aws-sdk-go v1.20.16/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/aws/aws-sdk-go v1.21.8 h1:Lv6hW2twBhC6mGZAuWtqplEpIIqtVctJg02sE7Qn0Zw=
github.com/aws/aws-sdk-go v1.21.8/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/bobesa/go-domain-util v0.0.0-20180815122459-1d708c097a6a/go.mod h1:/mf0HzRK9xVv+1puqGSMzCo7bhEcQhiisuUXlMkq2p4=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -45,8 +45,12 @@ github.com/go-openapi/swag v0.19.2 h1:jvO6bCMBEilGwMfHhrd61zIID4oIFdwb76V17SM88d
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.4 h1:i/65mCM9s1h8eCkT07F5Z/C1e/f8VTgEwer+00yevpA=
github.com/go-openapi/swag v0.19.4/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI=
github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/pkg v0.0.0-20190522230805-792a755e6910 h1:h7toKaxfg9ttAloheYEndInQhXwOC/Knglt0L5MMVCM=
github.com/go-playground/pkg v0.0.0-20190522230805-792a755e6910/go.mod h1:Wg1j+HqWLhhVIfYdaoOuBzdutBEVcqwvBxgFZRWbybk=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-redis/redis v6.15.2+incompatible h1:9SpNVG76gr6InJGxoZ6IuuxaCOQwDAhzyXg+Bs+0Sb4=
@@ -58,6 +62,8 @@ github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -67,8 +73,8 @@ github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/go-sqlbuilder v1.4.0 h1:2LIlTDOz63lOETLOIiKBPEu4PUbikmS5LUc3EekwYqM=
github.com/huandu/go-sqlbuilder v1.4.0/go.mod h1:mYfGcZTUS6yJsahUQ3imkYSkGGT3A+owd54+79kkW+U=
github.com/huandu/go-sqlbuilder v1.4.1 h1:DYGFGLbOUXhtQ2kwO1uyDIPJbsztmVWdPPDyxi0EJGw=
github.com/huandu/go-sqlbuilder v1.4.1/go.mod h1:mYfGcZTUS6yJsahUQ3imkYSkGGT3A+owd54+79kkW+U=
github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 h1:ECW73yc9MY7935nNYXUkK7Dz17YuSUI9yqRqYS8aBww=
github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
@@ -89,8 +95,8 @@ github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8=
@@ -98,6 +104,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -126,8 +134,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM=
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/swag v1.6.1 h1:r5kS0vSmXYrSBSNdCLgGV40DpAPzSwvuLNMVIR8y0Ic=
github.com/swaggo/swag v1.6.1/go.mod h1:YyZstMc22WYm6GEDx/CYWxq+faBbjQ5EqwQcrjREDBo=
github.com/swaggo/swag v1.6.2 h1:WQMAtT/FmMBb7g0rAuHDhG3vvdtHKJ3WZ+Ssb0p4Y6E=
github.com/swaggo/swag v1.6.2/go.mod h1:YyZstMc22WYm6GEDx/CYWxq+faBbjQ5EqwQcrjREDBo=
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/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
@@ -149,8 +157,8 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
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/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/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/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -159,8 +167,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190730183949-1393eb018365 h1:SaXEMXhWzMJThc05vu6uh61Q245r4KaWMrsTedk0FDc=
golang.org/x/sys v0.0.0-20190730183949-1393eb018365/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@@ -169,13 +177,15 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190708203411-c8855242db9c h1:rRFNgkkT7zOyWlroLBmsrKYtBNhox8WtulQlOr3jIDk=
golang.org/x/tools v0.0.0-20190708203411-c8855242db9c/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/tools v0.0.0-20190730205120-7deaedd405c4 h1:GhbPrljMrt6gCNHHAJcWLDV3nDPFkIm0EEuqY9GtuX0=
golang.org/x/tools v0.0.0-20190730205120-7deaedd405c4/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
gopkg.in/DataDog/dd-trace-go.v1 v1.15.0 h1:2LhklnAJsRSelbnBrrE5QuRleRDkmOh2JWxOtIX6yec=
gopkg.in/DataDog/dd-trace-go.v1 v1.15.0/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1 h1:Dngw1zun6yTYFHNdzEWBlrJzFA2QJMjSA2sZ4nH2UWo=
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -183,8 +193,8 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.0 h1:5ofssLNYgAA/inWn6rTZ4juWpRJUwEnXc1LG2IeXwgQ=
gopkg.in/go-playground/validator.v9 v9.29.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=

View File

@@ -3,7 +3,7 @@ package account
import (
"context"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
@@ -274,7 +274,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun
return uniq
}
v := web.NewValidator()
v := webcontext.Validator()
v.RegisterValidation("unique", f)
// Validate the request.
@@ -369,7 +369,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accoun
span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Update")
defer span.Finish()
v := web.NewValidator()
v := webcontext.Validator()
// Validation name is unique in the database.
if req.Name != nil {
@@ -497,7 +497,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Accou
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -576,7 +576,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, accountID
}
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err

View File

@@ -2,6 +2,7 @@ package mid
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"net/http"
"strings"
@@ -11,12 +12,14 @@ import (
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
// ErrForbidden is returned when an authenticated user does not have a
// ErrorForbidden is returned when an authenticated user does not have a
// sufficient role for an action.
var ErrForbidden = web.NewRequestError(
errors.New("you are not authorized for that action"),
http.StatusForbidden,
)
func ErrorForbidden(ctx context.Context) error {
return weberror.NewError(ctx,
errors.New("you are not authorized for that action"),
http.StatusForbidden,
)
}
// Authenticate validates a JWT from the `Authorization` header.
func Authenticate(authenticator *auth.Authenticator) web.Middleware {
@@ -33,17 +36,17 @@ func Authenticate(authenticator *auth.Authenticator) web.Middleware {
authHdr := r.Header.Get("Authorization")
if authHdr == "" {
err := errors.New("missing Authorization header")
return web.NewRequestError(err, http.StatusUnauthorized)
return weberror.NewError(ctx, err, http.StatusUnauthorized)
}
tknStr, err := parseAuthHeader(authHdr)
if err != nil {
return web.NewRequestError(err, http.StatusUnauthorized)
return weberror.NewError(ctx, err, http.StatusUnauthorized)
}
claims, err := authenticator.ParseClaims(tknStr)
if err != nil {
return web.NewRequestError(err, http.StatusUnauthorized)
return weberror.NewError(ctx, err, http.StatusUnauthorized)
}
// Add claims to the context so they can be retrieved later.
@@ -68,6 +71,45 @@ func Authenticate(authenticator *auth.Authenticator) web.Middleware {
return f
}
// HasAuth validates the current user is an authenticated user,
func HasAuth() web.Middleware {
// This is the actual middleware function to be executed.
f := func(after web.Handler) web.Handler {
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.HasAuth")
defer span.Finish()
m := func() error {
claims, err := auth.ClaimsFromContext(ctx)
if err != nil {
return err
}
if !claims.HasAuth() {
return ErrorForbidden(ctx)
}
return nil
}
if err := m(); err != nil {
if web.RequestIsJson(r) {
return web.RespondJsonError(ctx, w, err)
}
return err
}
return after(ctx, w, r, params)
}
return h
}
return f
}
// HasRole validates that an authenticated user has at least one role from a
// specified list. This method constructs the actual function that is used.
func HasRole(roles ...string) web.Middleware {
@@ -80,14 +122,13 @@ func HasRole(roles ...string) web.Middleware {
defer span.Finish()
m := func() error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
// TODO(jlw) should this be a web.Shutdown?
return errors.New("claims missing from context: HasRole called without/before Authenticate")
claims, err := auth.ClaimsFromContext(ctx)
if err != nil {
return err
}
if !claims.HasRole(roles...) {
return ErrForbidden
return ErrorForbidden(ctx)
}
return nil

View File

@@ -0,0 +1,45 @@
package mid
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"net/http"
)
// CustomContext sets a default set of context values.
func CustomContext(values map[interface{}]interface{}) web.Middleware {
// This is the actual middleware function to be executed.
f := func(after web.Handler) web.Handler {
// Wrap this handler around the next one provided.
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.CustomContext")
defer span.Finish()
m := func() error {
for k, v := range values {
if cv := ctx.Value(k); cv == nil {
ctx = context.WithValue(ctx, k, v)
}
}
return nil
}
if err := m(); err != nil {
if web.RequestIsJson(r) {
return web.RespondJsonError(ctx, w, err)
}
return err
}
return after(ctx, w, r, params)
}
return h
}
return f
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
@@ -40,7 +41,7 @@ func Errors(log *log.Logger) web.Middleware {
// If we receive the shutdown err we need to return it
// back to the base handler to shutdown the service.
if ok := web.IsShutdown(err); ok {
if ok := weberror.IsShutdown(err); ok {
return err
}
}

View File

@@ -2,6 +2,7 @@ package mid
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"log"
"net/http"
"time"
@@ -22,14 +23,12 @@ func Logger(log *log.Logger) web.Middleware {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.Logger")
defer span.Finish()
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
err := before(ctx, w, r, params)
err = before(ctx, w, r, params)
log.Printf("%d : (%d) : %s %s -> %s (%s)\n",
span.Context().TraceID(),

View File

@@ -3,11 +3,13 @@ package mid
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"net/http"
)
// Trace adds the base tracing info for requests
@@ -35,17 +37,17 @@ func Trace() web.Middleware {
span, ctx := tracer.StartSpanFromContext(ctx, "http.request", opts...)
defer span.Finish()
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
// Load the context values.
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
v.TraceID = span.Context().TraceID()
v.SpanID = span.Context().SpanID()
// Execute the request handler
err := before(ctx, w, r, params)
err = before(ctx, w, r, params)
// Set the span status code for the trace
span.SetTag(ext.HTTPCode, v.StatusCode)

View File

@@ -0,0 +1,62 @@
package mid
import (
"context"
"net/http"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
httpext "github.com/go-playground/pkg/net/http"
ut "github.com/go-playground/universal-translator"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
func Translator(utrans *ut.UniversalTranslator) web.Middleware {
// This is the actual middleware function to be executed.
f := func(after web.Handler) web.Handler {
// Wrap this handler around the next one provided.
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.Translator")
defer span.Finish()
m := func() error {
locale, _ := params["locale"]
locale = "fr"
var t ut.Translator
if len(locale) > 0 {
var found bool
if t, found = utrans.GetTranslator(locale); found {
goto END
}
}
// get and parse the "Accept-Language" http header and return an array
t, _ = utrans.FindTranslator(httpext.AcceptedLanguages(r)...)
END:
ctx = webcontext.ContextWithTranslator(ctx, t)
return nil
}
if err := m(); err != nil {
if web.RequestIsJson(r) {
return web.RespondJsonError(ctx, w, err)
}
return err
}
return after(ctx, w, r, params)
}
return h
}
return f
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"context"
"fmt"
"time"
@@ -66,6 +67,14 @@ func (c Claims) Valid() error {
return nil
}
// HasAuth returns true the user is authenticated.
func (c Claims) HasAuth() bool {
if c.Subject != "" {
return true
}
return false
}
// HasRole returns true if the claims has at least one of the provided roles.
func (c Claims) HasRole(roles ...string) bool {
for _, has := range c.Roles {
@@ -78,9 +87,21 @@ func (c Claims) HasRole(roles ...string) bool {
return false
}
// TimeLocation returns the timezone used to format datetimes for the user.
func (c Claims) TimeLocation() *time.Location {
if c.tz == nil && c.Timezone != "" {
c.tz, _ = time.LoadLocation(c.Timezone)
}
return c.tz
}
// ClaimsFromContext loads the claims from context.
func ClaimsFromContext(ctx context.Context) (Claims, error) {
claims, ok := ctx.Value(Key).(Claims)
if !ok {
// TODO(jlw) should this be a web.Shutdown?
return Claims{}, errors.New("claims missing from context: HasRole called without/before Authenticate")
}
return claims, nil
}

View File

@@ -1,99 +0,0 @@
package web
import (
"github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
"net/http"
)
// FieldError is used to indicate an error with a specific request field.
type FieldError struct {
Field string `json:"field"`
Error string `json:"error"`
}
// ErrorResponse is the form used for API responses from failures in the API.
type ErrorResponse struct {
Error string `json:"error"`
Fields []FieldError `json:"fields,omitempty"`
}
// Error is used to pass an error during the request through the
// application with web specific context.
type Error struct {
Err error
Status int
Fields []FieldError
}
// NewRequestError wraps a provided error with an HTTP status code. This
// function should be used when handlers encounter expected errors.
func NewRequestError(err error, status int) error {
// if its a validation error then
if verr, ok := NewValidationError(err); ok {
return verr
}
return &Error{err, status, nil}
}
// Error implements the error interface. It uses the default message of the
// wrapped error. This is what will be shown in the services' logs.
func (err *Error) Error() string {
return err.Err.Error()
}
// shutdown is a type used to help with the graceful termination of the service.
type shutdown struct {
Message string
}
// Error is the implementation of the error interface.
func (s *shutdown) Error() string {
return s.Message
}
// NewShutdownError returns an error that causes the framework to signal
// a graceful shutdown.
func NewShutdownError(message string) error {
return &shutdown{message}
}
// NewValidationError checks the error for validation errors and formats the correct response.
func NewValidationError(err error) (error, bool) {
// Use a type assertion to get the real error value.
verrors, ok := errors.Cause(err).(validator.ValidationErrors)
if !ok {
return err, false
}
// lang controls the language of the error messages. You could look at the
// Accept-Language header if you intend to support multiple languages.
lang, _ := translator.GetTranslator("en")
var fields []FieldError
for _, verror := range verrors {
field := FieldError{
Field: verror.Field(),
Error: verror.Translate(lang),
}
fields = append(fields, field)
}
return &Error{
Err: errors.New("field validation error"),
Status: http.StatusBadRequest,
Fields: fields,
}, true
}
// IsShutdown checks to see if the shutdown error is contained
// in the specified error value.
func IsShutdown(err error) bool {
if _, ok := errors.Cause(err).(*shutdown); ok {
return true
}
return false
}

View File

@@ -1,20 +1,18 @@
package web
import (
"context"
"encoding/json"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"net"
"net/http"
"reflect"
"strings"
"github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator"
"github.com/gorilla/schema"
"github.com/pkg/errors"
"github.com/xwb1989/sqlparser"
"github.com/xwb1989/sqlparser/dependency/querypb"
"gopkg.in/go-playground/validator.v9"
en_translations "gopkg.in/go-playground/validator.v9/translations/en"
)
// Headers
@@ -33,74 +31,28 @@ const (
HeaderOrigin = "Origin"
)
// validate holds the settings and caches for validating request struct values.
var validate = validator.New()
// translator is a cache of locale and translation information.
var translator *ut.UniversalTranslator
func init() {
// Instantiate the english locale for the validator library.
enLocale := en.New()
// Create a value using English as the fallback locale (first argument).
// Provide one or more arguments for additional supported locales.
translator = ut.New(enLocale, enLocale)
// Register the english error messages for validation errors.
lang, _ := translator.GetTranslator("en")
en_translations.RegisterDefaultTranslations(validate, lang)
// Use JSON tag names for errors instead of Go struct names.
validate = NewValidator()
// Empty method that can be overwritten in business logic packages to prevent web.Decode from failing.
f := func(fl validator.FieldLevel) bool {
return true
}
validate.RegisterValidation("unique", f)
}
// NewValidator inits a new validator with custom settings.
func NewValidator() *validator.Validate {
var v = validator.New()
// Use JSON tag names for errors instead of Go struct names.
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
return v
}
// Decode reads the body of an HTTP request looking for a JSON document. The
// body is decoded into the provided value.
//
// If the provided value is a struct then it is checked for validation tags.
func Decode(r *http.Request, val interface{}) error {
func Decode(ctx context.Context, r *http.Request, val interface{}) error {
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(val); err != nil {
err = errors.Wrap(err, "decode request body failed")
return NewRequestError(err, http.StatusBadRequest)
return weberror.NewErrorMessage(ctx, err, http.StatusBadRequest, "decode request body failed")
}
} else {
decoder := schema.NewDecoder()
if err := decoder.Decode(val, r.URL.Query()); err != nil {
err = errors.Wrap(err, "decode request query failed")
return NewRequestError(err, http.StatusBadRequest)
return weberror.NewErrorMessage(ctx, err, http.StatusBadRequest, "decode request query failed")
}
}
if err := validate.Struct(val); err != nil {
verr, _ := NewValidationError(err)
if err := webcontext.Validator().Struct(val); err != nil {
verr, _ := weberror.NewValidationError(ctx, err)
return verr
}

View File

@@ -4,12 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/pkg/errors"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"html/template"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
)
const (
@@ -30,32 +33,20 @@ const (
// RespondJsonError sends an error formatted as JSON response back to the client.
func RespondJsonError(ctx context.Context, w http.ResponseWriter, err error) error {
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
webErr, ok := errors.Cause(err).(*Error)
if !ok {
webErr, ok = err.(*Error)
}
if ok {
er := ErrorResponse{
Error: webErr.Err.Error(),
Fields: webErr.Fields,
}
if err := RespondJson(ctx, w, er, webErr.Status); err != nil {
return err
}
return nil
}
// If not, the handler sent any arbitrary error value so use 500.
er := ErrorResponse{
Error: http.StatusText(http.StatusInternalServerError),
}
if err := RespondJson(ctx, w, er, http.StatusInternalServerError); err != nil {
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
return nil
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
webErr := weberror.NewError(ctx, err, v.StatusCode).(*weberror.Error)
v.StatusCode = webErr.Status
return RespondJson(ctx, w, webErr.Display(ctx), webErr.Status)
}
// RespondJson converts a Go value to JSON and sends it to the client.
@@ -65,9 +56,9 @@ func RespondJson(ctx context.Context, w http.ResponseWriter, data interface{}, s
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, ok := ctx.Value(KeyValues).(*Values)
if !ok {
return NewShutdownError("web value missing from context")
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
v.StatusCode = statusCode
@@ -105,25 +96,39 @@ func RespondJson(ctx context.Context, w http.ResponseWriter, data interface{}, s
// RespondError sends an error back to the client as plain text with
// the status code 500 Internal Service Error
func RespondError(ctx context.Context, w http.ResponseWriter, er error) error {
return RespondErrorStatus(ctx, w, er, http.StatusInternalServerError)
return RespondErrorStatus(ctx, w, er, 0)
}
// RespondErrorStatus sends an error back to the client as plain text with
// the specified HTTP status code.
func RespondErrorStatus(ctx context.Context, w http.ResponseWriter, er error, statusCode int) error {
msg := fmt.Sprintf("%s", er)
if err := Respond(ctx, w, []byte(msg), statusCode, MIMETextPlainCharsetUTF8); err != nil {
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
return nil
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
webErr := weberror.NewError(ctx, er, v.StatusCode).(*weberror.Error)
v.StatusCode = webErr.Status
respErr := webErr.Display(ctx).String()
switch webcontext.ContextEnv(ctx) {
case webcontext.Env_Dev, webcontext.Env_Stage:
respErr = respErr + fmt.Sprintf("\n%s\n%+v", webErr.Error(), webErr.Cause)
}
return RespondText(ctx, w, respErr, statusCode)
}
// RespondText sends text back to the client as plain text with the specified HTTP status code.
func RespondText(ctx context.Context, w http.ResponseWriter, text string, statusCode int) error {
if err := Respond(ctx, w, []byte(text), statusCode, MIMETextPlainCharsetUTF8); err != nil {
return err
}
return nil
return Respond(ctx, w, []byte(text), statusCode, MIMETextPlainCharsetUTF8)
}
// Respond writes the data to the client with the specified HTTP status code and
@@ -133,9 +138,9 @@ func Respond(ctx context.Context, w http.ResponseWriter, data []byte, statusCode
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, ok := ctx.Value(KeyValues).(*Values)
if !ok {
return NewShutdownError("web value missing from context")
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
v.StatusCode = statusCode
@@ -159,6 +164,46 @@ func Respond(ctx context.Context, w http.ResponseWriter, data []byte, statusCode
return nil
}
// RenderError sends an error back to the client as html with
// the specified HTTP status code.
func RenderError(ctx context.Context, w http.ResponseWriter, r *http.Request, er error, renderer Renderer, templateLayoutName, templateContentName, contentType string) error {
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, err := webcontext.ContextValues(ctx)
if err != nil {
return err
}
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
webErr := weberror.NewError(ctx, er, v.StatusCode).(*weberror.Error)
v.StatusCode = webErr.Status
respErr := webErr.Display(ctx)
var fullError string
switch webcontext.ContextEnv(ctx) {
case webcontext.Env_Dev, webcontext.Env_Stage:
if webErr.Cause != nil && webErr.Cause.Error() != webErr.Err.Error() {
fullError = fmt.Sprintf("\n%s\n%+v", webErr.Error(), webErr.Cause)
} else {
fullError = fmt.Sprintf("%+v", webErr.Err)
}
fullError = strings.Replace(fullError, "\n", "<br/>", -1)
}
data := map[string]interface{}{
"statusCode": webErr.Status,
"errorMessage": respErr.Error,
"fullError": template.HTML(fullError),
}
return renderer.Render(ctx, w, r, templateLayoutName, templateContentName, contentType, webErr.Status, data)
}
// Static registers a new route with path prefix to serve static files from the
// provided root directory. All errors will result in 404 File Not Found.
func Static(rootDir, prefix string) Handler {

View File

@@ -3,12 +3,15 @@ package template_renderer
import (
"context"
"fmt"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"html/template"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
@@ -105,6 +108,49 @@ func NewTemplate(templateFuncs template.FuncMap) *Template {
"html": func(value interface{}) template.HTML {
return template.HTML(fmt.Sprint(value))
},
"HasAuth": func(ctx context.Context) bool {
claims, err := auth.ClaimsFromContext(ctx)
if err != nil {
return false
}
return claims.HasAuth()
},
"HasRole": func(ctx context.Context, roles ...string) bool {
claims, err := auth.ClaimsFromContext(ctx)
if err != nil {
return false
}
return claims.HasRole(roles...)
},
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values) == 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{})
for i := 0; i < len(values); i++ {
key, isset := values[i].(string)
if !isset {
if reflect.TypeOf(values[i]).Kind() == reflect.Map {
m := values[i].(map[string]interface{})
for i, v := range m {
dict[i] = v
}
} else {
return nil, errors.New("dict values must be maps")
}
} else {
i++
if i == len(values) {
return nil, errors.New("specify the key for non array values")
}
dict[key] = values[i]
}
}
return dict, nil
},
}
for fn, f := range templateFuncs {
t.Funcs[fn] = f
@@ -280,6 +326,24 @@ func (r *TemplateRenderer) Render(ctx context.Context, w http.ResponseWriter, re
// to define context.Context as an argument
renderData["_Ctx"] = ctx
if qv := req.URL.Query().Get("test-validation-error"); qv != "" {
data["validationErrors"] = data["validationDefaults"]
}
if qv := req.URL.Query().Get("test-web-error"); qv != "" {
terr := errors.New("Some random error")
terr = errors.WithMessage(terr, "Actual error message")
rerr := weberror.NewError(ctx, terr, http.StatusBadRequest).(*weberror.Error)
rerr.Message = "Test Web Error Message"
data["error"] = rerr
}
if qv := req.URL.Query().Get("test-error"); qv != "" {
terr := errors.New("Test error")
terr = errors.WithMessage(terr, "Error message")
data["error"] = terr
}
// Append request data map to render data last so any previous value can be overwritten.
if data != nil {
for k, v := range data {

View File

@@ -2,6 +2,8 @@ package web
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror"
"log"
"net/http"
"os"
@@ -11,20 +13,6 @@ import (
"github.com/dimfeld/httptreemux"
)
// ctxKey represents the type of value for the context key.
type ctxKey int
// KeyValues is how request values or stored/retrieved.
const KeyValues ctxKey = 1
// Values represent state for each request.
type Values struct {
Now time.Time
TraceID uint64
SpanID uint64
StatusCode int
}
// A Handler is a type that handles an http request within our own little mini
// framework.
type Handler func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error
@@ -36,15 +24,17 @@ type App struct {
*httptreemux.TreeMux
shutdown chan os.Signal
log *log.Logger
env webcontext.Env
mw []Middleware
}
// NewApp creates an App value that handle a set of routes for the application.
func NewApp(shutdown chan os.Signal, log *log.Logger, mw ...Middleware) *App {
func NewApp(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, mw ...Middleware) *App {
app := App{
TreeMux: httptreemux.New(),
shutdown: shutdown,
log: log,
env: env,
mw: mw,
}
@@ -76,17 +66,18 @@ func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
h := func(w http.ResponseWriter, r *http.Request, params map[string]string) {
// Set the context with the required values to
// process the request.
v := Values{
v := webcontext.Values{
Now: time.Now(),
Env: a.env,
}
ctx := context.WithValue(r.Context(), KeyValues, &v)
ctx := context.WithValue(r.Context(), webcontext.KeyValues, &v)
// Call the wrapped handler functions.
err := handler(ctx, w, r, params)
if err != nil {
// If we have specifically handled the error, then no need
// to initiate a shutdown.
if webErr, ok := err.(*Error); ok {
if webErr, ok := err.(*weberror.Error); ok {
// Render an error response.
if rerr := RespondErrorStatus(ctx, w, webErr.Err, webErr.Status); rerr == nil {
// If there was not error rending the error, then no need to continue.

View File

@@ -0,0 +1,53 @@
package webcontext
import (
"context"
"time"
"github.com/pkg/errors"
)
// ctxKey represents the type of value for the context key.
type ctxKey int
// KeyValues is how request values or stored/retrieved.
const KeyValues ctxKey = 1
var ErrContextRequired = errors.New("web value missing from context")
// Values represent state for each request.
type Values struct {
Now time.Time
TraceID uint64
SpanID uint64
StatusCode int
Env Env
}
func ContextValues(ctx context.Context) (*Values, error) {
// If the context is missing this value, request the service
// to be shutdown gracefully.
v, ok := ctx.Value(KeyValues).(*Values)
if !ok {
e := Values{}
return &e, ErrContextRequired
}
return v, nil
}
type Env = string
var (
Env_Dev Env = "dev"
Env_Stage Env = "stage"
Env_Prod Env = "prod"
)
func ContextEnv(ctx context.Context) string {
cv := ctx.Value(KeyValues).(*Values)
if cv != nil {
return cv.Env
}
return ""
}

View File

@@ -0,0 +1,157 @@
package webcontext
import (
"context"
"reflect"
"strings"
"github.com/go-playground/locales/en"
"github.com/go-playground/locales/fr"
"github.com/go-playground/locales/id"
"github.com/go-playground/locales/ja"
"github.com/go-playground/locales/nl"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"gopkg.in/go-playground/validator.v9"
en_translations "gopkg.in/go-playground/validator.v9/translations/en"
fr_translations "gopkg.in/go-playground/validator.v9/translations/fr"
id_translations "gopkg.in/go-playground/validator.v9/translations/id"
ja_translations "gopkg.in/go-playground/validator.v9/translations/ja"
nl_translations "gopkg.in/go-playground/validator.v9/translations/nl"
zh_translations "gopkg.in/go-playground/validator.v9/translations/zh"
)
// ctxKeyTranslate represents the type of value for the context key.
type ctxKeyTranslate int
// KeyTranslate is used to store/retrieve a Claims value from a context.Context.
const KeyTranslate ctxKeyTranslate = 1
// ContextWithTranslator appends a universal translator to a context.
func ContextWithTranslator(ctx context.Context, translator ut.Translator) context.Context {
return context.WithValue(ctx, KeyTranslate, translator)
}
// ContextTranslator returns the universal context from a context.
func ContextTranslator(ctx context.Context) ut.Translator {
return ctx.Value(KeyTranslate).(ut.Translator)
}
// validate holds the settings and caches for validating request struct values.
var validate *validator.Validate
// translator is a cache of locale and translation information.
var uniTrans *ut.UniversalTranslator
func init() {
// Example from https://github.com/go-playground/universal-translator/issues/7
// Instantiate the english and french locale for the validator library.
en := en.New()
fr := fr.New()
id := id.New()
ja := ja.New()
nl := nl.New()
zh := zh.New()
// Create a value using English as the fallback locale (first argument).
// Provide one or more arguments for additional supported locales.
uniTrans = ut.New(en, en, fr, id, ja, nl, zh)
// this is usually know or extracted from http 'Accept-Language' header
// also see uni.FindTranslator(...)
transEn, _ := uniTrans.GetTranslator(en.Locale())
transFr, _ := uniTrans.GetTranslator(fr.Locale())
transId, _ := uniTrans.GetTranslator(id.Locale())
transJa, _ := uniTrans.GetTranslator(ja.Locale())
transNl, _ := uniTrans.GetTranslator(nl.Locale())
transZh, _ := uniTrans.GetTranslator(zh.Locale())
transEn.Add("{{name}}", "Name", false)
transFr.Add("{{name}}", "Nom", false)
transEn.Add("{{first_name}}", "First Name", false)
transFr.Add("{{first_name}}", "Prénom", false)
transEn.Add("{{last_name}}", "Last Name", false)
transFr.Add("{{last_name}}", "Nom de famille", false)
validate = newValidator()
en_translations.RegisterDefaultTranslations(validate, transEn)
fr_translations.RegisterDefaultTranslations(validate, transFr)
id_translations.RegisterDefaultTranslations(validate, transId)
ja_translations.RegisterDefaultTranslations(validate, transJa)
nl_translations.RegisterDefaultTranslations(validate, transNl)
zh_translations.RegisterDefaultTranslations(validate, transZh)
/*
validate.RegisterTranslation("required", transEn, func(ut ut.Translator) error {
return ut.Add("required", "{0} must have a value!", true) // see universal-translator for details
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("required", fe.Field())
return t
})
validate.RegisterTranslation("required", transFr, func(ut ut.Translator) error {
return ut.Add("required", "{0} must have a value!", true) // see universal-translator for details
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("required", fe.Field())
return t
})
validate.RegisterTranslation("unique", transEn, func(ut ut.Translator) error {
return ut.Add("unique", "{0} must be unique", true) // see universal-translator for details
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("unique", fe.Field())
return t
})
validate.RegisterTranslation("unique", transFr, func(ut ut.Translator) error {
return ut.Add("unique", "{0} must be unique", true) // see universal-translator for details
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("unique", fe.Field())
return t
})*/
}
// newValidator inits a new validator with custom settings.
func newValidator() *validator.Validate {
var v = validator.New()
// Use JSON tag names for errors instead of Go struct names.
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return "{{" + name + "}}"
})
// Empty method that can be overwritten in business logic packages to prevent web.Decode from failing.
f := func(fl validator.FieldLevel) bool {
return true
}
v.RegisterValidation("unique", f)
return v
}
// Validator returns the current init validator.
func Validator() *validator.Validate {
return validate
}
// UniversalTranslator returns the current UniversalTranslator.
func UniversalTranslator() *ut.UniversalTranslator {
return uniTrans
}

View File

@@ -0,0 +1,121 @@
package weberror
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"net/http"
"github.com/pkg/errors"
)
// Error is used to pass an error during the request through the
// application with web specific context.
type Error struct {
Err error
Status int
Fields []FieldError
Cause error
Message string
}
// FieldError is used to indicate an error with a specific request field.
type FieldError struct {
Field string `json:"field"`
FormField string `json:"-"`
Value interface{} `json:"value"`
Tag string `json:"tag"`
Error string `json:"error"`
Display string `json:"display"`
}
// NewError wraps a provided error with an HTTP status code. This
// function should be used when handlers encounter expected errors.
func NewError(ctx context.Context, er error, status int) error {
webErr, ok := er.(*Error)
if ok {
return webErr
}
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
webErr, ok = errors.Cause(er).(*Error)
if ok {
return webErr
}
// Ensure the error is not a validation error.
if ne, ok := NewValidationError(ctx, er); ok {
return ne
}
if er == webcontext.ErrContextRequired {
return NewShutdownError(er.Error())
}
// If not, the handler sent any arbitrary error value so use 500.
if status == 0 {
status = http.StatusInternalServerError
}
cause := errors.Cause(er)
if cause == nil {
cause = er
}
return &Error{er, status, nil, cause, ""}
}
// Error implements the error interface. It uses the default message of the
// wrapped error. This is what will be shown in the services' logs.
func (err *Error) Error() string {
return err.Err.Error()
}
// Display renders an error that can be returned as ErrorResponse to the user via the API.
func (er *Error) Display(ctx context.Context) ErrorResponse {
var r ErrorResponse
if er.Message != "" {
r.Error = er.Message
} else {
r.Error = er.Error()
}
if len(er.Fields) > 0 {
r.Fields = er.Fields
}
return r
}
// ErrorResponse is the form used for API responses from failures in the API.
type ErrorResponse struct {
Error string `json:"error"`
Fields []FieldError `json:"fields,omitempty"`
}
// String returns the ErrorResponse formatted as a string.
func (er ErrorResponse) String() string {
str := er.Error
if len(er.Fields) > 0 {
for _, f := range er.Fields {
str = str + "\t" + f.Error + "\n"
}
}
return str
}
// NewErrorMessage wraps a provided error with an HTTP status code and message. The
// message value is given priority and returned as the error message.
func NewErrorMessage(ctx context.Context, er error, status int, msg string) error {
return WithMessage(ctx, NewError(ctx, er, status), msg)
}
// WithMessage appends the error with a message.
func WithMessage(ctx context.Context, er error, msg string) error {
weberr := NewError(ctx, er, 0).(*Error)
weberr.Message = msg
return weberr
}

View File

@@ -0,0 +1,28 @@
package weberror
import "github.com/pkg/errors"
// shutdown is a type used to help with the graceful termination of the service.
type shutdown struct {
Message string
}
// Error is the implementation of the error interface.
func (s *shutdown) Error() string {
return s.Message
}
// NewShutdownError returns an error that causes the framework to signal
// a graceful shutdown.
func NewShutdownError(message string) error {
return &shutdown{message}
}
// IsShutdown checks to see if the shutdown error is contained
// in the specified error value.
func IsShutdown(err error) bool {
if _, ok := errors.Cause(err).(*shutdown); ok {
return true
}
return false
}

View File

@@ -0,0 +1,103 @@
package weberror
import (
"context"
"fmt"
"net/http"
"strings"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"github.com/iancoleman/strcase"
"github.com/pkg/errors"
"gopkg.in/go-playground/validator.v9"
)
// NewValidationError checks the error for validation errors and formats the correct response.
func NewValidationError(ctx context.Context, err error) (error, bool) {
// Use a type assertion to get the real error value.
verrors, ok := errors.Cause(err).(validator.ValidationErrors)
if !ok {
return err, false
}
// Load the translator from the context that will be set by query string or HTTP headers.
trans := webcontext.ContextTranslator(ctx)
//fields := make(map[string][]FieldError)
var fields []FieldError
for _, verror := range verrors {
jsonKey := verror.Field()
fieldName := jsonKey[2 : len(jsonKey)-2]
localName, _ := trans.T(jsonKey)
if localName == "" {
localName = fieldName
}
namespace := strings.Replace(verror.Namespace(), "{{", "", -1)
namespace = strings.Replace(namespace, "}}", "", -1)
fieldErr := strings.Replace(verror.Translate(trans), jsonKey, localName, -1)
fieldErr = strings.Replace(fieldErr, "{{", "", -1)
fieldErr = strings.Replace(fieldErr, "}}", "", -1)
field := FieldError{
Field: verror.Field(),
Value: verror.Value(),
Tag: verror.Tag(),
Error: fieldErr,
FormField: FormField(verror.StructNamespace()),
Display: fieldErr,
}
switch verror.Tag() {
case "required":
field.Display = fmt.Sprintf("%s is required.", localName)
case "unique":
field.Display = fmt.Sprintf("%s must be unique.", localName)
}
/*
fmt.Println("field", field.Error)
fmt.Println("formField", field.FormField)
fmt.Println("Namespace: " + verror.Namespace())
fmt.Println("Field: " + verror.Field())
fmt.Println("StructNamespace: " + verror.StructNamespace()) // can differ when a custom TagNameFunc is registered or
fmt.Println("StructField: " + verror.StructField()) // by passing alt name to ReportError like below
fmt.Println("Tag: " + verror.Tag())
fmt.Println("ActualTag: " + verror.ActualTag())
fmt.Println("Kind: ", verror.Kind())
fmt.Println("Type: ", verror.Type())
fmt.Println("Value: ", verror.Value())
fmt.Println("Param: " + verror.Param())
fmt.Println()
*/
fields = append(fields, field)
}
return &Error{
Err: err,
Status: http.StatusBadRequest,
Fields: fields,
Cause: err,
Message: "Field validation error",
}, true
}
func FormField(namespace string) string {
if !strings.Contains(namespace, ".") {
return namespace
}
pts := strings.Split(namespace, ".")
var newPts []string
for i := 1; i < len(pts); i++ {
newPts = append(newPts, strcase.ToCamel(pts[i]))
}
return strings.Join(newPts, ".")
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"github.com/huandu/go-sqlbuilder"
"github.com/jmoiron/sqlx"
"github.com/pborman/uuid"
@@ -231,7 +231,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec
}
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return nil, err
@@ -302,7 +302,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Projec
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -372,7 +372,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Proje
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -428,7 +428,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string)
}
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err

View File

@@ -14,7 +14,7 @@ import (
// migration already exists in the migrations table it will be skipped.
func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return []*sqlxmigrate.Migration{
// create table users
// Create table users.
{
ID: "20190522-01a",
Migrate: func(tx *sql.Tx) error {
@@ -45,7 +45,7 @@ func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return nil
},
},
// create new table accounts
// Create new table accounts.
{
ID: "20190522-01b",
Migrate: func(tx *sql.Tx) error {
@@ -91,7 +91,7 @@ func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return nil
},
},
// create new table user_accounts
// Create new table user_accounts.
{
ID: "20190522-01d",
Migrate: func(tx *sql.Tx) error {
@@ -142,7 +142,7 @@ func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return nil
},
},
// create new table projects
// Create new table projects.
{
ID: "20190622-01",
Migrate: func(tx *sql.Tx) error {
@@ -179,5 +179,31 @@ func migrationList(db *sqlx.DB, log *log.Logger) []*sqlxmigrate.Migration {
return nil
},
},
// Split users.name into first_name and last_name columns.
{
ID: "201907-29-01a",
Migrate: func(tx *sql.Tx) error {
q1 := `ALTER TABLE users
RENAME COLUMN name to first_name;`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
q2 := `ALTER TABLE users
ADD last_name varchar(200) NOT NULL DEFAULT '';`
if _, err := tx.Exec(q2); err != nil {
return errors.WithMessagef(err, "Query failed %s", q2)
}
return nil
},
Rollback: func(tx *sql.Tx) error {
q1 := `DROP TABLE IF EXISTS users`
if _, err := tx.Exec(q1); err != nil {
return errors.WithMessagef(err, "Query failed %s", q1)
}
return nil
},
},
}
}

View File

@@ -26,10 +26,11 @@ type SignupAccount struct {
// SignupUser defined the details needed for user.
type SignupUser struct {
Name string `json:"name" validate:"required" example:"Gabi May"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Email string `json:"email" validate:"required,email,unique" example:"{RANDOM_EMAIL}"`
Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
}
// SignupResult response signup with created account and user.

View File

@@ -2,16 +2,15 @@ package signup
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/user"
"geeks-accelerator/oss/saas-starter-kit/internal/user_account"
"github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"gopkg.in/go-playground/validator.v9"
"time"
)
// Signup performs the steps needed to create a new account, new user and then associate
@@ -48,7 +47,7 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup
return uniq
}
v := web.NewValidator()
v := webcontext.Validator()
v.RegisterValidation("unique", f)
// Validate the request.
@@ -61,7 +60,8 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup
// UserCreateRequest contains information needed to create a new User.
userReq := user.UserCreateRequest{
Name: req.User.Name,
FirstName: req.User.FirstName,
LastName: req.User.LastName,
Email: req.User.Email,
Password: req.User.Password,
PasswordConfirm: req.User.PasswordConfirm,

View File

@@ -4,7 +4,7 @@ import (
"context"
"crypto/rsa"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"github.com/dgrijalva/jwt-go"
"strings"
"time"
@@ -80,7 +80,7 @@ func SwitchAccount(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator,
}
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return Token{}, err

View File

@@ -40,7 +40,8 @@ func TestAuthenticate(t *testing.T) {
// Create a new user for testing.
initPass := uuid.NewRandom().String()
user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: initPass,
PasswordConfirm: initPass,

View File

@@ -13,7 +13,8 @@ import (
// User represents someone with access to our system.
type User struct {
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" validate:"required" example:"Gabi May"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
PasswordSalt string `json:"-" validate:"required"`
PasswordHash []byte `json:"-" validate:"required"`
@@ -27,7 +28,8 @@ type User struct {
// 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"`
FirstName string `json:"first_name" example:"Gabi"`
LastName string `json:"last_name" example:"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.
@@ -44,7 +46,8 @@ func (m *User) Response(ctx context.Context) *UserResponse {
r := &UserResponse{
ID: m.ID,
Name: m.Name,
FirstName: m.FirstName,
LastName: m.LastName,
Email: m.Email,
Timezone: m.Timezone,
CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt),
@@ -61,10 +64,11 @@ func (m *User) Response(ctx context.Context) *UserResponse {
// UserCreateRequest contains information needed to create a new User.
type UserCreateRequest struct {
Name string `json:"name" validate:"required" example:"Gabi May"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
Password string `json:"password" validate:"required" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password" example:"SecretString"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
}
@@ -75,17 +79,18 @@ type UserCreateRequest struct {
// we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling.
type UserUpdateRequest struct {
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name *string `json:"name,omitempty" validate:"omitempty" example:"Gabi May Not"`
Email *string `json:"email,omitempty" validate:"omitempty,email,unique" example:"gabi.may@geeksinthewoods.com"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
FirstName *string `json:"first_name,omitempty" validate:"omitempty" example:"Gabi May Not"`
LastName *string `json:"last_name,omitempty" validate:"omitempty" example:"Gabi May Not"`
Email *string `json:"email,omitempty" validate:"omitempty,email,unique" example:"gabi.may@geeksinthewoods.com"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
}
// UserUpdatePasswordRequest defines what information is required to update a user password.
type UserUpdatePasswordRequest struct {
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Password string `json:"password" validate:"required" example:"NeverTellSecret"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password" example:"NeverTellSecret"`
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"NeverTellSecret"`
}
// UserArchiveRequest defines the information needed to archive an user. This will archive (soft-delete) the

View File

@@ -3,7 +3,7 @@ package user
import (
"context"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
@@ -38,7 +38,7 @@ var (
)
// userMapColumns is the list of columns needed for mapRowsToUser
var userMapColumns = "id,name,email,password_salt,password_hash,password_reset,timezone,created_at,updated_at,archived_at"
var userMapColumns = "id,first_name,last_name,email,password_salt,password_hash,password_reset,timezone,created_at,updated_at,archived_at"
// mapRowsToUser takes the SQL rows and maps it to the UserAccount struct
// with the columns defined by userMapColumns
@@ -47,7 +47,7 @@ func mapRowsToUser(rows *sql.Rows) (*User, error) {
u User
err error
)
err = rows.Scan(&u.ID, &u.Name, &u.Email, &u.PasswordSalt, &u.PasswordHash, &u.PasswordReset, &u.Timezone, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt)
err = rows.Scan(&u.ID, &u.FirstName, &u.LastName, &u.Email, &u.PasswordSalt, &u.PasswordHash, &u.PasswordReset, &u.Timezone, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt)
if err != nil {
return nil, errors.WithStack(err)
}
@@ -290,7 +290,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
return uniq
}
v := web.NewValidator()
v := webcontext.Validator()
v.RegisterValidation("unique", f)
// Validate the request.
@@ -331,7 +331,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
u := User{
ID: uuid.NewRandom().String(),
Name: req.Name,
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
PasswordHash: passwordHash,
PasswordSalt: passwordSalt,
@@ -347,8 +348,8 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
// Build the insert SQL statement.
query := sqlbuilder.NewInsertBuilder()
query.InsertInto(userTableName)
query.Cols("id", "name", "email", "password_hash", "password_salt", "timezone", "created_at", "updated_at")
query.Values(u.ID, u.Name, u.Email, u.PasswordHash, u.PasswordSalt, u.Timezone, u.CreatedAt, u.UpdatedAt)
query.Cols("id", "first_name", "last_name", "email", "password_hash", "password_salt", "timezone", "created_at", "updated_at")
query.Values(u.ID, u.FirstName, u.LastName, u.Email, u.PasswordHash, u.PasswordSalt, u.Timezone, u.CreatedAt, u.UpdatedAt)
// Execute the query with the provided context.
sql, args := query.Build()
@@ -389,7 +390,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update")
defer span.Finish()
v := web.NewValidator()
v := webcontext.Validator()
// Validation email address is unique in the database.
if req.Email != nil {
@@ -435,8 +436,11 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUp
query.Update(userTableName)
var fields []string
if req.Name != nil {
fields = append(fields, query.Assign("name", req.Name))
if req.FirstName != nil {
fields = append(fields, query.Assign("name", req.FirstName))
}
if req.LastName != nil {
fields = append(fields, query.Assign("name", req.LastName))
}
if req.Email != nil {
fields = append(fields, query.Assign("email", req.Email))
@@ -475,7 +479,7 @@ func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, re
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -544,7 +548,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -623,7 +627,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID str
}
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err

View File

@@ -32,7 +32,7 @@ func testMain(m *testing.M) int {
// TestFindRequestQuery validates findRequestQuery
func TestFindRequestQuery(t *testing.T) {
where := "name = ? or email = ?"
where := "first_name = ? or email = ?"
var (
limit uint = 12
offset uint = 34
@@ -41,7 +41,7 @@ func TestFindRequestQuery(t *testing.T) {
req := UserFindRequest{
Where: &where,
Args: []interface{}{
"lee brown",
"lee",
"lee@geeksinthewoods.com",
},
Order: []string{
@@ -51,7 +51,7 @@ func TestFindRequestQuery(t *testing.T) {
Limit: &limit,
Offset: &offset,
}
expected := "SELECT " + userMapColumns + " FROM " + userTableName + " WHERE (name = ? or email = ?) ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34"
expected := "SELECT " + userMapColumns + " FROM " + userTableName + " WHERE (first_name = ? or email = ?) ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34"
res, args := findRequestQuery(req)
@@ -149,13 +149,15 @@ func TestCreateValidation(t *testing.T) {
func(req UserCreateRequest, res *User) *User {
return nil
},
errors.New("Key: 'UserCreateRequest.name' Error:Field validation for 'name' failed on the 'required' tag\n" +
errors.New("Key: 'UserCreateRequest.first_name' Error:Field validation for 'first_name' failed on the 'required' tag\n" +
"Key: 'UserCreateRequest.last_name' Error:Field validation for 'last_name' failed on the 'required' tag\n" +
"Key: 'UserCreateRequest.email' Error:Field validation for 'email' failed on the 'required' tag\n" +
"Key: 'UserCreateRequest.password' Error:Field validation for 'password' failed on the 'required' tag"),
},
{"Valid Email",
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: "xxxxxxxxxx",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -167,7 +169,8 @@ func TestCreateValidation(t *testing.T) {
},
{"Passwords Match",
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "W0rkL1fe#",
@@ -179,16 +182,18 @@ func TestCreateValidation(t *testing.T) {
},
{"Default Timezone",
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
},
func(req UserCreateRequest, res *User) *User {
return &User{
Name: req.Name,
Email: req.Email,
Timezone: "America/Anchorage",
FirstName: "Lee",
LastName: "Brown",
Email: req.Email,
Timezone: "America/Anchorage",
// Copy this fields from the result.
ID: res.ID,
@@ -259,7 +264,8 @@ func TestCreateValidationEmailUnique(t *testing.T) {
ctx := tests.Context()
req1 := UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -271,7 +277,8 @@ func TestCreateValidationEmailUnique(t *testing.T) {
}
req2 := UserCreateRequest{
Name: "Lucas Brown",
FirstName: "Lee",
LastName: "Brown",
Email: user1.Email,
Password: "W0rkL1fe#",
PasswordConfirm: "W0rkL1fe#",
@@ -307,7 +314,8 @@ func TestCreateClaims(t *testing.T) {
{"EmptyClaims",
auth.Claims{},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -324,7 +332,8 @@ func TestCreateClaims(t *testing.T) {
},
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -341,7 +350,8 @@ func TestCreateClaims(t *testing.T) {
},
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -441,7 +451,8 @@ func TestUpdateValidationEmailUnique(t *testing.T) {
ctx := tests.Context()
req1 := UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -453,7 +464,8 @@ func TestUpdateValidationEmailUnique(t *testing.T) {
}
req2 := UserCreateRequest{
Name: "Lucas Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "W0rkL1fe#",
PasswordConfirm: "W0rkL1fe#",
@@ -500,7 +512,8 @@ func TestUpdatePassword(t *testing.T) {
// Create a new user for testing.
initPass := uuid.NewRandom().String()
user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: initPass,
PasswordConfirm: initPass,
@@ -591,7 +604,8 @@ func TestCrud(t *testing.T) {
return auth.Claims{}
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -609,7 +623,8 @@ func TestCrud(t *testing.T) {
Email: *req.Email,
// Copy this fields from the created user.
ID: user.ID,
Name: user.Name,
FirstName: user.FirstName,
LastName: user.LastName,
PasswordSalt: user.PasswordSalt,
PasswordHash: user.PasswordHash,
PasswordReset: user.PasswordReset,
@@ -634,7 +649,8 @@ func TestCrud(t *testing.T) {
}
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -665,7 +681,8 @@ func TestCrud(t *testing.T) {
}
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -683,7 +700,8 @@ func TestCrud(t *testing.T) {
Email: *req.Email,
// Copy this fields from the created user.
ID: user.ID,
Name: user.Name,
FirstName: user.FirstName,
LastName: user.LastName,
PasswordSalt: user.PasswordSalt,
PasswordHash: user.PasswordHash,
PasswordReset: user.PasswordReset,
@@ -708,7 +726,8 @@ func TestCrud(t *testing.T) {
}
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -739,7 +758,8 @@ func TestCrud(t *testing.T) {
}
},
UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",
@@ -757,7 +777,8 @@ func TestCrud(t *testing.T) {
Email: *req.Email,
// Copy this fields from the created user.
ID: user.ID,
Name: user.Name,
FirstName: user.FirstName,
LastName: user.LastName,
PasswordSalt: user.PasswordSalt,
PasswordHash: user.PasswordHash,
PasswordReset: user.PasswordReset,
@@ -882,7 +903,8 @@ func TestFind(t *testing.T) {
var users []*User
for i := 0; i <= 4; i++ {
user, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, UserCreateRequest{
Name: "Lee Brown",
FirstName: "Lee",
LastName: "Brown",
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
Password: "akTechFr0n!ier",
PasswordConfirm: "akTechFr0n!ier",

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"geeks-accelerator/oss/saas-starter-kit/internal/account"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"time"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
@@ -203,7 +203,7 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return nil, err
@@ -318,7 +318,7 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -391,7 +391,7 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err
@@ -443,7 +443,7 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAc
defer span.Finish()
// Validate the request.
v := web.NewValidator()
v := webcontext.Validator()
err := v.Struct(req)
if err != nil {
return err