From df6a802cd9741e91e27d12b74d0ff0e741d39a4a Mon Sep 17 00:00:00 2001 From: Jeevanandam M Date: Thu, 12 Apr 2018 00:33:41 -0700 Subject: [PATCH] #157 Code, request and response optimization (#166) This PR creates many opportunities for framework future. --- aah.go | 580 ++++++++++----- aah_test.go | 665 +++++++++++++----- access_log.go | 208 +++--- access_log_test.go | 133 +--- bind.go | 216 +++--- bind_test.go | 205 ++---- config.go | 143 ++-- config_test.go | 59 -- context.go | 296 +++++--- context_test.go | 224 ++---- controller.go | 139 ++-- controller_test.go | 27 - default.go | 400 +++++++++++ default_test.go | 171 +++++ dump.go | 112 ++- engine.go | 419 ++++++----- engine_test.go | 592 ++++++---------- error.go | 153 ++-- error_test.go | 126 +--- event.go | 282 +++----- event_test.go | 277 ++++---- i18n.go | 73 +- i18n_test.go | 41 -- log.go | 53 +- log_test.go | 78 +- middleware.go | 189 +++-- middleware_test.go | 26 +- render.go | 263 +++---- render_test.go | 262 +++---- reply.go | 172 ++--- reply_test.go | 276 +------- router.go | 132 ++-- router_test.go | 113 +-- security.go | 179 ++--- security_test.go | 206 +++--- server.go | 301 ++++---- server_test.go | 123 ++-- static.go | 227 +++--- static_test.go | 133 ++-- testdata/config/aah.conf | 209 ------ testdata/config/env/dev.conf | 3 - testdata/config/routes.conf | 135 ---- testdata/config/security.conf | 8 - testdata/i18n/messages.en | 15 - testdata/static/test.txt | 1 - testdata/views/common/footer_scripts.html | 1 - testdata/views/common/head_tags.html | 2 - testdata/views/pages/user/index.html | 5 - testdata/webapp1/.gitignore | 31 + testdata/webapp1/aah.project | 68 ++ testdata/webapp1/config/aah.conf | 453 ++++++++++++ testdata/webapp1/config/env/dev.conf | 45 ++ testdata/webapp1/config/env/prod.conf | 54 ++ testdata/webapp1/config/routes.conf | 243 +++++++ testdata/webapp1/config/security.conf | 244 +++++++ testdata/webapp1/i18n/messages.en | 27 + testdata/{ => webapp1}/i18n/messages.en-US | 0 testdata/webapp1/static/css/aah.css | 47 ++ .../webapp1/static/img/aah-framework-logo.png | Bin 0 -> 6990 bytes testdata/webapp1/static/img/favicon.ico | Bin 0 -> 15086 bytes testdata/webapp1/static/js/aah.js | 0 testdata/webapp1/static/robots.txt | 3 + .../webapp1/views/common/error_footer.html | 1 + .../webapp1/views/common/error_header.html | 34 + .../webapp1/views/common/footer_scripts.html | 1 + testdata/webapp1/views/common/head_tags.html | 2 + testdata/webapp1/views/errors/404.html | 14 + testdata/webapp1/views/errors/500.html | 14 + .../{ => webapp1}/views/layouts/master.html | 11 +- .../{ => webapp1}/views/pages/app/index.html | 0 .../webapp1/views/pages/testsite/index.html | 17 + util.go | 282 +++++--- view.go | 256 +++---- view_test.go | 202 ++---- 74 files changed, 5681 insertions(+), 5021 deletions(-) delete mode 100644 config_test.go create mode 100644 default.go create mode 100644 default_test.go delete mode 100644 i18n_test.go delete mode 100644 testdata/config/aah.conf delete mode 100644 testdata/config/env/dev.conf delete mode 100644 testdata/config/routes.conf delete mode 100644 testdata/config/security.conf delete mode 100644 testdata/i18n/messages.en delete mode 100644 testdata/static/test.txt delete mode 100644 testdata/views/common/footer_scripts.html delete mode 100644 testdata/views/common/head_tags.html delete mode 100644 testdata/views/pages/user/index.html create mode 100644 testdata/webapp1/.gitignore create mode 100644 testdata/webapp1/aah.project create mode 100644 testdata/webapp1/config/aah.conf create mode 100644 testdata/webapp1/config/env/dev.conf create mode 100644 testdata/webapp1/config/env/prod.conf create mode 100644 testdata/webapp1/config/routes.conf create mode 100644 testdata/webapp1/config/security.conf create mode 100644 testdata/webapp1/i18n/messages.en rename testdata/{ => webapp1}/i18n/messages.en-US (100%) create mode 100644 testdata/webapp1/static/css/aah.css create mode 100644 testdata/webapp1/static/img/aah-framework-logo.png create mode 100644 testdata/webapp1/static/img/favicon.ico create mode 100644 testdata/webapp1/static/js/aah.js create mode 100644 testdata/webapp1/static/robots.txt create mode 100644 testdata/webapp1/views/common/error_footer.html create mode 100644 testdata/webapp1/views/common/error_header.html create mode 100644 testdata/webapp1/views/common/footer_scripts.html create mode 100644 testdata/webapp1/views/common/head_tags.html create mode 100644 testdata/webapp1/views/errors/404.html create mode 100644 testdata/webapp1/views/errors/500.html rename testdata/{ => webapp1}/views/layouts/master.html (52%) rename testdata/{ => webapp1}/views/pages/app/index.html (100%) create mode 100644 testdata/webapp1/views/pages/testsite/index.html diff --git a/aah.go b/aah.go index 7f0c4be4..1e45f9f1 100644 --- a/aah.go +++ b/aah.go @@ -2,51 +2,41 @@ // go-aah/aah source code and usage is governed by a MIT style // license that can be found in the LICENSE file. -// Package aah is A scalable, performant, rapid development Web framework for Go -// https://aahframework.org +// Package aah is A secure, flexible, rapid Go web framework. +// +// Visit: https://aahframework.org to know more. package aah import ( - "bytes" + "crypto/tls" "errors" "fmt" + "net/http" + "os" "path" "path/filepath" + "strings" + "sync" "time" + "aahframework.org/ahttp.v0" "aahframework.org/aruntime.v0" + "aahframework.org/config.v0" "aahframework.org/essentials.v0" + "aahframework.org/i18n.v0" "aahframework.org/log.v0" + "aahframework.org/router.v0" + "aahframework.org/security.v0" + "golang.org/x/crypto/acme/autocert" ) -// aah application variables -var ( - appName string - appInstanceName string - appDesc string - appImportPath string - appProfile string - appBaseDir string - appIsPackaged bool - appHTTPReadTimeout time.Duration - appHTTPWriteTimeout time.Duration - appHTTPMaxHdrBytes int - appSSLCert string - appSSLKey string - appIsSSLEnabled bool - appIsLetsEncrypt bool - appIsProfileProd bool - appMultipartMaxMemory int64 - appMaxBodyBytesSize int64 - appPID int - appInitialized bool - appBuildInfo *BuildInfo - - appDefaultProfile = "dev" - appProfilePrefix = "env." - appDefaultHTTPPort = "8080" - appLogFatal = log.Fatal +const ( + defaultEnvProfile = "dev" + profilePrefix = "env." + defaultHTTPPort = "8080" +) +var ( goPath string goSrcDir string ) @@ -59,205 +49,332 @@ type BuildInfo struct { Date string } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// aah application instance +//______________________________________________________________________________ + +func newApp() *app { + aahApp := &app{ + mu: new(sync.Mutex), + } + + aahApp.engine = &engine{ + a: aahApp, + ctxPool: new(sync.Pool), + cregistry: make(controllerRegistry), + } + aahApp.engine.ctxPool.New = func() interface{} { return aahApp.engine.newContext() } + + aahApp.eventStore = &EventStore{ + a: aahApp, + e: aahApp.engine, + subscribers: make(map[string]EventCallbacks), + mu: new(sync.Mutex), + } -// AppName method returns aah application name from app config `name` otherwise app name -// of the base directory. -func AppName() string { - return appName + return aahApp } -// AppInstanceName method returns aah application instane name from app config `instance_name` -// otherwise empty string. -func AppInstanceName() string { - return appInstanceName +// app struct represents aah application. +type app struct { + name string + importPath string + baseDir string + webApp bool + physicalPathMode bool + isPackaged bool + envProfile string + sslCert string + sslKey string + httpReadTimeout time.Duration + httpWriteTimeout time.Duration + httpMaxHdrBytes int + multipartMaxMemory int64 + maxBodyBytes int64 + pid int + buildInfo *BuildInfo + serverHeaderEnabled bool + serverHeader string + requestIDEnabled bool + requestIDHeaderKey string + gzipEnabled bool + secureHeadersEnabled bool + accessLogEnabled bool + staticAccessLogEnabled bool + dumpLogEnabled bool + defaultContentType *ahttp.ContentType + renderPretty bool + shutdownGraceTimeStr string + shutdownGraceTimeout time.Duration + initialized bool + hotReload bool + + cfg *config.Config + tlsCfg *tls.Config + engine *engine + server *http.Server + redirectServer *http.Server + autocertMgr *autocert.Manager + router *router.Router + eventStore *EventStore + bindMgr *bindManager + i18n *i18n.I18n + securityMgr *security.Manager + viewMgr *viewManager + staticMgr *staticManager + errorMgr *errorManager + sc chan os.Signal + + logger log.Loggerer + accessLog *accessLogger + dumpLog *dumpLogger + + mu *sync.Mutex } -// AppDesc method returns aah application friendly description from app config -// otherwise empty string. -func AppDesc() string { - return appDesc +func (a *app) Init(importPath string) error { + a.importPath = path.Clean(importPath) + var err error + + if a.buildInfo == nil { + // aah CLI is accessing application for build purpose + _ = log.SetLevel("warn") + if err = a.initPath(); err != nil { + return err + } + if err = a.initConfig(); err != nil { + return err + } + if err = a.initConfigValues(); err != nil { + return err + } + if err = a.initRouter(); err != nil { + return err + } + _ = log.SetLevel("debug") + } else { + if err = a.initPath(); err != nil { + return err + } + if err = a.initConfig(); err != nil { + return err + } + + // publish `OnInit` server event + a.EventStore().sortAndPublishSync(&Event{Name: EventOnInit}) + + if err = a.initConfigValues(); err != nil { + return err + } + if err = a.initLog(); err != nil { + return err + } + if err = a.initI18n(); err != nil { + return err + } + if err = a.initRouter(); err != nil { + return err + } + if err = a.initBind(); err != nil { + return err + } + if err = a.initView(); err != nil { + return err + } + if err = a.initSecurity(); err != nil { + return err + } + if err = a.initStatic(); err != nil { + return err + } + if err = a.initError(); err != nil { + return err + } + if a.accessLogEnabled { + if err = a.initAccessLog(); err != nil { + return err + } + } + if a.dumpLogEnabled { + if err = a.initDumpLog(); err != nil { + return err + } + } + } + + a.initialized = true + return nil } -// AppProfile returns aah application configuration profile name -// For e.g.: dev, prod, etc. Default is `dev` -func AppProfile() string { - return appProfile +func (a *app) Name() string { + return a.name +} +func (a *app) InstanceName() string { + return a.Config().StringDefault("instance_name", "") } -// AppBaseDir method returns the application base or binary current directory -// For e.g.: -// $GOPATH/src/github.com/user/myproject -// -func AppBaseDir() string { - return appBaseDir +func (a *app) Type() string { + return a.Config().StringDefault("type", "") } -// AppImportPath method returns the application Go import path. -func AppImportPath() string { - return appImportPath +func (a *app) Desc() string { + return a.Config().StringDefault("desc", "") } -// AppHTTPAddress method returns aah application HTTP address otherwise empty string -func AppHTTPAddress() string { - return AppConfig().StringDefault("server.address", "") +func (a *app) BaseDir() string { + return a.baseDir } -// AppHTTPPort method returns aah application HTTP port number based on `server.port` -// value. Possible outcomes are user-defined port, `80`, `443` and `8080`. -func AppHTTPPort() string { - port := firstNonZeroString(AppConfig().StringDefault("server.proxyport", ""), - AppConfig().StringDefault("server.port", appDefaultHTTPPort)) - return parsePort(port) +func (a *app) ImportPath() string { + return a.importPath } -// AppBuildInfo method return user application version no. -func AppBuildInfo() *BuildInfo { - return appBuildInfo +func (a *app) HTTPAddress() string { + return a.Config().StringDefault("server.address", "") } -// AllAppProfiles method returns all the aah application environment profile names. -func AllAppProfiles() []string { - var profiles []string +func (a *app) HTTPPort() string { + port := firstNonZeroString( + a.Config().StringDefault("server.proxyport", ""), + a.Config().StringDefault("server.port", defaultHTTPPort), + ) + return a.parsePort(port) +} - for _, v := range AppConfig().KeysByPath("env") { - if v == "default" { - continue - } - profiles = append(profiles, v) - } +func (a *app) BuildInfo() *BuildInfo { + return a.buildInfo +} - return profiles +func (a *app) SetBuildInfo(bi *BuildInfo) { + a.buildInfo = bi } -// AppIsSSLEnabled method returns true if aah application is enabled with SSL -// otherwise false. -func AppIsSSLEnabled() bool { - return appIsSSLEnabled +func (a *app) IsPackaged() bool { + return a.isPackaged } -// SetAppProfile method sets given profile as current aah application profile. -// For Example: -// -// aah.SetAppProfile("prod") -func SetAppProfile(profile string) error { - if err := AppConfig().SetProfile(appProfilePrefix + profile); err != nil { +func (a *app) SetPackaged(pack bool) { + a.isPackaged = pack +} + +func (a *app) Profile() string { + return a.envProfile +} + +func (a *app) SetProfile(profile string) error { + a.mu.Lock() + defer a.mu.Unlock() + if err := a.Config().SetProfile(profilePrefix + profile); err != nil { return err } - appProfile = profile - appIsProfileProd = appProfile == "prod" + a.envProfile = profile return nil } -// SetAppBuildInfo method sets the user application build info into aah instance. -func SetAppBuildInfo(bi *BuildInfo) { - appBuildInfo = bi +func (a *app) IsProfile(profile string) bool { + return a.Profile() == profile } -// SetAppPackaged method sets the info of binary is packaged or not. -func SetAppPackaged(pack bool) { - appIsPackaged = pack +func (a *app) IsProfileDev() bool { + return a.IsProfile("dev") } -// NewChildLogger method create a child logger from aah application default logger. -func NewChildLogger(ctx log.Fields) *log.Logger { - return appLogger.New(ctx) +func (a *app) IsProfileProd() bool { + return a.IsProfile("prod") } -// Init method initializes `aah` application, if anything goes wrong during -// initialize process, it will log it as fatal msg and exit. -func Init(importPath string) { - defer aahRecover() - - if appBuildInfo == nil { - // aah CLI is accessing application for build purpose - _ = log.SetLevel("warn") - logAsFatal(initPath(importPath)) - logAsFatal(initConfig(appConfigDir())) - logAsFatal(initAppVariables()) - logAsFatal(initRoutes(appConfigDir(), AppConfig())) - _ = log.SetLevel("debug") - } else { - logAsFatal(initPath(importPath)) - logAsFatal(initConfig(appConfigDir())) +func (a *app) AllProfiles() []string { + var profiles []string - // publish `OnInit` server event - AppEventStore().sortAndPublishSync(&Event{Name: EventOnInit}) - - logAsFatal(initAppVariables()) - logAsFatal(initLogs(appLogsDir(), AppConfig())) - logAsFatal(initI18n(appI18nDir())) - logAsFatal(initRoutes(appConfigDir(), AppConfig())) - logAsFatal(initViewEngine(appViewsDir(), AppConfig())) - logAsFatal(initSecurity(AppConfig())) - if AppConfig().BoolDefault("server.access_log.enable", false) { - logAsFatal(initAccessLog(appLogsDir(), AppConfig())) - } - if AppConfig().BoolDefault("server.dump_log.enable", false) { - logAsFatal(initDumpLog(appLogsDir(), AppConfig())) + for _, v := range a.Config().KeysByPath("env") { + if v == "default" { + continue } + profiles = append(profiles, v) } - appInitialized = true + return profiles } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +func (a *app) IsSSLEnabled() bool { + return a.cfg.BoolDefault("server.ssl.enable", false) +} -func aahRecover() { - if r := recover(); r != nil { - strace := aruntime.NewStacktrace(r, AppConfig()) - buf := &bytes.Buffer{} - strace.Print(buf) +func (a *app) IsLetsEncrypt() bool { + return a.cfg.BoolDefault("server.ssl.lets_encrypt.enable", false) +} - log.Error("Recovered from panic:") - log.Error(buf.String()) - } +func (a *app) NewChildLogger(fields log.Fields) log.Loggerer { + return a.Log().WithFields(fields) } -func appLogsDir() string { - return filepath.Join(AppBaseDir(), "logs") +func (a *app) SetTLSConfig(tlsCfg *tls.Config) { + a.mu.Lock() + defer a.mu.Unlock() + a.tlsCfg = tlsCfg } -func logAsFatal(err error) { - if err != nil { - appLogFatal(err) - } +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) configDir() string { + return filepath.Join(a.BaseDir(), "config") } -func initPath(importPath string) (err error) { - appImportPath = path.Clean(importPath) - if goPath, err = ess.GoPath(); err != nil && !appIsPackaged { - return err +func (a *app) logsDir() string { + return filepath.Join(a.BaseDir(), "logs") +} + +func (a *app) showDeprecatedMsg(msg string, v ...interface{}) { + a.Log().Warnf("DEPRECATED: "+msg, v...) + a.Log().Warn("Deprecated elements are planned to be remove in major release v1.0.0") +} + +func (a *app) initPath() (err error) { + if goPath, err = ess.GoPath(); err != nil && !a.IsPackaged() { + return + } + + // If its a physical location, we got the app base directory + if filepath.IsAbs(a.ImportPath()) { + if !ess.IsFileExists(a.ImportPath()) { + err = fmt.Errorf("path does not exists: %s", a.ImportPath()) + return + } + + a.baseDir = a.ImportPath() + a.physicalPathMode = true + return } + // import path mode goSrcDir = filepath.Join(goPath, "src") - appBaseDir = filepath.Join(goSrcDir, filepath.FromSlash(appImportPath)) - if appIsPackaged { - appBaseDir = getWorkingDir() + a.baseDir = filepath.Join(goSrcDir, filepath.FromSlash(a.ImportPath())) + if a.isPackaged { + wd, er := os.Getwd() + if err != nil { + err = er + return + } + a.baseDir = wd } - if !ess.IsFileExists(appBaseDir) { - return fmt.Errorf("aah application does not exists: %s", appImportPath) + if !ess.IsFileExists(a.BaseDir()) { + err = fmt.Errorf("import path does not exists: %s", a.ImportPath()) } - return nil + return } -func initAppVariables() error { - var err error - cfg := AppConfig() - - appName = cfg.StringDefault("name", filepath.Base(AppBaseDir())) - appInstanceName = cfg.StringDefault("instance_name", "") - appDesc = cfg.StringDefault("desc", "") +func (a *app) initConfigValues() (err error) { + cfg := a.Config() + a.name = cfg.StringDefault("name", filepath.Base(a.BaseDir())) + a.webApp = strings.ToLower(cfg.StringDefault("type", "")) == "web" - appProfile = cfg.StringDefault("env.active", appDefaultProfile) - if err = SetAppProfile(AppProfile()); err != nil { + a.envProfile = cfg.StringDefault("env.active", defaultEnvProfile) + if err = a.SetProfile(a.Profile()); err != nil { return err } @@ -267,42 +384,147 @@ func initAppVariables() error { return errors.New("'server.timeout.{read|write}' value is not a valid time unit") } - if appHTTPReadTimeout, err = time.ParseDuration(readTimeout); err != nil { + if a.httpReadTimeout, err = time.ParseDuration(readTimeout); err != nil { return fmt.Errorf("'server.timeout.read': %s", err) } - if appHTTPWriteTimeout, err = time.ParseDuration(writeTimeout); err != nil { + if a.httpWriteTimeout, err = time.ParseDuration(writeTimeout); err != nil { return fmt.Errorf("'server.timeout.write': %s", err) } maxHdrBytesStr := cfg.StringDefault("server.max_header_bytes", "1mb") if maxHdrBytes, er := ess.StrToBytes(maxHdrBytesStr); er == nil { - appHTTPMaxHdrBytes = int(maxHdrBytes) + a.httpMaxHdrBytes = int(maxHdrBytes) } else { return errors.New("'server.max_header_bytes' value is not a valid size unit") } - appIsSSLEnabled = cfg.BoolDefault("server.ssl.enable", false) - appIsLetsEncrypt = cfg.BoolDefault("server.ssl.lets_encrypt.enable", false) - appSSLCert = cfg.StringDefault("server.ssl.cert", "") - appSSLKey = cfg.StringDefault("server.ssl.key", "") - if err = checkSSLConfigValues(AppIsSSLEnabled(), appIsLetsEncrypt, appSSLCert, appSSLKey); err != nil { + a.sslCert = cfg.StringDefault("server.ssl.cert", "") + a.sslKey = cfg.StringDefault("server.ssl.key", "") + if err = a.checkSSLConfigValues(); err != nil { return err } - if err = initAutoCertManager(cfg); err != nil { + if err = a.initAutoCertManager(); err != nil { return err } maxBodySizeStr := cfg.StringDefault("request.max_body_size", "5mb") - if appMaxBodyBytesSize, err = ess.StrToBytes(maxBodySizeStr); err != nil { + if a.maxBodyBytes, err = ess.StrToBytes(maxBodySizeStr); err != nil { return errors.New("'request.max_body_size' value is not a valid size unit") } multipartMemoryStr := cfg.StringDefault("request.multipart_size", "32mb") - if appMultipartMaxMemory, err = ess.StrToBytes(multipartMemoryStr); err != nil { + if a.multipartMaxMemory, err = ess.StrToBytes(multipartMemoryStr); err != nil { return errors.New("'request.multipart_size' value is not a valid size unit") } + a.serverHeader = cfg.StringDefault("server.header", "") + a.serverHeaderEnabled = !ess.IsStrEmpty(a.serverHeader) + a.requestIDEnabled = cfg.BoolDefault("request.id.enable", true) + a.requestIDHeaderKey = cfg.StringDefault("request.id.header", ahttp.HeaderXRequestID) + a.secureHeadersEnabled = cfg.BoolDefault("security.http_header.enable", true) + a.gzipEnabled = cfg.BoolDefault("render.gzip.enable", true) + a.accessLogEnabled = cfg.BoolDefault("server.access_log.enable", false) + a.staticAccessLogEnabled = cfg.BoolDefault("server.access_log.static_file", true) + a.dumpLogEnabled = cfg.BoolDefault("server.dump_log.enable", false) + a.renderPretty = cfg.BoolDefault("render.pretty", false) + a.defaultContentType = resolveDefaultContentType(a.Config().StringDefault("render.default", "")) + if a.defaultContentType == nil { + return errors.New("'render.default' config value is not defined") + } + + ahttp.GzipLevel = cfg.IntDefault("render.gzip.level", 5) + if !(ahttp.GzipLevel >= 1 && ahttp.GzipLevel <= 9) { + return fmt.Errorf("'render.gzip.level' is not a valid level value: %v", ahttp.GzipLevel) + } + + a.shutdownGraceTimeStr = cfg.StringDefault("server.timeout.grace_shutdown", "60s") + if !(strings.HasSuffix(a.shutdownGraceTimeStr, "s") || strings.HasSuffix(a.shutdownGraceTimeStr, "m")) { + a.Log().Warn("'server.timeout.grace_shutdown' value is not a valid time unit, assigning default value 60s") + a.shutdownGraceTimeStr = "60s" + } + a.shutdownGraceTimeout, _ = time.ParseDuration(a.shutdownGraceTimeStr) + return nil } + +func (a *app) checkSSLConfigValues() error { + if a.IsSSLEnabled() { + if !a.IsLetsEncrypt() && (ess.IsStrEmpty(a.sslCert) || ess.IsStrEmpty(a.sslKey)) { + return errors.New("SSL config is incomplete; either enable 'server.ssl.lets_encrypt.enable' or provide 'server.ssl.cert' & 'server.ssl.key' value") + } else if !a.IsLetsEncrypt() { + if !ess.IsFileExists(a.sslCert) { + return fmt.Errorf("SSL cert file not found: %s", a.sslCert) + } + + if !ess.IsFileExists(a.sslKey) { + return fmt.Errorf("SSL key file not found: %s", a.sslKey) + } + } + } + + if a.IsLetsEncrypt() && !a.IsSSLEnabled() { + return errors.New("let's encrypt enabled, however SSL 'server.ssl.enable' is not enabled for application") + } + return nil +} + +func (a *app) initAutoCertManager() error { + if !a.IsSSLEnabled() || !a.IsLetsEncrypt() { + return nil + } + + cfgKeyPrefix := "server.ssl.lets_encrypt" + hostPolicy, found := a.cfg.StringList(cfgKeyPrefix + ".host_policy") + if !found || len(hostPolicy) == 0 { + return errors.New("'server.ssl.lets_encrypt.host_policy' is empty, provide at least one hostname") + } + + renewBefore := time.Duration(a.cfg.IntDefault(cfgKeyPrefix+".renew_before", 10)) + + a.autocertMgr = &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(hostPolicy...), + RenewBefore: 24 * renewBefore * time.Hour, + ForceRSA: a.cfg.BoolDefault(cfgKeyPrefix+".force_rsa", false), + Email: a.cfg.StringDefault(cfgKeyPrefix+".email", ""), + } + + if cacheDir := a.cfg.StringDefault(cfgKeyPrefix+".cache_dir", ""); !ess.IsStrEmpty(cacheDir) { + a.autocertMgr.Cache = autocert.DirCache(cacheDir) + } + + return nil +} + +func (a *app) binaryFilename() string { + if a.buildInfo == nil { + return "" + } + return ess.StripExt(a.BuildInfo().BinaryName) +} + +func (a *app) parsePort(port string) string { + if !ess.IsStrEmpty(port) { + return port + } + + if a.IsSSLEnabled() { + return "443" + } + + return "80" +} + +func (a *app) aahRecover() { + if r := recover(); r != nil { + strace := aruntime.NewStacktrace(r, a.Config()) + buf := acquireBuffer() + defer releaseBuffer(buf) + strace.Print(buf) + + a.Log().Error("Recovered from panic:") + a.Log().Error(buf.String()) + } +} diff --git a/aah_test.go b/aah_test.go index fc205667..0e2f6e44 100644 --- a/aah_test.go +++ b/aah_test.go @@ -5,211 +5,546 @@ package aah import ( + "bytes" + "compress/gzip" + "io" "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" "path/filepath" "reflect" "strings" "testing" "time" + "aahframework.org/ahttp.v0" "aahframework.org/essentials.v0" + "aahframework.org/log.v0" "aahframework.org/test.v0/assert" ) -func TestAahInitAppVariables(t *testing.T) { - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) +func TestAahApp(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) + defer ts.Close() - err = initAppVariables() - assert.Nil(t, err) + t.Logf("Test Server URL: %s", ts.URL) - AppConfig().SetString("env.dev.test_value", "dev test value") - err = initAppVariables() - assert.Nil(t, err) + // Do not follow redirect + if http.DefaultClient.CheckRedirect == nil { + http.DefaultClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } - assert.Equal(t, "aahframework", AppName()) - assert.Equal(t, "aah framework test config", AppDesc()) - assert.Equal(t, "127.0.0.1", AppHTTPAddress()) - assert.Equal(t, "80", AppHTTPPort()) - assert.Equal(t, "en", AppDefaultI18nLang()) - assert.True(t, ess.IsStrEmpty(AppImportPath())) - assert.False(t, AppIsSSLEnabled()) - assert.Equal(t, "dev", AppProfile()) - assert.Equal(t, "1m30s", appHTTPReadTimeout.String()) - assert.Equal(t, "1m30s", appHTTPWriteTimeout.String()) - assert.Equal(t, 1048576, appHTTPMaxHdrBytes) - assert.False(t, appInitialized) - assert.Equal(t, int64(33554432), appMultipartMaxMemory) - assert.True(t, ess.IsStrEmpty(appSSLCert)) - assert.True(t, ess.IsStrEmpty(appSSLKey)) - - AppConfig().SetString("env.default", "dev") - profiles := AllAppProfiles() - assert.NotNil(t, profiles) - assert.True(t, len(profiles) == 1) - assert.Equal(t, "dev", profiles[0]) - - // App port no - AppConfig().SetString("server.port", "") - assert.Equal(t, "80", AppHTTPPort()) - appIsSSLEnabled = true - assert.Equal(t, "443", AppHTTPPort()) - - // app packaged - assert.False(t, appIsPackaged) - - // init auto cert - AppConfig().SetBool("server.ssl.enable", true) - AppConfig().SetBool("server.ssl.lets_encrypt.enable", true) - defer ess.DeleteFiles(filepath.Join(getTestdataPath(), "autocert")) - AppConfig().SetString("server.ssl.lets_encrypt.cache_dir", filepath.Join(getTestdataPath(), "autocert")) - err = initAppVariables() + // GET - /index.html or / + t.Log("GET - /index.html or /") + req, err := http.NewRequest(ahttp.MethodGet, ts.URL+"?lang=en", nil) + assert.Nil(t, err) + req.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") + result := fireRequest(t, req) + assert.Equal(t, 200, result.StatusCode) + assert.NotNil(t, result.Header) + assert.Equal(t, "text/html; charset=utf-8", result.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "SAMEORIGIN", result.Header.Get(ahttp.HeaderXFrameOptions)) + assert.Equal(t, "nosniff", result.Header.Get(ahttp.HeaderXContentTypeOptions)) + assert.Equal(t, "Before Called successfully", result.Header.Get("X-Before-Interceptor")) + assert.Equal(t, "After Called successfully", result.Header.Get("X-After-Interceptor")) + assert.Equal(t, "Finally Called successfully", result.Header.Get("X-Finally-Interceptor")) + assert.True(t, strings.Contains(result.Body, "Test Application webapp1 Yes it works!!!")) + assert.True(t, strings.Contains(result.Body, "aah framework web application")) + + // GET - /get-text.html + t.Log("GET - /get-text.html") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/get-text.html", nil) + assert.Nil(t, err) + req.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") + result = fireRequest(t, req) + assert.Equal(t, 200, result.StatusCode) + assert.NotNil(t, result.Header) + assert.Equal(t, "SAMEORIGIN", result.Header.Get(ahttp.HeaderXFrameOptions)) + assert.Equal(t, "nosniff", result.Header.Get(ahttp.HeaderXContentTypeOptions)) + assert.Equal(t, "BeforeText Called successfully", result.Header.Get("X-Beforetext-Interceptor")) + assert.Equal(t, "AfterText Called successfully", result.Header.Get("X-Aftertext-Interceptor")) + assert.Equal(t, "FinallyText Called successfully", result.Header.Get("X-Finallytext-Interceptor")) + assert.True(t, strings.Contains(result.Body, "This is text render response")) + + // Redirect - /test-redirect.html + t.Log("Redirect - /test-redirect.html") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/test-redirect.html", nil) + assert.Nil(t, err) + result = fireRequest(t, req) + assert.Equal(t, 302, result.StatusCode) + assert.NotNil(t, result.Header) + assert.True(t, result.Header.Get(ahttp.HeaderLocation) != "") + assert.True(t, strings.Contains(result.Body, "Found")) + + // Redirect - /test-redirect.html?mode=text_get + t.Log("Redirect - /test-redirect.html?mode=text_get") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/test-redirect.html?mode=text_get", nil) assert.Nil(t, err) - assert.NotNil(t, appAutocertManager) - - AppConfig().SetBool("server.ssl.enable", false) - err = initAppVariables() - assert.Equal(t, "let's encrypt enabled, however SSL 'server.ssl.enable' is not enabled for application", err.Error()) - - // revert values - AppConfig().SetString("server.port", appDefaultHTTPPort) - AppConfig().SetBool("server.ssl.lets_encrypt.enable", false) - - // config error scenario - - // unsupported read timeout - AppConfig().SetString("server.timeout.read", "20h") - err = initAppVariables() - assert.NotNil(t, err) - assert.Equal(t, "'server.timeout.{read|write}' value is not a valid time unit", err.Error()) - AppConfig().SetString("server.timeout.read", "90s") - - // read timeout parsing error - AppConfig().SetString("server.timeout.read", "ss") - err = initAppVariables() - assert.Equal(t, "'server.timeout.read': time: invalid duration ss", err.Error()) - AppConfig().SetString("server.timeout.read", "90s") - - // write timout pasring error - AppConfig().SetString("server.timeout.write", "mm") - err = initAppVariables() - assert.Equal(t, "'server.timeout.write': time: invalid duration mm", err.Error()) - AppConfig().SetString("server.timeout.write", "90s") - - // max header bytes parsing error - AppConfig().SetString("server.max_header_bytes", "2sb") - err = initAppVariables() - assert.Equal(t, "'server.max_header_bytes' value is not a valid size unit", err.Error()) - AppConfig().SetString("server.max_header_bytes", "1mb") - - // ssl cert required if enabled - AppConfig().SetBool("server.ssl.enable", true) - err = initAppVariables() - assert.True(t, (!appIsLetsEncrypt && strings.Contains(err.Error(), "server.ssl.cert"))) - assert.True(t, (!appIsLetsEncrypt && strings.Contains(err.Error(), "server.ssl.key"))) - AppConfig().SetBool("server.ssl.enable", false) - - // multipart size parsing error - AppConfig().SetString("request.multipart_size", "2sb") - err = initAppVariables() - assert.Equal(t, "'request.multipart_size' value is not a valid size unit", err.Error()) - AppConfig().SetString("request.multipart_size", "12mb") - - SetAppPackaged(true) - assert.True(t, appIsPackaged) - - // cleanup - appConfig = nil - SetAppPackaged(false) -} - -func TestAahInitPath(t *testing.T) { - err := initPath("github.com/jeevatkm/testapp") - assert.NotNil(t, err) - assert.Equal(t, "aah application does not exists: github.com/jeevatkm/testapp", err.Error()) - assert.True(t, !ess.IsStrEmpty(goSrcDir)) - assert.True(t, !ess.IsStrEmpty(goPath)) - - // cleanup - appImportPath, appBaseDir, goPath, goSrcDir = "", "", "", "" - appIsPackaged = false -} - -func TestAahRecover(t *testing.T) { - defer aahRecover() - - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) + result = fireRequest(t, req) + assert.Equal(t, 302, result.StatusCode) + assert.NotNil(t, result.Header) + hdrLocation := result.Header.Get(ahttp.HeaderLocation) + assert.True(t, hdrLocation != "") + assert.True(t, strings.Contains(hdrLocation, "Param2")) + assert.True(t, strings.Contains(result.Body, "Found")) + + // Redirect - /test-redirect.html?mode=status + t.Log("Redirect - /test-redirect.html?mode=status") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/test-redirect.html?mode=status", nil) assert.Nil(t, err) + result = fireRequest(t, req) + assert.Equal(t, 307, result.StatusCode) + assert.NotNil(t, result.Header) + assert.True(t, result.Header.Get(ahttp.HeaderLocation) != "") + assert.True(t, strings.Contains(result.Body, "Temporary Redirect")) + + // Redirect - /test-redirect.html?mode=redirect_decrep + t.Log("Redirect - /test-redirect.html?mode=redirect_decrep") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/test-redirect.html?mode=redirect_decrep", nil) + assert.Nil(t, err) + result = fireRequest(t, req) + assert.Equal(t, 307, result.StatusCode) + assert.NotNil(t, result.Header) + assert.True(t, result.Header.Get(ahttp.HeaderLocation) != "") + assert.True(t, strings.Contains(result.Body, "Temporary Redirect")) + + // Form Submit - /form-submit - Anti-CSRF nicely guarded the form request :) + t.Log("Form Submit - /form-submit - Anti-CSRF nicely guarded the form request :)") + form := url.Values{} + form.Add("id", "1000001") + form.Add("product_name", "Test Product") + form.Add("username", "welcome") + form.Add("email", "welcome@welcome.com") + req, err = http.NewRequest(ahttp.MethodPost, ts.URL+"/form-submit", strings.NewReader(form.Encode())) + assert.Nil(t, err) + req.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeForm.String()) + result = fireRequest(t, req) + assert.Equal(t, 403, result.StatusCode) + assert.NotNil(t, result.Header) + assert.True(t, strings.Contains(result.Body, "403 Forbidden")) + + // Form Submit - /form-submit with anti_csrf_token + t.Log("Form Submit - /form-submit with anti_csrf_token") + secret := ts.app.SecurityManager().AntiCSRF.GenerateSecret() + secretstr := ts.app.SecurityManager().AntiCSRF.SaltCipherSecret(secret) + form.Add("anti_csrf_token", secretstr) + wt := httptest.NewRecorder() + ts.app.SecurityManager().AntiCSRF.SetCookie(wt, secret) + cookieValue := wt.Header().Get("Set-Cookie") + req, err = http.NewRequest(ahttp.MethodPost, ts.URL+"/form-submit", strings.NewReader(form.Encode())) + assert.Nil(t, err) + req.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeForm.String()) + req.Header.Set(ahttp.HeaderCookie, cookieValue) + result = fireRequest(t, req) + assert.Equal(t, 200, result.StatusCode) + assert.True(t, strings.Contains(result.Body, "Data recevied successfully")) + assert.True(t, strings.Contains(result.Body, "welcome@welcome.com")) + assert.True(t, strings.Contains(strings.Join(result.Header["Set-Cookie"], "||"), "aah_session=")) + + // CreateRecord - /create-record - JSON post request + // This is webapp test app, send request with anti_csrf_token on HTTP header + t.Log("CreateRecord - /create-record - JSON post request\n" + + "This is webapp test app, send request with anti_csrf_token on HTTP header") + jsonStr := `{ + "first_name":"My firstname", + "last_name": "My lastname", + "email": "email@myemail.com", + "number": 8253645635463 + }` + req, err = http.NewRequest(ahttp.MethodPost, ts.URL+"/create-record", strings.NewReader(jsonStr)) + assert.Nil(t, err) + req.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeJSON.String()) + req.Header.Set("X-Anti-CSRF-Token", secretstr) + req.Header.Set(ahttp.HeaderCookie, cookieValue) + req.Header.Set(ahttp.HeaderXRequestID, ess.NewGUID()+"jeeva") + result = fireRequest(t, req) + assert.Equal(t, 200, result.StatusCode) + assert.True(t, strings.Contains(result.Body, "JSON Payload recevied successfully")) + assert.True(t, strings.Contains(result.Body, "8253645635463")) + assert.True(t, strings.Contains(result.Body, "email@myemail.com")) - panic("this is recover test") } -func TestWritePID(t *testing.T) { - pidfile := filepath.Join(getTestdataPath(), "test-app") - defer ess.DeleteFiles(pidfile + ".pid") +func TestAppMisc(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) + t.Logf("Test Server URL [App Misc]: %s", ts.URL) + + a := ts.app + + assert.Equal(t, "web", a.Type()) + assert.Equal(t, "aah framework web application", a.Desc()) + assert.False(t, a.IsPackaged()) + a.SetPackaged(true) + assert.True(t, a.IsPackaged()) + assert.True(t, a.IsProfileDev()) + assert.True(t, strings.Contains(strings.Join(a.AllProfiles(), " "), "prod")) + + ll := a.NewChildLogger(log.Fields{"key1": "value1"}) + assert.NotNil(t, ll) + + // simualate CLI call + t.Log("simualate CLI call") + a.SetBuildInfo(nil) + err = a.Init(importPath) assert.Nil(t, err) - writePID(AppConfig(), "test-app", getTestdataPath()) - assert.True(t, ess.IsFileExists(pidfile+".pid")) + // SSL + t.Log("SSL") + a.SetTLSConfig(nil) + a.Config().SetBool("server.ssl.enable", true) + a.Config().SetBool("server.ssl.lets_encrypt.enable", true) + err = a.checkSSLConfigValues() + assert.Nil(t, err) + err = a.initAutoCertManager() + assert.Nil(t, err) + + // simulate import path + t.Log("simulate import path") + a.importPath = "github.com/jeevatkm/noapp" + err = a.initPath() + assert.Nil(t, err) } -func TestAahBuildInfo(t *testing.T) { - assert.Nil(t, AppBuildInfo()) +func fireRequest(t *testing.T, req *http.Request) *testResult { + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("Request failed %s", err) + t.FailNow() + return nil + } + + return &testResult{ + StatusCode: resp.StatusCode, + Header: resp.Header, + Body: responseBody(resp), + Raw: resp, + } +} - buildTime := time.Now().Format(time.RFC3339) - SetAppBuildInfo(&BuildInfo{ - BinaryName: "testapp", - Date: buildTime, +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Test Server +//______________________________________________________________________________ + +func newTestServer(t *testing.T, importPath string) (*testServer, error) { + ts := &testServer{ + app: newApp(), + } + + ts.server = httptest.NewServer(ts.app.engine) + ts.URL = ts.server.URL + + ts.app.SetBuildInfo(&BuildInfo{ + BinaryName: filepath.Base(importPath), + Date: time.Now().Format(time.RFC3339), Version: "1.0.0", }) - assert.NotNil(t, AppBuildInfo()) - assert.Equal(t, "testapp", AppBuildInfo().BinaryName) - assert.Equal(t, buildTime, AppBuildInfo().Date) - assert.Equal(t, "1.0.0", AppBuildInfo().Version) + if err := ts.app.Init(importPath); err != nil { + return nil, err + } + + // Manually do it here here, for aah CLI test no issue `aah test` :) + ts.manualInit() + + ts.app.Log().(*log.Logger).SetWriter(ioutil.Discard) + + return ts, nil +} + +type testResult struct { + StatusCode int + Header http.Header + Body string + Raw *http.Response +} + +// TestServer provides capabilities to test aah application end-to-end. +// +// Note: after sometime I will expose this test server, I'm not fully satisfied with +// the implementation yet! Becuase there are short comings in the test server.... +type testServer struct { + URL string + app *app + server *httptest.Server +} + +func (ts *testServer) Close() { + ts.server.Close() +} + +// It a workaround to init required things for application, since test `webapp1` +// residing in `aahframework.org/aah.v0/testdata/webapp1`. +// +// This is not required for actual application residing in $GOPATH :) +func (ts *testServer) manualInit() { + // adding middlewares + ts.app.engine.Middlewares( + RouteMiddleware, + CORSMiddleware, + BindMiddleware, + AntiCSRFMiddleware, + AuthcAuthzMiddleware, + ActionMiddleware, + ) + + // adding controller + ts.app.AddController((*testSiteController)(nil), []*MethodInfo{ + { + Name: "Index", + Parameters: []*ParameterInfo{}, + }, + { + Name: "Text", + Parameters: []*ParameterInfo{}, + }, + { + Name: "Redirect", + Parameters: []*ParameterInfo{ + &ParameterInfo{Name: "mode", Type: reflect.TypeOf((*string)(nil))}, + }, + }, + { + Name: "FormSubmit", + Parameters: []*ParameterInfo{ + &ParameterInfo{Name: "id", Type: reflect.TypeOf((*int)(nil))}, + &ParameterInfo{Name: "info", Type: reflect.TypeOf((**sample)(nil))}, + }, + }, + { + Name: "CreateRecord", + Parameters: []*ParameterInfo{ + &ParameterInfo{Name: "info", Type: reflect.TypeOf((**sampleJSON)(nil))}, + }, + }, + { + Name: "XML", + Parameters: []*ParameterInfo{}, + }, + { + Name: "JSONP", + Parameters: []*ParameterInfo{ + &ParameterInfo{Name: "callback", Type: reflect.TypeOf((*string)(nil))}, + }, + }, + { + Name: "TriggerPanic", + Parameters: []*ParameterInfo{}, + }, + { + Name: "BinaryBytes", + Parameters: []*ParameterInfo{}, + }, + { + Name: "SendFile", + Parameters: []*ParameterInfo{}, + }, + { + Name: "Cookies", + Parameters: []*ParameterInfo{}, + }, + }) + + // reset controller namespace and key + cregistry := make(controllerRegistry) + for k, v := range ts.app.engine.cregistry { + v.Namespace = "" + cregistry[path.Base(k)] = v + } + ts.app.engine.cregistry = cregistry +} + +// Test types +type sample struct { + ProductID int `bind:"id"` + ProductName string `bind:"product_name"` + Username string `bind:"username"` + Email string `bind:"email"` + Page int `bind:"page"` + Count string `bind:"count"` +} + +type sampleJSON struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Number int `json:"number"` +} + +// Test Controller + +type testSiteController struct { + *Context +} + +func (s *testSiteController) Index() { + s.Reply().HTML(Data{ + "Message": "Welcome to aah framework - Test Application webapp1", + "IsSubDomain": s.Subdomain(), + "StaticRoute": s.IsStaticRoute(), + }) +} + +func (s *testSiteController) Text() { + s.Reply().Text(s.Msg("test.text.msg.render")) } -func TestAahConfigValidation(t *testing.T) { - err := checkSSLConfigValues(true, false, "/path/to/cert.pem", "/path/to/cert.key") - assert.Equal(t, "SSL cert file not found: /path/to/cert.pem", err.Error()) +func (s *testSiteController) Redirect(mode string) { + switch mode { + case "status": + s.Reply().RedirectWithStatus(s.ReverseURL("text_get"), 307) + case "text_get": + s.Reply().Redirect(s.ReverseURLm("text_get", map[string]interface{}{ + "param1": "param1value", + "Param2": "Param2Value", + })) + case "redirect_decrep": + s.Reply().RedirectSts(s.ReverseURL("text_get"), 307) + default: + s.Reply().Redirect(s.ReverseURL("index")) + } +} - certPath := filepath.Join(getTestdataPath(), "cert.pem") - defer ess.DeleteFiles(certPath) - _ = ioutil.WriteFile(certPath, []byte("cert.pem file"), 0755) - err = checkSSLConfigValues(true, false, certPath, "/path/to/cert.key") - assert.Equal(t, "SSL key file not found: /path/to/cert.key", err.Error()) +func (s *testSiteController) FormSubmit(id int, info *sample) { + s.Session().Set("session_val1", "This is my session 1 value") + s.Reply().JSON(Data{ + "message": "Data recevied successfully", + "success": true, + "id": id, + "data": info, + }) } -func TestAahAppInit(t *testing.T) { - Init("aahframework.org/aah.v0/testdata") - assert.NotNil(t, appConfig) - assert.NotNil(t, appRouter) - assert.NotNil(t, appSecurityManager) +func (s *testSiteController) CreateRecord(info *sampleJSON) { + s.Reply().JSON(Data{ + "message": "JSON Payload recevied successfully", + "success": true, + "data": info, + }) +} - oldAppBuildInfo := appBuildInfo - appBuildInfo = nil +func (s *testSiteController) XML() { + s.Reply().XML(Data{ + "message": "This is XML payload result", + "success": true, + }) +} - Init("aahframework.org/aah.v0/testdata") - appBuildInfo = oldAppBuildInfo +func (s *testSiteController) JSONP(callback string) { + s.Reply().JSONP(sample{ + Username: "myuser_name", + ProductName: "JSONP product", + ProductID: 190398398, + Email: "email@email.com", + Page: 2, + Count: "1000", + }, callback) +} - // reset it - appConfig = nil - appBaseDir = "" +func (s *testSiteController) TriggerPanic() { + if s.Req.AcceptContentType().IsEqual("application/json") { + s.Reply().ContentType(ahttp.ContentTypeJSON.String()) + } + panic("This panic flow test and recovery") } -func TestAahMisc(t *testing.T) { - assert.Equal(t, int64(0), firstNonZeroInt64(0)) +func (s *testSiteController) BinaryBytes() { + s.Reply(). + HeaderAppend(ahttp.HeaderContentType, ahttp.ContentTypePlainText.String()). + Binary([]byte("This is my Binary Bytes")) +} - assert.True(t, kind(reflect.TypeOf(sample{})) == reflect.Struct) +func (s *testSiteController) SendFile() { + s.Reply(). + Header("X-Before-Interceptor", ""). + Header(ahttp.HeaderContentType, ""). // this is just invoke the method + Header(ahttp.HeaderContentType, "text/css"). + FileInline(filepath.Join("static", "css", "aah.css"), "aah.css") + s.Reply().IsContentTypeSet() +} - host := parseHost("localhost:::8080", "") - assert.Equal(t, "localhost:::8080", host) +func (s *testSiteController) Cookies() { + s.Reply().Cookie(&http.Cookie{ + Name: "test_cookie_1", + Value: "This is test cookie value 1", + Path: "/", + Expires: time.Now().AddDate(1, 0, 0), + HttpOnly: true, + }). + Cookie(&http.Cookie{ + Name: "test_cookie_2", + Value: "This is test cookie value 2", + Path: "/", + Expires: time.Now().AddDate(1, 0, 0), + HttpOnly: true, + }).Text("Hey I'm sending cookies for you :)") +} + +func (s *testSiteController) HandleError(err *Error) bool { + s.Log().Infof("we got the callbakc from error handler: %s", err) + s.Reply().Header("X-Cntrl-ErrorHandler", "true") + return false +} + +func (s *testSiteController) Before() { + s.Reply().Header("X-Before-Interceptor", "Before Called successfully") + s.Log().Info("Before controller interceptor") +} + +func (s *testSiteController) After() { + s.Reply().Header("X-After-Interceptor", "After Called successfully") + s.Log().Info("After controller interceptor") +} + +func (s *testSiteController) Finally() { + s.Reply().Header("X-Finally-Interceptor", "Finally Called successfully") + s.Log().Info("Finally controller interceptor") +} + +func (s *testSiteController) BeforeText() { + s.Reply().Header("X-BeforeText-Interceptor", "BeforeText Called successfully") + s.Log().Info("Before action Text interceptor") +} + +func (s *testSiteController) AfterText() { + s.Reply().Header("X-AfterText-Interceptor", "AfterText Called successfully") + s.Log().Info("After action Text interceptor") +} + +func (s *testSiteController) FinallyText() { + s.Reply().Header("X-FinallyText-Interceptor", "FinallyText Called successfully") + s.Log().Info("Finally action Text interceptor") +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Test util methods +//______________________________________________________________________________ + +func testdataBaseDir() string { + wd, _ := os.Getwd() + if idx := strings.Index(wd, "testdata"); idx > 0 { + wd = wd[:idx] + } + return filepath.Join(wd, "testdata") +} - host = parseHost("localhost:8080", "") - assert.Equal(t, "localhost", host) +func responseBody(res *http.Response) string { + body := res.Body + defer ess.CloseQuietly(body) + if strings.Contains(res.Header.Get(ahttp.HeaderContentEncoding), "gzip") { + body, _ = gzip.NewReader(body) + } + buf := new(bytes.Buffer) + io.Copy(buf, body) + return buf.String() } diff --git a/access_log.go b/access_log.go index 4fbd2e4a..a8b79fda 100644 --- a/access_log.go +++ b/access_log.go @@ -51,86 +51,22 @@ var ( "custom": fmtFlagCustom, } - appDefaultAccessLogPattern = "%clientip %custom:- %reqtime %reqmethod %requrl %reqproto %resstatus %ressize %restime %reqhdr:referer" - appReqStartTimeKey = "_appReqStartTimeKey" - appReqIDHdrKey = ahttp.HeaderXRequestID - appAccessLog *log.Logger - appAccessLogFmtFlags []ess.FmtFlagPart - appAccessLogChan chan *accessLog - accessLogPool = &sync.Pool{New: func() interface{} { return &accessLog{} }} + defaultAccessLogPattern = "%clientip %custom:- %reqtime %reqmethod %requrl %reqproto %resstatus %ressize %restime %reqhdr:referer" + reqStartTimeKey = "_appReqStartTimeKey" ) -type ( - //accessLog contains data about the current request - accessLog struct { - StartTime time.Time - ElapsedDuration time.Duration - Request *ahttp.Request - RequestID string - ResStatus int - ResBytes int - ResHdr http.Header - } -) - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// accessLog methods -//___________________________________ - -// FmtRequestTime method returns the formatted request time. There are three -// possibilities to handle, `%reqtime`, `%reqtime:` and `%reqtime:`. -func (al *accessLog) FmtRequestTime(format string) string { - if format == "%v" || ess.IsStrEmpty(format) { - return al.StartTime.Format(time.RFC3339) - } - return al.StartTime.Format(format) -} - -func (al *accessLog) GetRequestHdr(hdrKey string) string { - hdrValues := al.Request.Header[http.CanonicalHeaderKey(hdrKey)] - if len(hdrValues) == 0 { - return "-" - } - return `"` + strings.Join(hdrValues, ", ") + `"` -} - -func (al *accessLog) GetResponseHdr(hdrKey string) string { - hdrValues := al.ResHdr[http.CanonicalHeaderKey(hdrKey)] - if len(hdrValues) == 0 { - return "-" - } - return `"` + strings.Join(hdrValues, ", ") + `"` -} - -func (al *accessLog) GetQueryString() string { - queryStr := al.Request.Raw.URL.Query().Encode() - if ess.IsStrEmpty(queryStr) { - return "-" - } - return `"` + queryStr + `"` -} - -func (al *accessLog) Reset() { - al.StartTime = time.Time{} - al.ElapsedDuration = 0 - al.Request = nil - al.ResStatus = 0 - al.ResBytes = 0 - al.ResHdr = nil -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ -func initAccessLog(logsDir string, appCfg *config.Config) error { +func (a *app) initAccessLog() error { // log file configuration cfg, _ := config.ParseString("") - file := appCfg.StringDefault("server.access_log.file", "") + file := a.Config().StringDefault("server.access_log.file", "") cfg.SetString("log.receiver", "file") if ess.IsStrEmpty(file) { - cfg.SetString("log.file", filepath.Join(logsDir, getBinaryFileName()+"-access.log")) + cfg.SetString("log.file", filepath.Join(a.logsDir(), a.binaryFilename()+"-access.log")) } else { abspath, err := filepath.Abs(file) if err != nil { @@ -146,61 +82,60 @@ func initAccessLog(logsDir string, appCfg *config.Config) error { if err != nil { return err } - appAccessLog = aaLog + + aaLogger := &accessLogger{ + a: a, + e: a.engine, + logger: aaLog, + logPool: &sync.Pool{New: func() interface{} { return new(accessLog) }}, + } // parse request access log pattern - pattern := appCfg.StringDefault("server.access_log.pattern", appDefaultAccessLogPattern) + pattern := a.Config().StringDefault("server.access_log.pattern", defaultAccessLogPattern) aaLogFmtFlags, err := ess.ParseFmtFlag(pattern, accessLogFmtFlags) if err != nil { return err } - appAccessLogFmtFlags = aaLogFmtFlags + aaLogger.fmtFlags = aaLogFmtFlags // initialize request access log channel - if appAccessLogChan == nil { - appAccessLogChan = make(chan *accessLog, cfg.IntDefault("server.access_log.channel_buffer_size", 500)) - go listenForAccessLog() - } + aaLogger.logChan = make(chan *accessLog, a.Config().IntDefault("server.access_log.channel_buffer_size", 500)) - appReqIDHdrKey = cfg.StringDefault("request.id.header", ahttp.HeaderXRequestID) + a.accessLog = aaLogger + go a.accessLog.listenToLogChan() return nil } -func listenForAccessLog() { - for { - al := <-appAccessLogChan - appAccessLog.Print(accessLogFormatter(al)) - } +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// accessLogger +//______________________________________________________________________________ + +type accessLogger struct { + a *app + e *engine + logger *log.Logger + fmtFlags []ess.FmtFlagPart + logChan chan *accessLog + logPool *sync.Pool } -func sendToAccessLog(ctx *Context) { - al := acquireAccessLog() - al.StartTime = ctx.Get(appReqStartTimeKey).(time.Time) - - // All the bytes have been written on the wire - // so calculate elapsed time - al.ElapsedDuration = time.Since(al.StartTime) - - req := *ctx.Req - al.Request = &req - al.RequestID = firstNonZeroString(req.Header.Get(appReqIDHdrKey), "-") - al.ResStatus = ctx.Res.Status() - al.ResBytes = ctx.Res.BytesWritten() - al.ResHdr = ctx.Res.Header() - - appAccessLogChan <- al +func (aal *accessLogger) listenToLogChan() { + for { + al := <-aal.logChan + aal.logger.Print(aal.accessLogFormatter(al)) + } } -func accessLogFormatter(al *accessLog) string { - defer releaseAccessLog(al) +func (aal *accessLogger) accessLogFormatter(al *accessLog) string { + defer aal.releaseAccessLog(al) buf := acquireBuffer() defer releaseBuffer(buf) - for _, part := range appAccessLogFmtFlags { + for _, part := range aal.fmtFlags { switch part.Flag { case fmtFlagClientIP: - buf.WriteString(al.Request.ClientIP) + buf.WriteString(al.Request.ClientIP()) case fmtFlagRequestTime: buf.WriteString(al.FmtRequestTime(part.Format)) case fmtFlagRequestURL: @@ -231,13 +166,64 @@ func accessLogFormatter(al *accessLog) string { return strings.TrimSpace(buf.String()) } -func acquireAccessLog() *accessLog { - return accessLogPool.Get().(*accessLog) +func (aal *accessLogger) releaseAccessLog(al *accessLog) { + al.Reset() + aal.logPool.Put(al) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// accessLog +//______________________________________________________________________________ + +//accessLog contains data about the current request +type accessLog struct { + StartTime time.Time + ElapsedDuration time.Duration + Request *ahttp.Request + RequestID string + ResStatus int + ResBytes int + ResHdr http.Header +} + +// FmtRequestTime method returns the formatted request time. There are three +// possibilities to handle, `%reqtime`, `%reqtime:` and `%reqtime:`. +func (al *accessLog) FmtRequestTime(format string) string { + if format == "%v" || ess.IsStrEmpty(format) { + return al.StartTime.Format(time.RFC3339) + } + return al.StartTime.Format(format) } -func releaseAccessLog(al *accessLog) { - if al != nil { - al.Reset() - accessLogPool.Put(al) +func (al *accessLog) GetRequestHdr(hdrKey string) string { + hdrValues := al.Request.Header[http.CanonicalHeaderKey(hdrKey)] + if len(hdrValues) == 0 { + return "-" } + return `"` + strings.Join(hdrValues, ", ") + `"` +} + +func (al *accessLog) GetResponseHdr(hdrKey string) string { + hdrValues := al.ResHdr[http.CanonicalHeaderKey(hdrKey)] + if len(hdrValues) == 0 { + return "-" + } + return `"` + strings.Join(hdrValues, ", ") + `"` +} + +func (al *accessLog) GetQueryString() string { + queryStr := al.Request.URL().Query().Encode() + if ess.IsStrEmpty(queryStr) { + return "-" + } + return `"` + queryStr + `"` +} + +func (al *accessLog) Reset() { + al.StartTime = time.Time{} + al.ElapsedDuration = 0 + al.Request = nil + al.ResStatus = 0 + al.ResBytes = 0 + al.ResHdr = nil } diff --git a/access_log_test.go b/access_log_test.go index 95de7646..0b9040a9 100644 --- a/access_log_test.go +++ b/access_log_test.go @@ -6,137 +6,26 @@ package aah import ( "fmt" - "net/http" - "net/http/httptest" "path/filepath" "testing" - "time" - "aahframework.org/ahttp.v0" "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/test.v0/assert" ) -func TestAccessLogFormatter(t *testing.T) { - al := createTestAccessLog() +func TestAccessLogInitAbsPath(t *testing.T) { + logPath := filepath.Join(testdataBaseDir(), "sample-test-access.log") + defer ess.DeleteFiles(logPath) - // Since we are not bootstrapping the framework's engine, - // We need to manually set this - al.Request.Path = "/oops" - al.Request.Header.Set(ahttp.HeaderXRequestID, "5946ed129bf23409520736de") - - // Testing for the default access log pattern first - expectedDefaultFormat := fmt.Sprintf("%v - %v %v %v %v %v %v %v %v", - "[::1]", al.StartTime.Format(time.RFC3339), - al.Request.Method, al.Request.Path, al.Request.Raw.Proto, al.ResStatus, - al.ResBytes, fmt.Sprintf("%.4f", al.ElapsedDuration.Seconds()*1e3), "-") - - testFormatter(t, al, appDefaultAccessLogPattern, expectedDefaultFormat) - - // Testing custom access log pattern - al = createTestAccessLog() - al.ResHdr.Add("content-type", "application/json") - pattern := "%reqtime:2016-05-16 %reqhdr %querystr %reshdr:content-type" - expected := fmt.Sprintf(`%s %s "%s" "%s"`, al.StartTime.Format("2016-05-16"), "-", "me=human", al.ResHdr.Get("Content-Type")) - - testFormatter(t, al, pattern, expected) - - // Testing all available access log pattern - al = createTestAccessLog() - al.Request.Header = al.Request.Raw.Header - al.Request.Header.Add(ahttp.HeaderAccept, "text/html") - al.Request.Header.Set(ahttp.HeaderXRequestID, "5946ed129bf23409520736de") - al.RequestID = "5946ed129bf23409520736de" - al.Request.ClientIP = "127.0.0.1" - al.ResHdr.Add("content-type", "application/json") - allAvailablePatterns := "%clientip %reqid %reqtime %restime %resstatus %ressize %reqmethod %requrl %reqhdr:accept %querystr %reshdr" - expectedForAllAvailablePatterns := fmt.Sprintf(`%s %s %s %v %d %d %s %s "%s" "%s" %s`, - al.Request.ClientIP, al.Request.Header.Get(ahttp.HeaderXRequestID), - al.StartTime.Format(time.RFC3339), fmt.Sprintf("%.4f", al.ElapsedDuration.Seconds()*1e3), - al.ResStatus, al.ResBytes, al.Request.Method, - al.Request.Path, "text/html", "me=human", "-") - - testFormatter(t, al, allAvailablePatterns, expectedForAllAvailablePatterns) -} - -func TestAccessLogFormatterInvalidPattern(t *testing.T) { - _, err := ess.ParseFmtFlag("%oops", accessLogFmtFlags) - - assert.NotNil(t, err) -} - -func TestAccessLogInitDefault(t *testing.T) { - testAccessInit(t, ` - server { - access_log { - # Default value is false - enable = true - } - } - `) - - testAccessInit(t, ` - server { - access_log { - # Default value is false - enable = true - - file = "testdata/test-access.log" - } - } - `) - - testAccessInit(t, ` - server { - access_log { - # Default value is false - enable = true - - file = "/tmp/test-access.log" - } - } - `) -} - -func testFormatter(t *testing.T, al *accessLog, pattern, expected string) { - var err error - appAccessLogFmtFlags, err = ess.ParseFmtFlag(pattern, accessLogFmtFlags) + a := newApp() + cfg, _ := config.ParseString(fmt.Sprintf(`server { + access_log { + file = "%s" + } + }`, logPath)) + a.cfg = cfg + err := a.initAccessLog() assert.Nil(t, err) - assert.Equal(t, expected, accessLogFormatter(al)) -} - -func testAccessInit(t *testing.T, cfgStr string) { - buildTime := time.Now().Format(time.RFC3339) - SetAppBuildInfo(&BuildInfo{ - BinaryName: "testapp", - Date: buildTime, - Version: "1.0.0", - }) - - cfg, _ := config.ParseString(cfgStr) - logsDir := filepath.Join(getTestdataPath(), appLogsDir()) - err := initAccessLog(logsDir, cfg) - - assert.Nil(t, err) - assert.NotNil(t, appAccessLog) -} - -func createTestAccessLog() *accessLog { - startTime := time.Now() - req := httptest.NewRequest("GET", "/oops?me=human", nil) - req.Header = http.Header{} - - w := httptest.NewRecorder() - - al := acquireAccessLog() - al.StartTime = startTime - al.ElapsedDuration = time.Now().Add(2 * time.Second).Sub(startTime) - al.Request = &ahttp.Request{Raw: req, Header: req.Header, ClientIP: "[::1]"} - al.ResStatus = 200 - al.ResBytes = 63 - al.ResHdr = w.HeaderMap - - return al } diff --git a/bind.go b/bind.go index ae94a814..34194f84 100644 --- a/bind.go +++ b/bind.go @@ -5,7 +5,6 @@ package aah import ( - "errors" "fmt" "net/http" "net/url" @@ -27,23 +26,11 @@ const ( allContentTypes = "*/*" ) -var ( - keyQueryParamName = keyOverrideI18nName - keyPathParamName = keyOverrideI18nName - requestParsers = make(map[string]requestParser) - isContentNegotiationEnabled bool - acceptedContentTypes []string - offeredContentTypes []string - autobindPriority []string - - errInvalidParsedValue = errors.New("aah: parsed value is invalid") -) - type requestParser func(ctx *Context) flowResult -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Package method -//___________________________________ +//______________________________________________________________________________ // AddValueParser method adds given custom value parser for the `reflect.Type` func AddValueParser(typ reflect.Type, parser valpar.Parser) error { @@ -93,22 +80,22 @@ func ValidateValue(v interface{}, rules string) bool { return valpar.ValidateValue(v, rules) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Bind Middleware -//___________________________________ +//______________________________________________________________________________ // BindMiddleware method parses the incoming HTTP request to collects request // parameters (Path, Form, Query, Multipart) stores into context. Request // params are made available in View via template functions. func BindMiddleware(ctx *Context, m *Middleware) { - if AppI18n() != nil { + if ctx.a.I18n() != nil { // i18n locale HTTP header `Accept-Language` value override via // Path Variable and URL Query Param (config i18n { param_name { ... } }). // Note: Query parameter takes precedence of all. if locale := firstNonZeroString( - ctx.Req.QueryValue(keyQueryParamName), - ctx.Req.PathValue(keyPathParamName)); !ess.IsStrEmpty(locale) { - ctx.Req.Locale = ahttp.NewLocale(locale) + ctx.Req.QueryValue(ctx.a.bindMgr.keyQueryParamName), + ctx.Req.PathValue(ctx.a.bindMgr.keyPathParamName)); !ess.IsStrEmpty(locale) { + ctx.Req.SetLocale(ahttp.NewLocale(locale)) } } @@ -116,13 +103,13 @@ func BindMiddleware(ctx *Context, m *Middleware) { goto PCont } - ctx.Log().Debugf("Request Content-Type mime: %s", ctx.Req.ContentType.Mime) + ctx.Log().Debugf("Request Content-Type mime: %s", ctx.Req.ContentType()) // Content Negotitaion - Accepted & Offered, refer to GitHub #75 - if isContentNegotiationEnabled { - if len(acceptedContentTypes) > 0 && - !ess.IsSliceContainsString(acceptedContentTypes, ctx.Req.ContentType.Mime) { - ctx.Log().Warnf("Content type '%v' not accepted by server", ctx.Req.ContentType.Mime) + if ctx.a.bindMgr.isContentNegotiationEnabled { + if len(ctx.a.bindMgr.acceptedContentTypes) > 0 && + !ess.IsSliceContainsString(ctx.a.bindMgr.acceptedContentTypes, ctx.Req.ContentType().Mime) { + ctx.Log().Warnf("Content type '%v' not accepted by server", ctx.Req.ContentType()) ctx.Reply().Error(&Error{ Reason: ErrContentTypeNotAccepted, Code: http.StatusUnsupportedMediaType, @@ -131,27 +118,27 @@ func BindMiddleware(ctx *Context, m *Middleware) { return } - if len(offeredContentTypes) > 0 && - !ess.IsSliceContainsString(offeredContentTypes, ctx.Req.AcceptContentType.Mime) { + if len(ctx.a.bindMgr.offeredContentTypes) > 0 && + !ess.IsSliceContainsString(ctx.a.bindMgr.offeredContentTypes, ctx.Req.AcceptContentType().Mime) { ctx.Reply().Error(&Error{ Reason: ErrContentTypeNotOffered, Code: http.StatusNotAcceptable, Message: http.StatusText(http.StatusNotAcceptable), }) - ctx.Log().Warnf("Content type '%v' not offered by server", ctx.Req.AcceptContentType.Mime) + ctx.Log().Warnf("Content type '%v' not offered by server", ctx.Req.AcceptContentType()) return } } // Prevent DDoS attacks by large HTTP request bodies by enforcing - // configured hard limit, GitHub #83. - if ctx.Req.ContentType.Mime != ahttp.ContentTypeMultipartForm.Mime { + // configured hard limit for non-multipart/form-data Content-Type GitHub #83. + if !ahttp.ContentTypeMultipartForm.IsEqual(ctx.Req.ContentType().Mime) { ctx.Req.Unwrap().Body = http.MaxBytesReader(ctx.Res, ctx.Req.Unwrap().Body, - firstNonZeroInt64(ctx.route.MaxBodySize, appMaxBodyBytesSize)) + firstNonZeroInt64(ctx.route.MaxBodySize, ctx.a.maxBodyBytes)) } // Parse request content by Content-Type - if parser, found := requestParsers[ctx.Req.ContentType.Mime]; found { + if parser, found := ctx.a.bindMgr.requestParsers[ctx.Req.ContentType().Mime]; found { if res := parser(ctx); res == flowStop { return } @@ -159,19 +146,90 @@ func BindMiddleware(ctx *Context, m *Middleware) { PCont: // Compose request details, we can log at the end of the request. - if isDumpLogEnabled { - ctx.Set(keyAahRequestDump, composeRequestDump(ctx)) + if ctx.a.dumpLogEnabled { + ctx.Set(keyAahRequestDump, ctx.a.dumpLog.composeRequestDump(ctx)) } m.Next(ctx) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) initBind() error { + cfg := a.Config() + + bindMgr := &bindManager{ + keyPathParamName: cfg.StringDefault("i18n.param_name.path", keyOverrideI18nName), + keyQueryParamName: cfg.StringDefault("i18n.param_name.query", keyOverrideI18nName), + isContentNegotiationEnabled: cfg.BoolDefault("request.content_negotiation.enable", false), + requestParsers: make(map[string]requestParser), + } + + // Content Negotitaion, GitHub #75 + bindMgr.acceptedContentTypes, _ = cfg.StringList("request.content_negotiation.accepted") + for idx, v := range bindMgr.acceptedContentTypes { + bindMgr.acceptedContentTypes[idx] = strings.ToLower(v) + if v == allContentTypes { + // when `*/*` is mentioned, don't check the condition + // because it means every content type is allowed + bindMgr.acceptedContentTypes = make([]string, 0) + break + } + } + + bindMgr.offeredContentTypes, _ = cfg.StringList("request.content_negotiation.offered") + for idx, v := range bindMgr.offeredContentTypes { + bindMgr.offeredContentTypes[idx] = strings.ToLower(v) + if v == allContentTypes { + // when `*/*` is mentioned, don't check the condition + // because it means every content type is allowed + bindMgr.offeredContentTypes = make([]string, 0) + break + } + } + + // Auto Parse and Bind, GitHub #26 + bindMgr.requestParsers[ahttp.ContentTypeMultipartForm.Mime] = multipartFormParser + bindMgr.requestParsers[ahttp.ContentTypeForm.Mime] = formParser + + bindMgr.autobindPriority = reverseSlice(strings.Split(cfg.StringDefault("request.auto_bind.priority", "PFQ"), "")) + timeFormats, found := cfg.StringList("format.time") + if !found { + timeFormats = []string{ + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02"} + } + valpar.TimeFormats = timeFormats + valpar.StructTagName = cfg.StringDefault("request.auto_bind.tag_name", "bind") + + a.bindMgr = bindMgr + return nil +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Bind Manager +//______________________________________________________________________________ + +type bindManager struct { + keyQueryParamName string + keyPathParamName string + requestParsers map[string]requestParser + isContentNegotiationEnabled bool + acceptedContentTypes []string + offeredContentTypes []string + autobindPriority []string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Content Parser methods -//___________________________________ +//______________________________________________________________________________ func multipartFormParser(ctx *Context) flowResult { - if err := ctx.Req.Unwrap().ParseMultipartForm(appMultipartMaxMemory); err != nil { + if err := ctx.Req.Unwrap().ParseMultipartForm(ctx.a.multipartMaxMemory); err != nil { ctx.Log().Errorf("Unable to parse multipart form: %s", err) } else { ctx.Req.Params.Form = ctx.Req.Unwrap().MultipartForm.Value @@ -189,11 +247,11 @@ func formParser(ctx *Context) flowResult { return flowCont } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Action Parameters Auto Parse -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Context - Action Parameters Auto Parse +//______________________________________________________________________________ -func parseParameters(ctx *Context) ([]reflect.Value, *Error) { +func (ctx *Context) parseParameters() ([]reflect.Value, *Error) { paramCnt := len(ctx.action.Parameters) // If parameters not exists, return here @@ -202,7 +260,7 @@ func parseParameters(ctx *Context) ([]reflect.Value, *Error) { } // Parse and Bind parameters - params := createParams(ctx) + params := ctx.createParams() var err error actionArgs := make([]reflect.Value, paramCnt) for idx, val := range ctx.action.Parameters { @@ -225,12 +283,12 @@ func parseParameters(ctx *Context) ([]reflect.Value, *Error) { } } } else if val.kind == reflect.Struct { - ct := ctx.Req.ContentType.Mime + ct := ctx.Req.ContentType().Mime if ct == ahttp.ContentTypeJSON.Mime || ct == ahttp.ContentTypeJSONText.Mime || ct == ahttp.ContentTypeXML.Mime || ct == ahttp.ContentTypeXMLText.Mime { result, err = valpar.Body(ct, ctx.Req.Body(), val.Type) - if isDumpLogEnabled && dumpRequestBody { - addReqBodyIntoCtx(ctx, result) + if ctx.a.dumpLogEnabled && ctx.a.dumpLog.dumpRequestBody { + ctx.a.dumpLog.addReqBodyIntoCtx(ctx, result) } } else { result, err = valpar.Struct("", val.Type, params) @@ -275,9 +333,9 @@ func parseParameters(ctx *Context) ([]reflect.Value, *Error) { } // Create param values based on autobind priority -func createParams(ctx *Context) url.Values { +func (ctx *Context) createParams() url.Values { params := make(url.Values) - for _, priority := range autobindPriority { + for _, priority := range ctx.a.bindMgr.autobindPriority { switch priority { case "P": // Path Values for k, v := range ctx.Req.Params.Path { @@ -296,74 +354,24 @@ func createParams(ctx *Context) url.Values { return params } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Template methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Template methods +//______________________________________________________________________________ // tmplPathParam method returns Request Path Param value for the given key. -func tmplPathParam(viewArgs map[string]interface{}, key string) interface{} { +func (vm *viewManager) tmplPathParam(viewArgs map[string]interface{}, key string) interface{} { params := viewArgs[KeyViewArgRequestParams].(*ahttp.Params) return sanatizeValue(params.PathValue(key)) } // tmplFormParam method returns Request Form value for the given key. -func tmplFormParam(viewArgs map[string]interface{}, key string) interface{} { +func (vm *viewManager) tmplFormParam(viewArgs map[string]interface{}, key string) interface{} { params := viewArgs[KeyViewArgRequestParams].(*ahttp.Params) return sanatizeValue(params.FormValue(key)) } // tmplQueryParam method returns Request Query String value for the given key. -func tmplQueryParam(viewArgs map[string]interface{}, key string) interface{} { +func (vm *viewManager) tmplQueryParam(viewArgs map[string]interface{}, key string) interface{} { params := viewArgs[KeyViewArgRequestParams].(*ahttp.Params) return sanatizeValue(params.QueryValue(key)) } - -func bindInitialize(e *Event) { - cfg := AppConfig() - keyPathParamName = cfg.StringDefault("i18n.param_name.path", keyOverrideI18nName) - keyQueryParamName = cfg.StringDefault("i18n.param_name.query", keyOverrideI18nName) - - // Content Negotitaion, GitHub #75 - isContentNegotiationEnabled = cfg.BoolDefault("request.content_negotiation.enable", false) - acceptedContentTypes, _ = cfg.StringList("request.content_negotiation.accepted") - for idx, v := range acceptedContentTypes { - acceptedContentTypes[idx] = strings.ToLower(v) - if v == allContentTypes { - // when `*/*` is mentioned, don't check the condition - // because it means every content type is allowed - acceptedContentTypes = make([]string, 0) - break - } - } - - offeredContentTypes, _ = cfg.StringList("request.content_negotiation.offered") - for idx, v := range offeredContentTypes { - offeredContentTypes[idx] = strings.ToLower(v) - if v == allContentTypes { - // when `*/*` is mentioned, don't check the condition - // because it means every content type is allowed - offeredContentTypes = make([]string, 0) - break - } - } - - // Auto Parse and Bind, GitHub #26 - requestParsers[ahttp.ContentTypeMultipartForm.Mime] = multipartFormParser - requestParsers[ahttp.ContentTypeForm.Mime] = formParser - - autobindPriority = reverseSlice(strings.Split(cfg.StringDefault("request.auto_bind.priority", "PFQ"), "")) - timeFormats, found := cfg.StringList("format.time") - if !found { - timeFormats = []string{ - "2006-01-02T15:04:05Z07:00", - "2006-01-02T15:04:05Z", - "2006-01-02 15:04:05", - "2006-01-02"} - } - valpar.TimeFormats = timeFormats - valpar.StructTagName = cfg.StringDefault("request.auto_bind.tag_name", "bind") -} - -func init() { - OnStart(bindInitialize) -} diff --git a/bind_test.go b/bind_test.go index 394e6e25..46425266 100644 --- a/bind_test.go +++ b/bind_test.go @@ -5,6 +5,7 @@ package aah import ( + "io/ioutil" "net/http" "net/http/httptest" "net/url" @@ -16,173 +17,46 @@ import ( "aahframework.org/ahttp.v0" "aahframework.org/config.v0" "aahframework.org/essentials.v0" - "aahframework.org/i18n.v0" - "aahframework.org/router.v0" - "aahframework.org/security.v0" + "aahframework.org/log.v0" "aahframework.org/test.v0/assert" ) -func TestBindParamTemplateFuncs(t *testing.T) { - form := url.Values{} - form.Add("names", "Test1") - form.Add("names", "Test 2 value") - form.Add("username", "welcome") - form.Add("email", "welcome@welcome.com") - req1, _ := http.NewRequest("POST", "http://localhost:8080/user/registration?_ref=true&locale=en-CA", strings.NewReader(form.Encode())) - req1.Header.Add(ahttp.HeaderContentType, ahttp.ContentTypeForm.Raw()) - _ = req1.ParseForm() - - aahReq1 := ahttp.ParseRequest(req1, &ahttp.Request{}) - aahReq1.Params.Form = req1.Form - aahReq1.Params.Path = make(map[string]string) - aahReq1.Params.Path["userId"] = "100001" - - viewArgs := map[string]interface{}{} - viewArgs[KeyViewArgRequestParams] = aahReq1.Params - - v1 := tmplQueryParam(viewArgs, "_ref") - assert.Equal(t, "true", v1) - - v2 := tmplFormParam(viewArgs, "email") - assert.Equal(t, "welcome@welcome.com", v2) - - v3 := tmplPathParam(viewArgs, "userId") - assert.Equal(t, "100001", v3) -} - -func TestBindParse(t *testing.T) { - defer ess.DeleteFiles("testapp.pid") - requestParsers[ahttp.ContentTypeMultipartForm.Mime] = multipartFormParser - requestParsers[ahttp.ContentTypeForm.Mime] = formParser - - // Request Query String - r1 := httptest.NewRequest("GET", "http://localhost:8080/index.html?lang=en-CA", nil) - ctx1 := &Context{ - Req: ahttp.AcquireRequest(r1), - Res: ahttp.AcquireResponseWriter(httptest.NewRecorder()), - subject: security.AcquireSubject(), - route: &router.Route{MaxBodySize: 5 << 20}, - values: make(map[string]interface{}), - viewArgs: make(map[string]interface{}), - } - - appI18n = i18n.New() - - assert.Nil(t, ctx1.Req.Locale) - BindMiddleware(ctx1, &Middleware{}) - assert.NotNil(t, ctx1.Req.Locale) - assert.Equal(t, "en", ctx1.Req.Locale.Language) - assert.Equal(t, "CA", ctx1.Req.Locale.Region) - assert.Equal(t, "en-CA", ctx1.Req.Locale.String()) - - // Request Form Values - form := url.Values{} - form.Add("names", "Test1") - form.Add("names", "Test 2 value") - form.Add("username", "welcome") - form.Add("email", "welcome@welcome.com") - r2, _ := http.NewRequest("POST", "http://localhost:8080/user/registration", strings.NewReader(form.Encode())) - r2.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeForm.String()) - ctx2 := &Context{ - Req: ahttp.AcquireRequest(r2), - Res: ahttp.AcquireResponseWriter(httptest.NewRecorder()), - subject: security.AcquireSubject(), - values: make(map[string]interface{}), - viewArgs: make(map[string]interface{}), - route: &router.Route{MaxBodySize: 5 << 20}, - } - - BindMiddleware(ctx2, &Middleware{}) - assert.NotNil(t, ctx2.Req.Params.Form) - assert.True(t, len(ctx2.Req.Params.Form) == 3) - - // Request Form Multipart - r3, _ := http.NewRequest("POST", "http://localhost:8080/user/registration", strings.NewReader(form.Encode())) - r3.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeMultipartForm.String()) - ctx3 := &Context{ - Req: ahttp.AcquireRequest(r3), - subject: security.AcquireSubject(), - values: make(map[string]interface{}), - viewArgs: make(map[string]interface{}), - route: &router.Route{MaxBodySize: 5 << 20}, - } - BindMiddleware(ctx3, &Middleware{}) - assert.Nil(t, ctx3.Req.Params.Form) - assert.False(t, len(ctx3.Req.Params.Form) == 3) -} - -func TestBindParamParseLocaleFromAppConfiguration(t *testing.T) { +func TestBindParamContentNegotiation(t *testing.T) { defer ess.DeleteFiles("testapp.pid") - cfg, err := config.ParseString(` - i18n { - param_name { - query = "language" - } - } - `) - appConfig = cfg - bindInitialize(&Event{}) - + a := newApp() + cfg, _ := config.ParseString(`request { + content_negotiation { + enable = true + accepted = ["application/json"] + offered = ["application/json"] + } + }`) + a.cfg = cfg + err := a.initLog() assert.Nil(t, err) - r := httptest.NewRequest("GET", "http://localhost:8080/index.html?language=en-CA", nil) - ctx1 := &Context{ - Req: ahttp.AcquireRequest(r), - viewArgs: make(map[string]interface{}), - values: make(map[string]interface{}), - } - - assert.Nil(t, ctx1.Req.Locale) - BindMiddleware(ctx1, &Middleware{}) - assert.NotNil(t, ctx1.Req.Locale) - assert.Equal(t, "en", ctx1.Req.Locale.Language) - assert.Equal(t, "CA", ctx1.Req.Locale.Region) - assert.Equal(t, "en-CA", ctx1.Req.Locale.String()) -} - -func TestBindParamContentNegotiation(t *testing.T) { - defer ess.DeleteFiles("testapp.pid") + err = a.initBind() + assert.Nil(t, err) - errorHandlerFunc = defaultErrorHandlerFunc - isContentNegotiationEnabled = true + a.Log().(*log.Logger).SetWriter(ioutil.Discard) // Accepted - acceptedContentTypes = []string{"application/json"} r1 := httptest.NewRequest("POST", "http://localhost:8080/v1/userinfo", nil) r1.Header.Set(ahttp.HeaderContentType, "application/xml") - ctx1 := &Context{ - Req: ahttp.AcquireRequest(r1), - reply: acquireReply(), - subject: security.AcquireSubject(), - } + ctx1 := newContext(nil, r1) + ctx1.a = a BindMiddleware(ctx1, &Middleware{}) assert.Equal(t, http.StatusUnsupportedMediaType, ctx1.Reply().err.Code) // Offered - offeredContentTypes = []string{"application/json"} r2 := httptest.NewRequest("POST", "http://localhost:8080/v1/userinfo", nil) r2.Header.Set(ahttp.HeaderContentType, "application/json") r2.Header.Set(ahttp.HeaderAccept, "application/xml") - ctx2 := &Context{ - Req: ahttp.AcquireRequest(r2), - reply: acquireReply(), - subject: security.AcquireSubject(), - } + ctx2 := newContext(nil, r2) + ctx2.a = a BindMiddleware(ctx2, &Middleware{}) assert.Equal(t, http.StatusNotAcceptable, ctx2.Reply().err.Code) - - isContentNegotiationEnabled = false - - appConfig, _ = config.ParseString(` - request { - content_negotiation { - accepted = ["*/*"] - offered = ["*/*"] - } - }`) - bindInitialize(&Event{}) - appConfig = nil } func TestBindAddValueParser(t *testing.T) { @@ -193,14 +67,6 @@ func TestBindAddValueParser(t *testing.T) { assert.Equal(t, "valpar: value parser is already exists", err.Error()) } -func TestBindFormBodyNil(t *testing.T) { - // Request Body is nil - r1, _ := http.NewRequest("POST", "http://localhost:8080/user/registration", nil) - ctx1 := &Context{Req: ahttp.AcquireRequest(r1), subject: security.AcquireSubject()} - result := formParser(ctx1) - assert.Equal(t, flowCont, result) -} - func TestBindValidatorWithValue(t *testing.T) { assert.NotNil(t, Validator()) @@ -230,3 +96,34 @@ func TestBindValidatorWithValue(t *testing.T) { result = ValidateValue(numbers, "unique") assert.True(t, result) } + +func TestBindParamTemplateFuncs(t *testing.T) { + a := newApp() + a.viewMgr = &viewManager{a: a} + + form := url.Values{} + form.Add("names", "Test1") + form.Add("names", "Test 2 value") + form.Add("username", "welcome") + form.Add("email", "welcome@welcome.com") + req1, _ := http.NewRequest("POST", "http://localhost:8080/user/registration?_ref=true&locale=en-CA", strings.NewReader(form.Encode())) + req1.Header.Add(ahttp.HeaderContentType, ahttp.ContentTypeForm.Raw()) + _ = req1.ParseForm() + + aahReq1 := ahttp.ParseRequest(req1, &ahttp.Request{}) + aahReq1.Params.Form = req1.Form + aahReq1.Params.Path = make(map[string]string) + aahReq1.Params.Path["userId"] = "100001" + + viewArgs := map[string]interface{}{} + viewArgs[KeyViewArgRequestParams] = aahReq1.Params + + v1 := a.viewMgr.tmplQueryParam(viewArgs, "_ref") + assert.Equal(t, "true", v1) + + v2 := a.viewMgr.tmplFormParam(viewArgs, "email") + assert.Equal(t, "welcome@welcome.com", v2) + + v3 := a.viewMgr.tmplPathParam(viewArgs, "userId") + assert.Equal(t, "100001", v3) +} diff --git a/config.go b/config.go index 9dac77d8..7749a777 100644 --- a/config.go +++ b/config.go @@ -5,142 +5,119 @@ package aah import ( - "fmt" "os" "os/signal" "path/filepath" "syscall" "aahframework.org/config.v0" - "aahframework.org/essentials.v0" - "aahframework.org/log.v0" ) -var ( - appConfig *config.Config - isHotReload = false -) - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ - -// AppConfig method returns aah application configuration instance. -func AppConfig() *config.Config { - return appConfig -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -func appConfigDir() string { - return filepath.Join(AppBaseDir(), "config") +func (a *app) Config() *config.Config { + return a.cfg } -func initConfig(cfgDir string) error { - confPath := filepath.Join(cfgDir, "aah.conf") +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ - cfg, err := config.LoadFile(confPath) +func (a *app) initConfig() error { + aahConf := filepath.Join(a.configDir(), "aah.conf") + cfg, err := config.LoadFile(aahConf) if err != nil { - return fmt.Errorf("aah application %s", err) + return err } - appConfig = cfg - + a.cfg = cfg + a.sc = make(chan os.Signal, 2) return nil } -func hotReloadConfig() { - isHotReload = true - defer func() { isHotReload = false }() +func (a *app) listenForHotConfigReload() { + signal.Notify(a.sc, syscall.SIGHUP) + for { + <-a.sc + a.Log().Warn("Hangup signal (SIGHUP) received") + if a.IsProfile(defaultEnvProfile) { + a.Log().Info("Currently active environment profile is 'dev', config hot-reload is not applicable") + continue + } + a.hotReloadConfig() + } +} + +func (a *app) hotReloadConfig() { + a.hotReload = true + defer func() { a.hotReload = false }() - log.Info("Configuration reload and application reinitialization is in-progress ...") + a.Log().Info("Configuration hot-reload and application reinitialization starts ...") var err error - cfgDir := appConfigDir() - if err = initConfig(cfgDir); err != nil { - log.Errorf("Unable to reload aah.conf: %v", err) + if err = a.initConfig(); err != nil { + a.Log().Errorf("Unable to reload aah.conf: %v", err) return } - if err = initAppVariables(); err != nil { - log.Errorf("Unable to reinitialize aah application variables: %v", err) + if err = a.initConfigValues(); err != nil { + a.Log().Errorf("Unable to reinitialize aah application variables: %v", err) return } - logDir := appLogsDir() - if err = initLogs(logDir, AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application logger: %v", err) + if err = a.initLog(); err != nil { + a.Log().Errorf("Unable to reinitialize application logger: %v", err) return } - i18nDir := appI18nDir() - if ess.IsFileExists(i18nDir) { - if err = initI18n(i18nDir); err != nil { - log.Errorf("Unable to reinitialize application i18n: %v", err) - return - } + if err = a.initI18n(); err != nil { + a.Log().Errorf("Unable to reinitialize application i18n: %v", err) + return } - if err = initRoutes(cfgDir, AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application %v", err) + if err = a.initRouter(); err != nil { + a.Log().Errorf("Unable to reinitialize application %v", err) return } - viewDir := appViewsDir() - if ess.IsFileExists(viewDir) { - if err = initViewEngine(viewDir, AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application views: %v", err) - return - } + if err = a.initView(); err != nil { + a.Log().Errorf("Unable to reinitialize application views: %v", err) + return } - if err = initSecurity(AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application security manager: %v", err) + if err = a.initSecurity(); err != nil { + a.Log().Errorf("Unable to reinitialize application security manager: %v", err) return } - if AppConfig().BoolDefault("server.access_log.enable", false) { - if err = initAccessLog(logDir, AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application access log: %v", err) + if a.accessLogEnabled { + if err = a.initAccessLog(); err != nil { + a.Log().Errorf("Unable to reinitialize application access log: %v", err) return } } - if AppConfig().BoolDefault("server.dump_log.enable", false) { - if err = initDumpLog(logDir, AppConfig()); err != nil { - log.Errorf("Unable to reinitialize application dump log: %v", err) + if a.dumpLogEnabled { + if err = a.initDumpLog(); err != nil { + a.Log().Errorf("Unable to reinitialize application dump log: %v", err) return } } - log.Info("Configuration reload and application reinitialization is successful") -} - -func listenForHotConfigReload() { - sc := make(chan os.Signal, 1) - signal.Notify(sc, syscall.SIGHUP) - for { - <-sc - log.Warn("Hangup signal (SIGHUP) received") - if appProfile == appDefaultProfile { - log.Info("Currently active environment profile is 'dev', config hot-reload is not applicable") - continue - } - hotReloadConfig() - } + a.Log().Info("Configuration hot-reload and application reinitialization was successful") } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Template methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Template methods +//______________________________________________________________________________ // tmplConfig method provides access to application config on templates. -func tmplConfig(key string) interface{} { - if value, found := AppConfig().Get(key); found { +func (vm *viewManager) tmplConfig(key string) interface{} { + if value, found := vm.a.Config().Get(key); found { return sanatizeValue(value) } - log.Warnf("app config key not found: %v", key) + vm.a.Log().Warnf("app config key not found: %s", key) return "" } diff --git a/config_test.go b/config_test.go deleted file mode 100644 index 26ec6ed2..00000000 --- a/config_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Jeevanandam M. (https://github.com/jeevatkm) -// go-aah/aah source code and usage is governed by a MIT style -// license that can be found in the LICENSE file. - -package aah - -import ( - "path/filepath" - "strings" - "testing" - "time" - - "aahframework.org/test.v0/assert" -) - -func TestConfigInit(t *testing.T) { - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) - - assert.Nil(t, err) - assert.NotNil(t, AppConfig()) - assert.Equal(t, "127.0.0.1", AppConfig().StringDefault("server.address", "")) - assert.Equal(t, "X-Request-Id", AppConfig().StringDefault("request.id.header", "")) - - appConfig = nil - err = initConfig(getTestdataPath()) - assert.Nil(t, AppConfig()) - assert.NotNil(t, err) - assert.True(t, strings.HasPrefix(err.Error(), "aah application configuration does not exists")) -} - -func TestConfigTemplateFuncs(t *testing.T) { - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) - assert.Nil(t, err) - assert.NotNil(t, AppConfig()) - - v1 := tmplConfig("request.multipart_size") - assert.Equal(t, "32mb", v1.(string)) - - v2 := tmplConfig("server.timeout.grace_shutdown") - assert.Equal(t, "60s", v2.(string)) - - v3 := tmplConfig("key.not.exists") - assert.Equal(t, "", v3.(string)) -} - -func TestConfigHotReload(t *testing.T) { - SetAppBuildInfo(&BuildInfo{ - BinaryName: "testapp", - Date: time.Now().Format(time.RFC3339), - Version: "1.0.0", - }) - - assert.False(t, isHotReload) - appBaseDir = getTestdataPath() - hotReloadConfig() - appBaseDir = "" -} diff --git a/context.go b/context.go index 77b0353c..3c9bf50d 100644 --- a/context.go +++ b/context.go @@ -6,6 +6,7 @@ package aah import ( "errors" + "net/http" "net/url" "reflect" "strings" @@ -24,43 +25,51 @@ var ( errTargetNotFound = errors.New("target not found") ) -type ( - // Context type for aah framework, gets embedded in application controller. - // - // Note: this is not standard package `context.Context`. - Context struct { - // Req is HTTP request instance - Req *ahttp.Request - - // Res is HTTP response writer compliant. It is highly recommended to use - // `Reply()` builder for composing response. - // - // Note: If you're using `cxt.Res` directly, don't forget to call - // `Reply().Done()` so that framework will not intervene with your - // response. - Res ahttp.ResponseWriter - - controller *controllerInfo - action *MethodInfo - target interface{} - domain *router.Domain - route *router.Route - subject *security.Subject - reply *Reply - viewArgs map[string]interface{} - values map[string]interface{} - abort bool - decorated bool - } -) +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Context +//______________________________________________________________________________ + +// Context type for aah framework, gets embedded in application controller. +// +// Note: this is not standard package `context.Context`. +type Context struct { + // Req is HTTP request instance + Req *ahttp.Request -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Context methods -//___________________________________ + // Res is HTTP response writer compliant. + // + // Note 1: It is highly recommended to use `Reply()` builder for + // composing your response. + // + // Note 2: If you're using `cxt.Res` directly, don't forget to call + // `Reply().Done()`; so that aah will not intervene with your + // response. + Res ahttp.ResponseWriter + + a *app + e *engine + controller *controllerInfo + action *MethodInfo + actionrv reflect.Value + target interface{} + targetrv reflect.Value + domain *router.Domain + route *router.Route + subject *security.Subject + reply *Reply + viewArgs map[string]interface{} + values map[string]interface{} + abort bool + decorated bool + logger log.Loggerer +} // Reply method gives you control and convenient way to write // a response effectively. func (ctx *Context) Reply() *Reply { + if ctx.reply == nil { + ctx.reply = newReply(ctx) + } return ctx.reply } @@ -73,6 +82,9 @@ func (ctx *Context) ViewArgs() map[string]interface{} { // AddViewArg method adds given key and value into `viewArgs`. These view args // values accessible on templates. Chained call is possible. func (ctx *Context) AddViewArg(key string, value interface{}) *Context { + if ctx.viewArgs == nil { + ctx.viewArgs = make(map[string]interface{}) + } ctx.viewArgs[key] = value return ctx } @@ -80,24 +92,26 @@ func (ctx *Context) AddViewArg(key string, value interface{}) *Context { // ReverseURL method returns the URL for given route name and args. // See `Domain.ReverseURL` for more information. func (ctx *Context) ReverseURL(routeName string, args ...interface{}) string { - return createReverseURL(ctx.Req.Host, routeName, nil, args...) + domain, rn := ctx.a.findReverseURLDomain(ctx.Req.Host, routeName) + return createReverseURL(ctx.Log(), domain, rn, nil, args...) } // ReverseURLm method returns the URL for given route name and key-value paris. // See `Domain.ReverseURLm` for more information. func (ctx *Context) ReverseURLm(routeName string, args map[string]interface{}) string { - return createReverseURL(ctx.Req.Host, routeName, args) + domain, rn := ctx.a.findReverseURLDomain(ctx.Req.Host, routeName) + return createReverseURL(ctx.Log(), domain, rn, args) } // Msg method returns the i18n value for given key otherwise empty string returned. func (ctx *Context) Msg(key string, args ...interface{}) string { - return AppI18n().Lookup(ctx.Req.Locale, key, args...) + return ctx.Msgl(ctx.Req.Locale(), key, args...) } // Msgl method returns the i18n value for given local and key otherwise // empty string returned. func (ctx *Context) Msgl(locale *ahttp.Locale, key string, args ...interface{}) string { - return AppI18n().Lookup(locale, key, args...) + return ctx.a.I18n().Lookup(locale, key, args...) } // Subdomain method returns the subdomain from the incoming request if available @@ -113,6 +127,9 @@ func (ctx *Context) Subdomain() string { // Subject method the subject (aka application user) of current request. func (ctx *Context) Subject() *security.Subject { + if ctx.subject == nil { + ctx.subject = security.AcquireSubject() + } return ctx.subject } @@ -120,8 +137,8 @@ func (ctx *Context) Subject() *security.Subject { // to identify whether sesison is newly created or restored from the request // which was already created. func (ctx *Context) Session() *session.Session { - if ctx.subject.Session == nil { - ctx.subject.Session = AppSessionManager().NewSession() + if ctx.Subject().Session == nil { + ctx.subject.Session = ctx.a.SessionManager().NewSession() } return ctx.subject.Session } @@ -199,24 +216,30 @@ func (ctx *Context) SetMethod(method string) { } // Reset method resets context instance for reuse. -func (ctx *Context) Reset() { +func (ctx *Context) reset() { ctx.Req = nil ctx.Res = nil ctx.controller = nil ctx.action = nil + ctx.actionrv = reflect.Value{} ctx.target = nil + ctx.targetrv = reflect.Value{} ctx.domain = nil ctx.route = nil ctx.subject = nil ctx.reply = nil - ctx.viewArgs = make(map[string]interface{}) - ctx.values = make(map[string]interface{}) + ctx.viewArgs = nil + ctx.values = nil ctx.abort = false ctx.decorated = false + ctx.logger = nil } // Set method is used to set value for the given key in the current request flow. func (ctx *Context) Set(key string, value interface{}) { + if ctx.values == nil { + ctx.values = make(map[string]interface{}) + } ctx.values[key] = value } @@ -225,94 +248,173 @@ func (ctx *Context) Get(key string) interface{} { return ctx.values[key] } -// Log method addeds `Request ID`, `Primary Principal` into current log entry. +// Log method addeds field `Request ID` into current log context and returns +// the logger. func (ctx *Context) Log() log.Loggerer { - fields := log.Fields{"reqid": ctx.Req.Header.Get(appReqIDHdrKey)} - if ctx.Subject().AuthenticationInfo != nil { - fields["principal"] = ctx.Subject().PrimaryPrincipal().Value + if ctx.logger == nil { + ctx.logger = ctx.a.Log().WithFields(log.Fields{ + "reqid": ctx.Req.Header.Get(ctx.a.requestIDHeaderKey), + }) } - return log.WithFields(fields) + return ctx.logger } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Context Unexported methods -//___________________________________ +//______________________________________________________________________________ + +func (ctx *Context) setRequestID() { + reqID := ctx.Req.Header.Get(ctx.a.requestIDHeaderKey) + if reqID == "" { + guid := ess.NewGUID() + ctx.Req.Header.Set(ctx.a.requestIDHeaderKey, guid) + ctx.Reply().Header(ctx.a.requestIDHeaderKey, guid) + return + } + ctx.Log().Debugf("Request already has traceability ID: %v", reqID) +} // setTarget method sets contoller, action, embedded context into // controller. func (ctx *Context) setTarget(route *router.Route) error { - controller := cRegistry.Lookup(route) - if controller == nil { + if ctx.controller = ctx.e.cregistry.Lookup(route); ctx.controller == nil { return errTargetNotFound } - ctx.controller = controller - ctx.action = controller.FindMethod(route.Action) - if ctx.action == nil { + if ctx.action = ctx.controller.Lookup(route.Action); ctx.action == nil { return errTargetNotFound } - targetPtr := reflect.New(controller.Type) - target := targetPtr.Elem() - ctxv := reflect.ValueOf(ctx) - for _, index := range controller.EmbeddedIndexes { - target.FieldByIndex(index).Set(ctxv) + target := reflect.New(ctx.controller.Type) + + // check action method exists or not + ctx.actionrv = reflect.ValueOf(target.Interface()).MethodByName(ctx.action.Name) + if !ctx.actionrv.IsValid() { + return errTargetNotFound } - ctx.target = targetPtr.Interface() + targetElem := target.Elem() + ctxrv := reflect.ValueOf(ctx) + for _, index := range ctx.controller.EmbeddedIndexes { + targetElem.FieldByIndex(index).Set(ctxrv) + } + + ctx.target = target.Interface() + ctx.targetrv = reflect.ValueOf(ctx.target) return nil } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -// findEmbeddedContext method does breadth-first search on struct anonymous -// field to find `aah.Context` index positions. -func findEmbeddedContext(controllerType reflect.Type) [][]int { - var indexes [][]int - type nodeType struct { - val reflect.Value - index []int +func (ctx *Context) detectContentType() *ahttp.ContentType { + // based on HTTP Header 'Accept' + acceptContType := ctx.Req.AcceptContentType() + if acceptContType.Mime == "" || acceptContType.Mime == "*/*" { + // as per 'render.default' from aah.conf + return ctx.a.defaultContentType } + return acceptContType +} - queue := []nodeType{{reflect.New(controllerType), []int{}}} +// wrapGzipWriter method writes respective header for gzip and wraps write into +// gzip writer. +func (ctx *Context) wrapGzipWriter() { + ctx.Res.Header().Add(ahttp.HeaderVary, ahttp.HeaderAcceptEncoding) + ctx.Res.Header().Add(ahttp.HeaderContentEncoding, gzipContentEncoding) + ctx.Res.Header().Del(ahttp.HeaderContentLength) + ctx.Res = ahttp.WrapGzipWriter(ctx.Res) +} - for len(queue) > 0 { - var ( - node = queue[0] - elem = node.val - elemType = elem.Type() - ) +// writeCookies method writes the user provided cookies and session cookie; also +// saves the session data into session store if its stateful. +func (ctx *Context) writeCookies() { + for _, c := range ctx.Reply().cookies { + http.SetCookie(ctx.Res, c) + } - if elemType.Kind() == reflect.Ptr { - elem = elem.Elem() - elemType = elem.Type() + if ctx.a.SessionManager().IsStateful() && ctx.subject != nil && ctx.subject.Session != nil { + if err := ctx.a.SessionManager().SaveSession(ctx.Res, ctx.subject.Session); err != nil { + ctx.Log().Error(err) } + } +} + +func (ctx *Context) writeHeaders() { + if ctx.a.serverHeaderEnabled { + ctx.Res.Header().Set(ahttp.HeaderServer, ctx.a.serverHeader) + } - queue = queue[1:] - if elemType.Kind() != reflect.Struct { - continue + // Write application security headers with many safe defaults and + // configured header values. + if ctx.a.secureHeadersEnabled { + secureHeaders := ctx.a.SecurityManager().SecureHeaders + // Write common secure headers for all request + for header, value := range secureHeaders.Common { + ctx.Res.Header().Set(header, value) } - for i := 0; i < elem.NumField(); i++ { - // skip non-anonymous fields - field := elemType.Field(i) - if !field.Anonymous { - continue + // Applied to all HTML Content-Type + if ctx.Reply().isHTML() { + // X-XSS-Protection + ctx.Res.Header().Set(ahttp.HeaderXXSSProtection, secureHeaders.XSSFilter) + + // Content-Security-Policy (CSP) and applied only to environment `prod` + if ctx.a.IsProfileProd() && !ess.IsStrEmpty(secureHeaders.CSP) { + if secureHeaders.CSPReportOnly { + ctx.Res.Header().Set(ahttp.HeaderContentSecurityPolicy+"-Report-Only", secureHeaders.CSP) + } else { + ctx.Res.Header().Set(ahttp.HeaderContentSecurityPolicy, secureHeaders.CSP) + } } + } - // If it's a `aah.Context`, record the field indexes - if field.Type == ctxPtrType { - indexes = append(indexes, append(node.index, i)) - continue + // Apply only if HTTPS (SSL) + if ctx.a.IsSSLEnabled() { + // Strict-Transport-Security (STS, aka HSTS) + ctx.Res.Header().Set(ahttp.HeaderStrictTransportSecurity, secureHeaders.STS) + + // Public-Key-Pins PKP (aka HPKP) and applied only to environment `prod` + if ctx.a.IsProfileProd() && !ess.IsStrEmpty(secureHeaders.PKP) { + if secureHeaders.PKPReportOnly { + ctx.Res.Header().Set(ahttp.HeaderPublicKeyPins+"-Report-Only", secureHeaders.PKP) + } else { + ctx.Res.Header().Set(ahttp.HeaderPublicKeyPins, secureHeaders.PKP) + } } - - fieldValue := elem.Field(i) - queue = append(queue, - nodeType{fieldValue, append(append([]int{}, node.index...), i)}) } } +} + +// Render method renders and detects the errors earlier. Writes the +// error info if any. +func (ctx *Context) render() { + r := ctx.Reply() + if r.Rdr == nil { + return + } + + r.body = acquireBuffer() + if err := r.Rdr.Render(r.body); err != nil { + ctx.Log().Error("Render response body error: ", err) + + // panic would be appropriate here, since it handle by aah error + // handling mechanism. This is second spot in entire + // aah framework the `panic` used. + panic(ErrRenderResponse) + } +} - return indexes +// callAction method calls targed action method on the controller. +func (ctx *Context) callAction() { + // Parse Action Parameters + actionArgs, err := ctx.parseParameters() + if err != nil { // Any error of parameter parsing result in 400 Bad Request + ctx.Reply().Error(err) + return + } + + ctx.Log().Debugf("Calling controller: %s.%s", ctx.controller.FqName, ctx.action.Name) + if ctx.actionrv.Type().IsVariadic() { + ctx.actionrv.CallSlice(actionArgs) + } else { + ctx.actionrv.Call(actionArgs) + } } diff --git a/context_test.go b/context_test.go index 889e34d0..11d0c768 100644 --- a/context_test.go +++ b/context_test.go @@ -5,17 +5,15 @@ package aah import ( + "io/ioutil" "net/http/httptest" - "path/filepath" "reflect" - "strings" "testing" "aahframework.org/ahttp.v0" "aahframework.org/config.v0" "aahframework.org/log.v0" "aahframework.org/router.v0" - "aahframework.org/security.v0" "aahframework.org/test.v0/assert" ) @@ -47,105 +45,6 @@ type ( } ) -func TestContextReverseURL(t *testing.T) { - appCfg, _ := config.ParseString("") - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initRoutes(cfgDir, appCfg) - assert.Nil(t, err) - assert.NotNil(t, AppRouter()) - - ctx := &Context{ - Req: getAahRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", ""), - } - - reverseURL1 := ctx.ReverseURL("version_home", "v0.1") - assert.Equal(t, "//localhost:8080/doc/v0.1", reverseURL1) - - reverseURL2 := ctx.ReverseURLm("show_doc", map[string]interface{}{ - "version": "v0.2", - "content": "getting-started.html", - }) - assert.Equal(t, "//localhost:8080/doc/v0.2/getting-started.html", reverseURL2) - - reverseURL3 := ctx.ReverseURL("root.show_doc", "v0.2", "getting-started.html") - assert.Equal(t, "//localhost:8080/doc/v0.2/getting-started.html", reverseURL3) - - reverseURL4 := ctx.ReverseURL("root.host") - assert.Equal(t, "//localhost:8080", reverseURL4) - - ctx.Reset() -} - -func TestContextViewArgs(t *testing.T) { - ctx := &Context{viewArgs: make(map[string]interface{})} - - ctx.AddViewArg("key1", "key1 value") - assert.Equal(t, "key1 value", ctx.viewArgs["key1"]) - assert.Nil(t, ctx.viewArgs["notexists"]) -} - -func TestContextMsg(t *testing.T) { - i18nDir := filepath.Join(getTestdataPath(), appI18nDir()) - err := initI18n(i18nDir) - assert.Nil(t, err) - assert.NotNil(t, AppI18n()) - - ctx := &Context{ - Req: getAahRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", "en-us;q=0.0,en;q=0.7, da, en-gb;q=0.8"), - } - - msg := ctx.Msg("label.pages.site.get_involved.title") - assert.Equal(t, "en: Get Involved - aah web framework for Go", msg) - - msg = ctx.Msgl(ahttp.ToLocale(&ahttp.AcceptSpec{Value: "en", Raw: "en"}), "label.pages.site.get_involved.title") - assert.Equal(t, "en: Get Involved - aah web framework for Go", msg) - - ctx.Req = getAahRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", "en-us;q=0.0,en;q=0.7,en-gb;q=0.8") - msg = ctx.Msg("label.pages.site.get_involved.title") - assert.Equal(t, "en: Get Involved - aah web framework for Go", msg) - - ctx.Reset() -} - -func TestContextSetTarget(t *testing.T) { - addToCRegistry() - - ctx := &Context{} - - err1 := ctx.setTarget(&router.Route{Controller: "Level3", Action: "Testing"}) - assert.Nil(t, err1) - assert.Equal(t, "Level3", ctx.controller.Name()) - assert.True(t, strings.HasPrefix(ctx.controller.Namespace, "ahframework.org/aah.v0")) - assert.NotNil(t, ctx.action) - assert.Equal(t, "Testing", ctx.action.Name) - assert.NotNil(t, ctx.action.Parameters) - assert.Equal(t, "userId", ctx.action.Parameters[0].Name) - - ctx.controller.Namespace = "" - assert.Equal(t, "Level3", resolveControllerName(ctx)) - - err2 := ctx.setTarget(&router.Route{Controller: "NoController"}) - assert.Equal(t, errTargetNotFound, err2) - - err3 := ctx.setTarget(&router.Route{Controller: "Level3", Action: "NoAction"}) - assert.Equal(t, errTargetNotFound, err3) -} - -func TestContextSession(t *testing.T) { - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) - assert.Nil(t, err) - - err = initSecurity(AppConfig()) - assert.Nil(t, err) - - ctx := &Context{viewArgs: make(map[string]interface{}), subject: &security.Subject{}} - s1 := ctx.Session() - assert.NotNil(t, s1) - assert.True(t, s1.IsNew) - assert.NotNil(t, s1.ID) -} - func TestContextSubdomain(t *testing.T) { testSubdomainValue(t, "username1.sample.com", "username1", true) @@ -156,49 +55,27 @@ func TestContextSubdomain(t *testing.T) { testSubdomainValue(t, "sample.com", "", false) } -func TestContextAbort(t *testing.T) { - ctx := &Context{} - - assert.False(t, ctx.abort) - ctx.Abort() - assert.True(t, ctx.abort) -} - -func TestContentIsStaticRoute(t *testing.T) { - ctx1 := &Context{} - assert.False(t, ctx1.IsStaticRoute()) - - ctx2 := &Context{ - route: &router.Route{ - IsStatic: true, - }, +func testSubdomainValue(t *testing.T, host, subdomain string, isSubdomain bool) { + ctx := &Context{ + Req: &ahttp.Request{Host: host}, + domain: &router.Domain{IsSubDomain: isSubdomain}, } - assert.True(t, ctx2.IsStaticRoute()) -} -func TestContextNil(t *testing.T) { - ctx := &Context{} - - assert.Nil(t, ctx.Reply()) - assert.Nil(t, ctx.ViewArgs()) + assert.Equal(t, subdomain, ctx.Subdomain()) } -func TestContextEmbeddedAndController(t *testing.T) { - addToCRegistry() +func TestContextSetURL(t *testing.T) { + a := newApp() + cfg, _ := config.ParseString("") + a.cfg = cfg + err := a.initLog() + assert.Nil(t, err) - testEmbeddedIndexes(t, Level1{}, [][]int{{0}}) - testEmbeddedIndexes(t, Level2{}, [][]int{{0, 0}}) - testEmbeddedIndexes(t, Level3{}, [][]int{{0, 0, 0}}) - testEmbeddedIndexes(t, Level4{}, [][]int{{0, 0, 0, 0}}) - testEmbeddedIndexes(t, Path1{}, [][]int{{1}}) - testEmbeddedIndexes(t, Path2{}, [][]int{{0, 0}, {1, 1}, {2, 0, 0, 0, 0}}) -} + a.Log().(*log.Logger).SetWriter(ioutil.Discard) -func TestContextSetURL(t *testing.T) { - ctx := &Context{ - Req: getAahRequest("POST", "http://localhost:8080/users/edit", ""), - subject: security.AcquireSubject(), - } + req := httptest.NewRequest("POST", "http://localhost:8080/users/edit", nil) + ctx := newContext(nil, req) + ctx.a = a assert.Equal(t, "localhost:8080", ctx.Req.Host) assert.Equal(t, "POST", ctx.Req.Method) @@ -212,8 +89,6 @@ func TestContextSetURL(t *testing.T) { // now it affects ctx.decorated = true - cfg, _ := config.ParseString("") - appLogger, _ = log.New(cfg) ctx.SetURL("http://status.localhost:8080/maintenance") assert.True(t, ctx.decorated) assert.Equal(t, "status.localhost:8080", ctx.Req.Host) @@ -228,10 +103,17 @@ func TestContextSetURL(t *testing.T) { } func TestContextSetMethod(t *testing.T) { - ctx := &Context{ - Req: getAahRequest("POST", "http://localhost:8080/users/edit", ""), - subject: security.AcquireSubject(), - } + a := newApp() + cfg, _ := config.ParseString("") + a.cfg = cfg + err := a.initLog() + assert.Nil(t, err) + + a.Log().(*log.Logger).SetWriter(ioutil.Discard) + + req := httptest.NewRequest("POST", "http://localhost:8080/users/edit", nil) + ctx := newContext(nil, req) + ctx.a = a assert.Equal(t, "localhost:8080", ctx.Req.Host) assert.Equal(t, "POST", ctx.Req.Method) @@ -244,8 +126,6 @@ func TestContextSetMethod(t *testing.T) { // now it affects ctx.decorated = true - cfg, _ := config.ParseString("") - appLogger, _ = log.New(cfg) ctx.SetMethod("get") assert.Equal(t, "GET", ctx.Req.Method) assert.Equal(t, "localhost:8080", ctx.Req.Host) // no change expected @@ -257,29 +137,22 @@ func TestContextSetMethod(t *testing.T) { assert.Equal(t, "GET", ctx.Req.Method) } -func testEmbeddedIndexes(t *testing.T, c interface{}, expected [][]int) { - actual := findEmbeddedContext(reflect.TypeOf(c)) - if !reflect.DeepEqual(expected, actual) { - t.Errorf("Indexes do not match. expected %v actual %v", expected, actual) - } -} - -func addToCRegistry() { - cRegistry = controllerRegistry{} +func TestContextEmbeddedAndController(t *testing.T) { + a := newApp() - AddController((*Level1)(nil), []*MethodInfo{ + a.AddController((*Level1)(nil), []*MethodInfo{ { Name: "Index", Parameters: []*ParameterInfo{}, }, }) - AddController((*Level2)(nil), []*MethodInfo{ + a.AddController((*Level2)(nil), []*MethodInfo{ { Name: "Scope", Parameters: []*ParameterInfo{}, }, }) - AddController((*Level3)(nil), []*MethodInfo{ + a.AddController((*Level3)(nil), []*MethodInfo{ { Name: "Testing", Parameters: []*ParameterInfo{ @@ -290,26 +163,21 @@ func addToCRegistry() { }, }, }) - AddController((*Level4)(nil), nil) - AddController((*Path1)(nil), nil) - AddController((*Path2)(nil), nil) -} + a.AddController((*Level4)(nil), nil) + a.AddController((*Path1)(nil), nil) + a.AddController((*Path2)(nil), nil) -func testSubdomainValue(t *testing.T, host, subdomain string, isSubdomain bool) { - ctx := &Context{ - Req: &ahttp.Request{Host: host}, - domain: &router.Domain{IsSubDomain: isSubdomain}, - } - - assert.Equal(t, subdomain, ctx.Subdomain()) -} - -func getAahRequest(method, target, al string) *ahttp.Request { - rawReq := httptest.NewRequest(method, target, nil) - rawReq.Header.Add(ahttp.HeaderAcceptLanguage, al) - return ahttp.AcquireRequest(rawReq) + testEmbeddedIndexes(t, Level1{}, [][]int{{0}}) + testEmbeddedIndexes(t, Level2{}, [][]int{{0, 0}}) + testEmbeddedIndexes(t, Level3{}, [][]int{{0, 0, 0}}) + testEmbeddedIndexes(t, Level4{}, [][]int{{0, 0, 0, 0}}) + testEmbeddedIndexes(t, Path1{}, [][]int{{1}}) + testEmbeddedIndexes(t, Path2{}, [][]int{{0, 0}, {1, 1}, {2, 0, 0, 0, 0}}) } -func getTestdataPath() string { - return filepath.Join(getWorkingDir(), "testdata") +func testEmbeddedIndexes(t *testing.T, c interface{}, expected [][]int) { + actual := findEmbeddedContext(reflect.TypeOf(c)) + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Indexes do not match. expected %v actual %v", expected, actual) + } } diff --git a/controller.go b/controller.go index 7947b68a..2ffe5f69 100644 --- a/controller.go +++ b/controller.go @@ -9,14 +9,10 @@ import ( "reflect" "strings" - "aahframework.org/essentials.v0" "aahframework.org/router.v0" ) const ( - controllerNameSuffix = "Controller" - controllerNameSuffixLen = len(controllerNameSuffix) - // Interceptor Action Name incpBeforeActionName = "Before" incpAfterActionName = "After" @@ -25,45 +21,17 @@ const ( ) var ( - cRegistry = make(controllerRegistry) - emptyArg = make([]reflect.Value, 0) + emptyArg = make([]reflect.Value, 0) ) -type ( - // ControllerInfo holds all application controller - controllerRegistry map[string]*controllerInfo - - // ControllerInfo holds information of single controller information. - controllerInfo struct { - Type reflect.Type - Namespace string - Methods map[string]*MethodInfo - EmbeddedIndexes [][]int - } - - // MethodInfo holds information of single method information in the controller. - MethodInfo struct { - Name string - Parameters []*ParameterInfo - } - - // ParameterInfo holds information of single parameter in the method. - ParameterInfo struct { - Name string - Type reflect.Type - kind reflect.Kind - } -) +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ - -// AddController method adds given controller into controller registory. -// with "dereferenced" a.k.a "indirecting". -func AddController(c interface{}, methods []*MethodInfo) { +func (a *app) AddController(c interface{}, methods []*MethodInfo) { cType := actualType(c) + // Method Info methodMapping := map[string]*MethodInfo{} for _, method := range methods { for _, param := range method.Parameters { @@ -73,18 +41,41 @@ func AddController(c interface{}, methods []*MethodInfo) { methodMapping[strings.ToLower(method.Name)] = method } + // Controller Info key, namespace := createRegistryKeyAndNamespace(cType) - cRegistry[key] = &controllerInfo{ + controllerInfo := &controllerInfo{ + Name: cType.Name(), Type: cType, Namespace: namespace, Methods: methodMapping, EmbeddedIndexes: findEmbeddedContext(cType), } + + // Fully qualified name + controllerInfo.FqName = path.Join(controllerInfo.Namespace, controllerInfo.Name) + + // No suffix name which maps to controller name towards directory + // name by convention. + // For e.g.: UserController, User ==> User + noSuffixName := controllerInfo.Name + if strings.HasSuffix(noSuffixName, "Controller") { + noSuffixName = noSuffixName[:len(noSuffixName)-len("Controller")] + } + controllerInfo.NoSuffixName = noSuffixName + + a.engine.cregistry.Add(key, controllerInfo) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// ControllerRegistry methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// ControllerRegistry +//______________________________________________________________________________ + +// ControllerRegistry struct holds all application controller and related methods. +type controllerRegistry map[string]*controllerInfo + +func (cr controllerRegistry) Add(key string, ci *controllerInfo) { + cr[key] = ci +} // Lookup method returns `controllerInfo` if given route controller and // action exists in the controller registory. @@ -95,7 +86,7 @@ func (cr controllerRegistry) Lookup(route *router.Route) *controllerInfo { for _, ci := range cr { // match exact character case - if strings.HasSuffix(route.Controller, ci.Name()) { + if strings.HasSuffix(route.Controller, ci.Name) { return ci } } @@ -103,48 +94,40 @@ func (cr controllerRegistry) Lookup(route *router.Route) *controllerInfo { return nil } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// ControllerInfo methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// ControllerInfo +//______________________________________________________________________________ + +// ControllerInfo holds information of single controller information. +type controllerInfo struct { + Name string + FqName string + NoSuffixName string + Type reflect.Type + Namespace string + Methods map[string]*MethodInfo + EmbeddedIndexes [][]int +} + +// MethodInfo holds information of single method information in the controller. +type MethodInfo struct { + Name string + Parameters []*ParameterInfo +} -// Name method returns name of the controller. -func (ci *controllerInfo) Name() string { - return ci.Type.Name() +// ParameterInfo holds information of single parameter in the method. +type ParameterInfo struct { + Name string + Type reflect.Type + kind reflect.Kind } -// FindMethod method returns the `aah.MethodInfo` by given name +// Lookup method returns the `aah.MethodInfo` by given name // (case insensitive) otherwise nil. -func (ci *controllerInfo) FindMethod(name string) *MethodInfo { +func (ci *controllerInfo) Lookup(name string) *MethodInfo { if method, found := ci.Methods[strings.ToLower(name)]; found { return method } return nil } - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -func actualType(v interface{}) reflect.Type { - vt := reflect.TypeOf(v) - if vt.Kind() == reflect.Ptr { - vt = vt.Elem() - } - - return vt -} - -// createRegistryKeyAndNamespace method creates the controller registry key. -func createRegistryKeyAndNamespace(cType reflect.Type) (string, string) { - namespace := cType.PkgPath() - if idx := strings.Index(namespace, "controllers"); idx > -1 { - namespace = namespace[idx+11:] - } - - if ess.IsStrEmpty(namespace) { - return strings.ToLower(cType.Name()), "" - } - - return strings.ToLower(path.Join(namespace[1:], cType.Name())), namespace[1:] -} diff --git a/controller_test.go b/controller_test.go index d84c33eb..1f010998 100644 --- a/controller_test.go +++ b/controller_test.go @@ -7,7 +7,6 @@ package aah import ( "testing" - "aahframework.org/router.v0" "aahframework.org/test.v0/assert" ) @@ -21,29 +20,3 @@ func TestActualType(t *testing.T) { ct = actualType(&engine{}) assert.Equal(t, "aah.engine", ct.String()) } - -func TestCRegistryLookup(t *testing.T) { - addToCRegistry() - - ci := cRegistry.Lookup(&router.Route{Controller: "Path1"}) - assert.NotNil(t, ci) - assert.Equal(t, "Path1", ci.Name()) - - ci = cRegistry.Lookup(&router.Route{Controller: "ControllerNotExists"}) - assert.Nil(t, ci) -} - -func TestFindMethodController(t *testing.T) { - addToCRegistry() - - ci := cRegistry.Lookup(&router.Route{Controller: "Level3"}) - assert.NotNil(t, ci) - mi := ci.FindMethod("Testing") - assert.NotNil(t, mi) - assert.Equal(t, "Testing", mi.Name) - - ci = cRegistry.Lookup(&router.Route{Controller: "Path1"}) - assert.NotNil(t, ci) - mi = ci.FindMethod("NoMethodExists") - assert.Nil(t, mi) -} diff --git a/default.go b/default.go new file mode 100644 index 00000000..53fc074f --- /dev/null +++ b/default.go @@ -0,0 +1,400 @@ +// Copyright (c) Jeevanandam M. (https://github.com/jeevatkm) +// go-aah/aah source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package aah + +import ( + "crypto/tls" + "html/template" + + "aahframework.org/config.v0" + "aahframework.org/i18n.v0" + "aahframework.org/log.v0" + "aahframework.org/router.v0" + "aahframework.org/security.v0" + "aahframework.org/security.v0/session" + "aahframework.org/view.v0" +) + +var defaultApp = newApp() + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// App Info methods +//______________________________________________________________________________ + +// AppName method returns aah application name from app config `name` otherwise +// app name of the base directory. +func AppName() string { + return defaultApp.Name() +} + +// AppInstanceName method returns aah application instane name from app config +// `instance_name` otherwise empty string. +func AppInstanceName() string { + return defaultApp.InstanceName() +} + +// AppDesc method returns aah application friendly description from app config +// otherwise empty string. +func AppDesc() string { + return defaultApp.Desc() +} + +// AppProfile returns aah application configuration profile name +// For e.g.: dev, prod, etc. Default is `dev` +func AppProfile() string { + return defaultApp.Profile() +} + +// AppBaseDir method returns the application base or binary current directory +// For e.g.: +// $GOPATH/src/github.com/user/myproject +// +func AppBaseDir() string { + return defaultApp.BaseDir() +} + +// AppImportPath method returns the application Go import path. +func AppImportPath() string { + return defaultApp.ImportPath() +} + +// AppHTTPAddress method returns aah application HTTP address otherwise empty string +func AppHTTPAddress() string { + return defaultApp.HTTPAddress() +} + +// AppHTTPPort method returns aah application HTTP port number based on `server.port` +// value. Possible outcomes are user-defined port, `80`, `443` and `8080`. +func AppHTTPPort() string { + return defaultApp.HTTPPort() +} + +// AppBuildInfo method return user application version no. +func AppBuildInfo() *BuildInfo { + return defaultApp.BuildInfo() +} + +// AllAppProfiles method returns all the aah application environment profile names. +func AllAppProfiles() []string { + return defaultApp.AllProfiles() +} + +// AppIsSSLEnabled method returns true if aah application is enabled with SSL +// otherwise false. +func AppIsSSLEnabled() bool { + return defaultApp.IsSSLEnabled() +} + +// AppSSLCert method returns SSL cert filpath if its configured in aah.conf +// otherwise empty string. +func AppSSLCert() string { + return defaultApp.sslCert +} + +// AppSSLKey method returns SSL key filepath if its configured in aah.conf +// otherwise empty string. +func AppSSLKey() string { + return defaultApp.sslKey +} + +// SetAppProfile method sets given profile as current aah application profile. +// For Example: +// +// aah.SetAppProfile("prod") +func SetAppProfile(profile string) error { + return defaultApp.SetProfile(profile) +} + +// SetAppBuildInfo method sets the user application build info into aah instance. +func SetAppBuildInfo(bi *BuildInfo) { + defaultApp.SetBuildInfo(bi) +} + +// SetAppPackaged method sets the info of binary is packaged or not. +func SetAppPackaged(pack bool) { + defaultApp.SetPackaged(pack) +} + +// NewChildLogger method create a child logger from aah application default logger. +func NewChildLogger(fields log.Fields) log.Loggerer { + return defaultApp.NewChildLogger(fields) +} + +// Init method initializes `aah` application, if anything goes wrong during +// initialize process, it will log it as fatal msg and exit. +func Init(importPath string) error { + return defaultApp.Init(importPath) +} + +// AppLog method return the aah application logger instance. +func AppLog() log.Loggerer { + return defaultApp.Log() +} + +// AppDefaultI18nLang method returns aah application i18n default language if +// configured other framework defaults to "en". +func AppDefaultI18nLang() string { + return defaultApp.DefaultI18nLang() +} + +// AppI18n method returns aah application I18n store instance. +func AppI18n() *i18n.I18n { + return defaultApp.I18n() +} + +// AppI18nLocales returns all the loaded locales from i18n store +func AppI18nLocales() []string { + if defaultApp.I18n() == nil { + return []string{} + } + return defaultApp.I18n().Locales() +} + +// AddServerTLSConfig method can be used for custom TLS config for aah server. +// +// DEPRECATED: Use method `aah.SetTLSConfig` instead. Planned to be +// removed in `v1.0.0` release. +func AddServerTLSConfig(tlsCfg *tls.Config) { + // DEPRECATED, planned to be removed in v1.0 + defaultApp.Log().Warn("DEPRECATED: Method 'AddServerTLSConfig' deprecated in v0.9, use method 'SetTLSConfig' instead. Deprecated method will not break your functionality, its good to update to new method.") + + SetTLSConfig(tlsCfg) +} + +// SetTLSConfig method is used to set custom TLS config for aah server. +// Note: if `server.ssl.lets_encrypt.enable=true` then framework sets the +// `GetCertificate` from autocert manager. +// +// Use `aah.OnInit` or `func init() {...}` to assign your custom TLS Config. +func SetTLSConfig(tlsCfg *tls.Config) { + defaultApp.SetTLSConfig(tlsCfg) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// App module instance methods +//______________________________________________________________________________ + +// AppConfig method returns aah application configuration instance. +func AppConfig() *config.Config { + return defaultApp.Config() +} + +// AppRouter method returns aah application router instance. +func AppRouter() *router.Router { + return defaultApp.Router() +} + +// AppViewEngine method returns aah application view Engine instance. +func AppViewEngine() view.Enginer { + return defaultApp.ViewEngine() +} + +// AppSecurityManager method returns the application security instance, +// which manages the Session, CORS, CSRF, Security Headers, etc. +func AppSecurityManager() *security.Manager { + return defaultApp.SecurityManager() +} + +// AppSessionManager method returns the application session manager. +// By default session is stateless. +func AppSessionManager() *session.Manager { + return defaultApp.SessionManager() +} + +// AppEventStore method returns aah application event store. +func AppEventStore() *EventStore { + return defaultApp.EventStore() +} + +// AddController method adds given controller into controller registory. +// with "dereferenced" a.k.a "indirecting". +func AddController(c interface{}, methods []*MethodInfo) { + defaultApp.AddController(c, methods) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// App Start and Shutdown methods +//______________________________________________________________________________ + +// Start method starts the Go HTTP server based on aah config "server.*". +func Start() { + defaultApp.Start() +} + +// Shutdown method allows aah server to shutdown gracefully with given timeout +// in seconds. It's invoked on OS signal `SIGINT` and `SIGTERM`. +// +// Method performs: +// - Graceful server shutdown with timeout by `server.timeout.grace_shutdown` +// - Publishes `OnShutdown` event +// - Exits program with code 0 +func Shutdown() { + defaultApp.Shutdown() +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// App Error and middlewares +//______________________________________________________________________________ + +// SetErrorHandler method is used to register custom centralized application +// error handler. If custom handler is not then default error handler takes place. +func SetErrorHandler(handlerFunc ErrorHandlerFunc) { + defaultApp.errorMgr.SetHandler(handlerFunc) +} + +// Middlewares method adds given middleware into middleware stack +func Middlewares(middlewares ...MiddlewareFunc) { + defaultApp.engine.Middlewares(middlewares...) +} + +// AddLoggerHook method adds given logger into aah application default logger. +func AddLoggerHook(name string, hook log.HookFunc) error { + return defaultApp.AddLoggerHook(name, hook) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// App View methods +//______________________________________________________________________________ + +// AddTemplateFunc method adds template func map into view engine. +func AddTemplateFunc(funcs template.FuncMap) { + defaultApp.AddTemplateFunc(funcs) +} + +// AddViewEngine method adds the given name and view engine to view store. +func AddViewEngine(name string, engine view.Enginer) error { + return defaultApp.AddViewEngine(name, engine) +} + +// SetMinifier method sets the given minifier func into aah framework. +// Note: currently minifier is called only for HTML contentType. +func SetMinifier(fn MinifierFunc) { + defaultApp.SetMinifier(fn) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app event methods +//______________________________________________________________________________ + +// OnInit method is to subscribe to aah application `OnInit` event. `OnInit` +// event published right after the aah application configuration `aah.conf` +// initialized. +func OnInit(ecb EventCallbackFunc, priority ...int) { + defaultApp.OnInit(ecb, priority...) +} + +// OnStart method is to subscribe to aah application `OnStart` event. `OnStart` +// event pubished right before the aah server listen and serving request. +func OnStart(ecb EventCallbackFunc, priority ...int) { + defaultApp.OnStart(ecb, priority...) +} + +// OnShutdown method is to subscribe to aah application `OnShutdown` event. +// `OnShutdown` event pubished right before the aah server is stopped Listening +// and serving request. +func OnShutdown(ecb EventCallbackFunc, priority ...int) { + defaultApp.OnShutdown(ecb, priority...) +} + +// OnRequest method is to subscribe to aah server `OnRequest` extension point. +// `OnRequest` called for every incoming request. +// +// The `aah.Context` object passed to the extension functions is decorated with +// the `ctx.SetURL()` and `ctx.SetMethod()` methods. Calls to these methods will +// impact how the request is routed and can be used for rewrite rules. +// +// Route is not yet populated/evaluated at this point. +func OnRequest(sef EventCallbackFunc) { + defaultApp.OnRequest(sef) +} + +// OnPreReply method is to subscribe to aah server `OnPreReply` extension point. +// `OnPreReply` called for every reply from aah server. +// +// Except when +// 1) `Reply().Done()`, +// 2) `Reply().Redirect(...)` is called. +// Refer `aah.Reply.Done()` godoc for more info. +func OnPreReply(sef EventCallbackFunc) { + defaultApp.OnPreReply(sef) +} + +// OnAfterReply method is to subscribe to aah server `OnAfterReply` extension +// point. `OnAfterReply` called for every reply from aah server. +// +// Except when +// 1) `Reply().Done()`, +// 2) `Reply().Redirect(...)` is called. +// Refer `aah.Reply.Done()` godoc for more info. +func OnAfterReply(sef EventCallbackFunc) { + defaultApp.OnAfterReply(sef) +} + +// OnPreAuth method is to subscribe to aah application `OnPreAuth` event. +// `OnPreAuth` event pubished right before the aah server is authenticates & +// authorizes an incoming request. +func OnPreAuth(sef EventCallbackFunc) { + defaultApp.OnPreAuth(sef) +} + +// OnPostAuth method is to subscribe to aah application `OnPreAuth` event. +// `OnPostAuth` event pubished right after the aah server is authenticates & +// authorizes an incoming request. +func OnPostAuth(sef EventCallbackFunc) { + defaultApp.OnPostAuth(sef) +} + +// PublishEvent method publishes events to subscribed callbacks asynchronously. +// It means each subscribed callback executed via goroutine. +func PublishEvent(eventName string, data interface{}) { + defaultApp.PublishEvent(eventName, data) +} + +// PublishEventSync method publishes events to subscribed callbacks +// synchronously. +func PublishEventSync(eventName string, data interface{}) { + defaultApp.PublishEventSync(eventName, data) +} + +// SubscribeEvent method is to subscribe to new or existing event. +func SubscribeEvent(eventName string, ec EventCallback) { + defaultApp.SubscribeEvent(eventName, ec) +} + +// SubscribeEventFunc method is to subscribe to new or existing event +// by `EventCallbackFunc`. +func SubscribeEventFunc(eventName string, ecf EventCallbackFunc) { + defaultApp.SubscribeEventFunc(eventName, ecf) +} + +// SubscribeEventf method is to subscribe to new or existing event +// by `EventCallbackFunc`. +// +// DEPRECATED: use `SubscribeEventFunc` instead. Planned to be removed in +// `v1.0.0` release. +func SubscribeEventf(eventName string, ecf EventCallbackFunc) { + defaultApp.SubscribeEventf(eventName, ecf) +} + +// UnsubscribeEvent method is to unsubscribe by event name and `EventCallback` +// from app event store. +func UnsubscribeEvent(eventName string, ec EventCallback) { + defaultApp.UnsubscribeEvent(eventName, ec) +} + +// UnsubscribeEventFunc method is to unsubscribe by event name and +// `EventCallbackFunc` from app event store. +func UnsubscribeEventFunc(eventName string, ecf EventCallbackFunc) { + defaultApp.UnsubscribeEventFunc(eventName, ecf) +} + +// UnsubscribeEventf method is to unsubscribe by event name and +// `EventCallbackFunc` from app event store. +// +// DEPRECATED: use `UnsubscribeEventFunc` instead. Planned to be removed in +// `v1.0.0` release. +func UnsubscribeEventf(eventName string, ecf EventCallbackFunc) { + defaultApp.UnsubscribeEventf(eventName, ecf) +} diff --git a/default_test.go b/default_test.go new file mode 100644 index 00000000..0c305882 --- /dev/null +++ b/default_test.go @@ -0,0 +1,171 @@ +// Copyright (c) Jeevanandam M. (https://github.com/jeevatkm) +// go-aah/aah source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package aah + +import ( + "crypto/tls" + "html/template" + "io" + "io/ioutil" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "aahframework.org/log.v0" + "aahframework.org/test.v0/assert" + "aahframework.org/view.v0" +) + +func TestDefaultApp(t *testing.T) { + // Default App init + t.Log("Default App init") + importPath := filepath.Join(testdataBaseDir(), "webapp1") + SetAppBuildInfo(&BuildInfo{ + BinaryName: filepath.Base(importPath), + Date: time.Now().Format(time.RFC3339), + Version: "1.0.0", + }) + err := Init(importPath) + assert.Nil(t, err) + + AppLog().(*log.Logger).SetWriter(ioutil.Discard) + + // Default App information + assert.Equal(t, "webapp1", AppName()) + assert.Equal(t, "", AppInstanceName()) + assert.Equal(t, "aah framework web application", AppDesc()) + assert.Equal(t, "dev", AppProfile()) + assert.Equal(t, importPath, AppBaseDir()) + assert.Equal(t, importPath, AppImportPath()) // this is only for test scenario + assert.Equal(t, "", AppHTTPAddress()) + assert.Equal(t, "8080", AppHTTPPort()) + assert.False(t, AppIsSSLEnabled()) + assert.Equal(t, "webapp1", AppBuildInfo().BinaryName) + assert.Equal(t, "1.0.0", AppBuildInfo().Version) + assert.Equal(t, "", AppSSLCert()) + assert.Equal(t, "", AppSSLKey()) + assert.Equal(t, "en", AppDefaultI18nLang()) + assert.True(t, strings.Contains(strings.Join(AppI18nLocales(), ", "), "en-us")) + assert.True(t, strings.Contains(strings.Join(AllAppProfiles(), ", "), "prod")) + + // Default App module instances + assert.NotNil(t, AppI18n()) + assert.NotNil(t, AppLog()) + assert.NotNil(t, AppConfig()) + assert.NotNil(t, AppRouter()) + assert.NotNil(t, AppEventStore()) + assert.NotNil(t, AppViewEngine()) + assert.NotNil(t, AppSecurityManager()) + assert.NotNil(t, AppSessionManager()) + + // Default App Start and Shutdown + t.Log("Default App Start and Shutdown") + go Start() + time.Sleep(10 * time.Millisecond) + defer Shutdown() + + // Set default app to packaged + SetAppPackaged(true) + + // Child app logger + t.Log("Child app logger") + ll := NewChildLogger(log.Fields{"key1": "value1"}) + assert.NotNil(t, ll) + + // TLS config + t.Log("TLS Config") + AddServerTLSConfig(&tls.Config{}) + SetTLSConfig(&tls.Config{}) + + // Add controller + AddController(reflect.ValueOf(testSiteController{}), make([]*MethodInfo, 0)) + + SetErrorHandler(func(ctx *Context, e *Error) bool { + t.Log("Error hanlder") + return true + }) + + Middlewares(ToMiddleware(thirdPartyMiddleware1)) + + AddLoggerHook("testhook", func(e log.Entry) { + t.Log("test logger hook") + }) + + // View Part + AddTemplateFunc(template.FuncMap{ + "t1": func() string { return "t1 func" }, + }) + + AddViewEngine("go", new(view.GoViewEngine)) + + SetMinifier(func(contentType string, w io.Writer, r io.Reader) error { + t.Log("this is second set", contentType, w, r) + return nil + }) + + // Events part + OnInit(func(e *Event) { + t.Log("Application OnInit extension point") + }) + + OnStart(func(e *Event) { + t.Log("Application OnStart extension point") + }) + + OnShutdown(func(e *Event) { + t.Log("Application OnShutdown extension point") + }) + + OnRequest(func(e *Event) { + t.Log("Application OnRequest extension point") + }) + + OnPreReply(func(e *Event) { + t.Log("Application OnPreReply extension point") + }) + + OnAfterReply(func(e *Event) { + t.Log("Application OnAfterReply extension point") + }) + + OnPreAuth(func(e *Event) { + t.Log("Application OnPreAuth extension point") + }) + + OnPostAuth(func(e *Event) { + t.Log("Application OnPostAuth extension point") + }) + + eventFunc1 := func(e *Event) { + t.Log("custom-event-1") + } + SubscribeEvent("custom-event-1", EventCallback{Callback: eventFunc1}) + SubscribeEventf("custom-event-2", eventFunc1) + SubscribeEventFunc("custom-event-2", eventFunc1) + PublishEvent("custom-event-1", "event data 1") + PublishEventSync("custom-event-1", "event data 2") + UnsubscribeEventf("custom-event-1", eventFunc1) + UnsubscribeEventFunc("custom-event-2", eventFunc1) + UnsubscribeEvent("custom-event-1", EventCallback{Callback: eventFunc1}) + + // Set default app profile to prod + t.Log("Set default app profile to prod") + err = SetAppProfile("prod") + assert.Nil(t, err) + +} + +func TestHotAppReload(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Hot Reload]: %s", ts.URL) + + ts.app.hotReloadConfig() +} diff --git a/dump.go b/dump.go index 50d319f5..e63c69d6 100644 --- a/dump.go +++ b/dump.go @@ -13,7 +13,6 @@ import ( "net/http" "path/filepath" "reflect" - "sort" "strings" "aahframework.org/ahttp.v0" @@ -28,30 +27,18 @@ const ( keyAahResponseDumpBody = "_aahResponseDumpBody" ) -var ( - appDumpLog *log.Logger - dumpRequestBody bool - dumpResponseBody bool - isDumpLogEnabled bool -) - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -func initDumpLog(logsDir string, appCfg *config.Config) error { - isDumpLogEnabled = appCfg.BoolDefault("server.dump_log.enable", false) - if !isDumpLogEnabled { - return nil - } +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ +func (a *app) initDumpLog() error { // log file configuration cfg, _ := config.ParseString("") - file := appCfg.StringDefault("server.dump_log.file", "") + file := a.Config().StringDefault("server.dump_log.file", "") cfg.SetString("log.receiver", "file") if ess.IsStrEmpty(file) { - cfg.SetString("log.file", filepath.Join(logsDir, getBinaryFileName()+"-dump.log")) + cfg.SetString("log.file", filepath.Join(a.logsDir(), a.binaryFilename()+"-dump.log")) } else { abspath, err := filepath.Abs(file) if err != nil { @@ -62,19 +49,35 @@ func initDumpLog(logsDir string, appCfg *config.Config) error { cfg.SetString("log.pattern", "%message") - dumpRequestBody = appCfg.BoolDefault("server.dump_log.request_body", false) - dumpResponseBody = appCfg.BoolDefault("server.dump_log.response_body", false) - adLog, err := log.New(cfg) if err != nil { return err } - appDumpLog = adLog + a.dumpLog = &dumpLogger{ + a: a, + e: a.engine, + logger: adLog, + dumpRequestBody: a.Config().BoolDefault("server.dump_log.request_body", false), + dumpResponseBody: a.Config().BoolDefault("server.dump_log.response_body", false), + } + return nil } -func composeRequestDump(ctx *Context) string { +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// dumpLogger +//______________________________________________________________________________ + +type dumpLogger struct { + a *app + e *engine + logger *log.Logger + dumpRequestBody bool + dumpResponseBody bool +} + +func (d *dumpLogger) composeRequestDump(ctx *Context) string { buf := acquireBuffer() defer releaseBuffer(buf) @@ -88,13 +91,13 @@ func composeRequestDump(ctx *Context) string { buf.WriteString(fmt.Sprintf("METHOD: %s\n", ctx.Req.Method)) buf.WriteString(fmt.Sprintf("PROTO: %s\n", ctx.Req.Proto)) buf.WriteString("HEADERS:\n") - buf.WriteString(composeHeaders(ctx.Req.Header)) + buf.WriteString(d.composeHeaders(ctx.Req.Header)) - if dumpRequestBody { + if ctx.a.dumpLog.dumpRequestBody { if len(ctx.Req.Params.Form) > 0 { ctx.Set(keyAahRequestDumpBody, ctx.Req.Params.Form.Encode()) - } else if ahttp.ContentTypePlainText.IsEqual(ctx.Req.ContentType.Mime) || - ahttp.ContentTypeHTML.IsEqual(ctx.Req.ContentType.Mime) { + } else if ahttp.ContentTypePlainText.IsEqual(ctx.Req.ContentType().Mime) || + ahttp.ContentTypeHTML.IsEqual(ctx.Req.ContentType().Mime) { if b, err := ioutil.ReadAll(ctx.Req.Body()); err == nil { ctx.Set(keyAahRequestDumpBody, string(b)) ctx.Req.Unwrap().Body = ioutil.NopCloser(bytes.NewReader(b)) @@ -105,19 +108,19 @@ func composeRequestDump(ctx *Context) string { return buf.String() } -func composeResponseDump(ctx *Context) string { +func (d *dumpLogger) composeResponseDump(ctx *Context) string { buf := acquireBuffer() defer releaseBuffer(buf) buf.WriteString(fmt.Sprintf("STATUS: %d %s\n", ctx.Res.Status(), http.StatusText(ctx.Res.Status()))) buf.WriteString(fmt.Sprintf("BYTES WRITTEN: %d\n", ctx.Res.BytesWritten())) buf.WriteString("HEADERS:\n") - buf.WriteString(composeHeaders(ctx.Res.Header())) + buf.WriteString(d.composeHeaders(ctx.Res.Header())) return buf.String() } -func composeHeaders(hdrs http.Header) string { +func (d *dumpLogger) composeHeaders(hdrs http.Header) string { var str []string for _, k := range sortHeaderKeys(hdrs) { str = append(str, fmt.Sprintf(" %s: %s", k, strings.Join(hdrs[k], ", "))) @@ -125,17 +128,8 @@ func composeHeaders(hdrs http.Header) string { return strings.Join(str, "\n") } -func sortHeaderKeys(hdrs http.Header) []string { - keys := make([]string, 0, len(hdrs)) - for key := range hdrs { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func addReqBodyIntoCtx(ctx *Context, result reflect.Value) { - switch ctx.Req.ContentType.Mime { +func (d *dumpLogger) addReqBodyIntoCtx(ctx *Context, result reflect.Value) { + switch ctx.Req.ContentType().Mime { case ahttp.ContentTypeJSON.Mime, ahttp.ContentTypeJSONText.Mime: if b, err := json.MarshalIndent(result.Interface(), "", " "); err == nil { ctx.Set(keyAahRequestDumpBody, string(b)) @@ -147,27 +141,29 @@ func addReqBodyIntoCtx(ctx *Context, result reflect.Value) { } } -func addResBodyIntoCtx(ctx *Context) { +func (d *dumpLogger) addResBodyIntoCtx(ctx *Context) { ct := ctx.Reply().ContType - if ahttp.ContentTypeHTML.IsEqual(ct) || ahttp.ContentTypeJSON.IsEqual(ct) || ahttp.ContentTypeJSONText.IsEqual(ct) || ahttp.ContentTypeXML.IsEqual(ct) || - ahttp.ContentTypeXMLText.IsEqual(ct) || ahttp.ContentTypePlainText.IsEqual(ct) { + if ahttp.ContentTypeHTML.IsEqual(ct) || + ahttp.ContentTypeJSON.IsEqual(ct) || ahttp.ContentTypeJSONText.IsEqual(ct) || + ahttp.ContentTypeXML.IsEqual(ct) || ahttp.ContentTypeXMLText.IsEqual(ct) || + ahttp.ContentTypePlainText.IsEqual(ct) { ctx.Set(keyAahResponseDumpBody, ctx.Reply().Body().String()) } } -func dump(ctx *Context) { - appDumpLog.Print(ctx.Get(keyAahRequestDump)) - if dumpRequestBody && ctx.Get(keyAahRequestDumpBody) != nil { - appDumpLog.Printf("BODY:\n%v\n", ctx.Get(keyAahRequestDumpBody)) - } else { - appDumpLog.Println() +func (e *engine) dump(ctx *Context) { + dumpStr := fmt.Sprint(ctx.Get(keyAahRequestDump)) + "\n" + if e.a.dumpLog.dumpRequestBody && ctx.Get(keyAahRequestDumpBody) != nil { + dumpStr += "BODY:\n" + fmt.Sprint(ctx.Get(keyAahRequestDumpBody)) + "\n" } - appDumpLog.Print("-----------------------------------------------------------------------\n") - appDumpLog.Print(composeResponseDump(ctx)) - if dumpResponseBody && ctx.Get(keyAahResponseDumpBody) != nil { - appDumpLog.Printf("BODY:\n%v\n", ctx.Get(keyAahResponseDumpBody)) - } else { - appDumpLog.Println() + + dumpStr += "\n-----------------------------------------------------------------------\n\n" + + dumpStr += ctx.a.dumpLog.composeResponseDump(ctx) + "\n" + if e.a.dumpLog.dumpResponseBody && ctx.Get(keyAahResponseDumpBody) != nil { + dumpStr += "BODY:\n" + fmt.Sprint(ctx.Get(keyAahResponseDumpBody)) + "\n" } - appDumpLog.Print("=======================================================================") + dumpStr += "\n=======================================================================\n" + + e.a.dumpLog.logger.Print(dumpStr) } diff --git a/engine.go b/engine.go index 43de1c51..8e19acc8 100644 --- a/engine.go +++ b/engine.go @@ -6,7 +6,6 @@ package aah import ( "errors" - "fmt" "io" "net/http" "sync" @@ -14,8 +13,8 @@ import ( "aahframework.org/ahttp.v0" "aahframework.org/aruntime.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" + "aahframework.org/log.v0" "aahframework.org/security.v0" ) @@ -30,10 +29,6 @@ const ( var ( errFileNotFound = errors.New("file not found") - ctHTML = ahttp.ContentTypeHTML - - minifier MinifierFunc - ctxPool *sync.Pool ) type ( @@ -43,46 +38,140 @@ type ( // flowResult is result of engine activities flow. // For e.g.: route, authentication, authorization, etc. flowResult uint8 +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Engine +//______________________________________________________________________________ + +// Engine is the aah framework application server handler. +// +// Implements `http.Handler` interface. +type engine struct { + a *app + ctxPool *sync.Pool + mwStack []MiddlewareFunc + mwChain []*Middleware + cregistry controllerRegistry + + // server extensions + onRequestFunc EventCallbackFunc + onPreReplyFunc EventCallbackFunc + onAfterReplyFunc EventCallbackFunc + onPreAuthFunc EventCallbackFunc + onPostAuthFunc EventCallbackFunc +} + +func (e *engine) Log() log.Loggerer { + return e.a.logger +} - // Engine is the aah framework application server handler for request and response. - // Implements `http.Handler` interface. - engine struct { - isRequestIDEnabled bool - requestIDHeader string - isGzipEnabled bool - isAccessLogEnabled bool - isStaticAccessLogEnabled bool - isServerHeaderEnabled bool - serverHeader string +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Engine - Server Extensions +//______________________________________________________________________________ + +func (e *engine) OnRequest(sef EventCallbackFunc) { + if e.onRequestFunc != nil { + e.Log().Warnf("Changing 'OnRequest' server extension from '%s' to '%s'", + funcName(e.onRequestFunc), funcName(sef)) } -) + e.onRequestFunc = sef +} -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Engine methods -//___________________________________ +func (e *engine) OnPreReply(sef EventCallbackFunc) { + if e.onPreReplyFunc != nil { + e.Log().Warnf("Changing 'OnPreReply' server extension from '%s' to '%s'", + funcName(e.onPreReplyFunc), funcName(sef)) + } + e.onPreReplyFunc = sef +} + +func (e *engine) OnAfterReply(sef EventCallbackFunc) { + if e.onAfterReplyFunc != nil { + e.Log().Warnf("Changing 'OnAfterReply' server extension from '%s' to '%s'", + funcName(e.onAfterReplyFunc), funcName(sef)) + } + e.onAfterReplyFunc = sef +} + +func (e *engine) OnPreAuth(sef EventCallbackFunc) { + if e.onPreAuthFunc != nil { + e.Log().Warnf("Changing 'OnPreAuth' server extension from '%s' to '%s'", + funcName(e.onPreAuthFunc), funcName(sef)) + } + e.onPreAuthFunc = sef +} + +func (e *engine) OnPostAuth(sef EventCallbackFunc) { + if e.onPostAuthFunc != nil { + e.Log().Warnf("Changing 'OnPostAuth' server extension from '%s' to '%s'", + funcName(e.onPostAuthFunc), funcName(sef)) + } + e.onPostAuthFunc = sef +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Engine - Server Extension Publish +//______________________________________________________________________________ + +func (e *engine) publishOnRequestEvent(ctx *Context) { + if e.onRequestFunc != nil { + ctx.decorated = true + e.onRequestFunc(&Event{Name: EventOnRequest, Data: ctx}) + ctx.decorated = false + } +} + +func (e *engine) publishOnPreReplyEvent(ctx *Context) { + if e.onPreReplyFunc != nil { + e.onPreReplyFunc(&Event{Name: EventOnPreReply, Data: ctx}) + } +} + +func (e *engine) publishOnAfterReplyEvent(ctx *Context) { + if e.onAfterReplyFunc != nil { + e.onAfterReplyFunc(&Event{Name: EventOnAfterReply, Data: ctx}) + } +} + +func (e *engine) publishOnPreAuthEvent(ctx *Context) { + if e.onPreAuthFunc != nil { + e.onPreAuthFunc(&Event{Name: EventOnPreAuth, Data: ctx}) + } +} + +func (e *engine) publishOnPostAuthEvent(ctx *Context) { + if e.onPostAuthFunc != nil { + e.onPostAuthFunc(&Event{Name: EventOnPostAuth, Data: ctx}) + } +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Engine - HTTP Handler +//______________________________________________________________________________ // ServeHTTP method implementation of http.Handler interface. func (e *engine) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Capture the startTime earlier. - // This value is as accurate as could be. + // Capture the startTime earlier, so that value is as accurate. startTime := time.Now() - ctx := e.prepareContext(w, r) - ctx.Set(appReqStartTimeKey, startTime) - defer releaseContext(ctx) + ctx := e.ctxPool.Get().(*Context) + ctx.Req, ctx.Res = ahttp.AcquireRequest(r), ahttp.AcquireResponseWriter(w) + ctx.Set(reqStartTimeKey, startTime) + defer e.releaseContext(ctx) - // Recovery handling, capture every possible panic(s) + // Recovery handling, capture every possible panic's defer e.handleRecovery(ctx) - if e.isRequestIDEnabled { - e.setRequestID(ctx) + if e.a.requestIDEnabled { + ctx.setRequestID() } // 'OnRequest' server extension point - publishOnRequestEvent(ctx) + e.publishOnRequestEvent(ctx) // Middlewares, interceptors, targeted controller - if len(mwChain) == 0 { + if len(e.mwChain) == 0 { ctx.Log().Error("'init.go' file introduced in release v0.10; please check your 'app-base-dir/app' " + "and then add to your version control") ctx.Reply().Error(&Error{ @@ -90,30 +179,41 @@ func (e *engine) ServeHTTP(w http.ResponseWriter, r *http.Request) { Code: http.StatusInternalServerError, Message: http.StatusText(http.StatusInternalServerError), }) - goto wReply + } else { + e.mwChain[0].Next(ctx) } - mwChain[0].Next(ctx) -wReply: - // Write Reply on the wire e.writeReply(ctx) } +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Engine Unexported methods +//______________________________________________________________________________ + +func (e *engine) newContext() *Context { + return &Context{a: e.a, e: e} +} + // handleRecovery method handles application panics and recovers from it. // Panic gets translated into HTTP Internal Server Error (Status 500). func (e *engine) handleRecovery(ctx *Context) { if r := recover(); r != nil { ctx.Log().Errorf("Internal Server Error on %s", ctx.Req.Path) - st := aruntime.NewStacktrace(r, AppConfig()) + st := aruntime.NewStacktrace(r, e.a.Config()) buf := acquireBuffer() defer releaseBuffer(buf) st.Print(buf) ctx.Log().Error(buf.String()) + err := ErrPanicRecovery + if er, ok := r.(error); ok && er == ErrRenderResponse { + err = er + } + ctx.Reply().Error(&Error{ - Reason: ErrPanicRecovery, + Reason: err, Code: http.StatusInternalServerError, Message: http.StatusText(http.StatusInternalServerError), Data: r, @@ -123,247 +223,126 @@ func (e *engine) handleRecovery(ctx *Context) { } } -// setRequestID method sets the unique request id in the request header. -// It won't set new request id header already present. -func (e *engine) setRequestID(ctx *Context) { - if ess.IsStrEmpty(ctx.Req.Header.Get(e.requestIDHeader)) { - ctx.Req.Header.Set(e.requestIDHeader, ess.NewGUID()) - } else { - ctx.Log().Debugf("Request already has ID: %v", ctx.Req.Header.Get(e.requestIDHeader)) - } - ctx.Reply().Header(e.requestIDHeader, ctx.Req.Header.Get(e.requestIDHeader)) -} - -// prepareContext method gets controller, request from pool, set the targeted -// controller, parses the request and returns the controller. -func (e *engine) prepareContext(w http.ResponseWriter, r *http.Request) *Context { - ctx := acquireContext() - ctx.Req = ahttp.AcquireRequest(r) - ctx.Res = ahttp.AcquireResponseWriter(w) - ctx.reply = acquireReply() - ctx.subject = security.AcquireSubject() - return ctx -} - // writeReply method writes the response on the wire based on `Reply` instance. func (e *engine) writeReply(ctx *Context) { if ctx.Reply().err != nil { - handleError(ctx, ctx.Reply().err) + e.a.errorMgr.Handle(ctx) } - // Response already written on the wire, don't go forward. - // refer to `Reply().Done()` method. - if ctx.Reply().done { + // don't go forward, if: + // - Response already written on the wire, refer to method `Reply().Done()` + // - Static file route + if ctx.Reply().done || ctx.IsStaticRoute() { return } // 'OnPreReply' server extension point - publishOnPreReplyEvent(ctx) + e.publishOnPreReplyEvent(ctx) // HTTP headers - e.writeHeaders(ctx) + ctx.writeHeaders() // Set Cookies - e.setCookies(ctx) + ctx.writeCookies() - // reply := ctx.Reply() if ctx.Reply().redirect { // handle redirects ctx.Log().Debugf("Redirecting to '%s' with status '%d'", ctx.Reply().path, ctx.Reply().Code) http.Redirect(ctx.Res, ctx.Req.Unwrap(), ctx.Reply().path, ctx.Reply().Code) return } - // ContentType - if !ctx.Reply().IsContentTypeSet() { - if ct := identifyContentType(ctx); ct != nil { - ctx.Reply().ContentType(ct.String()) + bodyAllowed := bodyAllowedForStatus(ctx.Reply().Code) + if bodyAllowed { + // check ContentType + if ess.IsStrEmpty(ctx.Reply().ContType) { + ctx.Reply().ContentType(ctx.detectContentType().String()) } - } - // resolving view template - e.resolveView(ctx) + // resolving view template + if e.a.viewMgr != nil { + e.a.viewMgr.resolve(ctx) + } - // Render it and detect the errors earlier. So that framework can write - // error info without messing with response on the wire. - e.doRender(ctx) + // Render and detect the errors earlier. So that framework can write the + // error info without messing with response on the wire. + ctx.render() - isBodyAllowed := isResponseBodyAllowed(ctx.Reply().Code) - // Gzip, 1kb above TODO make it configurable from aah.conf - if isBodyAllowed && ctx.Reply().body.Len() > 1024 { - e.wrapGzipWriter(ctx) + // Gzip, 1kb above TODO make it configurable from aah.conf + if e.a.gzipEnabled && ctx.Req.IsGzipAccepted && + ctx.Reply().gzip && ctx.Reply().Body() != nil && ctx.Reply().Body().Len() > 1024 { + ctx.wrapGzipWriter() + } } - // ContentType, if it's not set then auto detect later in the writer - if ctx.Reply().IsContentTypeSet() { - ctx.Res.Header().Set(ahttp.HeaderContentType, ctx.Reply().ContType) - } + // HTTP ContentType + ctx.Res.Header().Set(ahttp.HeaderContentType, ctx.Reply().ContType) - // HTTP status + // HTTP Status ctx.Res.WriteHeader(ctx.Reply().Code) // Write response on the wire - if isBodyAllowed { + if bodyAllowed { e.writeBody(ctx) + } // 'OnAfterReply' server extension point - publishOnAfterReplyEvent(ctx) + e.publishOnAfterReplyEvent(ctx) // Send data to access log channel - if e.isAccessLogEnabled { - sendToAccessLog(ctx) + if e.a.accessLogEnabled { + e.sendToAccessLog(ctx) } // Dump request and response - if isDumpLogEnabled { - dump(ctx) - } -} - -// wrapGzipWriter method writes respective header for gzip and wraps write into -// gzip writer. -func (e *engine) wrapGzipWriter(ctx *Context) { - if ctx.Req.IsGzipAccepted && e.isGzipEnabled && ctx.Reply().gzip { - ctx.Res.Header().Add(ahttp.HeaderVary, ahttp.HeaderAcceptEncoding) - ctx.Res.Header().Add(ahttp.HeaderContentEncoding, gzipContentEncoding) - ctx.Res.Header().Del(ahttp.HeaderContentLength) - ctx.Res = ahttp.WrapGzipWriter(ctx.Res) + if e.a.dumpLogEnabled { + e.dump(ctx) } } -// writeHeaders method writes the headers on the wire. -func (e *engine) writeHeaders(ctx *Context) { - for k, v := range ctx.Reply().Hdr { - for _, vv := range v { - ctx.Res.Header().Add(k, vv) - } - } - - if e.isServerHeaderEnabled { - ctx.Res.Header().Set(ahttp.HeaderServer, e.serverHeader) - } - - // Write application security headers with many safe defaults and - // configured header values. - secureHeaders := AppSecurityManager().SecureHeaders - - // Write common secure headers for all request - for header, value := range secureHeaders.Common { - ctx.Res.Header().Set(header, value) - } - - // Applied to all HTML Content-Type - if ahttp.ContentTypeHTML.IsEqual(ctx.Reply().ContType) { - // X-XSS-Protection - ctx.Res.Header().Set(ahttp.HeaderXXSSProtection, secureHeaders.XSSFilter) - - // Content-Security-Policy (CSP) and applied only to environment `prod` - if appIsProfileProd && len(secureHeaders.CSP) > 0 { - if secureHeaders.CSPReportOnly { - ctx.Res.Header().Set(ahttp.HeaderContentSecurityPolicy+"-Report-Only", secureHeaders.CSP) - } else { - ctx.Res.Header().Set(ahttp.HeaderContentSecurityPolicy, secureHeaders.CSP) - } - } - } - - // Apply only if HTTPS (SSL) - if AppIsSSLEnabled() { - // Public-Key-Pins PKP (aka HPKP) and applied only to environment `prod` - if appIsProfileProd && len(secureHeaders.PKP) > 0 { - if secureHeaders.PKPReportOnly { - ctx.Res.Header().Set(ahttp.HeaderPublicKeyPins+"-Report-Only", secureHeaders.PKP) - } else { - ctx.Res.Header().Set(ahttp.HeaderPublicKeyPins, secureHeaders.PKP) - } - } - - // Strict-Transport-Security (STS, aka HSTS) - ctx.Res.Header().Set(ahttp.HeaderStrictTransportSecurity, secureHeaders.STS) - } -} - -// setCookies method sets the user cookies, session cookie and saves session -// into session store is session mode is stateful. -func (e *engine) setCookies(ctx *Context) { - for _, c := range ctx.Reply().cookies { - http.SetCookie(ctx.Res, c) +func (e *engine) writeBody(ctx *Context) { + if e.a.dumpLogEnabled && e.a.dumpLog.dumpResponseBody { + ctx.a.dumpLog.addResBodyIntoCtx(ctx) } - if AppSessionManager().IsStateful() && ctx.subject.Session != nil { - if err := AppSessionManager().SaveSession(ctx.Res, ctx.subject.Session); err != nil { - ctx.Log().Error(err) + // For Prod && HTML && minifier exists + if e.a.IsProfileProd() && ctx.Reply().isHTML() && e.a.viewMgr.minifier != nil { + if err := e.a.viewMgr.minifier(ctx.Reply().ContType, ctx.Res, ctx.Reply().Body()); err != nil { + ctx.Log().Errorf("Minifier error: %s", err.Error()) } - } -} - -func (e *engine) writeBody(ctx *Context) { - if isDumpLogEnabled && dumpResponseBody { - addResBodyIntoCtx(ctx) + return } - if minifier == nil || !appIsProfileProd || !ctHTML.IsEqual(ctx.Reply().ContType) { - if _, err := ctx.Reply().body.WriteTo(ctx.Res); err != nil { - ctx.Log().Error(err) - } - } else if err := minifier(ctx.Reply().ContType, ctx.Res, ctx.Reply().body); err != nil { - ctx.Log().Errorf("Minifier error: %s", err.Error()) + // For all cases + if _, err := ctx.Reply().Body().WriteTo(ctx.Res); err != nil { + ctx.Log().Error(err) } } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +func (e *engine) sendToAccessLog(ctx *Context) { + al := e.a.accessLog.logPool.Get().(*accessLog) + al.StartTime = ctx.Get(reqStartTimeKey).(time.Time) -func newEngine(cfg *config.Config) *engine { - ahttp.GzipLevel = cfg.IntDefault("render.gzip.level", 5) - if !(ahttp.GzipLevel >= 1 && ahttp.GzipLevel <= 9) { - logAsFatal(fmt.Errorf("'render.gzip.level' is not a valid level value: %v", ahttp.GzipLevel)) - } + // All the bytes have been written on the wire + // so calculate elapsed time + al.ElapsedDuration = time.Since(al.StartTime) - serverHeader := cfg.StringDefault("server.header", "") - appReqIDHdrKey = cfg.StringDefault("request.id.header", ahttp.HeaderXRequestID) - - return &engine{ - isRequestIDEnabled: cfg.BoolDefault("request.id.enable", true), - requestIDHeader: appReqIDHdrKey, - isGzipEnabled: cfg.BoolDefault("render.gzip.enable", true), - isAccessLogEnabled: cfg.BoolDefault("server.access_log.enable", false), - isStaticAccessLogEnabled: cfg.BoolDefault("server.access_log.static_file", true), - isServerHeaderEnabled: !ess.IsStrEmpty(serverHeader), - serverHeader: serverHeader, - } -} + req := *ctx.Req + al.Request = &req + al.RequestID = firstNonZeroString(req.Header.Get(e.a.requestIDHeaderKey), "-") + al.ResStatus = ctx.Res.Status() + al.ResBytes = ctx.Res.BytesWritten() + al.ResHdr = ctx.Res.Header() -func acquireContext() *Context { - return ctxPool.Get().(*Context) + e.a.accessLog.logChan <- al } -func releaseContext(ctx *Context) { - cleanup(ctx) +func (e *engine) releaseContext(ctx *Context) { ahttp.ReleaseResponseWriter(ctx.Res) ahttp.ReleaseRequest(ctx.Req) security.ReleaseSubject(ctx.subject) - releaseReply(ctx.reply) - - ctx.Reset() - ctxPool.Put(ctx) -} + releaseBuffer(ctx.Reply().Body()) -func cleanup(ctx *Context) { - if ctx.Req.Unwrap().MultipartForm != nil { - ctx.Log().Debug("MultipartForm file(s) clean up") - if err := ctx.Req.Unwrap().MultipartForm.RemoveAll(); err != nil { - ctx.Log().Error(err) - } - } -} - -func init() { - ctxPool = &sync.Pool{New: func() interface{} { - return &Context{ - viewArgs: make(map[string]interface{}), - values: make(map[string]interface{}), - } - }} + ctx.reset() + e.ctxPool.Put(ctx) } diff --git a/engine_test.go b/engine_test.go index 364a4d85..6f4397aa 100644 --- a/engine_test.go +++ b/engine_test.go @@ -5,436 +5,238 @@ package aah import ( - "bytes" - "compress/gzip" - "io/ioutil" "net/http" - "net/http/httptest" - "net/url" - "os" "path/filepath" - "reflect" "strings" "testing" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" - "aahframework.org/essentials.v0" - "aahframework.org/log.v0" "aahframework.org/test.v0/assert" ) -type ( - Site struct { - *Context - } +func TestEngineTestRequests(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() - sample struct { - ProductID int `bind:"id"` - ProductName string `bind:"product_name"` - Username string `bind:"username"` - Email string `bind:"email"` - Page int `bind:"page"` - Count string `bind:"count"` - } + t.Logf("Test Server URL [Engine Handling]: %s", ts.URL) - sampleJSON struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - Number int `json:"number"` + // declare functions + testOnRequest := func(e *Event) { + ctx := e.Data.(*Context) + ctx.Log().Info("Application OnRequest extension point") } -) - -func (s *Site) GetInvolved() { - s.Session().Set("test1", "test1value") - s.Reply().Text("GetInvolved action") -} -func (s *Site) Credits() { - s.Log().Info("Credits action called") - s.Reply(). - Header("X-Custom-Header", "custom value"). - DisableGzip(). - JSON(Data{ - "message": "This is credits page", - "code": 1000001, - }) -} + testOnPreReply := func(e *Event) { + ctx := e.Data.(*Context) + ctx.Log().Info("Application OnPreReply extension point") + } -func (s *Site) ContributeCode() { - s.Log().Info("ContributeCode action called") - panic("panic flow testing") -} + testOnAfterReply := func(e *Event) { + ctx := e.Data.(*Context) + ctx.Log().Info("Application OnAfterReply extension point") + } -func (s *Site) Before() { - log.Info("Before interceptor") -} + testOnPreAuth := func(e *Event) { + ctx := e.Data.(*Context) + ctx.Log().Info("Application OnPreAuth extension point") + } -func (s *Site) After() { - log.Info("After interceptor") -} + testOnPostAuth := func(e *Event) { + ctx := e.Data.(*Context) + ctx.Log().Info("Application OnPostAuth extension point") + } -func (s *Site) Finally() { - log.Info("Finally interceptor") -} + // Adding Server extension points + ts.app.OnRequest(func(e *Event) { + t.Log("Application OnRequest extension point") + }) + ts.app.OnRequest(testOnRequest) -func (s *Site) BeforeGetInvolved() { - log.Info("Before GetInvolved interceptor") -} + ts.app.OnPreReply(func(e *Event) { + t.Log("Application OnPreReply extension point") + }) + ts.app.OnPreReply(testOnPreReply) -func (s *Site) AfterGetInvolved() { - log.Info("After GetInvolved interceptor") -} + ts.app.OnAfterReply(func(e *Event) { + t.Log("Application OnAfterReply extension point") + }) + ts.app.OnAfterReply(testOnAfterReply) -func (s *Site) FinallyGetInvolved() { - log.Info("Finally GetInvolved interceptor") -} + ts.app.OnPreAuth(func(e *Event) { + t.Log("Application OnPreAuth extension point") + }) + ts.app.OnPreAuth(testOnPreAuth) -func (s *Site) AutoBind(id int, info *sample) { - s.Log().Info("AutoBind action called") - log.Info("ID:", id) - log.Infof("Info: %+v", info) - s.Reply().Text("Data have been recevied successfully") -} + ts.app.OnPostAuth(func(e *Event) { + t.Log("Application OnPostAuth extension point") + }) + ts.app.OnPostAuth(testOnPostAuth) -func (s *Site) JSONRequest(info *sampleJSON) { - s.Log().Info("JSONRequest action called") - log.Infof("JSON Info: %+v", info) - s.Reply().JSON(Data{ - "success": true, - "data": info, + ts.app.errorMgr.SetHandler(func(ctx *Context, err *Error) bool { + ctx.Log().Infof("Centrallized error handler called : %s", err) + t.Logf("Centrallized error handler called : %s", err) + ctx.Reply().Header("X-Centrallized-ErrorHandler", "true") + return false }) -} -func testEngineMiddleware(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Custom-Name", "test engine middleware") -} + httpClient := new(http.Client) -func TestEngineNew(t *testing.T) { - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) + // Panic Flow test HTML - /trigger-panic + t.Log("Panic Flow test - /trigger-panic") + resp, err := httpClient.Get(ts.URL + "/trigger-panic") assert.Nil(t, err) - assert.NotNil(t, AppConfig()) - - AppConfig().SetInt("render.gzip.level", 5) - AppConfig().SetString("request.id.header", "X-Test-Request-Id") - - e := newEngine(AppConfig()) - assert.Equal(t, "X-Test-Request-Id", e.requestIDHeader) - assert.True(t, e.isRequestIDEnabled) - assert.True(t, e.isGzipEnabled) - - ctx := acquireContext() - req := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - ctx.Req = ahttp.AcquireRequest(req) - assert.NotNil(t, ctx) - assert.NotNil(t, ctx.Req) - releaseContext(ctx) - - buf := acquireBuffer() - assert.NotNil(t, buf) - releaseBuffer(buf) - - appLogFatal = func(v ...interface{}) { t.Log(v) } - AppConfig().SetInt("render.gzip.level", 10) - e = newEngine(AppConfig()) - assert.NotNil(t, e) -} - -func TestEngineServeHTTP(t *testing.T) { - // App Config - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) + assert.Equal(t, 500, resp.StatusCode) + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "true", resp.Header.Get("X-Centrallized-ErrorHandler")) + assert.Equal(t, "true", resp.Header.Get("X-Cntrl-ErrorHandler")) + assert.True(t, strings.Contains(responseBody(resp), "500 Internal Server Error")) + + // Panic Flow test JSON - /trigger-panic + t.Log("Panic Flow test JSON - /trigger-panic") + req, err := http.NewRequest(ahttp.MethodGet, ts.URL+"/trigger-panic", nil) assert.Nil(t, err) - assert.NotNil(t, AppConfig()) - - AppConfig().SetString("server.port", "8080") - - // Router - err = initRoutes(cfgDir, AppConfig()) + req.Header.Set(ahttp.HeaderAccept, "application/json") + resp, err = httpClient.Do(req) assert.Nil(t, err) - assert.NotNil(t, AppRouter()) - - // Security - err = initSecurity(AppConfig()) + assert.Equal(t, 500, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "true", resp.Header.Get("X-Centrallized-ErrorHandler")) + assert.Equal(t, "true", resp.Header.Get("X-Cntrl-ErrorHandler")) + assert.True(t, strings.Contains(responseBody(resp), `"message": "Internal Server Error"`)) + + // Panic Flow test XML - /trigger-panic + t.Log("Panic Flow test XML - /trigger-panic") + req, err = http.NewRequest(ahttp.MethodGet, ts.URL+"/trigger-panic", nil) assert.Nil(t, err) - assert.True(t, AppSessionManager().IsStateful()) - - err = initLogs(getTestdataPath(), AppConfig()) + req.Header.Set(ahttp.HeaderAccept, "application/xml") + resp, err = httpClient.Do(req) assert.Nil(t, err) - - err = initAccessLog(getTestdataPath(), AppConfig()) + assert.Equal(t, 500, resp.StatusCode) + assert.Equal(t, "application/xml; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "true", resp.Header.Get("X-Centrallized-ErrorHandler")) + assert.Equal(t, "true", resp.Header.Get("X-Cntrl-ErrorHandler")) + assert.True(t, strings.Contains(responseBody(resp), `Internal Server Error`)) + + // GET XML pretty response - /get-xml + t.Log("GET XML pretty response - /get-xml") + ts.app.renderPretty = true + resp, err = httpClient.Get(ts.URL + "/get-xml") assert.Nil(t, err) - appAccessLog.SetWriter(os.Stdout) - - err = initDumpLog(getTestdataPath(), AppConfig()) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/xml; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "131", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.Contains(responseBody(resp), "This is XML payload result")) + + // GET XML non-pretty response - /get-xml + t.Log("GET XML non-pretty response - /get-xml") + ts.app.renderPretty = false + resp, err = httpClient.Get(ts.URL + "/get-xml") assert.Nil(t, err) - appDumpLog.SetWriter(os.Stdout) - - // Controllers - cRegistry = controllerRegistry{} - - AddController((*Site)(nil), []*MethodInfo{ - { - Name: "GetInvolved", - Parameters: []*ParameterInfo{}, - }, - { - Name: "ContributeCode", - Parameters: []*ParameterInfo{}, - }, - { - Name: "Credits", - Parameters: []*ParameterInfo{}, - }, - { - Name: "AutoBind", - Parameters: []*ParameterInfo{ - &ParameterInfo{Name: "id", Type: reflect.TypeOf((*int)(nil))}, - &ParameterInfo{Name: "info", Type: reflect.TypeOf((**sample)(nil))}, - }, - }, - { - Name: "JSONRequest", - Parameters: []*ParameterInfo{ - &ParameterInfo{Name: "info", Type: reflect.TypeOf((**sampleJSON)(nil))}, - }, - }, - }) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/xml; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "120", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.Contains(responseBody(resp), "This is XML payload result")) + + // GET JSONP pretty response - /get-jsonp?callback=welcome1 + t.Log("GET JSONP pretty response - /get-jsonp?callback=welcome1") + ts.app.renderPretty = true + resp, err = httpClient.Get(ts.URL + "/get-jsonp?callback=welcome1") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/javascript; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "176", resp.Header.Get(ahttp.HeaderContentLength)) + assert.Equal(t, `welcome1({ + "ProductID": 190398398, + "ProductName": "JSONP product", + "Username": "myuser_name", + "Email": "email@email.com", + "Page": 2, + "Count": "1000" +});`, responseBody(resp)) + + // GET JSONP non-pretty response - /get-jsonp?callback=welcome1 + t.Log("GET JSONP non-pretty response - /get-jsonp?callback=welcome1") + ts.app.renderPretty = false + resp, err = httpClient.Get(ts.URL + "/get-jsonp?callback=welcome1") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/javascript; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "139", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.HasPrefix(responseBody(resp), `welcome1({"ProductID":190398398,"ProductName":"JSONP product","Username"`)) + + // GET JSONP non-pretty response no callback input - /get-jsonp + t.Log("GET JSONP non-pretty response no callback input - /get-jsonp") + ts.app.renderPretty = false + resp, err = httpClient.Get(ts.URL + "/get-jsonp") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "application/javascript; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "128", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.HasPrefix(responseBody(resp), `{"ProductID":190398398,"ProductName":"JSONP product","Username"`)) + + // GET Binary bytes - /binary-bytes + t.Log("GET Binary bytes - /binary-bytes") + resp, err = httpClient.Get(ts.URL + "/binary-bytes") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "23", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.Contains(responseBody(resp), "This is my Binary Bytes")) + + // GET Send File - /send-file + t.Log("GET Send File - /send-file") + resp, err = httpClient.Get(ts.URL + "/send-file") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/css", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "inline; filename=aah.css", resp.Header.Get(ahttp.HeaderContentDisposition)) + assert.Equal(t, "700", resp.Header.Get(ahttp.HeaderContentLength)) + assert.True(t, strings.Contains(responseBody(resp), "Minimal aah framework application template CSS.")) + + // GET Hey Cookies - /hey-cookies + t.Log("GET Send File - /hey-cookies") + resp, err = httpClient.Get(ts.URL + "/hey-cookies") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.True(t, strings.Contains(responseBody(resp), "Hey I'm sending cookies for you :)")) + cookieStr := strings.Join(resp.Header["Set-Cookie"], "||") + assert.NotEqual(t, "", cookieStr) + assert.True(t, strings.Contains(cookieStr, `test_cookie_1="This is test cookie value 1"`)) + assert.True(t, strings.Contains(cookieStr, `test_cookie_2="This is test cookie value 2"`)) + + // OPTIONS request - /get-xml + t.Log("OPTIONS request - /get-xml") + req, err = http.NewRequest(ahttp.MethodOptions, ts.URL+"/get-xml", nil) + assert.Nil(t, err) + resp, err = httpClient.Do(req) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "GET, OPTIONS", resp.Header.Get(ahttp.HeaderAllow)) + assert.Equal(t, "0", resp.Header.Get(ahttp.HeaderContentLength)) - // Middlewares - Middlewares( - RouteMiddleware, - BindMiddleware, - AntiCSRFMiddleware, - AuthcAuthzMiddleware, - - // - // NOTE: Register your Custom middleware's right here - // - ToMiddleware(testEngineMiddleware), - - ActionMiddleware, - ) - - AppConfig().SetBool("server.access_log.enable", true) - - // Engine - appEngine = newEngine(AppConfig()) - e := appEngine - - // Request 1 - r1 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - w1 := httptest.NewRecorder() - e.ServeHTTP(w1, r1) - - resp1 := w1.Result() - assert.Equal(t, 404, resp1.StatusCode) - assert.True(t, strings.Contains(resp1.Status, "Not Found")) - assert.Equal(t, "aah-go-server", resp1.Header.Get(ahttp.HeaderServer)) - - // Request 2 - r2 := httptest.NewRequest("GET", "http://localhost:8080/get-involved.html", nil) - r2.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") - w2 := httptest.NewRecorder() - e.ServeHTTP(w2, r2) - - resp2 := w2.Result() - body2 := getResponseBody(resp2) - assert.Equal(t, 200, resp2.StatusCode) - assert.True(t, strings.Contains(resp2.Status, "OK")) - assert.Equal(t, "GetInvolved action", body2) - assert.Equal(t, "test engine middleware", resp2.Header.Get("X-Custom-Name")) - - // Request 3 - r3 := httptest.NewRequest("GET", "http://localhost:8080/contribute-to-code.html", nil) - w3 := httptest.NewRecorder() - e.ServeHTTP(w3, r3) - - resp3 := w3.Result() - body3 := getResponseBody(resp3) - assert.Equal(t, 500, resp3.StatusCode) - assert.True(t, strings.Contains(resp3.Status, "Internal Server Error")) - assert.True(t, strings.Contains(body3, "Internal Server Error")) - - // Request 4 static - r4 := httptest.NewRequest("GET", "http://localhost:8080/assets/logo.png", nil) - w4 := httptest.NewRecorder() - e.ServeHTTP(w4, r4) - - resp4 := w4.Result() - assert.NotNil(t, resp4) - - // Request 5 RedirectTrailingSlash - 302 status - wd, _ := os.Getwd() - appBaseDir = wd - r5 := httptest.NewRequest("GET", "http://localhost:8080/testdata", nil) - w5 := httptest.NewRecorder() - e.ServeHTTP(w5, r5) - - resp5 := w5.Result() - assert.Equal(t, 302, resp5.StatusCode) - assert.True(t, strings.Contains(resp5.Status, "Found")) - assert.Equal(t, "http://localhost:8080/testdata/", resp5.Header.Get(ahttp.HeaderLocation)) - - // Request 6 Directory Listing - appIsSSLEnabled = true - appIsProfileProd = true - AppSecurityManager().SecureHeaders.CSP = "default-erc 'self'" - r6 := httptest.NewRequest("GET", "http://localhost:8080/testdata/", nil) - r6.Header.Add(e.requestIDHeader, "D9391509-595B-4B92-BED7-F6A9BE0DFCF2") - r6.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") - w6 := httptest.NewRecorder() - e.ServeHTTP(w6, r6) - - resp6 := w6.Result() - body6 := getResponseBody(resp6) - assert.True(t, strings.Contains(body6, "Listing of /testdata/")) - assert.True(t, strings.Contains(body6, "config/")) - AppSecurityManager().SecureHeaders.CSP = "" - appIsSSLEnabled = false - appIsProfileProd = false - - // Request 7 Custom Headers - r7 := httptest.NewRequest("GET", "http://localhost:8080/credits", nil) - r7.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") - w7 := httptest.NewRecorder() - e.ServeHTTP(w7, r7) - - resp7 := w7.Result() - body7 := getResponseBody(resp7) - assert.Equal(t, `{"code":1000001,"message":"This is credits page"}`, body7) - assert.Equal(t, "custom value", resp7.Header.Get("X-Custom-Header")) - - // Request 8 - r8 := httptest.NewRequest("POST", "http://localhost:8080/credits", nil) - r8.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") - r8.Header.Add(ahttp.HeaderAccept, ahttp.ContentTypeJSON.String()) - w8 := httptest.NewRecorder() - e.ServeHTTP(w8, r8) - - // Method Not Allowed 405 response - resp8 := w8.Result() - body8 := getResponseBody(resp8) - assert.Equal(t, 405, resp8.StatusCode) - assert.Equal(t, `{"code":405,"message":"Method Not Allowed"}`, body8) - assert.Equal(t, "GET, OPTIONS", resp8.Header.Get("Allow")) - - // Request 9 Auto Options - r9 := httptest.NewRequest("OPTIONS", "http://localhost:8080/credits", nil) - r9.Header.Add(ahttp.HeaderAcceptEncoding, "gzip, deflate, sdch, br") - w9 := httptest.NewRecorder() - e.ServeHTTP(w9, r9) - - resp9 := w9.Result() - assert.Equal(t, 200, resp9.StatusCode) - assert.Equal(t, "GET, OPTIONS", resp9.Header.Get("Allow")) - - // Request 10 Auto Bind request - autobindPriority = []string{"Q", "F", "P"} - requestParsers[ahttp.ContentTypeMultipartForm.Mime] = multipartFormParser - requestParsers[ahttp.ContentTypeForm.Mime] = formParser - secret := AppSecurityManager().AntiCSRF.GenerateSecret() - secretstr := AppSecurityManager().AntiCSRF.SaltCipherSecret(secret) - form := url.Values{} - form.Add("product_name", "Test Product") - form.Add("username", "welcome") - form.Add("email", "welcome@welcome.com") - form.Add("anti_csrf_token", secretstr) - r10 := httptest.NewRequest("POST", "http://localhost:8080/products/100002?page=10&count=20", - strings.NewReader(form.Encode())) - r10.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeForm.String()) - - w10 := httptest.NewRecorder() - _ = AppSecurityManager().AntiCSRF.SetCookie(w10, secret) - cookieValue := w10.Header().Get("Set-Cookie") - r10.Header.Set(ahttp.HeaderCookie, cookieValue) - - e.ServeHTTP(w10, r10) - - resp10 := w10.Result() - body10 := getResponseBody(resp10) - assert.NotNil(t, resp10) - assert.Equal(t, http.StatusOK, resp10.StatusCode) - assert.Equal(t, "text/plain; charset=utf-8", resp10.Header.Get(ahttp.HeaderContentType)) - assert.Equal(t, "Data have been recevied successfully", body10) - - // Request 11 multipart - r11 := httptest.NewRequest("POST", "http://localhost:8080/products/100002?page=10&count=20", - strings.NewReader(form.Encode())) - r11.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeForm.String()) - w11 := httptest.NewRecorder() - r11.Header.Set(ahttp.HeaderCookie, cookieValue) - - e.ServeHTTP(w11, r11) - - resp11 := w11.Result() - body11 := getResponseBody(resp11) - assert.NotNil(t, resp11) - assert.Equal(t, http.StatusOK, resp11.StatusCode) - assert.Equal(t, "text/plain; charset=utf-8", resp11.Header.Get(ahttp.HeaderContentType)) - assert.Equal(t, "Data have been recevied successfully", body11) - - // Request 12 JSON request - jsonBytes := []byte(`{ - "first_name":"My firstname", - "last_name": "My lastname", - "email": "email@myemail.com", - "number": 8253645635463 -}`) - - r12 := httptest.NewRequest("POST", "http://localhost:8080/json-submit", - bytes.NewReader(jsonBytes)) - r12.Header.Set(ahttp.HeaderContentType, ahttp.ContentTypeJSON.String()) - r12.Header.Set("X-Anti-CSRF-Token", secretstr) - r12.Header.Set(ahttp.HeaderCookie, cookieValue) - w12 := httptest.NewRecorder() - e.ServeHTTP(w12, r12) - - resp12 := w12.Result() - body12 := getResponseBody(resp12) - assert.NotNil(t, resp12) - assert.Equal(t, http.StatusOK, resp12.StatusCode) - assert.Equal(t, "application/json; charset=utf-8", resp12.Header.Get(ahttp.HeaderContentType)) - assert.True(t, strings.Contains(body12, `"success":true`)) - - // Request 13 domain not found - r13 := httptest.NewRequest("GET", "http://localhost:7070/index.html", nil) - w13 := httptest.NewRecorder() - e.ServeHTTP(w13, r13) - - resp13 := w13.Result() - assert.Equal(t, 404, resp13.StatusCode) - assert.True(t, strings.Contains(resp13.Status, "Not Found")) - - appEngine = nil - appBaseDir = "" + // POST - Method Not allowed - /binary-bytes + t.Log("POST - Method Not allowed - /binary-bytes") + resp, err = httpClient.Post(ts.URL+"/binary-bytes", ahttp.ContentTypeJSON.String(), strings.NewReader(`{"message":"accept this request"}`)) + assert.Nil(t, err) + assert.Equal(t, 405, resp.StatusCode) + assert.Equal(t, "GET, OPTIONS", resp.Header.Get(ahttp.HeaderAllow)) + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.True(t, strings.Contains(responseBody(resp), "405 Method Not Allowed")) } -func TestEngineGzipHeaders(t *testing.T) { - cfg, _ := config.ParseString("") - e := newEngine(cfg) - - req := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - req.Header.Add(ahttp.HeaderAcceptEncoding, "gzip") - ctx := e.prepareContext(httptest.NewRecorder(), req) - e.wrapGzipWriter(ctx) - - assert.True(t, ctx.Req.IsGzipAccepted) - assert.Equal(t, "gzip", ctx.Res.Header().Get(ahttp.HeaderContentEncoding)) - assert.Equal(t, "Accept-Encoding", ctx.Res.Header().Get(ahttp.HeaderVary)) - assert.False(t, isResponseBodyAllowed(199)) - assert.False(t, isResponseBodyAllowed(304)) - assert.False(t, isResponseBodyAllowed(100)) -} +func newContext(w http.ResponseWriter, r *http.Request) *Context { + ctx := &Context{} -func getResponseBody(res *http.Response) string { - r := res.Body - defer ess.CloseQuietly(r) - if strings.Contains(res.Header.Get("Content-Encoding"), "gzip") { - r, _ = gzip.NewReader(r) + if r != nil { + ctx.Req = ahttp.AcquireRequest(r) } - body, _ := ioutil.ReadAll(r) - return string(body) + + if w != nil { + ctx.Res = ahttp.AcquireResponseWriter(w) + } + + return ctx } diff --git a/error.go b/error.go index 5773fc91..c94f42b4 100644 --- a/error.go +++ b/error.go @@ -8,12 +8,10 @@ import ( "errors" "fmt" "html/template" - "reflect" "strings" "aahframework.org/ahttp.v0" "aahframework.org/essentials.v0" - "aahframework.org/log.v0" ) // aah errors @@ -31,10 +29,9 @@ var ( ErrAuthenticationFailed = errors.New("aah: authentication failed") ErrGeneric = errors.New("aah: generic error") ErrValidation = errors.New("aah: validation error") + ErrRenderResponse = errors.New("aah: render response error") ) -var errorHandlerFunc ErrorHandlerFunc - var defaultErrorHTMLTemplate = template.Must(template.New("error_template").Parse(` @@ -82,87 +79,85 @@ var defaultErrorHTMLTemplate = template.Must(template.New("error_template").Pars `)) -type ( - // Error structure used to represent the error details in the aah framework. - Error struct { - Reason error `json:"-" xml:"-"` - Code int `json:"code,omitempty" xml:"code,omitempty"` - Message string `json:"message,omitempty" xml:"message,omitempty"` - Data interface{} `json:"data,omitempty" xml:"data,omitempty"` - } - - // ErrorHandlerFunc is function type, it used to define centralized error handler - // for your application. +// ErrorHandlerFunc is function type, it used to define centralized error handler +// for your application. +// +// - Return `true`, if you have handled your errors, aah just writes the reply on the wire. +// +// - Return `false`, you may or may not handled the error, aah would propagate the error further to default +// error handler. +type ErrorHandlerFunc func(ctx *Context, err *Error) bool + +// ErrorHandler is interface for implement controller level error handling +type ErrorHandler interface { + // HandleError method is to handle error on your controller // // - Return `true`, if you have handled your errors, aah just writes the reply on the wire. // - // - Return `false`, you may or may not handled the error, aah would propagate the error further to default - // error handler. - ErrorHandlerFunc func(ctx *Context, err *Error) bool - - // ErrorHandler is interface for implement controller level error handling - ErrorHandler interface { - // HandleError method is to handle error on your controller - // - // - Return `true`, if you have handled your errors, aah just writes the reply on the wire. - // - // - Return `false`, aah would propagate the error further to centralized - // error handler, if not handled and then finally default error handler would take place. - HandleError(err *Error) bool - } -) + // - Return `false`, aah would propagate the error further to centralized + // error handler, if not handled and then finally default error handler would take place. + HandleError(err *Error) bool +} -// SetErrorHandler method is used to register centralized application error -// handling. If custom handler is not then default error handler is used. -func SetErrorHandler(handlerFunc ErrorHandlerFunc) { - if handlerFunc != nil { - log.Infof("Custom centralized application error handler registered: %v", funcName(handlerFunc)) - errorHandlerFunc = handlerFunc +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) initError() error { + a.errorMgr = &errorManager{ + a: a, + e: a.engine, } + + return nil } -// Error method is to comply error interface. -func (e *Error) Error() string { - return fmt.Sprintf("%v, code '%v', message '%s'", e.Reason, e.Code, e.Message) +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Error Manager +//______________________________________________________________________________ + +type errorManager struct { + a *app + e *engine + handlerFunc ErrorHandlerFunc } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported package methods -//___________________________________ +func (er *errorManager) SetHandler(handlerFn ErrorHandlerFunc) { + if handlerFn != nil { + er.handlerFunc = handlerFn + er.a.Log().Infof("Custom centralized application error handler is registered with: %v", funcName(handlerFn)) + } +} -// handleError method is aah centralized error handler. -func handleError(ctx *Context, err *Error) { +func (er *errorManager) Handle(ctx *Context) { // GitHub #132 Call Controller error handler if exists - if target := reflect.ValueOf(ctx.target); target.IsValid() { - if eh, ok := target.Interface().(ErrorHandler); ok { - ctx.Log().Trace("Calling controller error handler") - if eh.HandleError(err) { - return - } + if ceh, ok := ctx.target.(ErrorHandler); ok { + ctx.Log().Trace("Calling controller error handler: %s.HandleError", ctx.controller.FqName) + if ceh.HandleError(ctx.Reply().err) { + return } } // Call Centralized error handler if registered - if errorHandlerFunc != nil { + if er.handlerFunc != nil { ctx.Log().Trace("Calling centralized error handler") - if errorHandlerFunc(ctx, err) { + if er.handlerFunc(ctx, ctx.Reply().err) { return } } // Call Default error handler ctx.Log().Trace("Calling default error handler") - defaultErrorHandlerFunc(ctx, err) + er.DefaultHandler(ctx, ctx.Reply().err) } -// defaultErrorHandlerFunc method is used when custom error handler is not register +// DefaultHandler method is used when custom error handler is not register // in the aah. It writes the response based on HTTP Content-Type. -func defaultErrorHandlerFunc(ctx *Context, err *Error) bool { +func (er *errorManager) DefaultHandler(ctx *Context, err *Error) bool { ct := ctx.Reply().ContType + if ess.IsStrEmpty(ct) { - if ict := identifyContentType(ctx); ict != nil { - ct = ict.Mime - } + ct = ctx.detectContentType().Mime } else if idx := strings.IndexByte(ct, ';'); idx > 0 { ct = ct[:idx] } @@ -170,29 +165,49 @@ func defaultErrorHandlerFunc(ctx *Context, err *Error) bool { // Set HTTP response code ctx.Reply().Status(err.Code) + // Set it to nil do not expose any app internal info + err.Data = nil + switch ct { case ahttp.ContentTypeJSON.Mime, ahttp.ContentTypeJSONText.Mime: ctx.Reply().JSON(err) case ahttp.ContentTypeXML.Mime, ahttp.ContentTypeXMLText.Mime: ctx.Reply().XML(err) case ahttp.ContentTypeHTML.Mime: - html := acquireHTML() - html.Filename = fmt.Sprintf("%d%s", err.Code, appViewExt) - if AppViewEngine() != nil { - tmpl, er := AppViewEngine().Get("", "errors", html.Filename) - if tmpl == nil || er != nil { - html.Template = defaultErrorHTMLTemplate - } else { + html := &htmlRender{ + Template: defaultErrorHTMLTemplate, + Filename: fmt.Sprintf("%d%s", err.Code, ctx.a.viewMgr.fileExt), + ViewArgs: Data{"Error": err}, + } + + if ctx.a.viewMgr != nil { + tmpl, terr := ctx.a.ViewEngine().Get("", "errors", html.Filename) + if tmpl != nil || terr == nil { html.Template = tmpl } - } else { - html.Template = defaultErrorHTMLTemplate } - html.ViewArgs = Data{"Error": err} - addFrameworkValuesIntoViewArgs(ctx, html) + ctx.Reply().Rdr = html + ctx.a.viewMgr.addFrameworkValuesIntoViewArgs(ctx) default: ctx.Reply().Text("%d - %s", err.Code, err.Message) } return true } + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Error type +//______________________________________________________________________________ + +// Error structure used to represent the error details in the aah framework. +type Error struct { + Reason error `json:"-" xml:"-"` + Code int `json:"code,omitempty" xml:"code,omitempty"` + Message string `json:"message,omitempty" xml:"message,omitempty"` + Data interface{} `json:"data,omitempty" xml:"data,omitempty"` +} + +// Error method is to comply error interface. +func (e *Error) Error() string { + return fmt.Sprintf("%v, code '%v', message '%s'", e.Reason, e.Code, e.Message) +} diff --git a/error_test.go b/error_test.go index fbe6df16..421a7b4a 100644 --- a/error_test.go +++ b/error_test.go @@ -5,129 +5,45 @@ package aah import ( - "fmt" "net/http" - "net/http/httptest" - "path/filepath" "testing" "aahframework.org/ahttp.v0" "aahframework.org/config.v0" "aahframework.org/log.v0" - "aahframework.org/security.v0" "aahframework.org/test.v0/assert" ) -func TestErrorHandler(t *testing.T) { - // 400 - ctx1 := &Context{ - Req: getAahRequest("GET", "http://localhost:8080", ""), - subject: security.AcquireSubject(), - reply: acquireReply(), - } - ctx1.Reply().ContentType("application/json") - handleError(ctx1, &Error{ - Reason: ErrInvalidRequestParameter, - Code: http.StatusBadRequest, - Message: http.StatusText(http.StatusBadRequest), - }) - assert.NotNil(t, ctx1.Reply().Rdr) - jsonr := ctx1.Reply().Rdr.(*JSON) - assert.NotNil(t, jsonr) - assert.NotNil(t, jsonr.Data) - - err := jsonr.Data.(*Error) - assert.Equal(t, 400, err.Code) - assert.Equal(t, "Bad Request", err.Message) - assert.Equal(t, "aah: invalid request parameter, code '400', message 'Bad Request'", err.Error()) - - // 500 - ctx2 := &Context{ - Req: getAahRequest("GET", "http://localhost:8080", ""), - subject: security.AcquireSubject(), - reply: acquireReply(), - } - ctx2.Reply().ContentType("application/xml") - handleError(ctx2, &Error{ - Code: http.StatusInternalServerError, - Message: http.StatusText(http.StatusInternalServerError), - }) - assert.NotNil(t, ctx2.Reply().Rdr) - xmlr := ctx2.Reply().Rdr.(*XML) - assert.NotNil(t, xmlr) - assert.NotNil(t, xmlr.Data) - assert.Equal(t, 500, xmlr.Data.(*Error).Code) - assert.Equal(t, "Internal Server Error", xmlr.Data.(*Error).Message) - - SetErrorHandler(func(ctx *Context, err *Error) bool { - t.Log(ctx, err) - return false - }) - - // 403 - ctx3 := &Context{ - Req: getAahRequest("GET", "http://localhost:8080", ""), - subject: security.AcquireSubject(), - reply: acquireReply(), - } - ctx3.Reply().ContentType("text/plain") - handleError(ctx3, &Error{ - Code: http.StatusForbidden, - Message: http.StatusText(http.StatusForbidden), - }) - assert.NotNil(t, ctx3.Reply().Rdr) - plain := ctx3.Reply().Rdr.(*Text) - assert.NotNil(t, plain) - assert.Equal(t, "[403 Forbidden]", fmt.Sprint(plain.Values)) +type testErrorController1 struct { } -func TestErrorDefaultHandler(t *testing.T) { - appCfg, _ := config.ParseString("") - viewDir := filepath.Join(getTestdataPath(), appViewsDir()) - err := initViewEngine(viewDir, appCfg) - assert.Nil(t, err) - assert.NotNil(t, AppViewEngine()) - - // 400 - r1 := httptest.NewRequest("GET", "http://localhost:8080/get-involved.html", nil) - ctx1 := &Context{Req: ahttp.AcquireRequest(r1), subject: security.AcquireSubject(), reply: acquireReply()} - ctx1.Reply().ContentType(ahttp.ContentTypeHTML.String()) - defaultErrorHandlerFunc(ctx1, &Error{Code: http.StatusNotFound, Message: "Test message"}) - html := ctx1.Reply().Rdr.(*HTML) - t.Logf("%+v\n", html) - assert.True(t, defaultErrorHTMLTemplate == html.Template) - assert.Equal(t, "404.html", html.Filename) - - // 500 - r2 := httptest.NewRequest("GET", "http://localhost:8080/get-involved.html", nil) - ctx2 := &Context{Req: ahttp.AcquireRequest(r2), subject: security.AcquireSubject(), reply: acquireReply()} - ctx2.Reply().ContentType(ahttp.ContentTypeHTML.String()) - defaultErrorHandlerFunc(ctx2, &Error{Code: http.StatusInternalServerError, Message: "Test message"}) - html = ctx2.Reply().Rdr.(*HTML) - t.Logf("%+v\n", html) - assert.True(t, defaultErrorHTMLTemplate == html.Template) - assert.Equal(t, "500.html", html.Filename) -} - -type testErrorController struct { -} - -func (tec *testErrorController) HandleError(err *Error) bool { +func (tec *testErrorController1) HandleError(err *Error) bool { log.Info("I have handler it") return true } func TestErrorCallControllerHandler(t *testing.T) { - // 400 - ctx1 := &Context{ - Req: getAahRequest("GET", "http://localhost:8080", ""), - target: &testErrorController{}, - subject: security.AcquireSubject(), - reply: acquireReply(), + req, err := http.NewRequest(ahttp.MethodGet, "http://localhost:8080", nil) + assert.Nil(t, err) + ctx := &Context{ + Req: ahttp.AcquireRequest(req), + controller: &controllerInfo{FqName: "testErrorController1"}, + target: &testErrorController1{}, } - ctx1.Reply().ContentType("application/json") - handleError(ctx1, &Error{ + + cfg, err := config.ParseString("") + assert.Nil(t, err) + + l, err := log.New(cfg) + assert.Nil(t, err) + ctx.logger = l + + ctx.Reply().ContentType("application/json") + ctx.Reply().Error(&Error{ Code: http.StatusBadRequest, Message: http.StatusText(http.StatusBadRequest), }) + + em := new(errorManager) + em.Handle(ctx) } diff --git a/event.go b/event.go index 0da5a0c9..f1a1bac2 100644 --- a/event.go +++ b/event.go @@ -5,11 +5,9 @@ package aah import ( - "reflect" "sort" "sync" - "aahframework.org/essentials.v0" "aahframework.org/log.v0" ) @@ -47,15 +45,6 @@ const ( EventOnPostAuth = "OnPostAuth" ) -var ( - appEventStore = &EventStore{subscribers: make(map[string]EventCallbacks), mu: &sync.Mutex{}} - onRequestFunc EventCallbackFunc - onPreReplyFunc EventCallbackFunc - onAfterReplyFunc EventCallbackFunc - onPreAuthFunc EventCallbackFunc - onPostAuthFunc EventCallbackFunc -) - type ( // Event type holds the details of single event. Event struct { @@ -63,12 +52,6 @@ type ( Data interface{} } - // EventStore type holds all the events belongs to aah application. - EventStore struct { - subscribers map[string]EventCallbacks - mu *sync.Mutex - } - // EventCallback type is store particular callback in priority for calling sequance. EventCallback struct { Callback EventCallbackFunc @@ -84,154 +67,109 @@ type ( EventCallbackFunc func(e *Event) ) -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app event methods +//______________________________________________________________________________ -// AppEventStore method returns aah application event store. -func AppEventStore() *EventStore { - return appEventStore +func (a *app) OnInit(ecb EventCallbackFunc, priority ...int) { + a.eventStore.Subscribe(EventOnInit, EventCallback{ + Callback: ecb, + CallOnce: true, + priority: parsePriority(priority...), + }) } -// PublishEvent method publishes events to subscribed callbacks asynchronously. It -// means each subscribed callback executed via goroutine. -func PublishEvent(eventName string, data interface{}) { - AppEventStore().Publish(&Event{Name: eventName, Data: data}) +func (a *app) OnStart(ecb EventCallbackFunc, priority ...int) { + a.eventStore.Subscribe(EventOnStart, EventCallback{ + Callback: ecb, + CallOnce: true, + priority: parsePriority(priority...), + }) } -// PublishEventSync method publishes events to subscribed callbacks synchronously. -func PublishEventSync(eventName string, data interface{}) { - AppEventStore().PublishSync(&Event{Name: eventName, Data: data}) +func (a *app) OnShutdown(ecb EventCallbackFunc, priority ...int) { + a.eventStore.Subscribe(EventOnShutdown, EventCallback{ + Callback: ecb, + CallOnce: true, + priority: parsePriority(priority...), + }) } -// SubscribeEvent method is to subscribe to new or existing event. -func SubscribeEvent(eventName string, ec EventCallback) { - AppEventStore().Subscribe(eventName, ec) +func (a *app) OnRequest(sef EventCallbackFunc) { + a.engine.OnRequest(sef) } -// SubscribeEventf method is to subscribe to new or existing event by `EventCallbackFunc`. -func SubscribeEventf(eventName string, ecf EventCallbackFunc) { - AppEventStore().Subscribe(eventName, EventCallback{Callback: ecf}) +func (a *app) OnPreReply(sef EventCallbackFunc) { + a.engine.OnPreReply(sef) } -// UnsubscribeEvent method is to unsubscribe by event name and `EventCallback` -// from app event store. -func UnsubscribeEvent(eventName string, ec EventCallback) { - UnsubscribeEventf(eventName, ec.Callback) +func (a *app) OnAfterReply(sef EventCallbackFunc) { + a.engine.OnAfterReply(sef) } -// UnsubscribeEventf method is to unsubscribe by event name and `EventCallbackFunc` -// from app event store. -func UnsubscribeEventf(eventName string, ecf EventCallbackFunc) { - AppEventStore().Unsubscribe(eventName, ecf) +func (a *app) OnPreAuth(sef EventCallbackFunc) { + a.engine.OnPreAuth(sef) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods - Server events -//___________________________________ +func (a *app) OnPostAuth(sef EventCallbackFunc) { + a.engine.OnPostAuth(sef) +} -// OnInit method is to subscribe to aah application `OnInit` event. `OnInit` event -// published right after the aah application configuration `aah.conf` initialized. -func OnInit(ecb EventCallbackFunc, priority ...int) { - AppEventStore().Subscribe(EventOnInit, EventCallback{ - Callback: ecb, - CallOnce: true, - priority: parsePriority(priority...), - }) +func (a *app) PublishEvent(eventName string, data interface{}) { + a.eventStore.Publish(&Event{Name: eventName, Data: data}) } -// OnStart method is to subscribe to aah application `OnStart` event. `OnStart` -// event pubished right before the aah server listen and serving request. -func OnStart(ecb EventCallbackFunc, priority ...int) { - AppEventStore().Subscribe(EventOnStart, EventCallback{ - Callback: ecb, - CallOnce: true, - priority: parsePriority(priority...), - }) +func (a *app) PublishEventSync(eventName string, data interface{}) { + a.eventStore.PublishSync(&Event{Name: eventName, Data: data}) } -// OnShutdown method is to subscribe to aah application `OnShutdown` event. -// `OnShutdown` event pubished right before the aah server is stopped Listening -// and serving request. -func OnShutdown(ecb EventCallbackFunc, priority ...int) { - AppEventStore().Subscribe(EventOnShutdown, EventCallback{ - Callback: ecb, - CallOnce: true, - priority: parsePriority(priority...), - }) +func (a *app) SubscribeEvent(eventName string, ec EventCallback) { + a.eventStore.Subscribe(eventName, ec) } -// OnRequest method is to subscribe to aah server `OnRequest` extension point. -// `OnRequest` called for every incoming request. -// -// The `aah.Context` object passed to the extension functions is decorated with -// the `ctx.SetURL()` and `ctx.SetMethod()` methods. Calls to these methods will -// impact how the request is routed and can be used for rewrite rules. -// -// Route is not yet populated/evaluated at this point. -func OnRequest(sef EventCallbackFunc) { - if onRequestFunc == nil { - onRequestFunc = sef - return - } - log.Warn("'OnRequest' aah server extension point is already subscribed.") +func (a *app) SubscribeEventFunc(eventName string, ecf EventCallbackFunc) { + a.eventStore.Subscribe(eventName, EventCallback{Callback: ecf}) } -// OnPreReply method is to subscribe to aah server `OnPreReply` extension point. -// `OnPreReply` called for every reply from aah server. -// -// Except when -// 1) `Reply().Done()`, -// 2) `Reply().Redirect(...)` is called. -// Refer `aah.Reply.Done()` godoc for more info. -func OnPreReply(sef EventCallbackFunc) { - if onPreReplyFunc == nil { - onPreReplyFunc = sef - return - } - log.Warn("'OnPreReply' aah server extension point is already subscribed.") +// DEPRECATED: use SubscribeEventFunc instead. +func (a *app) SubscribeEventf(eventName string, ecf EventCallbackFunc) { + a.showDeprecatedMsg("SubscribeEventf, use 'SubscribeEventFunc' instead") + a.SubscribeEventFunc(eventName, ecf) } -// OnAfterReply method is to subscribe to aah server `OnAfterReply` extension point. -// `OnAfterReply` called for every reply from aah server. -// -// Except when -// 1) `Reply().Done()`, -// 2) `Reply().Redirect(...)` is called. -// Refer `aah.Reply.Done()` godoc for more info. -func OnAfterReply(sef EventCallbackFunc) { - if onAfterReplyFunc == nil { - onAfterReplyFunc = sef - return - } - log.Warn("'OnAfterReply' aah server extension point is already subscribed.") +func (a *app) UnsubscribeEvent(eventName string, ec EventCallback) { + a.UnsubscribeEventFunc(eventName, ec.Callback) } -// OnPreAuth method is to subscribe to aah application `OnPreAuth` event. -// `OnPreAuth` event pubished right before the aah server is authenticates & -// authorizes an incoming request. -func OnPreAuth(sef EventCallbackFunc) { - if onPreAuthFunc == nil { - onPreAuthFunc = sef - return - } - log.Warn("'OnPreAuth' aah server extension point is already subscribed.") +func (a *app) UnsubscribeEventFunc(eventName string, ecf EventCallbackFunc) { + a.eventStore.Unsubscribe(eventName, ecf) } -// OnPostAuth method is to subscribe to aah application `OnPreAuth` event. -// `OnPostAuth` event pubished right after the aah server is authenticates & -// authorizes an incoming request. -func OnPostAuth(sef EventCallbackFunc) { - if onPostAuthFunc == nil { - onPostAuthFunc = sef - return - } - log.Warn("'OnPostAuth' aah server extension point is already subscribed.") +// DEPRECATED: use UnsubscribeEventFunc instead. +func (a *app) UnsubscribeEventf(eventName string, ecf EventCallbackFunc) { + a.showDeprecatedMsg("UnsubscribeEventf, use 'UnsubscribeEventFunc' instead") + a.UnsubscribeEventFunc(eventName, ecf) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// EventStore methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ + +func (a *app) EventStore() *EventStore { + return a.eventStore +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// EventStore +//______________________________________________________________________________ + +// EventStore type holds all the events belongs to aah application. +type EventStore struct { + a *app + e *engine + subscribers map[string]EventCallbacks + mu *sync.Mutex +} // IsEventExists method returns true if given event is exists in the event store // otherwise false. @@ -247,7 +185,7 @@ func (es *EventStore) Publish(e *Event) { return } - log.Debugf("Event [%s] published in asynchronous mode", e.Name) + es.a.Log().Debugf("Event [%s] published in asynchronous mode", e.Name) for idx, ec := range es.subscribers[e.Name] { if ec.CallOnce { if !ec.published { @@ -273,7 +211,12 @@ func (es *EventStore) PublishSync(e *Event) { return } - log.Debugf("Event [%s] publishing in synchronous mode", e.Name) + if es.a.Log() == nil { + log.Debugf("Event [%s] publishing in synchronous mode", e.Name) + } else { + es.a.Log().Debugf("Event [%s] publishing in synchronous mode", e.Name) + } + for idx, ec := range es.subscribers[e.Name] { if ec.CallOnce { if !ec.published { @@ -307,7 +250,7 @@ func (es *EventStore) Unsubscribe(event string, callback EventCallbackFunc) { es.mu.Lock() defer es.mu.Unlock() if !es.IsEventExists(event) { - log.Warnf("Subscribers not exists for event: %s", event) + es.a.Log().Warnf("Subscribers not exists for event: %s", event) return } @@ -315,12 +258,12 @@ func (es *EventStore) Unsubscribe(event string, callback EventCallbackFunc) { ec := es.subscribers[event][idx] if funcEqual(ec.Callback, callback) { es.subscribers[event] = append(es.subscribers[event][:idx], es.subscribers[event][idx+1:]...) - log.Debugf("Callback: %s, unsubscribed from event: %s", funcName(callback), event) + es.a.Log().Debugf("Callback: %s, unsubscribed from event: %s", funcName(callback), event) return } } - log.Warnf("Given callback: %s, not found in eventStore for event: %s", funcName(callback), event) + es.a.Log().Warnf("Given callback: %s, not found in eventStore for event: %s", funcName(callback), event) } // SubscriberCount method returns subscriber count for given event name. @@ -338,70 +281,11 @@ func (es *EventStore) sortAndPublishSync(e *Event) { } } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // EventCallbacks methods -//___________________________________ +//______________________________________________________________________________ // Sort interface for EventCallbacks func (ec EventCallbacks) Len() int { return len(ec) } func (ec EventCallbacks) Less(i, j int) bool { return ec[i].priority < ec[j].priority } func (ec EventCallbacks) Swap(i, j int) { ec[i], ec[j] = ec[j], ec[i] } - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -func publishOnRequestEvent(ctx *Context) { - if onRequestFunc != nil { - ctx.decorated = true - onRequestFunc(&Event{Name: EventOnRequest, Data: ctx}) - ctx.decorated = false - } -} - -func publishOnPreReplyEvent(ctx *Context) { - if onPreReplyFunc != nil { - onPreReplyFunc(&Event{Name: EventOnPreReply, Data: ctx}) - } -} - -func publishOnAfterReplyEvent(ctx *Context) { - if onAfterReplyFunc != nil { - onAfterReplyFunc(&Event{Name: EventOnAfterReply, Data: ctx}) - } -} - -func publishOnPreAuthEvent(ctx *Context) { - if onPreAuthFunc != nil { - onPreAuthFunc(&Event{Name: EventOnPreAuth, Data: ctx}) - } -} - -func publishOnPostAuthEvent(ctx *Context) { - if onPostAuthFunc != nil { - onPostAuthFunc(&Event{Name: EventOnPostAuth, Data: ctx}) - } -} - -// funcEqual method to compare to function callback interface data. In effect -// comparing the pointers of the indirect layer. Read more about the -// representation of functions here: http://golang.org/s/go11func -func funcEqual(a, b interface{}) bool { - av := reflect.ValueOf(&a).Elem() - bv := reflect.ValueOf(&b).Elem() - return av.InterfaceData() == bv.InterfaceData() -} - -// funcName method to get callback function name. -func funcName(f interface{}) string { - fi := ess.GetFunctionInfo(f) - return fi.Name -} - -func parsePriority(priority ...int) int { - pr := 1 // default priority is 1 - if len(priority) > 0 && priority[0] > 0 { - pr = priority[0] - } - return pr -} diff --git a/event_test.go b/event_test.go index 4aa0660c..516daa41 100644 --- a/event_test.go +++ b/event_test.go @@ -5,14 +5,22 @@ package aah import ( - "sync" + "path/filepath" "testing" "time" "aahframework.org/test.v0/assert" ) -func TestOnInitEvent(t *testing.T) { +func TestEvenOnInit(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Event Publisher]: %s", ts.URL) + + // declare functions onInitFunc1 := func(e *Event) { t.Log("onInitFunc1:", e) } @@ -25,47 +33,55 @@ func TestOnInitEvent(t *testing.T) { t.Log("onInitFunc3:", e) } - appEventStore = &EventStore{subscribers: make(map[string]EventCallbacks), mu: &sync.Mutex{}} - assert.False(t, AppEventStore().IsEventExists(EventOnInit)) + es := ts.app.eventStore + assert.False(t, es.IsEventExists(EventOnInit)) - OnInit(onInitFunc1) - assert.True(t, AppEventStore().IsEventExists(EventOnInit)) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnInit)) + ts.app.OnInit(onInitFunc1) + assert.True(t, es.IsEventExists(EventOnInit)) + assert.Equal(t, 1, es.SubscriberCount(EventOnInit)) - OnInit(onInitFunc3, 4) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnInit)) + ts.app.OnInit(onInitFunc3, 4) + assert.Equal(t, 2, es.SubscriberCount(EventOnInit)) - OnInit(onInitFunc2, 2) - assert.Equal(t, 3, AppEventStore().SubscriberCount(EventOnInit)) + ts.app.OnInit(onInitFunc2, 2) + assert.Equal(t, 3, es.SubscriberCount(EventOnInit)) // publish 1 - AppEventStore().sortAndPublishSync(&Event{Name: EventOnInit, Data: "On Init event published 1"}) + es.sortAndPublishSync(&Event{Name: EventOnInit, Data: "On Init event published 1"}) - AppEventStore().Unsubscribe(EventOnInit, onInitFunc2) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnInit)) + es.Unsubscribe(EventOnInit, onInitFunc2) + assert.Equal(t, 2, es.SubscriberCount(EventOnInit)) // publish 2 - PublishEventSync(EventOnInit, "On Init event published 2") + ts.app.PublishEventSync(EventOnInit, "On Init event published 2") - AppEventStore().Unsubscribe(EventOnInit, onInitFunc1) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnInit)) + es.Unsubscribe(EventOnInit, onInitFunc1) + assert.Equal(t, 1, es.SubscriberCount(EventOnInit)) // publish 3 - PublishEventSync(EventOnInit, "On Init event published 3") - PublishEventSync(EventOnStart, "On start not gonna fire") + ts.app.PublishEventSync(EventOnInit, "On Init event published 3") + ts.app.PublishEventSync(EventOnStart, "On start not gonna fire") // event not exists - AppEventStore().Unsubscribe(EventOnStart, onInitFunc1) + es.Unsubscribe(EventOnStart, onInitFunc1) - AppEventStore().Unsubscribe(EventOnInit, onInitFunc3) - assert.Equal(t, 0, AppEventStore().SubscriberCount(EventOnInit)) - assert.Equal(t, 0, AppEventStore().SubscriberCount(EventOnStart)) + es.Unsubscribe(EventOnInit, onInitFunc3) + assert.Equal(t, 0, es.SubscriberCount(EventOnInit)) + assert.Equal(t, 0, es.SubscriberCount(EventOnStart)) // EventOnInit not exists - AppEventStore().Unsubscribe(EventOnInit, onInitFunc3) + es.Unsubscribe(EventOnInit, onInitFunc3) } -func TestOnStartEvent(t *testing.T) { +func TestEventOnStart(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Event Publisher]: %s", ts.URL) + + // declare functions onStartFunc1 := func(e *Event) { t.Log("onStartFunc1:", e) } @@ -78,47 +94,55 @@ func TestOnStartEvent(t *testing.T) { t.Log("onStartFunc3:", e) } - appEventStore = &EventStore{subscribers: make(map[string]EventCallbacks), mu: &sync.Mutex{}} - assert.False(t, AppEventStore().IsEventExists(EventOnStart)) + es := ts.app.eventStore + assert.False(t, es.IsEventExists(EventOnStart)) - OnStart(onStartFunc1) - assert.True(t, AppEventStore().IsEventExists(EventOnStart)) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnStart)) + ts.app.OnStart(onStartFunc1) + assert.True(t, es.IsEventExists(EventOnStart)) + assert.Equal(t, 1, es.SubscriberCount(EventOnStart)) - OnStart(onStartFunc3, 4) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnStart)) + ts.app.OnStart(onStartFunc3, 4) + assert.Equal(t, 2, es.SubscriberCount(EventOnStart)) - OnStart(onStartFunc2, 2) - assert.Equal(t, 3, AppEventStore().SubscriberCount(EventOnStart)) + ts.app.OnStart(onStartFunc2, 2) + assert.Equal(t, 3, es.SubscriberCount(EventOnStart)) // publish 1 - AppEventStore().sortAndPublishSync(&Event{Name: EventOnStart, Data: "On start event published 1"}) + es.sortAndPublishSync(&Event{Name: EventOnStart, Data: "On start event published 1"}) - AppEventStore().Unsubscribe(EventOnStart, onStartFunc2) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnStart)) + es.Unsubscribe(EventOnStart, onStartFunc2) + assert.Equal(t, 2, es.SubscriberCount(EventOnStart)) // publish 2 - PublishEventSync(EventOnStart, "On start event published 2") + ts.app.PublishEventSync(EventOnStart, "On start event published 2") - AppEventStore().Unsubscribe(EventOnStart, onStartFunc1) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnStart)) + es.Unsubscribe(EventOnStart, onStartFunc1) + assert.Equal(t, 1, es.SubscriberCount(EventOnStart)) // publish 3 - AppEventStore().sortAndPublishSync(&Event{Name: EventOnStart, Data: "On start event published 3"}) - PublishEventSync(EventOnInit, "On init not gonna fire") + es.sortAndPublishSync(&Event{Name: EventOnStart, Data: "On start event published 3"}) + ts.app.PublishEventSync(EventOnInit, "On init not gonna fire") // event not exists - AppEventStore().Unsubscribe(EventOnInit, onStartFunc1) + es.Unsubscribe(EventOnInit, onStartFunc1) - AppEventStore().Unsubscribe(EventOnStart, onStartFunc3) - assert.Equal(t, 0, AppEventStore().SubscriberCount(EventOnStart)) - assert.Equal(t, 0, AppEventStore().SubscriberCount(EventOnInit)) + es.Unsubscribe(EventOnStart, onStartFunc3) + assert.Equal(t, 0, es.SubscriberCount(EventOnStart)) + assert.Equal(t, 0, es.SubscriberCount(EventOnInit)) // EventOnInit not exists - AppEventStore().Unsubscribe(EventOnStart, onStartFunc3) + es.Unsubscribe(EventOnStart, onStartFunc3) } -func TestOnShutdownEvent(t *testing.T) { +func TestEventOnShutdown(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Event Publisher]: %s", ts.URL) + + // declare functions onShutdownFunc1 := func(e *Event) { t.Log("onShutdownFunc1:", e) } @@ -131,118 +155,53 @@ func TestOnShutdownEvent(t *testing.T) { t.Log("onShutdownFunc3:", e) } - appEventStore = &EventStore{subscribers: make(map[string]EventCallbacks), mu: &sync.Mutex{}} - assert.False(t, AppEventStore().IsEventExists(EventOnShutdown)) + es := ts.app.eventStore + assert.False(t, es.IsEventExists(EventOnShutdown)) - OnShutdown(onShutdownFunc1) - assert.True(t, AppEventStore().IsEventExists(EventOnShutdown)) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnShutdown)) + ts.app.OnShutdown(onShutdownFunc1) + assert.True(t, es.IsEventExists(EventOnShutdown)) + assert.Equal(t, 1, es.SubscriberCount(EventOnShutdown)) - OnShutdown(onShutdownFunc3, 4) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnShutdown)) + ts.app.OnShutdown(onShutdownFunc3, 4) + assert.Equal(t, 2, es.SubscriberCount(EventOnShutdown)) - OnShutdown(onShutdownFunc2, 2) - assert.Equal(t, 3, AppEventStore().SubscriberCount(EventOnShutdown)) + ts.app.OnShutdown(onShutdownFunc2, 2) + assert.Equal(t, 3, es.SubscriberCount(EventOnShutdown)) // publish 1 - AppEventStore().sortAndPublishSync(&Event{Name: EventOnShutdown, Data: "On shutdown event published 1"}) + es.sortAndPublishSync(&Event{Name: EventOnShutdown, Data: "On shutdown event published 1"}) - AppEventStore().Unsubscribe(EventOnShutdown, onShutdownFunc2) - assert.Equal(t, 2, AppEventStore().SubscriberCount(EventOnShutdown)) + es.Unsubscribe(EventOnShutdown, onShutdownFunc2) + assert.Equal(t, 2, es.SubscriberCount(EventOnShutdown)) // publish 2 - PublishEventSync(EventOnShutdown, "On shutdown event published 2") + ts.app.PublishEventSync(EventOnShutdown, "On shutdown event published 2") - AppEventStore().Unsubscribe(EventOnShutdown, onShutdownFunc1) - assert.Equal(t, 1, AppEventStore().SubscriberCount(EventOnShutdown)) + es.Unsubscribe(EventOnShutdown, onShutdownFunc1) + assert.Equal(t, 1, es.SubscriberCount(EventOnShutdown)) // publish 3 - AppEventStore().sortAndPublishSync(&Event{Name: EventOnShutdown, Data: "On shutdown event published 3"}) - PublishEventSync(EventOnShutdown, "On shutdown not gonna fire") + es.sortAndPublishSync(&Event{Name: EventOnShutdown, Data: "On shutdown event published 3"}) + ts.app.PublishEventSync(EventOnShutdown, "On shutdown not gonna fire") // event not exists - AppEventStore().Unsubscribe(EventOnShutdown, onShutdownFunc1) + es.Unsubscribe(EventOnShutdown, onShutdownFunc1) - AppEventStore().Unsubscribe(EventOnShutdown, onShutdownFunc3) - assert.Equal(t, 0, AppEventStore().SubscriberCount(EventOnShutdown)) + es.Unsubscribe(EventOnShutdown, onShutdownFunc3) + assert.Equal(t, 0, es.SubscriberCount(EventOnShutdown)) // EventOnShutdown not exists - AppEventStore().Unsubscribe(EventOnShutdown, onShutdownFunc3) + es.Unsubscribe(EventOnShutdown, onShutdownFunc3) } +func TestEventSubscribeAndUnsubscribeAndPublish(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() -func TestServerExtensionEvent(t *testing.T) { - // OnRequest - assert.Nil(t, onRequestFunc) - publishOnRequestEvent(&Context{}) - OnRequest(func(e *Event) { - t.Log("OnRequest event func called") - }) - assert.NotNil(t, onRequestFunc) - - onRequestFunc(&Event{Name: EventOnRequest, Data: "request Data OnRequest"}) - publishOnRequestEvent(&Context{}) - OnRequest(func(e *Event) { - t.Log("OnRequest event func called 2") - }) - - // OnPreReply - assert.Nil(t, onPreReplyFunc) - publishOnPreReplyEvent(&Context{}) - OnPreReply(func(e *Event) { - t.Log("OnPreReply event func called") - }) - assert.NotNil(t, onPreReplyFunc) - - onPreReplyFunc(&Event{Name: EventOnPreReply, Data: "Context Data OnPreReply"}) - publishOnPreReplyEvent(&Context{}) - OnPreReply(func(e *Event) { - t.Log("OnPreReply event func called 2") - }) - - // OnAfterReply - assert.Nil(t, onAfterReplyFunc) - publishOnAfterReplyEvent(&Context{}) - OnAfterReply(func(e *Event) { - t.Log("OnAfterReply event func called") - }) - assert.NotNil(t, onAfterReplyFunc) - - onAfterReplyFunc(&Event{Name: EventOnAfterReply, Data: "Context Data OnAfterReply"}) - publishOnAfterReplyEvent(&Context{}) - OnAfterReply(func(e *Event) { - t.Log("OnAfterReply event func called 2") - }) - - // OnPreAuth - assert.Nil(t, onPreAuthFunc) - publishOnPreAuthEvent(&Context{}) - OnPreAuth(func(e *Event) { - t.Log("OnPreAuth event func called") - }) - assert.NotNil(t, onPreAuthFunc) - - onPreAuthFunc(&Event{Name: EventOnPreAuth, Data: "Context Data OnPreAuth"}) - publishOnPreAuthEvent(&Context{}) - OnPreAuth(func(e *Event) { - t.Log("OnPreAuth event func called 2") - }) - - // OnPostAuth - assert.Nil(t, onPostAuthFunc) - publishOnPostAuthEvent(&Context{}) - OnPostAuth(func(e *Event) { - t.Log("OnPostAuth event func called") - }) - assert.NotNil(t, onPostAuthFunc) - - onPostAuthFunc(&Event{Name: EventOnPostAuth, Data: "Context Data OnPostAuth"}) - publishOnPostAuthEvent(&Context{}) - OnPostAuth(func(e *Event) { - t.Log("OnPostAuth event func called 2") - }) -} + t.Logf("Test Server URL [Event Publisher]: %s", ts.URL) -func TestSubscribeAndUnsubscribeAndPublish(t *testing.T) { + // declare functions myEventFunc1 := func(e *Event) { t.Log("myEventFunc1:", e) } @@ -255,33 +214,35 @@ func TestSubscribeAndUnsubscribeAndPublish(t *testing.T) { t.Log("myEventFunc3:", e) } + es := ts.app.eventStore + ecb1 := EventCallback{Callback: myEventFunc1, CallOnce: true} - assert.Equal(t, 0, AppEventStore().SubscriberCount("myEvent1")) - SubscribeEvent("myEvent1", ecb1) - assert.Equal(t, 1, AppEventStore().SubscriberCount("myEvent1")) + assert.Equal(t, 0, es.SubscriberCount("myEvent1")) + ts.app.SubscribeEvent("myEvent1", ecb1) + assert.Equal(t, 1, es.SubscriberCount("myEvent1")) - SubscribeEvent("myEvent1", EventCallback{Callback: myEventFunc2}) - SubscribeEventf("myEvent1", myEventFunc2) - assert.Equal(t, 3, AppEventStore().SubscriberCount("myEvent1")) + ts.app.SubscribeEvent("myEvent1", EventCallback{Callback: myEventFunc2}) + ts.app.SubscribeEventf("myEvent1", myEventFunc2) + assert.Equal(t, 3, es.SubscriberCount("myEvent1")) - assert.Equal(t, 0, AppEventStore().SubscriberCount("myEvent2")) - SubscribeEvent("myEvent2", EventCallback{Callback: myEventFunc3}) - assert.Equal(t, 1, AppEventStore().SubscriberCount("myEvent2")) + assert.Equal(t, 0, es.SubscriberCount("myEvent2")) + ts.app.SubscribeEvent("myEvent2", EventCallback{Callback: myEventFunc3}) + assert.Equal(t, 1, es.SubscriberCount("myEvent2")) - PublishEvent("myEvent2", "myEvent2 is fired async") + ts.app.PublishEvent("myEvent2", "myEvent2 is fired async") time.Sleep(time.Millisecond * 100) // for goroutine to finish - UnsubscribeEvent("myEvent1", ecb1) - assert.Equal(t, 2, AppEventStore().SubscriberCount("myEvent1")) + ts.app.UnsubscribeEvent("myEvent1", ecb1) + assert.Equal(t, 2, es.SubscriberCount("myEvent1")) - PublishEvent("myEvent1", "myEvent1 is fired async") + ts.app.PublishEvent("myEvent1", "myEvent1 is fired async") time.Sleep(time.Millisecond * 100) // for goroutine to finish - PublishEvent("myEventNotExists", nil) + ts.app.PublishEvent("myEventNotExists", nil) - SubscribeEvent("myEvent2", EventCallback{Callback: myEventFunc3, CallOnce: true}) - PublishEvent("myEvent2", "myEvent2 is fired async") + ts.app.SubscribeEvent("myEvent2", EventCallback{Callback: myEventFunc3, CallOnce: true}) + ts.app.PublishEvent("myEvent2", "myEvent2 is fired async") time.Sleep(time.Millisecond * 100) // for goroutine to finish - PublishEventSync("myEvent2", "myEvent2 is fired sync") + ts.app.PublishEventSync("myEvent2", "myEvent2 is fired sync") } diff --git a/i18n.go b/i18n.go index 64a4f271..e9ea3975 100644 --- a/i18n.go +++ b/i18n.go @@ -5,7 +5,6 @@ package aah import ( - "html/template" "path/filepath" "aahframework.org/ahttp.v0" @@ -15,68 +14,58 @@ import ( const keyLocale = "Locale" -var appI18n *i18n.I18n +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ - -// AppDefaultI18nLang method returns aah application i18n default language if -// configured other framework defaults to "en". -func AppDefaultI18nLang() string { - return AppConfig().StringDefault("i18n.default", "en") +func (a *app) I18n() *i18n.I18n { + return a.i18n } -// AppI18n method returns aah application I18n store instance. -func AppI18n() *i18n.I18n { - return appI18n +// DefaultI18nLang method returns application i18n default language if +// configured otherwise framework defaults to "en". +func (a *app) DefaultI18nLang() string { + return a.Config().StringDefault("i18n.default", "en") } -// AppI18nLocales returns all the loaded locales from i18n store -func AppI18nLocales() []string { - if AppI18n() != nil { - return appI18n.Locales() - } - return []string{} -} +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +func (a *app) initI18n() error { + i18nDir := filepath.Join(a.BaseDir(), "i18n") + if !ess.IsFileExists(i18nDir) { + // i18n directory not exists, scenario could be only API application + return nil + } -func appI18nDir() string { - return filepath.Join(AppBaseDir(), "i18n") -} + ai18n := i18n.New() + ai18n.DefaultLocale = a.DefaultI18nLang() + if err := ai18n.Load(i18nDir); err != nil { + return err + } -func initI18n(cfgDir string) error { - if ess.IsFileExists(cfgDir) { - ai18n := i18n.New() - ai18n.DefaultLocale = AppDefaultI18nLang() - if err := ai18n.Load(cfgDir); err != nil { - return err - } + a.i18n = ai18n - appI18n = ai18n - } return nil } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Template methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Template methods +//______________________________________________________________________________ // tmplI18n method is mapped to Go template func for resolving i18n values. -func tmplI18n(viewArgs map[string]interface{}, key string, args ...interface{}) template.HTML { +func (vm *viewManager) tmplI18n(viewArgs map[string]interface{}, key string, args ...interface{}) string { if locale, ok := viewArgs[keyLocale].(*ahttp.Locale); ok { if len(args) == 0 { - return template.HTML(AppI18n().Lookup(locale, key, args...)) + return vm.a.I18n().Lookup(locale, key) } sanatizeArgs := make([]interface{}, 0) for _, value := range args { sanatizeArgs = append(sanatizeArgs, sanatizeValue(value)) } - return template.HTML(AppI18n().Lookup(locale, key, sanatizeArgs...)) + return vm.a.I18n().Lookup(locale, key, sanatizeArgs...) } - return template.HTML("") + return "" } diff --git a/i18n_test.go b/i18n_test.go deleted file mode 100644 index ece21352..00000000 --- a/i18n_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Jeevanandam M. (https://github.com/jeevatkm) -// go-aah/aah source code and usage is governed by a MIT style -// license that can be found in the LICENSE file. - -package aah - -import ( - "path/filepath" - "testing" - - "aahframework.org/ahttp.v0" - "aahframework.org/essentials.v0" - "aahframework.org/test.v0/assert" -) - -func TestI18nAll(t *testing.T) { - testdataPath := getTestdataPath() - i18nDir := filepath.Join(testdataPath, appI18nDir()) - - err := initI18n(i18nDir) - assert.FailNowOnError(t, err, "") - assert.NotNil(t, AppI18n()) - assert.True(t, ess.IsSliceContainsString(AppI18nLocales(), "en")) - - viewArgs := map[string]interface{}{} - localeEnUS := ahttp.ToLocale(&ahttp.AcceptSpec{Value: "en-US", Raw: "en-US"}) - - v1 := tmplI18n(viewArgs, "label.pages.site.get_involved.title") - assert.Equal(t, "", string(v1)) - - viewArgs[keyLocale] = localeEnUS - v2 := tmplI18n(viewArgs, "label.pages.site.get_involved.title") - assert.Equal(t, "en-US: Get Involved - aah web framework for Go", string(v2)) - - v3 := tmplI18n(viewArgs, "label.pages.site.with_args.title", "My Page", 1) - assert.Equal(t, "en-US: My Page no 1 - aah web framework for Go", string(v3)) - - appI18n = nil - assert.True(t, len(AppI18nLocales()) == 0) - assert.Nil(t, initI18n(filepath.Join(i18nDir, "not-exists"))) -} diff --git a/log.go b/log.go index 0cb2cf5a..a109a54a 100644 --- a/log.go +++ b/log.go @@ -7,55 +7,56 @@ package aah import ( "path/filepath" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/log.v0" ) -var appLogger *log.Logger +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ +func (a *app) Log() log.Loggerer { + return a.logger +} // AddLoggerHook method adds given logger into aah application default logger. -func AddLoggerHook(name string, hook log.HookFunc) error { - return appLogger.AddHook(name, hook) +func (a *app) AddLoggerHook(name string, hook log.HookFunc) error { + return a.Log().(*log.Logger).AddHook(name, hook) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ -func initLogs(logsDir string, appCfg *config.Config) error { - if !appCfg.IsExists("log") { - log.Debug("Section 'log {...}' configuration not exists, move on.") - return nil +func (a *app) initLog() error { + if !a.Config().IsExists("log") { + log.Warn("Section 'log { ... }' configuration does not exists, initializing app logger with default values.") } - if appCfg.StringDefault("log.receiver", "") == "file" { - file := appCfg.StringDefault("log.file", "") + if a.Config().StringDefault("log.receiver", "") == "file" { + file := a.Config().StringDefault("log.file", "") if ess.IsStrEmpty(file) { - appCfg.SetString("log.file", filepath.Join(logsDir, getBinaryFileName()+".log")) + a.Config().SetString("log.file", filepath.Join(a.logsDir(), a.binaryFilename()+".log")) } else if !filepath.IsAbs(file) { - appCfg.SetString("log.file", filepath.Join(logsDir, file)) + a.Config().SetString("log.file", filepath.Join(a.logsDir(), file)) } } - if !appCfg.IsExists("log.pattern") { - appCfg.SetString("log.pattern", "%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields") + if !a.Config().IsExists("log.pattern") { + a.Config().SetString("log.pattern", "%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields") } - al, err := log.New(appCfg) + al, err := log.New(a.Config()) if err != nil { return err } - appLogger = al - appLogger.AddContext(log.Fields{ - "appname": AppName(), - "insname": AppInstanceName(), + al.AddContext(log.Fields{ + "appname": a.Name(), + "insname": a.InstanceName(), }) - log.SetDefaultLogger(appLogger) + + a.logger = al + log.SetDefaultLogger(al) return nil } diff --git a/log_test.go b/log_test.go index 847b232c..4f45d170 100644 --- a/log_test.go +++ b/log_test.go @@ -5,10 +5,8 @@ package aah import ( - "errors" "path/filepath" "testing" - "time" "aahframework.org/config.v0" "aahframework.org/essentials.v0" @@ -16,62 +14,42 @@ import ( "aahframework.org/test.v0/assert" ) -func TestAahLogDir(t *testing.T) { - logsDir := filepath.Join(getTestdataPath(), appLogsDir()) - logFile := filepath.Join(logsDir, "test.log") - defer ess.DeleteFiles(logsDir) +func TestLogInitRelativeFilePath(t *testing.T) { + logPath := filepath.Join(testdataBaseDir(), "sample-test-app.log") + defer ess.DeleteFiles(logPath) - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) - assert.Nil(t, err) - - err = initLogs(logsDir, AppConfig()) - assert.Nil(t, err) + // Relative path file + a := newApp() + cfg, _ := config.ParseString(`log { + receiver = "file" + file = "sample-test-app.log" + }`) + a.cfg = cfg - AppConfig().SetString("log.receiver", "file") - AppConfig().SetString("log.file", logFile) - err = initLogs(logsDir, AppConfig()) + err := a.initLog() assert.Nil(t, err) - assert.True(t, ess.IsFileExists(logFile)) - cfg, _ := config.ParseString("") - logger, _ := log.New(cfg) - log.SetDefaultLogger(logger) - err = AddLoggerHook("defaulthook", func(e log.Entry) { - logger.Info(e) + a.AddLoggerHook("myapphook", func(e log.Entry) { + t.Logf("%v", e) }) - assert.Nil(t, err) +} - // relative path filename - cfgRelativeFile, _ := config.ParseString(` - log { - receiver = "file" - file = "my-test-file.log" - } - `) - err = initLogs(logsDir, cfgRelativeFile) - assert.Nil(t, err) +func TestLogInitNoFilePath(t *testing.T) { + // No file input - auto location + logPath := filepath.Join(testdataBaseDir(), "wepapp1.log") + defer ess.DeleteFiles(logPath) - // no filename mentioned - cfgNoFile, _ := config.ParseString(` - log { - receiver = "file" - } - `) - SetAppBuildInfo(&BuildInfo{ - BinaryName: "testapp", - Date: time.Now().Format(time.RFC3339), - Version: "1.0.0", - }) - err = initLogs(logsDir, cfgNoFile) - assert.Nil(t, err) - appBuildInfo = nil + // Relative path file + a := newApp() + cfg, _ := config.ParseString(`log { + receiver = "file" + }`) + a.cfg = cfg - appLogFatal = func(v ...interface{}) { t.Log(v) } - logAsFatal(errors.New("test msg")) + err := a.initLog() + assert.Nil(t, err) - logger2 := NewChildLogger(log.Fields{ - "myname": "I'm child logger", + a.AddLoggerHook("myapphook", func(e log.Entry) { + t.Logf("%v", e) }) - logger2.Info("Hi child logger") } diff --git a/middleware.go b/middleware.go index 404f048f..33c73c84 100644 --- a/middleware.go +++ b/middleware.go @@ -11,32 +11,12 @@ import ( "aahframework.org/log.v0" ) -var ( - mwStack []MiddlewareFunc - mwChain []*Middleware -) - -type ( - // MiddlewareFunc func type is aah framework middleware signature. - MiddlewareFunc func(ctx *Context, m *Middleware) +// MiddlewareFunc func type is aah framework middleware signature. +type MiddlewareFunc func(ctx *Context, m *Middleware) - // Middleware struct is to implement aah framework middleware chain. - Middleware struct { - next MiddlewareFunc - further *Middleware - } -) - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Package methods -//___________________________________ - -// Middlewares method adds given middleware into middleware stack -func Middlewares(middlewares ...MiddlewareFunc) { - mwStack = append(mwStack, middlewares...) - - invalidateMwChain() -} +//______________________________________________________________________________ // ToMiddleware method expands the possibilities. It helps Golang community to // convert the third-party or your own net/http middleware into `aah.MiddlewareFunc` @@ -63,9 +43,15 @@ func ToMiddleware(handler interface{}) MiddlewareFunc { } } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Middleware methods -//___________________________________ +//______________________________________________________________________________ + +// Middleware struct is to implement aah framework middleware chain. +type Middleware struct { + next MiddlewareFunc + further *Middleware +} // Next method calls next middleware in the chain if available. func (mw *Middleware) Next(ctx *Context) { @@ -79,44 +65,81 @@ func (mw *Middleware) Next(ctx *Context) { } } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// engine - Middleware +//______________________________________________________________________________ + +// Middlewares method adds given middleware into middleware stack +func (e *engine) Middlewares(middlewares ...MiddlewareFunc) { + e.mwStack = append(e.mwStack, middlewares...) + + e.invalidateMwChain() +} + +func (e *engine) invalidateMwChain() { + e.mwChain = nil + cnt := len(e.mwStack) + e.mwChain = make([]*Middleware, cnt) + + for idx := 0; idx < cnt; idx++ { + e.mwChain[idx] = &Middleware{next: e.mwStack[idx]} + } + + for idx := cnt - 1; idx > 0; idx-- { + e.mwChain[idx-1].further = e.mwChain[idx] + } + + e.mwChain[cnt-1].further = &Middleware{} +} + +type beforeInterceptor interface { + Before() +} + +type afterInterceptor interface { + After() +} + +type finallyInterceptor interface { + Finally() +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Action middleware -//___________________________________ +//______________________________________________________________________________ // ActionMiddleware performs // - Executes Interceptors (Before, Before, After, After, // Panic, Panic, Finally, Finally) // - Invokes Controller Action func ActionMiddleware(ctx *Context, m *Middleware) { - target := reflect.ValueOf(ctx.target) - controller := resolveControllerName(ctx) - // Finally action and method. Always executed if present defer func() { - if finallyActionMethod := target.MethodByName(incpFinallyActionName + ctx.action.Name); finallyActionMethod.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpFinallyActionName+ctx.action.Name) + if finallyActionMethod := ctx.targetrv.MethodByName(incpFinallyActionName + ctx.action.Name); finallyActionMethod.IsValid() { + ctx.Log().Debugf("Calling interceptor: %s.%s", ctx.controller.FqName, incpFinallyActionName+ctx.action.Name) finallyActionMethod.Call(emptyArg) } - if finallyAction := target.MethodByName(incpFinallyActionName); finallyAction.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpFinallyActionName) - finallyAction.Call(emptyArg) + // Finally: executes always if its implemented. Its applicable for every action in the controller + if cntrl, ok := ctx.target.(finallyInterceptor); ok { + ctx.Log().Debugf("Calling interceptor: %s.Finally", ctx.controller.FqName) + cntrl.Finally() } }() // Panic action and method defer func() { if r := recover(); r != nil { - if ctx.abort { - return - } + // if ctx.abort { + // return + // } - if panicActionMethod := target.MethodByName(incpPanicActionName + ctx.action.Name); panicActionMethod.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpPanicActionName+ctx.action.Name) + if panicActionMethod := ctx.targetrv.MethodByName(incpPanicActionName + ctx.action.Name); panicActionMethod.IsValid() { + ctx.Log().Debugf("Calling interceptor: %s.%s", ctx.controller.FqName, incpPanicActionName+ctx.action.Name) rv := append([]reflect.Value{}, reflect.ValueOf(r)) panicActionMethod.Call(rv) - } else if panicAction := target.MethodByName(incpPanicActionName); panicAction.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpPanicActionName) + } else if panicAction := ctx.targetrv.MethodByName(incpPanicActionName); panicAction.IsValid() { + ctx.Log().Debugf("Calling interceptor: %s.%s", ctx.controller.FqName, incpPanicActionName) rv := append([]reflect.Value{}, reflect.ValueOf(r)) panicAction.Call(rv) } else { // propagate it @@ -125,88 +148,36 @@ func ActionMiddleware(ctx *Context, m *Middleware) { } }() - // Before action - if beforeAction := target.MethodByName(incpBeforeActionName); beforeAction.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpBeforeActionName) - beforeAction.Call(emptyArg) + // Before: executes before every action in the controller + if cntrl, ok := ctx.target.(beforeInterceptor); ok { + ctx.Log().Debugf("Calling interceptor: %s.Before", ctx.controller.FqName) + cntrl.Before() } // Before action method if !ctx.abort { - if beforeActionMethod := target.MethodByName(incpBeforeActionName + ctx.action.Name); beforeActionMethod.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpBeforeActionName+ctx.action.Name) + if beforeActionMethod := ctx.targetrv.MethodByName(incpBeforeActionName + ctx.action.Name); beforeActionMethod.IsValid() { + ctx.Log().Debugf("Calling interceptor: %s.%s", ctx.controller.FqName, incpBeforeActionName+ctx.action.Name) beforeActionMethod.Call(emptyArg) } } - // Invokes Controller action - invokeAction(ctx) + // Calls controller action + ctx.callAction() // After action method if !ctx.abort { - if afterActionMethod := target.MethodByName(incpAfterActionName + ctx.action.Name); afterActionMethod.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpAfterActionName+ctx.action.Name) + if afterActionMethod := ctx.targetrv.MethodByName(incpAfterActionName + ctx.action.Name); afterActionMethod.IsValid() { + ctx.Log().Debugf("Calling interceptor: %s.%s", ctx.controller.FqName, incpAfterActionName+ctx.action.Name) afterActionMethod.Call(emptyArg) } } - // After action + // After: executes after every action in the controller if !ctx.abort { - if afterAction := target.MethodByName(incpAfterActionName); afterAction.IsValid() { - ctx.Log().Debugf("Calling interceptor: %s.%s", controller, incpAfterActionName) - afterAction.Call(emptyArg) + if cntrl, ok := ctx.target.(afterInterceptor); ok { + ctx.Log().Debugf("Calling interceptor: %s.After", ctx.controller.FqName) + cntrl.After() } } } - -// invokeAction calls the requested action on controller -func invokeAction(ctx *Context) { - target := reflect.ValueOf(ctx.target) - controllerName := resolveControllerName(ctx) - action := target.MethodByName(ctx.action.Name) - - if !action.IsValid() { - ctx.Log().Errorf("Action '%s' doesn't exists on controller '%s'", ctx.action.Name, controllerName) - ctx.Reply().Error(&Error{ - Reason: ErrControllerOrActionNotFound, - Code: http.StatusNotFound, - Message: http.StatusText(http.StatusNotFound), - }) - return - } - - ctx.Log().Debugf("Calling controller: %s.%s", controllerName, ctx.action.Name) - - // Parse Action Parameters - actionArgs, err := parseParameters(ctx) - if err != nil { // Any error of parameter parsing result in 400 Bad Request - ctx.Reply().Error(err) - return - } - - if action.Type().IsVariadic() { - action.CallSlice(actionArgs) - } else { - action.Call(actionArgs) - } -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -func invalidateMwChain() { - mwChain = nil - cnt := len(mwStack) - mwChain = make([]*Middleware, cnt) - - for idx := 0; idx < cnt; idx++ { - mwChain[idx] = &Middleware{next: mwStack[idx]} - } - - for idx := cnt - 1; idx > 0; idx-- { - mwChain[idx-1].further = mwChain[idx] - } - - mwChain[cnt-1].further = &Middleware{} -} diff --git a/middleware_test.go b/middleware_test.go index bdb3ca04..036c62d2 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -6,7 +6,6 @@ package aah import ( "fmt" - "io/ioutil" "net/http" "net/http/httptest" "strings" @@ -27,8 +26,10 @@ func (th *TestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func TestMiddlewareToHandler(t *testing.T) { - mwStack = make([]MiddlewareFunc, 0) - mwStack = append(mwStack, + a := newApp() + e := a.engine + + e.Middlewares( ToMiddleware(thirdPartyMiddleware3), ToMiddleware(http.HandlerFunc(thirdPartyMiddleware2)), ToMiddleware(&TestHandler{}), @@ -36,25 +37,20 @@ func TestMiddlewareToHandler(t *testing.T) { ToMiddleware(invaildHandlerType), ) - invalidateMwChain() - - req := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - ctx := &Context{ - Req: ahttp.ParseRequest(req, &ahttp.Request{}), - Res: ahttp.GetResponseWriter(httptest.NewRecorder()), - } + req := httptest.NewRequest(ahttp.MethodGet, "http://localhost:8080/doc/v0.3/mydoc.html", nil) + ctx := newContext(httptest.NewRecorder(), req) // Execute the middleware - mwChain[0].Next(ctx) + e.mwChain[0].Next(ctx) w := ctx.Res.Unwrap().(*httptest.ResponseRecorder) resp := w.Result() - body, _ := ioutil.ReadAll(resp.Body) - t.Log(string(body)) + body := responseBody(resp) + t.Log(body) assert.Equal(t, http.StatusAccepted, resp.StatusCode) - assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) - assert.True(t, strings.Contains(string(body), "localhost:8080--GET--/doc/v0.3/mydoc.html")) + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get(ahttp.HeaderContentType)) + assert.True(t, strings.Contains(body, "localhost:8080--GET--/doc/v0.3/mydoc.html")) } func thirdPartyMiddleware1(w http.ResponseWriter, r *http.Request) { diff --git a/render.go b/render.go index 4a8fff07..b0cf1ff8 100644 --- a/render.go +++ b/render.go @@ -12,9 +12,7 @@ import ( "html/template" "io" "os" - "path/filepath" "strings" - "sync" "aahframework.org/essentials.v0" ) @@ -27,14 +25,9 @@ var ( JSONMarshalIndent func(v interface{}, prefix, indent string) ([]byte, error) xmlHeaderBytes = []byte(xml.Header) - rdrHTMLPool = &sync.Pool{New: func() interface{} { return &HTML{} }} - rdrJSONPool = &sync.Pool{New: func() interface{} { return &JSON{} }} - rdrXMLPool = &sync.Pool{New: func() interface{} { return &XML{} }} ) type ( - // Data type used for convenient data type of map[string]interface{} - Data map[string]interface{} // Render interface to various rendering classifcation for HTTP responses. Render interface { @@ -44,124 +37,120 @@ type ( // RenderFunc type is an adapter to allow the use of regular function as // custom Render. RenderFunc func(w io.Writer) error - - // Text renders the response as plain text - Text struct { - Format string - Values []interface{} - } - - // JSON renders the response JSON content. - JSON struct { - IsJSONP bool - Callback string - Data interface{} - } - - // XML renders the response XML content. - XML struct { - Data interface{} - } - - // Binary renders given path or io.Reader into response and closes the file. - Binary struct { - Path string - Reader io.Reader - } - - // HTML renders the given HTML into response with given model data. - HTML struct { - Template *template.Template - Layout string - Filename string - ViewArgs Data - } ) -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // RenderFunc methods -//___________________________________ +//______________________________________________________________________________ // Render method is implementation of Render interface in the adapter type. func (rf RenderFunc) Render(w io.Writer) error { return rf(w) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Plain Text Render methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Plain Text Render +//______________________________________________________________________________ + +// textRender renders the response as plain text +type textRender struct { + Format string + Values []interface{} +} -// Render method writes Text into HTTP response. -func (t *Text) Render(w io.Writer) (err error) { +// textRender method writes given text into HTTP response. +func (t textRender) Render(w io.Writer) (err error) { if len(t.Values) > 0 { _, err = fmt.Fprintf(w, t.Format, t.Values...) } else { - _, err = fmt.Fprint(w, t.Format) + _, err = io.WriteString(w, t.Format) } return } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// JSON Render methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// JSON Render +//______________________________________________________________________________ + +// jsonRender renders the response JSON content. +type jsonRender struct { + Data interface{} + r *Reply +} // Render method writes JSON into HTTP response. -func (j *JSON) Render(w io.Writer) error { - var ( - bytes []byte - err error - ) - - if appConfig.BoolDefault("render.pretty", false) { - bytes, err = JSONMarshalIndent(j.Data, "", " ") +func (j jsonRender) Render(w io.Writer) error { + var jsonBytes []byte + var err error + + if j.r.ctx.a.renderPretty { + jsonBytes, err = JSONMarshalIndent(j.Data, "", " ") } else { - bytes, err = JSONMarshal(j.Data) + jsonBytes, err = JSONMarshal(j.Data) } if err != nil { return err } - if j.IsJSONP { - if _, err = w.Write([]byte(j.Callback + "(")); err != nil { - return err - } + _, err = w.Write(jsonBytes) + return err +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// JSONP Render +//______________________________________________________________________________ + +// jsonpRender renders the JSONP response. +type jsonpRender struct { + Callback string + Data interface{} + r *Reply +} + +// Render method writes JSONP into HTTP response. +func (j jsonpRender) Render(w io.Writer) error { + var jsonBytes []byte + var err error + + if j.r.ctx.a.renderPretty { + jsonBytes, err = JSONMarshalIndent(j.Data, "", " ") + } else { + jsonBytes, err = JSONMarshal(j.Data) } - if _, err = w.Write(bytes); err != nil { + if err != nil { return err } - if j.IsJSONP { - if _, err = w.Write([]byte(");")); err != nil { - return err - } + if ess.IsStrEmpty(j.Callback) { + _, err = w.Write(jsonBytes) + } else { + _, err = fmt.Fprintf(w, "%s(%s);", j.Callback, jsonBytes) } - return nil + return err } -func (j *JSON) reset() { - j.Callback = "" - j.IsJSONP = false - j.Data = nil -} +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// XML Render +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// XML Render methods -//___________________________________ +// xmlRender renders the response XML content. +type xmlRender struct { + Data interface{} + r *Reply +} // Render method writes XML into HTTP response. -func (x *XML) Render(w io.Writer) error { - var ( - bytes []byte - err error - ) - - if appConfig.BoolDefault("render.pretty", false) { - bytes, err = xml.MarshalIndent(x.Data, "", " ") +func (x xmlRender) Render(w io.Writer) error { + var xmlBytes []byte + var err error + + if x.r.ctx.a.renderPretty { + xmlBytes, err = xml.MarshalIndent(x.Data, "", " ") } else { - bytes, err = xml.Marshal(x.Data) + xmlBytes, err = xml.Marshal(x.Data) } if err != nil { @@ -172,16 +161,19 @@ func (x *XML) Render(w io.Writer) error { return err } - if _, err = w.Write(bytes); err != nil { + if _, err = w.Write(xmlBytes); err != nil { return err } return nil } -func (x *XML) reset() { - x.Data = nil -} +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Data +//______________________________________________________________________________ + +// Data type used for convenient data type of map[string]interface{} +type Data map[string]interface{} // MarshalXML method is to marshal `aah.Data` into XML. func (d Data) MarshalXML(e *xml.Encoder, start xml.StartElement) error { @@ -206,22 +198,24 @@ func (d Data) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.Flush() } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// File and Reader Render methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Reader Render +//______________________________________________________________________________ + +// Binary renders given path or io.Reader into response and closes the file. +type binaryRender struct { + Path string + Reader io.Reader +} // Render method writes File into HTTP response. -func (f *Binary) Render(w io.Writer) error { +func (f binaryRender) Render(w io.Writer) error { if f.Reader != nil { defer ess.CloseQuietly(f.Reader) _, err := io.Copy(w, f.Reader) return err } - if !filepath.IsAbs(f.Path) { - f.Path = filepath.Join(AppBaseDir(), "static", f.Path) - } - file, err := os.Open(f.Path) if err != nil { return err @@ -241,78 +235,31 @@ func (f *Binary) Render(w io.Writer) error { return err } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// HTML Render methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// HTML Render +//______________________________________________________________________________ + +// htmlRender renders the given HTML template into response with given model data. +type htmlRender struct { + Template *template.Template + Layout string + Filename string + ViewArgs Data +} // Render method renders the HTML template into HTTP response. -func (h *HTML) Render(w io.Writer) error { +func (h htmlRender) Render(w io.Writer) error { if h.Template == nil { return errors.New("template is nil") } - if ess.IsStrEmpty(h.Layout) { + if h.Layout == "" { return h.Template.Execute(w, h.ViewArgs) } return h.Template.ExecuteTemplate(w, h.Layout, h.ViewArgs) } -func (h *HTML) reset() { - h.Template = nil - h.Filename = "" - h.Layout = "" - h.ViewArgs = make(Data) -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Render Unexported methods -//___________________________________ - -// doRender method renders and detects the errors earlier. Writes the -// error info if any. -func (e *engine) doRender(ctx *Context) { - if ctx.Reply().Rdr != nil { - ctx.Reply().body = acquireBuffer() - if err := ctx.Reply().Rdr.Render(ctx.Reply().body); err != nil { - ctx.Log().Error("Render response body error: ", err) - - // panic would be appropriate here, since it handle by centralized error - // handler. Funny though this is second spot in entire aah framework - // the `panic` used other then panic interceptor for propagtion. - panic(err) - } - } -} - -func acquireHTML() *HTML { - return rdrHTMLPool.Get().(*HTML) -} - -func acquireJSON() *JSON { - return rdrJSONPool.Get().(*JSON) -} - -func acquireXML() *XML { - return rdrXMLPool.Get().(*XML) -} - -func releaseRender(r Render) { - if r != nil { - switch t := r.(type) { - case *JSON: - t.reset() - rdrJSONPool.Put(t) - case *HTML: - t.reset() - rdrHTMLPool.Put(t) - case *XML: - t.reset() - rdrXMLPool.Put(t) - } - } -} - func init() { // Registering default standard JSON library JSONMarshal = json.Marshal diff --git a/render_test.go b/render_test.go index 21420e86..4ebe5380 100644 --- a/render_test.go +++ b/render_test.go @@ -6,20 +6,18 @@ package aah import ( "bytes" - "html/template" "os" "path/filepath" "strings" "testing" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/test.v0/assert" ) func TestRenderText(t *testing.T) { buf := &bytes.Buffer{} - text1 := Text{ + text1 := textRender{ Format: "welcome to %s %s", Values: []interface{}{"aah", "framework"}, } @@ -29,7 +27,7 @@ func TestRenderText(t *testing.T) { assert.Equal(t, "welcome to aah framework", buf.String()) buf.Reset() - text2 := Text{Format: "welcome to aah framework"} + text2 := textRender{Format: "welcome to aah framework"} err = text2.Render(buf) assert.FailOnError(t, err, "") @@ -37,8 +35,7 @@ func TestRenderText(t *testing.T) { } func TestRenderJSON(t *testing.T) { - buf := &bytes.Buffer{} - appConfig = getRenderCfg() + buf := acquireBuffer() data := struct { Name string @@ -50,96 +47,86 @@ func TestRenderJSON(t *testing.T) { Address: "this is my street", } - json1 := JSON{Data: data} + a := newApp() + reply := newReply(&Context{a: a}) + json1 := jsonRender{Data: data, r: reply} err := json1.Render(buf) assert.FailOnError(t, err, "") - assert.Equal(t, `{ - "Name": "John", - "Age": 28, - "Address": "this is my street" -}`, buf.String()) - - buf.Reset() - appConfig.SetBool("render.pretty", false) - - err = json1.Render(buf) - assert.FailOnError(t, err, "") assert.Equal(t, `{"Name":"John","Age":28,"Address":"this is my street"}`, buf.String()) } -func TestRenderJSONP(t *testing.T) { - buf := &bytes.Buffer{} - appConfig = getRenderCfg() - - data := struct { - Name string - Age int - Address string - }{ - Name: "John", - Age: 28, - Address: "this is my street", - } - - json1 := JSON{Data: data, IsJSONP: true, Callback: "mycallback"} - err := json1.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `mycallback({ - "Name": "John", - "Age": 28, - "Address": "this is my street" -});`, buf.String()) - - buf.Reset() - appConfig.SetBool("render.pretty", false) - - err = json1.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `mycallback({"Name":"John","Age":28,"Address":"this is my street"});`, - buf.String()) -} - -func TestRenderXML(t *testing.T) { - buf := &bytes.Buffer{} - appConfig = getRenderCfg() - - type Sample struct { - Name string - Age int - Address string - } - - data := Sample{ - Name: "John", - Age: 28, - Address: "this is my street", - } - - xml1 := XML{Data: data} - err := xml1.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` - - John - 28 -
this is my street
-
`, buf.String()) - - buf.Reset() - - appConfig.SetBool("render.pretty", false) - - err = xml1.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -John28
this is my street
`, - buf.String()) -} - +// func TestRenderJSONP(t *testing.T) { +// buf := acquireBuffer() +// +// data := struct { +// Name string +// Age int +// Address string +// }{ +// Name: "John", +// Age: 28, +// Address: "this is my street", +// } +// +// renderPretty = true +// json1 := jsonpRender{Data: data, Callback: "mycallback"} +// err := json1.Render(buf) +// assert.FailOnError(t, err, "") +// assert.Equal(t, `mycallback({ +// "Name": "John", +// "Age": 28, +// "Address": "this is my street" +// });`, buf.String()) +// +// buf.Reset() +// renderPretty = false +// +// err = json1.Render(buf) +// assert.FailOnError(t, err, "") +// assert.Equal(t, `mycallback({"Name":"John","Age":28,"Address":"this is my street"});`, +// buf.String()) +// } +// +// func TestRenderXML(t *testing.T) { +// buf := acquireBuffer() +// +// type Sample struct { +// Name string +// Age int +// Address string +// } +// +// data := Sample{ +// Name: "John", +// Age: 28, +// Address: "this is my street", +// } +// +// renderPretty = true +// xml1 := xmlRender{Data: data} +// err := xml1.Render(buf) +// assert.FailOnError(t, err, "") +// assert.Equal(t, ` +// +// John +// 28 +//
this is my street
+//
`, buf.String()) +// +// buf.Reset() +// +// renderPretty = false +// +// err = xml1.Render(buf) +// assert.FailOnError(t, err, "") +// assert.Equal(t, ` +// John28
this is my street
`, +// buf.String()) +// } +// func TestRenderFailureXML(t *testing.T) { - buf := &bytes.Buffer{} - appConfig = getRenderCfg() + buf := new(bytes.Buffer) data := struct { Name string @@ -151,109 +138,40 @@ func TestRenderFailureXML(t *testing.T) { Address: "this is my street", } - xml1 := XML{Data: data} + a := newApp() + reply := newReply(&Context{a: a}) + xml1 := xmlRender{Data: data, r: reply} err := xml1.Render(buf) assert.Equal(t, "xml: unsupported type: struct { Name string; Age int; Address string }", err.Error()) } -func TestRenderFileAndReader(t *testing.T) { - f, _ := os.Open(getRenderFilepath("file1.txt")) - defer ess.CloseQuietly(f) - - buf := &bytes.Buffer{} - file1 := Binary{Reader: f} - - err := file1.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -Each incoming request passes through a pre-defined list of steps -`, buf.String()) - - // Reader - buf.Reset() - file2 := Binary{Path: getRenderFilepath("file1.txt")} - err = file2.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -Each incoming request passes through a pre-defined list of steps -`, buf.String()) - - // Reader string - buf.Reset() - file3 := Binary{Reader: strings.NewReader(`John28
this is my street
`)} - err = file3.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `John28
this is my street
`, - buf.String()) +func TestRenderFileNotExistsAndDir(t *testing.T) { + buf := new(bytes.Buffer) // Directory error - buf.Reset() - file4 := Binary{Path: os.Getenv("HOME")} - err = file4.Render(buf) + // buf.Reset() + file1 := binaryRender{Path: os.Getenv("HOME")} + err := file1.Render(buf) assert.NotNil(t, err) assert.True(t, strings.Contains(err.Error(), "is a directory")) assert.True(t, ess.IsStrEmpty(buf.String())) // File not exists - file5 := Binary{Path: filepath.Join(getTestdataPath(), "file-not-exists.txt")[1:]} - err = file5.Render(buf) + file2 := binaryRender{Path: filepath.Join(testdataBaseDir(), "file-not-exists.txt")[1:]} + err = file2.Render(buf) assert.NotNil(t, err) assert.True(t, strings.Contains(err.Error(), "file-not-exists.txt: no such file or directory")) assert.True(t, ess.IsStrEmpty(buf.String())) } -func TestHTMLRender(t *testing.T) { - tmplStr := ` - {{ define "title" }}This is test title{{ end }} - {{ define "body" }}

This is test body

{{ end }} - ` - - tmpl := template.Must(template.New("test").Parse(tmplStr)) - assert.NotNil(t, tmpl) - - masterTmpl := getRenderFilepath(filepath.Join("views", "master.html")) - _, err := tmpl.ParseFiles(masterTmpl) - assert.Nil(t, err) - - htmlRdr := HTML{ - Layout: "master", - Template: tmpl, - ViewArgs: nil, - } - - var buf bytes.Buffer - err = htmlRdr.Render(&buf) - assert.Nil(t, err) - assert.True(t, strings.Contains(buf.String(), "This is test title")) - assert.True(t, strings.Contains(buf.String(), "

This is test body

")) - - buf.Reset() - htmlRdr.Layout = "" - err = htmlRdr.Render(&buf) - assert.Nil(t, err) - assert.True(t, ess.IsStrEmpty(buf.String())) - +func TestHTMLRenderTmplNil(t *testing.T) { // Template is Nil - htmlTmplNil := HTML{ + htmlTmplNil := htmlRender{ Layout: "master", } - buf.Reset() - err = htmlTmplNil.Render(&buf) + var buf bytes.Buffer + err := htmlTmplNil.Render(&buf) assert.NotNil(t, err) assert.Equal(t, "template is nil", err.Error()) } - -func getRenderCfg() *config.Config { - cfg, _ := config.ParseString(` - render { - pretty = true - } - `) - return cfg -} - -func getRenderFilepath(name string) string { - wd, _ := os.Getwd() - return filepath.Join(wd, "testdata", "render", name) -} diff --git a/reply.go b/reply.go index 90791965..65bf5900 100644 --- a/reply.go +++ b/reply.go @@ -8,6 +8,7 @@ import ( "bytes" "io" "net/http" + "path/filepath" "strings" "sync" @@ -16,16 +17,15 @@ import ( ) var ( - bufPool = &sync.Pool{New: func() interface{} { return &bytes.Buffer{} }} - replyPool = &sync.Pool{New: func() interface{} { return NewReply() }} + bufPool = &sync.Pool{New: func() interface{} { return new(bytes.Buffer) }} ) // Reply gives you control and convenient way to write a response effectively. type Reply struct { Code int ContType string - Hdr http.Header Rdr Render + body *bytes.Buffer cookies []*http.Cookie redirect bool @@ -33,24 +33,12 @@ type Reply struct { done bool gzip bool err *Error + ctx *Context } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ - -// NewReply method returns the new instance on reply builder. -func NewReply() *Reply { - return &Reply{ - Hdr: http.Header{}, - Code: http.StatusOK, - gzip: true, - } -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Reply methods - Code Codes -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Reply - HTTP Status Code +//______________________________________________________________________________ // Status method sets the HTTP Code code for the response. // Also Reply instance provides easy to use method for very frequently used @@ -135,9 +123,9 @@ func (r *Reply) ServiceUnavailable() *Reply { return r.Status(http.StatusServiceUnavailable) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Reply methods - Content Types -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Reply - Content Types +//______________________________________________________________________________ // ContentType method sets given Content-Type string for the response. // Also Reply instance provides easy to use method for very frequently used @@ -146,50 +134,43 @@ func (r *Reply) ServiceUnavailable() *Reply { // By default aah framework try to determine response 'Content-Type' from // 'ahttp.Request.AcceptContentType'. func (r *Reply) ContentType(contentType string) *Reply { - r.ContType = strings.ToLower(contentType) + if ess.IsStrEmpty(r.ContType) { + r.ContType = strings.ToLower(contentType) + } return r } -// JSON method renders given data as JSON response. -// Also it sets HTTP 'Content-Type' as 'application/json; charset=utf-8'. +// JSON method renders given data as JSON response +// and it sets HTTP 'Content-Type' as 'application/json; charset=utf-8'. // Response rendered pretty if 'render.pretty' is true. func (r *Reply) JSON(data interface{}) *Reply { - j := acquireJSON() - j.Data = data - r.Rdr = j - r.ContentType(ahttp.ContentTypeJSON.Raw()) + r.ContentType(ahttp.ContentTypeJSON.String()) + r.Render(&jsonRender{Data: data, r: r}) return r } -// JSONP method renders given data as JSONP response with callback. -// Also it sets HTTP 'Content-Type' as 'application/json; charset=utf-8'. -// Response rendered pretty if 'render.pretty' is true. +// JSONP method renders given data as JSONP response with callback +// and it sets HTTP 'Content-Type' as 'application/javascript; charset=utf-8'. func (r *Reply) JSONP(data interface{}, callback string) *Reply { - j := acquireJSON() - j.Data = data - j.IsJSONP = true - j.Callback = callback - r.Rdr = j - r.ContentType(ahttp.ContentTypeJSON.Raw()) + r.ContentType(ahttp.ContentTypeJavascript.String()) + r.Render(&jsonpRender{Data: data, Callback: callback, r: r}) return r } -// XML method renders given data as XML response. Also it sets +// XML method renders given data as XML response and it sets // HTTP Content-Type as 'application/xml; charset=utf-8'. // Response rendered pretty if 'render.pretty' is true. func (r *Reply) XML(data interface{}) *Reply { - x := acquireXML() - x.Data = data - r.Rdr = x - r.ContentType(ahttp.ContentTypeXML.Raw()) + r.ContentType(ahttp.ContentTypeXML.String()) + r.Render(&xmlRender{Data: data, r: r}) return r } -// Text method renders given data as Plain Text response with given values. -// Also it sets HTTP Content-Type as 'text/plain; charset=utf-8'. +// Text method renders given data as Plain Text response with given values +// and it sets HTTP Content-Type as 'text/plain; charset=utf-8'. func (r *Reply) Text(format string, values ...interface{}) *Reply { - r.Rdr = &Text{Format: format, Values: values} - r.ContentType(ahttp.ContentTypePlainText.Raw()) + r.ContentType(ahttp.ContentTypePlainText.String()) + r.Render(&textRender{Format: format, Values: values}) return r } @@ -201,16 +182,23 @@ func (r *Reply) Binary(b []byte) *Reply { // Readfrom method reads the data from given reader and writes into response. // It auto-detects the content type of the file if `Content-Type` is not set. +// // Note: Method will close the reader after serving if it's satisfies the `io.Closer`. func (r *Reply) Readfrom(reader io.Reader) *Reply { - r.Rdr = &Binary{Reader: reader} + r.Render(&binaryRender{Reader: reader}) return r } // File method send the given as file to client. It auto-detects the content type // of the file if `Content-Type` is not set. +// +// Note: If give filepath is relative path then application base directory is used +// as prefix. func (r *Reply) File(file string) *Reply { - r.Rdr = &Binary{Path: file} + if !filepath.IsAbs(file) { + file = filepath.Join(r.ctx.a.BaseDir(), file) + } + r.Render(&binaryRender{Path: file}) return r } @@ -267,48 +255,55 @@ func (r *Reply) HTMLf(filename string, data Data) *Reply { // HTMLlf method renders based on given layout, filename and data. Refer `Reply.HTML(...)` // method. func (r *Reply) HTMLlf(layout, filename string, data Data) *Reply { - html := acquireHTML() - html.Layout = layout - html.Filename = filename - html.ViewArgs = data - r.Rdr = html r.ContentType(ahttp.ContentTypeHTML.String()) + r.Render(&htmlRender{Layout: layout, Filename: filename, ViewArgs: data}) return r } -// Redirect method redirect the to given redirect URL with status 302. +// Redirect method redirects to given redirect URL with status 302. func (r *Reply) Redirect(redirectURL string) *Reply { - return r.RedirectSts(redirectURL, http.StatusFound) + return r.RedirectWithStatus(redirectURL, http.StatusFound) } -// RedirectSts method redirect the to given redirect URL and status code. -func (r *Reply) RedirectSts(redirectURL string, code int) *Reply { +// RedirectWithStatus method redirects to given redirect URL and status code. +func (r *Reply) RedirectWithStatus(redirectURL string, code int) *Reply { r.redirect = true r.Status(code) r.path = redirectURL return r } -// Error method is used send an error reply, which is handled by centralized -// error handler. +// RedirectSts method redirects to given redirect URL and status code. +// +// DEPRECATED: use `RedirectWithStatus` instead. +func (r *Reply) RedirectSts(redirectURL string, code int) *Reply { + r.ctx.Log().Warnf("Method: RedirectSts is deprecated, use 'RedirectWithStatus' instead. Planned to be removed in v1.0 release") + r.RedirectWithStatus(redirectURL, code) + return r +} + +// Error method is used send an error reply, which is handled by error handling +// mechanism. +// +// More Info: https://docs.aahframework.org/error-handling.html func (r *Reply) Error(err *Error) *Reply { r.err = err return r } -// Render method is used for custom rendering by implementing interface -// `aah.Render`. +// Render method is used render custom implementation using interface `aah.Render`. func (r *Reply) Render(rdr Render) *Reply { r.Rdr = rdr return r } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Reply methods -//___________________________________ +//______________________________________________________________________________ // Header method sets the given header and value for the response. // If value == "", then this method deletes the header. +// // Note: It overwrites existing header value if it's present. func (r *Reply) Header(key, value string) *Reply { if ess.IsStrEmpty(value) { @@ -316,32 +311,34 @@ func (r *Reply) Header(key, value string) *Reply { return r.ContentType("") } - r.Hdr.Del(key) + r.ctx.Res.Header().Del(key) } else { if key == ahttp.HeaderContentType { return r.ContentType(value) } - r.Hdr.Set(key, value) + r.ctx.Res.Header().Set(key, value) } return r } // HeaderAppend method appends the given header and value for the response. +// // Note: It does not overwrite existing header, it just appends to it. func (r *Reply) HeaderAppend(key, value string) *Reply { if key == ahttp.HeaderContentType { return r.ContentType(value) } - r.Hdr.Add(key, value) + r.ctx.Res.Header().Add(key, value) return r } -// Done method indicates to framework and informing that reply has already -// been sent via `aah.Context.Res` and that no further action is needed. -// Framework doesn't intervene with response if this method called. +// Done method is used to indicate response has already been written using +// `aah.Context.Res` so no further action is needed from framework. +// +// Note: Framework doesn't intervene with response if this method called. func (r *Reply) Done() *Reply { r.done = true return r @@ -380,35 +377,20 @@ func (r *Reply) Body() *bytes.Buffer { return r.body } -// Reset method resets the instance values for repurpose. -func (r *Reply) Reset() { - r.Code = http.StatusOK - r.ContType = "" - r.Hdr = http.Header{} - r.Rdr = nil - r.body = nil - r.cookies = make([]*http.Cookie, 0) - r.redirect = false - r.path = "" - r.done = false - r.gzip = true - r.err = nil +func (r *Reply) isHTML() bool { + return ahttp.ContentTypeHTML.IsEqual(r.ContType) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Unexported methods -//___________________________________ - -func acquireReply() *Reply { - return replyPool.Get().(*Reply) -} +//______________________________________________________________________________ -func releaseReply(r *Reply) { - if r != nil { - releaseBuffer(r.body) - releaseRender(r.Rdr) - r.Reset() - replyPool.Put(r) +// newReply method returns the new instance on reply builder. +func newReply(ctx *Context) *Reply { + return &Reply{ + Code: http.StatusOK, + gzip: true, + ctx: ctx, } } diff --git a/reply_test.go b/reply_test.go index 55eccf8b..929fcae4 100644 --- a/reply_test.go +++ b/reply_test.go @@ -9,19 +9,16 @@ import ( "html/template" "io" "net/http" - "os" "path/filepath" "strings" "testing" - "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/test.v0/assert" ) func TestReplyStatusCodes(t *testing.T) { - re := NewReply() + re := newReply(newContext(nil, nil)) assert.Equal(t, http.StatusOK, re.Code) @@ -70,220 +67,23 @@ func TestReplyStatusCodes(t *testing.T) { re.ServiceUnavailable() assert.Equal(t, http.StatusServiceUnavailable, re.Code) } - -func TestReplyText(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - - re1.Text("welcome to %s %s", "aah", "framework") - assert.True(t, re1.IsContentTypeSet()) - - err := re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, "welcome to aah framework", buf.String()) - - buf.Reset() - - re2 := Reply{} - re2.Text("welcome to aah framework") - - err = re2.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, "welcome to aah framework", buf.String()) -} - -func TestReplyJSON(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - appConfig = getReplyRenderCfg() - - data := struct { - Name string - Age int - Address string - }{ - Name: "John", - Age: 28, - Address: "this is my street", - } - - re1.JSON(data) - assert.True(t, re1.IsContentTypeSet()) - - re1.Header(ahttp.HeaderContentType, "") - assert.False(t, re1.IsContentTypeSet()) - - re1.HeaderAppend(ahttp.HeaderContentType, "application/json") - assert.True(t, re1.IsContentTypeSet()) - - err := re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `{ - "Name": "John", - "Age": 28, - "Address": "this is my street" -}`, buf.String()) - - buf.Reset() - - appConfig.SetBool("render.pretty", false) - - err = re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `{"Name":"John","Age":28,"Address":"this is my street"}`, - buf.String()) -} - -func TestReplyJSONP(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - re1.body = buf - appConfig = getReplyRenderCfg() - - data := struct { - Name string - Age int - Address string - }{ - Name: "John", - Age: 28, - Address: "this is my street", - } - - re1.JSONP(data, "mycallback") - assert.True(t, re1.IsContentTypeSet()) - - re1.HeaderAppend("X-Request-Type", "JSONP") - re1.Header("X-Request-Type", "") - - err := re1.Rdr.Render(re1.body) - assert.FailOnError(t, err, "") - assert.Equal(t, `mycallback({ - "Name": "John", - "Age": 28, - "Address": "this is my street" -});`, re1.body.String()) - assert.NotNil(t, re1.Body()) - - re1.body.Reset() - - appConfig.SetBool("render.pretty", false) - - err = re1.Rdr.Render(re1.body) - assert.FailOnError(t, err, "") - assert.Equal(t, `mycallback({"Name":"John","Age":28,"Address":"this is my street"});`, - re1.body.String()) -} - -func TestReplyXML(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - appConfig = getReplyRenderCfg() - - type Sample struct { - Name string - Age int - Address string - } - - data := Sample{ - Name: "John", - Age: 28, - Address: "this is my street", - } - - re1.XML(data) - assert.True(t, re1.IsContentTypeSet()) - - err := re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` - - John - 28 -
this is my street
-
`, buf.String()) - - buf.Reset() - - appConfig.SetBool("render.pretty", false) - - err = re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -John28
this is my street
`, - buf.String()) - - buf.Reset() - - data2 := Data{ - "Name": "John", - "Age": 28, - "Address": "this is my street", - } - - re1.Rdr.(*XML).reset() - re1.XML(data2) - assert.True(t, re1.IsContentTypeSet()) - - err = re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.True(t, strings.HasPrefix(buf.String(), ``)) - re1.Rdr.(*XML).reset() -} - -func TestReplyReadfrom(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - re1.ContentType(ahttp.ContentTypeOctetStream.Raw()). - Binary([]byte(`John28
this is my street
`)) - - assert.Equal(t, http.StatusOK, re1.Code) - - // Just apply it again, no reason! - re1.Header(ahttp.HeaderContentType, ahttp.ContentTypeXML.Raw()) - - err := re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, `John28
this is my street
`, - buf.String()) -} - -func TestReplyFileDownload(t *testing.T) { - buf, re1 := acquireBuffer(), acquireReply() - re1.FileDownload(getReplyFilepath("file1.txt"), "sample.txt") - assert.Equal(t, http.StatusOK, re1.Code) - - err := re1.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -Each incoming request passes through a pre-defined list of steps -`, buf.String()) - - buf.Reset() - - re2 := &Reply{Hdr: http.Header{}} - re2.FileInline(getReplyFilepath("file1.txt"), "sample.txt") - - err = re2.Rdr.Render(buf) - assert.FailOnError(t, err, "") - assert.Equal(t, ` -Each incoming request passes through a pre-defined list of steps -`, buf.String()) -} - func TestReplyHTML(t *testing.T) { tmplStr := ` {{ define "title" }}This is test title{{ end }} {{ define "body" }}

This is test body

{{ end }} ` - buf, re1 := acquireBuffer(), acquireReply() + buf, re1 := acquireBuffer(), newReply(nil) tmpl := template.Must(template.New("test").Parse(tmplStr)) assert.NotNil(t, tmpl) - masterTmpl := getReplyFilepath(filepath.Join("views", "master.html")) + masterTmpl := filepath.Join(testdataBaseDir(), "reply", "views", "master.html") _, err := tmpl.ParseFiles(masterTmpl) assert.Nil(t, err) re1.HTMLl("master", nil) - htmlRdr := re1.Rdr.(*HTML) + htmlRdr := re1.Rdr.(*htmlRender) htmlRdr.Template = tmpl err = re1.Rdr.Render(buf) @@ -305,71 +105,33 @@ func TestReplyHTML(t *testing.T) { err = re1.Rdr.Render(buf) assert.NotNil(t, err) assert.Equal(t, "template is nil", err.Error()) - releaseReply(re1) // HTMLlf - relf := acquireReply() + relf := newReply(nil) relf.HTMLlf("docs.html", "Filename.html", nil) assert.Equal(t, "text/html; charset=utf-8", relf.ContType) - htmllf := relf.Rdr.(*HTML) + htmllf := relf.Rdr.(*htmlRender) assert.Equal(t, "docs.html", htmllf.Layout) assert.Equal(t, "Filename.html", htmllf.Filename) - releaseRender(htmllf) // HTMLf - ref := acquireReply() + ref := newReply(nil) ref.HTMLf("Filename1.html", nil) assert.Equal(t, "text/html; charset=utf-8", ref.ContType) - htmlf := ref.Rdr.(*HTML) + htmlf := ref.Rdr.(*htmlRender) assert.True(t, ess.IsStrEmpty(htmlf.Layout)) assert.Equal(t, "Filename1.html", htmlf.Filename) - releaseRender(htmlf) -} - -func TestReplyRedirect(t *testing.T) { - redirect1 := acquireReply() - redirect1.Redirect("/go-to-see.page") - assert.Equal(t, http.StatusFound, redirect1.Code) - assert.True(t, redirect1.redirect) - assert.Equal(t, "/go-to-see.page", redirect1.path) - releaseReply(redirect1) - - redirect2 := acquireReply() - redirect2.RedirectSts("/go-to-see-gone-premanent.page", http.StatusMovedPermanently) - assert.Equal(t, http.StatusMovedPermanently, redirect2.Code) - assert.True(t, redirect2.redirect) - assert.Equal(t, "/go-to-see-gone-premanent.page", redirect2.path) - releaseReply(redirect2) } - func TestReplyDone(t *testing.T) { - re1 := acquireReply() + re1 := newReply(nil) assert.False(t, re1.done) re1.Done() assert.True(t, re1.done) } -func TestReplyCookie(t *testing.T) { - re1 := acquireReply() - - re1.Cookie(&http.Cookie{ - Name: "aah-test-cookie", - Value: "This is reply cookie interface test value", - Path: "/", - Domain: "*.sample.com", - HttpOnly: true, - }) - - assert.NotNil(t, re1.cookies) - - cookie := re1.cookies[0] - assert.Equal(t, "aah-test-cookie", cookie.Name) - releaseReply(re1) -} - // customRender implements the interface `aah.Render`. type customRender struct { // ... your fields goes here @@ -381,7 +143,7 @@ func (cr *customRender) Render(w io.Writer) error { } func TestReplyCustomRender(t *testing.T) { - re := acquireReply() + re := newReply(nil) buf := acquireBuffer() re.Render(&customRender{}) @@ -390,10 +152,9 @@ func TestReplyCustomRender(t *testing.T) { assert.Equal(t, "This is custom render struct", buf.String()) releaseBuffer(buf) - releaseReply(re) // Render func - re = acquireReply() + re = newReply(nil) buf = acquireBuffer() re.Render(RenderFunc(func(w io.Writer) error { @@ -405,19 +166,4 @@ func TestReplyCustomRender(t *testing.T) { assert.Equal(t, "This is custom render func", buf.String()) releaseBuffer(buf) - releaseReply(re) -} - -func getReplyRenderCfg() *config.Config { - cfg, _ := config.ParseString(` - render { - pretty = true - } - `) - return cfg -} - -func getReplyFilepath(name string) string { - wd, _ := os.Getwd() - return filepath.Join(wd, "testdata", "reply", name) } diff --git a/router.go b/router.go index df30fc46..c43507f0 100644 --- a/router.go +++ b/router.go @@ -9,26 +9,19 @@ import ( "fmt" "html/template" "net/http" + "net/url" "path/filepath" "strings" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/log.v0" "aahframework.org/router.v0" ) -var appRouter *router.Router - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Package methods -//___________________________________ - -// AppRouter method returns aah application router instance. -func AppRouter() *router.Router { - return appRouter -} +//______________________________________________________________________________ // RouteMiddleware method performs the routing logic. func RouteMiddleware(ctx *Context, m *Middleware) { @@ -39,21 +32,33 @@ func RouteMiddleware(ctx *Context, m *Middleware) { m.Next(ctx) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ + +func (a *app) Router() *router.Router { + return a.router +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ -func initRoutes(cfgDir string, appCfg *config.Config) error { - routesPath := filepath.Join(cfgDir, "routes.conf") - arouter := router.New(routesPath, appCfg) - if err := arouter.Load(); err != nil { +func (a *app) initRouter() error { + routesPath := filepath.Join(a.configDir(), "routes.conf") + rtr := router.New(routesPath, a.Config()) + if err := rtr.Load(); err != nil { return fmt.Errorf("routes.conf: %s", err) } - appRouter = arouter + a.router = rtr return nil } +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Unexported methods +//______________________________________________________________________________ + // handleRoute method handle route processing for the incoming request. // It does- // - finding domain @@ -69,7 +74,7 @@ func initRoutes(cfgDir string, appCfg *config.Config) error { // - flowCont // - flowStop func handleRoute(ctx *Context) flowResult { - domain := AppRouter().FindDomain(ctx.Req) + domain := ctx.a.Router().Lookup(ctx.Req.Host) if domain == nil { ctx.Log().Warnf("Domain not found, Host: %s, Path: %s", ctx.Req.Host, ctx.Req.Path) ctx.Reply().Error(&Error{ @@ -96,23 +101,12 @@ func handleRoute(ctx *Context) flowResult { return flowStop } ctx.route = route - - // security form auth case - if isFormAuthLoginRoute(ctx) { - return flowCont - } - - // Path parameters - if pathParams.Len() > 0 { - ctx.Req.Params.Path = make(map[string]string, pathParams.Len()) - for _, v := range *pathParams { - ctx.Req.Params.Path[v.Key] = v.Value - } - } + ctx.Req.Params.Path = pathParams // Serving static file if route.IsStatic { - if err := appEngine.serveStatic(ctx); err == errFileNotFound { + // TODO fix me use better way to access engine + if err := ctx.a.staticMgr.Serve(ctx); err == errFileNotFound { ctx.Log().Warnf("Static file not found, Host: %s, Path: %s", ctx.Req.Host, ctx.Req.Path) ctx.Reply().done = false ctx.Reply().Error(&Error{ @@ -124,8 +118,13 @@ func handleRoute(ctx *Context) flowResult { return flowStop } - // No controller or action found for the route + // security form auth case + if isFormAuthLoginRoute(ctx) { + return flowCont + } + if err := ctx.setTarget(route); err == errTargetNotFound { + // No controller or action found for the route ctx.Log().Warnf("Target not found, Controller: %s, Action: %s", route.Controller, route.Action) ctx.Reply().Error(&Error{ Reason: ErrControllerOrActionNotFound, @@ -165,38 +164,31 @@ func composeRouteURL(domain *router.Domain, routePath, anchorLink string) string return appendAnchorLink(routePath, anchorLink) } -func findReverseURLDomain(host, routeName string) (*router.Domain, int) { +func (a *app) findReverseURLDomain(host, routeName string) (*router.Domain, string) { idx := strings.IndexByte(routeName, '.') if idx > 0 { subDomain := routeName[:idx] // Returning current subdomain if strings.HasPrefix(host, subDomain) { - log.Tracef("ReverseURL: routeName: %s, host: %s", routeName, host) - return AppRouter().Domains[host], idx + return a.Router().Lookup(host), routeName[idx+1:] } // Returning requested subdomain - for k, v := range AppRouter().Domains { + for k, v := range a.Router().Domains { if strings.HasPrefix(k, subDomain) && v.IsSubDomain { - log.Tracef("ReverseURL: routeName: %s, host: %s", routeName, v.Host) - return v, idx + return v, routeName[idx+1:] } } } // return root domain - root := AppRouter().RootDomain() - log.Tracef("ReverseURL: routeName: %s, host: %s", routeName, root.Host) - return root, idx + root := a.Router().RootDomain() + a.Log().Tracef("ReverseURL: routeName: %s, host: %s", routeName, root.Host) + return root, routeName } -func createReverseURL(host, routeName string, margs map[string]interface{}, args ...interface{}) string { - domain, idx := findReverseURLDomain(host, routeName) - if idx > 0 { - routeName = routeName[idx+1:] - } - +func createReverseURL(l log.Loggerer, domain *router.Domain, routeName string, margs map[string]interface{}, args ...interface{}) string { if routeName == "host" { return composeRouteURL(domain, "", "") } @@ -209,7 +201,13 @@ func createReverseURL(host, routeName string, margs map[string]interface{}, args routePath = domain.ReverseURLm(routeName, margs) } - return composeRouteURL(domain, routePath, anchorLink) + // URL escapes + rURL, err := url.Parse(composeRouteURL(domain, routePath, anchorLink)) + if err != nil { + l.Error(err) + return "" + } + return rURL.String() } // handleRtsOptionsMna method handles 1) Redirect Trailing Slash 2) Options @@ -234,7 +232,7 @@ func handleRtsOptionsMna(ctx *Context, domain *router.Domain, rts bool) error { } reply.Redirect(ctx.Req.Unwrap().URL.String()) - log.Debugf("RedirectTrailingSlash: %d, %s ==> %s", reply.Code, reqPath, reply.path) + ctx.Log().Debugf("RedirectTrailingSlash: %d, %s ==> %s", reply.Code, reqPath, reply.path) return nil } } @@ -268,18 +266,20 @@ func processAllowedMethods(reply *Reply, allowed, prefix string) bool { if !ess.IsStrEmpty(allowed) { allowed += ", " + ahttp.MethodOptions reply.Header(ahttp.HeaderAllow, allowed) - log.Debugf("%sAllowed HTTP Methods: %s", prefix, allowed) + reply.ctx.Log().Debugf("%sAllowed HTTP Methods: %s", prefix, allowed) return true } return false } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // CORS Implementation -//___________________________________ +//______________________________________________________________________________ +// CORSMiddleware provides Cross-Origin Resource Sharing (CORS) access +// control feature. func CORSMiddleware(ctx *Context, m *Middleware) { - // If CORS not enabled move on + // If CORS not enabled or nil move on if !ctx.domain.CORSEnabled || ctx.route.CORS == nil { m.Next(ctx) return @@ -290,7 +290,7 @@ func CORSMiddleware(ctx *Context, m *Middleware) { // CORS OPTIONS request if ctx.Req.Method == ahttp.MethodOptions && - !ess.IsStrEmpty(ctx.Req.Header.Get(ahttp.HeaderAccessControlRequestMethod)) { + ctx.Req.Header.Get(ahttp.HeaderAccessControlRequestMethod) != "" { handleCORSPreflight(ctx) return } @@ -381,22 +381,26 @@ func handleCORSPreflight(ctx *Context) { ctx.Reply().Ok().Text("") } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Template methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Template methods +//______________________________________________________________________________ // tmplURL method returns reverse URL by given route name and args. // Mapped to Go template func. -func tmplURL(viewArgs map[string]interface{}, args ...interface{}) template.URL { +func (vm *viewManager) tmplURL(viewArgs map[string]interface{}, args ...interface{}) template.URL { if len(args) == 0 { - log.Errorf("router: template 'rurl' - route name is empty: %v", args) + vm.a.Log().Errorf("router: template 'rurl' - route name is empty: %v", args) return template.URL("#") } - return template.URL(createReverseURL(viewArgs["Host"].(string), args[0].(string), nil, args[1:]...)) + domain, routeName := vm.a.findReverseURLDomain(viewArgs["Host"].(string), args[0].(string)) + /* #nosec */ + return template.URL(createReverseURL(vm.a.Log(), domain, routeName, nil, args[1:]...)) } // tmplURLm method returns reverse URL by given route name and // map[string]interface{}. Mapped to Go template func. -func tmplURLm(viewArgs map[string]interface{}, routeName string, args map[string]interface{}) template.URL { - return template.URL(createReverseURL(viewArgs["Host"].(string), routeName, args)) +func (vm *viewManager) tmplURLm(viewArgs map[string]interface{}, routeName string, args map[string]interface{}) template.URL { + domain, rn := vm.a.findReverseURLDomain(viewArgs["Host"].(string), routeName) + /* #nosec */ + return template.URL(createReverseURL(vm.a.Log(), domain, rn, args)) } diff --git a/router_test.go b/router_test.go index 2786d745..4850ddc7 100644 --- a/router_test.go +++ b/router_test.go @@ -5,46 +5,49 @@ package aah import ( + "net/http" + "net/http/httptest" "path/filepath" "testing" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/router.v0" - "aahframework.org/security.v0" "aahframework.org/test.v0/assert" ) func TestRouterTemplateFuncs(t *testing.T) { - appCfg, _ := config.ParseString("") - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initRoutes(cfgDir, appCfg) + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppRouter()) + defer ts.Close() - ctx := &Context{ - Req: getAahRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", ""), - } + t.Logf("Test Server URL [Router Template funcs]: %s", ts.URL) + + err = ts.app.initRouter() + assert.Nil(t, err) + + err = ts.app.initView() + assert.Nil(t, err) + + vm := ts.app.viewMgr viewArgs := map[string]interface{}{} viewArgs["Host"] = "localhost:8080" - url1 := tmplURL(viewArgs, "version_home#welcome", "v0.1") + url1 := vm.tmplURL(viewArgs, "version_home#welcome", "v0.1") assert.Equal(t, "//localhost:8080/doc/v0.1#welcome", string(url1)) - url2 := tmplURLm(viewArgs, "show_doc", map[string]interface{}{ + url2 := vm.tmplURLm(viewArgs, "show_doc", map[string]interface{}{ "version": "v0.2", "content": "getting-started.html", }) assert.Equal(t, "//localhost:8080/doc/v0.2/getting-started.html", string(url2)) - url3 := tmplURL(viewArgs) + url3 := vm.tmplURL(viewArgs) assert.Equal(t, "#", string(url3)) - url4 := tmplURL(viewArgs, "host") + url4 := vm.tmplURL(viewArgs, "host") assert.Equal(t, "//localhost:8080", string(url4)) - - ctx.Reset() } func TestRouterMisc(t *testing.T) { @@ -54,54 +57,70 @@ func TestRouterMisc(t *testing.T) { } func TestRouterCORS(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [CORS]: %s", ts.URL) + // CORS NOT enabled + t.Log("CORS NOT enabled") ctx1 := &Context{ domain: &router.Domain{}, } CORSMiddleware(ctx1, &Middleware{}) - ctx2 := &Context{ - domain: &router.Domain{CORSEnabled: true}, - route: &router.Route{}, - } - CORSMiddleware(ctx2, &Middleware{}) - // CORS preflight request - req3 := getAahRequest(ahttp.MethodOptions, "http://localhost:8080/users/edit", "") + t.Log("CORS preflight request") + req3, err := http.NewRequest(ahttp.MethodOptions, ts.URL+"/users/edit", nil) req3.Header.Set(ahttp.HeaderAccessControlRequestMethod, ahttp.MethodPost) req3.Header.Set(ahttp.HeaderOrigin, "http://sample.com") - ctx3 := &Context{ - Req: req3, - subject: security.AcquireSubject(), - reply: acquireReply(), - domain: &router.Domain{CORSEnabled: true}, - route: &router.Route{ - CORS: &router.CORS{ - AllowOrigins: []string{"http://sample.com"}, - AllowMethods: []string{ahttp.MethodGet, ahttp.MethodPost, ahttp.MethodOptions}, - AllowCredentials: true, - MaxAge: "806400", - }, + ctx3 := newContext(httptest.NewRecorder(), req3) + ctx3.a = ts.app + ctx3.domain = &router.Domain{CORSEnabled: true} + ctx3.route = &router.Route{ + CORS: &router.CORS{ + AllowOrigins: []string{"http://sample.com"}, + AllowMethods: []string{ahttp.MethodGet, ahttp.MethodPost, ahttp.MethodOptions}, + AllowCredentials: true, + MaxAge: "806400", }, } CORSMiddleware(ctx3, &Middleware{}) // CORS regular request - req4 := getAahRequest(ahttp.MethodPost, "http://localhost:8080/users/edit", "") + t.Log("CORS regular request") + req4, err := http.NewRequest(ahttp.MethodOptions, ts.URL+"/users/edit", nil) req4.Header.Set(ahttp.HeaderOrigin, "http://sample.com") - ctx4 := &Context{ - Req: req4, - subject: security.AcquireSubject(), - reply: acquireReply(), - domain: &router.Domain{CORSEnabled: true}, - route: &router.Route{ - CORS: &router.CORS{ - AllowOrigins: []string{"http://sample.com"}, - AllowMethods: []string{ahttp.MethodGet, ahttp.MethodPost, ahttp.MethodOptions}, - ExposeHeaders: []string{ahttp.HeaderXRequestedWith}, - AllowCredentials: true, - }, + ctx4 := newContext(httptest.NewRecorder(), req4) + ctx4.a = ts.app + ctx4.domain = &router.Domain{CORSEnabled: true} + ctx4.route = &router.Route{ + CORS: &router.CORS{ + AllowOrigins: []string{"http://sample.com"}, + AllowMethods: []string{ahttp.MethodGet, ahttp.MethodPost, ahttp.MethodOptions}, + ExposeHeaders: []string{ahttp.HeaderXRequestedWith}, + AllowCredentials: true, }, } CORSMiddleware(ctx4, &Middleware{}) + + // Preflight invalid origin + t.Log("Preflight invalid origin") + req5, err := http.NewRequest(ahttp.MethodOptions, ts.URL+"/users/edit", nil) + req5.Header.Set(ahttp.HeaderAccessControlRequestMethod, ahttp.MethodPost) + req5.Header.Set(ahttp.HeaderOrigin, "http://example.com") + ctx5 := newContext(httptest.NewRecorder(), req5) + ctx5.a = ts.app + ctx5.domain = &router.Domain{CORSEnabled: true} + ctx5.route = &router.Route{ + CORS: &router.CORS{ + AllowOrigins: []string{"http://sample.com"}, + AllowMethods: []string{ahttp.MethodGet, ahttp.MethodPost, ahttp.MethodOptions}, + AllowCredentials: true, + MaxAge: "806400", + }, + } + CORSMiddleware(ctx5, &Middleware{}) } diff --git a/security.go b/security.go index b24321c7..ec49e061 100644 --- a/security.go +++ b/security.go @@ -10,7 +10,6 @@ import ( "net/url" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/security.v0" "aahframework.org/security.v0/acrypto" @@ -27,26 +26,12 @@ const ( // KeyViewArgSubject key name is used to store `Subject` instance into `ViewArgs`. KeyViewArgSubject = "_aahSubject" - keyAntiCSRFSecret = "_AntiCSRFSecret" + keyAntiCSRF = "_AntiCSRF" ) -var appSecurityManager *security.Manager - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Package methods -//___________________________________ - -// AppSecurityManager method returns the application security instance, -// which manages the Session, CORS, CSRF, Security Headers, etc. -func AppSecurityManager() *security.Manager { - return appSecurityManager -} - -// AppSessionManager method returns the application session manager. -// By default session is stateless. -func AppSessionManager() *session.Manager { - return AppSecurityManager().SessionManager -} +//______________________________________________________________________________ // AddSessionStore method allows you to add custom session store which // implements `session.Storer` interface. The `name` parameter is used in @@ -63,9 +48,37 @@ func AddPasswordAlgorithm(name string, encoder acrypto.PasswordEncoder) error { return acrypto.AddPasswordAlgorithm(name, encoder) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ + +func (a *app) SecurityManager() *security.Manager { + return a.securityMgr +} + +func (a *app) SessionManager() *session.Manager { + return a.SecurityManager().SessionManager +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) initSecurity() error { + asecmgr := security.New() + asecmgr.IsSSLEnabled = a.IsSSLEnabled() + if err := asecmgr.Init(a.Config()); err != nil { + return err + } + + asecmgr.AntiCSRF.Enabled = (a.ViewEngine() != nil) + a.securityMgr = asecmgr + return nil +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Authentication and Authorization Middleware -//_____________________________________________ +//______________________________________________________________________________ // AuthcAuthzMiddleware is aah Authentication and Authorization Middleware. func AuthcAuthzMiddleware(ctx *Context, m *Middleware) { @@ -77,11 +90,11 @@ func AuthcAuthzMiddleware(ctx *Context, m *Middleware) { return } - authScheme := AppSecurityManager().GetAuthScheme(ctx.route.Auth) + authScheme := ctx.a.SecurityManager().GetAuthScheme(ctx.route.Auth) if authScheme == nil { // If one or more auth schemes are defined in `security.auth_schemes { ... }` // and routes `auth` attribute is not defined then framework treats that route as `403 Forbidden`. - if AppSecurityManager().IsAuthSchemesConfigured() { + if ctx.a.SecurityManager().IsAuthSchemesConfigured() { ctx.Log().Warnf("Auth schemes are configured in security.conf, however attribute 'auth' or 'default_auth' is not defined in routes.conf, so treat it as 403 forbidden: %v", ctx.Req.Path) ctx.Reply().Error(&Error{ Reason: ErrAccessDenied, @@ -98,8 +111,8 @@ func AuthcAuthzMiddleware(ctx *Context, m *Middleware) { } // loadSession method loads session from request for `stateful` session. - if AppSessionManager().IsStateful() { - ctx.subject.Session = AppSessionManager().GetSession(ctx.Req.Unwrap()) + if ctx.a.SessionManager().IsStateful() { + ctx.Subject().Session = ctx.a.SessionManager().GetSession(ctx.Req.Unwrap()) } ctx.Log().Debugf("Route auth scheme: %s", authScheme.Scheme()) @@ -126,6 +139,7 @@ func doFormAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { if ctx.Session().IsKeyExists(KeyViewArgAuthcInfo) { ctx.Subject().AuthenticationInfo = ctx.Session().Get(KeyViewArgAuthcInfo).(*authc.AuthenticationInfo) ctx.Subject().AuthorizationInfo = formAuth.DoAuthorizationInfo(ctx.Subject().AuthenticationInfo) + ctx.logger = ctx.Log().WithField("principal", ctx.Subject().AuthenticationInfo.PrimaryPrincipal().Value) } else { ctx.Log().Warn("It seems there is an issue with session data - AuthenticationInfo") } @@ -144,7 +158,7 @@ func doFormAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { return flowStop } - publishOnPreAuthEvent(ctx) + ctx.e.publishOnPreAuthEvent(ctx) // Do Authentication authcInfo, err := formAuth.DoAuthenticate(formAuth.ExtractAuthenticationToken(ctx.Req)) @@ -161,7 +175,9 @@ func doFormAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { return flowStop } - ctx.Log().Debug(ctx.Subject().AuthenticationInfo) + ctx.logger = ctx.Log().WithField("principal", authcInfo.PrimaryPrincipal().Value) + + ctx.Log().Debug(authcInfo) ctx.Log().Info("Authentication successful") ctx.Subject().AuthenticationInfo = authcInfo ctx.Subject().AuthorizationInfo = formAuth.DoAuthorizationInfo(authcInfo) @@ -170,19 +186,19 @@ func doFormAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { // Change the Anti-CSRF token in use for a request after login for security purposes. ctx.Log().Debug("Change Anti-CSRF secret after login for security purpose") - ctx.AddViewArg(keyAntiCSRFSecret, AppSecurityManager().AntiCSRF.GenerateSecret()) + ctx.AddViewArg(keyAntiCSRF, ctx.a.SecurityManager().AntiCSRF.GenerateSecret()) // Remove the credential ctx.Subject().AuthenticationInfo.Credential = nil ctx.Session().Set(KeyViewArgAuthcInfo, ctx.Subject().AuthenticationInfo) - publishOnPostAuthEvent(ctx) + ctx.e.publishOnPostAuthEvent(ctx) - rt := ctx.Req.Unwrap().FormValue("_rt") + rt := ctx.Req.Unwrap().FormValue("_rt") // redirect to value if formAuth.IsAlwaysToDefaultTarget || ess.IsStrEmpty(rt) { ctx.Reply().Redirect(formAuth.DefaultTargetURL) } else { - ctx.Log().Debugf("Redirect to URL found: %v", rt) + ctx.Log().Debugf("Redirect to URL found ('_rt'): %v", rt) ctx.Reply().Redirect(rt) } @@ -191,7 +207,7 @@ func doFormAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { // doAuthcAndAuthz method does Authentication and Authorization. func doAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { - publishOnPreAuthEvent(ctx) + ctx.e.publishOnPreAuthEvent(ctx) // Do Authentication authcInfo, err := ascheme.DoAuthenticate(ascheme.ExtractAuthenticationToken(ctx.Req)) @@ -211,7 +227,9 @@ func doAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { return flowStop } - ctx.Log().Debug(ctx.Subject().AuthenticationInfo) + ctx.logger = ctx.Log().WithField("principal", authcInfo.PrimaryPrincipal().Value) + + ctx.Log().Debug(authcInfo) ctx.Log().Info("Authentication successful") ctx.Subject().AuthenticationInfo = authcInfo ctx.Subject().AuthorizationInfo = ascheme.DoAuthorizationInfo(authcInfo) @@ -221,28 +239,30 @@ func doAuthcAndAuthz(ascheme scheme.Schemer, ctx *Context) flowResult { // Remove the credential ctx.Subject().AuthenticationInfo.Credential = nil - publishOnPostAuthEvent(ctx) + ctx.e.publishOnPostAuthEvent(ctx) return flowCont } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ // Anti-CSRF Middleware -//___________________________________ +//______________________________________________________________________________ +// AntiCSRFMiddleware provides feature to prevent Cross-Site Request Forgery (CSRF) +// attacks. func AntiCSRFMiddleware(ctx *Context, m *Middleware) { // If Anti-CSRF is not enabled, move on. // It is highly recommended to enable for web application. - if !AppSecurityManager().AntiCSRF.Enabled || !ctx.route.IsAntiCSRFCheck || AppViewEngine() == nil { - ctx.Log().Tracef("Anti CSRF protection is not enabled [%s: %s], clear the cookie if present.", ctx.Req.Method, ctx.Req.Path) - AppSecurityManager().AntiCSRF.ClearCookie(ctx.Res, ctx.Req) + if !ctx.a.SecurityManager().AntiCSRF.Enabled || !ctx.route.IsAntiCSRFCheck || ctx.a.ViewEngine() == nil { + // ctx.Log().Tracef("Anti CSRF protection is not enabled [%s: %s], clear the cookie if present.", ctx.Req.Method, ctx.Req.Path) + ctx.a.SecurityManager().AntiCSRF.ClearCookie(ctx.Res, ctx.Req) m.Next(ctx) return } // Get cipher secret from anti-csrf cookie - secret := AppSecurityManager().AntiCSRF.CipherSecret(ctx.Req) - ctx.AddViewArg(keyAntiCSRFSecret, secret) + secret := ctx.a.SecurityManager().AntiCSRF.CipherSecret(ctx.Req) + ctx.AddViewArg(keyAntiCSRF, secret) // HTTP Method is safe per defined in // https://tools.ietf.org/html/rfc7231#section-4.2.1 @@ -269,7 +289,7 @@ func AntiCSRFMiddleware(ctx *Context, m *Middleware) { // Barth et al. found that the Referer header is missing for // same-domain requests in only about 0.2% of cases or less, so // we can use strict Referer checking. - if ctx.Req.Scheme == "https" { + if ctx.Req.Scheme == ahttp.SchemeHTTPS { referer, err := url.Parse(ctx.Req.Referer) if err != nil { ctx.Log().Warnf("Anti-CSRF: malformed referer %s", ctx.Req.Referer) @@ -291,7 +311,7 @@ func AntiCSRFMiddleware(ctx *Context, m *Middleware) { return } - if !anticsrf.IsSameOrigin(ctx.Req.Unwrap().URL, referer) { + if !anticsrf.IsSameOrigin(ctx.Req.URL(), referer) { ctx.Log().Warnf("Anti-CSRF: bad referer %s", ctx.Req.Referer) ctx.Reply().Error(&Error{ Reason: anticsrf.ErrBadReferer, @@ -303,8 +323,8 @@ func AntiCSRFMiddleware(ctx *Context, m *Middleware) { } // Get request cipher secret from HTTP header or Form - requestSecret := AppSecurityManager().AntiCSRF.RequestCipherSecret(ctx.Req) - if requestSecret == nil || !AppSecurityManager().AntiCSRF.IsAuthentic(secret, requestSecret) { + requestSecret := ctx.a.SecurityManager().AntiCSRF.RequestCipherSecret(ctx.Req) + if requestSecret == nil || !ctx.a.SecurityManager().AntiCSRF.IsAuthentic(secret, requestSecret) { ctx.Log().Warn("Anti-CSRF: verification failed, invalid cipher secret") ctx.Reply().Error(&Error{ Reason: anticsrf.ErrNoCookieFound, @@ -313,52 +333,35 @@ func AntiCSRFMiddleware(ctx *Context, m *Middleware) { }) return } - ctx.Log().Trace("Anti-CSRF cipher secret verification passed") + ctx.Log().Debug("Anti-CSRF cipher secret verification passed") m.Next(ctx) - writeAntiCSRFCookie(ctx, ctx.viewArgs[keyAntiCSRFSecret].([]byte)) + writeAntiCSRFCookie(ctx, ctx.viewArgs[keyAntiCSRF].([]byte)) } func writeAntiCSRFCookie(ctx *Context, secret []byte) { - if err := AppSecurityManager().AntiCSRF.SetCookie(ctx.Res, secret); err != nil { + if err := ctx.a.SecurityManager().AntiCSRF.SetCookie(ctx.Res, secret); err != nil { ctx.Log().Error("Unable to write Anti-CSRF cookie") } - ctx.Res.Header().Add(ahttp.HeaderVary, ahttp.HeaderCookie) -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ - -func initSecurity(appCfg *config.Config) error { - asecmgr := security.New() - asecmgr.IsSSLEnabled = AppIsSSLEnabled() - if err := asecmgr.Init(appCfg); err != nil { - return err - } - asecmgr.AntiCSRF.Enabled = (AppViewEngine() != nil) - appSecurityManager = asecmgr - - return nil } func isFormAuthLoginRoute(ctx *Context) bool { - authScheme := AppSecurityManager().GetAuthScheme(ctx.route.Auth) + authScheme := ctx.a.SecurityManager().GetAuthScheme(ctx.route.Auth) if authScheme != nil && authScheme.Scheme() == "form" { return authScheme.(*scheme.FormAuth).LoginSubmitURL == ctx.route.Path } return false } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Template methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Template methods +//______________________________________________________________________________ // tmplSessionValue method returns session value for the given key. If session // object unavailable this method returns nil. -func tmplSessionValue(viewArgs map[string]interface{}, key string) interface{} { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplSessionValue(viewArgs map[string]interface{}, key string) interface{} { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { if sub.Session != nil { value := sub.Session.Get(key) return sanatizeValue(value) @@ -369,8 +372,8 @@ func tmplSessionValue(viewArgs map[string]interface{}, key string) interface{} { // tmplFlashValue method returns session value for the given key. If session // object unavailable this method returns nil. -func tmplFlashValue(viewArgs map[string]interface{}, key string) interface{} { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplFlashValue(viewArgs map[string]interface{}, key string) interface{} { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { if sub.Session != nil { return sanatizeValue(sub.Session.GetFlash(key)) } @@ -379,8 +382,8 @@ func tmplFlashValue(viewArgs map[string]interface{}, key string) interface{} { } // tmplIsAuthenticated method returns the value of `Session.IsAuthenticated`. -func tmplIsAuthenticated(viewArgs map[string]interface{}) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplIsAuthenticated(viewArgs map[string]interface{}) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { if sub.Session != nil { return sub.Session.IsAuthenticated } @@ -389,40 +392,40 @@ func tmplIsAuthenticated(viewArgs map[string]interface{}) bool { } // tmplHasRole method returns the value of `Subject.HasRole`. -func tmplHasRole(viewArgs map[string]interface{}, role string) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplHasRole(viewArgs map[string]interface{}, role string) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { return sub.HasRole(role) } return false } // tmplHasAllRoles method returns the value of `Subject.HasAllRoles`. -func tmplHasAllRoles(viewArgs map[string]interface{}, roles ...string) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplHasAllRoles(viewArgs map[string]interface{}, roles ...string) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { return sub.HasAllRoles(roles...) } return false } // tmplHasAnyRole method returns the value of `Subject.HasAnyRole`. -func tmplHasAnyRole(viewArgs map[string]interface{}, roles ...string) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplHasAnyRole(viewArgs map[string]interface{}, roles ...string) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { return sub.HasAnyRole(roles...) } return false } // tmplIsPermitted method returns the value of `Subject.IsPermitted`. -func tmplIsPermitted(viewArgs map[string]interface{}, permission string) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplIsPermitted(viewArgs map[string]interface{}, permission string) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { return sub.IsPermitted(permission) } return false } // tmplIsPermittedAll method returns the value of `Subject.IsPermittedAll`. -func tmplIsPermittedAll(viewArgs map[string]interface{}, permissions ...string) bool { - if sub := getSubjectFromViewArgs(viewArgs); sub != nil { +func (vm *viewManager) tmplIsPermittedAll(viewArgs map[string]interface{}, permissions ...string) bool { + if sub := vm.getSubjectFromViewArgs(viewArgs); sub != nil { return sub.IsPermittedAll(permissions...) } return false @@ -430,14 +433,14 @@ func tmplIsPermittedAll(viewArgs map[string]interface{}, permissions ...string) // tmplAntiCSRFToken method returns the salted Anti-CSRF secret for the view, // if enabled otherwise empty string. -func tmplAntiCSRFToken(viewArgs map[string]interface{}) string { - if AppSecurityManager().AntiCSRF.Enabled { - return AppSecurityManager().AntiCSRF.SaltCipherSecret(viewArgs[keyAntiCSRFSecret].([]byte)) +func (vm *viewManager) tmplAntiCSRFToken(viewArgs map[string]interface{}) string { + if vm.a.SecurityManager().AntiCSRF.Enabled { + return vm.a.SecurityManager().AntiCSRF.SaltCipherSecret(viewArgs[keyAntiCSRF].([]byte)) } return "" } -func getSubjectFromViewArgs(viewArgs map[string]interface{}) *security.Subject { +func (vm *viewManager) getSubjectFromViewArgs(viewArgs map[string]interface{}) *security.Subject { if sv, found := viewArgs[KeyViewArgSubject]; found { return sv.(*security.Subject) } diff --git a/security_test.go b/security_test.go index 9ba2a463..5acfb78f 100644 --- a/security_test.go +++ b/security_test.go @@ -5,13 +5,14 @@ package aah import ( + "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "aahframework.org/ahttp.v0" "aahframework.org/config.v0" - "aahframework.org/log.v0" "aahframework.org/router.v0" "aahframework.org/security.v0" "aahframework.org/security.v0/anticsrf" @@ -33,25 +34,40 @@ func TestSecuritySessionStore(t *testing.T) { } func TestSecuritySessionTemplateFuns(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Security Template funcs]: %s", ts.URL) + + err = ts.app.initSecurity() + assert.Nil(t, err) + + err = ts.app.initView() + assert.Nil(t, err) + + vm := ts.app.viewMgr + viewArgs := make(map[string]interface{}) assert.Nil(t, viewArgs[KeyViewArgSubject]) - bv1 := tmplSessionValue(viewArgs, "my-testvalue") + bv1 := vm.tmplSessionValue(viewArgs, "my-testvalue") assert.Nil(t, bv1) - bv2 := tmplFlashValue(viewArgs, "my-flashvalue") + bv2 := vm.tmplFlashValue(viewArgs, "my-flashvalue") assert.Nil(t, bv2) session := &session.Session{Values: make(map[string]interface{})} session.Set("my-testvalue", 38458473684763) session.SetFlash("my-flashvalue", "user not found") - assert.False(t, tmplHasRole(viewArgs, "role1")) - assert.False(t, tmplHasAllRoles(viewArgs, "role1", "role2", "role3")) - assert.False(t, tmplHasAnyRole(viewArgs, "role1", "role2", "role3")) - assert.False(t, tmplIsPermitted(viewArgs, "*")) - assert.False(t, tmplIsPermittedAll(viewArgs, "news:read,write", "manage:*")) + assert.False(t, vm.tmplHasRole(viewArgs, "role1")) + assert.False(t, vm.tmplHasAllRoles(viewArgs, "role1", "role2", "role3")) + assert.False(t, vm.tmplHasAnyRole(viewArgs, "role1", "role2", "role3")) + assert.False(t, vm.tmplIsPermitted(viewArgs, "*")) + assert.False(t, vm.tmplIsPermittedAll(viewArgs, "news:read,write", "manage:*")) viewArgs[KeyViewArgSubject] = &security.Subject{ Session: session, @@ -60,23 +76,23 @@ func TestSecuritySessionTemplateFuns(t *testing.T) { } assert.NotNil(t, viewArgs[KeyViewArgSubject]) - v1 := tmplSessionValue(viewArgs, "my-testvalue") + v1 := vm.tmplSessionValue(viewArgs, "my-testvalue") assert.Equal(t, 38458473684763, v1) - v2 := tmplFlashValue(viewArgs, "my-flashvalue") + v2 := vm.tmplFlashValue(viewArgs, "my-flashvalue") assert.Equal(t, "user not found", v2) - v3 := tmplIsAuthenticated(viewArgs) + v3 := vm.tmplIsAuthenticated(viewArgs) assert.False(t, v3) - assert.False(t, tmplHasRole(viewArgs, "role1")) - assert.False(t, tmplHasAllRoles(viewArgs, "role1", "role2", "role3")) - assert.False(t, tmplHasAnyRole(viewArgs, "role1", "role2", "role3")) - assert.False(t, tmplIsPermitted(viewArgs, "*")) - assert.False(t, tmplIsPermittedAll(viewArgs, "news:read,write", "manage:*")) + assert.False(t, vm.tmplHasRole(viewArgs, "role1")) + assert.False(t, vm.tmplHasAllRoles(viewArgs, "role1", "role2", "role3")) + assert.False(t, vm.tmplHasAnyRole(viewArgs, "role1", "role2", "role3")) + assert.False(t, vm.tmplIsPermitted(viewArgs, "*")) + assert.False(t, vm.tmplIsPermittedAll(viewArgs, "news:read,write", "manage:*")) delete(viewArgs, KeyViewArgSubject) - v4 := tmplIsAuthenticated(viewArgs) + v4 := vm.tmplIsAuthenticated(viewArgs) assert.False(t, v4) } @@ -108,15 +124,12 @@ func testGetAuthenticationInfo() *authc.AuthenticationInfo { } func TestSecurityHandleFormAuthcAndAuthz(t *testing.T) { - // anonymous - r1 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - ctx1 := &Context{ - Req: ahttp.ParseRequest(r1, &ahttp.Request{}), - route: &router.Route{Auth: "anonymous"}, - subject: security.AcquireSubject(), - viewArgs: make(map[string]interface{}), - } - AuthcAuthzMiddleware(ctx1, &Middleware{}) + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Security Form Authc and Authz]: %s", ts.URL) // form auth scheme cfg, _ := config.ParseString(` @@ -136,46 +149,49 @@ func TestSecurityHandleFormAuthcAndAuthz(t *testing.T) { } } `) - err := initSecurity(cfg) + + err = ts.app.Config().Merge(cfg) assert.Nil(t, err) - r2 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - w2 := httptest.NewRecorder() - ctx2 := &Context{ - Req: ahttp.ParseRequest(r2, &ahttp.Request{}), - Res: ahttp.GetResponseWriter(w2), - route: &router.Route{Auth: "form_auth"}, - subject: security.AcquireSubject(), - viewArgs: make(map[string]interface{}), - reply: NewReply(), - } - AuthcAuthzMiddleware(ctx2, &Middleware{}) + + err = ts.app.initSecurity() + assert.Nil(t, err) + + r1 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) + ctx := ts.app.engine.newContext() + ctx.Req = ahttp.AcquireRequest(r1) + ctx.route = &router.Route{Auth: "form_auth"} + AuthcAuthzMiddleware(ctx, &Middleware{}) // session is authenticated - ctx2.Session().IsAuthenticated = true - AuthcAuthzMiddleware(ctx2, &Middleware{}) + t.Log("session is authenticated") + ctx.Session().IsAuthenticated = true + AuthcAuthzMiddleware(ctx, &Middleware{}) // form auth + t.Log("form auth") testFormAuth := &testFormAuthentication{} - formAuth := AppSecurityManager().GetAuthScheme("form_auth").(*scheme.FormAuth) + formAuth := ts.app.SecurityManager().GetAuthScheme("form_auth").(*scheme.FormAuth) err = formAuth.SetAuthenticator(testFormAuth) assert.Nil(t, err) err = formAuth.SetAuthorizer(testFormAuth) assert.Nil(t, err) - r3 := httptest.NewRequest("POST", "http://localhost:8080/login", nil) - ctx2.Req = ahttp.ParseRequest(r3, &ahttp.Request{}) - ctx2.Session().Set(KeyViewArgAuthcInfo, testGetAuthenticationInfo()) - AuthcAuthzMiddleware(ctx2, &Middleware{}) + r2, _ := http.NewRequest("POST", "http://localhost:8080/login", nil) + ctx.Req = ahttp.AcquireRequest(r2) + ctx.Session().Set(KeyViewArgAuthcInfo, testGetAuthenticationInfo()) + AuthcAuthzMiddleware(ctx, &Middleware{}) // form auth not authenticated and no credentials - ctx2.Session().IsAuthenticated = false - delete(ctx2.Session().Values, KeyViewArgAuthcInfo) - AuthcAuthzMiddleware(ctx2, &Middleware{}) + t.Log("form auth not authenticated and no credentials") + ctx.Session().IsAuthenticated = false + delete(ctx.Session().Values, KeyViewArgAuthcInfo) + AuthcAuthzMiddleware(ctx, &Middleware{}) // form auth not authenticated and with credentials - r4 := httptest.NewRequest("POST", "http://localhost:8080/login", strings.NewReader("username=jeeva&password=welcome123")) - r4.Header.Set(ahttp.HeaderContentType, "application/x-www-form-urlencoded") - ctx2.Req = ahttp.ParseRequest(r4, &ahttp.Request{}) - AuthcAuthzMiddleware(ctx2, &Middleware{}) + t.Log("form auth not authenticated and with credentials") + r3 := httptest.NewRequest("POST", "http://localhost:8080/login", strings.NewReader("username=jeeva&password=welcome123")) + r3.Header.Set(ahttp.HeaderContentType, "application/x-www-form-urlencoded") + ctx.Req = ahttp.AcquireRequest(r3) + AuthcAuthzMiddleware(ctx, &Middleware{}) } type testBasicAuthentication struct { @@ -199,6 +215,13 @@ func (tba *testBasicAuthentication) GetAuthorizationInfo(authcInfo *authc.Authen } func TestSecurityHandleBasicAuthcAndAuthz(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Security Basic Authc and Authz]: %s", ts.URL) + // basic auth scheme cfg, _ := config.ParseString(` security { @@ -212,37 +235,47 @@ func TestSecurityHandleBasicAuthcAndAuthz(t *testing.T) { } } `) - err := initSecurity(cfg) + + err = ts.app.Config().Merge(cfg) assert.Nil(t, err) - r1 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - w1 := httptest.NewRecorder() - ctx1 := &Context{ - Req: ahttp.ParseRequest(r1, &ahttp.Request{}), - Res: ahttp.GetResponseWriter(w1), - route: &router.Route{Auth: "basic_auth"}, - viewArgs: make(map[string]interface{}), - subject: security.AcquireSubject(), - reply: NewReply(), - } + + err = ts.app.initSecurity() + assert.Nil(t, err) + + r1, err := http.NewRequest(ahttp.MethodGet, "http://localhost:8080/doc/v0.3/mydoc.html", nil) + assert.Nil(t, err) + ctx1 := ts.app.engine.newContext() + ctx1.Req = ahttp.AcquireRequest(r1) + ctx1.Res = ahttp.AcquireResponseWriter(httptest.NewRecorder()) + ctx1.route = &router.Route{Auth: "basic_auth"} AuthcAuthzMiddleware(ctx1, &Middleware{}) testBasicAuth := &testBasicAuthentication{} - basicAuth := AppSecurityManager().GetAuthScheme("basic_auth").(*scheme.BasicAuth) + basicAuth := ts.app.SecurityManager().GetAuthScheme("basic_auth").(*scheme.BasicAuth) err = basicAuth.SetAuthenticator(testBasicAuth) assert.Nil(t, err) err = basicAuth.SetAuthorizer(testBasicAuth) assert.Nil(t, err) - r2 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) - ctx1.Req = ahttp.ParseRequest(r2, &ahttp.Request{}) + r2, err := http.NewRequest(ahttp.MethodGet, "http://localhost:8080/doc/v0.3/mydoc.html", nil) + assert.Nil(t, err) + ctx1.Req = ahttp.AcquireRequest(r2) AuthcAuthzMiddleware(ctx1, &Middleware{}) - r3 := httptest.NewRequest("GET", "http://localhost:8080/doc/v0.3/mydoc.html", nil) + r3, err := http.NewRequest(ahttp.MethodGet, "http://localhost:8080/doc/v0.3/mydoc.html", nil) + assert.Nil(t, err) r3.SetBasicAuth("jeeva", "welcome123") - ctx1.Req = ahttp.ParseRequest(r3, &ahttp.Request{}) + ctx1.Req = ahttp.AcquireRequest(r3) AuthcAuthzMiddleware(ctx1, &Middleware{}) } func TestSecurityAntiCSRF(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() + + t.Logf("Test Server URL [Security Basic Authc and Authz]: %s", ts.URL) + cfg, _ := config.ParseString(` security { anti_csrf { @@ -250,53 +283,58 @@ func TestSecurityAntiCSRF(t *testing.T) { } } `) - err := initSecurity(cfg) + err = ts.app.Config().Merge(cfg) + assert.Nil(t, err) + + err = ts.app.initView() + assert.Nil(t, err) + + err = ts.app.initSecurity() assert.Nil(t, err) - appLogger, _ = log.New(cfg) r1 := httptest.NewRequest("POST", "https://localhost:8080/login", strings.NewReader("username=jeeva&password=welcome123")) r1.Header.Set(ahttp.HeaderContentType, "application/x-www-form-urlencoded") w1 := httptest.NewRecorder() - ctx1 := &Context{ - Req: ahttp.AcquireRequest(r1), - Res: ahttp.GetResponseWriter(w1), - viewArgs: make(map[string]interface{}), - reply: NewReply(), - subject: security.AcquireSubject(), - route: &router.Route{IsAntiCSRFCheck: true}, - } - - ctx1.viewArgs[keyAntiCSRFSecret] = AppSecurityManager().AntiCSRF.GenerateSecret() + ctx1 := newContext(w1, r1) + ctx1.a = ts.app + ctx1.route = &router.Route{IsAntiCSRFCheck: true} + ctx1.AddViewArg(keyAntiCSRF, ts.app.SecurityManager().AntiCSRF.GenerateSecret()) // Anti-CSRF request + t.Log("Anti-CSRF request") ctx1.Req.Scheme = "http" AntiCSRFMiddleware(ctx1, &Middleware{}) assert.Equal(t, anticsrf.ErrNoCookieFound, ctx1.reply.err.Reason) ctx1.Req.Scheme = "https" // No referer + t.Log("No referer") AntiCSRFMiddleware(ctx1, &Middleware{}) assert.Equal(t, anticsrf.ErrNoReferer, ctx1.reply.err.Reason) // https: malformed URL + t.Log("https: malformed URL") ctx1.Req.Referer = ":host:8080" AntiCSRFMiddleware(ctx1, &Middleware{}) assert.Equal(t, anticsrf.ErrMalformedReferer, ctx1.reply.err.Reason) // Bad referer + t.Log("Bad referer") ctx1.Req.Referer = "https:::" AntiCSRFMiddleware(ctx1, &Middleware{}) assert.Equal(t, anticsrf.ErrBadReferer, ctx1.reply.err.Reason) // Template funcs - result := tmplAntiCSRFToken(ctx1.viewArgs) + t.Log("Template funcs") + result := ts.app.viewMgr.tmplAntiCSRFToken(ctx1.viewArgs) assert.NotNil(t, result) - AppSecurityManager().AntiCSRF.Enabled = false - assert.Equal(t, "", tmplAntiCSRFToken(ctx1.viewArgs)) + ts.app.SecurityManager().AntiCSRF.Enabled = false + assert.Equal(t, "", ts.app.viewMgr.tmplAntiCSRFToken(ctx1.viewArgs)) AntiCSRFMiddleware(ctx1, &Middleware{}) - AppSecurityManager().AntiCSRF.Enabled = true + ts.app.SecurityManager().AntiCSRF.Enabled = true // Password Encoder + t.Log("Password Encoder") err = AddPasswordAlgorithm("mypass", nil) assert.NotNil(t, err) } diff --git a/server.go b/server.go index a6a71b3e..cf7d0c14 100644 --- a/server.go +++ b/server.go @@ -7,269 +7,248 @@ package aah import ( "context" "crypto/tls" - "errors" "fmt" + "io/ioutil" "net" "net/http" "os" + "path/filepath" + "strconv" "strings" - "time" - "golang.org/x/crypto/acme/autocert" - - "aahframework.org/config.v0" "aahframework.org/essentials.v0" - "aahframework.org/log.v0" ) -var ( - aahServer *http.Server - appEngine *engine - appTLSCfg *tls.Config - appAutocertManager *autocert.Manager -) +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ +func (a *app) Start() { + defer a.aahRecover() -// AddServerTLSConfig method can be used for custom TLS config for aah server. -// -// DEPRECATED: Use method `aah.SetTLSConfig` instead. Planned to be removed in `v1.0` release. -func AddServerTLSConfig(tlsCfg *tls.Config) { - // DEPRECATED, planned to be removed in v1.0 - log.Warn("DEPRECATED: Method 'AddServerTLSConfig' deprecated in v0.9, use method 'SetTLSConfig' instead. Deprecated method will not break your functionality, its good to update to new method.") + if !a.initialized { + a.Log().Fatal("aah application is not initialized, call `aah.Init` before the `aah.Start`.") + } - SetTLSConfig(tlsCfg) -} + sessionMode := "stateless" + if a.SessionManager().IsStateful() { + sessionMode = "stateful" + } -// SetTLSConfig method is used to set custom TLS config for aah server. -// Note: if `server.ssl.lets_encrypt.enable=true` then framework sets the -// `GetCertificate` from autocert manager. -// -// Use `aah.OnInit` or `func init() {...}` to assign your custom TLS Config. -func SetTLSConfig(tlsCfg *tls.Config) { - appTLSCfg = tlsCfg -} + a.Log().Infof("App Name: %s", a.Name()) + a.Log().Infof("App Version: %s", a.BuildInfo().Version) + a.Log().Infof("App Build Date: %s", a.BuildInfo().Date) + a.Log().Infof("App Profile: %s", a.Profile()) + a.Log().Infof("App TLS/SSL Enabled: %t", a.IsSSLEnabled()) + + if a.viewMgr != nil { + a.Log().Infof("App View Engine: %s", a.viewMgr.engineName) + } -// Start method starts the Go HTTP server based on aah config "server.*". -func Start() { - defer aahRecover() + a.Log().Infof("App Session Mode: %s", sessionMode) - if !appInitialized { - log.Fatal("aah application is not initialized, call `aah.Init` before the `aah.Start`.") + if a.webApp || a.viewMgr != nil { + a.Log().Infof("App Anti-CSRF Protection Enabled: %t", a.SecurityManager().AntiCSRF.Enabled) } - sessionMode := "stateless" - if AppSessionManager().IsStateful() { - sessionMode = "stateful" + a.Log().Info("App Route Domains:") + for _, name := range a.Router().DomainAddresses() { + a.Log().Infof(" Host: %s, CORS Enabled: %t", name, a.Router().Domains[name].CORSEnabled) } - log.Infof("App Name: %v", AppName()) - log.Infof("App Version: %v", AppBuildInfo().Version) - log.Infof("App Build Date: %v", AppBuildInfo().Date) - log.Infof("App Profile: %v", AppProfile()) - log.Infof("App TLS/SSL Enabled: %v", AppIsSSLEnabled()) - log.Infof("App View Engine: %v", AppConfig().StringDefault("view.engine", "go")) - log.Infof("App Session Mode: %v", sessionMode) - log.Infof("App Anti-CSRF Protection Enabled: %v", AppSecurityManager().AntiCSRF.Enabled) - - if log.IsLevelDebug() { - log.Debugf("App Route Domains: %v", strings.Join(AppRouter().DomainAddresses(), ", ")) - if AppI18n() != nil { - log.Debugf("App i18n Locales: %v", strings.Join(AppI18n().Locales(), ", ")) - } + if a.I18n() != nil { + a.Log().Infof("App i18n Locales: %s", strings.Join(a.I18n().Locales(), ", ")) + } - for event := range AppEventStore().subscribers { - for _, c := range AppEventStore().subscribers[event] { - log.Debugf("Callback: %s, subscribed to event: %s", funcName(c.Callback), event) + if a.Log().IsLevelDebug() { + for event := range a.EventStore().subscribers { + for _, c := range a.EventStore().subscribers[event] { + a.Log().Debugf("Callback: %s, subscribed to event: %s", funcName(c.Callback), event) } } } + a.Log().Infof("App Shutdown Grace Timeout: %s", a.shutdownGraceTimeStr) + // Publish `OnStart` event - AppEventStore().sortAndPublishSync(&Event{Name: EventOnStart}) - - appEngine = newEngine(AppConfig()) - aahServer = &http.Server{ - Handler: appEngine, - ReadTimeout: appHTTPReadTimeout, - WriteTimeout: appHTTPWriteTimeout, - MaxHeaderBytes: appHTTPMaxHdrBytes, - ErrorLog: log.ToGoLogger(), + a.EventStore().sortAndPublishSync(&Event{Name: EventOnStart}) + + hl := a.Log().ToGoLogger() + hl.SetOutput(ioutil.Discard) + + a.server = &http.Server{ + Handler: a.engine, + ReadTimeout: a.httpReadTimeout, + WriteTimeout: a.httpWriteTimeout, + MaxHeaderBytes: a.httpMaxHdrBytes, + ErrorLog: hl, } - aahServer.SetKeepAlivesEnabled(AppConfig().BoolDefault("server.keep_alive", true)) + a.server.SetKeepAlivesEnabled(a.Config().BoolDefault("server.keep_alive", true)) + a.writePID() - go writePID(AppConfig(), getBinaryFileName(), AppBaseDir()) - go listenForHotConfigReload() + go a.listenForHotConfigReload() // Unix Socket - if strings.HasPrefix(AppHTTPAddress(), "unix") { - startUnix(aahServer, AppHTTPAddress()) + if strings.HasPrefix(a.HTTPAddress(), "unix") { + a.startUnix() return } - aahServer.Addr = fmt.Sprintf("%s:%s", AppHTTPAddress(), AppHTTPPort()) + a.server.Addr = fmt.Sprintf("%s:%s", a.HTTPAddress(), a.HTTPPort()) // HTTPS - if AppIsSSLEnabled() { - startHTTPS(aahServer) + if a.IsSSLEnabled() { + a.startHTTPS() return } // HTTP - startHTTP(aahServer) + a.startHTTP() } -// Shutdown method allows aah server to shutdown gracefully with given timeoout -// in seconds. It's invoked on OS signal `SIGINT` and `SIGTERM`. -// -// Method performs: -// - Graceful server shutdown with timeout by `server.timeout.grace_shutdown` -// - Publishes `OnShutdown` event -// - Exits program with code 0 -func Shutdown() { - graceTime := AppConfig().StringDefault("server.timeout.grace_shutdown", "60s") - if !(strings.HasSuffix(graceTime, "s") || strings.HasSuffix(graceTime, "m")) { - log.Warn("'server.timeout.grace_shutdown' value is not a valid time unit, assigning default") - graceTime = "60s" - } - - graceTimeout, _ := time.ParseDuration(graceTime) - ctx, cancel := context.WithTimeout(context.Background(), graceTimeout) +func (a *app) Shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), a.shutdownGraceTimeout) defer cancel() - log.Trace("aah go server shutdown with timeout: ", graceTime) - if err := aahServer.Shutdown(ctx); err != nil && err != http.ErrServerClosed { - log.Error(err) + a.Log().Warn("aah go server graceful shutdown triggered with timeout of ", a.shutdownGraceTimeStr) + if err := a.server.Shutdown(ctx); err != nil && err != http.ErrServerClosed { + a.Log().Error(err) } + a.shutdownRedirectServer() + // Publish `OnShutdown` event - AppEventStore().sortAndPublishSync(&Event{Name: EventOnShutdown}) + a.EventStore().sortAndPublishSync(&Event{Name: EventOnShutdown}) } -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) writePID() { + // Get the application PID + a.pid = os.Getpid() + + pidFile := a.Config().StringDefault("pid_file", "") + if ess.IsStrEmpty(pidFile) { + pidFile = filepath.Join(a.BaseDir(), a.binaryFilename()) + } + + if !strings.HasSuffix(pidFile, ".pid") { + pidFile += ".pid" + } -func startUnix(server *http.Server, address string) { - sockFile := address[5:] + if err := ioutil.WriteFile(pidFile, []byte(strconv.Itoa(a.pid)), 0644); err != nil { + a.Log().Error(err) + } +} + +func (a *app) startUnix() { + sockFile := a.HTTPAddress()[5:] if err := os.Remove(sockFile); !os.IsNotExist(err) { - logAsFatal(err) + a.Log().Fatal(err) } listener, err := net.Listen("unix", sockFile) - logAsFatal(err) + if err != nil { + a.Log().Fatal(err) + return + } - server.Addr = address - log.Infof("aah go server running on %v", server.Addr) - if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { - log.Error(err) + a.server.Addr = a.HTTPAddress() + a.Log().Infof("aah go server running on %v", a.server.Addr) + if err := a.server.Serve(listener); err != nil && err != http.ErrServerClosed { + a.Log().Error(err) } } -func startHTTPS(server *http.Server) { +func (a *app) startHTTPS() { // Assign user-defined TLS config if provided - if appTLSCfg == nil { - server.TLSConfig = new(tls.Config) + if a.tlsCfg == nil { + a.server.TLSConfig = new(tls.Config) } else { - log.Info("Adding user provided TLS Config") - server.TLSConfig = appTLSCfg + a.Log().Info("Adding user provided TLS Config") + a.server.TLSConfig = a.tlsCfg } // Add cert, if let's encrypt enabled - if appIsLetsEncrypt { - log.Infof("Let's Encypyt CA Cert enabled") - server.TLSConfig.GetCertificate = appAutocertManager.GetCertificate + if a.IsLetsEncrypt() { + a.Log().Infof("Let's Encypyt CA Cert enabled") + a.server.TLSConfig.GetCertificate = a.autocertMgr.GetCertificate } else { - log.Infof("SSLCert: %v, SSLKey: %v", appSSLCert, appSSLKey) + a.Log().Infof("SSLCert: %s, SSLKey: %s", a.sslCert, a.sslKey) } // Enable & Disable HTTP/2 - if AppConfig().BoolDefault("server.ssl.disable_http2", false) { + if a.Config().BoolDefault("server.ssl.disable_http2", false) { // To disable HTTP/2 is- // - Don't add "h2" to TLSConfig.NextProtos // - Initialize TLSNextProto with empty map // Otherwise Go will enable HTTP/2 by default. It's not gonna listen to you :) - server.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){} + a.server.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){} } else { - server.TLSConfig.NextProtos = append(server.TLSConfig.NextProtos, "h2") + a.server.TLSConfig.NextProtos = append(a.server.TLSConfig.NextProtos, "h2") } // start HTTP redirect server if enabled - go startHTTPRedirect(AppConfig()) + go a.startHTTPRedirect() - printStartupNote() - if err := server.ListenAndServeTLS(appSSLCert, appSSLKey); err != nil && err != http.ErrServerClosed { - log.Error(err) + a.printStartupNote() + if err := a.server.ListenAndServeTLS(a.sslCert, a.sslKey); err != nil && err != http.ErrServerClosed { + a.Log().Error(err) } } -func startHTTP(server *http.Server) { - printStartupNote() - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error(err) +func (a *app) startHTTP() { + a.printStartupNote() + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + a.Log().Error(err) } } -func startHTTPRedirect(cfg *config.Config) { +func (a *app) startHTTPRedirect() { + cfg := a.Config() keyPrefix := "server.ssl.redirect_http" if !cfg.BoolDefault(keyPrefix+".enable", false) { return } - address := cfg.StringDefault("server.address", "") - toPort := parsePort(cfg.StringDefault("server.port", appDefaultHTTPPort)) + address := a.HTTPAddress() + toPort := a.parsePort(cfg.StringDefault("server.port", defaultHTTPPort)) fromPort, found := cfg.String(keyPrefix + ".port") if !found { - log.Errorf("'%s.port' is required value, unable to start redirect server", keyPrefix) + a.Log().Errorf("'%s.port' is required value, unable to start redirect server", keyPrefix) return } redirectCode := cfg.IntDefault(keyPrefix+".code", http.StatusTemporaryRedirect) - log.Infof("aah go redirect server running on %s:%s", address, fromPort) - if err := http.ListenAndServe(address+":"+fromPort, http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { + a.Log().Infof("aah go redirect server running on %s:%s", address, fromPort) + a.redirectServer = &http.Server{ + Addr: address + ":" + fromPort, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target := "https://" + parseHost(r.Host, toPort) + r.URL.Path if len(r.URL.RawQuery) > 0 { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, redirectCode) - })); err != nil && err != http.ErrServerClosed { - log.Error(err) + }), } -} -func initAutoCertManager(cfg *config.Config) error { - if !AppIsSSLEnabled() || !appIsLetsEncrypt { - return nil - } - - hostPolicy, found := cfg.StringList("server.ssl.lets_encrypt.host_policy") - if !found || len(hostPolicy) == 0 { - return errors.New("'server.ssl.lets_encrypt.host_policy' is empty, provide at least one hostname") - } - - renewBefore := time.Duration(cfg.IntDefault("server.ssl.lets_encrypt.renew_before", 10)) - - appAutocertManager = &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(hostPolicy...), - RenewBefore: 24 * renewBefore * time.Hour, - ForceRSA: cfg.BoolDefault("server.ssl.lets_encrypt.force_rsa", false), - Email: cfg.StringDefault("server.ssl.lets_encrypt.email", ""), + if err := a.redirectServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + a.Log().Error(err) } +} - cacheDir := cfg.StringDefault("server.ssl.lets_encrypt.cache_dir", "") - if !ess.IsStrEmpty(cacheDir) { - appAutocertManager.Cache = autocert.DirCache(cacheDir) +func (a *app) shutdownRedirectServer() { + if a.redirectServer != nil { + _ = a.redirectServer.Close() } - - return nil } -func printStartupNote() { - port := firstNonZeroString(AppConfig().StringDefault("server.port", appDefaultHTTPPort), AppConfig().StringDefault("server.proxyport", "")) - log.Infof("aah go server running on %s:%s", AppHTTPAddress(), parsePort(port)) +func (a *app) printStartupNote() { + port := firstNonZeroString( + a.Config().StringDefault("server.port", defaultHTTPPort), + a.Config().StringDefault("server.proxyport", "")) + a.Log().Infof("aah go server running on %s:%s", a.HTTPAddress(), a.parsePort(port)) } diff --git a/server_test.go b/server_test.go index b0ca8ed5..8c774fab 100644 --- a/server_test.go +++ b/server_test.go @@ -5,112 +5,87 @@ package aah import ( - "crypto/tls" "net/http" "path/filepath" "strings" "testing" "time" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" "aahframework.org/test.v0/assert" ) -func TestServerStart1(t *testing.T) { - defer ess.DeleteFiles("testapp.pid") - // App Config - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) +func TestServerStartHTTP(t *testing.T) { + defer ess.DeleteFiles("webapp1.pid") + + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppConfig()) + defer ts.Close() - AppConfig().SetString("env.dev.name", "testapp") + t.Logf("Test Server URL [Server Start HTTP]: %s", ts.URL) - err = initAppVariables() - assert.Nil(t, err) - appInitialized = true - Start() + go ts.app.Start() + defer ts.app.Shutdown() - if aahServer != nil { - Shutdown() - } + time.Sleep(10 * time.Millisecond) } -func TestServerStart2(t *testing.T) { - defer ess.DeleteFiles("testapp.pid") +func TestServerStartUnix(t *testing.T) { + defer ess.DeleteFiles("webapp1.pid") - // App Config - cfgDir := filepath.Join(getTestdataPath(), appConfigDir()) - err := initConfig(cfgDir) + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppConfig()) + defer ts.Close() - AppConfig().SetString("env.dev.name", "testapp") + t.Logf("Test Server URL [Server Start Unix]: %s", ts.URL) - err = initAppVariables() - assert.Nil(t, err) - appInitialized = true + ts.app.Config().SetString("server.address", "unix:/tmp/testserver") + go ts.app.Start() + defer ts.app.Shutdown() - // Router - err = initRoutes(cfgDir, AppConfig()) - assert.Nil(t, err) - assert.NotNil(t, AppRouter()) + time.Sleep(10 * time.Millisecond) +} - // Security - err = initSecurity(AppConfig()) - assert.Nil(t, err) +func TestServerHTTPRedirect(t *testing.T) { + defer ess.DeleteFiles("webapp1.pid") - // i18n - i18nDir := filepath.Join(getTestdataPath(), appI18nDir()) - err = initI18n(i18nDir) + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts1, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppI18n()) - - buildTime := time.Now().Format(time.RFC3339) - SetAppBuildInfo(&BuildInfo{ - BinaryName: "testapp", - Date: buildTime, - Version: "1.0.0", - }) - AppConfig().SetString("server.port", "80") - Start() - - if aahServer != nil { - Shutdown() - } -} + defer ts1.Close() -func TestServerHTTPRedirect(t *testing.T) { - cfg, _ := config.ParseString("") + t.Logf("Test Server URL [Redirect Server]: %s", ts1.URL) // redirect not enabled - startHTTPRedirect(cfg) + t.Log("redirect not enabled") + ts1.app.startHTTPRedirect() // redirect enabled but port not provided - cfg.SetBool("server.ssl.redirect_http.enable", true) - cfg.SetString("server.port", "8443") - startHTTPRedirect(cfg) + t.Log("redirect enabled but port not provided") + ts1.app.Config().SetBool("server.ssl.redirect_http.enable", true) + ts1.app.Config().SetString("server.port", "8443") + go ts1.app.startHTTPRedirect() + defer ts1.app.shutdownRedirectServer() // redirect enabled with port - cfg.SetString("server.ssl.redirect_http.port", "8080") - go startHTTPRedirect(cfg) + ts2, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts2.Close() - // send request to redirect server - resp, err := http.Get("http://localhost:8080/contact-us.html?utm_source=footer") - assert.Nil(t, resp) - assert.True(t, strings.Contains(err.Error(), "localhost:8443")) -} + t.Logf("Test Server URL [Redirect Server]: %s", ts2.URL) -func TestServerTLSSettings(t *testing.T) { - tlsCfg := &tls.Config{} - AddServerTLSConfig(tlsCfg) - SetTLSConfig(tlsCfg) -} + t.Log("redirect enabled with port") + ts2.app.Config().SetString("server.ssl.redirect_http.port", "8080") + go ts2.app.startHTTPRedirect() + defer ts2.app.shutdownRedirectServer() -func TestServerStartUnix(t *testing.T) { - server := &http.Server{} - go startUnix(server, "unix:/tmp/testserver") - time.Sleep(5 * time.Millisecond) - _ = server.Close() + // send request to redirect server + t.Log("send request to redirect server") + resp, err := http.Get("http://localhost:8080/contact-us.html?utm_source=footer") + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 307, resp.StatusCode) + assert.True(t, strings.Contains(responseBody(resp), "Temporary Redirect")) } diff --git a/static.go b/static.go index f3df3218..2265ce6b 100644 --- a/static.go +++ b/static.go @@ -8,8 +8,6 @@ import ( "errors" "fmt" "html/template" - "io" - "mime" "net/http" "net/url" "os" @@ -20,35 +18,61 @@ import ( "aahframework.org/ahttp.v0" "aahframework.org/essentials.v0" - "aahframework.org/log.v0" -) - -const ( - sniffLen = 512 - noCacheHdrValue = "no-cache, no-store, must-revalidate" - dirListDateTimeFormat = "2006-01-02 15:04:05" ) var ( - staticDfltCacheHdr string - staticMimeCacheHdrMap = make(map[string]string) - errSeeker = errors.New("static: seeker can't seek") + errSeeker = errors.New("static: seeker can't seek") ) -type byName []os.FileInfo +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) initStatic() error { + a.staticMgr = &staticManager{ + a: a, + e: a.engine, + mimeCacheHdrMap: make(map[string]string), + noCacheHdrValue: "no-cache, no-store, must-revalidate", + dirListDateTimeFormat: "2006-01-02 15:04:05", + } + + // default cache header + a.staticMgr.defaultCacheHdr = a.Config().StringDefault("cache.static.default_cache_control", "max-age=31536000, public") -// serveStatic method static file/directory delivery. -func (e *engine) serveStatic(ctx *Context) error { - // Taking control over for static file delivery - ctx.Reply().Done() + // MIME cache headers + // static file cache configuration is from `cache.static.*` + keyPrefix := "cache.static.mime_types" + for _, k := range a.Config().KeysByPath(keyPrefix) { + mimes := strings.Split(a.Config().StringDefault(keyPrefix+"."+k+".mime", ""), ",") + for _, m := range mimes { + if !ess.IsStrEmpty(m) { + hdr := a.Config().StringDefault(keyPrefix+"."+k+".cache_control", a.staticMgr.defaultCacheHdr) + a.staticMgr.mimeCacheHdrMap[strings.TrimSpace(strings.ToLower(m))] = hdr + } + } + } + return nil +} + +type staticManager struct { + a *app + e *engine + defaultCacheHdr string + noCacheHdrValue string + dirListDateTimeFormat string + mimeCacheHdrMap map[string]string +} + +func (s *staticManager) Serve(ctx *Context) error { // TODO static assets Dynamic minify for JS and CSS for non-dev profile // Determine route is file or directory as per user defined // static route config (refer to https://docs.aahframework.org/static-files.html#section-static). // httpDir -> value is from routes config // filePath -> value is from request - httpDir, filePath := getHTTPDirAndFilePath(ctx) + httpDir, filePath := s.httpDirAndFilePath(ctx) ctx.Log().Tracef("Path: %s, Dir: %s", filePath, httpDir) f, err := httpDir.Open(filePath) @@ -56,23 +80,25 @@ func (e *engine) serveStatic(ctx *Context) error { if os.IsNotExist(err) { return errFileNotFound } - writeStaticFileError(ctx.Res, ctx.Req, err) + s.writeFileError(ctx.Res, ctx.Req, err) return nil } - defer ess.CloseQuietly(f) + fi, err := f.Stat() if err != nil { - writeStaticFileError(ctx.Res, ctx.Req, err) + s.writeFileError(ctx.Res, ctx.Req, err) return nil } // Gzip, 1kb above, TODO make it configurable from aah.conf - if fi.Size() > 1024 { - ctx.Reply().gzip = checkGzipRequired(filePath) - e.wrapGzipWriter(ctx) + if s.a.gzipEnabled && ctx.Req.IsGzipAccepted && + fi.Size() > 1024 && checkGzipRequired(filePath) { + ctx.wrapGzipWriter() } - e.writeHeaders(ctx) + + // write headers + ctx.writeHeaders() // Serve file if fi.Mode().IsRegular() { @@ -81,25 +107,25 @@ func (e *engine) serveStatic(ctx *Context) error { ctx.Res.Header().Set(ahttp.HeaderContentType, contentType) // apply cache header if environment profile is `prod` - if appIsProfileProd { - ctx.Res.Header().Set(ahttp.HeaderCacheControl, cacheHeader(contentType)) + if s.a.IsProfileProd() { + ctx.Res.Header().Set(ahttp.HeaderCacheControl, s.cacheHeader(contentType)) } else { // for static files hot-reload ctx.Res.Header().Set(ahttp.HeaderExpires, "0") - ctx.Res.Header().Set(ahttp.HeaderCacheControl, noCacheHdrValue) + ctx.Res.Header().Set(ahttp.HeaderCacheControl, s.noCacheHdrValue) } } // 'OnPreReply' server extension point - publishOnPreReplyEvent(ctx) + s.e.publishOnPreReplyEvent(ctx) http.ServeContent(ctx.Res, ctx.Req.Unwrap(), path.Base(filePath), fi.ModTime(), f) // 'OnAfterReply' server extension point - publishOnAfterReplyEvent(ctx) + s.e.publishOnAfterReplyEvent(ctx) // Send data to access log channel - if e.isAccessLogEnabled && e.isStaticAccessLogEnabled { - sendToAccessLog(ctx) + if s.a.accessLogEnabled && s.a.staticAccessLogEnabled { + s.e.sendToAccessLog(ctx) } return nil } @@ -114,16 +140,16 @@ func (e *engine) serveStatic(ctx *Context) error { } // 'OnPreReply' server extension point - publishOnPreReplyEvent(ctx) + s.e.publishOnPreReplyEvent(ctx) - directoryList(ctx.Res, ctx.Req.Unwrap(), f) + s.listDirectory(ctx.Res, ctx.Req.Unwrap(), f) // 'OnAfterReply' server extension point - publishOnAfterReplyEvent(ctx) + s.e.publishOnAfterReplyEvent(ctx) // Send data to access log channel - if e.isAccessLogEnabled && e.isStaticAccessLogEnabled { - sendToAccessLog(ctx) + if s.a.accessLogEnabled && s.a.staticAccessLogEnabled { + s.e.sendToAccessLog(ctx) } return nil } @@ -136,8 +162,33 @@ func (e *engine) serveStatic(ctx *Context) error { return nil } -// directoryList method compose directory listing response -func directoryList(res http.ResponseWriter, req *http.Request, f http.File) { +// httpDirAndFilePath method returns the `http.Dir` and requested file path. +// +// Note: `ctx.route.*` values come from application routes configuration. +func (s *staticManager) httpDirAndFilePath(ctx *Context) (http.Dir, string) { + dirpath := filepath.Join(s.a.BaseDir(), ctx.route.Dir) + if ctx.route.IsFile() { // this is configured value from routes.conf + return http.Dir(dirpath), + parseCacheBustPart(ctx.route.File, s.a.BuildInfo().Version) + } + + return http.Dir(dirpath), + parseCacheBustPart(ctx.Req.PathValue("filepath"), s.a.BuildInfo().Version) +} + +func (s *staticManager) cacheHeader(contentType string) string { + if idx := strings.IndexByte(contentType, ';'); idx > 0 { + contentType = contentType[:idx] + } + + if hdrValue, found := s.mimeCacheHdrMap[contentType]; found { + return hdrValue + } + return s.defaultCacheHdr +} + +// listDirectory method compose directory listing response +func (s *staticManager) listDirectory(res http.ResponseWriter, req *http.Request, f http.File) { dirs, err := f.Readdir(-1) if err != nil { res.WriteHeader(http.StatusInternalServerError) @@ -165,7 +216,7 @@ func directoryList(res http.ResponseWriter, req *http.Request, f http.File) { fmt.Fprintf(res, "%s%s\n", url.String(), template.HTMLEscapeString(name), - d.ModTime().Format(dirListDateTimeFormat), + d.ModTime().Format(s.dirListDateTimeFormat), ) } fmt.Fprintf(res, "\n") @@ -173,92 +224,9 @@ func directoryList(res http.ResponseWriter, req *http.Request, f http.File) { fmt.Fprintf(res, "\n") } -// checkGzipRequired method return for static which requires gzip response. -func checkGzipRequired(file string) bool { - switch filepath.Ext(file) { - case ".css", ".js", ".html", ".htm", ".json", ".xml", - ".txt", ".csv", ".ttf", ".otf", ".eot": - return true - default: - return false - } -} - -// getHTTPDirAndFilePath method returns the `http.Dir` and requested file path. -// Note: `ctx.route.*` values come from application routes configuration. -func getHTTPDirAndFilePath(ctx *Context) (http.Dir, string) { - if ctx.route.IsFile() { // this is configured value from routes.conf - return http.Dir(filepath.Join(AppBaseDir(), ctx.route.Dir)), - parseCacheBustPart(ctx.route.File, AppBuildInfo().Version) - } - return http.Dir(filepath.Join(AppBaseDir(), ctx.route.Dir)), - parseCacheBustPart(ctx.Req.PathValue("filepath"), AppBuildInfo().Version) -} - -// detectFileContentType method to identify the static file content-type. -func detectFileContentType(file string, content io.ReadSeeker) (string, error) { - ctype := mime.TypeByExtension(filepath.Ext(file)) - if ctype == "" { - // read a chunk to decide between utf-8 text and binary - var buf [sniffLen]byte - n, _ := io.ReadFull(content, buf[:]) - ctype = http.DetectContentType(buf[:n]) - - // rewind to output whole file - if _, err := content.Seek(0, io.SeekStart); err != nil { - return "", errSeeker - } - } - return ctype, nil -} - -func cacheHeader(contentType string) string { - if idx := strings.IndexByte(contentType, ';'); idx > 0 { - contentType = contentType[:idx] - } - - if hdrValue, found := staticMimeCacheHdrMap[contentType]; found { - return hdrValue - } - return staticDfltCacheHdr -} - -// Sort interface for Directory list -func (s byName) Len() int { return len(s) } -func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } -func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// parseStaticMimeCacheMap method parses the static file cache configuration -// `cache.static.*`. -func parseStaticMimeCacheMap(e *Event) { - cfg := AppConfig() - staticDfltCacheHdr = cfg.StringDefault("cache.static.default_cache_control", "max-age=31536000, public") - keyPrefix := "cache.static.mime_types" - - for _, k := range cfg.KeysByPath(keyPrefix) { - mimes := strings.Split(cfg.StringDefault(keyPrefix+"."+k+".mime", ""), ",") - for _, m := range mimes { - if ess.IsStrEmpty(m) { - continue - } - hdr := cfg.StringDefault(keyPrefix+"."+k+".cache_control", staticDfltCacheHdr) - staticMimeCacheHdrMap[strings.TrimSpace(strings.ToLower(m))] = hdr - } - } -} - -func parseCacheBustPart(name, part string) string { - if strings.Contains(name, part) { - name = strings.Replace(name, "-"+part, "", 1) - name = strings.Replace(name, part+"-", "", 1) - return name - } - return name -} - -func writeStaticFileError(res ahttp.ResponseWriter, req *ahttp.Request, err error) { +func (s *staticManager) writeFileError(res ahttp.ResponseWriter, req *ahttp.Request, err error) { if os.IsPermission(err) { - log.Warnf("Static file permission issue: %s", req.Path) + s.a.Log().Warnf("Static file permission issue: %s", req.Path) res.WriteHeader(http.StatusForbidden) fmt.Fprintf(res, "403 Forbidden") } else { @@ -267,6 +235,9 @@ func writeStaticFileError(res ahttp.ResponseWriter, req *ahttp.Request, err erro } } -func init() { - OnStart(parseStaticMimeCacheMap) -} +// Sort interface for Directory list +type byName []os.FileInfo + +func (s byName) Len() int { return len(s) } +func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } +func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } diff --git a/static_test.go b/static_test.go index ab454790..7dfe6482 100644 --- a/static_test.go +++ b/static_test.go @@ -7,83 +7,67 @@ package aah import ( "bytes" "io/ioutil" - "net/http/httptest" - "os" + "net/http" "path/filepath" "strings" "testing" - "aahframework.org/config.v0" - "aahframework.org/router.v0" + "aahframework.org/ahttp.v0" "aahframework.org/test.v0/assert" ) -func TestStaticFileAndDirectoryListing(t *testing.T) { - appCfg, _ := config.ParseString("") - e := newEngine(appCfg) - - testStaticServe(t, e, "http://localhost:8080/static/css/aah\x00.css", "static", "css/aah\x00.css", "", "500 Internal Server Error", false) - - testStaticServe(t, e, "http://localhost:8080/static/", "static", "", "", `Listing of /static/`, true) - - testStaticServe(t, e, "http://localhost:8080/static", "static", "", "", "403 Directory listing not allowed", false) - - testStaticServe(t, e, "http://localhost:8080/static", "static", "", "", `Moved Permanently`, true) - - testStaticServe(t, e, "http://localhost:8080/static/test.txt", "static", "test.txt", "", "This is file content of test.txt", false) - - appIsProfileProd = true - testStaticServe(t, e, "http://localhost:8080/robots.txt", "static", "", "test.txt", "This is file content of test.txt", false) - appIsProfileProd = false -} +func TestStaticFilesDelivery(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() -func TestStaticMisc(t *testing.T) { - // File extension check for gzip - v1 := checkGzipRequired("sample.css") - assert.True(t, v1) + t.Logf("Test Server URL [Static Files Delivery]: %s", ts.URL) - v2 := checkGzipRequired("font.otf") - assert.True(t, v2) + httpClient := new(http.Client) - // directoryList for read error - r1 := httptest.NewRequest("GET", "http://localhost:8080/assets/css/app.css", nil) - w1 := httptest.NewRecorder() - f, err := os.Open(filepath.Join(getTestdataPath(), "static", "test.txt")) + // Static File - /robots.txt + t.Log("Static File - /robots.txt") + resp, err := httpClient.Get(ts.URL + "/robots.txt") assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.True(t, strings.Contains(responseBody(resp), "User-agent: *")) + assert.Equal(t, "no-cache, no-store, must-revalidate", resp.Header.Get(ahttp.HeaderCacheControl)) - directoryList(w1, r1, f) - assert.Equal(t, "Error reading directory", w1.Body.String()) - - // cache bust filename parse - filename := parseCacheBustPart("aah-813e524.css", "813e524") - assert.Equal(t, "aah.css", filename) -} + // Static File - /assets/css/aah.css + t.Log("Static File - /assets/css/aah.css") + resp, err = httpClient.Get(ts.URL + "/assets/css/aah.css") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.True(t, strings.Contains(responseBody(resp), "Minimal aah framework application template CSS.")) + assert.Equal(t, "no-cache, no-store, must-revalidate", resp.Header.Get(ahttp.HeaderCacheControl)) -func TestParseStaticCacheMap(t *testing.T) { - appConfig, _ = config.ParseString(` - cache { - static { - default_cache_control = "public, max-age=31536000" - - mime_types { - css_js { - mime = "text/css, application/javascript" - cache_control = "public, max-age=2628000, must-revalidate, proxy-revalidate" - } - - images { - mime = "image/jpeg, image/png, image/gif, image/svg+xml, image/x-icon" - cache_control = "public, max-age=2628000, must-revalidate, proxy-revalidate" - } - } - } - } - `) - - parseStaticMimeCacheMap(&Event{}) - assert.Equal(t, "public, max-age=2628000, must-revalidate, proxy-revalidate", cacheHeader("image/png")) - assert.Equal(t, "public, max-age=31536000", cacheHeader("application/x-font-ttf")) - appConfig = nil + // Directory Listing - /assets + t.Log("Directory Listing - /assets") + resp, err = httpClient.Get(ts.URL + "/assets") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + body := responseBody(resp) + assert.True(t, strings.Contains(body, "Listing of /assets/")) + assert.True(t, strings.Contains(body, "

Listing of /assets/


")) + assert.True(t, strings.Contains(body, `robots.txt`)) + assert.Equal(t, "", resp.Header.Get(ahttp.HeaderCacheControl)) + + // Static File - /assets/img/aah-framework-logo.png + t.Log("Static File - /assets/img/aah-framework-logo.png") + resp, err = httpClient.Get(ts.URL + "/assets/img/aah-framework-logo.png") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "image/png", resp.Header.Get(ahttp.HeaderContentType)) + assert.Equal(t, "6990", resp.Header.Get(ahttp.HeaderContentLength)) + assert.Equal(t, "no-cache, no-store, must-revalidate", resp.Header.Get(ahttp.HeaderCacheControl)) + + // Static File - /assets/img/notfound/file.txt + t.Log("Static File - /assets/img/notfound/file.txt") + resp, err = httpClient.Get(ts.URL + "/assets/img/notfound/file.txt") + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "0", resp.Header.Get(ahttp.HeaderContentLength)) } func TestStaticDetectContentType(t *testing.T) { @@ -120,26 +104,7 @@ func TestStaticDetectContentType(t *testing.T) { v, _ = detectFileContentType("file.css", nil) assert.Equal(t, "text/css; charset=utf-8", v) - content, _ := ioutil.ReadFile(filepath.Join(getTestdataPath(), "test-image.noext")) + content, _ := ioutil.ReadFile(filepath.Join(testdataBaseDir(), "test-image.noext")) v, _ = detectFileContentType("test-image.noext", bytes.NewReader(content)) assert.Equal(t, "image/png", v) } - -func testStaticServe(t *testing.T, e *engine, reqURL, dir, filePath, file, result string, listDir bool) { - r := httptest.NewRequest("GET", reqURL, nil) - w := httptest.NewRecorder() - ctx := e.prepareContext(w, r) - ctx.route = &router.Route{IsStatic: true, Dir: dir, ListDir: listDir, File: file} - ctx.Req.Params.Path = map[string]string{ - "filepath": filePath, - } - appBaseDir = getTestdataPath() - err := e.serveStatic(ctx) - appBaseDir = "" - assert.Nil(t, err) - if !strings.Contains(w.Body.String(), result) { - t.Log(w.Body.String(), result) - } - - assert.True(t, strings.Contains(w.Body.String(), result)) -} diff --git a/testdata/config/aah.conf b/testdata/config/aah.conf deleted file mode 100644 index 32c5321e..00000000 --- a/testdata/config/aah.conf +++ /dev/null @@ -1,209 +0,0 @@ -################################################### -# aahframework - aah framework application -# -# Complete configuration reference: -# https://docs.aahframework.org/routes-config.html -################################################### - -# ---------------------------------------------------------- -# Application name (non-whitespace) and Friendly description -# ---------------------------------------------------------- -name = "aahframework" - -desc = "aah framework test config" - -# --------------------------- -# Server configuration - HTTP -# --------------------------- -server { - # For unix socket: unix:/tmp/aahframework.sock - # Default value is empty - address = "127.0.0.1" - - # For port `80`, put empty string or `80` as a value - # Default value is 8080 - port = "80" - - # Header value written as response HTTP header. - # If you do not want to include `Server` header, comment it out. - header = "aah-go-server" - - # Valid time units are "s = seconds", "m = minutes" - timeout { - # mapped to `http.Server.ReadTimeout`. - # Default value is `90s` - read = "90s" - - # mapped to `http.Server.WriteTimeout` - # Default value is `90s` - write = "90s" - - # aah sever graceful shutdown timeout, default value is 60 seconds. - # Note: applicable only to go1.8 and above - grace_shutdown = "60s" - } - - # mapped to `http.Server.MaxHeaderBytes` default value is `1mb` - max_header_bytes = "1mb" - - # http server keep alive option default value is true - keep_alive = true - - ssl { - # Default value is false - enable = false - - # Default value is empty - #cert = "" - - # Default value is empty - #key = "" - - lets_encrypt { - enable = false - - host_policy = ["sample.com"] - } - } - - # To manage aah server effectively it is necessary to know details about the - # request, response, processing time, client IP address, etc. aah framework - # provides the flexible and configurable access log capabilities. - access_log { - # Enabling server access log - # Default value is `false`. - enable = true - - # Absolute path to access log file or relative path. - # Default location is application logs directory - #file = "{{ .AppName }}-access.log" - - # Default server access log pattern - #pattern = "%clientip %custom:- %reqtime %reqmethod %requrl %reqproto %resstatus %ressize %restime %reqhdr:referer" - - # Access Log channel buffer size - # Default value is `500`. - #channel_buffer_size = 500 - - # Include static files access log too. - # Default value is `true`. - #static_file = false - } - - # ------------------------------------------------------- - # Dump Request & Response Details - # Such as URL, Protocol, Headers, Body, etc. - # Note: Dump is not applicable for Static Files delivery. - # Doc: https://docs.aahframework.org/server-dump-log.html - # ------------------------------------------------------- - dump_log { - # Default value is `false`. - enable = true - - # Absolute path to dump log file or relative path. - # Default location is application logs directory - #file = "{{ .AppName }}-dump.log" - - # Log Request body into dump log. aah dumps body for JSON, XML, Form - # HTML and Plain Text content types. - # Default value is `false`. - request_body = true - - # Log Request body into dump log. aah dumps body for JSON, XML, Form - # HTML, and Plain Text content types. - # Default value is `false`. - response_body = true - } -} - -# --------------------- -# Request configuration -# --------------------- -request { - - # aah framework encourages to have unique `Request Id` for each incoming - # request, it helps in tracebility. If request has already `X-Request-Id` - # HTTP header for then it does not generate one. - id { - # Default value is true - enable = true - - # Default value is `X-Request-Id`, change it if you have different one. - header = "X-Request-Id" - } - - # Max request body size for all incoming HTTP requests except `MultipartForm`. - # Also you can override this size for individual route on specific cases - # in `routes.conf` if need be. - # Default value is `5mb`. - #max_body_size = "5mb" - - # Default value is 32mb, choose your value based on your use case - multipart_size = "32mb" - - # aah provides `Content Negotiation` feature for the incoming HTTP request. - # Read more about implementation and RFC details here GitHub #75. - # Perfect for REST API, also can be used for web application too if needed. - content_negotiation { - # To enable Content Negotiation for the application. - # Default value is `false`. - enable = true - - # For example: Client sends Accept header as `application/xml`. - # However server only supports serving JSON i.e. `application/json`. - # Then server responds with 406 Not Acceptable. - # Default value is empty list and disabled. - offered = ["*/*", "application/json", "text/json"] - - # For example: Client sends Content-Type header as `application/xml`. - # However server only supports JSON payload as request body. - # Then server responds with 415 Unsupported Media Type. - # Default value is empty list and disabled. - accepted = ["*/*", "application/json", "text/json"] - } - - # Auto Bind configuration used to bind request parameters to controller - # action parameters. - auto_bind { - # Priority is used to select the bind source priority. - # P -> Path Parameter - # F -> Form Parameter - # Q -> Query Parameter - # - # For example: Let's say you have controller action named `OrderInfo` and its has - # parameter called `orderId`. So framework tries to parse and bind based - # on the priority. The `orderId` present in `Path` and `Form`, framework - # binds the value from `Path`. Typically recommended to have unique names - # in the request parameter though :) - # Path -> Form -> Query - # If not found then it returns with default Go zero value. - # - # Default value is `PFQ`. - #priority = "PFQ" - - # Tag Name is used for bind values to struct exported fields. - # Default value is `bind`. - #tag_name = "bind" - } -} - -# -------------------------------------------------------------- -# Application Security -# Doc: https://docs.aahframework.org/security-config.html -# -------------------------------------------------------------- -include "./security.conf" - -# -------------------------------------------------------------- -# Environment Profiles e.g.: dev, qa, prod -# Doc: https://docs.aahframework.org/app-config.html#section-env -# -------------------------------------------------------------- -env { - # Indicates active profile name for application configuration. - # Default value is `dev`. - #active = "dev" - - # ---------------------------------- - # Environment profile configurations - # ---------------------------------- - include "./env/*.conf" -} diff --git a/testdata/config/env/dev.conf b/testdata/config/env/dev.conf deleted file mode 100644 index 9882a4f1..00000000 --- a/testdata/config/env/dev.conf +++ /dev/null @@ -1,3 +0,0 @@ -dev { - -} diff --git a/testdata/config/routes.conf b/testdata/config/routes.conf deleted file mode 100644 index 51f1959f..00000000 --- a/testdata/config/routes.conf +++ /dev/null @@ -1,135 +0,0 @@ -################################################### -# aahframework - Application Routes Configuration -################################################### - -name = "aah test routes" - -#------------------------------------------------ -# All domains or sub-domains goes as section -# To understand routes configuration -#------------------------------------------------ -domains { - - # Domain name/ip address with port no, basically unique name - aahframework { - name = "aahframework.org routes" - host = "localhost" - - #----------------------------------------------------------------- - # To serve Static files. It can be directory or individual file. - # 'static' section is optional, if you don't have static files. - # e.g.: REST API - #----------------------------------------------------------------- - static { - - #------------------------------------- - # Static route name, pick a unique one - # for serving directory - #------------------------------------- - public_assets { - # URL 'path' for serving directory - # Below definition means '/assets/**' - path = "/assets" - - # Relative to application base directory or an absolute path - dir = "static" - - # list directory, default is 'false' - #list = false - } - - testdata { - path = "/testdata" - dir = "testdata" - list = true - } - - # serving single file - favicon { - path = "/favicon.png" - - # Path with direct file mapping - # It can be relative to application base directory or an absolute path - # If it's relative path '/static/' prefixed automatically - file = "img/aah-logo-32x32.png" - } - } - - #---------------------------------------------- - # Application routes, to know more. - #---------------------------------------------- - routes { - - #------------------------------------------------------ - # namespace or group or route name, pick an unique name - # This name is used for reverse route. - #------------------------------------------------------ - index { - # path is used to access URL - path = "/" - - # Optional, default value is GET, it can be lowercase or uppercase - #method = "GET" - - controller = "Site" - - # Optional, if want to go with defaults 'Action' names - # Default action value for GET is 'Index', - #action = "Index" - } - - get_involved { - path = "/get-involved.html" - controller = "Site" - action = "GetInvolved" - } - - contribute_code { - path = "/contribute-to-code.html" - controller = "Site" - action = "ContributeCode" - } - - security_vulnerability { - path = "/security.html" - controller = "Site" - action = "Security" - } - - credits { - path = "/credits" - controller = "Site" - action = "Credits" - } - - version_home { - path = "/doc/:version" - controller = "Doc" - action = "VersionHome" - } - - show_doc { - path = "/doc/:version/*content" - controller = "Doc" - action = "ShowDoc" - } - - auto_bind { - path = "/products/:id" - method = "POST" - controller = "Site" - action = "AutoBind" - } - - json_submit { - path = "/json-submit" - method = "POST" - controller = "Site" - action = "JSONRequest" - } - - } # end - app routes - - } # end - aahframework - -} # end - domains diff --git a/testdata/config/security.conf b/testdata/config/security.conf deleted file mode 100644 index 92dc0646..00000000 --- a/testdata/config/security.conf +++ /dev/null @@ -1,8 +0,0 @@ -# App security configuration - -security { - session { - # defaults with take place - mode = "stateful" - } -} diff --git a/testdata/i18n/messages.en b/testdata/i18n/messages.en deleted file mode 100644 index 49626b9f..00000000 --- a/testdata/i18n/messages.en +++ /dev/null @@ -1,15 +0,0 @@ -# test i18n file - en - -label { - pages { - site { - index { - title = "en: Welcome to aah framework, A scalable, performant, rapid development Web framework for Go" - } - - get_involved { - title = "en: Get Involved - aah web framework for Go" - } - } - } -} diff --git a/testdata/static/test.txt b/testdata/static/test.txt deleted file mode 100644 index ab92b19d..00000000 --- a/testdata/static/test.txt +++ /dev/null @@ -1 +0,0 @@ -This is file content of test.txt diff --git a/testdata/views/common/footer_scripts.html b/testdata/views/common/footer_scripts.html deleted file mode 100644 index 06a46b2d..00000000 --- a/testdata/views/common/footer_scripts.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/testdata/views/common/head_tags.html b/testdata/views/common/head_tags.html deleted file mode 100644 index 0906b966..00000000 --- a/testdata/views/common/head_tags.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/testdata/views/pages/user/index.html b/testdata/views/pages/user/index.html deleted file mode 100644 index f248673e..00000000 --- a/testdata/views/pages/user/index.html +++ /dev/null @@ -1,5 +0,0 @@ -{{ define "title" }}aah framework - User Home{{ end }} - -{{ define "body" -}} -

{{ .GreetName }} {{ .PageName }}.

-{{- end }} diff --git a/testdata/webapp1/.gitignore b/testdata/webapp1/.gitignore new file mode 100644 index 00000000..0d8386db --- /dev/null +++ b/testdata/webapp1/.gitignore @@ -0,0 +1,31 @@ +# aah framework application - .gitignore + +aah.go +*.pid +build/ +vendor/*/ + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/testdata/webapp1/aah.project b/testdata/webapp1/aah.project new file mode 100644 index 00000000..8a457e62 --- /dev/null +++ b/testdata/webapp1/aah.project @@ -0,0 +1,68 @@ +############################################################## +# webapp1 - aah framework project +# +# Note: Add it to version control +############################################################## + +# Build section is used during aah application compile and build command. +build { + # Application binary name + # Default value is `name` attribute value from `aah.conf` + #binary_name = "webapp1" + + # Used as fallback if + # - `git commit sha` or + # - `AAH_APP_VERSION` environment value is not available. + version = "0.0.1" + + # If application is missing any dependencies in `build import path` + # during a compile and build, aah CLI will try to get dependencies + # using 'go get '. + # Default value is `false`. + #dep_get = true + + flags = ["-i"] + + ldflags = "" + + tags = "" + + # AST excludes is used for `aah.Context` inspection and generating aah + # application main Go file. Valid exclude patterns + # refer: https://golang.org/pkg/path/filepath/#Match + ast_excludes = ["*_test.go", ".*", "*.bak", "*.tmp", "vendor"] + + # Packing excludes is used to exclude file/directory during aah application + # build archive. Valid exclude patterns + # refer: https://golang.org/pkg/path/filepath/#Match + excludes = ["*.go", "*_test.go", ".*", "*.bak", "*.tmp", "vendor", "app", "build", "tests", "logs"] +} + +# Logger configuration for aah CLI tool. +log { + # Log level + # Default value is `info`. + #level = "info" + + # Log colored output + # Default value is `true`. + #color = false +} + +# Hot-Reload is development purpose to help developer. +# Read more about implementation here - https://aahframework.org/issues/4 +# +# NOTE: Do not use hot-reload feature for production purpose, it's not recommended. +hot_reload { + # Default value is `true`. + enable = true + + # Watch configuration - files/directories exclusion list. + watch { + # Note: static directory not required to be monitored, since server delivers + # up-to-date file on environment profile `dev`. + dir_excludes = [".*"] + + file_excludes = [".*", "_test.go", "LICENSE", "README.md"] + } +} diff --git a/testdata/webapp1/config/aah.conf b/testdata/webapp1/config/aah.conf new file mode 100644 index 00000000..2bbd6968 --- /dev/null +++ b/testdata/webapp1/config/aah.conf @@ -0,0 +1,453 @@ +################################################### +# webapp1 - aah framework application +# +# Complete configuration reference: +# https://docs.aahframework.org/app-config.html +################################################### + +# Application name (non-whitespace) +# Default value is `basename` of import path. +name = "webapp1" + +# Friendly description of application +desc = "aah framework web application" + +# Application type, typically either Web or API. +type = "web" + +# Application instance name is used when you're running aah application cluster. +# This value is used in the context based logging, it distinguishes your instance +# log from other instances. +# +# Typically you can to pass `instance_name` value via aah external config +# support or Environment variable. +instance_name = $AAH_INSTANCE_NAME + +# Configure file path of application PID file to be written. +# Ensure application has appropriate permission and directory exists. +# Default value is `/.pid` +#pid_file = "/path/to/pidfile.pid" + +# ----------------------------------------------------------------- +# Server configuration - HTTP +# Doc: https://docs.aahframework.org/app-config.html#section-server +# ----------------------------------------------------------------- +server { + # For unix socket: unix:/tmp/aahframework.sock + # Default value is `empty` string. + #address = "" + + # For standard port `80` and `443`, put empty string or a value + # Default value is 8080. + #port = "" + + # Header value written as `Server` HTTP header. + # If you do not want to include `Server` header, comment it out. + header = "aah-go-server" + + # Valid time units are "s = seconds", "m = minutes" + timeout { + # Mapped to `http.Server.ReadTimeout`, is the maximum duration for reading + # the entire request, including the body. + # + # Because ReadTimeout does not let Handlers make per-request decisions on + # each request body's acceptable deadline or upload rate, most users will + # prefer to use ReadHeaderTimeout. It is valid to use them both. + # Default value is `90s`. + #read = "90s" + + # Mapped to `http.Server.WriteTimeout`, is the maximum duration before timing + # out writes of the response. It is reset whenever a new request's header is + # read. Like ReadTimeout, it does not let Handlers make decisions on a + # per-request basis. + # Default value is `90s`. + #write = "90s" + + # aah server graceful shutdown timeout + # Default value is `60s`. + #grace_shutdown = "60s" + } + + # Mapped to `http.Server.MaxHeaderBytes`. + # Default value is `1mb`. + #max_header_bytes = "1mb" + + # HTTP server keep alive option. + # Default value is `true`. + #keep_alive = true + + ssl { + # Default value is `false`. + #enable = false + + # Default value is `empty` string. + #cert = "" + + # Default value is `empty` string. + #key = "" + + # Disabling HTTP/2 set it true. + # Default value is `false`. + #disable_http2 = true + + # Redirect HTTP => HTTPS functionality does protocol switch, so it works + # with domain and subdomains. + # For example: + # http://aahframework.org => https://aahframework.org + # http://www.aahframework.org => https://www.aahframework.org + # http://docs.aahframework.org => https://docs.aahframework.org + redirect_http { + # Enabling HTTP => HTTPS redirects. + # Default is value is `false`. + #enable = true + + # Port no. of HTTP requests to listen. + # For standard port `80` put empty string or a value. + # It is required value, no default. + port = "8080" + + # Redirect code + # Default value is `307`. + #code = 307 + } + + lets_encrypt { + # To get SSL certificate from Let's Encrypt CA, enable it. + # Don't forget to enable `server.ssl.enable=true`. + # Default value is `false`. + #enable = false + + # Host policy controls which domains the autocert will attempt + # to retrieve new certificates for. It does not affect cached certs. + # It is required, no default value. + host_policy = ["example.org", "docs.example.org"] + + # Renew before optionally specifies how early certificates should + # be renewed before they expire. + # Default value is `10` days. + #renew_before = 10 + + # Email optionally specifies a contact email address. This is used by CAs, + # such as Let's Encrypt, to notify about problems with issued certificates. + # If the Client's account key is already registered, Email is not used. + #email = "jeeva@myjeeva.com" + + # Force RSA makes the autocert generate certificates with 2048-bit RSA keys. + # If false, a default is used. Currently the default is + # EC-based keys using the P-256 curve. + #force_rsa = false + + # Cache optionally stores and retrieves previously-obtained certificates + # autocert manager. By default certs will only be cached for the lifetime + # of the autocert manager. + # + # autocert manager passes the Cache certificates data encoded in PEM, + # with private/public parts combined in a single Cache.Put call, + # private key first. + # Default value is `empty` string. + #cache_dir = "/Users/jeeva/autocert" + } + } + + # -------------------------------------------------------------------------- + # To manage aah server effectively it is necessary to know details about the + # request, response, processing time, client IP address, etc. aah framework + # provides the flexible and configurable access log capabilities. + # Doc: https://docs.aahframework.org/server-access-log.html + # -------------------------------------------------------------------------- + access_log { + # Enabling server access log + # Default value is `false`. + enable = true + + # Absolute path to access log file or relative path. + # Default location is application logs directory + #file = "webapp1-access.log" + + # Default server access log pattern + pattern = "%clientip %custom:- %reqtime %reqid %reqmethod %requrl %reqproto %resstatus %ressize %restime %reqhdr:referer %querystr %reqhdr:Accept-Encoding %reshdr:Not-Exists %reshdr:X-Content-Type-Options" + + # Access Log channel buffer size + # Default value is `500`. + #channel_buffer_size = 500 + + # Include static files access log too. + # Default value is `true`. + #static_file = false + } + + # ------------------------------------------------------- + # Dump Request & Response Details + # Such as URL, Proto, Headers, Body, etc. + # Note: Dump is not applicable for Static Files delivery. + # Doc: https://docs.aahframework.org/server-dump-log.html + # ------------------------------------------------------- + dump_log { + # Default value is `false`. + enable = true + + # Absolute path to dump log file or relative path. + # Default location is application logs directory + #file = "webapp1-dump.log" + + # Log Request body into dump log. aah dumps body for JSON, XML, Form + # HTML and Plain Text content types. + # Default value is `false`. + request_body = true + + # Log Request body into dump log. aah dumps body for JSON, XML, Form + # HTML, and Plain Text content types. + # Default value is `false`. + response_body = true + } +} + +# ------------------------------------------------------------------ +# Request configuration +# Doc: https://docs.aahframework.org/app-config.html#section-request +# ------------------------------------------------------------------ +request { + # aah framework encourages to have unique `Request Id` for each incoming + # request, it helps in traceability. If request has already `sX-Request-Id` + # HTTP header then it does not generate one. + id { + # Default value is `true`. + enable = true + + # Default value is `X-Request-Id`, change it if you have different one. + #header = "X-Request-Id" + } + + # Max request body size for all incoming HTTP requests except `MultipartForm`. + # Also you can override this size for individual route on specific cases + # in `routes.conf` if need be. + # Default value is `5mb`. + #max_body_size = "5mb" + + # Default value is `32mb`, choose your value based on your use case + #multipart_size = "32mb" + + # aah provides `Content Negotiation` feature for the incoming HTTP request. + # Read more about implementation and RFC details here GitHub #75. + # Perfect for REST API, also can be used for web application too if needed. + content_negotiation { + # To enable Content Negotiation for the application. + # Default value is `false`. + enable = true + + # For example: Client sends Content-Type header as `application/xml`. + # However server only supports JSON payload as request body. + # Then server responds with 415 Unsupported Media Type. + # Default value is empty list and disabled. + accepted = ["application/json", "text/json", "*/*"] + + # For example: Client sends Accept header as `application/xml`. + # However server only supports serving JSON i.e. `application/json`. + # Then server responds with 406 Not Acceptable. + # Default value is empty list and disabled. + offered = ["application/json", "text/json", "*/*"] + } + + # Auto Bind configuration used to bind request parameters to controller + # action parameters. + auto_bind { + # Priority is used to select the bind source priority. + # P -> Path Parameter + # F -> Form Parameter + # Q -> Query Parameter + # + # For example: Let's say you have a controller action named `OrderInfo` and its has + # parameter called `orderId`. So framework tries to parse and bind based + # on the priority. The `orderId` present in `Path` and `Form`, framework + # binds the value from `Path`. Typically recommended to have unique names + # in the request parameter though :) + # Path -> Form -> Query + # If not found then it returns with default Go zero value. + # + # Default value is `PFQ`. + #priority = "PFQ" + + # Tag Name is used for bind values to struct exported fields. + # Default value is `bind`. + #tag_name = "bind" + } +} +# --------------------------------------------------------------- +# i18n configuration +# Doc: https://docs.aahframework.org/app-config.html#section-i18n +# --------------------------------------------------------------- +i18n { + # It is used as fallback if framework is unable to determine the + # locale from HTTP Request. + # Default value is `en`. + #default = "en" + + # Overriding Request Locale `Accept-Language` header value via URL Path + # parameter or URL Query parameter. + param_name { + # Specify URL Path Param name i.e. `/:lang/home.html`, `/:lang/aboutus.html`, etc. + # For e.g.: `/en/home.html`, `/en/aboutus.html`, `/zh-CN/home.html`, `/zh-CN/aboutus.html` etc. + # Default value is `lang`. + #path = "locale" + + # Specify URL Query Param name i.e `?lang=en`, `?lang=zh-CN`, etc. + # Default value is `lang`. + #query = "locale" + } +} + +# ----------------------------------------------------------------- +# Format configuration +# Doc: https://docs.aahframework.org/app-config.html#section-format +# ----------------------------------------------------------------- +format { + # Time format for auto parse and bind. aah tries to parse the + # time value in the order they defined till it gets success + # otherwise returns the error. + time = [ + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02" + ] +} + +# ------------------------------------------------------------------ +# Runtime configuration +# Doc: https://docs.aahframework.org/app-config.html#section-runtime +# ------------------------------------------------------------------ +runtime { + debug { + # Choose an appropriate buffer size for collecting all goroutines stack trace + # dump based on your case. + # Default value is `2mb`. + #stack_buffer_size = "2mb" + + # Whether to collect all the Go routines details or not. + # Default value is `false`. + #all_goroutines = true + + # Whether to strip source `src` base path from file path. + # Default value is `false`. + #strip_src_base = true + } +} + +# ----------------------------------------------------------------- +# Render configuration +# Doc: https://docs.aahframework.org/app-config.html#section-render +# ----------------------------------------------------------------- +render { + # aah framework chooses the `Content-Type` value automatically based on + # configuration if `aah.Reply()` builder value is not set. It selects in + # the order of: + # - Based on URL file extension, supported `.html`, `.htm`, `.json`, `.js`, `.xml` and `.txt` + # - Request Accept Header - Most Qualified one as per RFC7321 + # - Based `render.default` value supported types are `html`, `json`, `xml` and `text` + # - Finally aah framework uses `http.DetectContentType` API + # Default value is `empty` string. + default = "html" + + # Pretty print option is helpful in `dev` environment profile. + # It is only applicable to JSON and XML. + # Default value is `false`. + #pretty = true + + # Gzip compression configuration for HTTP response. + gzip { + # By default Gzip compression is enabled in aah framework, however + # framework ensures HTTP client accepts Gzip response otherwise + # it won't use Gzip compression. + # + # Tips: If you have nginx or apache web server enabled with gzip in-front + # of aah server the set this value to `false`. + # + # Default value is `true`. + #enable = true + + # Used to control Gzip compression levels. Valid levels are + # 1 = BestSpeed to 9 = BestCompression. + # Default value is `4`. + #level = 4 + } +} +# ------------------------------------------------------------------ +# Cache configuration +# Doc: https://docs.aahframework.org/static-files.html#cache-control +# ------------------------------------------------------------------ +cache { + static { + # Default `Cache-Control` for all static files, + # if specific mime type is not defined. + default_cache_control = "public, max-age=31536000" + + # Define by mime types, if mime is not present then default is applied. + # Config is very flexible to define by mime type. + # + # Create a unique name and provide `mime` with comma separated value + # and `cache_control`. + mime_types { + css_js { + mime = "text/css, application/javascript" + cache_control = "public, max-age=604800, proxy-revalidate" + } + + images { + mime = "image/jpeg, image/png, image/gif, image/svg+xml, image/x-icon" + cache_control = "public, max-age=2628000, proxy-revalidate" + } + } + } +} + +# --------------------------------------------------------------- +# View configuration +# Doc: https://docs.aahframework.org/app-config.html#section-view +# --------------------------------------------------------------- +view { + # Choosing view engine for application. You could implement + # on your own with simple interface `view.Enginer`. + # Default value is `go`. + engine = "go" + + # Choose your own view file extension. + # Default value is chosen based on `view.engine`, + # while creating a new app using command `aah new`. + ext = ".html" + + # Choose whether you need a case sensitive view file resolve or not. + # For e.g.: "/views/pages/app/login.tmpl" == "/views/pages/App/Login.tmpl" + # Default value is `false`. + #case_sensitive = false + + # To use custom Go template delimiters for view files. + # Default value is `{{.}}`. + #delimiters = "{{.}}" + + # Framework chooses the default app layout as `master.html` if you do not + # provide one. However you may have pages or template without layout too. + # So option to disable the default layout for HTML. + # Default value is `true`. Available since v0.6 + #default_layout = false +} + +# -------------------------------------------------------------- +# Application Security +# Doc: https://docs.aahframework.org/security-config.html +# -------------------------------------------------------------- +include "./security.conf" + +# -------------------------------------------------------------- +# Environment Profiles e.g.: dev, qa, prod +# Doc: https://docs.aahframework.org/app-config.html#section-env +# -------------------------------------------------------------- +env { + # Indicates active profile name for application configuration. + # Default value is `dev`. + #active = "dev" + + # ---------------------------------- + # Environment profile configurations + # ---------------------------------- + include "./env/*.conf" +} diff --git a/testdata/webapp1/config/env/dev.conf b/testdata/webapp1/config/env/dev.conf new file mode 100644 index 00000000..86e16fa9 --- /dev/null +++ b/testdata/webapp1/config/env/dev.conf @@ -0,0 +1,45 @@ +# --------------------------------- +# Development Configuration Section +# --------------------------------- + +dev { + + # -------------------------------------------------- + # Log Configuration + # Doc: https://docs.aahframework.org/logging.html + # -------------------------------------------------- + log { + # Receiver is where is log values gets logged. aah + # supports `console` and `file` receivers. Hooks for extension. + # Default value is `console`. + receiver = "console" + + # Level indicates the logging levels like `ERROR`, `WARN`, `INFO`, `DEBUG`, + # `TRACE`, FATAL and PANIC. Config value can be in lowercase or uppercase. + # Default value is `debug`. + level = "info" + + # Format to define log entry output format. Supported formats are `text` and `json`. + # Default value is `text`. + #format = "json" + + # Pattern config defines the message flags and formatting while logging + # into receivers. Customize it as per your need, learn more about flags + # and format - https://docs.aahframework.org/log-config.html#pattern + # Default value is `%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields` + #pattern = "%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields" + + # Log colored output, applicable only to `console` receiver type. + # Default value is `true`. + #color = false + } + + # ------------------------- + # Render configuration + # ------------------------- + render { + # pretty is only applicable to JSON & XML rendering + pretty = true + } + +} diff --git a/testdata/webapp1/config/env/prod.conf b/testdata/webapp1/config/env/prod.conf new file mode 100644 index 00000000..e76eaa5a --- /dev/null +++ b/testdata/webapp1/config/env/prod.conf @@ -0,0 +1,54 @@ +# --------------------------------- +# Production Configuration Section +# --------------------------------- + +prod { + + # -------------------------------------------------- + # Log Configuration + # Doc: https://docs.aahframework.org/logging.html + # -------------------------------------------------- + log { + # Receiver is where is log values gets logged. aah + # supports `console` and `file` receivers. Hooks for extension. + # Default value is `console`. + receiver = "file" + + # Level indicates the logging levels like `ERROR`, `WARN`, `INFO`, `DEBUG`, + # `TRACE`, FATAL and PANIC. Config value can be in lowercase or uppercase. + # Default value is `debug`. + level = "warn" + + # Format to define log entry output format. Supported formats are `text` and `json`. + # Default value is `text`. + #format = "json" + + # Pattern config defines the message flags and formatting while logging + # into receivers. Customize it as per your need, learn more about flags + # and format - https://docs.aahframework.org/log-config.html#pattern + # Default value is `%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields` + #pattern = "%time:2006-01-02 15:04:05.000 %level:-5 %appname %insname %reqid %principal %message %fields" + + # File config attribute is applicable only to `file` receiver type. + # Default value is `aah-log-file.log`. + file = "webapp1.log" + + # Rotate config section is applicable only to `file` receiver type. + # Default rotation is 'daily'. + rotate { + # Policy is used to determine rotate policy. aah supports `daily`, + # `lines` and `size` policies. + # Default value is `daily`. + #policy = "daily" + + # This is applicable only to if `mode` is `size`. + # Default value is 100MB. + #size = 500 + + # This is applicable only to if `mode` is `lines`. + # Default value is unlimited. + #lines = 100000 + } + } + +} diff --git a/testdata/webapp1/config/routes.conf b/testdata/webapp1/config/routes.conf new file mode 100644 index 00000000..88963b9b --- /dev/null +++ b/testdata/webapp1/config/routes.conf @@ -0,0 +1,243 @@ +#################################################### +# webapp1 - Application Routes Configuration +# +# Complete routes configuration reference: +# https://docs.aahframework.org/routes-config.html +#################################################### + +#------------------------------------------------------------------------ +# Domain and sub-domain configuration goes into section `domains { ... }` +#------------------------------------------------------------------------ +domains { + + # Pick your choice of an `unique keyname` to define your domain section + # in the routes configuration. + # For e.g.: Domain name/ip address with port no + localhost { + name = "webapp1 routes" + + # aah supports multi-domain routes configuration out-of-the-box. + # `host` used to determine domain routes for the incoming request. + # For e.g: example.org + host = "localhost" + + # Redirect trailing slash is to enable automatic redirection if the current + # route can't be matched but a `route` for the path with (without) + # the trailing slash exists. + # Default value is `true`. + redirect_trailing_slash = true + + # aah supports out-of-the-box `405 MethodNotAllowed` status with `Allow` + # header as per `RFC7231`. Perfect for RESTful APIs. + # Default value is `true`. + #method_not_allowed = true + + # aah framework supports out-of-the-box `OPTIONS` request replies. + # User defined `OPTIONS` routes take priority over the automatic replies. + # Perfect for RESTful APIs. + # Default value is `true`. + #auto_options = true + + # Default auth is used when route does not have attribute `auth` defined. + # If you don't define attribute `auth` then framework treats that route as + # `anonymous` auth scheme. + # Default value is empty string. + #default_auth = "" + + cors { + enable = true + allow_origins = ["*"] + allow_credentials = true + } + + #---------------------------------------------------------------------------- + # Static Routes Configuration + # To serve static files, it can be directory or individual file. + # This section optional one, for e.g: RESTful APIs doesn't need this section. + # Static files are delivered via `http.ServeContent`. + # + # Supported features: + # * Serve directory + # * Serve individual file + # * Directory listing + # + # Pick your choice of `unique name` for each `directory` or `individual` file + # static route definition. It is called `route name`. + # Doc: https://docs.aahframework.org/routes-config.html#section-static + #---------------------------------------------------------------------------- + static { + # Static route name, pick a unique one + public_assets { + # URL 'path' for serving directory + # Below definition means '/static/**' + path = "/assets" + + # Relative to application base directory or an absolute path + dir = "static" + + # list directory, default is 'false' + list = true + } + + # serving single file + favicon { + path = "/favicon.ico" + + # Direct file mapping, It can be relative to application base directory + # or an absolute path. For relative path, it uses below `base_dir` config value. + file = "img/favicon.ico" + + # Default value for relative path file mapping is `public_assets.dir` + #base_dir = "assets" + } + + # Robots Configuration file. + # Know more: https://en.wikipedia.org/wiki/Robots_exclusion_standard + robots_txt { + path = "/robots.txt" + file = "robots.txt" + } + } + + #----------------------------------------------------------------------------- + # Application routes + # Doc: https://docs.aahframework.org/routes-config.html#section-routes + # Doc: https://docs.aahframework.org/routes-config.html#namespace-group-routes + #----------------------------------------------------------------------------- + routes { + + #------------------------------------------------------ + # Pick an unique name, it's called `route name`, + # used for reverse URL. + #------------------------------------------------------ + index { + # path is used to match incoming requests + # It can contain `:name` - Named parameter and + # `*name` - Catch-all parameter + path = "/" + + # HTTP method mapping, It can be multiple `HTTP` methods with comma separated + # Default value is `GET`, it can be lowercase or uppercase + #method = "GET" + + # The controller to be called for mapped URL path. + # * `controller` attribute supports with or without package prefix. For e.g.: `v1/User` or `User` + # * `controller` attribute supports both naming conventions. For e.g.: `User` or `UserController` + controller = "testSiteController" + + # The action/method name in the controller to be called for mapped URL path. + # Default values are mapped based on `HTTP` method. Refer doc for more info. + # Default action value for GET is 'Index'. + #action = "Index" + + # Auth config attribute is used to assign auth scheme for the route. + # If you do not this attribute then framework acquire value as follows. + # + # - Inherits the parent route `auth` attribute value if present. + # - Inherits the `default_auth` attribute config value if defined. + # - Otherwise it becomes not defined. + # + # When routes auth attribute is not defined; two possible actions are taken: + # - If one or more auth schemes are defined in security.auth_schemes { ... } + # and routes auth attribute is not defined then framework treats that route as 403 Forbidden. + # - Else framework treats that route as anonymous. + # + # When you want to define particular route as anonymous then define + # `auth` attribute as `anonymous`. + # Default value is empty string. + auth = "anonymous" + + # Max request body size for this route. If its happen to be `MultipartForm` + # then this value ignored since `request.multipart_size` config from `aah.conf` + # is applied. + # + # If this value is not provided then global `request.max_body_size` config + # from `aah.conf` is applied. So use it for specific cases. + # No default value, global value is applied. + #max_body_size = "5mb" + + # Optionally you can disable Anti-CSRF check for particular route. + # There are cases you might need this option. In-general don't disable the check. + # Default value is `true`. + #anti_csrf_check = false + } + + text_get { + path = "/get-text.html" + controller = "testSiteController" + action = "Text" + } + + test_redirect { + path = "/test-redirect.html" + controller = "testSiteController" + action = "Redirect" + } + + form_submit { + path = "/form-submit" + controller = "testSiteController" + method = "post" + action = "FormSubmit" + } + + create_record { + path = "/create-record" + controller = "testSiteController" + method = "post" + action = "CreateRecord" + } + + get_xml { + path = "/get-xml" + controller = "testSiteController" + action = "XML" + } + + get_jsonp { + path = "/get-jsonp" + controller = "testSiteController" + action = "JSONP" + } + + trigger_panic { + path = "/trigger-panic" + controller = "testSiteController" + action = "TriggerPanic" + } + + binary_bytes { + path = "/binary-bytes" + controller = "testSiteController" + action = "BinaryBytes" + } + + send_file { + path = "/send-file" + controller = "testSiteController" + action = "SendFile" + } + + hey_cookies { + path = "/hey-cookies" + controller = "testSiteController" + action = "Cookies" + } + + version_home { + path = "/doc/:version" + controller = "Doc" + action = "VersionHome" + } + + show_doc { + path = "/doc/:version/*content" + controller = "Doc" + action = "ShowDoc" + } + + } # end - routes + + } # end - localhost + +} # end - domains diff --git a/testdata/webapp1/config/security.conf b/testdata/webapp1/config/security.conf new file mode 100644 index 00000000..e09e6c9e --- /dev/null +++ b/testdata/webapp1/config/security.conf @@ -0,0 +1,244 @@ +###################################################### +# webapp1 - Application Security Configuration +# +# Complete routes configuration reference: +# https://docs.aahframework.org/security-config.html +###################################################### + +security { + # ------------------------------------------------------- + # Authentication & Authorization configuration + # Doc: https://docs.aahframework.org/security-design.html + # ------------------------------------------------------- + auth_schemes { + + } + + # ------------------------------------------------------------ + # Password Encoders Configuration + # aah supports `bcrypt`, `scrypt`, `pbkdf2` password algorithm + # Doc: https://docs.aahframework.org/password-encoders.html + # ------------------------------------------------------------ + password_encoder { + + + } + + session { + mode = "stateful" + } + + # ------------------------------------------------------------ + # Anti-CSRF Protection + # Doc: https://docs.aahframework.org/anti-csrf-protection.html + # ------------------------------------------------------------ + anti_csrf { + # Enabling Anti-CSRF Protection. + # Default value is `true`. + #enable = true + + # Anti-CSRF secret length + # Default value is `32`. + #secret_length = 32 + + # HTTP Header name for cipher token + # Default value is `X-Anti-CSRF-Token`. + #header_name = "X-Anti-CSRF-Token" + + # Form field name for cipher token + # Default value is `anti_csrf_token`. + #form_field_name = "anti_csrf_token" + + #Anti-CSRF secure cookie prefix + # Default value is `aah`. Cookie name would be `aah_anti_csrf`. + #prefix = "aah" + + # Default value is `empty` string. + #domain = "" + + # Default value is `/`. + #path = "/" + + # Time-to-live for Anti-CSRF secret. Valid time units are "m = minutes", + # "h = hours" and 0. + # Default value is `24h`. + #ttl = "24h" + + # Anti-CSRF cookie value signing using `HMAC`. For server farm this + # should be same in all instance. For HMAC sign & verify it recommend to use + # key size is `32` or `64` bytes. + # Default value is `64` bytes (`aah new` generates strong one). + sign_key = "6440c2ed05652cd452a6ee5125f4135e665348a82be1784c06e414d79a9e27c1" + + # Anti-CSRF cookie value encryption and decryption using `AES`. For server + # farm this should be same in all instance. AES algorithm is used, valid + # lengths are `16`, `24`, or `32` bytes to select `AES-128`, `AES-192`, or `AES-256`. + # Default value is `32` bytes (`aah new` generates strong one). + enc_key = "9547aab75a1f57dcfaf38c68dfbbc80f" + } + + # --------------------------------------------------------------------------- + # HTTP Secure Header(s) + # Application security headers with many safe defaults. + # Doc: https://docs.aahframework.org/security-config.html#section-http-header + # + # Tip: Quick way to verify secure headers - https://securityheaders.io + # --------------------------------------------------------------------------- + http_header { + # Enabling HTTP secure headers. + # Default value is `true`. + enable = true + + # X-XSS-Protection + # Designed to enable the cross-site scripting (XSS) filter built into modern + # web browsers. This is usually enabled by default, but using this header + # will enforce it. + # + # Learn more: + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#xxxsp + # https://www.keycdn.com/blog/x-xss-protection/ + # + # Encouraged to make use of header `Content-Security-Policy` with enhanced + # policy to reduce XSS risk along with header `X-XSS-Protection`. + # Default values is `1; mode=block`. + #xxssp = "1; mode=block" + + # X-Content-Type-Options + # Prevent Content Sniffing or MIME sniffing. + # + # Learn more: + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#xcto + # https://en.wikipedia.org/wiki/Content_sniffing + # Default value is `nosniff`. + #xcto = "nosniff" + + # X-Frame-Options + # Prevents Clickjacking. + # + # Learn more: + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#xfo + # https://www.keycdn.com/blog/x-frame-options/ + # Default value is `SAMEORIGIN`. + #xfo = "SAMEORIGIN" + + # Referrer-Policy + # This header governs which referrer information, sent in the Referer header, should + # be included with requests made. + # Referrer Policy has been a W3C Candidate Recommendation since 26 January 2017. + # + # Learn more: + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + # https://scotthelme.co.uk/a-new-security-header-referrer-policy/ + # https://www.w3.org/TR/referrer-policy/ + # Default value is `no-referrer-when-downgrade`. + #rp = "no-referrer-when-downgrade" + + # Strict-Transport-Security (STS, aka HSTS) + # STS header that lets a web site tell browsers that it should only be communicated + # with using HTTPS, instead of using HTTP. + # + # Learn more: + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#hsts + # https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security + # + # Note: Framework checks that application uses SSL on startup then applies + # this header. Otherwise it does not apply. + sts { + # The time, in seconds, that the browser should remember that this site + # is only to be accessed using HTTPS. Valid time units are + # "s -> seconds", "m -> minutes", "h - hours". + # Default value is `30 days` in hours. + #max_age = "720h" + + # If enabled the STS rule applies to all of the site's subdomains as well. + # Default value is `false`. + #include_subdomains = true + + # Before enabling preload option, please read about pros and cons from above links. + # Default value is `false`. + #preload = false + } + + # Content-Security-Policy (CSP) + # Provides a rich set of policy directives that enable fairly granular control + # over the resources that a page is allowed. Prevents XSS risks. + # + # Learn more: + # https://content-security-policy.com/ + # https://developers.google.com/web/fundamentals/security/csp/ + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#csp + # + # Read above references and define your policy. + # + # Note: It is highly recommended to verify your policy directives in report + # only mode before enabling this header. Since its highly controls how your + # page is rendered. + # + # No default values, you have to provide it. + csp { + # Set of directives to govern the resources load on a page. + #directives = "" + + # By default, violation reports aren't sent. To enable violation reporting, + # you need to specify the report-uri policy directive. + report_uri = "" + + # Puts your `Content-Security-Policy` in report only mode, so that you can verify + # and then set `csp_report_only` value to false. + # Don't forget to set the `report-uri` for validation. + report_only = true + } + + # Public-Key-Pins PKP (aka HPKP) + # This header prevents the Man-in-the-Middle Attack (MITM) with forged certificates. + # + # Learn more: + # https://scotthelme.co.uk/hpkp-http-public-key-pinning/ + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Public_Key_Pinning + # Read above references and define your keys. + # + # Note: + # - HPKP has the potential to lock out users for a long time if used incorrectly! + # The use of backup certificates and/or pinning the CA certificate is recommended. + # - It is highly recommended to verify your policy directives in report only mode + # before enabling this header + # - It is highly recommended to verify your PKP in report only mode before enabling this header. + # No default values, you have to provide it. + pkp { + # The Base64 encoded Subject Public Key Information (SPKI) fingerprint. + # These values gets added as `pin-sha256=; ...`. + #keys = [ + #"X3pGTSOuJeEVw989IJ/cEtXUEmy52zs1TZQrU06KUKg=", + #"MHJYVThihUrJcxW6wcqyOISTXIsInsdj3xK8QrZbHec=" + #] + + # The time that the browser should remember that this site is only to be + # accessed using one of the defined keys. + # Valid time units are "s -> seconds", "m -> minutes", "h - hours". + max_age = "720h" + + # If enabled the PKP keys applies to all of the site's subdomains as well. + # Default value is `false`. + include_subdomains = false + + # By default, Pin validation failure reports aren't sent. To enable Pin validation + # failure reporting, you need to specify the report-uri. + report_uri = "" + + # Puts your `Public-Key-Pins` in report only mode, so that you can verify + # and then set `pkp_report_only` value to false. + # Don't forget to set the `report-uri` for validation. + report_only = true + } + + # X-Permitted-Cross-Domain-Policies + # Restrict Adobe Flash Player's or PDF documents access via crossdomain.xml, + # and this header. + # + # Learn more: + # https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#xpcdp + # https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html + # Default value is `master-only`. + #xpcdp = "master-only" + } +} diff --git a/testdata/webapp1/i18n/messages.en b/testdata/webapp1/i18n/messages.en new file mode 100644 index 00000000..074d09c1 --- /dev/null +++ b/testdata/webapp1/i18n/messages.en @@ -0,0 +1,27 @@ +############################################# +# i18n messages for webapp1 application +# +# Complete configuration reference: +# https://docs.aahframework.org/i18n.html +############################################# + +# This structure is example purpose +# So choose your suitable structure for your application +label { + pages { + app { + index { + title = "aah framework application - Home" + } + } + } +} + + +test { + text { + msg { + render = "This is text render response" + } + } +} diff --git a/testdata/i18n/messages.en-US b/testdata/webapp1/i18n/messages.en-US similarity index 100% rename from testdata/i18n/messages.en-US rename to testdata/webapp1/i18n/messages.en-US diff --git a/testdata/webapp1/static/css/aah.css b/testdata/webapp1/static/css/aah.css new file mode 100644 index 00000000..fd463e97 --- /dev/null +++ b/testdata/webapp1/static/css/aah.css @@ -0,0 +1,47 @@ +/* +Minimal aah framework application template CSS. +Based on your need choose your CSS framework. +*/ + +html { + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +body { + margin: 0; +} + +.container { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +@media (min-width: 992px) { + .container { + width: 970px; + } +} +@media (min-width: 1200px) { + .container { + width: 1170px; + } +} + +.row { + margin-right: -15px; + margin-left: -15px; +} + +.text-center { + text-align: center; +} + +.welcome-msg { + padding-top: 30px; + padding-bottom: 30px; + margin-bottom: 30px; + color: inherit; +} diff --git a/testdata/webapp1/static/img/aah-framework-logo.png b/testdata/webapp1/static/img/aah-framework-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4287e4adbeb33dc33b1ad4f03432cbc71c654a75 GIT binary patch literal 6990 zcmV-U8?oexP)s0)sdM0wOSA5W+w>%#96(7$IXU zFkpi~mJBkd(S4y4A)y1ENGpNvMF$c>_p!U3_xoyIb-Vl9?V0^{W_Ffus-9+6GxL4j z@BRB-{a(+X)WYNV^Twpnp9=k%&}Vqv%x8G-AfZk|Ul%$<=u)A+Lf56x#X_eGognnD zLVqK4Kr`1qfa}}$L&_iJ5_&q*|F$5w%Z2(24HKF#v|ebZP@|9$YU0@^?>#=dU1*)q zY@t_$9u&Gr=xCw8OalaCV~jZv+FJ1GLT7b$F9>Fm&_`B?P#{#E8w=EsO8UNjC%leQ zz0hc(I}4iS^Zt0DTq|S~`~fLUJVdCs&?`b4Qv{Ci_B+&|0&NW`SiEaCrQCP5(DOp) zpo!cu=USl^g7;SYfkHhCYIs|qs^fw-87aJ`3$)O*Y!VtM^c|r;&w=% z5#ONZZ9UHL7R`;uMz6?hnv`Ujdq6?7|yQ<&!TbtyDWm^{7PG97pB=;Zmt^Mx+p*}gvE ze0@JN2-NXjMz+xN(c0YZQ|3H9_xOt!KF>$?7-70Nbj0Z+%+Y6!()ZgkG8h!m0-06+ z{khXu8VFeJSKK+n{NUbq%vJZyG*{g{(_H)GS>~)hQ_L}ETM)FNKX6I9KagGZAEz5T zx!o_0j_End{MY|XFrRGSXLvLSRkQ;@x+ zYg(@QO9#MrE*o#Qe6rW@XlQ6M`}Q{)9?!q_K?DGqOu@h`rXWrA_sLa%YXLC9Y~7Xs zz%;4`XnO!aO`ay`?t!3W;j8-ig3x;P`g7s89so_{1%QG;HT29#mayp6-!EVNH3ERp zA-uwqWy!0*6ES~Y)KMb<2pvLl1Apylfs#D`r*-w`!f#suJojq(0N`oto6@yFeDxnD zv@>7*j7XV#m<*QZmzsaIr^+)%MO5N zUQPvo9n*Q)$KsiRp&5EU%LdkFR}ir1|QZ2_?H=|8~x3CduS^ zt^^qD*qr+w+jBJI(Xla3gvRIi4D-&)8ygq)7sax?$vJOF!nm(XqMjuUxmo{OmW20VgXq z!u&V@*b&qy;ND*@aNmQP8H-7t-z20??V9)sI*!shj5@m}zg6mAR~F9*|F(Yb znhjR)tSFGKD=Q%WA4c#Qcj6OgZZM~Of1)`;t_h*ZG$W0KFb7oa^&wDDcDQS0jMYu{fW7>cwSf0<=;%j<^}< zyx@HT&VX@OeFT_C~`48}Cj;1+0I&59iN-0hZBC=H3AdSZ%&Jh?ig$R{kT; zzQ<)<0ouQXtUyAQoBlm(zsF34`J=d>s^L}vz+T(sL-@>i?ccVjPrOiPe)7aZbKm0&%r6ElO`O*i zYq$ULcv6@@Y~osqJw4_}0gwtbeZ73uHuL*YtIU8Qb>>$uEHlI99vftwY2#qg@nGx$ zkK!OWNzH(3|6lY0kj~)YpNG>bP95VJ#<+BxJd?zt zcoPlhfHGwMW4~P*Fh33e`>bOEaMwRDTQut^#9;m2`5FJF$mRcUngb+hO>eQxs)M# zO|^9rl5NG_Ml}GRA*u4gn(cfou&q2lEuj}e(HsTi@W-dZWUc@NKVsXCeZe&~r0aF_ zTg+7f8UsKed>mB9&6t#5E-}tSPkMte5MjFja&rnE5Tky;D2)UZ0EfjYez-7$S+sns zWrp+2N+x;HiGh&aDk5{m=kR0?lL_{F#@cLef`nn-1Vhb))Ayu!$L{xBNA8YC=b1yh zzoAUlbTyRfNGdoEKY5rWil!2i>-ZF)DnLR_fLyc>px0O>A?APlcM_@sf`DK8n$Iu} zO3UqatcI)KxC@Pj#x2$atQI=ZjRJaj@qgf0p^onO`8-!$L+ZJcMMJeSkz&I)b)oQ@=nbiaiqv|)v1TRXTAm1l_+Fde^?h0|${zJcBjPOGy$8!lVAMmjPNff0ClBk6o zfhLNI2Ea`Z&EeY0vlhp~XAT#0_1gF~Dg*ag(rZ8-g)tO;2vuhn0JnJdKVUK;$DBo* z?J9L{e!BpJ!7<2I#_imLgpGd>U1=H>07kwO_$4@+npkp$KlZH;0WaSxR`?MEW#XCwDXQ?)oiWchp(LYkLE5Gqz}bhbSOq4!sB5AL%| zQnzRPU=pFBjxU-C96o8C0<-9Gp#i@ks(zzJ2F~?f8@EQUSF9s=vkQPpo(b?*7S z_1q|G3}-1mkvJ*XW-Z+8JB92i_}(s`N4S_DQO^wllSc@Xi_ay*Lrnaehb!V0>;(+^!wR{cCcBEwb3mhsWC|d(_aFDxhXpwl9SJt?dAu|BT`;&B0hD8Cu-;WQe3w(g>JNKEBukdr^+38qM05uGNz3c!3fb&`c z02k!Kqo>vfs9&pgwk=lKxweRdT|>QB3jok4fL+7X4C1x-x!JMo<_U-L^s8L|q~-ze z)ldVZhi2?ZrR4$0Pb-tN#@bv16-#uX0&Nihng{6FcM3anK_1|x(W^Ap&OA2d1Cfa3 zL&wknApkaN>ukG#un zBZ4e>H;}QBO=M4D+ji&rkTEr{0it1`lGeq$n<4J=VyP4O)+OT^1WdCTF(%i1PDM2DCBS}cX-4B5HK46qK$k(X)T}ZF)Y?56x;sg zjgSA+O)2h0zXH2&DZ=Mb^NFBnDp<)4yD0SJp`N!}#|NV+Y-H1fw5is%!E{thFjU7#rhae7t772>*O(V;eqkor2N_RLzEg zVJbMj8Kg{BQ@|jo14u0lg;ed*$wGxItzxuUQTRx8FsWNIi&i*3Q%i~h0#oql^Gj@I z&;x0m$B*^cuAcfN(P?+zyU+1W|&8oo6+r z_XYT>yoK8PkhvbyV*m)#wX9^ys#Oy}M#ERVc4TZi4-edWjgA0-5iJyi#A9}1YhI6> zyiNz>)CyQeF=1u8e#UDX(=BF=-6`*)zOMuTgbo~D#X-ZCO01LfTg-6n*dgt<3;nyz)lp zl4&QEdvGrv+*=D(>BaDhU!o;p@uJXpMK9U_Ml(@XD3eWfmcqX+kzk_1l!yR85q7a1 z96wko-W^PKk?Y?1=sZ@AcYnVdIQ*Lk0Kb41Uu+<%CfQd8KmudofM9RWT>cj=@Oo&iS>f`1mt7PTc?R2yRT{ZOJ)AvrJ zCDlKqSh3rhuKh-@akARqr_cjc$>);DqVEH<5U75x=oB-rxjtj0<7ddQm4FV_oUC*= zlG!22Br1M}y}1@pQu4;UXk@|fNy6KE#Bm%tVikS+3H@&PO3W4xa{?iz=g3A9+s~KE z_-FuO??7`D5c>>%moghxhZn{L@tW1Asb;4TMX$wtl=Nhj)^t8|4?2j?h| zD=hr*fQaZ3F%>4=-lf%w(yO2W;|~+oM!J2fPg*@pdRe0jHw1iY=yIJydB+kzA|`%F zR5eJ8Lngev-tZvVp&L&hONOv}?>8|Kx`L`kk)2bHlF{)ad_6CT^Ad45%df@aheXAX ziK`feyN+zmr1s&l!IUIXwD_d`2w%67F;##z*od07V6zvmXQr&j#KjMatOn_cA8S6` z>DT(PPrwPJeyv~CNwbfDcjV;yH1Lv<^t*yHQuc!)+nE_i@B3H#>9#|6_9}j>F8m7< z;ZuE-Ry#>GRpkL2KPonUSag*=Y09zE7|Q9Ndf;-EKoIVbvS+YvgKDkR7V^r0Hhx%i zhU4NZs7^W6%T=dt*>}(DrjXvPygv}0nu_h$o(2`Vwfze>6TVh%Z1LmbYvTtav?F&s z5rH)Yss->7XeQ+|stGE+@_8m|;owGB_zG6GjoFrFBP)^xto{#1RR1Sq!qtUp#p&Z9 zY8Ad;j7mc(+QK~usv;VSaFl(7SI4e#`x7Del(X2@Z$*AGrY3(hN*T=x`~F=sY)^uB zp5VqUyE$Ko!<_hD)Wc>K zQEOn!v7CB6E&cwbq*}4H+OG&@*9d&q3LcHbSgyQlrddsx%6L+QKV#k|s*iDIWBV7C z{@J+N`{78p9sI$k7OCJNzrd9KJ$|#F6$%h^S1x@gGQL}=Tf>|Y#Qiy}ik7p~B2g9o z94T+(e1c;tovVodaAb}BbZl5_fDow9E2!K*cDmfxsOGEgnOUe-8jcgjYIOJW5v)gc z-(Js0;wN1I(wWyxrk0Nr%bcRn;52a}0rg!nIDKXN>DU_l@#t`mT~#o}n*Qk^LFqTO zfTWKvmUDek;RsW=nvWojx zVA=QSjp#0ZLF7o8W@Ywsv?;KUs@f+w1KMsI+L~xwYK1Evf01z)t$1FTHXeqxPpg;&a zg3Aj@z~H6i?QtGC0vBhG<5y`GVbg{a(P;QU;Peb6%^lj>o`iN`IlEG| zRsWzORDWhu(8b9OG*%1*+4}&r^-M!RoJanTW)|`T2$vl_KBJYq^bF7(Vh@k-ei*wQ z2*1IJ`45Vy{x&lOz1<99djvpw*Ra=A;q?IopNVA;t1o=zJ-%7D$m(x%5DZ`t+&rQM z+9@EQC}`Rzg?7Jh#O}8_2(+POOqx@eQ+b8&ExaenE&LDw*2Wwxv?K>X)r3DXlI<%i z2-MabDpaoyNe+UFdHl1f;?@P34FK8wf#Zd?Jw*JhQ&`#n3f&Nx(NOp`3xWfjNp*9yLF1>!EQEqr@J}FeN>%xB;n%E5z_YzoXq!5T9Tf=P zoc4S5d%fUC29;m)+TcrtD%O1+Od~Y4u;Tx}o%820Ac~@RB%?t=qtMD0TZlxhl&D0r zy4^&f_6@Y6)%XAkmC$IEKEO7c_yC$M&@EeyKc_hI#CZ{$k*q(j_!UgZxaYik?=yF1 z1zI2>@vw{kfJV@CmjQhS>NIFu!#xO3G^?#a=cvgG1z7cumkmf3^uRCJX&?q2Aq|>frejc=8E!;$|Kxn8ygN53zw^BC&Im`Zqu>n;O~WX))fYpM zD+;ZG+qba`_A~_$b}K%J5wt$0IFUR6^E1sB5o7$7jVV7wCvp$Z-|vFTp|Nwq2Q-2N zQPXA|6xmHrz*2lz^>W-uf;kW%BnjU({I zTie%?ss$-6KisE(f+3Ce4ssx7%UEu;|Bo3w6Li7jb@W%R)H0}Yj=pZ;1C(f_7Z4Y- g5x(!q@jDZK53;LH!%^9DNB{r;07*qoM6N<$f-Wj%NdN!< literal 0 HcmV?d00001 diff --git a/testdata/webapp1/static/img/favicon.ico b/testdata/webapp1/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8cc419e5a6bd02e7aba5e0acfc64290c0a31c785 GIT binary patch literal 15086 zcmdU03vgD&6~2m{c3MZMjx&8sh3T}@PN(B&%S%wf*QjH~DhP-mf+B*74^$KoqA;K$ zf(SkU6%-Mwh=M@g`4h;KguEdMB>8y+2;`kW-r0WNxp)8E|C#)M(xLfhJ~sE>-LvPM zJ-d7M?7=WDGx`}f++d&_WNiDhVGK14W6&V$d4OS*;N1X}{QmS6hOrD4{)#$iVmuD; zz5M=U7QOjzgu0oMYz0&{>*fe0W2$OrO(bYLIwG4Kp961WDqtT%ZHh8Ft13b+ec z4UoSopbb!6fORT?RA2=#8n`l;zI##^ef=7k3d8{yfUfxM>U%wCL;@3mt9rs)pthj> zGvGdee768y@fGa%CeWna-3ee8^xhBjehbh}YJp(+3bsD&a5->oZ%6<-Hv(}$2M{bT zq1JClU!3oM>xm3#&$)FN2$heXw#!7{!$Oh4;{E6)oNKTJi88=@jnMoqd|#XL4zMNb)}q8=HcA|4(JnBPL6zYA@y4kQ4+ zUje)q0&U@!Jjc(vUuCaeppNg^rp{#?P}N7WR8@Y4I-4A=j{Rr7N`LDa6*KjAQ;y)q zmveeCaJfGj@a-O;8VJf>s8`2~dsWfak5t1cr|M{LSNyfNwy4&N4XUm2qU&8-bCasf zJ*cu)zNVrcz1ftz`~8)Jw_AMikN5on?t?+|p7Q!MRdx7~QXL(t^+JOxPm5QFH@>S5 zzBx-JzdA*wyf#%GdV8)a{&u6PEj|oc+EiOpqdF1(t%`f{ZWS?pXfS>51dqS;lmYK1 z0nNeiPrauud{Q-(IYr;pEA1ot(OYB=(AG_xhuvdN@Ne>aVWRkn0S+N?%E>#S7fC?ulERF-K9Y})0aG_nro{tM=q$0 zWiOyT)~4?9P=R`bZT!E}&2~bc`b(Vqh-#`jr&=58)xof*%{8-oecN~(rb>R;EIKGp zjZ@K+Z?%2vQkM(JKlr{HND7vJ>Wg#Mx%+Fe_h8!Vx}t}dCaUv?5@mgjfBIj+%!4S< z`4uo6IIn5iOVZc#&i=R5f&FlQ;=W{WqyM(o*MD<=;CG>E?Qwh-I)3fn+z)-stCMK^ zci<_Ym2_ON`Z@5zc$NR@GF7_kE9iNPtcgi4PXf;)Wo^t^vj}te-)=hNyyjd?nLk|> zZeFdNKW$YdJ2$J`4;QP1*<(};_TtPHud1x~Ustiy?vS>rZ_H46A1_g)tz{Hmz(?$~ zJ5|o=g(~~~`Plbvvwv?yoe2Q_dN+V zen6d!`d+m()Y+S1Yv_>mrg+C^m^bHT-X_9!IA>4A{3vycwybs0B2T2pIxTh8s@Gzyd75>S|y+ z%fs4EGqtxjo z{`Z1+`le!6v5((@H6>H%)0dHb9eUOB(KgA?;SKMIF17x{ANq&bE^Ukc&Y|U4%f}7w zY-iEuyf_v8LvQjQtNEwy>8sFgxwr3s?jf;17w>wGKnBOm`{1XQ3Lmte@CR>HdF$Si z=ZdUkzw0>X3HJ*6KFzh&yAQ5awZbRJ6Cc-YEzta<U&=;d_s9XBOKKY0Kx$e~-%g0=7hhO=U zqWv8CY=!WDCNWY)VsG#oH^}Wa#uXT2t|u7x&h_WRVAmhcUvvEgU-6b*w70g3f9_?& z0&3~p0JQE$>@V1=6>rWh$J?VUAk}CM( zeVHe$V;f7?-{kASoN;niugc4SEG4SWS%kef3g`NTu=(5Ec(?Ok7Wb1nzIVHD^ZBIt-5MR9T%MZHZ-*5cN;y?8GVPKsFl!<;&#?t4!&T<}l z=`wz3$@m#|Lu1{o`CUN^3NaY_1XBjDS^l^8glf>`wI^l`sDqQO@=;u ze`k}S&)%QfWazW^*IEXanA5)orUG$5pUEGv%V5cI75Kjk;Q2Eh;Jx|(eg1|IIWB4b zj8ZlIl`;$$+6-e9pKA=GKTC&U^kW%@JR+790RMEbTx=Mqjs}L&#B!ivpjl_x-!Pnf z#!ZPX9enO*7`hCzo~1-KkY~xyJ^6*Pu@AcP-o}T6_3-RuY2#PqfgD;cEvJ@S>j5ee zy}0z`u=J+&sP&49rmjuB^HO^dFdA^>X$`{RQ9}?u%AW-qB|MsQd9%;`?<#Y`6Nx z*;)J7{?7kC=+H)g=cjMpnSJS_jhoCmzT$Z5Ud8))#-mafJR$Kl#z*z{0Cd*l+b=wM zz`Geh!0~46Bk{%Ya(BhM2;SW=?#Gfbxsy>lRr0*Yx{66$L_bsP{o{EcKwEW_)$JMj z&V2V}b>Z}J#QF*q&y;D1;qktf^7HPVcRA!Icio$y<2zPt(d6BRYaH-5=c?bnsZ+*r zd4F&!c9$9FlGvxtBQRsAjEy<>e50CcDpl5d^8$?p=jLqc0Od76qTl`@H{;^WVc5iW<2~#v`PIo1w`AUjTO7hx&uowS z&+uf)Wi`IgHFI5p6gDSbU>3axC76R zQ=AKYX53uvLNO1RFC_UJx^I+>zcHR|_1A&&HDI?qgC^s8v?IpI885zg_LRiJk7G>F zBt}Vl>Vq*u#;@yn}@Lt|_kFg!!b6AIQ zMAqj$72`g%;iKOmZ^+7Fq0Mvb87JlVHrG@MEykaDkF4(w%hMcU1B|PmPS|Jbf2720 z&nCy%`%gjNrId+rXWA|Ai)0;Pj2~c*59btPwbWhqss)O7sB%AycKR-W@#(h43#$BJ zd@%iU-!7%i6l`84cNrXKyM5Chs7uE6DL?PAsu3sGcO=wZy(gupQt`l41_%kjqv1ZVr zzNojdxZN(EpgY>%63$6}=N&>L?$3DV#&w*zB)s?F_@^$I4!(xS+Q)ks#{QY-kUVdS z_pl{w$kRRqe@Sdfmp;mJEtk}1nf3p6V_w@He&fStjzoWJPLgq^9X3n78t|DSwn}|OBY%#vw_d0hU6Bs&pqVFV%f->_Pv`mzJuvr< zGG(rK1?$&rSwl$Ev@>&$;4}5beJ2Y!8@x+0a}UjW?4SClKB-6EWt#aRBV2NTo~*yt z9^khBkUn)C*stK)Z0$u6_+JCJ62ZEnmyNo^gTla6be-C(}A zm;Q$b4Yx6|f1}Mazvk$+Pkhz!@;|KpyXAk``w8?n?Tl-Zk2*g6FN=rkP(Rj38((xn zdFR*vWdHo3?@Q)?n;Zxa1Hs27Ie*xAxMb%q%>zs5;gXy`?L1g~{7=r`5~gwVlh6S} z35Y`sWN5*OXa(UwxPb^m7=bWMpz!&VI$@~eu-Z8-I;2V3u5bnWM5uys3Wg;H0j@Yj z7yru!W5WBW5x_biAE>s#|C6v57!Dxn;{C&W#wp(f&H(=A@M(No1Rz4w`NQ)7U=zUg zTiVKAfjyDW9{T`eh8qE)f%0-73~;rjpGQA}_q$vxnHRxa7w)mBN07!sfU&%rfHFXT zliUa9xuz|8O7=JU1I$%pE{Wc+QU3%m1Xx2q&D@U$i=TCL+oy82VV)0t8RjcxES;nB zKUr$Fr=L$u7`_r^4$n5(tL6*8c^@y8ez_0lB8QUaLH3_Af0~>VnfsZ9v!R*GG&BSL zMJ01KkA1sA`Z#wmLFg19cZ4~S;_GCmDBiU?cW;%P6#C$#88LQnIo9$@`tHmv+B@cY z;e);n_Zj9m?|a~S$qAL*PAm78zPWBs{+T0D_~mNxQ<%@dv-?rx(=l(3{uO;&%FqAP zK%as6rRlhTgdYh0hpwUjOaG5L=UJHJ$1_mDrd4uwU|pVhm~%|N@;+K(`a9!?t%MG5 z0uN={m(eBltH+yT!?O*~Fgz=nb%yEj^R^$GEr7n*1^2zQ#WTeR!#kJ#(2DWwWxu+e uy|j2^+l_$d90+;-=$QnXLC@bN#DJaP#DQ5IMsOwa^W!(xVO_Ri+y4S%cH$iX literal 0 HcmV?d00001 diff --git a/testdata/webapp1/static/js/aah.js b/testdata/webapp1/static/js/aah.js new file mode 100644 index 00000000..e69de29b diff --git a/testdata/webapp1/static/robots.txt b/testdata/webapp1/static/robots.txt new file mode 100644 index 00000000..9d0006bb --- /dev/null +++ b/testdata/webapp1/static/robots.txt @@ -0,0 +1,3 @@ +# Prevents all robots visiting your site. +User-agent: * +Disallow: / diff --git a/testdata/webapp1/views/common/error_footer.html b/testdata/webapp1/views/common/error_footer.html new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/testdata/webapp1/views/common/error_footer.html @@ -0,0 +1 @@ + diff --git a/testdata/webapp1/views/common/error_header.html b/testdata/webapp1/views/common/error_header.html new file mode 100644 index 00000000..ae050584 --- /dev/null +++ b/testdata/webapp1/views/common/error_header.html @@ -0,0 +1,34 @@ + + + + + {{ .Error.Code }} {{ .Error.Message }} + + + + diff --git a/testdata/webapp1/views/common/footer_scripts.html b/testdata/webapp1/views/common/footer_scripts.html new file mode 100644 index 00000000..ce1fb71f --- /dev/null +++ b/testdata/webapp1/views/common/footer_scripts.html @@ -0,0 +1 @@ + diff --git a/testdata/webapp1/views/common/head_tags.html b/testdata/webapp1/views/common/head_tags.html new file mode 100644 index 00000000..db191547 --- /dev/null +++ b/testdata/webapp1/views/common/head_tags.html @@ -0,0 +1,2 @@ + + diff --git a/testdata/webapp1/views/errors/404.html b/testdata/webapp1/views/errors/404.html new file mode 100644 index 00000000..05174a76 --- /dev/null +++ b/testdata/webapp1/views/errors/404.html @@ -0,0 +1,14 @@ + + + {{ import "error_header.html" . -}} + +
{{ with .Error }} +
+
+ {{ .Code }} {{ .Message }} +
+
{{ end }} +
+ {{ import "error_footer.html" . -}} + + diff --git a/testdata/webapp1/views/errors/500.html b/testdata/webapp1/views/errors/500.html new file mode 100644 index 00000000..6bd0250e --- /dev/null +++ b/testdata/webapp1/views/errors/500.html @@ -0,0 +1,14 @@ + + +{{ import "error_header.html" . -}} + +
{{ with .Error }} +
+
+ {{ .Code }} {{ .Message }} +
+
{{ end }} +
+ {{ import "error_footer.html" . -}} + + diff --git a/testdata/views/layouts/master.html b/testdata/webapp1/views/layouts/master.html similarity index 52% rename from testdata/views/layouts/master.html rename to testdata/webapp1/views/layouts/master.html index 4bf9dc66..99359875 100644 --- a/testdata/views/layouts/master.html +++ b/testdata/webapp1/views/layouts/master.html @@ -3,13 +3,12 @@ - - {{ template "title" . }} - {{ import "head_tags.html" . -}} + + {{ template "title" . }} + {{ import "head_tags.html" . }} - {{ template "body" . }} - {{ import "footer_scripts.html" . -}} - {{ import "not_found.html" . }} + {{ template "body" . -}} + {{ import "footer_scripts.html" . }} diff --git a/testdata/views/pages/app/index.html b/testdata/webapp1/views/pages/app/index.html similarity index 100% rename from testdata/views/pages/app/index.html rename to testdata/webapp1/views/pages/app/index.html diff --git a/testdata/webapp1/views/pages/testsite/index.html b/testdata/webapp1/views/pages/testsite/index.html new file mode 100644 index 00000000..3d8e861b --- /dev/null +++ b/testdata/webapp1/views/pages/testsite/index.html @@ -0,0 +1,17 @@ +{{ define "title" -}} +{{ i18n . "label.pages.app.index.title" }} +{{- end }} + +{{ define "body" -}} +
+
+ aah framework logo +

{{ .Message }} Yes it works!!!

+

{{ config "desc" }}

+

{{ config "key.not.exists" }}

+

aah aims to provide necessary components to build modern Web and API application with secure, high performance, scalable yet lightweight, flexible. aah takes care of infrastructure, boilerplate code, repetitive activities, reusable components, etc. aah is not a micro web framework.

+

aah aims to provide necessary components to build modern Web and API application with secure, high performance, scalable yet lightweight, flexible. aah takes care of infrastructure, boilerplate code, repetitive activities, reusable components, etc. aah is not a micro web framework.

+

{{ i18n . "test.text.msg.render" "welcome to aah :) " }}

+
+
+{{- end }} diff --git a/util.go b/util.go index 9268f7f9..2920c23f 100644 --- a/util.go +++ b/util.go @@ -5,29 +5,21 @@ package aah import ( - "errors" - "fmt" - "io/ioutil" + "html/template" + "io" + "mime" "net" "net/http" - "os" "path" "path/filepath" "reflect" - "strconv" + "sort" "strings" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" - "aahframework.org/log.v0" ) -func getWorkingDir() string { - wd, _ := os.Getwd() - return wd -} - func isValidTimeUnit(str string, units ...string) bool { for _, v := range units { if strings.HasSuffix(str, v) { @@ -37,75 +29,28 @@ func isValidTimeUnit(str string, units ...string) bool { return false } -func checkSSLConfigValues(isSSLEnabled, isLetsEncrypt bool, sslCert, sslKey string) error { - if isSSLEnabled { - if !isLetsEncrypt && (ess.IsStrEmpty(sslCert) || ess.IsStrEmpty(sslKey)) { - return errors.New("SSL config is incomplete; either enable 'server.ssl.lets_encrypt.enable' or provide 'server.ssl.cert' & 'server.ssl.key' value") - } else if !isLetsEncrypt { - if !ess.IsFileExists(sslCert) { - return fmt.Errorf("SSL cert file not found: %s", sslCert) - } - - if !ess.IsFileExists(sslKey) { - return fmt.Errorf("SSL key file not found: %s", sslKey) - } - } - } - - if isLetsEncrypt && !isSSLEnabled { - return errors.New("let's encrypt enabled, however SSL 'server.ssl.enable' is not enabled for application") - } - return nil -} - -func writePID(cfg *config.Config, appBinaryName, appBaseDir string) { - // Get the application PID - appPID = os.Getpid() - - pidFile := cfg.StringDefault("pid_file", "") - if ess.IsStrEmpty(pidFile) { - pidFile = filepath.Join(appBaseDir, appBinaryName) - } - - if !strings.HasSuffix(pidFile, ".pid") { - pidFile += ".pid" - } - - if err := ioutil.WriteFile(pidFile, []byte(strconv.Itoa(appPID)), 0644); err != nil { - log.Error(err) - } -} - -func getBinaryFileName() string { - return ess.StripExt(AppBuildInfo().BinaryName) -} - -// This method is similar to -// https://golang.org/src/net/http/transfer.go#bodyAllowedForStatus -func isResponseBodyAllowed(code int) bool { - if (code >= http.StatusContinue && code < http.StatusOK) || - code == http.StatusNoContent || code == http.StatusNotModified { +// bodyAllowedForStatus reports whether a given response status code +// permits a body. See RFC 2616, section 4.4. +// +// This method taken from https://golang.org/src/net/http/transfer.go#bodyAllowedForStatus +func bodyAllowedForStatus(status int) bool { + switch { + case status >= 100 && status <= 199: + return false + case status == 204: // Status NoContent + return false + case status == 304: // Status NotModified return false } return true } -func resolveControllerName(ctx *Context) string { - if ess.IsStrEmpty(ctx.controller.Namespace) { - return ctx.controller.Name() - } - return path.Join(ctx.controller.Namespace, ctx.controller.Name()) -} - -func isCharsetExists(value string) bool { - return strings.Contains(value, "charset") -} - // TODO this method is candidate for essentials library // move it when you get a time func firstNonZeroString(values ...string) string { for _, v := range values { - if !ess.IsStrEmpty(v) { + v = strings.TrimSpace(v) + if len(v) > 0 { return v } } @@ -123,27 +68,23 @@ func firstNonZeroInt64(values ...int64) int64 { return 0 } -func identifyContentType(ctx *Context) *ahttp.ContentType { - // based on 'Accept' Header - if !ess.IsStrEmpty(ctx.Req.AcceptContentType.Mime) && - ctx.Req.AcceptContentType.Mime != "*/*" { - return ctx.Req.AcceptContentType - } - - // as per 'render.default' in aah.conf or nil - return defaultContentType() -} - -func parsePort(port string) string { - if !ess.IsStrEmpty(port) { - return port - } - - if AppIsSSLEnabled() { - return "443" +// resolveDefaultContentType method returns the Content-Type based on given +// input. +func resolveDefaultContentType(ct string) *ahttp.ContentType { + switch ct { + case "html": + return ahttp.ContentTypeHTML + case "json": + return ahttp.ContentTypeJSON + case "xml": + return ahttp.ContentTypeXML + case "text": + return ahttp.ContentTypePlainText + case "js": + return ahttp.ContentTypeJavascript + default: + return nil } - - return "80" } func parseHost(address, toPort string) string { @@ -171,3 +112,160 @@ func kind(t reflect.Type) reflect.Kind { } return t.Kind() } + +func actualType(v interface{}) reflect.Type { + vt := reflect.TypeOf(v) + if vt.Kind() == reflect.Ptr { + vt = vt.Elem() + } + + return vt +} + +// createRegistryKeyAndNamespace method creates the controller registry key. +func createRegistryKeyAndNamespace(cType reflect.Type) (string, string) { + namespace := cType.PkgPath() + if idx := strings.Index(namespace, "controllers"); idx > -1 { + namespace = namespace[idx+11:] + } + + if ess.IsStrEmpty(namespace) { + return strings.ToLower(cType.Name()), "" + } + + if strings.HasPrefix(namespace, string(filepath.Separator)) { + namespace = namespace[1:] + } + + return strings.ToLower(path.Join(namespace, cType.Name())), namespace +} + +// findEmbeddedContext method does breadth-first search on struct anonymous +// field to find `aah.Context` index positions. +func findEmbeddedContext(controllerType reflect.Type) [][]int { + var indexes [][]int + type nodeType struct { + val reflect.Value + index []int + } + + queue := []nodeType{{reflect.New(controllerType), []int{}}} + + for len(queue) > 0 { + var ( + node = queue[0] + elem = node.val + elemType = elem.Type() + ) + + if elemType.Kind() == reflect.Ptr { + elem = elem.Elem() + elemType = elem.Type() + } + + queue = queue[1:] + if elemType.Kind() != reflect.Struct { + continue + } + + for i := 0; i < elem.NumField(); i++ { + // skip non-anonymous fields + field := elemType.Field(i) + if !field.Anonymous { + continue + } + + // If it's a `aah.Context`, record the field indexes + if field.Type == ctxPtrType { + indexes = append(indexes, append(node.index, i)) + continue + } + + fieldValue := elem.Field(i) + queue = append(queue, + nodeType{fieldValue, append(append([]int{}, node.index...), i)}) + } + } + + return indexes +} + +func sortHeaderKeys(hdrs http.Header) []string { + keys := make([]string, 0, len(hdrs)) + for key := range hdrs { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func parseCacheBustPart(name, part string) string { + if strings.Contains(name, part) { + name = strings.Replace(name, "-"+part, "", 1) + name = strings.Replace(name, part+"-", "", 1) + } + return name +} + +// checkGzipRequired method return for static which requires gzip response. +func checkGzipRequired(file string) bool { + switch filepath.Ext(file) { + case ".css", ".js", ".html", ".htm", ".json", ".xml", + ".txt", ".csv", ".ttf", ".otf", ".eot": + return true + default: + return false + } +} + +// detectFileContentType method to identify the static file content-type. +func detectFileContentType(file string, content io.ReadSeeker) (string, error) { + ctype := mime.TypeByExtension(filepath.Ext(file)) + if ctype == "" { + // read a chunk to decide between utf-8 text and binary + // only 512 bytes expected by `http.DetectContentType` + var buf [512]byte + n, _ := io.ReadFull(content, buf[:]) + ctype = http.DetectContentType(buf[:n]) + + // rewind to output whole file + if _, err := content.Seek(0, io.SeekStart); err != nil { + return "", errSeeker + } + } + return ctype, nil +} + +// sanatizeValue method sanatizes string type value, rest we can't do any. +// It's a user responbility. +func sanatizeValue(value interface{}) interface{} { + switch v := value.(type) { + case string: + return template.HTMLEscapeString(v) + default: + return v + } +} + +// funcEqual method to compare to function callback interface data. In effect +// comparing the pointers of the indirect layer. Read more about the +// representation of functions here: http://golang.org/s/go11func +func funcEqual(a, b interface{}) bool { + av := reflect.ValueOf(&a).Elem() + bv := reflect.ValueOf(&b).Elem() + return av.InterfaceData() == bv.InterfaceData() +} + +// funcName method to get callback function name. +func funcName(f interface{}) string { + fi := ess.GetFunctionInfo(f) + return fi.Name +} + +func parsePriority(priority ...int) int { + pr := 1 // default priority is 1 + if len(priority) > 0 && priority[0] > 0 { + pr = priority[0] + } + return pr +} diff --git a/view.go b/view.go index 543c0b4a..4ec4a398 100644 --- a/view.go +++ b/view.go @@ -10,111 +10,143 @@ import ( "path/filepath" "strings" - "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" - "aahframework.org/log.v0" "aahframework.org/view.v0" ) -const appDefaultViewEngine = "go" - -var ( - appViewEngine view.Enginer - appViewExt string - appDefaultTmplLayout string - appIsDefaultLayoutEnabled bool - appViewFileCaseSensitive bool - appIsExternalTmplEngine bool - viewNotFoundTemplate = template.Must(template.New("not_found").Parse(` - {{ .ViewNotFound }} - `)) +const ( + defaultViewEngineName = "go" + defaultViewFileExt = ".html" ) -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Package methods -//___________________________________ +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app methods +//______________________________________________________________________________ -// AppViewEngine method returns aah application view Engine instance. -func AppViewEngine() view.Enginer { - return appViewEngine +func (a *app) ViewEngine() view.Enginer { + if a.viewMgr == nil { + return nil + } + return a.viewMgr.engine } -// AddTemplateFunc method adds template func map into view engine. -func AddTemplateFunc(funcs template.FuncMap) { +func (a *app) AddTemplateFunc(funcs template.FuncMap) { view.AddTemplateFunc(funcs) } -// AddViewEngine method adds the given name and view engine to view store. -func AddViewEngine(name string, engine view.Enginer) error { +func (a *app) AddViewEngine(name string, engine view.Enginer) error { return view.AddEngine(name, engine) } -// SetMinifier method sets the given minifier func into aah framework. -// Note: currently minifier is called only for HTML contentType. -func SetMinifier(fn MinifierFunc) { - if minifier == nil { - minifier = fn - } else { - log.Warnf("Minifier is already set: %v", funcName(minifier)) +func (a *app) SetMinifier(fn MinifierFunc) { + if a.viewMgr == nil { + a.viewMgr = &viewManager{a: a, e: a.engine} } -} - -//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -// Unexported methods -//___________________________________ -func appViewsDir() string { - return filepath.Join(AppBaseDir(), "views") + if a.viewMgr.minifier != nil { + a.Log().Warnf("Changing Minifier from: '%s' to '%s'", funcName(a.viewMgr.minifier), funcName(fn)) + } + a.viewMgr.minifier = fn } -func initViewEngine(viewDir string, appCfg *config.Config) error { - if !ess.IsFileExists(viewDir) { - // view directory not exists +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// app Unexported methods +//______________________________________________________________________________ + +func (a *app) initView() error { + viewsDir := filepath.Join(a.BaseDir(), "views") + if !ess.IsFileExists(viewsDir) { + // view directory not exists, scenario could be only API application return nil } - // application config values - appViewExt = appCfg.StringDefault("view.ext", ".html") - appDefaultTmplLayout = "master" + appViewExt - appViewFileCaseSensitive = appCfg.BoolDefault("view.case_sensitive", false) - appIsDefaultLayoutEnabled = appCfg.BoolDefault("view.default_layout", true) - - // initialize view engine - viewEngineName := appCfg.StringDefault("view.engine", appDefaultViewEngine) - viewEngine, found := view.GetEngine(viewEngineName) + engineName := a.Config().StringDefault("view.engine", defaultViewEngineName) + viewEngine, found := view.GetEngine(engineName) if !found { - return fmt.Errorf("view: named engine not found: %s", viewEngineName) + return fmt.Errorf("view: named engine not found: %s", engineName) } - if err := viewEngine.Init(appCfg, viewDir); err != nil { + viewMgr := &viewManager{ + a: a, + e: a.engine, + engineName: engineName, + fileExt: a.Config().StringDefault("view.ext", defaultViewFileExt), + defaultTmplLayout: "master" + a.Config().StringDefault("view.ext", defaultViewFileExt), + filenameCaseSensitive: a.Config().BoolDefault("view.case_sensitive", false), + defaultLayoutEnabled: a.Config().BoolDefault("view.default_layout", true), + notFoundTmpl: template.Must(template.New("not_found").Parse(` + {{ .ViewNotFound }} + `)), + } + + // Add Framework template methods + a.AddTemplateFunc(template.FuncMap{ + "config": viewMgr.tmplConfig, + "i18n": viewMgr.tmplI18n, + "rurl": viewMgr.tmplURL, + "rurlm": viewMgr.tmplURLm, + "pparam": viewMgr.tmplPathParam, + "fparam": viewMgr.tmplFormParam, + "qparam": viewMgr.tmplQueryParam, + "session": viewMgr.tmplSessionValue, + "flash": viewMgr.tmplFlashValue, + "isauthenticated": viewMgr.tmplIsAuthenticated, + "hasrole": viewMgr.tmplHasRole, + "hasallroles": viewMgr.tmplHasAllRoles, + "hasanyrole": viewMgr.tmplHasAnyRole, + "ispermitted": viewMgr.tmplIsPermitted, + "ispermittedall": viewMgr.tmplIsPermittedAll, + "anitcsrftoken": viewMgr.tmplAntiCSRFToken, + }) + + if err := viewEngine.Init(a.Config(), viewsDir); err != nil { return err } - appIsExternalTmplEngine = viewEngineName != appDefaultViewEngine - appViewEngine = viewEngine + viewMgr.engine = viewEngine + if a.viewMgr != nil && a.viewMgr.minifier != nil { + viewMgr.minifier = a.viewMgr.minifier + } + + a.viewMgr = viewMgr return nil } -// resolveView method does - -// 1) Prepare ViewArgs -// 2) If HTML content type find appropriate template -func (e *engine) resolveView(ctx *Context) { - if appViewEngine == nil || ctx.Reply().err != nil || !ctHTML.IsEqual(ctx.Reply().ContType) { +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// View Manager +//______________________________________________________________________________ + +type viewManager struct { + a *app + e *engine + engineName string + engine view.Enginer + fileExt string + defaultTmplLayout string + filenameCaseSensitive bool + defaultLayoutEnabled bool + notFoundTmpl *template.Template + minifier MinifierFunc +} + +// resolve method resolves the view template based available facts, such as +// controller name, action and user provided inputs. +func (vm *viewManager) resolve(ctx *Context) { + if ctx.Reply().err != nil || !ctx.Reply().isHTML() { return } // Resolving view by convention and configuration reply := ctx.Reply() if reply.Rdr == nil { - reply.Rdr = &HTML{} + reply.Rdr = &htmlRender{} } - htmlRdr := reply.Rdr.(*HTML) + htmlRdr := reply.Rdr.(*htmlRender) - if ess.IsStrEmpty(htmlRdr.Layout) && appIsDefaultLayoutEnabled { - htmlRdr.Layout = appDefaultTmplLayout + if ess.IsStrEmpty(htmlRdr.Layout) && vm.defaultLayoutEnabled { + htmlRdr.Layout = vm.defaultTmplLayout } if htmlRdr.ViewArgs == nil { @@ -128,38 +160,15 @@ func (e *engine) resolveView(ctx *Context) { htmlRdr.ViewArgs[k] = v } - // ViewArgs values from framework - addFrameworkValuesIntoViewArgs(ctx, htmlRdr) - - // find view template by convention if not provided - findViewTemplate(ctx) -} + // Add ViewArgs values from framework + vm.addFrameworkValuesIntoViewArgs(ctx) -// defaultContentType method returns the Content-Type based on 'render.default' -// config from aah.conf -func defaultContentType() *ahttp.ContentType { - switch AppConfig().StringDefault("render.default", "") { - case "html": - return ahttp.ContentTypeHTML - case "json": - return ahttp.ContentTypeJSON - case "xml": - return ahttp.ContentTypeXML - case "text": - return ahttp.ContentTypePlainText - default: - return nil - } -} - -func findViewTemplate(ctx *Context) { - htmlRdr := ctx.Reply().Rdr.(*HTML) var tmplPath, tmplName string // If user not provided the template info, auto resolve by convention if ess.IsStrEmpty(htmlRdr.Filename) { - tmplName = ctx.action.Name + appViewExt - tmplPath = filepath.Join(ctx.controller.Namespace, tmplControllerName(ctx)) + tmplName = ctx.action.Name + vm.fileExt + tmplPath = filepath.Join(ctx.controller.Namespace, ctx.controller.NoSuffixName) } else { // User provided view info like layout, filename. // Taking full-control of view rendering. @@ -172,7 +181,7 @@ func findViewTemplate(ctx *Context) { if strings.HasPrefix(htmlRdr.Filename, "/") { tmplPath = strings.TrimLeft(tmplPath, "/") } else { - tmplPath = filepath.Join(ctx.controller.Namespace, tmplControllerName(ctx), tmplPath) + tmplPath = filepath.Join(ctx.controller.Namespace, ctx.controller.NoSuffixName, tmplPath) } } @@ -180,81 +189,44 @@ func findViewTemplate(ctx *Context) { ctx.Log().Tracef("Layout: %s, Template Path: %s, Template Name: %s", htmlRdr.Layout, tmplPath, tmplName) var err error - if htmlRdr.Template, err = appViewEngine.Get(htmlRdr.Layout, tmplPath, tmplName); err != nil { + if htmlRdr.Template, err = vm.engine.Get(htmlRdr.Layout, tmplPath, tmplName); err != nil { if err == view.ErrTemplateNotFound { tmplFile := filepath.Join("views", tmplPath, tmplName) - if !appViewFileCaseSensitive { + if !vm.filenameCaseSensitive { tmplFile = strings.ToLower(tmplFile) } ctx.Log().Errorf("template not found: %s", tmplFile) - if appIsProfileProd { + if vm.a.IsProfileProd() { htmlRdr.ViewArgs["ViewNotFound"] = "View Not Found" } else { htmlRdr.ViewArgs["ViewNotFound"] = "View Not Found: " + tmplFile } htmlRdr.Layout = "" - htmlRdr.Template = viewNotFoundTemplate + htmlRdr.Template = vm.notFoundTmpl } else { ctx.Log().Error(err) } } } -// sanatizeValue method sanatizes string type value, rest we can't do any. -// It's a user responbility. -func sanatizeValue(value interface{}) interface{} { - switch v := value.(type) { - case string: - return template.HTMLEscapeString(v) - default: - return v - } -} - -func tmplControllerName(ctx *Context) string { - cName := ctx.controller.Name() - - if strings.HasSuffix(cName, controllerNameSuffix) { - cName = cName[:len(cName)-controllerNameSuffixLen] - } - return cName -} - -func addFrameworkValuesIntoViewArgs(ctx *Context, html *HTML) { +func (vm *viewManager) addFrameworkValuesIntoViewArgs(ctx *Context) { + html := ctx.Reply().Rdr.(*htmlRender) html.ViewArgs["Scheme"] = ctx.Req.Scheme html.ViewArgs["Host"] = ctx.Req.Host html.ViewArgs["HTTPMethod"] = ctx.Req.Method html.ViewArgs["RequestPath"] = ctx.Req.Path - html.ViewArgs["Locale"] = ctx.Req.Locale - html.ViewArgs["ClientIP"] = ctx.Req.ClientIP + html.ViewArgs["Locale"] = ctx.Req.Locale() + html.ViewArgs["ClientIP"] = ctx.Req.ClientIP() html.ViewArgs["IsJSONP"] = ctx.Req.IsJSONP() html.ViewArgs["IsAJAX"] = ctx.Req.IsAJAX() html.ViewArgs["HTTPReferer"] = ctx.Req.Referer html.ViewArgs["AahVersion"] = Version - html.ViewArgs["EnvProfile"] = AppProfile() - html.ViewArgs["AppBuildInfo"] = AppBuildInfo() - html.ViewArgs[KeyViewArgSubject] = ctx.Subject() html.ViewArgs[KeyViewArgRequestParams] = ctx.Req.Params -} + if ctx.subject != nil { + html.ViewArgs[KeyViewArgSubject] = ctx.Subject() + } -func init() { - AddTemplateFunc(template.FuncMap{ - "config": tmplConfig, - "rurl": tmplURL, - "rurlm": tmplURLm, - "i18n": tmplI18n, - "pparam": tmplPathParam, - "fparam": tmplFormParam, - "qparam": tmplQueryParam, - "session": tmplSessionValue, - "flash": tmplFlashValue, - "isauthenticated": tmplIsAuthenticated, - "hasrole": tmplHasRole, - "hasallroles": tmplHasAllRoles, - "hasanyrole": tmplHasAnyRole, - "ispermitted": tmplIsPermitted, - "ispermittedall": tmplIsPermittedAll, - "anitcsrftoken": tmplAntiCSRFToken, - }) + html.ViewArgs["EnvProfile"] = vm.a.Profile() + html.ViewArgs["AppBuildInfo"] = vm.a.BuildInfo() } diff --git a/view_test.go b/view_test.go index 51d4e844..7cdd243d 100644 --- a/view_test.go +++ b/view_test.go @@ -5,7 +5,6 @@ package aah import ( - "html/template" "io" "net/http/httptest" "path/filepath" @@ -14,61 +13,24 @@ import ( "testing" "aahframework.org/ahttp.v0" - "aahframework.org/config.v0" "aahframework.org/essentials.v0" - "aahframework.org/security.v0" "aahframework.org/test.v0/assert" "aahframework.org/view.v0" ) -func TestViewInit(t *testing.T) { - appCfg, _ := config.ParseString("") - viewDir := filepath.Join(getTestdataPath(), appViewsDir()) - err := initViewEngine(viewDir, appCfg) +func TestViewStore(t *testing.T) { + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppViewEngine()) - - // cleanup - appViewEngine = nil -} + defer ts.Close() -func TestViewInitDirNotExists(t *testing.T) { - appCfg, _ := config.ParseString("") - viewDir := filepath.Join(getTestdataPath(), "views-not-exists") - - err := initViewEngine(viewDir, appCfg) - assert.True(t, err == nil) - assert.Nil(t, AppViewEngine()) -} - -func TestViewInitEngineNotFound(t *testing.T) { - appCfg, _ := config.ParseString(` - view { - engine = "jade1" - } - `) - viewDir := filepath.Join(getTestdataPath(), appViewsDir()) - err := initViewEngine(viewDir, appCfg) - assert.Equal(t, "view: named engine not found: jade1", err.Error()) - assert.Nil(t, AppViewEngine()) -} - -func TestViewAddTemplateFunc(t *testing.T) { - AddTemplateFunc(template.FuncMap{ - "join": strings.Join, - "safeHTML": strings.Join, // for duplicate test, don't mind - }) + t.Logf("Test Server URL [View Store]: %s", ts.URL) - _, found := view.TemplateFuncMap["join"] - assert.True(t, found) -} - -func TestViewStore(t *testing.T) { - err := AddViewEngine("go", &view.GoViewEngine{}) + err = ts.app.AddViewEngine("go", &view.GoViewEngine{}) assert.NotNil(t, err) assert.Equal(t, "view: engine name 'go' is already added, skip it", err.Error()) - err = AddViewEngine("custom", nil) + err = ts.app.AddViewEngine("custom", nil) assert.NotNil(t, err) assert.Equal(t, "view: engine value is nil", err.Error()) @@ -82,144 +44,108 @@ func TestViewStore(t *testing.T) { } func TestViewResolveView(t *testing.T) { - defer ess.DeleteFiles("testapp.pid") - appCfg, _ := config.ParseString("") - e := newEngine(appCfg) + defer ess.DeleteFiles("webapp1.pid") - viewDir := filepath.Join(getTestdataPath(), appViewsDir()) - err := initViewEngine(viewDir, appCfg) + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) assert.Nil(t, err) - assert.NotNil(t, AppViewEngine()) + defer ts.Close() - req := httptest.NewRequest("GET", "http://localhost:8080/index.html", nil) - ctx := e.prepareContext(httptest.NewRecorder(), req) + t.Logf("Test Server URL [Resolve View]: %s", ts.URL) + + vm := ts.app.viewMgr + assert.NotNil(t, vm) + assert.NotNil(t, vm.engine) + + req := httptest.NewRequest(ahttp.MethodGet, ts.URL, nil) + ctx := newContext(httptest.NewRecorder(), req) + ctx.a = ts.app type AppController struct{} - ctx.controller = &controllerInfo{Type: reflect.TypeOf(AppController{})} - ctx.action = &MethodInfo{ - Name: "Index", - Parameters: []*ParameterInfo{}, - } + cType := reflect.TypeOf(AppController{}) + ctx.controller = &controllerInfo{Name: cType.Name(), Type: cType, NoSuffixName: "app"} + ctx.action = &MethodInfo{Name: "Index", Parameters: []*ParameterInfo{}} ctx.Reply().ContentType(ahttp.ContentTypeHTML.Raw()) ctx.AddViewArg("MyName", "aah framework") - e.resolveView(ctx) - + t.Log("Template exists") + vm.resolve(ctx) assert.NotNil(t, ctx.Reply().Rdr) - htmlRdr := ctx.Reply().Rdr.(*HTML) - + htmlRdr := ctx.Reply().Rdr.(*htmlRender) assert.Equal(t, "master.html", htmlRdr.Layout) assert.Equal(t, "pages/app/index.html", htmlRdr.Template.Name()) assert.Equal(t, "http", htmlRdr.ViewArgs["Scheme"]) - assert.Equal(t, "localhost:8080", htmlRdr.ViewArgs["Host"]) - assert.Equal(t, "/index.html", htmlRdr.ViewArgs["RequestPath"]) + assert.True(t, strings.Contains(ts.URL, htmlRdr.ViewArgs["Host"].(string))) + assert.Equal(t, "", htmlRdr.ViewArgs["RequestPath"]) assert.Equal(t, Version, htmlRdr.ViewArgs["AahVersion"]) assert.Equal(t, "aah framework", htmlRdr.ViewArgs["MyName"]) + assert.True(t, htmlRdr.ViewArgs["ClientIP"].(string) != "") // User provided template file + t.Log("User provided template file") ctx.Reply().HTMLf("/admin/index.html", Data{}) - e.resolveView(ctx) - htmlRdr = ctx.Reply().Rdr.(*HTML) + vm.resolve(ctx) + htmlRdr = ctx.Reply().Rdr.(*htmlRender) assert.Equal(t, "/admin/index.html", htmlRdr.Filename) assert.Equal(t, "View Not Found: views/pages/admin/index.html", htmlRdr.ViewArgs["ViewNotFound"]) // User provided template file with controller context + t.Log("User provided template file with controller context") ctx.Reply().HTMLf("user/index.html", Data{}) - e.resolveView(ctx) - htmlRdr = ctx.Reply().Rdr.(*HTML) + vm.resolve(ctx) + htmlRdr = ctx.Reply().Rdr.(*htmlRender) assert.Equal(t, "user/index.html", htmlRdr.Filename) assert.Equal(t, "View Not Found: views/pages/app/user/index.html", htmlRdr.ViewArgs["ViewNotFound"]) // Namespace/Sub-package - appIsProfileProd = true + t.Log("Namespace/Sub-package") + ts.app.envProfile = "prod" ctx.controller = &controllerInfo{Type: reflect.TypeOf(AppController{}), Namespace: "frontend"} ctx.Reply().HTMLf("index.html", Data{}) - e.resolveView(ctx) - htmlRdr = ctx.Reply().Rdr.(*HTML) + vm.resolve(ctx) + htmlRdr = ctx.Reply().Rdr.(*htmlRender) assert.Equal(t, "index.html", htmlRdr.Filename) assert.Equal(t, "View Not Found", htmlRdr.ViewArgs["ViewNotFound"]) - appIsProfileProd = false - - ctx.Req.AcceptContentType.Mime = "" - appConfig = appCfg - assert.Nil(t, identifyContentType(ctx)) - - // cleanup - appViewEngine = nil - appConfig = nil + ts.app.envProfile = "dev" } -func TestViewResolveViewNotFound(t *testing.T) { - e := &engine{} - appConfig, _ = config.ParseString("") - viewDir := filepath.Join(getTestdataPath(), "idontknow") - appViewEngine = &view.GoViewEngine{} - appViewEngine.Init(appConfig, viewDir) +func TestViewMinifier(t *testing.T) { + defer ess.DeleteFiles("webapp1.pid") - req := httptest.NewRequest("GET", "http://localhost:8080/index.html", nil) - type AppController struct{} - ctx := &Context{ - Req: ahttp.ParseRequest(req, &ahttp.Request{}), - controller: &controllerInfo{Type: reflect.TypeOf(AppController{}), Namespace: "site"}, - action: &MethodInfo{ - Name: "Index", - Parameters: []*ParameterInfo{}, - }, - reply: NewReply(), - subject: security.AcquireSubject(), - } - ctx.Reply().ContentType(ahttp.ContentTypeHTML.Raw()) - appViewExt = ".html" + importPath := filepath.Join(testdataBaseDir(), "webapp1") + ts, err := newTestServer(t, importPath) + assert.Nil(t, err) + defer ts.Close() - e.resolveView(ctx) + t.Logf("Test Server URL [View Minifier]: %s", ts.URL) - assert.NotNil(t, ctx.Reply().Rdr) - htmlRdr := ctx.Reply().Rdr.(*HTML) - assert.NotNil(t, htmlRdr.Template) + assert.NotNil(t, ts.app.viewMgr) + assert.Nil(t, ts.app.viewMgr.minifier) + ts.app.SetMinifier(func(contentType string, w io.Writer, r io.Reader) error { + t.Log(contentType, w, r) + return nil + }) + assert.NotNil(t, ts.app.viewMgr.minifier) - // cleanup - appViewEngine = nil + t.Log("Second set") + ts.app.SetMinifier(func(contentType string, w io.Writer, r io.Reader) error { + t.Log("this is second set", contentType, w, r) + return nil + }) } func TestViewDefaultContentType(t *testing.T) { - appConfig, _ = config.ParseString("") - assert.Nil(t, defaultContentType()) - - appConfig, _ = config.ParseString(` - render { - default = "html" - } - `) + assert.Nil(t, resolveDefaultContentType("")) - v1 := defaultContentType() + v1 := resolveDefaultContentType("html") assert.Equal(t, "text/html; charset=utf-8", v1.Raw()) - AppConfig().SetString("render.default", "xml") - v2 := defaultContentType() + v2 := resolveDefaultContentType("xml") assert.Equal(t, "application/xml; charset=utf-8", v2.Raw()) - AppConfig().SetString("render.default", "json") - v3 := defaultContentType() + v3 := resolveDefaultContentType("json") assert.Equal(t, "application/json; charset=utf-8", v3.Raw()) - AppConfig().SetString("render.default", "text") - v4 := defaultContentType() + v4 := resolveDefaultContentType("text") assert.Equal(t, "text/plain; charset=utf-8", v4.Raw()) - - // cleanup - appConfig = nil -} - -func TestViewSetMinifier(t *testing.T) { - testMinifier := func(contentType string, w io.Writer, r io.Reader) error { - t.Log("called minifier func") - return nil - } - - assert.Nil(t, minifier) - SetMinifier(testMinifier) - assert.NotNil(t, minifier) - - SetMinifier(testMinifier) - assert.NotNil(t, minifier) }