diff --git a/config/dev.yml b/config/dev.yml index d57075a..3a30596 100644 --- a/config/dev.yml +++ b/config/dev.yml @@ -167,10 +167,13 @@ roles: block: false - name: deals - query: limit: 3 - columns: ["name", "description" ] + aggregation: false + + - name: purchases + query: + limit: 3 aggregation: false - name: user @@ -183,12 +186,10 @@ roles: query: limit: 50 filters: ["{ user_id: { eq: $user_id } }"] - columns: ["id", "name", "description", "search_rank", "search_headline_description" ] disable_functions: false insert: filters: ["{ user_id: { eq: $user_id } }"] - columns: ["id", "name", "description" ] presets: - user_id: "$user_id" - created_at: "now" diff --git a/docs/guide.md b/docs/guide.md index 6299a1f..34510ef 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1319,7 +1319,7 @@ auth: max_active: 12000 ``` -### JWT Token Auth +### JWT Tokens ```yaml auth: @@ -1339,6 +1339,51 @@ We can get the JWT token either from the `authorization` header where we expect For validation a `secret` or a public key (ecdsa or rsa) is required. When using public keys they have to be in a PEM format file. +### HTTP Headers + +```yaml +header: + name: X-AppEngine-QueueName + exists: true + #value: default +``` + +Header auth is usually the best option to authenticate requests to the action endpoints. For example you +might want to use an action to refresh a materalized view every hour and only want a cron service like the Google AppEngine Cron service to make that request in this case a config similar to the one above will do. + +The `exists: true` parameter ensures that only the existance of the header is checked not its value. The `value` parameter lets you confirm that the value matches the one assgined to the parameter. This helps in the case you are using a shared secret to protect the endpoint. + +### Named Auth + +```yaml +# You can add additional named auths to use with actions +# In this example actions using this auth can only be +# called from the Google Appengine Cron service that +# sets a special header to all it's requests +auths: + - name: from_taskqueue + type: header + header: + name: X-Appengine-Cron + exists: true +``` + +In addition to the default auth configuration you can create additional named auth configurations to be used +with features like `actions`. For example while your main GraphQL endpoint uses JWT for authentication you may want to use a header value to ensure your actions can only be called by clients having access to a shared secret +or security header. + +## Actions + +Actions is a very useful feature that is currently work in progress. For now the best use case for actions is to +refresh database tables like materialized views or call a database procedure to refresh a cache table, etc. An action creates an http endpoint that anyone can call to have the SQL query executed. The below example will create an endpoint `/api/v1/actions/refresh_leaderboard_users` any request send to that endpoint will cause the sql query to be executed. the `auth_name` points to a named auth that should be used to secure this endpoint. In future we have big plans to allow your own custom code to run using actions. + +```yaml +actions: + - name: refresh_leaderboard_users + sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users" + auth_name: from_taskqueue +``` + #### Using CURL to test a query ```bash @@ -1593,6 +1638,22 @@ auth: # public_key_file: /secrets/public_key.pem # public_key_type: ecdsa #rsa + # header: + # name: dnt + # exists: true + # value: localhost:8080 + +# You can add additional named auths to use with actions +# In this example actions using this auth can only be +# called from the Google Appengine Cron service that +# sets a special header to all it's requests +auths: + - name: from_taskqueue + type: header + header: + name: X-Appengine-Cron + exists: true + database: type: postgres host: db @@ -1623,6 +1684,17 @@ database: - encrypted - token +# Create custom actions with their own api endpoints +# For example the below action will be available at /api/v1/actions/refresh_leaderboard_users +# A request to this url will execute the configured SQL query +# which in this case refreshes a materialized view in the database. +# The auth_name is from one of the configured auths +actions: + - name: refresh_leaderboard_users + sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users" + auth_name: from_taskqueue + + tables: - name: customers remotes: diff --git a/psql/tables.go b/psql/tables.go index b9b88e9..26c36b6 100644 --- a/psql/tables.go +++ b/psql/tables.go @@ -151,6 +151,7 @@ SELECT pg_catalog.format_type(f.atttypid,f.atttypmod) AS type, CASE WHEN f.attndims != 0 THEN true + WHEN right(pg_catalog.format_type(f.atttypid,f.atttypmod), 2) = '[]' THEN true ELSE false END AS array, CASE @@ -175,7 +176,7 @@ FROM pg_attribute f LEFT JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_constraint p ON p.conrelid = c.oid AND f.attnum = ANY (p.conkey) LEFT JOIN pg_class AS g ON p.confrelid = g.oid -WHERE c.relkind = ('r'::char) +WHERE c.relkind IN ('r', 'v', 'm', 'f') AND n.nspname = $1 -- Replace with Schema name AND c.relname = $2 -- Replace with table name AND f.attnum > 0 diff --git a/serv/actions.go b/serv/actions.go new file mode 100644 index 0000000..54e6065 --- /dev/null +++ b/serv/actions.go @@ -0,0 +1,41 @@ +package serv + +import ( + "fmt" + "net/http" +) + +type actionFn func(w http.ResponseWriter, r *http.Request) error + +func newAction(a configAction) (http.Handler, error) { + var fn actionFn + var err error + + if len(a.SQL) != 0 { + fn, err = newSQLAction(a) + } else { + return nil, fmt.Errorf("invalid config for action '%s'", a.Name) + } + + if err != nil { + return nil, err + } + + httpFn := func(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + errlog.Error().Err(err).Send() + errorResp(w, err) + } + } + + return http.HandlerFunc(httpFn), nil +} + +func newSQLAction(a configAction) (actionFn, error) { + fn := func(w http.ResponseWriter, r *http.Request) error { + _, err := db.Exec(r.Context(), a.SQL) + return err + } + + return fn, nil +} diff --git a/serv/auth.go b/serv/auth.go index 60c76a4..0205213 100644 --- a/serv/auth.go +++ b/serv/auth.go @@ -3,7 +3,6 @@ package serv import ( "context" "net/http" - "strings" ) type ctxkey int @@ -14,7 +13,7 @@ const ( userRoleKey ) -func headerAuth(next http.Handler) http.HandlerFunc { +func headerAuth(authc configAuth, next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -37,28 +36,53 @@ func headerAuth(next http.Handler) http.HandlerFunc { } } -func withAuth(next http.Handler) http.Handler { - at := conf.Auth.Type - ru := conf.Auth.Rails.URL +func headerHandler(authc configAuth, next http.Handler) http.HandlerFunc { + hdr := authc.Header - if conf.Auth.CredsInHeader { - next = headerAuth(next) + if len(hdr.Name) == 0 { + errlog.Fatal().Str("auth", authc.Name).Msg("no header.name defined") } - switch at { + if !hdr.Exists && len(hdr.Value) == 0 { + errlog.Fatal().Str("auth", authc.Name).Msg("no header.value defined") + } + + return func(w http.ResponseWriter, r *http.Request) { + var fo1 bool + value := r.Header.Get(hdr.Name) + + switch { + case hdr.Exists: + fo1 = (len(value) == 0) + + default: + fo1 = (value != hdr.Value) + } + + if fo1 { + http.Error(w, "401 unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + } +} + +func withAuth(next http.Handler, authc configAuth) http.Handler { + if authc.CredsInHeader { + next = headerAuth(authc, next) + } + + switch authc.Type { case "rails": - if strings.HasPrefix(ru, "memcache:") { - return railsMemcacheHandler(next) - } - - if strings.HasPrefix(ru, "redis:") { - return railsRedisHandler(next) - } - - return railsCookieHandler(next) + return railsHandler(authc, next) case "jwt": - return jwtHandler(next) + return jwtHandler(authc, next) + + case "header": + return headerHandler(authc, next) + } return next diff --git a/serv/auth_jwt.go b/serv/auth_jwt.go index 456b576..ac8fe88 100644 --- a/serv/auth_jwt.go +++ b/serv/auth_jwt.go @@ -14,18 +14,18 @@ const ( jwtAuth0 int = iota + 1 ) -func jwtHandler(next http.Handler) http.HandlerFunc { +func jwtHandler(authc configAuth, next http.Handler) http.HandlerFunc { var key interface{} var jwtProvider int - cookie := conf.Auth.Cookie + cookie := authc.Cookie - if conf.Auth.JWT.Provider == "auth0" { + if authc.JWT.Provider == "auth0" { jwtProvider = jwtAuth0 } - secret := conf.Auth.JWT.Secret - publicKeyFile := conf.Auth.JWT.PubKeyFile + secret := authc.JWT.Secret + publicKeyFile := authc.JWT.PubKeyFile switch { case len(secret) != 0: @@ -37,7 +37,7 @@ func jwtHandler(next http.Handler) http.HandlerFunc { errlog.Fatal().Err(err).Send() } - switch conf.Auth.JWT.PubKeyType { + switch authc.JWT.PubKeyType { case "ecdsa": key, err = jwt.ParseECPublicKeyFromPEM(kd) diff --git a/serv/auth_rails.go b/serv/auth_rails.go index 7f8a8fe..be1cfba 100644 --- a/serv/auth_rails.go +++ b/serv/auth_rails.go @@ -6,32 +6,47 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/bradfitz/gomemcache/memcache" "github.com/dosco/super-graph/rails" "github.com/garyburd/redigo/redis" ) -func railsRedisHandler(next http.Handler) http.HandlerFunc { - cookie := conf.Auth.Cookie +func railsHandler(authc configAuth, next http.Handler) http.HandlerFunc { + ru := authc.Rails.URL + + if strings.HasPrefix(ru, "memcache:") { + return railsMemcacheHandler(authc, next) + } + + if strings.HasPrefix(ru, "redis:") { + return railsRedisHandler(authc, next) + } + + return railsCookieHandler(authc, next) +} + +func railsRedisHandler(authc configAuth, next http.Handler) http.HandlerFunc { + cookie := authc.Cookie if len(cookie) == 0 { errlog.Fatal().Msg("no auth.cookie defined") } - if len(conf.Auth.Rails.URL) == 0 { + if len(authc.Rails.URL) == 0 { errlog.Fatal().Msg("no auth.rails.url defined") } rp := &redis.Pool{ - MaxIdle: conf.Auth.Rails.MaxIdle, - MaxActive: conf.Auth.Rails.MaxActive, + MaxIdle: authc.Rails.MaxIdle, + MaxActive: authc.Rails.MaxActive, Dial: func() (redis.Conn, error) { - c, err := redis.DialURL(conf.Auth.Rails.URL) + c, err := redis.DialURL(authc.Rails.URL) if err != nil { errlog.Fatal().Err(err).Send() } - pwd := conf.Auth.Rails.Password + pwd := authc.Rails.Password if len(pwd) != 0 { if _, err := c.Do("AUTH", pwd); err != nil { errlog.Fatal().Err(err).Send() @@ -66,17 +81,17 @@ func railsRedisHandler(next http.Handler) http.HandlerFunc { } } -func railsMemcacheHandler(next http.Handler) http.HandlerFunc { - cookie := conf.Auth.Cookie +func railsMemcacheHandler(authc configAuth, next http.Handler) http.HandlerFunc { + cookie := authc.Cookie if len(cookie) == 0 { errlog.Fatal().Msg("no auth.cookie defined") } - if len(conf.Auth.Rails.URL) == 0 { + if len(authc.Rails.URL) == 0 { errlog.Fatal().Msg("no auth.rails.url defined") } - rURL, err := url.Parse(conf.Auth.Rails.URL) + rURL, err := url.Parse(authc.Rails.URL) if err != nil { errlog.Fatal().Err(err).Send() } @@ -108,13 +123,13 @@ func railsMemcacheHandler(next http.Handler) http.HandlerFunc { } } -func railsCookieHandler(next http.Handler) http.HandlerFunc { - cookie := conf.Auth.Cookie +func railsCookieHandler(authc configAuth, next http.Handler) http.HandlerFunc { + cookie := authc.Cookie if len(cookie) == 0 { errlog.Fatal().Msg("no auth.cookie defined") } - ra, err := railsAuth(conf) + ra, err := railsAuth(authc) if err != nil { errlog.Fatal().Err(err).Send() } @@ -139,13 +154,13 @@ func railsCookieHandler(next http.Handler) http.HandlerFunc { } } -func railsAuth(c *config) (*rails.Auth, error) { - secret := c.Auth.Rails.SecretKeyBase +func railsAuth(authc configAuth) (*rails.Auth, error) { + secret := authc.Rails.SecretKeyBase if len(secret) == 0 { return nil, errors.New("no auth.rails.secret_key_base defined") } - version := c.Auth.Rails.Version + version := authc.Rails.Version if len(version) == 0 { return nil, errors.New("no auth.rails.version defined") } @@ -155,16 +170,16 @@ func railsAuth(c *config) (*rails.Auth, error) { return nil, err } - if len(c.Auth.Rails.Salt) != 0 { - ra.Salt = c.Auth.Rails.Salt + if len(authc.Rails.Salt) != 0 { + ra.Salt = authc.Rails.Salt } - if len(conf.Auth.Rails.SignSalt) != 0 { - ra.SignSalt = c.Auth.Rails.SignSalt + if len(authc.Rails.SignSalt) != 0 { + ra.SignSalt = authc.Rails.SignSalt } - if len(conf.Auth.Rails.AuthSalt) != 0 { - ra.AuthSalt = c.Auth.Rails.AuthSalt + if len(authc.Rails.AuthSalt) != 0 { + ra.AuthSalt = authc.Rails.AuthSalt } return ra, nil diff --git a/serv/cmd_migrate.go b/serv/cmd_migrate.go index b151cd0..fdeae85 100644 --- a/serv/cmd_migrate.go +++ b/serv/cmd_migrate.go @@ -311,3 +311,13 @@ func getMigrationVars() map[string]interface{} { "env": strings.ToLower(os.Getenv("GO_ENV")), } } + +func initConfOnce() { + var err error + + if conf == nil { + if conf, err = initConf(); err != nil { + errlog.Fatal().Err(err).Msg("failed to read config") + } + } +} diff --git a/serv/cmd_serv.go b/serv/cmd_serv.go index 37ac757..f1b3584 100644 --- a/serv/cmd_serv.go +++ b/serv/cmd_serv.go @@ -7,23 +7,22 @@ import ( func cmdServ(cmd *cobra.Command, args []string) { var err error + initWatcher(confPath) + if conf, err = initConf(); err != nil { fatalInProd(err, "failed to read config") } - if conf != nil { - db, err = initDBPool(conf) + db, err = initDBPool(conf) - if err == nil { - initCompiler() - initAllowList(confPath) - initPreparedList(confPath) - } else { - fatalInProd(err, "failed to connect to database") - } + if err != nil { + fatalInProd(err, "failed to connect to database") } - initWatcher(confPath) + initCompiler() + initResolvers() + initAllowList(confPath) + initPreparedList(confPath) startHTTP() } diff --git a/serv/config.go b/serv/config.go index 7df9086..9662a7f 100644 --- a/serv/config.go +++ b/serv/config.go @@ -33,30 +33,8 @@ type config struct { Inflections map[string]string - Auth struct { - Type string - Cookie string - CredsInHeader bool `mapstructure:"creds_in_header"` - - Rails struct { - Version string - SecretKeyBase string `mapstructure:"secret_key_base"` - URL string - Password string - MaxIdle int `mapstructure:"max_idle"` - MaxActive int `mapstructure:"max_active"` - Salt string - SignSalt string `mapstructure:"sign_salt"` - AuthSalt string `mapstructure:"auth_salt"` - } - - JWT struct { - Provider string - Secret string - PubKeyFile string `mapstructure:"public_key_file"` - PubKeyType string `mapstructure:"public_key_type"` - } - } + Auth configAuth + Auths []configAuth DB struct { Type string @@ -77,6 +55,8 @@ type config struct { Tables []configTable } `mapstructure:"database"` + Actions []configAction + Tables []configTable RolesQuery string `mapstructure:"roles_query"` @@ -85,6 +65,38 @@ type config struct { abacEnabled bool } +type configAuth struct { + Name string + Type string + Cookie string + CredsInHeader bool `mapstructure:"creds_in_header"` + + Rails struct { + Version string + SecretKeyBase string `mapstructure:"secret_key_base"` + URL string + Password string + MaxIdle int `mapstructure:"max_idle"` + MaxActive int `mapstructure:"max_active"` + Salt string + SignSalt string `mapstructure:"sign_salt"` + AuthSalt string `mapstructure:"auth_salt"` + } + + JWT struct { + Provider string + Secret string + PubKeyFile string `mapstructure:"public_key_file"` + PubKeyType string `mapstructure:"public_key_type"` + } + + Header struct { + Name string + Value string + Exists bool + } +} + type configColumn struct { Name string Type string @@ -156,6 +168,12 @@ type configRole struct { tablesMap map[string]*configRoleTable } +type configAction struct { + Name string + SQL string + AuthName string `mapstructure:"auth_name"` +} + func newConfig(name string) *viper.Viper { vi := viper.New() @@ -283,26 +301,48 @@ func (c *config) Init(vi *viper.Viper) error { func (c *config) validate() { rm := make(map[string]struct{}) - for i := range c.Roles { - name := c.Roles[i].Name + for _, v := range c.Roles { + name := strings.ToLower(v.Name) if _, ok := rm[name]; ok { - errlog.Fatal().Msgf("duplicate config for role '%s'", c.Roles[i].Name) + errlog.Fatal().Msgf("duplicate config for role '%s'", v.Name) } rm[name] = struct{}{} } tm := make(map[string]struct{}) - for i := range c.Tables { - name := c.Tables[i].Name + for _, v := range c.Tables { + name := strings.ToLower(v.Name) if _, ok := tm[name]; ok { - errlog.Fatal().Msgf("duplicate config for table '%s'", c.Tables[i].Name) + errlog.Fatal().Msgf("duplicate config for table '%s'", v.Name) } tm[name] = struct{}{} } + am := make(map[string]struct{}) + + for _, v := range c.Auths { + name := strings.ToLower(v.Name) + + if _, ok := am[name]; ok { + errlog.Fatal().Msgf("duplicate config for auth '%s'", v.Name) + } + am[name] = struct{}{} + } + + for _, v := range c.Actions { + if len(v.AuthName) == 0 { + continue + } + authName := strings.ToLower(v.AuthName) + + if _, ok := am[authName]; !ok { + errlog.Fatal().Msgf("invalid auth_name for action '%s'", v.Name) + } + } + if len(c.RolesQuery) == 0 { logger.Warn().Msgf("no 'roles_query' defined.") } @@ -349,3 +389,31 @@ func sanitize(s string) string { return strings.ToLower(m) }) } + +func getConfigName() string { + if len(os.Getenv("GO_ENV")) == 0 { + return "dev" + } + + ge := strings.ToLower(os.Getenv("GO_ENV")) + + switch { + case strings.HasPrefix(ge, "pro"): + return "prod" + + case strings.HasPrefix(ge, "sta"): + return "stage" + + case strings.HasPrefix(ge, "tes"): + return "test" + + case strings.HasPrefix(ge, "dev"): + return "dev" + } + + return ge +} + +func isDev() bool { + return strings.HasPrefix(os.Getenv("GO_ENV"), "dev") +} diff --git a/serv/init.go b/serv/init.go index c155c31..2b4aa39 100644 --- a/serv/init.go +++ b/serv/init.go @@ -148,20 +148,6 @@ func initCompiler() { if err != nil { errlog.Fatal().Err(err).Msg("failed to initialize compilers") } - - if err := initResolvers(); err != nil { - errlog.Fatal().Err(err).Msg("failed to initialized resolvers") - } -} - -func initConfOnce() { - var err error - - if conf == nil { - if conf, err = initConf(); err != nil { - errlog.Fatal().Err(err).Msg("failed to read config") - } - } } func initAllowList(cpath string) { diff --git a/serv/reso.go b/serv/resolve.go similarity index 95% rename from serv/reso.go rename to serv/resolve.go index 3af9a9e..6fb244b 100644 --- a/serv/reso.go +++ b/serv/resolve.go @@ -22,16 +22,20 @@ type resolvFn struct { Fn func(h http.Header, id []byte) ([]byte, error) } -func initResolvers() error { +func initResolvers() { + var err error rmap = make(map[uint64]*resolvFn) for _, t := range conf.Tables { - err := initRemotes(t) + err = initRemotes(t) if err != nil { - return err + break } } - return nil + + if err != nil { + errlog.Fatal().Err(err).Msg("failed to initialize resolvers") + } } func initRemotes(t configTable) error { diff --git a/serv/serv.go b/serv/serv.go index d5b94ef..865ba63 100644 --- a/serv/serv.go +++ b/serv/serv.go @@ -101,9 +101,14 @@ func startHTTP() { hostPort = defaultHP } + routes, err := routeHandler() + if err != nil { + errlog.Fatal().Err(err).Send() + } + srv := &http.Server{ Addr: hostPort, - Handler: routeHandler(), + Handler: routes, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, @@ -140,59 +145,74 @@ func startHTTP() { <-idleConnsClosed } -func routeHandler() http.Handler { - var apiH http.Handler - - if conf != nil && conf.HTTPGZip { - gzipH := gziphandler.MustNewGzipLevelHandler(6) - apiH = gzipH(http.HandlerFunc(apiV1)) - } else { - apiH = http.HandlerFunc(apiV1) - } - +func routeHandler() (http.Handler, error) { mux := http.NewServeMux() - if conf != nil { - mux.HandleFunc("/health", health) - mux.Handle("/api/v1/graphql", withAuth(apiH)) + if conf == nil { + return mux, nil + } - if conf.WebUI { - mux.Handle("/", http.FileServer(rice.MustFindBox("../web/build").HTTPBox())) + routes := map[string]http.Handler{ + "/health": http.HandlerFunc(health), + "/api/v1/graphql": withAuth(http.HandlerFunc(apiV1), conf.Auth), + } + + if err := setActionRoutes(routes); err != nil { + return nil, err + } + + if conf.WebUI { + routes["/"] = http.FileServer(rice.MustFindBox("../web/build").HTTPBox()) + } + + if conf.HTTPGZip { + gz := gziphandler.MustNewGzipLevelHandler(6) + for k, v := range routes { + routes[k] = gz(v) } } + for k, v := range routes { + mux.Handle(k, v) + } + fn := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", serverName) mux.ServeHTTP(w, r) } - return http.HandlerFunc(fn) + return http.HandlerFunc(fn), nil } -func getConfigName() string { - if len(os.Getenv("GO_ENV")) == 0 { - return "dev" +func setActionRoutes(routes map[string]http.Handler) error { + var err error + + for _, a := range conf.Actions { + var fn http.Handler + + fn, err = newAction(a) + if err != nil { + break + } + + p := fmt.Sprintf("/api/v1/actions/%s", strings.ToLower(a.Name)) + + if authc, ok := findAuth(a.AuthName); ok { + routes[p] = withAuth(fn, authc) + } else { + routes[p] = fn + } } + return nil +} - ge := strings.ToLower(os.Getenv("GO_ENV")) +func findAuth(name string) (configAuth, bool) { + var authc configAuth - switch { - case strings.HasPrefix(ge, "pro"): - return "prod" - - case strings.HasPrefix(ge, "sta"): - return "stage" - - case strings.HasPrefix(ge, "tes"): - return "test" - - case strings.HasPrefix(ge, "dev"): - return "dev" + for _, a := range conf.Auths { + if strings.EqualFold(a.Name, name) { + return a, true + } } - - return ge -} - -func isDev() bool { - return strings.HasPrefix(os.Getenv("GO_ENV"), "dev") + return authc, false } diff --git a/serv/utils.go b/serv/utils.go index c96c76b..78d1d8c 100644 --- a/serv/utils.go +++ b/serv/utils.go @@ -7,6 +7,7 @@ import ( "io" "sort" "strings" + "sync" "github.com/cespare/xxhash/v2" "github.com/dosco/super-graph/jsn" @@ -127,9 +128,14 @@ func findStmt(role string, stmts []stmt) *stmt { } func fatalInProd(err error, msg string) { - if isDev() { - errlog.Error().Err(err).Msg(msg) - } else { + var wg sync.WaitGroup + + if !isDev() { errlog.Fatal().Err(err).Msg(msg) } + + errlog.Error().Err(err).Msg(msg) + + wg.Add(1) + wg.Wait() } diff --git a/tmpl/dev.yml b/tmpl/dev.yml index 7e022df..f7b0bc5 100644 --- a/tmpl/dev.yml +++ b/tmpl/dev.yml @@ -49,7 +49,7 @@ migrations_path: ./config/migrations # sheep: sheep auth: - # Can be 'rails' or 'jwt' + # Can be 'rails', 'jwt' or 'header' type: rails cookie: _{% app_name_slug %}_session @@ -83,6 +83,22 @@ auth: # public_key_file: /secrets/public_key.pem # public_key_type: ecdsa #rsa + # header: + # name: dnt + # exists: true + # value: localhost:8080 + +# You can add additional named auths to use with actions +# In this example actions using this auth can only be +# called from the Google Appengine Cron service that +# sets a special header to all it's requests +auths: + - name: from_taskqueue + type: header + header: + name: X-Appengine-Cron + exists: true + database: type: postgres host: db @@ -116,6 +132,16 @@ database: - encrypted - token +# Create custom actions with their own api endpoints +# For example the below action will be available at /api/v1/actions/refresh_leaderboard_users +# A request to this url will execute the configured SQL query +# which in this case refreshes a materialized view in the database. +# The auth_name is from one of the configured auths +actions: + - name: refresh_leaderboard_users + sql: REFRESH MATERIALIZED VIEW CONCURRENTLY "leaderboard_users" + auth_name: from_taskqueue + tables: - name: customers remotes: @@ -137,6 +163,7 @@ tables: name: me table: users + roles_query: "SELECT * FROM users WHERE id = $user_id" roles: @@ -168,20 +195,16 @@ roles: query: limit: 50 filters: ["{ user_id: { eq: $user_id } }"] - columns: ["id", "name", "description" ] disable_functions: false insert: filters: ["{ user_id: { eq: $user_id } }"] - columns: ["id", "name", "description" ] presets: + - user_id: "$user_id" - created_at: "now" update: filters: ["{ user_id: { eq: $user_id } }"] - columns: - - id - - name presets: - updated_at: "now"