diff --git a/example-project/cmd/web-api/main.go b/example-project/cmd/web-api/main.go
index 6992515..0fe03ab 100644
--- a/example-project/cmd/web-api/main.go
+++ b/example-project/cmd/web-api/main.go
@@ -45,14 +45,14 @@ func main() {
// =========================================================================
// Logging
- log := log.New(os.Stdout, "WEB_APP : ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
+ log := log.New(os.Stdout, "WEB_API : ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
// =========================================================================
// Configuration
var cfg struct {
- Web struct {
- APIHost string `default:"0.0.0.0:3000" envconfig:"API_HOST"`
+ HTTP struct {
+ Host string `default:"0.0.0.0:3001" envconfig:"HTTP_HOST"`
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
ReadTimeout time.Duration `default:"5s" envconfig:"READ_TIMEOUT"`
WriteTimeout time.Duration `default:"5s" envconfig:"WRITE_TIMEOUT"`
@@ -164,10 +164,12 @@ func main() {
//
// /debug/vars - Added to the default mux by the expvars package.
// /debug/pprof - Added to the default mux by the net/http/pprof package.
- go func() {
- log.Printf("main : Debug Listening %s", cfg.Web.DebugHost)
- log.Printf("main : Debug Listener closed : %v", http.ListenAndServe(cfg.Web.DebugHost, http.DefaultServeMux))
- }()
+ if cfg.HTTP.DebugHost != "" {
+ go func() {
+ log.Printf("main : Debug Listening %s", cfg.HTTP.DebugHost)
+ log.Printf("main : Debug Listener closed : %v", http.ListenAndServe(cfg.HTTP.DebugHost, http.DefaultServeMux))
+ }()
+ }
// =========================================================================
// Start API Service
@@ -178,10 +180,10 @@ func main() {
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
api := http.Server{
- Addr: cfg.Web.APIHost,
+ Addr: cfg.HTTP.Host,
Handler: handlers.API(shutdown, log, masterDB, authenticator),
- ReadTimeout: cfg.Web.ReadTimeout,
- WriteTimeout: cfg.Web.WriteTimeout,
+ ReadTimeout: cfg.HTTP.ReadTimeout,
+ WriteTimeout: cfg.HTTP.WriteTimeout,
MaxHeaderBytes: 1 << 20,
}
@@ -191,7 +193,7 @@ func main() {
// Start the service listening for requests.
go func() {
- log.Printf("main : API Listening %s", cfg.Web.APIHost)
+ log.Printf("main : API Listening %s", cfg.HTTP.Host)
serverErrors <- api.ListenAndServe()
}()
@@ -207,13 +209,13 @@ func main() {
log.Printf("main : %v : Start shutdown..", sig)
// Create context for Shutdown call.
- ctx, cancel := context.WithTimeout(context.Background(), cfg.Web.ShutdownTimeout)
+ ctx, cancel := context.WithTimeout(context.Background(), cfg.HTTP.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.Web.ShutdownTimeout, err)
+ log.Printf("main : Graceful shutdown did not complete in %v : %v", cfg.HTTP.ShutdownTimeout, err)
err = api.Close()
}
diff --git a/example-project/cmd/web-app/handlers/check.go b/example-project/cmd/web-app/handlers/check.go
new file mode 100644
index 0000000..3b36c4c
--- /dev/null
+++ b/example-project/cmd/web-app/handlers/check.go
@@ -0,0 +1,37 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/db"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+ "go.opencensus.io/trace"
+)
+
+// Check provides support for orchestration health checks.
+type Check struct {
+ MasterDB *db.DB
+ Renderer web.Renderer
+
+ // ADD OTHER STATE LIKE THE LOGGER IF NEEDED.
+}
+
+// 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 {
+ ctx, span := trace.StartSpan(ctx, "handlers.Check.Health")
+ defer span.End()
+
+ dbConn := c.MasterDB.Copy()
+ defer dbConn.Close()
+
+ if err := dbConn.StatusCheck(ctx); err != nil {
+ return err
+ }
+
+ data := map[string]interface{}{
+ "Status": "ok",
+ }
+
+ return c.Renderer.Render(ctx, w, r, baseLayoutTmpl, "health.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
+}
diff --git a/example-project/cmd/web-app/handlers/root.go b/example-project/cmd/web-app/handlers/root.go
new file mode 100644
index 0000000..900b7de
--- /dev/null
+++ b/example-project/cmd/web-app/handlers/root.go
@@ -0,0 +1,25 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/db"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+ "go.opencensus.io/trace"
+)
+
+// User represents the User API method handler set.
+type Root struct {
+ MasterDB *db.DB
+ Renderer web.Renderer
+ // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
+}
+
+// List returns all the existing users in the system.
+func (u *Root) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
+ ctx, span := trace.StartSpan(ctx, "handlers.Root.Index")
+ defer span.End()
+
+ return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
+}
diff --git a/example-project/cmd/web-app/handlers/routes.go b/example-project/cmd/web-app/handlers/routes.go
new file mode 100644
index 0000000..e8375b6
--- /dev/null
+++ b/example-project/cmd/web-app/handlers/routes.go
@@ -0,0 +1,52 @@
+package handlers
+
+import (
+ "log"
+ "net/http"
+ "os"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/mid"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/db"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+)
+
+const baseLayoutTmpl = "base.tmpl"
+
+// API returns a handler for a set of routes.
+func APP(shutdown chan os.Signal, log *log.Logger, staticDir, templateDir string, masterDB *db.DB, authenticator *auth.Authenticator, renderer web.Renderer) http.Handler {
+
+ // Construct the web.App which holds all routes as well as common Middleware.
+ app := web.NewApp(shutdown, log, mid.Logger(log), mid.Errors(log), mid.Metrics(), mid.Panics())
+
+ // Register health check endpoint. This route is not authenticated.
+ check := Check{
+ MasterDB: masterDB,
+ Renderer: renderer,
+ }
+ app.Handle("GET", "/v1/health", check.Health)
+
+ // Register user management and authentication endpoints.
+ u := User{
+ MasterDB: masterDB,
+ Renderer: renderer,
+ }
+
+ // This route is not authenticated
+ app.Handle("POST", "/users/login", u.Login)
+ app.Handle("GET", "/users/login", u.Login)
+
+ // Register root
+ r := Root{
+ MasterDB: masterDB,
+ Renderer: renderer,
+ }
+ // This route is not authenticated
+ app.Handle("GET", "/index.html", r.Index)
+ app.Handle("GET", "/", r.Index)
+
+ // Static file server
+ app.Handle("GET", "/*", web.Static(staticDir,""))
+
+ return app
+}
diff --git a/example-project/cmd/web-app/handlers/user.go b/example-project/cmd/web-app/handlers/user.go
new file mode 100644
index 0000000..41eb342
--- /dev/null
+++ b/example-project/cmd/web-app/handlers/user.go
@@ -0,0 +1,28 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/db"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+ "go.opencensus.io/trace"
+)
+
+// User represents the User API method handler set.
+type User struct {
+ MasterDB *db.DB
+ Renderer web.Renderer
+ // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE.
+}
+
+// List returns all the existing users in the system.
+func (u *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
+ ctx, span := trace.StartSpan(ctx, "handlers.User.Login")
+ defer span.End()
+
+ //dbConn := u.MasterDB.Copy()
+ //defer dbConn.Close()
+
+ return u.Renderer.Render(ctx, w, r, baseLayoutTmpl, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
+}
diff --git a/example-project/cmd/web-app/static/assets/css/base.css b/example-project/cmd/web-app/static/assets/css/base.css
new file mode 100644
index 0000000..e69de29
diff --git a/example-project/cmd/web-app/static/assets/js/base.js b/example-project/cmd/web-app/static/assets/js/base.js
new file mode 100644
index 0000000..02525d0
--- /dev/null
+++ b/example-project/cmd/web-app/static/assets/js/base.js
@@ -0,0 +1 @@
+console.log("test");
\ No newline at end of file
diff --git a/example-project/cmd/web-app/templates/content/root-index.tmpl b/example-project/cmd/web-app/templates/content/root-index.tmpl
new file mode 100644
index 0000000..80933f6
--- /dev/null
+++ b/example-project/cmd/web-app/templates/content/root-index.tmpl
@@ -0,0 +1,10 @@
+{{define "title"}}Welcome{{end}}
+{{define "style"}}
+
+{{end}}
+{{define "content"}}
+ Welcome to the web app
+{{end}}
+{{define "js"}}
+
+{{end}}
diff --git a/example-project/cmd/web-app/templates/content/user-login.tmpl b/example-project/cmd/web-app/templates/content/user-login.tmpl
new file mode 100644
index 0000000..ffb1304
--- /dev/null
+++ b/example-project/cmd/web-app/templates/content/user-login.tmpl
@@ -0,0 +1,10 @@
+{{define "title"}}User Login{{end}}
+{{define "style"}}
+
+{{end}}
+{{define "content"}}
+ Login to this amazing web app
+{{end}}
+{{define "js"}}
+
+{{end}}
diff --git a/example-project/cmd/web-app/templates/layouts/base.tmpl b/example-project/cmd/web-app/templates/layouts/base.tmpl
new file mode 100644
index 0000000..2f6a73c
--- /dev/null
+++ b/example-project/cmd/web-app/templates/layouts/base.tmpl
@@ -0,0 +1,48 @@
+{{ define "base" }}
+
+
+
+
+ {{block "title" .}}{{end}} Web App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{block "style" .}} {{end}}
+
+
+
+
+
+ {{ template "content" . }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{block "js" .}} {{end}}
+
+
+{{end}}
\ No newline at end of file
diff --git a/example-project/cmd/web-app/tests/tests_test.go b/example-project/cmd/web-app/tests/tests_test.go
new file mode 100644
index 0000000..edc6e8f
--- /dev/null
+++ b/example-project/cmd/web-app/tests/tests_test.go
@@ -0,0 +1,97 @@
+package tests
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-app/handlers"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/user"
+)
+
+var a http.Handler
+var test *tests.Test
+
+// Information about the users we have created for testing.
+var adminAuthorization string
+var adminID string
+var userAuthorization string
+var userID string
+
+// TestMain is the entry point for testing.
+func TestMain(m *testing.M) {
+ os.Exit(testMain(m))
+}
+
+func testMain(m *testing.M) int {
+ test = tests.New()
+ defer test.TearDown()
+
+ // Create RSA keys to enable authentication in our service.
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ panic(err)
+ }
+
+ kid := "4754d86b-7a6d-4df5-9c65-224741361492"
+ kf := auth.NewSingleKeyFunc(kid, key.Public().(*rsa.PublicKey))
+ authenticator, err := auth.NewAuthenticator(key, kid, "RS256", kf)
+ if err != nil {
+ panic(err)
+ }
+
+ shutdown := make(chan os.Signal, 1)
+ a = handlers.API(shutdown, test.Log, test.MasterDB, authenticator)
+
+ // Create an admin user directly with our business logic. This creates an
+ // initial user that we will use for admin validated endpoints.
+ nu := user.NewUser{
+ Email: "admin@ardanlabs.com",
+ Name: "Admin User",
+ Roles: []string{auth.RoleAdmin, auth.RoleUser},
+ Password: "gophers",
+ PasswordConfirm: "gophers",
+ }
+
+ admin, err := user.Create(tests.Context(), test.MasterDB, &nu, time.Now())
+ if err != nil {
+ panic(err)
+ }
+ adminID = admin.ID.Hex()
+
+ tkn, err := user.Authenticate(tests.Context(), test.MasterDB, authenticator, time.Now(), nu.Email, nu.Password)
+ if err != nil {
+ panic(err)
+ }
+
+ adminAuthorization = "Bearer " + tkn.Token
+
+ // Create a regular user to use when calling regular validated endpoints.
+ nu = user.NewUser{
+ Email: "user@ardanlabs.com",
+ Name: "Regular User",
+ Roles: []string{auth.RoleUser},
+ Password: "concurrency",
+ PasswordConfirm: "concurrency",
+ }
+
+ usr, err := user.Create(tests.Context(), test.MasterDB, &nu, time.Now())
+ if err != nil {
+ panic(err)
+ }
+ userID = usr.ID.Hex()
+
+ tkn, err = user.Authenticate(tests.Context(), test.MasterDB, authenticator, time.Now(), nu.Email, nu.Password)
+ if err != nil {
+ panic(err)
+ }
+
+ userAuthorization = "Bearer " + tkn.Token
+
+ return m.Run()
+}
diff --git a/example-project/cmd/web-app/tests/user_test.go b/example-project/cmd/web-app/tests/user_test.go
new file mode 100644
index 0000000..986da73
--- /dev/null
+++ b/example-project/cmd/web-app/tests/user_test.go
@@ -0,0 +1,576 @@
+package tests
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/user"
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "gopkg.in/mgo.v2/bson"
+)
+
+// TestUsers is the entry point for testing user management functions.
+func TestUsers(t *testing.T) {
+ defer tests.Recover(t)
+
+ t.Run("getToken401", getToken401)
+ t.Run("getToken200", getToken200)
+ t.Run("postUser400", postUser400)
+ t.Run("postUser401", postUser401)
+ t.Run("postUser403", postUser403)
+ t.Run("getUser400", getUser400)
+ t.Run("getUser403", getUser403)
+ t.Run("getUser404", getUser404)
+ t.Run("deleteUser404", deleteUser404)
+ t.Run("putUser404", putUser404)
+ t.Run("crudUsers", crudUser)
+}
+
+// getToken401 ensures an unknown user can't generate a token.
+func getToken401(t *testing.T) {
+ r := httptest.NewRequest("GET", "/v1/users/token", nil)
+ w := httptest.NewRecorder()
+
+ r.SetBasicAuth("unknown@example.com", "some-password")
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to deny tokens to unknown users.")
+ {
+ t.Log("\tTest 0:\tWhen fetching a token with an unrecognized email.")
+ {
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("\t%s\tShould receive a status code of 401 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 401 for the response.", tests.Success)
+ }
+ }
+}
+
+// getToken200
+func getToken200(t *testing.T) {
+
+ r := httptest.NewRequest("GET", "/v1/users/token", nil)
+ w := httptest.NewRecorder()
+
+ r.SetBasicAuth("admin@ardanlabs.com", "gophers")
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to issues tokens to known users.")
+ {
+ t.Log("\tTest 0:\tWhen fetching a token with valid credentials.")
+ {
+ if w.Code != http.StatusOK {
+ t.Fatalf("\t%s\tShould receive a status code of 200 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 200 for the response.", tests.Success)
+
+ var got user.Token
+ if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
+ t.Fatalf("\t%s\tShould be able to unmarshal the response : %v", tests.Failed, err)
+ }
+ t.Logf("\t%s\tShould be able to unmarshal the response.", tests.Success)
+
+ // TODO(jlw) Should we ensure the token is valid?
+ }
+ }
+}
+
+// postUser400 validates a user can't be created with the endpoint
+// unless a valid user document is submitted.
+func postUser400(t *testing.T) {
+ body, err := json.Marshal(&user.NewUser{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("POST", "/v1/users", bytes.NewBuffer(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate a new user can't be created with an invalid document.")
+ {
+ t.Log("\tTest 0:\tWhen using an incomplete user value.")
+ {
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("\t%s\tShould receive a status code of 400 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 400 for the response.", tests.Success)
+
+ // Inspect the response.
+ var got web.ErrorResponse
+ if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
+ t.Fatalf("\t%s\tShould be able to unmarshal the response to an error type : %v", tests.Failed, err)
+ }
+ t.Logf("\t%s\tShould be able to unmarshal the response to an error type.", tests.Success)
+
+ // Define what we want to see.
+ want := web.ErrorResponse{
+ Error: "field validation error",
+ Fields: []web.FieldError{
+ {Field: "name", Error: "name is a required field"},
+ {Field: "email", Error: "email is a required field"},
+ {Field: "roles", Error: "roles is a required field"},
+ {Field: "password", Error: "password is a required field"},
+ },
+ }
+
+ // We can't rely on the order of the field errors so they have to be
+ // sorted. Tell the cmp package how to sort them.
+ sorter := cmpopts.SortSlices(func(a, b web.FieldError) bool {
+ return a.Field < b.Field
+ })
+
+ if diff := cmp.Diff(want, got, sorter); diff != "" {
+ t.Fatalf("\t%s\tShould get the expected result. Diff:\n%s", tests.Failed, diff)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// postUser401 validates a user can't be created unless the calling user is
+// authenticated.
+func postUser401(t *testing.T) {
+ body, err := json.Marshal(&user.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("POST", "/v1/users", bytes.NewBuffer(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", userAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate a new user can't be created with an invalid document.")
+ {
+ t.Log("\tTest 0:\tWhen using an incomplete user value.")
+ {
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("\t%s\tShould receive a status code of 403 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 403 for the response.", tests.Success)
+ }
+ }
+}
+
+// postUser403 validates a user can't be created unless the calling user is
+// an admin user. Regular users can't do this.
+func postUser403(t *testing.T) {
+ body, err := json.Marshal(&user.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("POST", "/v1/users", bytes.NewBuffer(body))
+ w := httptest.NewRecorder()
+
+ // Not setting the Authorization header
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate a new user can't be created with an invalid document.")
+ {
+ t.Log("\tTest 0:\tWhen using an incomplete user value.")
+ {
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("\t%s\tShould receive a status code of 401 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 401 for the response.", tests.Success)
+ }
+ }
+}
+
+// getUser400 validates a user request for a malformed userid.
+func getUser400(t *testing.T) {
+ id := "12345"
+
+ r := httptest.NewRequest("GET", "/v1/users/"+id, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate getting a user with a malformed userid.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("\t%s\tShould receive a status code of 400 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 400 for the response.", tests.Success)
+
+ recv := w.Body.String()
+ resp := `{"error":"ID is not in its proper form"}`
+ if resp != recv {
+ t.Log("Got :", recv)
+ t.Log("Want:", resp)
+ t.Fatalf("\t%s\tShould get the expected result.", tests.Failed)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// getUser403 validates a regular user can't fetch anyone but themselves
+func getUser403(t *testing.T) {
+ t.Log("Given the need to validate regular users can't fetch other users.")
+ {
+ t.Logf("\tTest 0:\tWhen fetching the admin user as a regular user.")
+ {
+ r := httptest.NewRequest("GET", "/v1/users/"+adminID, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", userAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("\t%s\tShould receive a status code of 403 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 403 for the response.", tests.Success)
+
+ recv := w.Body.String()
+ resp := `{"error":"Attempted action is not allowed"}`
+ if resp != recv {
+ t.Log("Got :", recv)
+ t.Log("Want:", resp)
+ t.Fatalf("\t%s\tShould get the expected result.", tests.Failed)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+
+ t.Logf("\tTest 1:\tWhen fetching the user as a themselves.")
+ {
+
+ r := httptest.NewRequest("GET", "/v1/users/"+userID, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", userAuthorization)
+
+ a.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("\t%s\tShould receive a status code of 200 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 200 for the response.", tests.Success)
+ }
+ }
+}
+
+// getUser404 validates a user request for a user that does not exist with the endpoint.
+func getUser404(t *testing.T) {
+ id := bson.NewObjectId().Hex()
+
+ r := httptest.NewRequest("GET", "/v1/users/"+id, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate getting a user with an unknown id.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("\t%s\tShould receive a status code of 404 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 404 for the response.", tests.Success)
+
+ recv := w.Body.String()
+ resp := "Entity not found"
+ if !strings.Contains(recv, resp) {
+ t.Log("Got :", recv)
+ t.Log("Want:", resp)
+ t.Fatalf("\t%s\tShould get the expected result.", tests.Failed)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// deleteUser404 validates deleting a user that does not exist.
+func deleteUser404(t *testing.T) {
+ id := bson.NewObjectId().Hex()
+
+ r := httptest.NewRequest("DELETE", "/v1/users/"+id, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate deleting a user that does not exist.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("\t%s\tShould receive a status code of 404 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 404 for the response.", tests.Success)
+
+ recv := w.Body.String()
+ resp := "Entity not found"
+ if !strings.Contains(recv, resp) {
+ t.Log("Got :", recv)
+ t.Log("Want:", resp)
+ t.Fatalf("\t%s\tShould get the expected result.", tests.Failed)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// putUser404 validates updating a user that does not exist.
+func putUser404(t *testing.T) {
+ u := user.UpdateUser{
+ Name: tests.StringPointer("Doesn't Exist"),
+ }
+
+ id := bson.NewObjectId().Hex()
+
+ body, err := json.Marshal(&u)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("PUT", "/v1/users/"+id, bytes.NewBuffer(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate updating a user that does not exist.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("\t%s\tShould receive a status code of 404 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 404 for the response.", tests.Success)
+
+ recv := w.Body.String()
+ resp := "Entity not found"
+ if !strings.Contains(recv, resp) {
+ t.Log("Got :", recv)
+ t.Log("Want:", resp)
+ t.Fatalf("\t%s\tShould get the expected result.", tests.Failed)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// crudUser performs a complete test of CRUD against the api.
+func crudUser(t *testing.T) {
+ nu := postUser201(t)
+ defer deleteUser204(t, nu.ID.Hex())
+
+ getUser200(t, nu.ID.Hex())
+ putUser204(t, nu.ID.Hex())
+ putUser403(t, nu.ID.Hex())
+}
+
+// postUser201 validates a user can be created with the endpoint.
+func postUser201(t *testing.T) user.User {
+ nu := user.NewUser{
+ Name: "Bill Kennedy",
+ Email: "bill@ardanlabs.com",
+ Roles: []string{auth.RoleAdmin},
+ Password: "gophers",
+ PasswordConfirm: "gophers",
+ }
+
+ body, err := json.Marshal(&nu)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("POST", "/v1/users", bytes.NewBuffer(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ // u is the value we will return.
+ var u user.User
+
+ t.Log("Given the need to create a new user with the users endpoint.")
+ {
+ t.Log("\tTest 0:\tWhen using the declared user value.")
+ {
+ if w.Code != http.StatusCreated {
+ t.Fatalf("\t%s\tShould receive a status code of 201 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 201 for the response.", tests.Success)
+
+ if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
+ t.Fatalf("\t%s\tShould be able to unmarshal the response : %v", tests.Failed, err)
+ }
+
+ // Define what we wanted to receive. We will just trust the generated
+ // fields like ID and Dates so we copy u.
+ want := u
+ want.Name = "Bill Kennedy"
+ want.Email = "bill@ardanlabs.com"
+ want.Roles = []string{auth.RoleAdmin}
+
+ if diff := cmp.Diff(want, u); diff != "" {
+ t.Fatalf("\t%s\tShould get the expected result. Diff:\n%s", tests.Failed, diff)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+
+ return u
+}
+
+// deleteUser200 validates deleting a user that does exist.
+func deleteUser204(t *testing.T, id string) {
+ r := httptest.NewRequest("DELETE", "/v1/users/"+id, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate deleting a user that does exist.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("\t%s\tShould receive a status code of 204 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 204 for the response.", tests.Success)
+ }
+ }
+}
+
+// getUser200 validates a user request for an existing userid.
+func getUser200(t *testing.T, id string) {
+ r := httptest.NewRequest("GET", "/v1/users/"+id, nil)
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to validate getting a user that exsits.")
+ {
+ t.Logf("\tTest 0:\tWhen using the new user %s.", id)
+ {
+ if w.Code != http.StatusOK {
+ t.Fatalf("\t%s\tShould receive a status code of 200 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 200 for the response.", tests.Success)
+
+ var u user.User
+ if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
+ t.Fatalf("\t%s\tShould be able to unmarshal the response : %v", tests.Failed, err)
+ }
+
+ // Define what we wanted to receive. We will just trust the generated
+ // fields like Dates so we copy p.
+ want := u
+ want.ID = bson.ObjectIdHex(id)
+ want.Name = "Bill Kennedy"
+ want.Email = "bill@ardanlabs.com"
+ want.Roles = []string{auth.RoleAdmin}
+
+ if diff := cmp.Diff(want, u); diff != "" {
+ t.Fatalf("\t%s\tShould get the expected result. Diff:\n%s", tests.Failed, diff)
+ }
+ t.Logf("\t%s\tShould get the expected result.", tests.Success)
+ }
+ }
+}
+
+// putUser204 validates updating a user that does exist.
+func putUser204(t *testing.T, id string) {
+ body := `{"name": "Jacob Walker"}`
+
+ r := httptest.NewRequest("PUT", "/v1/users/"+id, strings.NewReader(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to update a user with the users endpoint.")
+ {
+ t.Log("\tTest 0:\tWhen using the modified user value.")
+ {
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("\t%s\tShould receive a status code of 204 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 204 for the response.", tests.Success)
+
+ r = httptest.NewRequest("GET", "/v1/users/"+id, nil)
+ w = httptest.NewRecorder()
+
+ r.Header.Set("Authorization", adminAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("\t%s\tShould receive a status code of 200 for the retrieve : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 200 for the retrieve.", tests.Success)
+
+ var ru user.User
+ if err := json.NewDecoder(w.Body).Decode(&ru); err != nil {
+ t.Fatalf("\t%s\tShould be able to unmarshal the response : %v", tests.Failed, err)
+ }
+
+ if ru.Name != "Jacob Walker" {
+ t.Fatalf("\t%s\tShould see an updated Name : got %q want %q", tests.Failed, ru.Name, "Jacob Walker")
+ }
+ t.Logf("\t%s\tShould see an updated Name.", tests.Success)
+
+ if ru.Email != "bill@ardanlabs.com" {
+ t.Fatalf("\t%s\tShould not affect other fields like Email : got %q want %q", tests.Failed, ru.Email, "bill@ardanlabs.com")
+ }
+ t.Logf("\t%s\tShould not affect other fields like Email.", tests.Success)
+ }
+ }
+}
+
+// putUser403 validates that a user can't modify users unless they are an admin.
+func putUser403(t *testing.T, id string) {
+ body := `{"name": "Anna Walker"}`
+
+ r := httptest.NewRequest("PUT", "/v1/users/"+id, strings.NewReader(body))
+ w := httptest.NewRecorder()
+
+ r.Header.Set("Authorization", userAuthorization)
+
+ a.ServeHTTP(w, r)
+
+ t.Log("Given the need to update a user with the users endpoint.")
+ {
+ t.Log("\tTest 0:\tWhen a non-admin user makes a request")
+ {
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("\t%s\tShould receive a status code of 403 for the response : %v", tests.Failed, w.Code)
+ }
+ t.Logf("\t%s\tShould receive a status code of 403 for the response.", tests.Success)
+ }
+ }
+}
diff --git a/example-project/go.mod b/example-project/go.mod
index 0fb6c6e..8c567d3 100644
--- a/example-project/go.mod
+++ b/example-project/go.mod
@@ -6,17 +6,21 @@ require (
github.com/go-playground/locales v0.12.1
github.com/go-playground/universal-translator v0.16.0
github.com/google/go-cmp v0.2.0
+ github.com/hashicorp/golang-lru v0.5.1 // indirect
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/openzipkin/zipkin-go v0.1.1
github.com/pborman/uuid v0.0.0-20180122190007-c65b2f87fee3
+ github.com/philhofer/fwd v1.0.0 // indirect
github.com/pkg/errors v0.8.0
github.com/stretchr/testify v1.3.0 // indirect
+ github.com/tinylib/msgp v1.1.0 // indirect
go.opencensus.io v0.14.0
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 // indirect
golang.org/x/text v0.3.0 // indirect
+ gopkg.in/DataDog/dd-trace-go.v1 v1.13.1
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v9 v9.28.0
diff --git a/example-project/go.sum b/example-project/go.sum
index 470ae03..861a9de 100644
--- a/example-project/go.sum
+++ b/example-project/go.sum
@@ -10,6 +10,8 @@ github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rm
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
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/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -23,6 +25,8 @@ github.com/openzipkin/zipkin-go v0.1.1 h1:A/ADD6HaPnAKj3yS7HjGHRK77qi41Hi0DirOOI
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
github.com/pborman/uuid v0.0.0-20180122190007-c65b2f87fee3 h1:9J0mOv1rXIBlRjQCiAGyx9C3dZZh5uIa3HU0oTV8v1E=
github.com/pborman/uuid v0.0.0-20180122190007-c65b2f87fee3/go.mod h1:VyrYX9gd7irzKovcSS6BIIEwPRkP2Wm2m9ufcdFSJ34=
+github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
+github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -30,6 +34,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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=
go.opencensus.io v0.14.0 h1:1eTLxqxSIAylcKoxnNkdhvvBNZDA8JwkKNXxgyma0IA=
go.opencensus.io v0.14.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0=
golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b h1:2b9XGzhjiYsYPnKXoEfL7klWZQIt8IfyRCz62gCqqlQ=
@@ -38,6 +44,8 @@ golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmy
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/DataDog/dd-trace-go.v1 v1.13.1 h1:oTzOClfuudNhW9Skkp2jxjqYO92uDKXqKLbiuPA13Rk=
+gopkg.in/DataDog/dd-trace-go.v1 v1.13.1/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/example-project/internal/platform/deploy/cloud_front.go b/example-project/internal/platform/deploy/cloud_front.go
new file mode 100644
index 0000000..56ee7ef
--- /dev/null
+++ b/example-project/internal/platform/deploy/cloud_front.go
@@ -0,0 +1,8 @@
+package deploy
+
+/*
+func () {
+
+ func (c *CloudFront) ListDistributions(input *ListDistributionsInput)
+
+} */
\ No newline at end of file
diff --git a/example-project/internal/platform/web/renderer.go b/example-project/internal/platform/web/renderer.go
new file mode 100644
index 0000000..92d6c8b
--- /dev/null
+++ b/example-project/internal/platform/web/renderer.go
@@ -0,0 +1,12 @@
+package web
+
+import (
+ "context"
+ "net/http"
+)
+
+type Renderer interface {
+ Render(ctx context.Context, w http.ResponseWriter, req *http.Request, templateLayoutName, templateContentName, contentType string, statusCode int, data map[string]interface{}) error
+ Error(ctx context.Context, w http.ResponseWriter, req *http.Request, statusCode int, er error) error
+ Static(rootDir, prefix string) Handler
+}
diff --git a/example-project/internal/platform/web/response.go b/example-project/internal/platform/web/response.go
index d2dba7c..a4cd824 100644
--- a/example-project/internal/platform/web/response.go
+++ b/example-project/internal/platform/web/response.go
@@ -3,13 +3,32 @@ package web
import (
"context"
"encoding/json"
- "net/http"
-
+ "fmt"
"github.com/pkg/errors"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
)
-// RespondError sends an error reponse back to the client.
-func RespondError(ctx context.Context, w http.ResponseWriter, err error) error {
+const (
+ charsetUTF8 = "charset=UTF-8"
+)
+
+// MIME types
+const (
+ MIMEApplicationJSON = "application/json"
+ MIMEApplicationJSONCharsetUTF8 = MIMEApplicationJSON + "; " + charsetUTF8
+ MIMETextHTML = "text/html"
+ MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8
+ MIMETextPlain = "text/plain"
+ MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8
+ MIMEOctetStream = "application/octet-stream"
+)
+
+// RespondJsonError sends an error formatted as JSON response back to the client.
+func RespondJsonError(ctx context.Context, w http.ResponseWriter, err error) error {
// If the error was of the type *Error, the handler has
// a specific status code and error to return.
@@ -18,7 +37,7 @@ func RespondError(ctx context.Context, w http.ResponseWriter, err error) error {
Error: webErr.Err.Error(),
Fields: webErr.Fields,
}
- if err := Respond(ctx, w, er, webErr.Status); err != nil {
+ if err := RespondJson(ctx, w, er, webErr.Status); err != nil {
return err
}
return nil
@@ -28,15 +47,15 @@ func RespondError(ctx context.Context, w http.ResponseWriter, err error) error {
er := ErrorResponse{
Error: http.StatusText(http.StatusInternalServerError),
}
- if err := Respond(ctx, w, er, http.StatusInternalServerError); err != nil {
+ if err := RespondJson(ctx, w, er, http.StatusInternalServerError); err != nil {
return err
}
return nil
}
-// Respond converts a Go value to JSON and sends it to the client.
+// RespondJson converts a Go value to JSON and sends it to the client.
// If code is StatusNoContent, v is expected to be nil.
-func Respond(ctx context.Context, w http.ResponseWriter, data interface{}, statusCode int) error {
+func RespondJson(ctx context.Context, w http.ResponseWriter, data interface{}, statusCode int) error {
// Set the status code for the request logger middleware.
// If the context is missing this value, request the service
@@ -60,7 +79,7 @@ func Respond(ctx context.Context, w http.ResponseWriter, data interface{}, statu
}
// Set the content type and headers once we know marshaling has succeeded.
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Type", MIMEApplicationJSONCharsetUTF8)
// Write the status code to the response.
w.WriteHeader(statusCode)
@@ -72,3 +91,102 @@ func Respond(ctx context.Context, w http.ResponseWriter, data interface{}, statu
return nil
}
+
+// RespondError sends an error back to the client as plain text with
+// the status code 500 Internal Service Error
+func RespondError(ctx context.Context, w http.ResponseWriter, er error) error {
+ return RespondErrorStatus(ctx, w, er, http.StatusInternalServerError)
+}
+
+// RespondErrorStatus sends an error back to the client as plain text with
+// the specified HTTP status code.
+func RespondErrorStatus(ctx context.Context, w http.ResponseWriter, er error, statusCode int) error {
+ msg := fmt.Sprintf("%s", er)
+ if err := Respond(ctx, w, []byte(msg), statusCode, MIMETextPlainCharsetUTF8); err != nil {
+ return err
+ }
+ return nil
+}
+
+// Respond writes the data to the client with the specified HTTP status code and
+// content type.
+func Respond(ctx context.Context, w http.ResponseWriter, data []byte, statusCode int, contentType string) error {
+ // Set the status code for the request logger middleware.
+ // If the context is missing this value, request the service
+ // to be shutdown gracefully.
+ v, ok := ctx.Value(KeyValues).(*Values)
+ if !ok {
+ return NewShutdownError("web value missing from context")
+ }
+ v.StatusCode = statusCode
+
+ // If there is nothing to marshal then set status code and return.
+ if statusCode == http.StatusNoContent {
+ w.WriteHeader(statusCode)
+ return nil
+ }
+
+ // Set the content type and headers once we know marshaling has succeeded.
+ w.Header().Set("Content-Type", contentType)
+
+ // Write the status code to the response.
+ w.WriteHeader(statusCode)
+
+ // Send the result back to the client.
+ if _, err := w.Write(data); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Static registers a new route with path prefix to serve static files from the
+// provided root directory. All errors will result in 404 File Not Found.
+func Static(rootDir, prefix string) Handler {
+ h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
+ err := StaticHandler(ctx, w, r, params, rootDir, prefix)
+ if err != nil {
+ return RespondErrorStatus(ctx, w, err, http.StatusNotFound)
+ }
+ return nil
+ }
+ return h
+}
+
+// StaticHandler sends a static file wo the client. The error is returned directly
+// from this function allowing it to be wrapped by a Handler. The handler then was the
+// the ability to format/display the error before responding to the client.
+func StaticHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string, rootDir, prefix string) error {
+ // Parse the URL from the http request.
+ urlPath := path.Clean("/"+r.URL.Path) // "/"+ for security
+ urlPath = strings.TrimLeft(urlPath, "/")
+
+ // Remove the static directory name from the url
+ urlPath = strings.TrimLeft(urlPath, filepath.Base(rootDir))
+
+ // Also remove the URL prefix used to serve the static file since
+ // this does not need to match any existing directory structure.
+ if prefix != "" {
+ urlPath = strings.TrimLeft(urlPath, prefix)
+ }
+
+ // Resolve the root directory to an absolute path
+ sd, err := filepath.Abs(rootDir)
+ if err != nil {
+ return err
+ }
+
+ // Append the requested file to the root directory
+ filePath := filepath.Join(sd, urlPath)
+
+ // Make sure the file exists before attempting to serve it so
+ // have the opportunity to handle the when a file does not exist.
+ if _, err := os.Stat(filePath); err != nil {
+ return err
+ }
+
+ // Serve the file from the local file system.
+ http.ServeFile(w, r , filePath)
+
+ return nil
+}
diff --git a/example-project/internal/platform/web/template-renderer/README.md b/example-project/internal/platform/web/template-renderer/README.md
new file mode 100644
index 0000000..eff2816
--- /dev/null
+++ b/example-project/internal/platform/web/template-renderer/README.md
@@ -0,0 +1,5 @@
+
+requires the following directories in the template directory
+content
+layouts
+partials
diff --git a/example-project/internal/platform/web/template-renderer/template_renderer.go b/example-project/internal/platform/web/template-renderer/template_renderer.go
new file mode 100644
index 0000000..47bf051
--- /dev/null
+++ b/example-project/internal/platform/web/template-renderer/template_renderer.go
@@ -0,0 +1,337 @@
+package template_renderer
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
+ "github.com/pkg/errors"
+ "gopkg.in/DataDog/dd-trace-go.v1/ddtrace"
+ "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext"
+ "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
+)
+
+var (
+ errInvalidTemplate = errors.New("Invalid template")
+
+ // Base template to support applying custom
+ // TODO try to remove this
+ //mainTmpl = `{{define "main" }} {{ template "base" . }} {{ end }}`
+)
+
+type Template struct {
+ Funcs template.FuncMap
+ mainTemplate *template.Template
+}
+
+
+func NewTemplate(templateFuncs template.FuncMap) *Template {
+ t := &Template{}
+
+ // these functions are used and rendered on run-time of web page so don't have to use javascript/jquery
+ // to for basic template formatting. transformation happens server-side instead of client-side to
+ // provide base-level consistency.
+ t.Funcs = template.FuncMap{
+ // probably could provide examples of each of these
+ "Minus": func(a, b int) int {
+ return a - b
+ },
+ "Add": func(a, b int) int {
+ return a + b
+ },
+ "Mod": func(a, b int) int {
+ return int(math.Mod(float64(a), float64(b)))
+ },
+ "AssetUrl": func(p string) string {
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return p
+ },
+ "AppAssetUrl": func(p string) string {
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return p
+ },
+ "SiteS3Url": func(p string) string {
+ return p
+ },
+ "S3Url": func(p string) string {
+ return p
+ },
+ "AppBaseUrl": func(p string) string {
+ return p
+ },
+ "Http2Https": func(u string) string {
+ return strings.Replace(u, "http:", "https:", 1)
+ },
+ "StringHasPrefix": func(str, match string) bool {
+ if strings.HasPrefix(str, match) {
+ return true
+ }
+ return false
+ },
+ "StringHasSuffix": func(str, match string) bool {
+ if strings.HasSuffix(str, match) {
+ return true
+ }
+ return false
+ },
+ "StringContains": func(str, match string) bool {
+ if strings.Contains(str, match) {
+ return true
+ }
+ return false
+ },
+ "NavPageClass": func(uri, uriMatch, uriClass string) string {
+ u, err := url.Parse(uri)
+ if err != nil {
+ return "?"
+ }
+ if strings.HasPrefix(u.Path, uriMatch) {
+ return uriClass
+ }
+ return ""
+ },
+ "UrlEncode": func(k string) string {
+ return url.QueryEscape(k)
+ },
+ "html": func(value interface{}) template.HTML {
+ return template.HTML(fmt.Sprint(value))
+ },
+ }
+ for fn, f := range templateFuncs {
+ t.Funcs[fn] = f
+ }
+
+ return t
+}
+
+// TemplateRenderer is a custom html/template renderer for Echo framework
+type TemplateRenderer struct {
+ templateDir string
+ // has to be map so can know the name and map the name to the location / file path
+ layoutFiles map[string]string
+ contentFiles map[string]string
+ partialFiles map[string]string
+ enableHotReload bool
+ templates map[string]*template.Template
+ globalViewData map[string]interface{}
+ //mainTemplate *template.Template
+ errorHandler func(ctx context.Context, w http.ResponseWriter, req *http.Request, renderer web.Renderer, statusCode int, er error) error
+}
+
+func NewTemplateRenderer(templateDir string, enableHotReload bool, globalViewData map[string]interface{}, tmpl *Template, errorHandler func(ctx context.Context, w http.ResponseWriter, req *http.Request, renderer web.Renderer, statusCode int, er error) error) (*TemplateRenderer, error) {
+ r := &TemplateRenderer{
+ templateDir: templateDir,
+ layoutFiles: make( map[string]string),
+ contentFiles: make( map[string]string),
+ partialFiles: make( map[string]string),
+ enableHotReload: enableHotReload,
+ templates: make(map[string]*template.Template),
+ globalViewData:globalViewData,
+ errorHandler: errorHandler,
+ }
+
+ //r.mainTemplate = template.New("main")
+ //r.mainTemplate, _ = r.mainTemplate.Parse(mainTmpl)
+ //r.mainTemplate.Funcs(tmpl.Funcs)
+
+ err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
+ dir := filepath.Base(filepath.Dir(path))
+
+ if info.IsDir() {
+ return nil
+ }
+
+ baseName := filepath.Base(path)
+
+ if dir == "content" {
+ r.contentFiles[baseName] = path
+ } else if dir == "layouts" {
+ r.layoutFiles[baseName] = path
+ } else if dir == "partials" {
+ r.partialFiles[baseName] = path
+ }
+
+ return err
+ })
+ if err != nil {
+ return r, err
+ }
+
+ // Ensure all layout files render successfully with no errors.
+ for _, f := range r.layoutFiles {
+ //t, err := r.mainTemplate.Clone()
+ //if err != nil {
+ // return r, err
+ //}
+ t := template.New("main")
+ t.Funcs(tmpl.Funcs)
+ template.Must(t.ParseFiles(f))
+ }
+
+ // Ensure all partial files render successfully with no errors.
+ for _, f := range r.partialFiles {
+ //t, err := r.mainTemplate.Clone()
+ //if err != nil {
+ // return r, err
+ //}
+ t := template.New("partial")
+ t.Funcs(tmpl.Funcs)
+ template.Must(t.ParseFiles(f))
+ }
+
+ // Ensure all content files render successfully with no errors.
+ for _, f := range r.contentFiles {
+ //t, err := r.mainTemplate.Clone()
+ //if err != nil {
+ // return r, err
+ //}
+ t := template.New("content")
+ t.Funcs(tmpl.Funcs)
+ template.Must(t.ParseFiles(f))
+ }
+
+ return r, nil
+}
+
+// Render renders a template document
+func (r *TemplateRenderer) Render(ctx context.Context, w http.ResponseWriter, req *http.Request, templateLayoutName, templateContentName, contentType string, statusCode int, data map[string]interface{}) error {
+
+ t, ok := r.templates[templateContentName]
+ if !ok || r.enableHotReload {
+ layoutFile, ok := r.layoutFiles[templateLayoutName]
+ if !ok {
+ return errors.Wrapf(errInvalidTemplate, "template layout file for %s does not exist", templateLayoutName)
+ }
+ files := []string{layoutFile}
+
+ for _, f := range r.partialFiles {
+ files = append(files, f)
+ }
+
+ contentFile, ok := r.contentFiles[templateContentName]
+ if !ok {
+ return errors.Wrapf(errInvalidTemplate, "template content file for %s does not exist", templateContentName)
+ }
+ files = append(files, contentFile)
+
+ t = template.Must(t.ParseFiles(files...))
+ r.templates[templateContentName] = t
+ }
+
+ opts := []ddtrace.StartSpanOption{
+ tracer.SpanType(ext.SpanTypeWeb),
+ tracer.ResourceName(templateContentName),
+ }
+
+ var span tracer.Span
+ span, ctx = tracer.StartSpanFromContext(ctx, "web.Render", opts...)
+ defer span.Finish()
+
+ // Specific new data map for render to allow values to be overwritten on a request
+ // basis.
+ // append the global key/pairs
+ renderData := r.globalViewData
+ if renderData == nil {
+ renderData = make(map[string]interface{})
+ }
+
+ // Add Request URL to render data
+ reqData := map[string]interface{}{
+ "Url": "",
+ "Uri": "",
+ }
+ if req != nil {
+ reqData["Url"] = req.URL.String()
+ reqData["Uri"] = req.URL.RequestURI()
+ }
+ renderData["_Request"] = reqData
+
+ // Add context to render data, this supports template functions having the ability
+ // to define context.Context as an argument
+ renderData["_Ctx"] = ctx
+
+
+ // Append request data map to render data last so any previous value can be overwritten.
+ if data != nil {
+ for k, v := range data {
+ renderData[k] = v
+ }
+ }
+
+ // Render template with data.
+ err := t.Execute(w, renderData)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (r *TemplateRenderer) Error(ctx context.Context, w http.ResponseWriter, req *http.Request, statusCode int, er error) error {
+ // If error hander was defined to support formated response for web, used it.
+ if r.errorHandler != nil {
+ return r.errorHandler(ctx, w, req, r, statusCode, er)
+ }
+
+ // Default response text response of error.
+ return web.RespondError(ctx, w, er)
+}
+
+func (tr *TemplateRenderer) Static(rootDir, prefix string) web.Handler {
+ h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
+ err := web.StaticHandler(ctx, w, r, params, rootDir, prefix)
+ if err != nil {
+ return tr.Error(ctx, w, r, http.StatusNotFound, err)
+ }
+ return nil
+ }
+ return h
+}
+
+func S3Url(baseS3Url, baseS3Origin, p string) string {
+ if strings.HasPrefix(p, "http") {
+ return p
+ }
+ org := strings.TrimRight(baseS3Origin, "/")
+ if org != "" {
+ p = strings.Replace(p, org+"/", "", 1)
+ }
+
+ pts := strings.Split(p, "?")
+ p = pts[0]
+
+ var rq string
+ if len(pts) > 1 {
+ rq = pts[1]
+ }
+
+ p = strings.TrimLeft(p, "/")
+
+ baseUrl := baseS3Url
+
+ u, err := url.Parse(baseUrl)
+ if err != nil {
+ return "?"
+ }
+ ldir := filepath.Base(u.Path)
+
+ if strings.HasPrefix(p, ldir) {
+ p = strings.Replace(p, ldir+"/", "", 1)
+ }
+
+ u.Path = filepath.Join(u.Path, p)
+ u.RawQuery = rq
+
+ return u.String()
+}