mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-06-08 23:56:37 +02:00
419 lines
14 KiB
Go
419 lines
14 KiB
Go
|
package main
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"encoding/json"
|
||
|
"expvar"
|
||
|
"fmt"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
_ "net/http/pprof"
|
||
|
"net/url"
|
||
|
"os"
|
||
|
"os/signal"
|
||
|
"path/filepath"
|
||
|
"reflect"
|
||
|
"strings"
|
||
|
"syscall"
|
||
|
"time"
|
||
|
"html/template"
|
||
|
|
||
|
template_renderer "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web/template-renderer"
|
||
|
lru "github.com/hashicorp/golang-lru"
|
||
|
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/web"
|
||
|
"geeks-accelerator/oss/saas-starter-kit/example-project/cmd/web-app/handlers"
|
||
|
"geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/flag"
|
||
|
itrace "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/trace"
|
||
|
"github.com/kelseyhightower/envconfig"
|
||
|
"go.opencensus.io/trace"
|
||
|
)
|
||
|
|
||
|
// build is the git version of this program. It is set using build flags in the makefile.
|
||
|
var build = "develop"
|
||
|
|
||
|
const LRU_CACHE_ITEMS = 128
|
||
|
|
||
|
var (
|
||
|
localCache *lru.Cache
|
||
|
)
|
||
|
|
||
|
func init() {
|
||
|
localCache, _ = lru.New(LRU_CACHE_ITEMS)
|
||
|
}
|
||
|
|
||
|
func main() {
|
||
|
|
||
|
// =========================================================================
|
||
|
// Logging
|
||
|
|
||
|
log := log.New(os.Stdout, "WEB_APP : ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile)
|
||
|
|
||
|
// =========================================================================
|
||
|
// Configuration
|
||
|
var cfg struct {
|
||
|
Env string `default:"dev" envconfig:"ENV"`
|
||
|
HTTP struct {
|
||
|
Host string `default:"0.0.0.0:3000" 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"`
|
||
|
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
|
||
|
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
|
||
|
StaticDir string `default:"./static" envconfig:"STATIC_DIR"`
|
||
|
}
|
||
|
App struct {
|
||
|
Name string `default:"web-app" envconfig:"APP_NAME"`
|
||
|
StaticS3 struct {
|
||
|
S3Bucket string `envconfig:"APP_STATIC_S3_BUCKET"`
|
||
|
S3KeyPrefix string `envconfig:"APP_STATIC_S3_KEY_PREFIX"`
|
||
|
EnableCloudFront bool `envconfig:"APP_STATIC_S3_ENABLE_CLOUDFRONT"`
|
||
|
}
|
||
|
}
|
||
|
BuildInfo struct {
|
||
|
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
||
|
CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"`
|
||
|
CiCommitSha string `envconfig:"CI_COMMIT_SHA"`
|
||
|
CiCommitTag string `envconfig:"CI_COMMIT_TAG"`
|
||
|
CiCommitTitle string `envconfig:"CI_COMMIT_TITLE"`
|
||
|
CiCommitDescription string `envconfig:"CI_COMMIT_DESCRIPTION"`
|
||
|
CiJobId string `envconfig:"CI_COMMIT_JOB_ID"`
|
||
|
CiJobUrl string `envconfig:"CI_COMMIT_JOB_URL"`
|
||
|
CiPipelineId string `envconfig:"CI_COMMIT_PIPELINE_ID"`
|
||
|
CiPipelineUrl string `envconfig:"CI_COMMIT_PIPELINE_URL"`
|
||
|
}
|
||
|
DB struct {
|
||
|
DialTimeout time.Duration `default:"5s" envconfig:"DIAL_TIMEOUT"`
|
||
|
Host string `default:"mongo:27017/gotraining" envconfig:"HOST"`
|
||
|
}
|
||
|
Trace struct {
|
||
|
Host string `default:"http://tracer:3002/v1/publish" envconfig:"HOST"`
|
||
|
BatchSize int `default:"1000" envconfig:"BATCH_SIZE"`
|
||
|
SendInterval time.Duration `default:"15s" envconfig:"SEND_INTERVAL"`
|
||
|
SendTimeout time.Duration `default:"500ms" envconfig:"SEND_TIMEOUT"`
|
||
|
}
|
||
|
Auth struct {
|
||
|
KeyID string `envconfig:"KEY_ID"`
|
||
|
PrivateKeyFile string `default:"/app/private.pem" envconfig:"PRIVATE_KEY_FILE"`
|
||
|
Algorithm string `default:"RS256" envconfig:"ALGORITHM"`
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if err := envconfig.Process("WEB_APP", &cfg); err != nil {
|
||
|
log.Fatalf("main : Parsing Config : %v", err)
|
||
|
}
|
||
|
|
||
|
if err := flag.Process(&cfg); err != nil {
|
||
|
if err != flag.ErrHelp {
|
||
|
log.Fatalf("main : Parsing Command Line : %v", err)
|
||
|
}
|
||
|
return // We displayed help.
|
||
|
}
|
||
|
|
||
|
// =========================================================================
|
||
|
// App Starting
|
||
|
|
||
|
// Print the build version for our logs. Also expose it under /debug/vars.
|
||
|
expvar.NewString("build").Set(build)
|
||
|
log.Printf("main : Started : Application Initializing version %q", build)
|
||
|
defer log.Println("main : Completed")
|
||
|
|
||
|
cfgJSON, err := json.MarshalIndent(cfg, "", " ")
|
||
|
if err != nil {
|
||
|
log.Fatalf("main : Marshalling Config to JSON : %v", err)
|
||
|
}
|
||
|
|
||
|
// TODO: Validate what is being written to the logs. We don't
|
||
|
// want to leak credentials or anything that can be a security risk.
|
||
|
log.Printf("main : Config : %v\n", string(cfgJSON))
|
||
|
|
||
|
// =========================================================================
|
||
|
// Template Renderer
|
||
|
// Implements interface web.Renderer to support alternative renderer
|
||
|
|
||
|
var (
|
||
|
staticS3BaseUrl string
|
||
|
staticS3CloudFrontOriginPrefix string
|
||
|
)
|
||
|
if cfg.App.StaticS3.S3Bucket != "" {
|
||
|
// TODO: lookup s3 url/cloud front distribution based on s3 bucket
|
||
|
}
|
||
|
|
||
|
// Append query string value to break browser cache used for services
|
||
|
// that render responses for a browser with the following:
|
||
|
// 1. when env=dev, the current timestamp will be used to ensure every
|
||
|
// request will skip browser cache.
|
||
|
// 2. all other envs, ie stage and prod. The commit hash will be used to
|
||
|
// ensure that all cache will be reset with each new deployment.
|
||
|
browserCacheBusterQueryString := func() string {
|
||
|
var v string
|
||
|
if cfg.Env == "dev" {
|
||
|
// On dev always break cache.
|
||
|
v = fmt.Sprintf("%d", time.Now().UTC().Unix())
|
||
|
} else {
|
||
|
// All other envs, use the current commit hash for the build
|
||
|
v = cfg.BuildInfo.CiCommitSha
|
||
|
}
|
||
|
return v
|
||
|
}
|
||
|
|
||
|
// Helper method for appending the browser cache buster as a query string to
|
||
|
// support breaking browser cache when necessary
|
||
|
browserCacheBusterFunc := browserCacheBuster(browserCacheBusterQueryString)
|
||
|
|
||
|
// Need defined functions below since they require config values, able to add additional functions
|
||
|
// here to extend functionality.
|
||
|
tmplFuncs := template.FuncMap{
|
||
|
"BuildInfo": func(k string) string {
|
||
|
r := reflect.ValueOf(cfg.BuildInfo)
|
||
|
f := reflect.Indirect(r).FieldByName(k)
|
||
|
return f.String()
|
||
|
},
|
||
|
"SiteBaseUrl": func(p string) string {
|
||
|
u, err := url.Parse(cfg.HTTP.Host)
|
||
|
if err != nil {
|
||
|
return "?"
|
||
|
}
|
||
|
u.Path = p
|
||
|
return u.String()
|
||
|
},
|
||
|
"AssetUrl": func(p string) string {
|
||
|
var u string
|
||
|
if staticS3BaseUrl != "" {
|
||
|
u = template_renderer.S3Url(staticS3BaseUrl, staticS3CloudFrontOriginPrefix, p)
|
||
|
} else {
|
||
|
if !strings.HasPrefix(p, "/") {
|
||
|
p = "/" + p
|
||
|
}
|
||
|
u = p
|
||
|
}
|
||
|
|
||
|
u = browserCacheBusterFunc( u)
|
||
|
|
||
|
return u
|
||
|
},
|
||
|
"SiteAssetUrl": func(p string) string {
|
||
|
var u string
|
||
|
if staticS3BaseUrl != "" {
|
||
|
u = template_renderer.S3Url(staticS3BaseUrl, staticS3CloudFrontOriginPrefix, filepath.Join(cfg.App.Name, p))
|
||
|
} else {
|
||
|
if !strings.HasPrefix(p, "/") {
|
||
|
p = "/" + p
|
||
|
}
|
||
|
u = p
|
||
|
}
|
||
|
|
||
|
u = browserCacheBusterFunc( u)
|
||
|
|
||
|
return u
|
||
|
},
|
||
|
"SiteS3Url": func(p string) string {
|
||
|
var u string
|
||
|
if staticS3BaseUrl != "" {
|
||
|
u = template_renderer.S3Url(staticS3BaseUrl, staticS3CloudFrontOriginPrefix, filepath.Join(cfg.App.Name, p))
|
||
|
} else {
|
||
|
u = p
|
||
|
}
|
||
|
return u
|
||
|
},
|
||
|
"S3Url": func(p string) string {
|
||
|
var u string
|
||
|
if staticS3BaseUrl != "" {
|
||
|
u = template_renderer.S3Url(staticS3BaseUrl, staticS3CloudFrontOriginPrefix, p)
|
||
|
} else {
|
||
|
u = p
|
||
|
}
|
||
|
return u
|
||
|
},
|
||
|
}
|
||
|
|
||
|
//
|
||
|
t := template_renderer.NewTemplate(tmplFuncs)
|
||
|
|
||
|
// global variables exposed for rendering of responses with templates
|
||
|
gvd := map[string]interface{}{
|
||
|
"_App": map[string]interface{}{
|
||
|
"ENV": cfg.Env,
|
||
|
"BuildInfo": cfg.BuildInfo,
|
||
|
"BuildVersion": build,
|
||
|
},
|
||
|
}
|
||
|
|
||
|
// Custom error handler to support rendering user friendly error page for improved web experience.
|
||
|
eh := func(ctx context.Context, w http.ResponseWriter, r *http.Request, renderer web.Renderer, statusCode int, er error) error {
|
||
|
data := map[string]interface{}{}
|
||
|
|
||
|
return renderer.Render(ctx, w, r,
|
||
|
"base.tmpl", // base layout file to be used for rendering of errors
|
||
|
"error.tmpl", // generic format for errors, could select based on status code
|
||
|
web.MIMETextHTMLCharsetUTF8,
|
||
|
http.StatusOK,
|
||
|
data,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
// Enable template renderer to reload and parse template files when generating a response of dev
|
||
|
// for a more developer friendly process. Any changes to the template files will be included
|
||
|
// without requiring re-build/re-start of service.
|
||
|
// This only supports files that already exist, if a new template file is added, then the
|
||
|
// serivce needs to be restarted, but not rebuilt.
|
||
|
enableHotReload := cfg.Env == "dev"
|
||
|
|
||
|
// Template Renderer used to generate HTML response for web experience.
|
||
|
renderer, err := template_renderer.NewTemplateRenderer(cfg.HTTP.TemplateDir, enableHotReload, gvd, t, eh)
|
||
|
if err != nil {
|
||
|
log.Fatalf("main : Marshalling Config to JSON : %v", err)
|
||
|
}
|
||
|
|
||
|
// =========================================================================
|
||
|
// Start Tracing Support
|
||
|
|
||
|
logger := func(format string, v ...interface{}) {
|
||
|
log.Printf(format, v...)
|
||
|
}
|
||
|
|
||
|
log.Printf("main : Tracing Started : %s", cfg.Trace.Host)
|
||
|
exporter, err := itrace.NewExporter(logger, cfg.Trace.Host, cfg.Trace.BatchSize, cfg.Trace.SendInterval, cfg.Trace.SendTimeout)
|
||
|
if err != nil {
|
||
|
log.Fatalf("main : RegiTracingster : ERROR : %v", err)
|
||
|
}
|
||
|
defer func() {
|
||
|
log.Printf("main : Tracing Stopping : %s", cfg.Trace.Host)
|
||
|
batch, err := exporter.Close()
|
||
|
if err != nil {
|
||
|
log.Printf("main : Tracing Stopped : ERROR : Batch[%d] : %v", batch, err)
|
||
|
} else {
|
||
|
log.Printf("main : Tracing Stopped : Flushed Batch[%d]", batch)
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
trace.RegisterExporter(exporter)
|
||
|
trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()})
|
||
|
|
||
|
// =========================================================================
|
||
|
// Start Debug Service. Not concerned with shutting this down when the
|
||
|
// application is being shutdown.
|
||
|
//
|
||
|
// /debug/vars - Added to the default mux by the expvars package.
|
||
|
// /debug/pprof - Added to the default mux by the net/http/pprof package.
|
||
|
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 APP Service
|
||
|
|
||
|
// Make a channel to listen for an interrupt or terminate signal from the OS.
|
||
|
// Use a buffered channel because the signal package requires it.
|
||
|
shutdown := make(chan os.Signal, 1)
|
||
|
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
|
||
|
|
||
|
api := http.Server{
|
||
|
Addr: cfg.HTTP.Host,
|
||
|
Handler: handlers.APP(shutdown, log, cfg.HTTP.StaticDir, cfg.HTTP.TemplateDir, nil, nil, renderer),
|
||
|
ReadTimeout: cfg.HTTP.ReadTimeout,
|
||
|
WriteTimeout: cfg.HTTP.WriteTimeout,
|
||
|
MaxHeaderBytes: 1 << 20,
|
||
|
}
|
||
|
|
||
|
// Make a channel to listen for errors coming from the listener. Use a
|
||
|
// buffered channel so the goroutine can exit if we don't collect this error.
|
||
|
serverErrors := make(chan error, 1)
|
||
|
|
||
|
// Start the service listening for requests.
|
||
|
go func() {
|
||
|
log.Printf("main : APP Listening %s", cfg.HTTP.Host)
|
||
|
serverErrors <- api.ListenAndServe()
|
||
|
}()
|
||
|
|
||
|
// =========================================================================
|
||
|
// Shutdown
|
||
|
|
||
|
// Blocking main and waiting for shutdown.
|
||
|
select {
|
||
|
case err := <-serverErrors:
|
||
|
log.Fatalf("main : Error starting server: %v", err)
|
||
|
|
||
|
case sig := <-shutdown:
|
||
|
log.Printf("main : %v : Start shutdown..", sig)
|
||
|
|
||
|
// Create context for Shutdown call.
|
||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.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.HTTP.ShutdownTimeout, err)
|
||
|
err = api.Close()
|
||
|
}
|
||
|
|
||
|
// Log the status of this shutdown.
|
||
|
switch {
|
||
|
case sig == syscall.SIGSTOP:
|
||
|
log.Fatal("main : Integrity issue caused shutdown")
|
||
|
case err != nil:
|
||
|
log.Fatalf("main : Could not stop server gracefully : %v", err)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// browserCacheBuster appends a the query string param v to a given url with
|
||
|
// a value based on the value returned from cacheBusterValueFunc
|
||
|
func browserCacheBuster(cacheBusterValueFunc func() string) func(uri string) string {
|
||
|
f := func(uri string) string {
|
||
|
v := cacheBusterValueFunc()
|
||
|
if v == "" {
|
||
|
return uri
|
||
|
}
|
||
|
|
||
|
u, err := url.Parse(uri)
|
||
|
if err != nil {
|
||
|
return ""
|
||
|
}
|
||
|
q := u.Query()
|
||
|
q.Set("v", v)
|
||
|
u.RawQuery = q.Encode()
|
||
|
|
||
|
return u.String()
|
||
|
}
|
||
|
|
||
|
return f
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
"S3ImgSrcLarge": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320, 480, 800}, true)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgThumbSrcLarge": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320, 480, 800}, false)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgSrcMedium": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320, 640}, true)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgThumbSrcMedium": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320, 640}, false)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgSrcSmall": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320}, true)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgThumbSrcSmall": func(p string) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, []int{320}, false)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgSrc": func(p string, sizes []int) template.HTMLAttr {
|
||
|
res, _ := blower_display.S3ImgSrc(cfg, site, p, sizes, true)
|
||
|
return template.HTMLAttr(res)
|
||
|
},
|
||
|
"S3ImgUrl": func(p string, size int) string {
|
||
|
res, _ := blower_display.S3ImgUrl(cfg, site, p, size)
|
||
|
return res
|
||
|
},
|
||
|
*/
|