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

authenticator storage engines

Created a storage interface used by authenticator to support multiple
types of storage types for private keys. Added a new file storage engine
which is now the default for web-api. Migrated aws secrets manager to be optional.
This commit is contained in:
Lee Brown
2019-06-24 17:36:42 -08:00
parent 07e86cfd52
commit ca8670eadf
42 changed files with 1967 additions and 428 deletions

View File

@ -7,10 +7,10 @@ import (
"net/url" "net/url"
"os" "os"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/schema"
"github.com/lib/pq"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/schema"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"github.com/lib/pq"
_ "github.com/lib/pq" _ "github.com/lib/pq"
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql" sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx" sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"

View File

@ -5,6 +5,8 @@ LABEL maintainer="lee@geeksinthewoods.com"
RUN apk --update --no-cache add \ RUN apk --update --no-cache add \
git git
RUN go get -u github.com/swaggo/swag/cmd/swag
# go to base project # go to base project
WORKDIR $GOPATH/src/gitlab.com/geeks-accelerator/oss/saas-starter-kit/example-project WORKDIR $GOPATH/src/gitlab.com/geeks-accelerator/oss/saas-starter-kit/example-project
@ -26,6 +28,9 @@ COPY cmd/web-api/templates /templates
WORKDIR ./cmd/web-api WORKDIR ./cmd/web-api
# Update the API documentation.
RUN swag init
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /gosrv . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /gosrv .
FROM alpine:3.9 FROM alpine:3.9

View File

@ -0,0 +1,26 @@
# SaaS Web API
Copyright 2019, Geeks Accelerator
accelerator@geeksinthewoods.com.com
## Description
Service exposes a JSON api.
## 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-api/Dockerfile -t saas-web-api .
```

View File

@ -0,0 +1,80 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag at
// 2019-06-24 15:42:25.999684 -0800 AKDT m=+0.030714022
package docs
import (
"bytes"
"github.com/alecthomas/template"
"github.com/swaggo/swag"
)
var doc = `{
"swagger": "2.0",
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "http://geeksinthewoods.com/terms",
"contact": {
"name": "API Support",
"url": "https://gitlab.com/geeks-accelerator/oss/saas-starter-kit",
"email": "support@geeksinthewoods.com"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {},
"securityDefinitions": {
"ApiKeyAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"BasicAuth": {
"type": "basic"
},
"OAuth2Password": {
"type": "oauth2",
"flow": "password",
"tokenUrl": "https://example.com/v1/oauth/token"
}
}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo swaggerInfo
type s struct{}
func (s *s) ReadDoc() string {
t, err := template.New("swagger_info").Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, SwaggerInfo); err != nil {
return doc
}
return tpl.String()
}
func init() {
swag.Register(swag.Name, &s{})
}

View File

@ -0,0 +1,36 @@
{
"swagger": "2.0",
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "http://geeksinthewoods.com/terms",
"contact": {
"name": "API Support",
"url": "https://gitlab.com/geeks-accelerator/oss/saas-starter-kit",
"email": "support@geeksinthewoods.com"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {},
"securityDefinitions": {
"ApiKeyAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"BasicAuth": {
"type": "basic"
},
"OAuth2Password": {
"type": "oauth2",
"flow": "password",
"tokenUrl": "https://example.com/v1/oauth/token"
}
}
}

View File

@ -0,0 +1,27 @@
basePath: '{{.BasePath}}'
host: '{{.Host}}'
info:
contact:
email: support@geeksinthewoods.com
name: API Support
url: https://gitlab.com/geeks-accelerator/oss/saas-starter-kit
description: This is a sample server celler server.
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
termsOfService: http://geeksinthewoods.com/terms
title: SaaS Example API
version: '{{.Version}}'
paths: {}
securityDefinitions:
ApiKeyAuth:
in: header
name: Authorization
type: apiKey
BasicAuth:
type: basic
OAuth2Password:
flow: password
tokenUrl: https://example.com/v1/oauth/token
type: oauth2
swagger: "2.0"

View File

@ -0,0 +1,191 @@
package handlers
import (
"context"
"net/http"
"strconv"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/account"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
// Account represents the Account API method handler set.
type Account struct {
MasterDB *sqlx.DB
// ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
}
// List returns all the existing accounts in the system.
func (a *Account) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req account.AccountFindRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := account.Find(ctx, claims, a.MasterDB, req)
if err != nil {
return err
}
return web.RespondJson(ctx, w, res, http.StatusOK)
}
// Read returns the specified account from the system.
func (a *Account) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var includeArchived bool
if qv := r.URL.Query().Get("include-archived"); qv != "" {
var err error
includeArchived, err = strconv.ParseBool(qv)
if err != nil {
return errors.Wrapf(err, "Invalid value for include-archived : %s", qv)
}
}
res, err := account.Read(ctx, claims, a.MasterDB, params["id"], includeArchived)
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "ID: %s", params["id"])
}
}
return web.RespondJson(ctx, w, res, http.StatusOK)
}
// Create inserts a new account into the system.
func (a *Account) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req account.AccountCreateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := account.Create(ctx, claims, a.MasterDB, req, v.Now)
if err != nil {
switch err {
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "User: %+v", &req)
}
}
return web.RespondJson(ctx, w, res, http.StatusCreated)
}
// Update updates the specified account in the system.
func (a *Account) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req account.AccountUpdateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
req.ID = params["id"]
err := account.Update(ctx, claims, a.MasterDB, req, v.Now)
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s Account: %+v", params["id"], &req)
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Archive soft-deletes the specified account from the system.
func (a *Account) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := account.Archive(ctx, claims, a.MasterDB, params["id"], v.Now)
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s", params["id"])
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Delete removes the specified account from the system.
func (a *Account) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := account.Delete(ctx, claims, a.MasterDB, params["id"])
if err != nil {
switch err {
case account.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case account.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case account.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s", params["id"])
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}

View File

@ -6,20 +6,31 @@ import (
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
) )
// Check provides support for orchestration health checks. // Check provides support for orchestration health checks.
type Check struct { type Check struct {
MasterDB *sqlx.DB MasterDB *sqlx.DB
Redis *redis.Client
// ADD OTHER STATE LIKE THE LOGGER IF NEEDED. // ADD OTHER STATE LIKE THE LOGGER IF NEEDED.
} }
// Health validates the service is healthy and ready to accept requests. // Health validates the service is healthy and ready to accept requests.
func (c *Check) Health(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (c *Check) Health(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
// check postgres
_, err := c.MasterDB.Exec("SELECT 1") _, err := c.MasterDB.Exec("SELECT 1")
if err != nil { if err != nil {
return err return errors.Wrap(err, "Postgres failed")
}
// check redis
err = c.Redis.Ping().Err()
if err != nil {
return errors.Wrap(err, "Redis failed")
} }
status := struct { status := struct {

View File

@ -3,7 +3,9 @@ package handlers
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/project" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/project"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -18,30 +20,56 @@ type Project struct {
} }
// List returns all the existing projects in the system. // List returns all the existing projects in the system.
func (p *Project) List(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
projects, err := project.List(ctx, p.MasterDB) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req project.ProjectFindRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := project.Find(ctx, claims, p.MasterDB, req)
if err != nil { if err != nil {
return err return err
} }
return web.RespondJson(ctx, w, projects, http.StatusOK) return web.RespondJson(ctx, w, res, http.StatusOK)
} }
// Retrieve returns the specified project from the system. // Read returns the specified project from the system.
func (p *Project) Retrieve(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
prod, err := project.Retrieve(ctx, p.MasterDB, params["id"]) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var includeArchived bool
if qv := r.URL.Query().Get("include-archived"); qv != "" {
var err error
includeArchived, err = strconv.ParseBool(qv)
if err != nil {
return errors.Wrapf(err, "Invalid value for include-archived : %s", qv)
}
}
res, err := project.Read(ctx, claims, p.MasterDB, params["id"], includeArchived)
if err != nil { if err != nil {
switch err { switch err {
case project.ErrInvalidID: case project.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest) return web.NewRequestError(err, http.StatusBadRequest)
case project.ErrNotFound: case project.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound) return web.NewRequestError(err, http.StatusNotFound)
case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "ID: %s", params["id"]) return errors.Wrapf(err, "ID: %s", params["id"])
} }
} }
return web.RespondJson(ctx, w, prod, http.StatusOK) return web.RespondJson(ctx, w, res, http.StatusOK)
} }
// Create inserts a new project into the system. // Create inserts a new project into the system.
@ -51,17 +79,27 @@ func (p *Project) Create(ctx context.Context, w http.ResponseWriter, r *http.Req
return web.NewShutdownError("web value missing from context") return web.NewShutdownError("web value missing from context")
} }
var np project.NewProject claims, ok := ctx.Value(auth.Key).(auth.Claims)
if err := web.Decode(r, &np); err != nil { if !ok {
return errors.New("claims missing from context")
}
var req project.ProjectCreateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "") return errors.Wrap(err, "")
} }
nUsr, err := project.Create(ctx, p.MasterDB, &np, v.Now) res, err := project.Create(ctx, claims, p.MasterDB, req, v.Now)
if err != nil { if err != nil {
return errors.Wrapf(err, "Project: %+v", &np) switch err {
case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Project: %+v", &req)
}
} }
return web.RespondJson(ctx, w, nUsr, http.StatusCreated) return web.RespondJson(ctx, w, res, http.StatusCreated)
} }
// Update updates the specified project in the system. // Update updates the specified project in the system.
@ -71,20 +109,57 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
return web.NewShutdownError("web value missing from context") return web.NewShutdownError("web value missing from context")
} }
var up project.UpdateProject claims, ok := ctx.Value(auth.Key).(auth.Claims)
if err := web.Decode(r, &up); err != nil { if !ok {
return errors.Wrap(err, "") return errors.New("claims missing from context")
} }
err := project.Update(ctx, p.MasterDB, params["id"], up, v.Now) var req project.ProjectUpdateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
req.ID = params["id"]
err := project.Update(ctx, claims, p.MasterDB, req, v.Now)
if err != nil { if err != nil {
switch err { switch err {
case project.ErrInvalidID: case project.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest) return web.NewRequestError(err, http.StatusBadRequest)
case project.ErrNotFound: case project.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound) return web.NewRequestError(err, http.StatusNotFound)
case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "ID: %s Update: %+v", params["id"], up) return errors.Wrapf(err, "ID: %s Update: %+v", params["id"], req)
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Archive soft-deletes the specified project from the system.
func (p *Project) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := project.Archive(ctx, claims, p.MasterDB, params["id"], v.Now)
if err != nil {
switch err {
case project.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case project.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s", params["id"])
} }
} }
@ -93,13 +168,20 @@ func (p *Project) Update(ctx context.Context, w http.ResponseWriter, r *http.Req
// Delete removes the specified project from the system. // Delete removes the specified project from the system.
func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (p *Project) Delete(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := project.Delete(ctx, p.MasterDB, params["id"]) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := project.Delete(ctx, claims, p.MasterDB, params["id"])
if err != nil { if err != nil {
switch err { switch err {
case project.ErrInvalidID: case project.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest) return web.NewRequestError(err, http.StatusBadRequest)
case project.ErrNotFound: case project.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound) return web.NewRequestError(err, http.StatusNotFound)
case project.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default: default:
return errors.Wrapf(err, "Id: %s", params["id"]) return errors.Wrapf(err, "Id: %s", params["id"])
} }

View File

@ -6,13 +6,15 @@ import (
"os" "os"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid"
saasSwagger "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
) )
// API returns a handler for a set of routes. // API returns a handler for a set of routes.
func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, authenticator *auth.Authenticator) http.Handler { func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator) http.Handler {
// Construct the web.App which holds all routes as well as common Middleware. // Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, mid.Trace(), mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics()) app := web.NewApp(shutdown, log, mid.Trace(), mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics())
@ -28,24 +30,43 @@ func API(shutdown chan os.Signal, log *log.Logger, masterDB *sqlx.DB, authentica
MasterDB: masterDB, MasterDB: masterDB,
TokenGenerator: authenticator, TokenGenerator: authenticator,
} }
app.Handle("GET", "/v1/users", u.List, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("GET", "/v1/users", u.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/v1/users/:id", u.Retrieve, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/users/:id", u.Read, mid.Authenticate(authenticator))
app.Handle("PUT", "/v1/users/:id", u.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("PATCH", "/v1/users/:id", u.Update, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/users/:id/password", u.UpdatePassword, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/users/:id/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/users/switch-account/:accountId", u.SwitchAccount, mid.Authenticate(authenticator))
// This route is not authenticated // This route is not authenticated
app.Handle("GET", "/v1/users/token", u.Token) app.Handle("GET", "/v1/oauth/token", u.Token)
// Register project and sale endpoints. // Register account endpoints.
a := Account{
MasterDB: masterDB,
}
app.Handle("GET", "/v1/accounts", a.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/accounts", a.Create, mid.Authenticate(authenticator))
app.Handle("GET", "/v1/accounts/:id", a.Read, mid.Authenticate(authenticator))
app.Handle("PATCH", "/v1/accounts/:id", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("PATCH", "/v1/accounts/:id/archive", a.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/accounts/:id", a.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
// Register project.
p := Project{ p := Project{
MasterDB: masterDB, MasterDB: masterDB,
} }
app.Handle("GET", "/v1/projects", p.List, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/projects", p.Find, mid.Authenticate(authenticator))
app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator)) app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/v1/projects/:id", p.Retrieve, mid.Authenticate(authenticator)) app.Handle("GET", "/v1/projects/:id", p.Read, mid.Authenticate(authenticator))
app.Handle("PUT", "/v1/projects/:id", p.Update, mid.Authenticate(authenticator)) app.Handle("PATCH", "/v1/projects/:id", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator)) app.Handle("PATCH", "/v1/projects/:id/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin))
// Register swagger documentation.
app.Handle("GET", "/swagger/", saasSwagger.WrapHandler, mid.Authenticate(authenticator))
app.Handle("GET", "/swagger/*", saasSwagger.WrapHandler, mid.Authenticate(authenticator))
return app return app
} }

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
@ -12,6 +13,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
// sessionTtl defines the auth token expiration.
var sessionTtl = time.Hour * 24
// User represents the User API method handler set. // User represents the User API method handler set.
type User struct { type User struct {
MasterDB *sqlx.DB MasterDB *sqlx.DB
@ -21,7 +25,7 @@ type User struct {
} }
// List returns all the existing users in the system. // List returns all the existing users in the system.
func (u *User) List(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Find(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
return errors.New("claims missing from context") return errors.New("claims missing from context")
@ -32,22 +36,160 @@ func (u *User) List(ctx context.Context, w http.ResponseWriter, r *http.Request,
return errors.Wrap(err, "") return errors.Wrap(err, "")
} }
usrs, err := user.Find(ctx, claims, u.MasterDB, req) res, err := user.Find(ctx, claims, u.MasterDB, req)
if err != nil { if err != nil {
return err return err
} }
return web.RespondJson(ctx, w, usrs, http.StatusOK) return web.RespondJson(ctx, w, res, http.StatusOK)
} }
// Retrieve returns the specified user from the system. // Read returns the specified user from the system.
func (u *User) Retrieve(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Read(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims) claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok { if !ok {
return errors.New("claims missing from context") return errors.New("claims missing from context")
} }
usr, err := user.FindById(ctx, claims, u.MasterDB, params["id"], false) var includeArchived bool
if qv := r.URL.Query().Get("include-archived"); qv != "" {
var err error
includeArchived, err = strconv.ParseBool(qv)
if err != nil {
return errors.Wrapf(err, "Invalid value for include-archived : %s", qv)
}
}
res, err := user.Read(ctx, claims, u.MasterDB, params["id"], includeArchived)
if err != nil {
switch err {
case user.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case user.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "ID: %s", params["id"])
}
}
return web.RespondJson(ctx, w, res, http.StatusOK)
}
// Create inserts a new user into the system.
func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req user.UserCreateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
res, err := user.Create(ctx, claims, u.MasterDB, req, v.Now)
if err != nil {
switch err {
case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "User: %+v", &req)
}
}
return web.RespondJson(ctx, w, res, http.StatusCreated)
}
// Update updates the specified user in the system.
func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req user.UserUpdateRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
req.ID = params["id"]
err := user.Update(ctx, claims, u.MasterDB, req, v.Now)
if err != nil {
switch err {
case user.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case user.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req)
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Update updates the password for a specified user in the system.
func (u *User) UpdatePassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
var req user.UserUpdatePasswordRequest
if err := web.Decode(r, &req); err != nil {
return errors.Wrap(err, "")
}
req.ID = params["id"]
err := user.UpdatePassword(ctx, claims, u.MasterDB, req, v.Now)
if err != nil {
switch err {
case user.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case user.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &req)
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent)
}
// Archive soft-deletes the specified user from the system.
func (u *User) Archive(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
err := user.Archive(ctx, claims, u.MasterDB, params["id"], v.Now)
if err != nil { if err != nil {
switch err { switch err {
case user.ErrInvalidID: case user.ErrInvalidID:
@ -61,66 +203,6 @@ func (u *User) Retrieve(ctx context.Context, w http.ResponseWriter, r *http.Requ
} }
} }
return web.RespondJson(ctx, w, usr, http.StatusOK)
}
// Create inserts a new user into the system.
func (u *User) Create(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
var newU user.CreateUserRequest
if err := web.Decode(r, &newU); err != nil {
return errors.Wrap(err, "")
}
usr, err := user.Create(ctx, claims, u.MasterDB, newU, v.Now)
if err != nil {
return errors.Wrapf(err, "User: %+v", &usr)
}
return web.RespondJson(ctx, w, usr, http.StatusCreated)
}
// Update updates the specified user in the system.
func (u *User) Update(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
var upd user.UpdateUserRequest
if err := web.Decode(r, &upd); err != nil {
return errors.Wrap(err, "")
}
err := user.Update(ctx, claims, u.MasterDB, upd, v.Now)
if err != nil {
switch err {
case user.ErrInvalidID:
return web.NewRequestError(err, http.StatusBadRequest)
case user.ErrNotFound:
return web.NewRequestError(err, http.StatusNotFound)
case user.ErrForbidden:
return web.NewRequestError(err, http.StatusForbidden)
default:
return errors.Wrapf(err, "Id: %s User: %+v", params["id"], &upd)
}
}
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
@ -148,6 +230,31 @@ func (u *User) Delete(ctx context.Context, w http.ResponseWriter, r *http.Reques
return web.RespondJson(ctx, w, nil, http.StatusNoContent) return web.RespondJson(ctx, w, nil, http.StatusNoContent)
} }
// SwitchAccount updates the claims.
func (u *User) SwitchAccount(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
v, ok := ctx.Value(web.KeyValues).(*web.Values)
if !ok {
return web.NewShutdownError("web value missing from context")
}
claims, ok := ctx.Value(auth.Key).(auth.Claims)
if !ok {
return errors.New("claims missing from context")
}
tkn, err := user.SwitchAccount(ctx, u.MasterDB, u.TokenGenerator, claims, params["accountId"], sessionTtl, v.Now)
if err != nil {
switch err {
case user.ErrAuthenticationFailure:
return web.NewRequestError(err, http.StatusUnauthorized)
default:
return errors.Wrap(err, "switch account")
}
}
return web.RespondJson(ctx, w, tkn, http.StatusNoContent)
}
// Token handles a request to authenticate a user. It expects a request using // Token handles a request to authenticate a user. It expects a request using
// Basic Auth with a user's email and password. It responds with a JWT. // Basic Auth with a user's email and password. It responds with a JWT.
func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
@ -162,8 +269,7 @@ func (u *User) Token(ctx context.Context, w http.ResponseWriter, r *http.Request
return web.NewRequestError(err, http.StatusUnauthorized) return web.NewRequestError(err, http.StatusUnauthorized)
} }
// TODO Constant for token lifespan? tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, sessionTtl, v.Now)
tkn, err := user.Authenticate(ctx, u.MasterDB, u.TokenGenerator, email, pass, time.Hour * 24, v.Now)
if err != nil { if err != nil {
switch err { switch err {
case user.ErrAuthenticationFailure: case user.ErrAuthenticationFailure:

View File

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"expvar" "expvar"
"fmt" "fmt"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"log" "log"
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
@ -16,6 +15,7 @@ import (
"syscall" "syscall"
"time" "time"
"geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-api/docs"
"geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-api/handlers" "geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-api/handlers"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
@ -29,6 +29,7 @@ import (
sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql" sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql"
redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" redistrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx" sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
) )
// build is the git version of this program. It is set using build flags in the makefile. // build is the git version of this program. It is set using build flags in the makefile.
@ -39,6 +40,26 @@ var build = "develop"
// ie: export WEB_API_ENV=dev // ie: export WEB_API_ENV=dev
var service = "WEB_API" var service = "WEB_API"
// @title SaaS Example API
// @description This is a sample server celler server.
// @termsOfService http://geeksinthewoods.com/terms
// @contact.name API Support
// @contact.email support@geeksinthewoods.com
// @contact.url https://gitlab.com/geeks-accelerator/oss/saas-starter-kit
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @securityDefinitions.basic BasicAuth
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @securitydefinitions.oauth2.password OAuth2Password
// @tokenUrl https://example.com/v1/oauth/token
func main() { func main() {
// ========================================================================= // =========================================================================
@ -98,6 +119,7 @@ func main() {
UseRole bool `envconfig:"AWS_USE_ROLE"` UseRole bool `envconfig:"AWS_USE_ROLE"`
} }
Auth struct { Auth struct {
UseAwsSecretManager bool `default:false envconfig:"USE_AWS_SECRET_MANAGER"`
AwsSecretID string `default:"auth-secret-key" envconfig:"AWS_SECRET_ID"` AwsSecretID string `default:"auth-secret-key" envconfig:"AWS_SECRET_ID"`
KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"` KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"`
} }
@ -252,8 +274,13 @@ func main() {
defer masterDb.Close() defer masterDb.Close()
// ========================================================================= // =========================================================================
// Load auth keys from AWS and init new Authenticator // Init new Authenticator
authenticator, err := auth.NewAuthenticator(awsSession, cfg.Auth.AwsSecretID, time.Now().UTC(), cfg.Auth.KeyExpiration) var authenticator *auth.Authenticator
if cfg.Auth.UseAwsSecretManager {
authenticator, err = auth.NewAuthenticatorAws(awsSession, cfg.Auth.AwsSecretID, time.Now().UTC(), cfg.Auth.KeyExpiration)
} else {
authenticator, err = auth.NewAuthenticatorFile("", time.Now().UTC(), cfg.Auth.KeyExpiration)
}
if err != nil { if err != nil {
log.Fatalf("main : Constructing authenticator : %v", err) log.Fatalf("main : Constructing authenticator : %v", err)
} }
@ -282,6 +309,19 @@ func main() {
// ========================================================================= // =========================================================================
// Start API Service // Start API Service
// Programmatically set swagger info.
{
docs.SwaggerInfo.Version = build
u, err := url.Parse(cfg.App.BaseUrl)
if err != nil {
log.Fatalf("main : Parse app base url %s : %v", cfg.App.BaseUrl, err)
}
docs.SwaggerInfo.Host = u.Host
docs.SwaggerInfo.BasePath = "/v1"
}
// Make a channel to listen for an interrupt or terminate signal from the OS. // Make a channel to listen for an interrupt or terminate signal from the OS.
// Use a buffered channel because the signal package requires it. // Use a buffered channel because the signal package requires it.
shutdown := make(chan os.Signal, 1) shutdown := make(chan os.Signal, 1)
@ -289,7 +329,7 @@ func main() {
api := http.Server{ api := http.Server{
Addr: cfg.HTTP.Host, Addr: cfg.HTTP.Host,
Handler: handlers.API(shutdown, log, masterDb, authenticator), Handler: handlers.API(shutdown, log, masterDb, redisClient, authenticator),
ReadTimeout: cfg.HTTP.ReadTimeout, ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout, WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,

View File

@ -30,6 +30,8 @@ func TestProjects(t *testing.T) {
t.Run("crudProjects", crudProject) t.Run("crudProjects", crudProject)
} }
// TODO: need to test Archive
// getProjects200Empty validates an empty projects list can be retrieved with the endpoint. // getProjects200Empty validates an empty projects list can be retrieved with the endpoint.
func getProjects200Empty(t *testing.T) { func getProjects200Empty(t *testing.T) {
r := httptest.NewRequest("GET", "/v1/projects", nil) r := httptest.NewRequest("GET", "/v1/projects", nil)

View File

@ -0,0 +1,27 @@
# SaaS Web App
Copyright 2019, Geeks Accelerator
accelerator@geeksinthewoods.com.com
## Description
Provides an http service.
## 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

@ -2,15 +2,18 @@ package handlers
import ( import (
"context" "context"
"github.com/jmoiron/sqlx"
"net/http" "net/http"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis"
) )
// Check provides support for orchestration health checks. // Check provides support for orchestration health checks.
type Check struct { type Check struct {
MasterDB *sqlx.DB MasterDB *sqlx.DB
Redis *redis.Client
Renderer web.Renderer Renderer web.Renderer
// ADD OTHER STATE LIKE THE LOGGER IF NEEDED. // ADD OTHER STATE LIKE THE LOGGER IF NEEDED.
@ -22,7 +25,13 @@ func (c *Check) Health(ctx context.Context, w http.ResponseWriter, r *http.Reque
// check postgres // check postgres
_, err := c.MasterDB.Exec("SELECT 1") _, err := c.MasterDB.Exec("SELECT 1")
if err != nil { if err != nil {
return err return errors.Wrap(err, "Postgres failed")
}
// check redis
err = c.Redis.Ping().Err()
if err != nil {
return errors.Wrap(err, "Redis failed")
} }
data := map[string]interface{}{ data := map[string]interface{}{

View File

@ -112,6 +112,7 @@ func main() {
UseRole bool `envconfig:"AWS_USE_ROLE"` UseRole bool `envconfig:"AWS_USE_ROLE"`
} }
Auth struct { Auth struct {
UseAwsSecretManager bool `default:false envconfig:"USE_AWS_SECRET_MANAGER"`
AwsSecretID string `default:"auth-secret-key" envconfig:"AWS_SECRET_ID"` AwsSecretID string `default:"auth-secret-key" envconfig:"AWS_SECRET_ID"`
KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"` KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"`
} }

View File

@ -1,26 +1,27 @@
module geeks-accelerator/oss/saas-starter-kit/example-project module geeks-accelerator/oss/saas-starter-kit/example-project
require ( require (
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc
github.com/aws/aws-sdk-go v1.19.33 github.com/aws/aws-sdk-go v1.19.33
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/dimfeld/httptreemux v5.0.1+incompatible github.com/dimfeld/httptreemux v5.0.1+incompatible
github.com/dustin/go-humanize v1.0.0 github.com/dustin/go-humanize v1.0.0
github.com/fatih/camelcase v1.0.0 github.com/fatih/camelcase v1.0.0
github.com/fatih/structtag v1.0.0 github.com/fatih/structtag v1.0.0
github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db
github.com/go-openapi/spec v0.19.2 // indirect
github.com/go-playground/locales v0.12.1 github.com/go-playground/locales v0.12.1
github.com/go-playground/universal-translator v0.16.0 github.com/go-playground/universal-translator v0.16.0
github.com/go-redis/redis v6.15.2+incompatible github.com/go-redis/redis v6.15.2+incompatible
github.com/golang/protobuf v1.3.1 // indirect
github.com/google/go-cmp v0.2.0 github.com/google/go-cmp v0.2.0
github.com/gorilla/schema v1.1.0
github.com/huandu/go-sqlbuilder v1.4.0 github.com/huandu/go-sqlbuilder v1.4.0
github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0
github.com/kelseyhightower/envconfig v1.3.0 github.com/kelseyhightower/envconfig v1.3.0
github.com/kr/pretty v0.1.0 // indirect
github.com/leodido/go-urn v1.1.0 // indirect github.com/leodido/go-urn v1.1.0 // indirect
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01 github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01
github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/onsi/ginkgo v1.8.0 // indirect github.com/onsi/ginkgo v1.8.0 // indirect
github.com/onsi/gomega v1.5.0 github.com/onsi/gomega v1.5.0
@ -30,16 +31,17 @@ require (
github.com/pkg/errors v0.8.1 github.com/pkg/errors v0.8.1
github.com/sergi/go-diff v1.0.0 github.com/sergi/go-diff v1.0.0
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
github.com/stretchr/testify v1.3.0
github.com/swaggo/files v0.0.0-20190110041405-30649e0721f8
github.com/swaggo/swag v1.5.1
github.com/tinylib/msgp v1.1.0 // indirect github.com/tinylib/msgp v1.1.0 // indirect
github.com/urfave/cli v1.20.0 github.com/urfave/cli v1.20.0
github.com/uudashr/go-module v0.0.0-20180827225833-c0ca9c3a4966 // indirect golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 // indirect golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 // indirect
golang.org/x/sys v0.0.0-20190526052359-791d8a0f4d09 // indirect golang.org/x/tools v0.0.0-20190624190245-7f2218787638 // indirect
golang.org/x/text v0.3.2 // indirect
google.golang.org/appengine v1.6.0 // indirect google.golang.org/appengine v1.6.0 // indirect
gopkg.in/DataDog/dd-trace-go.v1 v1.14.0 gopkg.in/DataDog/dd-trace-go.v1 v1.14.0
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v9 v9.29.0 gopkg.in/go-playground/validator.v9 v9.29.0
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce

View File

@ -1,3 +1,10 @@
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/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.19.33 h1:qz9ZQtxCUuwBKdc5QiY6hKuISYGeRQyLVA2RryDEDaQ= github.com/aws/aws-sdk-go v1.19.33 h1:qz9ZQtxCUuwBKdc5QiY6hKuISYGeRQyLVA2RryDEDaQ=
github.com/aws/aws-sdk-go v1.19.33/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.19.33/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
@ -18,6 +25,21 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db h1:mjErP7mTFHQ3cw/ibAkW3CvQ8gM4k19EkfzRzRINDAE= github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db h1:mjErP7mTFHQ3cw/ibAkW3CvQ8gM4k19EkfzRzRINDAE=
github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db/go.mod h1:dzpCjo4q7chhMVuHDzs/odROkieZ5Wjp70rNDuX83jU= github.com/geeks-accelerator/sqlxmigrate v0.0.0-20190527223850-4a863a2d30db/go.mod h1:dzpCjo4q7chhMVuHDzs/odROkieZ5Wjp70rNDuX83jU=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
github.com/go-openapi/jsonpointer v0.19.2 h1:A9+F4Dc/MCNB5jibxf6rRvOvR/iFgQdyNx9eIhnGqq0=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=
github.com/go-openapi/jsonreference v0.19.2 h1:o20suLFB4Ri0tuzpWtyHlh7E7HnkqTNLq6aR6WVNS1w=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.2 h1:SStNd1jRcYtfKCN7R0laGNs80WYYvn5CbBjM2sOmCrE=
github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2 h1:jvO6bCMBEilGwMfHhrd61zIID4oIFdwb76V17SM88dE=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= 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/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
@ -27,11 +49,12 @@ github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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.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/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
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 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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 h1:2LIlTDOz63lOETLOIiKBPEu4PUbikmS5LUc3EekwYqM=
@ -49,6 +72,7 @@ github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
@ -57,6 +81,10 @@ 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.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01 h1:EPw7R3OAyxHBCyl0oqh3lUZqS5lu3KSxzzGasE0opXQ= github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01 h1:EPw7R3OAyxHBCyl0oqh3lUZqS5lu3KSxzzGasE0opXQ=
github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.2-0.20190507191818-2ff3cb3adc01/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-20190620125010-da37f6c1e481 h1:IaSjLMT6WvkoZZjspGxy3rdaTEmWLoRm49WbtVUi9sA=
github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 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 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@ -82,34 +110,49 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 h1:X9XMOYjxEfAYSy3xK1DzO5dMkkWhs9E9UCcS1IERx2k= github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 h1:X9XMOYjxEfAYSy3xK1DzO5dMkkWhs9E9UCcS1IERx2k=
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0/go.mod h1:Ad7IjTpvzZO8Fl0vh9AzQ+j/jYZfyp2diGwI8m5q+ns= github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0/go.mod h1:Ad7IjTpvzZO8Fl0vh9AzQ+j/jYZfyp2diGwI8m5q+ns=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/swaggo/files v0.0.0-20190110041405-30649e0721f8 h1:ENF9W2s6+pqe/CmdQTQFPuzSdCB91LQ3WWzdMWucs7c=
github.com/swaggo/files v0.0.0-20190110041405-30649e0721f8/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
github.com/swaggo/swag v1.5.1 h1:2Agm8I4K5qb00620mHq0VJ05/KT4FtmALPIcQR9lEZM=
github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y=
github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU= github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/uudashr/go-module v0.0.0-20180827225833-c0ca9c3a4966 h1:7dS/ZO0dIwrtj/FGTt9I6urVpx7LEHzucegv4ORYK3M=
github.com/uudashr/go-module v0.0.0-20180827225833-c0ca9c3a4966/go.mod h1:P6Nk1sQWL6jcdBIxnLVlqCsOl0arao7gg7sPoM6gx4A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f h1:R423Cnkcp5JABoeemiGEPlt9tHXFfw5kvc0yqlxRPWo= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8= golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190526052359-791d8a0f4d09 h1:IlD35wZE03o2qJy2o37WIskL33b7PT6cHdGnE8bieZs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190526052359-791d8a0f4d09/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/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-20190624190245-7f2218787638 h1:uIfBkD8gLczr4XDgYpt/qJYds2YJwZRNw4zs7wSnNhk=
golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw= google.golang.org/appengine v1.6.0 h1:Tfd7cKwKbFRsI8RMAD3oqqw7JPFRrvFlOsfbgVkjOOw=
google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -130,5 +173,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=

View File

@ -257,7 +257,7 @@ func uniqueName(ctx context.Context, dbConn *sqlx.DB, name, accountId string) (b
} }
// Create inserts a new account into the database. // Create inserts a new account into the database.
func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req CreateAccountRequest, now time.Time) (*Account, error) { func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountCreateRequest, now time.Time) (*Account, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Create") span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Create")
defer span.Finish() defer span.Finish()
@ -364,7 +364,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, i
} }
// Update replaces an account in the database. // Update replaces an account in the database.
func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateAccountRequest, now time.Time) error { func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AccountUpdateRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Update") span, ctx := tracer.StartSpanFromContext(ctx, "internal.account.Update")
defer span.Finish() defer span.Finish()

View File

@ -29,8 +29,8 @@ type Account struct {
ArchivedAt pq.NullTime `json:"archived_at"` ArchivedAt pq.NullTime `json:"archived_at"`
} }
// CreateAccountRequest contains information needed to create a new Account. // AccountCreateRequest contains information needed to create a new Account.
type CreateAccountRequest struct { type AccountCreateRequest struct {
Name string `json:"name" validate:"required,unique"` Name string `json:"name" validate:"required,unique"`
Address1 string `json:"address1" validate:"required"` Address1 string `json:"address1" validate:"required"`
Address2 string `json:"address2" validate:"omitempty"` Address2 string `json:"address2" validate:"omitempty"`
@ -44,13 +44,13 @@ type CreateAccountRequest struct {
BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"` BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"`
} }
// UpdateAccountRequest defines what information may be provided to modify an existing // AccountUpdateRequest defines what information may be provided to modify an existing
// Account. All fields are optional so clients can send just the fields they want // Account. All fields are optional so clients can send just the fields they want
// changed. It uses pointer fields so we can differentiate between a field that // changed. It uses pointer fields so we can differentiate between a field that
// was not provided and a field that was provided as explicitly blank. Normally // was not provided and a field that was provided as explicitly blank. Normally
// we do not want to use pointers to basic types but we make exceptions around // we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling. // marshalling/unmarshalling.
type UpdateAccountRequest struct { type AccountUpdateRequest struct {
ID string `validate:"required,uuid"` ID string `validate:"required,uuid"`
Name *string `json:"name" validate:"omitempty,unique"` Name *string `json:"name" validate:"omitempty,unique"`
Address1 *string `json:"address1" validate:"omitempty"` Address1 *string `json:"address1" validate:"omitempty"`
@ -68,12 +68,12 @@ type UpdateAccountRequest struct {
// AccountFindRequest defines the possible options to search for accounts. By default // AccountFindRequest defines the possible options to search for accounts. By default
// archived accounts will be excluded from response. // archived accounts will be excluded from response.
type AccountFindRequest struct { type AccountFindRequest struct {
Where *string Where *string `schema:"where"`
Args []interface{} Args []interface{} `schema:"args"`
Order []string Order []string `schema:"order"`
Limit *uint Limit *uint `schema:"limit"`
Offset *uint Offset *uint `schema:"offset"`
IncludedArchived bool IncludedArchived bool `schema:"included-archived"`
} }
// AccountStatus represents the status of an account. // AccountStatus represents the status of an account.

View File

@ -0,0 +1,65 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag at
// 2019-06-24 13:07:10.15232 -0800 AKDT m=+0.076902855
package docs
import (
"bytes"
"github.com/alecthomas/template"
"github.com/swaggo/swag"
)
var doc = `{
"swagger": "2.0",
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "http://geeksinthewoods.com/terms",
"contact": {
"name": "API Support",
"url": "https://gitlab.com/geeks-accelerator/oss/saas-starter-kit",
"email": "support@geeksinthewoods.com"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "1.0"
},
"host": "example-api.saas.geeksinthewoods.com",
"basePath": "/v1",
"paths": {}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo swaggerInfo
type s struct{}
func (s *s) ReadDoc() string {
t, err := template.New("swagger_info").Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, SwaggerInfo); err != nil {
return doc
}
return tpl.String()
}
func init() {
swag.Register(swag.Name, &s{})
}

View File

@ -0,0 +1,21 @@
{
"swagger": "2.0",
"info": {
"description": "This is a sample server celler server.",
"title": "SaaS Example API",
"termsOfService": "http://geeksinthewoods.com/terms",
"contact": {
"name": "API Support",
"url": "https://gitlab.com/geeks-accelerator/oss/saas-starter-kit",
"email": "support@geeksinthewoods.com"
},
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "1.0"
},
"host": "example-api.saas.geeksinthewoods.com",
"basePath": "/v1",
"paths": {}
}

View File

@ -0,0 +1,16 @@
basePath: /v1
host: example-api.saas.geeksinthewoods.com
info:
contact:
email: support@geeksinthewoods.com
name: API Support
url: https://gitlab.com/geeks-accelerator/oss/saas-starter-kit
description: This is a sample server celler server.
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
termsOfService: http://geeksinthewoods.com/terms
title: SaaS Example API
version: "1.0"
paths: {}
swagger: "2.0"

View File

@ -0,0 +1,150 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid"
saasSwagger "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger"
_ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger/example/docs" // docs is generated by Swag CLI, you have to import it.
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/kelseyhightower/envconfig"
)
// build is the git version of this program. It is set using build flags in the makefile.
var build = "develop"
// service is the name of the program used for logging, tracing and the
// the prefix used for loading env variables
// ie: export WEB_API_ENV=dev
var service = "EXAMPLE_API"
// @title SaaS Example API
// @version 1.0
// @description This is a sample server celler server.
// @termsOfService http://geeksinthewoods.com/terms
// @contact.name API Support
// @contact.email support@geeksinthewoods.com
// @contact.url https://gitlab.com/geeks-accelerator/oss/saas-starter-kit
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host example-api.saas.geeksinthewoods.com
// @BasePath /v1
func main() {
// =========================================================================
// Logging
log := log.New(os.Stdout, service+" : ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
// =========================================================================
// Configuration
var cfg struct {
Env string `default:"dev" envconfig:"ENV"`
HTTP struct {
Host string `default:"0.0.0.0:1323" envconfig:"HOST"`
ReadTimeout time.Duration `default:"10s" envconfig:"READ_TIMEOUT"`
WriteTimeout time.Duration `default:"10s" envconfig:"WRITE_TIMEOUT"`
}
App struct {
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
}
}
// For additional details refer to https://github.com/kelseyhightower/envconfig
if err := envconfig.Process(service, &cfg); err != nil {
log.Fatalf("main : Parsing Config : %v", err)
}
if err := flag.Process(&cfg); err != nil {
if err != flag.ErrHelp {
log.Fatalf("main : Parsing Command Line : %v", err)
}
return // We displayed help.
}
// =========================================================================
// Start API Service
// Make a channel to listen for an interrupt or terminate signal from the OS.
// Use a buffered channel because the signal package requires it.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
api := http.Server{
Addr: cfg.HTTP.Host,
Handler: API(shutdown, log),
ReadTimeout: cfg.HTTP.ReadTimeout,
WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20,
}
// Make a channel to listen for errors coming from the listener. Use a
// buffered channel so the goroutine can exit if we don't collect this error.
serverErrors := make(chan error, 1)
// Start the service listening for requests.
go func() {
log.Printf("main : API Listening %s", cfg.HTTP.Host)
serverErrors <- api.ListenAndServe()
}()
// =========================================================================
// Shutdown
// Blocking main and waiting for shutdown.
select {
case err := <-serverErrors:
log.Fatalf("main : Error starting server: %v", err)
case sig := <-shutdown:
log.Printf("main : %v : Start shutdown..", sig)
// Create context for Shutdown call.
ctx, cancel := context.WithTimeout(context.Background(), cfg.App.ShutdownTimeout)
defer cancel()
// Asking listener to shutdown and load shed.
err := api.Shutdown(ctx)
if err != nil {
log.Printf("main : Graceful shutdown did not complete in %v : %v", cfg.App.ShutdownTimeout, err)
err = api.Close()
}
// Log the status of this shutdown.
switch {
case sig == syscall.SIGSTOP:
log.Fatal("main : Integrity issue caused shutdown")
case err != nil:
log.Fatalf("main : Could not stop server gracefully : %v", err)
}
}
}
// API returns a handler for a set of routes.
func API(shutdown chan os.Signal, log *log.Logger) http.Handler {
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, mid.Trace(), mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics())
app.Handle("GET", "/swagger/", saasSwagger.WrapHandler)
app.Handle("GET", "/swagger/*", saasSwagger.WrapHandler)
/*
Or can use SaasWrapHandler func with configurations.
url := saasSwagger.URL("http://localhost:1323/swagger/doc.json") //The url pointing to API definition
e.GET("/swagger/*", saasSwagger.SaasWrapHandler(url))
*/
return app
}

View File

@ -0,0 +1,211 @@
package saasSwagger
import (
"context"
"html/template"
"net/http"
"regexp"
"strings"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/pkg/errors"
"github.com/swaggo/files"
"github.com/swaggo/swag"
)
var (
// ErrNotFound used when a file doesn't exist.
ErrNotFound = errors.New("File not found")
)
// Config stores echoSwagger configuration variables.
type Config struct {
//The url pointing to API definition (normally swagger.json or swagger.yaml). Default is `doc.json`.
URL string
}
// URL presents the url pointing to API definition (normally swagger.json or swagger.yaml).
func URL(url string) func(c *Config) {
return func(c *Config) {
c.URL = url
}
}
// WrapHandler wraps swaggerFiles.Handler and returns web.Handler
var WrapHandler = SaasWrapHandler()
// SaasWrapHandler wraps `http.Handler` into `web.Handler`.
func SaasWrapHandler(confs ...func(c *Config)) web.Handler {
handler := swaggerFiles.Handler
config := &Config{
URL: "doc.json",
}
for _, c := range confs {
c(config)
}
// create a template with name
t := template.New("swagger_index.html")
index, _ := t.Parse(indexTempl)
type pro struct {
Host string
}
var re = regexp.MustCompile(`(.*)(index\.html|doc\.json|favicon-16x16\.png|favicon-32x32\.png|/oauth2-redirect\.html|swagger-ui\.css|swagger-ui\.css\.map|swagger-ui\.js|swagger-ui\.js\.map|swagger-ui-bundle\.js|swagger-ui-bundle\.js\.map|swagger-ui-standalone-preset\.js|swagger-ui-standalone-preset\.js\.map)[\?|.]*`)
// Create the handler that will be attached in the middleware chain.
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
var (
path string
prefix string
)
matches := re.FindStringSubmatch(r.RequestURI)
if len(matches) == 3 {
path = matches[2]
prefix = matches[1]
} else if len(matches) > 0 {
err := errors.WithMessagef(ErrNotFound, "page %s not found", r.RequestURI)
return web.NewRequestError(err, http.StatusNotFound)
}
// Default to index page.
if path == "" {
path = "index.html"
prefix = r.RequestURI
}
// Set the http prefix.
handler.Prefix = prefix
switch path {
case "index.html":
index.Execute(w, config)
case "doc.json":
doc, err := swag.ReadDoc()
if err != nil {
return web.NewRequestError(err, http.StatusInternalServerError)
}
return web.RespondJson(ctx, w, doc, http.StatusOK)
default:
if strings.HasSuffix(path, ".html") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
} else if strings.HasSuffix(path, ".css") {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
} else if strings.HasSuffix(path, ".js") {
w.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(path, ".json") {
w.Header().Set("Content-Type", "application/json")
}
handler.ServeHTTP(w, r)
}
return nil
}
return h
}
const indexTempl = `<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body {
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position:absolute;width:0;height:0">
<defs>
<symbol viewBox="0 0 20 20" id="unlocked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="locked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="close">
<path d="M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow">
<path d="M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow-down">
<path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"/>
</symbol>
<symbol viewBox="0 0 24 24" id="jump-to">
<path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"/>
</symbol>
<symbol viewBox="0 0 24 24" id="expand">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/>
</symbol>
</defs>
</svg>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"> </script>
<script src="./swagger-ui-standalone-preset.js"> </script>
<script>
window.onload = function() {
// Build a system
const ui = SwaggerUIBundle({
url: "{{.URL}}",
dom_id: '#swagger-ui',
validatorUrl: null,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
window.ui = ui
}
</script>
</body>
</html>
`

View File

@ -0,0 +1,43 @@
package saasSwagger
import (
"io/ioutil"
"log"
"net/http/httptest"
"os"
"testing"
_ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid/saas-swagger/example/docs"
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
"github.com/stretchr/testify/assert"
)
func TestWrapHandler(t *testing.T) {
log := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
log.SetOutput(ioutil.Discard)
app := web.NewApp(nil, log)
app.Handle("GET", "/swagger/*", WrapHandler)
w1 := performRequest("GET", "/swagger/index.html", app)
assert.Equal(t, 200, w1.Code)
w2 := performRequest("GET", "/swagger/doc.json", app)
assert.Equal(t, 200, w2.Code)
w3 := performRequest("GET", "/swagger/favicon-16x16.png", app)
assert.Equal(t, 200, w3.Code)
w4 := performRequest("GET", "/swagger/notfound", app)
assert.Equal(t, 404, w4.Code)
}
func performRequest(method, target string, app *web.App) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, nil)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
return w
}

View File

@ -1,18 +1,10 @@
package auth package auth
import ( import (
"bytes"
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"time" "time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/secretsmanager"
"github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -30,7 +22,7 @@ type KeyFunc func(keyID string) (*rsa.PublicKey, error)
// NewKeyFunc is a multiple implementation of KeyFunc that // NewKeyFunc is a multiple implementation of KeyFunc that
// supports a map of keys. // supports a map of keys.
func NewKeyFunc(keys map[string]*rsa.PrivateKey) KeyFunc { func NewKeyFunc(keys map[string]*PrivateKey) KeyFunc {
return func(kid string) (*rsa.PublicKey, error) { return func(kid string) (*rsa.PublicKey, error) {
key, ok := keys[kid] key, ok := keys[kid]
if !ok { if !ok {
@ -43,200 +35,33 @@ func NewKeyFunc(keys map[string]*rsa.PrivateKey) KeyFunc {
// Authenticator is used to authenticate clients. It can generate a token for a // Authenticator is used to authenticate clients. It can generate a token for a
// set of user claims and recreate the claims by parsing the token. // set of user claims and recreate the claims by parsing the token.
type Authenticator struct { type Authenticator struct {
privateKey *rsa.PrivateKey privateKey *PrivateKey
keyID string keyID string
algorithm string algorithm string
kf KeyFunc kf KeyFunc
parser *jwt.Parser parser *jwt.Parser
Storage Storage
}
// PrivateKey is used to associate a private key with a keyID and algorithm.
type PrivateKey struct {
*rsa.PrivateKey
keyID string
algorithm string
} }
// NewAuthenticator creates an *Authenticator for use. // NewAuthenticator creates an *Authenticator for use.
// key expiration is optional to filter out old keys // key expiration is optional to filter out old keys
// It will error if: // It will error if:
// - The aws session is nil.
// - The aws secret id is blank.
// - The specified algorithm is unsupported. // - The specified algorithm is unsupported.
func NewAuthenticator(awsSession *session.Session, awsSecretID string, now time.Time, keyExpiration time.Duration) (*Authenticator, error) { // - No current private key exists.
if awsSession == nil { func NewAuthenticator(storage Storage, now time.Time) (*Authenticator, error) {
return nil, errors.New("aws session cannot be nil")
}
if awsSecretID == "" {
return nil, errors.New("aws secret id cannot be empty")
}
if now.IsZero() {
now = time.Now().UTC()
}
// Time threshold to stop loading keys, any key with a created date
// before this value will not be loaded.
var disabledCreatedDate time.Time
// Time threshold to create a new key. If a current key exists and the
// created date of the key is before this value, a new key will be created.
var activeCreatedDate time.Time
// If an expiration duration is included, convert to past time from now.
if keyExpiration.Seconds() != 0 {
// Ensure the expiration is a time in the past for comparison below.
if keyExpiration.Seconds() > 0 {
keyExpiration = keyExpiration * -1
}
// Stop loading keys when the created date exceeds two times the key expiration
disabledCreatedDate = now.UTC().Add(keyExpiration * 2)
// Time used to determine when a new key should be created.
activeCreatedDate = now.UTC().Add(keyExpiration)
}
// Init new AWS Secret Manager using provided AWS session.
secretManager := secretsmanager.New(awsSession)
// A List of version ids for the stored secret. All keys will be stored under
// the same name in AWS secret manager. We still want to load old keys for a
// short period of time to ensure any requests in flight have the opportunity
// to be completed.
var versionIds []string
// Exec call to AWS secret manager to return a list of version ids for the
// provided secret ID.
listParams := &secretsmanager.ListSecretVersionIdsInput{
SecretId: aws.String(awsSecretID),
}
err := secretManager.ListSecretVersionIdsPages(listParams,
func(page *secretsmanager.ListSecretVersionIdsOutput, lastPage bool) bool {
for _, v := range page.Versions {
// When disabled CreatedDate is not empty, compare the created date
// for each key version to the disabled cut off time.
if !disabledCreatedDate.IsZero() && v.CreatedDate != nil && !v.CreatedDate.IsZero() {
// Skip any version ids that are less than the expiration time.
if v.CreatedDate.UTC().Unix() < disabledCreatedDate.UTC().Unix() {
continue
}
}
if v.VersionId != nil {
versionIds = append(versionIds, *v.VersionId)
}
}
return !lastPage
},
)
// Flag whether the secret exists and update needs to be used
// instead of create.
var awsSecretIDNotFound bool
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case secretsmanager.ErrCodeResourceNotFoundException:
awsSecretIDNotFound = true
}
}
if !awsSecretIDNotFound {
return nil, errors.Wrapf(err, "aws list secret version ids for secret ID %s failed", awsSecretID)
}
}
// Map of keys stored by version id. version id is kid.
keyContents := make(map[string][]byte)
// The current key id if there is an active one.
var curKeyId string
// If the list of version ids is not empty, load the keys from secret manager.
if len(versionIds) > 0 {
// The max created data to determine the most recent key.
var lastCreatedDate time.Time
for _, id := range versionIds {
res, err := secretManager.GetSecretValue(&secretsmanager.GetSecretValueInput{
SecretId: aws.String(awsSecretID),
VersionId: aws.String(id),
})
if err != nil {
return nil, errors.Wrapf(err, "aws secret id %s, version id %s value failed", awsSecretID, id)
}
if len(res.SecretBinary) == 0 {
continue
}
keyContents[*res.VersionId] = res.SecretBinary
if lastCreatedDate.IsZero() || res.CreatedDate.UTC().Unix() > lastCreatedDate.UTC().Unix() {
curKeyId = *res.VersionId
lastCreatedDate = res.CreatedDate.UTC()
}
}
//
if !activeCreatedDate.IsZero() && lastCreatedDate.UTC().Unix() < activeCreatedDate.UTC().Unix() {
curKeyId = ""
}
}
// If there are no keys stored in secret manager, create a new one or
// if the current key needs to be rotated, generate a new key and update the secret.
// @TODO: When a new key is generated and there are multiple instances of the service running
// its possible based on the key expiration set that requests fail because keys are only
// refreshed on instance launch. Could store keys in a kv store and update that value
// when new keys are generated
if len(keyContents) == 0 || curKeyId == "" {
privateKey, err := Keygen()
if err != nil {
return nil, errors.Wrap(err, "failed to generate new private key")
}
if awsSecretIDNotFound {
res, err := secretManager.CreateSecret(&secretsmanager.CreateSecretInput{
Name: aws.String(awsSecretID),
SecretBinary: privateKey,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create new secret with private key")
}
curKeyId = *res.VersionId
} else {
res, err := secretManager.UpdateSecret(&secretsmanager.UpdateSecretInput{
SecretId: aws.String(awsSecretID),
SecretBinary: privateKey,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create new secret with private key")
}
curKeyId = *res.VersionId
}
keyContents[curKeyId] = privateKey
}
// Map of keys by kid (version id).
keys := make(map[string]*rsa.PrivateKey)
// The current active key to be used.
var curPrivateKey *rsa.PrivateKey
// Loop through all the key bytes and load the private key.
for kid, keyContent := range keyContents {
key, err := jwt.ParseRSAPrivateKeyFromPEM(keyContent)
if err != nil {
return nil, errors.Wrap(err, "parsing auth private key")
}
keys[kid] = key
if kid == curKeyId {
curPrivateKey = key
}
}
// Lookup function to be used by the middleware to validate the kid and // Lookup function to be used by the middleware to validate the kid and
// Return the associated public key. // Return the associated public key.
publicKeyLookup := NewKeyFunc(keys) publicKeyLookup := NewKeyFunc(storage.Keys())
// Algorithm to be used to for the private key. // Validate the globally defined encryption algorithm is valid.
algorithm := "RS256"
if jwt.GetSigningMethod(algorithm) == nil { if jwt.GetSigningMethod(algorithm) == nil {
return nil, errors.Errorf("unknown algorithm %v", algorithm) return nil, errors.Errorf("unknown algorithm %v", algorithm)
} }
@ -248,9 +73,15 @@ func NewAuthenticator(awsSession *session.Session, awsSecretID string, now time.
ValidMethods: []string{algorithm}, ValidMethods: []string{algorithm},
} }
// Load the current key from the storage engine.
curKey := storage.Current()
if curKey == nil {
return nil, errors.New("Missing private key")
}
a := Authenticator{ a := Authenticator{
privateKey: curPrivateKey, privateKey: curKey,
keyID: curKeyId, keyID: curKey.keyID,
algorithm: algorithm, algorithm: algorithm,
kf: publicKeyLookup, kf: publicKeyLookup,
parser: &parser, parser: &parser,
@ -306,23 +137,3 @@ func (a *Authenticator) ParseClaims(tknStr string) (Claims, error) {
return claims, nil return claims, nil
} }
// Keygen creates an x509 private key for signing auth tokens.
func Keygen() ([]byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return []byte{}, errors.Wrap(err, "generating keys")
}
block := pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
buf := new(bytes.Buffer)
if err := pem.Encode(buf, &block); err != nil {
return []byte{}, errors.Wrap(err, "encoding to private file")
}
return buf.Bytes(), nil
}

View File

@ -0,0 +1,33 @@
package auth
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"github.com/pkg/errors"
)
// Algorithm to be used to for the private key.
const algorithm = "RS256"
// keyGen creates an x509 private key for signing auth tokens.
func keyGen() ([]byte, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return []byte{}, errors.Wrap(err, "generating keys")
}
block := pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
buf := new(bytes.Buffer)
if err := pem.Encode(buf, &block); err != nil {
return []byte{}, errors.Wrap(err, "encoding to private file")
}
return buf.Bytes(), nil
}

View File

@ -0,0 +1,212 @@
package auth
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"github.com/pborman/uuid"
"github.com/pkg/errors"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Storage provides the ability to persist keys to custom locations.
type Storage interface {
// Keys returns a map of private keys by kID.
Keys() map[string]*PrivateKey
// Current returns the most recently generated private key.
Current() *PrivateKey
}
// StorageFile is a storage engine that stores private keys on the local file system.
type StorageFile struct {
// Local directory for storing private keys.
localDir string
// Duration for keys to be valid.
keyExpiration time.Duration
// Map of keys by kid (version id).
keys map[string]*PrivateKey
// The current active key to be used.
curPrivateKey *PrivateKey
}
// Keys returns a map of private keys by kID.
func (s *StorageFile) Keys() map[string]*PrivateKey {
if s == nil || s.keys == nil {
return map[string]*PrivateKey{}
}
return s.keys
}
// Current returns the most recently generated private key.
func (s *StorageFile) Current() *PrivateKey {
if s == nil {
return nil
}
return s.curPrivateKey
}
// NewAuthenticatorFile is a help function that inits a new Authenticator
// using the file storage.
func NewAuthenticatorFile(localDir string, now time.Time, keyExpiration time.Duration) (*Authenticator, error) {
storage, err := NewStorageFile(localDir, now, keyExpiration)
if err != nil {
return nil, err
}
return NewAuthenticator(storage, time.Now().UTC())
}
// NewStorageFile implements the interface Storage to support persisting private keys
// to the local file system.
// It will error if:
func NewStorageFile(localDir string, now time.Time, keyExpiration time.Duration) (*StorageFile, error) {
if localDir == "" {
localDir = filepath.Join(os.TempDir(), "auth-private-keys")
}
if _, err := os.Stat(localDir); os.IsNotExist(err) {
err = os.MkdirAll(localDir, os.ModePerm)
if err != nil {
return nil, errors.Wrapf(err, "failed to create storage directory %s", localDir)
}
}
storage := &StorageFile{
localDir: localDir,
keyExpiration: keyExpiration,
keys: make(map[string]*PrivateKey),
}
if now.IsZero() {
now = time.Now().UTC()
}
// Time threshold to stop loading keys, any key with a created date
// before this value will not be loaded.
var disabledCreatedDate time.Time
// Time threshold to create a new key. If a current key exists and the
// created date of the key is before this value, a new key will be created.
var activeCreatedDate time.Time
// If an expiration duration is included, convert to past time from now.
if keyExpiration.Seconds() != 0 {
// Ensure the expiration is a time in the past for comparison below.
if keyExpiration.Seconds() > 0 {
keyExpiration = keyExpiration * -1
}
// Stop loading keys when the created date exceeds two times the key expiration
disabledCreatedDate = now.UTC().Add(keyExpiration * 2)
// Time used to determine when a new key should be created.
activeCreatedDate = now.UTC().Add(keyExpiration)
}
// Values used to format filename.
filePrefix := "auth_"
fileExt := ".privatekey"
files, err := ioutil.ReadDir(localDir)
if err != nil {
return nil, errors.Wrapf(err, "failed to list files in directory %s", localDir)
}
// Map of keys stored by version id. version id is kid.
keyContents := make(map[string][]byte)
// The current key id if there is an active one.
var curKeyId string
// The max created data to determine the most recent key.
var lastCreatedDate time.Time
for _, f := range files {
if !strings.HasPrefix(f.Name(), filePrefix) || !strings.HasSuffix(f.Name(), fileExt) {
continue
}
// Extract the created timestamp and kID from the filename.
fname := strings.TrimSuffix(f.Name(), fileExt)
pts := strings.Split(fname, "_")
if len(pts) != 3 {
return nil, errors.Errorf("unable to parse filename %s", f.Name())
}
createdAt := pts[1]
kID := pts[2]
// Covert string timestamp to int.
createdAtSecs, err := strconv.Atoi(createdAt)
if err != nil {
return nil, errors.Wrapf(err, "failed parse timestamp from %s", f.Name())
}
ts := time.Unix(int64(createdAtSecs), 0)
// If the created time of the key is less than the disabled threshold, skip.
if !disabledCreatedDate.IsZero() && ts.UTC().Unix() < disabledCreatedDate.UTC().Unix() {
continue
}
filePath := filepath.Join(localDir, f.Name())
dat, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, errors.Wrapf(err, "failed read file %s", f.Name())
}
keyContents[kID] = dat
if lastCreatedDate.IsZero() || ts.UTC().Unix() > lastCreatedDate.UTC().Unix() {
curKeyId = kID
lastCreatedDate = ts.UTC()
}
}
//
if !activeCreatedDate.IsZero() && lastCreatedDate.UTC().Unix() < activeCreatedDate.UTC().Unix() {
curKeyId = ""
}
// If there are no keys or the current key needs to be rotated, generate a new key.
if len(keyContents) == 0 || curKeyId == "" {
privateKey, err := keyGen()
if err != nil {
return nil, errors.Wrap(err, "failed to generate new private key")
}
kID := uuid.NewRandom().String()
fname := fmt.Sprintf("%s_%d_%s%s", filePrefix, now.UTC().Unix(), kID, fileExt)
filePath := filepath.Join(localDir, fname)
err = ioutil.WriteFile(filePath, privateKey, 0644)
if err != nil {
return nil, errors.Wrapf(err, "failed write file %s", filePath)
}
keyContents[curKeyId] = privateKey
}
// Loop through all the key bytes and load the private key.
for kid, key := range keyContents {
pk, err := jwt.ParseRSAPrivateKeyFromPEM(key)
if err != nil {
return nil, errors.Wrap(err, "parsing auth private key")
}
storage.keys[kid] = &PrivateKey{
PrivateKey: pk,
keyID: kid,
algorithm: algorithm,
}
if kid == curKeyId {
storage.curPrivateKey = storage.keys[kid]
}
}
return storage, nil
}

View File

@ -0,0 +1,236 @@
package auth
import (
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/secretsmanager"
"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
)
// StorageAws is a storage engine that uses AWS Secrets Manager to persist private keys.
type StorageAws struct {
keyExpiration time.Duration
// Map of keys by kid (version id).
keys map[string]*PrivateKey
// The current active key to be used.
curPrivateKey *PrivateKey
}
// Keys returns a map of private keys by kID.
func (s *StorageAws) Keys() map[string]*PrivateKey {
if s == nil || s.keys == nil {
return map[string]*PrivateKey{}
}
return s.keys
}
// Current returns the most recently generated private key.
func (s *StorageAws) Current() *PrivateKey {
if s == nil {
return nil
}
return s.curPrivateKey
}
// NewAuthenticatorAws is a help function that inits a new Authenticator
// using the AWS storage.
func NewAuthenticatorAws(awsSession *session.Session, awsSecretID string, now time.Time, keyExpiration time.Duration) (*Authenticator, error) {
storage, err := NewStorageAws(awsSession, awsSecretID, now, keyExpiration)
if err != nil {
return nil, err
}
return NewAuthenticator(storage, time.Now().UTC())
}
// NewStorageAws implements the interface Storage to support persisting private keys
// to AWS Secrets Manager.
// It will error if:
// - The aws session is nil.
// - The aws secret id is blank.
func NewStorageAws(awsSession *session.Session, awsSecretID string, now time.Time, keyExpiration time.Duration) (*StorageAws, error) {
if awsSession == nil {
return nil, errors.New("aws session cannot be nil")
}
if awsSecretID == "" {
return nil, errors.New("aws secret id cannot be empty")
}
storage := &StorageAws{
keyExpiration: keyExpiration,
keys: make(map[string]*PrivateKey),
}
if now.IsZero() {
now = time.Now().UTC()
}
// Time threshold to stop loading keys, any key with a created date
// before this value will not be loaded.
var disabledCreatedDate time.Time
// Time threshold to create a new key. If a current key exists and the
// created date of the key is before this value, a new key will be created.
var activeCreatedDate time.Time
// If an expiration duration is included, convert to past time from now.
if keyExpiration.Seconds() != 0 {
// Ensure the expiration is a time in the past for comparison below.
if keyExpiration.Seconds() > 0 {
keyExpiration = keyExpiration * -1
}
// Stop loading keys when the created date exceeds two times the key expiration
disabledCreatedDate = now.UTC().Add(keyExpiration * 2)
// Time used to determine when a new key should be created.
activeCreatedDate = now.UTC().Add(keyExpiration)
}
// Init new AWS Secret Manager using provided AWS session.
secretManager := secretsmanager.New(awsSession)
// A List of version ids for the stored secret. All keys will be stored under
// the same name in AWS secret manager. We still want to load old keys for a
// short period of time to ensure any requests in flight have the opportunity
// to be completed.
var versionIds []string
// Exec call to AWS secret manager to return a list of version ids for the
// provided secret ID.
listParams := &secretsmanager.ListSecretVersionIdsInput{
SecretId: aws.String(awsSecretID),
}
err := secretManager.ListSecretVersionIdsPages(listParams,
func(page *secretsmanager.ListSecretVersionIdsOutput, lastPage bool) bool {
for _, v := range page.Versions {
// When disabled CreatedDate is not empty, compare the created date
// for each key version to the disabled cut off time.
if !disabledCreatedDate.IsZero() && v.CreatedDate != nil && !v.CreatedDate.IsZero() {
// Skip any version ids that are less than the expiration time.
if v.CreatedDate.UTC().Unix() < disabledCreatedDate.UTC().Unix() {
continue
}
}
if v.VersionId != nil {
versionIds = append(versionIds, *v.VersionId)
}
}
return !lastPage
},
)
// Flag whether the secret exists and update needs to be used
// instead of create.
var awsSecretIDNotFound bool
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case secretsmanager.ErrCodeResourceNotFoundException:
awsSecretIDNotFound = true
}
}
if !awsSecretIDNotFound {
return nil, errors.Wrapf(err, "aws list secret version ids for secret ID %s failed", awsSecretID)
}
}
// Map of keys stored by version id. version id is kid.
keyContents := make(map[string][]byte)
// The current key id if there is an active one.
var curKeyId string
// If the list of version ids is not empty, load the keys from secret manager.
if len(versionIds) > 0 {
// The max created data to determine the most recent key.
var lastCreatedDate time.Time
for _, id := range versionIds {
res, err := secretManager.GetSecretValue(&secretsmanager.GetSecretValueInput{
SecretId: aws.String(awsSecretID),
VersionId: aws.String(id),
})
if err != nil {
return nil, errors.Wrapf(err, "aws secret id %s, version id %s value failed", awsSecretID, id)
}
if len(res.SecretBinary) == 0 {
continue
}
keyContents[*res.VersionId] = res.SecretBinary
if lastCreatedDate.IsZero() || res.CreatedDate.UTC().Unix() > lastCreatedDate.UTC().Unix() {
curKeyId = *res.VersionId
lastCreatedDate = res.CreatedDate.UTC()
}
}
//
if !activeCreatedDate.IsZero() && lastCreatedDate.UTC().Unix() < activeCreatedDate.UTC().Unix() {
curKeyId = ""
}
}
// If there are no keys stored in secret manager, create a new one or
// if the current key needs to be rotated, generate a new key and update the secret.
// @TODO: When a new key is generated and there are multiple instances of the service running
// its possible based on the key expiration set that requests fail because keys are only
// refreshed on instance launch. Could store keys in a kv store and update that value
// when new keys are generated
if len(keyContents) == 0 || curKeyId == "" {
privateKey, err := keyGen()
if err != nil {
return nil, errors.Wrap(err, "failed to generate new private key")
}
if awsSecretIDNotFound {
res, err := secretManager.CreateSecret(&secretsmanager.CreateSecretInput{
Name: aws.String(awsSecretID),
SecretBinary: privateKey,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create new secret with private key")
}
curKeyId = *res.VersionId
} else {
res, err := secretManager.UpdateSecret(&secretsmanager.UpdateSecretInput{
SecretId: aws.String(awsSecretID),
SecretBinary: privateKey,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create new secret with private key")
}
curKeyId = *res.VersionId
}
keyContents[curKeyId] = privateKey
}
// Loop through all the key bytes and load the private key.
for kid, key := range keyContents {
pk, err := jwt.ParseRSAPrivateKeyFromPEM(key)
if err != nil {
return nil, errors.Wrap(err, "parsing auth private key")
}
storage.keys[kid] = &PrivateKey{
PrivateKey: pk,
keyID: kid,
algorithm: algorithm,
}
if kid == curKeyId {
storage.curPrivateKey = storage.keys[kid]
}
}
return storage, nil
}

View File

@ -7,9 +7,10 @@ import (
"reflect" "reflect"
"strings" "strings"
en "github.com/go-playground/locales/en" "github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator" ut "github.com/go-playground/universal-translator"
validator "gopkg.in/go-playground/validator.v9" "github.com/gorilla/schema"
"gopkg.in/go-playground/validator.v9"
en_translations "gopkg.in/go-playground/validator.v9/translations/en" en_translations "gopkg.in/go-playground/validator.v9/translations/en"
) )
@ -47,11 +48,19 @@ func init() {
// //
// If the provided value is a struct then it is checked for validation tags. // If the provided value is a struct then it is checked for validation tags.
func Decode(r *http.Request, val interface{}) error { func Decode(r *http.Request, val interface{}) error {
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
if err := decoder.Decode(val); err != nil { if err := decoder.Decode(val); err != nil {
return NewRequestError(err, http.StatusBadRequest) return NewRequestError(err, http.StatusBadRequest)
} }
} else {
decoder := schema.NewDecoder()
if err := decoder.Decode(val, r.URL.Query()); err != nil {
return NewRequestError(err, http.StatusBadRequest)
}
}
if err := validate.Struct(val); err != nil { if err := validate.Struct(val); err != nil {

View File

@ -72,11 +72,16 @@ func RespondJson(ctx context.Context, w http.ResponseWriter, data interface{}, s
return nil return nil
} }
// Check to see if the json has already been encoded.
jsonData, ok := data.([]byte)
if !ok {
// Convert the response value to JSON. // Convert the response value to JSON.
jsonData, err := json.Marshal(data) var err error
jsonData, err = json.Marshal(data)
if err != nil { if err != nil {
return err return err
} }
}
// Set the content type and headers once we know marshaling has succeeded. // Set the content type and headers once we know marshaling has succeeded.
w.Header().Set("Content-Type", MIMEApplicationJSONCharsetUTF8) w.Header().Set("Content-Type", MIMEApplicationJSONCharsetUTF8)
@ -111,6 +116,7 @@ func RespondErrorStatus(ctx context.Context, w http.ResponseWriter, er error, st
// Respond writes the data to the client with the specified HTTP status code and // Respond writes the data to the client with the specified HTTP status code and
// content type. // content type.
func Respond(ctx context.Context, w http.ResponseWriter, data []byte, statusCode int, contentType string) error { func Respond(ctx context.Context, w http.ResponseWriter, data []byte, statusCode int, contentType string) error {
// Set the status code for the request logger middleware. // Set the status code for the request logger middleware.
// If the context is missing this value, request the service // If the context is missing this value, request the service
// to be shutdown gracefully. // to be shutdown gracefully.

View File

@ -53,9 +53,13 @@ func NewApp(shutdown chan os.Signal, log *log.Logger, mw ...Middleware) *App {
// SignalShutdown is used to gracefully shutdown the app when an integrity // SignalShutdown is used to gracefully shutdown the app when an integrity
// issue is identified. // issue is identified.
func (a *App) SignalShutdown() { func (a *App) SignalShutdown() bool {
if a.shutdown == nil {
return false
}
a.log.Println("error returned from handler indicated integrity issue, shutting down service") a.log.Println("error returned from handler indicated integrity issue, shutting down service")
a.shutdown <- syscall.SIGSTOP a.shutdown <- syscall.SIGSTOP
return true
} }
// Handle is our mechanism for mounting Handlers for a given HTTP verb and path // Handle is our mechanism for mounting Handlers for a given HTTP verb and path
@ -78,9 +82,24 @@ func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
ctx := context.WithValue(r.Context(), KeyValues, &v) ctx := context.WithValue(r.Context(), KeyValues, &v)
// Call the wrapped handler functions. // Call the wrapped handler functions.
if err := handler(ctx, w, r, params); err != nil { 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 {
// 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.
return
}
}
a.log.Printf("*****> critical shutdown error: %v", err) a.log.Printf("*****> critical shutdown error: %v", err)
a.SignalShutdown() if ok := a.SignalShutdown(); !ok {
// When shutdown chan is nil, in the case of unit testing
// we need to force display of the error.
panic(err)
}
return return
} }
} }

View File

@ -39,12 +39,12 @@ type ProjectUpdateRequest struct {
// ProjectFindRequest defines the possible options to search for projects. By default // ProjectFindRequest defines the possible options to search for projects. By default
// archived project will be excluded from response. // archived project will be excluded from response.
type ProjectFindRequest struct { type ProjectFindRequest struct {
Where *string Where *string `schema:"where"`
Args []interface{} Args []interface{} `schema:"args"`
Order []string Order []string `schema:"order"`
Limit *uint Limit *uint `schema:"limit"`
Offset *uint Offset *uint `schema:"offset"`
IncludedArchived bool IncludedArchived bool `schema:"included-archived"`
} }
// ProjectStatus represents the status of project. // ProjectStatus represents the status of project.

View File

@ -25,8 +25,8 @@ type User struct {
ArchivedAt pq.NullTime `json:"archived_at"` ArchivedAt pq.NullTime `json:"archived_at"`
} }
// CreateUserRequest contains information needed to create a new User. // UserCreateRequest contains information needed to create a new User.
type CreateUserRequest struct { type UserCreateRequest struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email,unique"` Email string `json:"email" validate:"required,email,unique"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
@ -34,21 +34,21 @@ type CreateUserRequest struct {
Timezone *string `json:"timezone" validate:"omitempty"` Timezone *string `json:"timezone" validate:"omitempty"`
} }
// UpdateUserRequest defines what information may be provided to modify an existing // UserUpdateRequest defines what information may be provided to modify an existing
// User. All fields are optional so clients can send just the fields they want // User. All fields are optional so clients can send just the fields they want
// changed. It uses pointer fields so we can differentiate between a field that // changed. It uses pointer fields so we can differentiate between a field that
// was not provided and a field that was provided as explicitly blank. Normally // was not provided and a field that was provided as explicitly blank. Normally
// we do not want to use pointers to basic types but we make exceptions around // we do not want to use pointers to basic types but we make exceptions around
// marshalling/unmarshalling. // marshalling/unmarshalling.
type UpdateUserRequest struct { type UserUpdateRequest struct {
ID string `validate:"required,uuid"` ID string `validate:"required,uuid"`
Name *string `json:"name" validate:"omitempty"` Name *string `json:"name" validate:"omitempty"`
Email *string `json:"email" validate:"omitempty,email,unique"` Email *string `json:"email" validate:"omitempty,email,unique"`
Timezone *string `json:"timezone" validate:"omitempty"` Timezone *string `json:"timezone" validate:"omitempty"`
} }
// UpdatePassword defines what information is required to update a user password. // UserUpdatePasswordRequest defines what information is required to update a user password.
type UpdatePasswordRequest struct { type UserUpdatePasswordRequest struct {
ID string `validate:"required,uuid"` ID string `validate:"required,uuid"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"` PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"`
@ -57,12 +57,12 @@ type UpdatePasswordRequest struct {
// UserFindRequest defines the possible options to search for users. By default // UserFindRequest defines the possible options to search for users. By default
// archived users will be excluded from response. // archived users will be excluded from response.
type UserFindRequest struct { type UserFindRequest struct {
Where *string Where *string `schema:"where"`
Args []interface{} Args []interface{} `schema:"args"`
Order []string Order []string `schema:"order"`
Limit *uint Limit *uint `schema:"limit"`
Offset *uint Offset *uint `schema:"offset"`
IncludedArchived bool IncludedArchived bool `schema:"included-archived"`
} }
// Token is the payload we deliver to users when they authenticate. // Token is the payload we deliver to users when they authenticate.

View File

@ -257,7 +257,7 @@ func uniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bo
} }
// Create inserts a new user into the database. // Create inserts a new user into the database.
func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req CreateUserRequest, now time.Time) (*User, error) { func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCreateRequest, now time.Time) (*User, error) {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create")
defer span.Finish() defer span.Finish()
@ -368,7 +368,7 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, i
} }
// Update replaces a user in the database. // Update replaces a user in the database.
func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateUserRequest, now time.Time) error { func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUpdateRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update")
defer span.Finish() defer span.Finish()
@ -452,9 +452,9 @@ func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Update
return nil return nil
} }
// Update replaces a user in the database. // Update changes the password for a user in the database.
func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdatePasswordRequest, now time.Time) error { func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUpdatePasswordRequest, now time.Time) error {
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.UpdatePassword")
defer span.Finish() defer span.Finish()
// Validate the request. // Validate the request.

View File

@ -65,12 +65,12 @@ type DeleteUserAccountRequest struct {
// UserAccountFindRequest defines the possible options to search for users accounts. // UserAccountFindRequest defines the possible options to search for users accounts.
// By default archived user accounts will be excluded from response. // By default archived user accounts will be excluded from response.
type UserAccountFindRequest struct { type UserAccountFindRequest struct {
Where *string Where *string `schema:"where"`
Args []interface{} Args []interface{} `schema:"args"`
Order []string Order []string `schema:"order"`
Limit *uint Limit *uint `schema:"limit"`
Offset *uint Offset *uint `schema:"offset"`
IncludedArchived bool IncludedArchived bool `schema:"included-archived"`
} }
// UserAccountStatus represents the status of a user for an account. // UserAccountStatus represents the status of a user for an account.

View File

@ -88,11 +88,9 @@ func ParseLines(lines []string, depth int) (objs *GoObjects, err error) {
ld := lineDepth(l) ld := lineDepth(l)
//fmt.Println("l", l) //fmt.Println("l", l)
//fmt.Println("> Depth", ld, "???", depth) //fmt.Println("> Depth", ld, "???", depth)
if ld == depth { if ld == depth {
if strings.HasPrefix(ls, "/*") { if strings.HasPrefix(ls, "/*") {
multiLine = true multiLine = true
@ -113,7 +111,6 @@ func ParseLines(lines []string, depth int) (objs *GoObjects, err error) {
} }
} }
//fmt.Println("> multiLine", multiLine) //fmt.Println("> multiLine", multiLine)
//fmt.Println("> multiComment", multiComment) //fmt.Println("> multiComment", multiComment)
//fmt.Println("> muiliVar", muiliVar) //fmt.Println("> muiliVar", muiliVar)

View File

@ -100,7 +100,6 @@ func TestParseLines1(t *testing.T) {
g.Expect(objs.Lines()).Should(gomega.Equal(lines)) g.Expect(objs.Lines()).Should(gomega.Equal(lines))
} }
} }
func TestParseLines2(t *testing.T) { func TestParseLines2(t *testing.T) {

View File

@ -133,7 +133,6 @@ func main() {
cli.StringFlag{Name: "templateDir, templates", Value: "./templates/dbtable2crud"}, cli.StringFlag{Name: "templateDir, templates", Value: "./templates/dbtable2crud"},
cli.StringFlag{Name: "projectPath"}, cli.StringFlag{Name: "projectPath"},
cli.BoolFlag{Name: "saveChanges, save"}, cli.BoolFlag{Name: "saveChanges, save"},
}, },
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
dbTable := strings.TrimSpace(c.String("dbtable")) dbTable := strings.TrimSpace(c.String("dbtable"))

View File

@ -21,12 +21,13 @@ type {{ FormatCamel $.Model.Name }}UpdateRequest struct {
// {{ FormatCamel $.Model.Name }}FindRequest defines the possible options to search for {{ FormatCamelPluralTitleLower $.Model.Name }}. By default // {{ FormatCamel $.Model.Name }}FindRequest defines the possible options to search for {{ FormatCamelPluralTitleLower $.Model.Name }}. By default
// archived {{ FormatCamelLowerTitle $.Model.Name }} will be excluded from response. // archived {{ FormatCamelLowerTitle $.Model.Name }} will be excluded from response.
type {{ FormatCamel $.Model.Name }}FindRequest struct { type {{ FormatCamel $.Model.Name }}FindRequest struct {
Where *string Where *string `schema:"where"`
Args []interface{} Args []interface{} `schema:"args"`
Order []string Order []string `schema:"order"`
Limit *uint Limit *uint `schema:"limit"`
Offset *uint Offset *uint `schema:"offset"`
{{ $hasArchived := (StringListHasValue $.Model.ColumnNames "archived_at") }}{{ if $hasArchived }}IncludedArchived bool{{ end }} IncludedArchived bool
{{ $hasArchived := (StringListHasValue $.Model.ColumnNames "archived_at") }}{{ if $hasArchived }}IncludedArchived bool `schema:"included-archived"`{{ end }}
} }
{{ end }} {{ end }}
{{ define "Enums"}} {{ define "Enums"}}