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() +}