diff --git a/.gitignore b/.gitignore index 474488e4d645d28352bce17353bff11cce21e57f..fb6d4c2be0a3fb37b59a6e1c196deb5e65fa330d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ # Dependency directories (remove the comment below to include it) # vendor/ .idea +examples/core/api/api +examples/core/cron/cron +examples/core/sqs/sqs diff --git a/api/README.md b/api/README.md index 010c529cf90d9feaa49d2e37b4b2c878c83fdcc3..07d1cb94ef67f150d4d34462e3128f7226c15e83 100644 --- a/api/README.md +++ b/api/README.md @@ -1,25 +1,23 @@ # TO TEST - mage run and local run -- claims -- ctx values, read from handler and set in handler then retrieve later in handler +- claims *& impersonate # TODO -- sqs -- cron -- combined local service with api,sqs and cron +- crash dump recover does not log the call stack - impossible to debug +- sqs local & cloud +- cron local & cloud - use in v3 and shiplogic -- config for local running -- db connection - - -- when handler returns an error to indicate HTTP code, then it is wrapped and user gets: - -```{"message":"error from handler, because user not found HTTP(404:Not Found), because user does not exist"}``` instead of just: -```{"message":"user does not exist"}``` - +- config for local running - from cdk stuff... +- db connection in app + claims in app - api-docs not yet working here - and need to provide HTML option or per-endpoint options at least +- log as JSON when not running local +- remove log clutter from API but allow switch on/off or dump on error using log sync... +- log as JSON - see if crash dump etc are clearly logged as fields, not as part of a string +# Later - add path parameters, e.g. /user/{user_id} - make sure it works the same way as lambda does - but we're not using it at present, I assume because the old simple map[][] router did not support it. -- load config for local running - document with examples and templates -- scheduled tasks from the db or some other scheduler from AWS? \ No newline at end of file +- scheduled tasks from the db or some other scheduler from AWS? +- clone db locally then run with local db not to mess up dev for others + +- API test sequences configure & part of docs diff --git a/api/api.go b/api/api.go index 5fcf8ca2a6d8c390533fe8dcd143726efa6d9c30..63d3dbe9671802dc5d7599d881dfd2132d75f198 100644 --- a/api/api.go +++ b/api/api.go @@ -3,19 +3,18 @@ package api import ( "fmt" "net/http" - "os" - "regexp" + "runtime/debug" "sync" - "time" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "gitlab.com/uafrica/go-utils/audit" "gitlab.com/uafrica/go-utils/errors" "gitlab.com/uafrica/go-utils/logger" - "gitlab.com/uafrica/go-utils/queues" queues_mem "gitlab.com/uafrica/go-utils/queues/mem" queues_sqs "gitlab.com/uafrica/go-utils/queues/sqs" "gitlab.com/uafrica/go-utils/service" + "gitlab.com/uafrica/go-utils/string_utils" ) //LEGACY: global variable is set only for backward compatibility @@ -26,10 +25,6 @@ var CurrentRequestID *string //value could be any of the handler function signatures supported by the api.Router //requestIDHeaderKey is defined in the response header to match the requestID from the request func New(requestIDHeaderKey string, routes map[string]map[string]interface{}) Api { - env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod - if env == "" { - env = "dev" - } if requestIDHeaderKey == "" { requestIDHeaderKey = "request-id" } @@ -40,87 +35,85 @@ func New(requestIDHeaderKey string, routes map[string]map[string]interface{}) Ap } return Api{ - ILogger: logger.New().WithFields(map[string]interface{}{"env": env}), - env: env, - router: router, - requestIDHeaderKey: requestIDHeaderKey, - checks: []check{}, - crashReporter: defaultCrashReporter{}, - auditor: noAudit{}, + Service: service.New(), + router: router, + requestIDHeaderKey: requestIDHeaderKey, + checks: map[string]ICheck{}, + crashReporter: defaultCrashReporter{}, + cors: nil, + localPort: 0, + localQueueEventHandlers: nil, } } type Api struct { - logger.ILogger //for logging outside of context - env string - router Router - requestIDHeaderKey string - - //options: - localPort int //==0 for lambda, >0 for http.ListenAndServe - eventHandlers map[string]interface{} - crashReporter ICrashReporter - cors ICORS - producer queues.IProducer - dbConn service.IDatabaseConnector - checks []check - auditor IAuditor + service.Service + router Router + requestIDHeaderKey string + checks map[string]ICheck + crashReporter ICrashReporter + cors ICORS + localPort int //==0 for default lambda, >0 for http.ListenAndServe to run locally + localQueueEventHandlers map[string]interface{} //only applies when running locally for local in-memory queues } -//checks are executed at the start of each request -//to stop processing, return an error, preferably with an HTTP status code (see errors.HTTP(...)) -//all values returned will be logged and added to the context for retrieval by any handler -//the values are added with the check's name as prefix: "<check.name>_<value.name>" -//and cannot be changed afterwards -type IChecker interface { - Check(req events.APIGatewayProxyRequest) (values map[string]interface{}, err error) +//wrap Service.WithStarter to return api, else cannot be chained +func (api Api) WithStarter(name string, starter service.IStarter) Api { + api.Service = api.Service.WithStarter(name, starter) + return api } -type check struct { - name string - checker IChecker +//wrap Service.WithErrorReporter to return api, else cannot be chained +func (api Api) WithErrorReporter(reporter service.IErrorReporter) Api { + api.Service = api.Service.WithErrorReporter(reporter) + return api } -const namePattern = `[a-z]([a-z0-9_]*[a-z0-9])*` +//wrap else cannot be chained +func (api Api) WithAuditor(auditor audit.Auditor) Api { + api.Service = api.Service.WithAuditor(auditor) + return api +} -var nameRegex = regexp.MustCompile("^" + namePattern + "$") +//wrap else cannot be chained +func (api Api) WithProducer(producer service.Producer) Api { + api.Service = api.Service.WithProducer(producer) + return api +} -//check name must be lowercase with optional underscores, e.g. "rate_limiter" or "claims" -func (app Api) WithCheck(name string, checker IChecker) Api { - if !nameRegex.MatchString(name) { - panic(errors.Errorf("invalid name for check(%s)", name)) +//add a check to startup of each context +//they will be called in the sequence they were added +//if check return error, processing stops and err is returned +//if check succeed, and return !=nil data, it is stored against the name +// so your handler can retieve it with: +// checkData := ctx.Value(name).(expectedType) +// or +// checkData,ok := ctx.Value(name).(expectedType) +// if !ok { ... } +//you can implement one check that does everything and return a struct or +//implement one for your db, one for rate limit, one for auth, one for ... +//the name must be snake-case, e.g. "this_is_my_check_data_name" +func (api Api) WithCheck(name string, check ICheck) Api { + if !string_utils.IsSnakeCase(name) { + panic(errors.Errorf("invalid check name=\"%s\", expecting snake_case names only", name)) } - if checker == nil { - panic(errors.Errorf("check(%s) is nil", name)) + if check == nil { + panic(errors.Errorf("check(%s) func==nil", name)) } - for _, check := range app.checks { - if check.name == name { - panic(errors.Errorf("check(%s) already registered", name)) - } + if _, ok := api.checks[name]; ok { + panic(errors.Errorf("check(%s) already defined", name)) } - app.checks = append(app.checks, check{name: name, checker: checker}) - return app -} - -func (api Api) WithDb(dbConn service.IDatabaseConnector) Api { - api.dbConn = dbConn + api.checks[name] = check return api } func (api Api) WithCORS(cors ICORS) Api { - api.cors = cors - return api -} - -func (api Api) WithProducer(producer queues.IProducer) Api { - api.producer = producer + if cors != nil { + api.cors = cors + } return api } -type ICORS interface { - CORS() map[string]string //return CORS headers -} - func (api Api) WithCrashReported(crashReporter ICrashReporter) Api { if crashReporter != nil { api.crashReporter = crashReporter @@ -128,15 +121,14 @@ func (api Api) WithCrashReported(crashReporter ICrashReporter) Api { return api } -type ICrashReporter interface { - Catch(ctx Context) //Report(method string, path string, crash interface{}) -} - func (api Api) WithLocalPort(localPortPtr *int, eventHandlers map[string]interface{}) Api { + if api.localPort != 0 { + panic("local port already defined") + } if localPortPtr != nil && *localPortPtr > 0 { api.localPort = *localPortPtr + api.localQueueEventHandlers = eventHandlers } - api.eventHandlers = eventHandlers return api } @@ -146,20 +138,20 @@ func (api Api) Run() { if api.localPort > 0 { //running locally with standard HTTP server - if api.eventHandlers != nil { + if api.localQueueEventHandlers != nil { //when running locally - we want to send and process SQS events locally using channels //here we create a SQS chan and start listening to it //again: this is quick hack... will make this part of framework once it works well api.Debugf("Creating local queue consumer/producer...") - memConsumer := queues_mem.NewConsumer(api.eventHandlers) - api.producer = queues_mem.NewProducer(memConsumer) + memConsumer := queues_mem.NewConsumer(api.localQueueEventHandlers) + api = api.WithProducer(queues_mem.NewProducer(memConsumer)) sqsEventChan := make(chan events.SQSEvent) sqsWaitGroup := sync.WaitGroup{} sqsWaitGroup.Add(1) go func() { for event := range sqsEventChan { - logger.Debug("NOT YET PROCESSING SQS Event: %+v", event) + logger.Debugf("NOT YET PROCESSING SQS Event: %+v", event) } sqsWaitGroup.Done() }() @@ -171,32 +163,24 @@ func (api Api) Run() { }() } else { //use SQS for events - api.producer = queues_sqs.NewProducer(api.requestIDHeaderKey) + api = api.WithProducer(queues_sqs.NewProducer(api.requestIDHeaderKey)) } - err := http.ListenAndServe(fmt.Sprintf(":%d", api.localPort), api) //calls app.ServeHTTP() which calls app.Handler() + err := http.ListenAndServe(fmt.Sprintf(":%d", api.localPort), api) //calls api.ServeHTTP() which calls api.Handler() if err != nil { panic(err) } } else { - api.producer = queues_sqs.NewProducer(api.requestIDHeaderKey) - lambda.Start(api.Handler) //calls app.Handler directly + api = api.WithProducer(queues_sqs.NewProducer(api.requestIDHeaderKey)) + lambda.Start(api.Handler) //calls api.Handler directly } } type defaultCrashReporter struct{} func (defaultCrashReporter) Catch(ctx Context) { - // crash := recover() - // if crash != nil { - // ctx.Errorf("CRASH: (%T) %+v\n", crash, crash) - // } -} - -type IAuditor interface { - Audit(startTime, endTime time.Time, values map[string]interface{}) + crashErr := recover() + if crashErr != nil { + ctx.Errorf("crashed: %v, with stack: %s", crashErr, string(debug.Stack())) + } } - -type noAudit struct{} - -func (noAudit) Audit(startTime, endTime time.Time, values map[string]interface{}) {} //do nothing diff --git a/api/check.go b/api/check.go new file mode 100644 index 0000000000000000000000000000000000000000..b7be7f618670557360ab03ef3dd1cb685e61abad --- /dev/null +++ b/api/check.go @@ -0,0 +1,5 @@ +package api + +type ICheck interface { + Check(Context) (interface{}, error) +} diff --git a/api/context.go b/api/context.go index ec961f0e43ded2b0629d785900922002048f4370..f031af756b5adc7f8c1c68570ef72eacab4b165f 100644 --- a/api/context.go +++ b/api/context.go @@ -1,96 +1,61 @@ package api import ( - "context" "encoding/json" - "fmt" "reflect" - "strconv" "strings" - "time" "github.com/aws/aws-lambda-go/events" - "github.com/uptrace/bun" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/logger" - "gitlab.com/uafrica/go-utils/queues" + "gitlab.com/uafrica/go-utils/reflection" "gitlab.com/uafrica/go-utils/service" ) -type IContext interface { - context.Context - logger.ILogger - queues.IProducer - StartTime() time.Time - MillisecondsSinceStart() int64 - CheckValues(checkName string) interface{} - CheckValue(checkName, valueName string) interface{} -} - -type Context struct { +type Context interface { service.Context - queues.IProducer - Request events.APIGatewayProxyRequest - RequestID string - ValuesFromChecks map[string]map[string]interface{} //also in context.Value(), but cannot retrieve iteratively from there for logging... - DB *bun.DB + Request() events.APIGatewayProxyRequest } -func (ctx Context) CheckValues(checkName string) interface{} { - if cv, ok := ctx.ValuesFromChecks[checkName]; ok { - return cv - } - return nil -} +var contextInterfaceType = reflect.TypeOf((*Context)(nil)).Elem() -func (ctx Context) CheckValue(checkName, valueName string) interface{} { - if cv, ok := ctx.ValuesFromChecks[checkName]; ok { - if v, ok := cv[valueName]; ok { - return v - } - } - return nil +type apiContext struct { + service.Context + request events.APIGatewayProxyRequest } -// func (ctx Context) Audit(org, new interface{}, eventType types.AuditEventType) { -// //call old function for now - should become part of context ONLY -// audit.SaveAuditEvent(org, new, ctx.Claims, eventType, &ctx.RequestID) -// } +func (ctx apiContext) Request() events.APIGatewayProxyRequest { + return ctx.request +} //todo: change to be a ctx method that defer to log so it does not have to be called explicitly //it should also capture metrics for the handler and automaticlaly write the audit record, //(but still allow for audit to be suppressed may be in some cases) -func (ctx Context) LogAPIRequestAndResponse(res events.APIGatewayProxyResponse, err error) { +func (ctx *apiContext) LogAPIRequestAndResponse(res events.APIGatewayProxyResponse, err error) { fields := map[string]interface{}{ - "path": ctx.Request.Path, - "method": ctx.Request.HTTPMethod, + "path": ctx.request.Path, + "method": ctx.request.HTTPMethod, "status_code": res.StatusCode, - "api_gateway_request_id": ctx.RequestID, + "api_gateway_request_id": ctx.RequestID(), } - if ctx.Request.HTTPMethod == "GET" { - fields["req-query"] = ctx.Request.QueryStringParameters + if ctx.request.HTTPMethod == "GET" { + fields["req-query"] = ctx.request.QueryStringParameters } statusOK := res.StatusCode >= 200 && res.StatusCode <= 299 if err != nil || !statusOK { fields["error"] = err - fields["req-body"] = ctx.Request.Body - fields["req-query"] = ctx.Request.QueryStringParameters + fields["req-body"] = ctx.request.Body + fields["req-query"] = ctx.request.QueryStringParameters fields["res-body"] = res.Body - for checkName, checkValues := range ctx.ValuesFromChecks { - for name, value := range checkValues { - fields[checkName+"_"+name] = value - } - } } - ctx.WithFields(fields).Infof("Request & Response: err=%+v", err) + ctx.Context.WithFields(fields).Infof("Request & Response: err=%+v", err) } //allocate struct for params, populate it from the URL parameters then validate and return the struct -func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, error) { +func (ctx apiContext) GetRequestParams(paramsStructType reflect.Type) (interface{}, error) { paramValues := map[string]interface{}{} - for n, v := range ctx.Request.QueryStringParameters { + for n, v := range ctx.request.QueryStringParameters { paramValues[n] = v } paramsStructValuePtr := reflect.New(paramsStructType) @@ -106,10 +71,10 @@ func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, //get value(s) from query string var paramStrValues []string - if paramStrValue, isDefined := ctx.Request.QueryStringParameters[n]; isDefined { + if paramStrValue, isDefined := ctx.request.QueryStringParameters[n]; isDefined { paramStrValues = []string{paramStrValue} //single value } else { - paramStrValues = ctx.Request.MultiValueQueryStringParameters[n] + paramStrValues = ctx.request.MultiValueQueryStringParameters[n] } if len(paramStrValues) == 0 { continue //param has no value specified in URL @@ -120,11 +85,8 @@ func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, //iterate over all specified values for index, paramStrValue := range paramStrValues { newValuePtr := reflect.New(f.Type.Elem()) - if err := setParamFromStr(fmt.Sprintf("%s[%d]", n, index), - //paramsStructValuePtr.Elem().Field(i).Index(index), - newValuePtr.Elem(), //.Elem() to dereference - paramStrValue); err != nil { - return nil, errors.Wrapf(err, "failed to set %s[%d]=%s", n, i, paramStrValues[0]) + if err := reflection.SetValue(newValuePtr.Elem(), paramStrValue); err != nil { + return nil, errors.Wrapf(err, "failed to set %s[%d]=%s", n, index, paramStrValues[0]) } paramsStructValuePtr.Elem().Field(i).Set(reflect.Append(paramsStructValuePtr.Elem().Field(i), newValuePtr.Elem())) } @@ -133,12 +95,16 @@ func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, return nil, errors.Errorf("%s does not support >1 values(%v)", n, strings.Join(paramStrValues, ",")) } //single value specified - if err := setParamFromStr(n, paramsStructValuePtr.Elem().Field(i), paramStrValues[0]); err != nil { + if err := reflection.SetValue(paramsStructValuePtr.Elem().Field(i), paramStrValues[0]); err != nil { return nil, errors.Wrapf(err, "failed to set %s=%s", n, paramStrValues[0]) } } } //for each param struct field + if err := ctx.applyClaim("params", paramsStructValuePtr.Interface()); err != nil { + return nil, errors.Wrapf(err, "failed to fill claims on params") + } + if validator, ok := paramsStructValuePtr.Interface().(IValidator); ok { if err := validator.Validate(); err != nil { return nil, errors.Wrapf(err, "invalid params") @@ -148,83 +114,16 @@ func (ctx Context) GetRequestParams(paramsStructType reflect.Type) (interface{}, return paramsStructValuePtr.Elem().Interface(), nil } -func setParamFromStr(fieldName string, fieldValue reflect.Value, paramStrValue string) error { - logger.Debugf("Set(%s,%v,%v,%v)", fieldName, fieldValue.Type(), fieldValue.Kind(), paramStrValue) - switch fieldValue.Type().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - //parse to int for this struct field - if i64, err := strconv.ParseInt(paramStrValue, 10, 64); err != nil { - return errors.Errorf("%s is not a number", paramStrValue) - } else { - switch fieldValue.Type().Kind() { - case reflect.Int: - fieldValue.Set(reflect.ValueOf(int(i64))) - case reflect.Int8: - fieldValue.Set(reflect.ValueOf(int8(i64))) - case reflect.Int16: - fieldValue.Set(reflect.ValueOf(int16(i64))) - case reflect.Int32: - fieldValue.Set(reflect.ValueOf(int32(i64))) - case reflect.Int64: - fieldValue.Set(reflect.ValueOf(i64)) - } - } - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - //parse to int for this struct field - if u64, err := strconv.ParseUint(paramStrValue, 10, 64); err != nil { - return errors.Errorf("%s is not a number", paramStrValue) - } else { - switch fieldValue.Type().Kind() { - case reflect.Uint: - fieldValue.Set(reflect.ValueOf(uint(u64))) - case reflect.Uint8: - fieldValue.Set(reflect.ValueOf(uint8(u64))) - case reflect.Uint16: - fieldValue.Set(reflect.ValueOf(uint16(u64))) - case reflect.Uint32: - fieldValue.Set(reflect.ValueOf(uint32(u64))) - case reflect.Uint64: - fieldValue.Set(reflect.ValueOf(u64)) - } - } - - case reflect.Bool: - bs := strings.ToLower(paramStrValue) - if bs == "true" || bs == "yes" || bs == "1" { - fieldValue.Set(reflect.ValueOf(true)) - } - - case reflect.String: - fieldValue.Set(reflect.ValueOf(paramStrValue)) - - case reflect.Float32: - if f32, err := strconv.ParseFloat(paramStrValue, 32); err != nil { - return errors.Wrapf(err, "invalid float") - } else { - fieldValue.Set(reflect.ValueOf(float32(f32))) - } - - case reflect.Float64: - if f64, err := strconv.ParseFloat(paramStrValue, 64); err != nil { - return errors.Wrapf(err, "invalid float") - } else { - fieldValue.Set(reflect.ValueOf(f64)) - } - - default: - return errors.Errorf("unsupported type %v", fieldValue.Type().Kind()) - } //switch param struct field - return nil -} - -func (ctx Context) GetRequestBody(requestStructType reflect.Type) (interface{}, error) { +func (ctx apiContext) GetRequestBody(requestStructType reflect.Type) (interface{}, error) { requestStructValuePtr := reflect.New(requestStructType) - err := json.Unmarshal([]byte(ctx.Request.Body), requestStructValuePtr.Interface()) + err := json.Unmarshal([]byte(ctx.request.Body), requestStructValuePtr.Interface()) if err != nil { return nil, errors.Wrapf(err, "failed to JSON request body") } + if err := ctx.applyClaim("body", requestStructValuePtr.Interface()); err != nil { + return nil, errors.Wrapf(err, "failed to fill claims on body") + } if validator, ok := requestStructValuePtr.Interface().(IValidator); ok { if err := validator.Validate(); err != nil { return nil, errors.Wrapf(err, "invalid request body") @@ -237,3 +136,52 @@ func (ctx Context) GetRequestBody(requestStructType reflect.Type) (interface{}, type IValidator interface { Validate() error } + +func (ctx *apiContext) applyClaim(name string, valuePtr interface{}) error { + t := reflect.TypeOf(valuePtr) + if t.Kind() != reflect.Ptr { + return errors.Errorf("%T is not a pointer", valuePtr) //programming error... it must be a pointer to be able to change it + } + t = t.Elem() + if t.Kind() != reflect.Struct { + //ctx.Debugf("Not setting claims on %T", valuePtr) + return nil //not a struct - nothing to do - is allowed e.g. for posting a string. + } + if err := ctx.setClaim(name, t, reflect.ValueOf(valuePtr).Elem()); err != nil { + return errors.Wrapf(err, "failed to set claim on %T", valuePtr) + } + return nil +} + +func (ctx *apiContext) setClaim(name string, structType reflect.Type, structValue reflect.Value) error { + for fieldName, claimValue := range ctx.Claim() { + if field := structValue.FieldByName(fieldName); field.IsValid() { + if err := reflection.SetValue(field, claimValue); err != nil { + return errors.Errorf("failed to set %s.%s=(%T)%v", structType.Name(), fieldName, claimValue, claimValue) + } + ctx.Debugf("defined claim %s.%s=(%T)%v ...", name, fieldName, claimValue, claimValue) + } /* else { + ctx.Debugf("claim(%s) does not apply to %s", fieldName, structType.Name()) + }*/ + } + + //recurse into sub-structs and sub struct ptrs (not yet slices) + for i := 0; i < structType.NumField(); i++ { + f := structType.Field(i) + if len(f.Name) > 0 && f.Name[0] >= 'a' && f.Name[0] <= 'z' { + //private field - do not enter + continue + } + if f.Type.Kind() == reflect.Struct { + if err := ctx.setClaim(name+"."+structType.Field(i).Name, f.Type, structValue.Field(i)); err != nil { + return errors.Wrapf(err, "failed to set claim on sub struct %s.%s", structType.Name(), f.Name) + } + } + if f.Type.Kind() == reflect.Ptr && f.Type.Elem().Kind() == reflect.Struct && !structValue.Field(i).IsNil() { + if err := ctx.setClaim(name+"."+structType.Field(i).Name, f.Type.Elem(), structValue.Field(i).Elem()); err != nil { + return errors.Wrapf(err, "failed to set claim on sub &struct %s.%s", structType.Name(), f.Name) + } + } + } + return nil +} diff --git a/api/cors.go b/api/cors.go new file mode 100644 index 0000000000000000000000000000000000000000..4d36770c56c41ccb1f309904ca2803c338ad9065 --- /dev/null +++ b/api/cors.go @@ -0,0 +1,5 @@ +package api + +type ICORS interface { + CORS() map[string]string //return CORS headers +} diff --git a/api/crash.go b/api/crash.go new file mode 100644 index 0000000000000000000000000000000000000000..bf686eac384417f9bf0eae14a7d6e805abb81125 --- /dev/null +++ b/api/crash.go @@ -0,0 +1,5 @@ +package api + +type ICrashReporter interface { + Catch(ctx Context) //Report(method string, path string, crash interface{}) +} diff --git a/api/handler.go b/api/handler.go index 8e4e785684117135fe33f1a6b3c980d4b21f7365..3aa1cebe7317eba0b6775a4e3e536499bfd62236 100644 --- a/api/handler.go +++ b/api/handler.go @@ -24,9 +24,12 @@ func NewHandler(fnc interface{}) (handler, error) { return h, errors.Errorf("returns %d results instead of ([Response,] error)", fncType.NumOut()) } - //arg[0] must implement interface lambda_helpers.Context - if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok { - return h, errors.Errorf("first arg %v does not implement api.IContext", fncType.In(0)) + //arg[0] must implement interface api.Context + //if _, ok := reflect.New(fncType.In(0)).Interface().(Context); !ok { + + if fncType.In(0) != contextInterfaceType && + !fncType.In(0).Implements(contextInterfaceType) { + return h, errors.Errorf("first arg %v does not implement %v", fncType.In(0), contextInterfaceType) } //arg[1] must be a struct for params. It may be an empty struct, but diff --git a/api/lambda.go b/api/lambda.go index 5f4836ebf9e6f1376a2a52256cdfdf11fda8fed9..f4fe02762a5585b87441dcddd8aa0be9f9a2cb56 100644 --- a/api/lambda.go +++ b/api/lambda.go @@ -2,6 +2,7 @@ package api import ( "context" + "database/sql" "encoding/json" "fmt" "math/rand" @@ -12,7 +13,7 @@ import ( "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambdacontext" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/service" + "gitlab.com/uafrica/go-utils/logger" ) //this is native handler for lambda passed into lambda.Start() @@ -30,24 +31,30 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat apiGatewayProxyReq.Resource = apiGatewayProxyReq.Path } - //setup context - requestID := apiGatewayProxyReq.RequestContext.RequestID - if lambdaContext, ok := lambdacontext.FromContext(baseCtx); ok && lambdaContext != nil { - requestID = lambdaContext.AwsRequestID + //get request-id from HTTP headers (used when making internal service calls) + //if not defined in header, get the AWS request id from the AWS context + requestID, ok := apiGatewayProxyReq.Headers[api.requestIDHeaderKey] + if !ok || requestID == "" { + if lambdaContext, ok := lambdacontext.FromContext(baseCtx); ok && lambdaContext != nil { + requestID = lambdaContext.AwsRequestID + } } - ctx := Context{ - Context: service.NewContext(baseCtx, map[string]interface{}{ - "env": api.env, - "request_id": requestID, - }), - IProducer: api.producer, - Request: apiGatewayProxyReq, - RequestID: requestID, - ValuesFromChecks: map[string]map[string]interface{}{}, + + //service context invoke the starters and could fail, e.g. if cannot connect to db + serviceContext, err := api.Service.NewContext(baseCtx, requestID, nil) + if err != nil { + return res, err + } + + ctx := &apiContext{ + Context: serviceContext, + request: apiGatewayProxyReq, } //report handler crashes - defer api.crashReporter.Catch(ctx) + if api.crashReporter != nil { + defer api.crashReporter.Catch(ctx) + } defer func() { //set CORS headers on every response @@ -61,48 +68,38 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat defer func() { ctx.LogAPIRequestAndResponse(res, err) if err != nil { + ctx.Errorf("failed: %+v", err) + + //try to retrieve HTTP code from error if withCause, ok := err.(errors.ErrorWithCause); ok && withCause.Code() != 0 { res.StatusCode = withCause.Code() + err = withCause.Cause() //drop the http layers + up + } else { + //no HTTP code indicate in err, + //see if there are SQL errors that could indicate code + if code, ok := StatusCodeFromSQLError(err); ok { + res.StatusCode = code + } } + errorMessage := fmt.Sprintf("%c", err) jsonError, _ := json.Marshal(map[string]interface{}{"message": errorMessage}) + res.Headers["Content-Type"] = "application/json" res.Body = string(jsonError) + api.Service.ReportError(ctx.Data(), err) err = nil //never pass error back to lambda or http server } if api.requestIDHeaderKey != "" { - res.Headers[api.requestIDHeaderKey] = ctx.RequestID + res.Headers[api.requestIDHeaderKey] = ctx.RequestID() } - api.auditor.Audit(ctx.StartTime(), time.Now(), map[string]interface{}{ - "request_id": ctx.RequestID, - "request": ctx.Request, + if err := api.Service.WriteValues(ctx.StartTime(), time.Now(), ctx.RequestID(), map[string]interface{}{ + "request_id": ctx.RequestID(), + "request": ctx.request, "response": res}, - ) - }() - - if api.dbConn != nil { - ctx.DB, err = api.dbConn.Connect() - if err != nil { - err = errors.Wrapf(err, "failed to connect to db") - return - } - } - - //do checks before proceed (may use db connection) - //(typical maintenance mode, rate limits, authentication/claims, ...) - for _, check := range api.checks { - var checkValues map[string]interface{} - checkValues, err = check.checker.Check(apiGatewayProxyReq) - //for n, v := range checkValues { - //ctx.Context = ctx.Context.WithValue(check.name+"_"+n, v) - //ctx.Debugf("Defined value[%s]=(%T)%v", check.name+"_"+n, v, v) - //} - ctx.ValuesFromChecks[check.name] = checkValues - ctx.Debugf("Defined ValuesFromChecks[%s]=(%T)%v", check.name, checkValues, checkValues) - if err != nil { - err = errors.Wrapf(err, "check(%s) failed", check.name) - return + ); err != nil { + ctx.Errorf("failed to audit: %+v", err) } - } + }() //Early return OPTIONS call if apiGatewayProxyReq.HTTPMethod == "OPTIONS" { @@ -113,17 +110,30 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat rand.Seed(time.Now().Unix()) + for checkName, check := range api.checks { + var checkData interface{} + checkData, err = check.Check(ctx) + if err != nil { + err = errors.Wrapf(err, "%s", checkName) + return + } + if err = ctx.Set(checkName, checkData); err != nil { + err = errors.Wrapf(err, "failed to set check(%s) data=(%T)%+v", checkName, checkData, checkData) + return + } + } + //LEGACY: delete this as soon as all handlers accepts context //this does not support concurrent execution! - CurrentRequestID = &ctx.Request.RequestContext.RequestID + CurrentRequestID = &ctx.request.RequestContext.RequestID ctx.Debugf("HTTP %s %s ...\n", apiGatewayProxyReq.HTTPMethod, apiGatewayProxyReq.Resource) ctx.WithFields(map[string]interface{}{ - "http_method": ctx.Request.HTTPMethod, - "path": ctx.Request.Path, - "api_gateway_request_id": ctx.Request.RequestContext.RequestID, - "user_cognito_auth_provider": ctx.Request.RequestContext.Identity.CognitoAuthenticationProvider, - "user_arn": ctx.Request.RequestContext.Identity.UserArn, + "http_method": ctx.request.HTTPMethod, + "path": ctx.request.Path, + "api_gateway_request_id": ctx.request.RequestContext.RequestID, + "user_cognito_auth_provider": ctx.request.RequestContext.Identity.CognitoAuthenticationProvider, + "user_arn": ctx.request.RequestContext.Identity.UserArn, }).Infof("Start API Handler") //TODO: @@ -181,7 +191,7 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat return } //apply claims to params struct - TODO: Removed - see if cannot force to get claims from context always - // if err = ctx.Claims.FillOnObject(ctx.Request, ¶msStruct); err != nil { + // if err = ctx.Claims.FillOnObject(ctx.request, ¶msStruct); err != nil { // err = errors.HTTP(http.StatusInternalServerError, err, "claims failed on parameters") // return // } @@ -202,7 +212,7 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat } //apply claims to request struct - TODO: Removed - see if cannot force to get claims from context always - // if err = ctx.Claims.FillOnObject(ctx.Request, &bodyStruct); err != nil { + // if err = ctx.Claims.FillOnObject(ctx.request, &bodyStruct); err != nil { // err = errors.HTTP(http.StatusInternalServerError, err, "claims failed on body") // return // } @@ -212,12 +222,13 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat } //call handler in a func with defer to catch potential crash - ctx.Debugf("calling handler") + ctx.Infof("Calling handle %s %s ...", apiGatewayProxyReq.HTTPMethod, apiGatewayProxyReq.Resource) var results []reflect.Value results, err = func() (results []reflect.Value, err error) { defer func() { - if crashDump := recover(); crashDump != nil { - err = errors.Errorf("handler function crashed: %+v", crashDump) + if crashErr := recover(); crashErr != nil { + stack := logger.CallStack() + err = errors.Errorf("handler function crashed: %v, with stack: %+v", crashErr, stack) return } }() @@ -229,14 +240,12 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat return } - ctx.Debugf("handler -> results: %v", results) - + //ctx.Debugf("handler -> results: %v", results) //see if handler failed using last result of type error lastResultValue := results[len(results)-1] if !lastResultValue.IsNil() { err = lastResultValue.Interface().(error) if err != nil { - err = errors.Wrapf(err, "error from handler") return } } @@ -255,7 +264,22 @@ func (api Api) Handler(baseCtx context.Context, apiGatewayProxyReq events.APIGat err = errors.Wrapf(err, "failed to encode response content") return } + res.Headers["Content-Type"] = "application/json" res.Body = string(bodyBytes) } return } + +//look for SQL errors in the error stack +func StatusCodeFromSQLError(err error) (int, bool) { + if err == sql.ErrNoRows { + return http.StatusNotFound, true + } + + if errWithCause, ok := err.(errors.ErrorWithCause); ok { + return StatusCodeFromSQLError(errWithCause.Cause()) + } + + //could not determine known SQL error + return 0, false +} diff --git a/api/local.go b/api/local.go index fb4b14293d35cdf90bf868b288c74b2a0da1373c..0aa903a5b342883e7fe57fcf2ccfd2c22370df13 100644 --- a/api/local.go +++ b/api/local.go @@ -13,6 +13,7 @@ import ( //use this in http.ListenAndServe() to test locally func (api Api) ServeHTTP(httpRes http.ResponseWriter, httpReq *http.Request) { + api.Debugf("HTTP %s %s", httpReq.Method, httpReq.URL.Path) ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() @@ -88,12 +89,14 @@ func (api Api) ServeHTTP(httpRes http.ResponseWriter, httpReq *http.Request) { httpRes.Header().Set(n, v) } if res.StatusCode < 200 || res.StatusCode >= 300 { - http.Error(httpRes, res.Body, res.StatusCode) + httpRes.Header().Set("X-Content-Type-Options", "nosniff") + httpRes.Header().Set("Content-Type", "application/json") + httpRes.WriteHeader(res.StatusCode) + httpRes.Write([]byte(res.Body)) //assuming this is a JSON error message return } if res.Body != "" { - httpRes.Header().Set("Content-Type", "application/json") httpRes.Write([]byte(res.Body)) } } diff --git a/audit/audit.go b/audit/audit.go new file mode 100644 index 0000000000000000000000000000000000000000..1a7f3710bd5f0a7039931947ef83060f1805bbca --- /dev/null +++ b/audit/audit.go @@ -0,0 +1,146 @@ +package audit + +import "time" + +type Auditor interface { + WriteValues(startTime, endTime time.Time, requestID string, values map[string]interface{}) error + WriteEvent(requestID string, event Event) error +} + +// func getAuthUsername(identity events.APIGatewayRequestIdentity) string { +// if identity.CognitoAuthenticationProvider != "" { +// split := strings.Split(identity.CognitoAuthenticationProvider, ":") +// return split[len(split)-1] +// } + +// // IAM +// split := strings.Split(identity.UserArn, ":user/") +// return split[len(split)-1] +// } + +// func SaveAPIRequest(req events.APIGatewayProxyRequest, response events.APIGatewayProxyResponse, startTime time.Time, currentRequestID *string) { +// claim, err := claims.RetrieveClaims(&req) +// authType := determineAuthType(req.RequestContext.Identity) +// authUsername := getAuthUsername(req.RequestContext.Identity) + +// responseBody := response.Body +// responseSize := int64(len(responseBody)) +// // SQS has a 256KB limit, so let's limit the response to 96KB +// if len(responseBody) > 12000 { +// responseBody = responseBody[:12000] + "..." +// } + +// var relevantID *string +// mappedResponse := map[string]interface{}{} +// err = json.Unmarshal([]byte(req.Body), &mappedResponse) + +// if err == nil { +// val, present := mappedResponse["id"] +// if present { +// valString := utils.Int64ToString(int64(val.(float64))) +// relevantID = &valString +// } +// } + +// accountID := fmt.Sprintf("%d", *claim.AccountID) +// apiLog := types.ApiLog{ +// AccountID: &accountID, +// UserID: claim.UserID, +// Path: req.Path, +// HTTPMethod: req.HTTPMethod, +// Timestamp: time.Now(), +// Request: map[string]interface{}{ +// "Body": req.Body, +// "QueryStringParameters": req.QueryStringParameters, +// }, +// Response: map[string]interface{}{ +// "Body": responseBody, +// }, +// ResponseCode: response.StatusCode, +// ExecutionTime: time.Now().Sub(startTime).Milliseconds(), +// RequestID: currentRequestID, +// RelevantID: relevantID, +// InitialAuthUsername: &authUsername, +// InitialAuthType: authType, +// IP: &req.RequestContext.Identity.SourceIP, +// UserAgent: &req.RequestContext.Identity.UserAgent, +// ResponseSize: responseSize, +// } + +// data, err := json.Marshal(apiLog) +// if err != nil { +// logs.LogErrorMessage("Failed to encode audit event", err) +// return +// } + +// _ = sqs.ApiLogEvent( +// map[string]string{}, +// string(data), +// currentRequestID, +// ) +// } + +// func SaveThirdPartyAPIRequest(url string, method string, requestBody string, responseBody string, responseStatus int, startTime time.Time, currentRequestID *string) { + +// responseSize := int64(len(responseBody)) +// // SQS has a 256KB limit, so let's limit the response to 96KB +// if len(responseBody) > 12000 { +// responseBody = responseBody[:12000] + "..." +// } + +// var relevantID *string +// mappedResponse := map[string]interface{}{} +// err := json.Unmarshal([]byte(requestBody), &mappedResponse) + +// if err == nil { +// val, present := mappedResponse["id"] +// if present { +// valString := utils.Int64ToString(int64(val.(float64))) +// relevantID = &valString +// } +// } + +// apiLog := types.ApiLog{ +// AccountID: nil, +// UserID: nil, +// Path: url, +// HTTPMethod: method, +// Timestamp: time.Now(), +// Request: map[string]interface{}{ +// "Body": requestBody, +// }, +// Response: map[string]interface{}{ +// "Body": responseBody, +// }, +// ResponseCode: responseStatus, +// ExecutionTime: time.Now().Sub(startTime).Milliseconds(), +// RequestID: currentRequestID, +// RelevantID: relevantID, +// InitialAuthUsername: nil, +// InitialAuthType: nil, +// IP: nil, +// UserAgent: nil, +// ResponseSize: responseSize, +// } + +// data, err := json.Marshal(apiLog) +// if err != nil { +// logs.LogErrorMessage("Failed to encode audit event", err) +// return +// } + +// _ = sqs.ApiLogEvent( +// map[string]string{}, +// string(data), +// currentRequestID, +// ) +// } + +// func determineAuthType(identity events.APIGatewayRequestIdentity) *string { +// result := "cognito" +// if identity.CognitoAuthenticationType == "" { +// result = "iam" +// } + +// return &result +// } diff --git a/audit/event.go b/audit/event.go new file mode 100644 index 0000000000000000000000000000000000000000..dc61743472d209b2f5d99e39027e908a7198d0e7 --- /dev/null +++ b/audit/event.go @@ -0,0 +1,177 @@ +package audit + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/r3labs/diff/v2" + "gitlab.com/uafrica/go-utils/reflection" +) + +type Event struct { + ID int64 `json:"id"` + ObjectID string `json:"object_id"` + Type string `json:"type"` + Source string `json:"source"` + Timestamp time.Time `json:"timestamp"` + Change map[string]interface{} `json:"change"` +} + +//parameters: +// source could be "" then defaults to "SYSTEM" or specify the user name that made the change +// orgValue and newValue could be nil +// they are compared and changes are logged +func NewEvent(source string, eventType string, orgValue, newValue interface{}) (Event, error) { + changelog, err := diff.Diff(orgValue, newValue) + if err != nil { + return Event{}, err + } + + changes := map[string]interface{}{} + for _, change := range changelog { + if len(change.Path) == 1 { + // Root object change + field := ToSnakeCase(change.Path[0]) + changes[field] = FieldChange{ + From: change.From, + To: change.To, + } + } else if len(change.Path) == 2 { + // Child object changed + // ["Account", "ID"] + // 0 = Object + // 1 = field + + objectKey := ToSnakeCase(change.Path[0]) + field := ToSnakeCase(change.Path[1]) + + existingObject, present := changes[objectKey] + if present { + if object, ok := existingObject.(map[string]interface{}); ok { + object[field] = FieldChange{ + From: change.From, + To: change.To, + } + changes[objectKey] = object + } + } else { + fieldChange := map[string]interface{}{ + field: FieldChange{ + From: change.From, + To: change.To, + }, + } + changes[objectKey] = fieldChange + } + + } else if len(change.Path) == 3 { + objectKey := ToSnakeCase(change.Path[0]) + indexString := change.Path[1] + index, _ := strconv.ParseInt(indexString, 10, 64) + field := ToSnakeCase(change.Path[2]) + arrayObject, present := changes[objectKey] + if present { + if arrayOfObjects, ok := arrayObject.([]map[string]interface{}); ok { + if len(arrayOfObjects) > int(index) { + // Add field to existing object in array + object := arrayOfObjects[index] + object[field] = FieldChange{ + From: change.From, + To: change.To, + } + } else { + // new object, append to existing array + fieldChange := map[string]interface{}{ + field: FieldChange{ + From: change.From, + To: change.To, + }, + } + changes[objectKey] = append(arrayOfObjects, fieldChange) + } + + } + } else { + // Create array of objects + fieldChange := map[string]interface{}{ + field: FieldChange{ + From: change.From, + To: change.To, + }, + } + changes[objectKey] = []map[string]interface{}{ + fieldChange, + } + } + } + } + + objectID := getIntValue(orgValue, "ID") + if objectID == 0 { + objectID = getIntValue(newValue, "ID") + } + + objectIDString := fmt.Sprintf("%v", objectID) + if objectIDString == "0" { + objectIDString = getStringValue(orgValue, "Username") + } + if objectIDString == "" { + objectIDString = getStringValue(newValue, "Username") + } + if objectIDString == "" { + objectIDString = getStringValue(orgValue, "Key") + } + if objectIDString == "" { + objectIDString = getStringValue(newValue, "Key") + } + + event := Event{ + ObjectID: objectIDString, + Source: source, + Type: eventType, + Timestamp: time.Now(), + Change: changes, + } + if event.Source == "" { + event.Source = "SYSTEM" + } + return event, nil +} + +type FieldChange struct { + From interface{} `json:"change_from"` + To interface{} `json:"change_to"` +} + +var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") +var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") + +func ToSnakeCase(str string) string { + snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") + snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") + return strings.ToLower(snake) +} + +func getIntValue(object interface{}, key string) int64 { + structValue := reflect.ValueOf(object) + if structValue.Kind() == reflect.Struct { + field := structValue.FieldByName(key) + id := reflection.GetInt64Value(field) + return id + } + return 0 +} + +func getStringValue(object interface{}, key string) string { + structValue := reflect.ValueOf(object) + if structValue.Kind() == reflect.Struct { + field := structValue.FieldByName(key) + id := reflection.GetStringValue(field) + return id + } + return "" +} diff --git a/audit/file-audit.go b/audit/file-audit.go new file mode 100644 index 0000000000000000000000000000000000000000..176851dbc2a1864e6fba8e6416f50c2d3a38b8f9 --- /dev/null +++ b/audit/file-audit.go @@ -0,0 +1,59 @@ +package audit + +import ( + "encoding/json" + "os" + "time" + + "gitlab.com/uafrica/go-utils/errors" +) + +//creates auditor that writes to file, which could be os.Stderr or os.Stdout for debugging +func File(f *os.File) Auditor { + if f == nil { + panic(errors.Errorf("cannot create file auditor with f=nil")) + } + return fileAudit{ + f: f, + } +} + +type fileAudit struct { + f *os.File +} + +func (fa fileAudit) WriteValues(startTime, endTime time.Time, requestID string, values map[string]interface{}) error { + obj := map[string]interface{}{ + "start_time": startTime, + "end_time": endTime, + "duration": endTime.Sub(startTime), + "request_id": requestID, + "values": values, + } + jsonObj, err := json.Marshal(obj) + if err != nil { + return errors.Wrapf(err, "failed to JSON encode audit values") + } + if _, err := fa.f.Write(jsonObj); err != nil { + return errors.Wrapf(err, "failed to write audit values to file") + } + return nil +} + +func (fa fileAudit) WriteEvent(requestID string, event Event) error { + obj := map[string]interface{}{ + "start_time": event.Timestamp, + "end_time": event.Timestamp, + "duration": 0, + "request_id": requestID, + "values": event, + } + jsonObj, err := json.Marshal(obj) + if err != nil { + return errors.Wrapf(err, "failed to JSON encode audit event") + } + if _, err := fa.f.Write(jsonObj); err != nil { + return errors.Wrapf(err, "failed to write audit event to file") + } + return nil +} diff --git a/audit/no-audit.go b/audit/no-audit.go new file mode 100644 index 0000000000000000000000000000000000000000..930f4134f7c4f2a820bea8ca64e6982bd90d4656 --- /dev/null +++ b/audit/no-audit.go @@ -0,0 +1,20 @@ +package audit + +import ( + "time" +) + +//creates auditor that writes nothiong +func None() Auditor { + return noAudit{} +} + +type noAudit struct{} + +func (noAudit) WriteValues(startTime, endTime time.Time, requestID string, values map[string]interface{}) error { + return nil +} + +func (noAudit) WriteEvent(requestID string, event Event) error { + return nil +} diff --git a/cron/check.go b/cron/check.go new file mode 100644 index 0000000000000000000000000000000000000000..71a3e5317bc451b47c889a26da23c79fb7c51705 --- /dev/null +++ b/cron/check.go @@ -0,0 +1,5 @@ +package cron + +type ICheck interface { + Check(Context) (interface{}, error) +} diff --git a/cron/context.go b/cron/context.go index 734dbcd220e9593e53c9ff63c100146a1bf360d3..7903e70e8c29690d5d2a3f97a94f852f806af6c4 100644 --- a/cron/context.go +++ b/cron/context.go @@ -2,22 +2,34 @@ package cron import ( "context" - "time" + "reflect" - "github.com/uptrace/bun" - "gitlab.com/uafrica/go-utils/logger" + "gitlab.com/uafrica/go-utils/errors" "gitlab.com/uafrica/go-utils/service" ) -type IContext interface { - context.Context - logger.ILogger - StartTime() time.Time - MillisecondsSinceStart() int64 +type Context interface { + service.Context } -type Context struct { +var contextInterfaceType = reflect.TypeOf((*Context)(nil)).Elem() + +type cronContext struct { service.Context - Name string //cron function name - DB *bun.DB + name string //cron function name +} + +func (cron Cron) NewContext(baseCtx context.Context, requestID string, cronName string) (Context, error) { + serviceContext, err := cron.Service.NewContext(baseCtx, requestID, map[string]interface{}{ + "cron": cronName, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create service context") + } + + ctx := cronContext{ + Context: serviceContext, + name: cronName, + } + return ctx, nil } diff --git a/cron/cron.go b/cron/cron.go index 0683c14646278dd8aaafad68bc2a693363fb65c5..abcfe25e33f2177c91a4be8d116f5d8e520277d3 100644 --- a/cron/cron.go +++ b/cron/cron.go @@ -8,9 +8,11 @@ import ( "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-lambda-go/lambdacontext" "github.com/google/uuid" + "gitlab.com/uafrica/go-utils/audit" "gitlab.com/uafrica/go-utils/errors" "gitlab.com/uafrica/go-utils/logger" "gitlab.com/uafrica/go-utils/service" + "gitlab.com/uafrica/go-utils/string_utils" ) func New(functions map[string]func(Context) error) Cron { @@ -25,22 +27,69 @@ func New(functions map[string]func(Context) error) Cron { } return Cron{ - ILogger: logger.New().WithFields(map[string]interface{}{"env": env}), + Service: service.New(), + Logger: logger.New().WithFields(map[string]interface{}{"env": env}), env: env, router: router, + checks: map[string]ICheck{}, crashReporter: defaultCrashReporter{}, } } type Cron struct { - logger.ILogger + service.Service + logger.Logger env string router Router - dbConn service.IDatabaseConnector + checks map[string]ICheck crashReporter ICrashReporter } -func (cron Cron) WithCrashReported(crashReporter ICrashReporter) Cron { +//wrap Service.WithStarter to return cron, else cannot be chained +func (cron Cron) WithStarter(name string, starter service.IStarter) Cron { + cron.Service = cron.Service.WithStarter(name, starter) + return cron +} + +//wrap Service.WithErrorReporter to return api, else cannot be chained +func (cron Cron) WithErrorReporter(reporter service.IErrorReporter) Cron { + cron.Service = cron.Service.WithErrorReporter(reporter) + return cron +} + +//wrap else cannot be chained +func (cron Cron) WithAuditor(auditor audit.Auditor) Cron { + cron.Service = cron.Service.WithAuditor(auditor) + return cron +} + +//add a check to startup of each context +//they will be called in the sequence they were added +//if check return error, processing stops and err is returned +//if check succeed, and return !=nil data, it is stored against the name +// so your handler can retieve it with: +// checkData := ctx.Value(name).(expectedType) +// or +// checkData,ok := ctx.Value(name).(expectedType) +// if !ok { ... } +//you can implement one check that does everything and return a struct or +//implement one for your db, one for rate limit, one for auth, one for ... +//the name must be snake-case, e.g. "this_is_my_check_data_name" +func (cron Cron) WithCheck(name string, check ICheck) Cron { + if !string_utils.IsSnakeCase(name) { + panic(errors.Errorf("invalid check name=\"%s\", expecting snake_case names only", name)) + } + if check == nil { + panic(errors.Errorf("check(%s) func==nil", name)) + } + if _, ok := cron.checks[name]; ok { + panic(errors.Errorf("check(%s) already defined", name)) + } + cron.checks[name] = check + return cron +} + +func (cron Cron) WithCrashReporter(crashReporter ICrashReporter) Cron { if crashReporter != nil { cron.crashReporter = crashReporter } @@ -60,11 +109,6 @@ func (defaultCrashReporter) Catch(ctx Context) { // } } -func (cron Cron) WithDb(dbConn service.IDatabaseConnector) Cron { - cron.dbConn = dbConn - return cron -} - func (cron Cron) Run(invokeArn *string) { if invokeArn != nil && *invokeArn != "" { //just run this handler and terminate - for testing on a terminal @@ -81,9 +125,10 @@ func (cron Cron) Run(invokeArn *string) { ) err := cron.Handler(lambdaContext) if err != nil { - panic(errors.Errorf("cron failed: %+v", err)) + logger.Errorf("local cron handler failed: %+v", err) + //cron.Service.ReportError(nil, err) } else { - logger.Debugf("cron success") + logger.Debugf("local cron success") } return } diff --git a/cron/handler.go b/cron/handler.go index dd3772bbfc261435d79ca0d97437b2d7f47d3939..956e14cb3d20992cfcacac4b34dc1f58a1e7b108 100644 --- a/cron/handler.go +++ b/cron/handler.go @@ -22,9 +22,10 @@ func NewHandler(fnc interface{}) (Handler, error) { return h, errors.Errorf("returns %d results instead of (error)", fncType.NumOut()) } - //arg[0] must implement interface sqs.IContext - if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok { - return h, errors.Errorf("first arg %v does not implement sqs.IContext", fncType.In(0)) + //arg[0] must implement interface queues.Context + if fncType.In(0) != contextInterfaceType && + !fncType.In(0).Implements(contextInterfaceType) { + return h, errors.Errorf("first arg %v does not implement %v", fncType.In(0), contextInterfaceType) } //arg[1] must be a struct for the message record body. It may be an empty struct, but diff --git a/cron/lambda.go b/cron/lambda.go index ea2fb6fd3ca09ffa74bc989b6f7b2d37e4b136a8..3835f08975f4a565c116942b80ef179897ab828f 100644 --- a/cron/lambda.go +++ b/cron/lambda.go @@ -7,7 +7,6 @@ import ( "github.com/aws/aws-lambda-go/lambdacontext" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/service" ) type LambdaCronHandler func(lambdaCtx context.Context) error @@ -24,13 +23,10 @@ func (cron Cron) Handler(lambdaCtx context.Context) (err error) { //got a handler, prepare to run: rand.Seed(time.Now().Unix()) - ctx := Context{ - Context: service.NewContext(lambdaCtx, map[string]interface{}{ - "env": cron.env, - "request-id": requestID, - "cron": cronName, - }), - Name: cronName, + //service context invoke the starters and could fail, e.g. if cannot connect to db + ctx, err := cron.NewContext(lambdaCtx, requestID, cronName) + if err != nil { + return err } defer func() { @@ -39,21 +35,29 @@ func (cron Cron) Handler(lambdaCtx context.Context) (err error) { } }() - if cron.dbConn != nil { - ctx.DB, err = cron.dbConn.Connect() + //report handler crashes + if cron.crashReporter != nil { + defer cron.crashReporter.Catch(ctx) + } + + for checkName, check := range cron.checks { + var checkData interface{} + checkData, err = check.Check(ctx) if err != nil { - err = errors.Wrapf(err, "failed to connect to db") + err = errors.Wrapf(err, "%s", checkName) + return + } + if err = ctx.Set(checkName, checkData); err != nil { + err = errors.Wrapf(err, "failed to set check(%s) data=(%T)%+v", checkName, checkData, checkData) return } } - //report handler crashes - defer cron.crashReporter.Catch(ctx) - //todo: set log level, trigger log on conditions, sync at end of transaction - after log level was determined ctx.Infof("Start CRON Handler") if err := cronFunc(ctx); err != nil { + cron.Service.ReportError(ctx.Data(), err) return errors.Wrapf(err, "Cron(%s) failed", cronName) } return nil diff --git a/errors/caller.go b/errors/caller.go index 3e29bf3d9d2ed1d3e7a8d25d91f42aee9b18d8ed..f3ef9cac480b9f232770e3c50bdba3b4a69204f8 100644 --- a/errors/caller.go +++ b/errors/caller.go @@ -23,6 +23,14 @@ type Caller struct { pkgDotFunc string } +func NewCaller(pkgDotFunc string, file string, line int) Caller { + return Caller{ + pkgDotFunc: pkgDotFunc, + file: file, + line: line, + } +} + func (c Caller) Info() CallerInfo { return CallerInfo{ Package: c.Package(), diff --git a/examples/core/api/main.go b/examples/core/api/main.go new file mode 100644 index 0000000000000000000000000000000000000000..88d14e452c3a5ff3fb472d92daa8f3386bdec922 --- /dev/null +++ b/examples/core/api/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "math/rand" + "net/http" + "os" + + "gitlab.com/uafrica/go-utils/api" + "gitlab.com/uafrica/go-utils/audit" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/examples/core/app" + "gitlab.com/uafrica/go-utils/examples/core/db" + "gitlab.com/uafrica/go-utils/logger" +) + +func main() { + logger.SetGlobalLevel(logger.LevelDebug) + logger.SetGlobalFormat(logger.NewConsole()) + + localPort := flag.Int("port", 0, "Run with local HTTP server in this port (default: Run as lambda)") + flag.Parse() + + api.New("uafrica-request-id", app.ApiRoutes()). + WithStarter("db", db.Connector("core")). + WithCheck("claims", claimsChecker{}). + WithCheck("maintenance", maint{}). + WithCheck("rate", rateLimiter{}). + WithCORS(cors{}). + WithAuditor(audit.File(os.Stdout)). + WithLocalPort(localPort, app.QueueRoutes()). //if nil will still run as lambda + Run() +} + +type claimsChecker struct{} + +type Claims struct { + AccountID int64 + UserID int64 +} + +func (claimsChecker) Check(ctx api.Context) (interface{}, error) { + //then extract auth claim and check against the db ... + claims := Claims{ + UserID: 1, + AccountID: 13, + } + + //set it in the API context (can be retrieved with api.Context.ClaimGet()) + ctx.ClaimSet("AccountID", claims.AccountID) + ctx.ClaimSet("UserID", claims.UserID) + + //return the struct (can be retrieved with service.Context.Get()/Value()) + return claims, nil +} + +type maint struct{} + +//for maintenance mode, put a message in environment variable MAINTENANCE_MODE +//then than message will be displayed in the response. Clear the variable to +//proceed normal operation +func (m maint) Check(ctx api.Context) (interface{}, error) { + msg := os.Getenv("MAINTENANCE_MODE") + if msg != "" { + return nil, errors.HTTP(http.StatusTeapot, errors.Errorf("maintenance mode"), "maintenance mode") + } + return nil, nil //not maint mode +} + +type rateLimiter struct{} + +func (r rateLimiter) Check(ctx api.Context) (interface{}, error) { + if rand.Intn(10) < 2 { + return nil, errors.Errorf("rate limited") + } + return nil, nil //not limited +} + +type cors struct{} + +func (cors) CORS() map[string]string { + return map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, Accept", + } +} diff --git a/examples/core/app/app.go b/examples/core/app/app.go new file mode 100644 index 0000000000000000000000000000000000000000..a1de7c506ed58014c2269c5ff20aa7e29502b3be --- /dev/null +++ b/examples/core/app/app.go @@ -0,0 +1,33 @@ +package app + +import ( + "gitlab.com/uafrica/go-utils/cron" + "gitlab.com/uafrica/go-utils/examples/core/app/users" + "gitlab.com/uafrica/go-utils/examples/core/email" +) + +func ApiRoutes() map[string]map[string]interface{} { + return map[string]map[string]interface{}{ + "/users": { + "GET": users.Get, + "POST": users.Add, + "PUT": users.Upd, + "DELETE": users.Del, + }, + "/crash": { + "GET": users.Crash, + }, + } +} + +func QueueRoutes() map[string]interface{} { + return map[string]interface{}{ + "email": email.Notify, + } +} + +func CronRoutes() map[string]func(cron.Context) error { + return map[string]func(cron.Context) error{ + "janitor": users.Janitor, + } +} diff --git a/examples/core/app/users/users.go b/examples/core/app/users/users.go new file mode 100644 index 0000000000000000000000000000000000000000..93e094a2aaf803f1c3809d6bb00b17d747458fc6 --- /dev/null +++ b/examples/core/app/users/users.go @@ -0,0 +1,157 @@ +package users + +import ( + "fmt" + "net/http" + "sync" + "time" + + "gitlab.com/uafrica/go-utils/api" + "gitlab.com/uafrica/go-utils/cron" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/examples/core/email" + "gitlab.com/uafrica/go-utils/logger" + "gitlab.com/uafrica/go-utils/service" +) + +type User struct { + ID int `json:"id"` + AccountID int `json:"account_id"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` +} + +func (u User) Validate() error { + logger.Debugf("Validating (%T)%+v", u, u) + if u.ID != 0 { + return errors.Errorf("id may not be specified for new user") + } + if u.AccountID == 0 { + return errors.Errorf("missing account_id (from claims)") + } + if u.Email == "" { + return errors.Errorf("missing email") + } + if u.Username == "" { + return errors.Errorf("missing username") + } + if u.Password == "" { + return errors.Errorf("missing password") + } + return nil +} + +//a simple in memory list of users for this example +var ( + usersMutex sync.Mutex + users = map[int]User{} //index on user.ID + nextUserID = 1 +) + +type getParams struct { + ID int `json:"id"` +} + +func (p getParams) Validate() error { + if p.ID <= 0 { + return errors.Errorf("id>0 is required") + } + return nil +} + +func Get(ctx api.Context, params getParams) (User, error) { + db := ctx.Value("db").(int) + ctx.Debugf("DB = %v", db) + ctx.Debugf("Claim: %+v", ctx.Claim()) + ctx.Debugf("Params: %+v", params) + usersMutex.Lock() + defer usersMutex.Unlock() + if user, ok := users[params.ID]; ok { + return user, nil + } + return User{}, errors.HTTP(http.StatusNotFound, errors.Errorf("user.id=%d not found", params.ID), "") +} + +type POSTUser struct { + User + U1 User `json:"u1"` + U2 *User `json:"u2"` +} + +func Add(ctx api.Context, params noParams, newUser POSTUser) (User, error) { + db := ctx.Value("db").(int) + ctx.Debugf("DB = %v", db) + ctx.Debugf("Claim: %+v", ctx.Claim()) + + usersMutex.Lock() + defer usersMutex.Unlock() + + //u1 and u2 not used - just to demonstrate how claims are populated in sub struct and sub struct ptrs + ctx.Debugf("u1: %+v", newUser.U1) + if newUser.U2 != nil { + ctx.Debugf("u2: %+v", newUser.U2) + } + + //make sure user is unique for this account + for _, u := range users { + if u.AccountID == newUser.AccountID && u.Username == newUser.Username { + return User{}, errors.HTTP(http.StatusBadRequest, errors.Errorf("username \"%s\" already exists in this account", newUser.Username), "") + } + } + + newUser.ID = nextUserID + nextUserID++ + users[newUser.ID] = newUser.User + + //send notification by email + email := email.Message{ + To: []string{newUser.Email}, + CC: nil, + BCC: nil, + From: "example@uafrica.com", + Subject: "Welcome User", + Body: "Your account has been created", + } + /*eventID*/ _, err := service.NewEvent(ctx, "notify").RequestID(ctx.RequestID()).Type("email").Delay(time.Second * 5).Params(map[string]string{}).Send(email) + if err != nil { + ctx.Errorf("failed to notify: %+v", err) + } + //ctx.Debugf("Notified: %v", eventID) + return newUser.User, nil +} + +func Upd(ctx api.Context, params noParams, updUser User) (User, error) { + usersMutex.Lock() + defer usersMutex.Unlock() + existingUser, ok := users[updUser.ID] + if !ok { + return User{}, errors.HTTP(http.StatusNotFound, errors.Errorf("user.id=%d not found", updUser.ID), "") + } + if existingUser.Username != updUser.Username { + return User{}, errors.HTTP(http.StatusBadRequest, errors.Errorf("user.id=%d username=%s may not change", existingUser.ID, existingUser.Username), "") + } + users[updUser.ID] = updUser + return updUser, nil +} + +func Del(ctx api.Context, params getParams) error { + usersMutex.Lock() + defer usersMutex.Unlock() + delete(users, params.ID) + return nil +} + +type noParams struct{} + +func Crash(ctx api.Context, params noParams) error { + var d *int + fmt.Printf("%d", *d) //this should crash - causing the crash dumper to trigger + return nil +} + +func Janitor(ctx cron.Context) error { + db := ctx.Value("db").(int) + ctx.Debugf("DB = %v", db) + return errors.Errorf("NYI") +} diff --git a/examples/core/cron/main.go b/examples/core/cron/main.go new file mode 100644 index 0000000000000000000000000000000000000000..f4f400ffcb1c19be1e908276c0ec8d373502942b --- /dev/null +++ b/examples/core/cron/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "flag" + "fmt" + "math/rand" + "net/http" + "os" + "time" + + "gitlab.com/uafrica/go-utils/api" + "gitlab.com/uafrica/go-utils/cron" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/examples/core/app" + "gitlab.com/uafrica/go-utils/examples/core/db" + "gitlab.com/uafrica/go-utils/logger" +) + +func main() { + invokeArnPtr := flag.String("arn", "", "Invoke this ARN and terminate (default is to run as lambda)") + flag.Parse() + + logger.SetGlobalLevel(logger.LevelDebug) + logger.SetGlobalFormat(logger.NewConsole()) + + cron.New(app.CronRoutes()). + WithStarter("db", db.Connector("core")). + //WithAuditor(audit{}). + Run(invokeArnPtr) +} + +type maint struct{} + +//for maintenance mode, put a message in environment variable MAINTENANCE_MODE +//then than message will be displayed in the response. Clear the variable to +//proceed normal operation +func (m maint) Check(ctx api.Context) (interface{}, error) { + msg := os.Getenv("MAINTENANCE_MODE") + if msg != "" { + return nil, errors.HTTP(http.StatusTeapot, errors.Errorf("maintenance mode"), "maintenance mode") + } + return nil, nil //not maint mode +} + +type rateLimiter struct{} + +func (r rateLimiter) Check(ctx api.Context) (interface{}, error) { + if rand.Intn(10) < 2 { + return nil, errors.Errorf("rate limited") + } + return nil, nil //not limited +} + +type cors struct{} + +func (cors) CORS() map[string]string { + return map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, Accept", + } +} + +type audit struct{} + +func (a audit) Audit(startTime, endTime time.Time, values map[string]interface{}) { + fmt.Printf("AUDIT: %v %v %v ...\n", startTime, endTime, endTime.Sub(startTime)) +} diff --git a/examples/core/db/database.go b/examples/core/db/database.go new file mode 100644 index 0000000000000000000000000000000000000000..8021c42f85802f829e1640840ef425c057abcfe7 --- /dev/null +++ b/examples/core/db/database.go @@ -0,0 +1,38 @@ +package db + +import ( + "math/rand" + + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/service" +) + +func Connector(dbName string) service.IStarter { + return &connector{ + name: dbName, + conn: 0, + } +} + +//connector implements service.IStarter +type connector struct { + name string + conn int +} + +//Start returns app specific data on success that can be retrieved by handlers +func (c *connector) Start(ctx service.Context) (interface{}, error) { + //make reusable db connection + if c.conn < 2 { + c.conn = rand.Intn(10) + if c.conn < 2 { + return nil, errors.Errorf("failed to connect to db (just a random fake event - try again :-))") + } + ctx.Debugf("Connected to db(%s): %d", c.name, c.conn) + } + + //return app data - here we only return db conn, + //which can be retrieved from ctx.Value("db"), + //because dbConnector{} is registered as "db" + return c.conn, nil +} diff --git a/examples/core/email/notify.go b/examples/core/email/notify.go new file mode 100644 index 0000000000000000000000000000000000000000..206f2be9ee540314b853eeb68843298a2233467b --- /dev/null +++ b/examples/core/email/notify.go @@ -0,0 +1,19 @@ +package email + +import ( + "gitlab.com/uafrica/go-utils/queues" +) + +type Message struct { + From string + To []string + CC []string + BCC []string + Subject string + Body string +} + +func Notify(ctx queues.Context, msg Message) error { + ctx.Debugf("Pretending to send email: %+v", msg) + return nil +} diff --git a/examples/core/sqs/main.go b/examples/core/sqs/main.go new file mode 100644 index 0000000000000000000000000000000000000000..7a019a7cf89b1d58996f51440fdd44cfa659c49f --- /dev/null +++ b/examples/core/sqs/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + + "gitlab.com/uafrica/go-utils/config" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/examples/core/db" + "gitlab.com/uafrica/go-utils/logger" + "gitlab.com/uafrica/go-utils/queues/sqs" +) + +func main() { + reqFile := flag.String("req", "", "Request file to process for testing.") + flag.Parse() + + sqsRoutes := map[string]interface{}{} + + consumer := sqs.NewConsumer("uafrica-request-id", sqsRoutes). + WithStarter("db", db.Connector("core")) + + if reqFile != nil && *reqFile != "" { + if err := config.LoadLocal(); err != nil { + panic(errors.Errorf("Failed to load local config: %+v", err)) + } + + if err := consumer.ProcessFile(*reqFile); err != nil { + panic(errors.Errorf("processing failed: %+v", err)) + } + logger.Debugf("Stop after processing event from file.") + return + } + + consumer.Run() +} diff --git a/go.mod b/go.mod index e90b1e04ffa5949a3f40256c3df0f0053af4e0d0..4aae0d6879bbc18bcfa36f2d46f2ff900fd1a730 100644 --- a/go.mod +++ b/go.mod @@ -2,36 +2,39 @@ module gitlab.com/uafrica/go-utils go 1.17 -require ( - github.com/go-pg/pg/v10 v10.10.5 - github.com/pkg/errors v0.9.1 - github.com/thoas/go-funk v0.9.1 - golang.org/x/text v0.3.6 -) - require ( github.com/aws/aws-lambda-go v1.26.0 + github.com/aws/aws-sdk-go v1.40.50 + github.com/aws/aws-secretsmanager-caching-go v1.1.0 + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.13.0 + github.com/go-pg/pg/v10 v10.10.5 github.com/go-pg/zerochecker v0.2.0 // indirect + github.com/go-redis/redis/v8 v8.11.3 github.com/google/uuid v1.3.0 github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pkg/errors v0.9.1 + github.com/thoas/go-funk v0.9.1 github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect - github.com/uptrace/bun v1.0.8 github.com/vmihailenco/bufpool v0.1.11 // indirect github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect github.com/vmihailenco/tagparser v0.1.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect + golang.org/x/text v0.3.7 mellium.im/sasl v0.2.1 // indirect ) require ( - github.com/aws/aws-sdk-go v1.40.50 // indirect - github.com/cespare/xxhash/v2 v2.1.1 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fatih/color v1.13.0 // indirect - github.com/go-redis/redis/v8 v8.11.3 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/mattn/go-colorable v0.1.9 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/r3labs/diff/v2 v2.14.0 + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect + google.golang.org/appengine v1.6.6 // indirect + google.golang.org/protobuf v1.26.0 // indirect ) diff --git a/go.sum b/go.sum index 732bbf4426935a37d6b94894adb28668d189f9e1..7deed11eb9a7a373cf2531b8ffc04d044f9e48a0 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,11 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/aws/aws-lambda-go v1.26.0 h1:6ujqBpYF7tdZcBvPIccs98SpeGfrt/UOVEiexfNIdHA= github.com/aws/aws-lambda-go v1.26.0/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XOkbv4w9SGLU= +github.com/aws/aws-sdk-go v1.19.23/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.40.50 h1:QP4NC9EZWBszbNo2UbG6bbObMtN35kCFb4h0r08q884= github.com/aws/aws-sdk-go v1.40.50/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-secretsmanager-caching-go v1.1.0 h1:vcV94XGJ9KouXKYBTMqgrBw96Tae8JKLmoUZ5SbaXNo= +github.com/aws/aws-secretsmanager-caching-go v1.1.0/go.mod h1:wahQpJP1dZKMqjGFAjGCqilHkTlN0zReGWocPLbXmxg= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -32,6 +35,7 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -42,6 +46,7 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -55,8 +60,10 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -69,17 +76,16 @@ github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M= github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/onsi/gomega v1.15.0 h1:WjP/FQ/sk43MRmnEcT+MlDw2TFvkrXlprrPST/IudjU= github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0= @@ -88,6 +94,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/r3labs/diff/v2 v2.14.0 h1:VRI8lhKFP4miM+RlyKkdoT94u7RlFge2S+WAqDScV2Q= +github.com/r3labs/diff/v2 v2.14.0/go.mod h1:I8noH9Fc2fjSaMxqF3G2lhDdC0b+JXCfyx85tWFM9kc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -100,12 +108,11 @@ github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= -github.com/uptrace/bun v1.0.8 h1:uWrBnun83dfNGP//xdLAdGA3UGISQtpdSzjZHUNmZh0= -github.com/uptrace/bun v1.0.8/go.mod h1:aL6D9vPw8DXaTQTwGrEPtUderBYXx7ShUmPfnxnqscw= github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= -github.com/vmihailenco/msgpack/v5 v5.3.1 h1:0i85a4dsZh8mC//wmyyTEzidDLPQfQAxZIOLtafGbFY= +github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v5 v5.3.1/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc= github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= @@ -131,13 +138,14 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -159,18 +167,16 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -180,11 +186,12 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= @@ -201,6 +208,7 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -212,11 +220,11 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/logger/context.go b/logger/context.go index 9f709dd5dec4f850d6834e3fb3d8a8a6376155e3..9e5195975b6b8afe3e507222853bec3d057a2d17 100644 --- a/logger/context.go +++ b/logger/context.go @@ -5,88 +5,40 @@ import ( ) func GetContextLogger() Logger { - return logger + return globalLogger } -// func ClearInfo() { -// InitLogs(nil, nil) -// } - -// func InitLogs(requestID *string, request *events.APIGatewayProxyRequest) { -// logger = Logger{ -// currentRequestID: requestID, -// apiRequest: request, -// level: LevelInfo, -// writer: os.Stderr, -// data: map[string]interface{}{ -// "environment": getEnvironment(), -// }, -// } -// if requestID != nil { -// logger.data["request_id"] = *requestID -// } - -// if val, exists := os.LookupEnv("DEBUGGING"); exists && strings.ToLower(val) == "true" { -// logger.level = LevelDebug -// } -// } - -// func getEnvironment() string { -// environment := os.Getenv("ENVIRONMENT") -// if environment == "" { -// environment = "prod" -// } -// return environment -// } - func LogMessageWithFields(fields map[string]interface{}, message interface{}) { - logger.WithFields(fields).log(LevelInfo, 1, fmt.Sprintf("%v", message)) + globalLogger.WithFields(fields).log(LevelInfo, 1, fmt.Sprintf("%v", message)) } func LogMessage(format string, a ...interface{}) { - logger.log(LevelInfo, 1, fmt.Sprintf(format, a...)) + globalLogger.log(LevelInfo, 1, fmt.Sprintf(format, a...)) } func LogError(fields map[string]interface{}, err error) { // sendRaygunError(fields, err) - logger.WithFields(fields).log(LevelError, 1, fmt.Sprintf("%+v", err)) + globalLogger.WithFields(fields).log(LevelError, 1, fmt.Sprintf("%+v", err)) } func LogErrorMessage(message interface{}, err error) { if err != nil || message != nil { - logger.WithFields(map[string]interface{}{ + globalLogger.WithFields(map[string]interface{}{ "error": fmt.Sprintf("%+v", err), }).log(LevelError, 1, fmt.Sprintf("%v", message)) } } func LogWarningMessage(format string, a ...interface{}) { - logger.log(LevelWarn, 1, fmt.Sprintf(format, a...)) + globalLogger.log(LevelWarn, 1, fmt.Sprintf(format, a...)) } func LogWarning(fields map[string]interface{}, err error) { - logger.WithFields(fields).log(LevelWarn, 1, fmt.Sprintf("%+v", err)) + globalLogger.WithFields(fields).log(LevelWarn, 1, fmt.Sprintf("%+v", err)) } func SQLDebugInfo(sql string) { - logger.WithFields(map[string]interface{}{ + globalLogger.WithFields(map[string]interface{}{ "sql": sql, }).log(LevelInfo, 1, "SQL") } - -// func LogRequestInfo(req events.APIGatewayProxyRequest) { -// fields := map[string]interface{}{ -// "http_method": req.HTTPMethod, -// "path": req.Path, -// "api_gateway_request_id": req.RequestContext.RequestID, -// "user_cognito_auth_provider": req.RequestContext.Identity.CognitoAuthenticationProvider, -// "user_arn": req.RequestContext.Identity.UserArn, -// } -// logger.WithFields(fields).log(LevelInfo, 1, "Request Info start") -// } - -// func LogSQSEvent(event events.SQSEvent) { -// logger.WithFields(map[string]interface{}{ -// "records": event.Records, -// }).log(LevelInfo, 1, "SQS event start") -// } diff --git a/logger/format.go b/logger/format.go new file mode 100644 index 0000000000000000000000000000000000000000..e960f8117f20e7c624abf337fe724504fa7ec252 --- /dev/null +++ b/logger/format.go @@ -0,0 +1,161 @@ +package logger + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/fatih/color" +) + +var nextFormatID = 1 + +type IFormatter interface { + Format(Entry) []byte + NextColor() IFormatter + Color() string +} + +func NewJSON() IFormatter { + return formatterJSON{} +} + +type formatterJSON struct{} + +func (f formatterJSON) Format(entry Entry) []byte { + jsonEntry, err := json.Marshal(entry) + if err != nil { + return []byte(fmt.Sprintf("failed to marshal entry: %v: %+v\n", err, entry)) + } + return append(jsonEntry, []byte("\n")...) +} + +func (f formatterJSON) NextColor() IFormatter { + return f //do not select colors for JSON (only used in console) +} + +func (f formatterJSON) Color() string { + return "default" +} + +func NewConsole() IFormatter { + nextFormatID++ + return formatterConsole{ + id: nextFormatID, + fg: 1, //color.FgWhite, + bg: 0, //color.BgBlack, + } +} + +type formatterConsole struct { + id int + fg int //color.Attribute + bg int //color.Attribute +} + +func (f formatterConsole) Format(entry Entry) []byte { + source := fmt.Sprintf("%s/%s:%d", entry.Caller.Package, entry.Caller.File, entry.Caller.Line) + if len(source) > 40 { + source = source[len(source)-40:] + } + + buffer := bytes.NewBuffer(nil) + + red := color.New(color.FgRed).FprintfFunc() + magenta := color.New(color.FgMagenta).FprintfFunc() + yellow := color.New(color.FgYellow).FprintfFunc() + //blue := color.New(color.FgBlue).FprintfFunc() + green := color.New(color.FgGreen).FprintfFunc() + cyan := color.New(color.FgCyan).FprintfFunc() + + cyan(buffer, entry.Timestamp.Format("2006-01-02 15:04:05")) + + levelString := fmt.Sprintf(" %5.5s", entry.Level) + switch entry.Level { + case LevelFatal: + red(buffer, levelString) + case LevelError: + red(buffer, levelString) + case LevelWarn: + magenta(buffer, levelString) + case LevelInfo: + yellow(buffer, levelString) + case LevelDebug: + green(buffer, levelString) + } + cyan(buffer, fmt.Sprintf(" %-40.40s| ", source)) + + base := color.New(fgColors[colorNames[f.fg]], bgColors[colorNames[f.bg]]).FprintfFunc() + base(buffer, entry.Message) //buffer.Write([]byte(entry.Message)) + + if len(entry.Data) > 0 { + jsonData, _ := json.Marshal(entry.Data) + green(buffer, " "+string(jsonData)) + } + buffer.WriteString("\n") + return buffer.Bytes() +} + +func (f formatterConsole) WithForeground(fg color.Attribute) IFormatter { + // f.fg = fg + return f +} + +func (f formatterConsole) WithBackground(bg color.Attribute) IFormatter { + // f.bg = bg + return f +} + +func (f formatterConsole) Color() string { + return colorNames[f.fg] + " on " + colorNames[f.bg] +} + +var ( + colorNames = []string{"black", "white", "red", "green", "yellow", "blue", "magenta", "cyan"} + fgColors = map[string]color.Attribute{ + "black": color.FgBlack, + "white": color.FgWhite, + "red": color.FgRed, + "green": color.FgGreen, + "yellow": color.FgYellow, + "blue": color.FgBlue, + "magenta": color.FgMagenta, + "cyan": color.FgCyan, + } + bgColors = map[string]color.Attribute{ + "black": color.BgBlack, + "white": color.BgWhite, + "red": color.BgRed, + "green": color.BgGreen, + "yellow": color.BgYellow, + "blue": color.BgBlue, + "magenta": color.BgMagenta, + "cyan": color.BgCyan, + } + nextFg = 1 + nextBg = 0 +) + +func (f formatterConsole) NextColor() IFormatter { + for { + nextFg++ + if nextFg >= len(fgColors) { + nextFg = 0 + // nextBg++ + // if nextBg >= len(bgColors) { + // nextBg++ + // } + } + if nextFg != nextBg { + break + } + } + nextFormatID++ + f = formatterConsole{ + id: nextFormatID, + fg: nextFg, + bg: nextBg, + } + //globalLogger.Debugf("NextColor return id=%d Color %s on %s", f.id, colorNames[f.fg], colorNames[f.bg]) + return f +} diff --git a/logger/global.go b/logger/global.go index f65afc41deb7a35be4195ce740d381874fc64b99..c04253667b5c9dff7402c2d2a5e853fce8cd5478 100644 --- a/logger/global.go +++ b/logger/global.go @@ -4,69 +4,75 @@ import ( "fmt" "os" - "github.com/fatih/color" - "gitlab.com/uafrica/go-utils/errors" ) -var logger Logger +var globalLogger logger func init() { - logger = Logger{ - level: LevelDebug, - writer: os.Stderr, - data: map[string]interface{}{}, - fg: color.FgWhite, - bg: color.BgBlack, + globalLogger = logger{ + level: LevelInfo, //default for production + writer: os.Stderr, + data: map[string]interface{}{}, + IFormatter: formatterJSON{}, //default to JSON format for production + } +} + +func SetGlobalLevel(level Level) { + globalLogger.level = level +} + +func SetGlobalFormat(f IFormatter) { + if f != nil { + globalLogger.IFormatter = f } - // InitLogs(nil, nil) } func New() Logger { - return logger.WithFields(nil) + return globalLogger.WithFields(nil) } //shortcut functions to use current logger //this should only be used outside of a request context //or anywhere if you have a single threaded process func Fatalf(format string, args ...interface{}) { - logger.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprintf(format, args...)) + globalLogger.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprintf(format, args...)) os.Exit(1) } func Fatal(args ...interface{}) { - logger.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprint(args...)) + globalLogger.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprint(args...)) os.Exit(1) } func Errorf(format string, args ...interface{}) { - logger.log(LevelError, 1, fmt.Sprintf(format, args...)) + globalLogger.log(LevelError, 1, fmt.Sprintf(format, args...)) } func Error(args ...interface{}) { - logger.log(LevelError, 1, fmt.Sprint(args...)) + globalLogger.log(LevelError, 1, fmt.Sprint(args...)) } func Warnf(format string, args ...interface{}) { - logger.log(LevelWarn, 1, fmt.Sprintf(format, args...)) + globalLogger.log(LevelWarn, 1, fmt.Sprintf(format, args...)) } func Warn(args ...interface{}) { - logger.log(LevelWarn, 1, fmt.Sprint(args...)) + globalLogger.log(LevelWarn, 1, fmt.Sprint(args...)) } func Infof(format string, args ...interface{}) { - logger.log(LevelInfo, 1, fmt.Sprintf(format, args...)) + globalLogger.log(LevelInfo, 1, fmt.Sprintf(format, args...)) } func Info(args ...interface{}) { - logger.log(LevelInfo, 1, fmt.Sprint(args...)) + globalLogger.log(LevelInfo, 1, fmt.Sprint(args...)) } func Debugf(format string, args ...interface{}) { - logger.log(LevelDebug, 1, fmt.Sprintf(format, args...)) + globalLogger.log(LevelDebug, 1, fmt.Sprintf(format, args...)) } func Debug(args ...interface{}) { - logger.log(LevelDebug, 1, fmt.Sprint(args...)) + globalLogger.log(LevelDebug, 1, fmt.Sprint(args...)) } diff --git a/logger/logger.go b/logger/logger.go index 8347fc56399c102740390e07446c965415e998fc..736846d0ec5117c1f6090c2bf3b2061c87d5a49f 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,18 +1,15 @@ package logger import ( - "bytes" - "encoding/json" "fmt" "io" "strings" "time" - "github.com/fatih/color" "gitlab.com/uafrica/go-utils/errors" ) -type ILogger interface { +type Logger interface { Fatalf(format string, args ...interface{}) Fatal(args ...interface{}) Errorf(format string, args ...interface{}) @@ -23,88 +20,23 @@ type ILogger interface { Info(args ...interface{}) Debugf(format string, args ...interface{}) Debug(args ...interface{}) + + WithFields(data map[string]interface{}) logger } -type Logger struct { - //apiRequest *events.APIGatewayProxyRequest - //currentRequestID *string +type logger struct { level Level writer io.Writer data map[string]interface{} - - fg color.Attribute - bg color.Attribute -} - -func (l Logger) WithForeground(fg color.Attribute) Logger { - l.fg = fg - return l -} - -func (l Logger) WithBackground(bg color.Attribute) Logger { - l.bg = bg - return l -} - -type textColor struct { - fg color.Attribute - bg color.Attribute -} - -var ( - fgColors = []color.Attribute{ - color.FgWhite, - color.FgRed, - color.FgGreen, - color.FgYellow, - color.FgBlue, - color.FgMagenta, - color.FgCyan, - color.FgBlack, - } - bgColors = []color.Attribute{ - color.BgBlack, - color.BgWhite, - color.BgRed, - color.BgGreen, - color.BgYellow, - color.BgBlue, - color.BgMagenta, - color.BgCyan, - } - nextFg = 0 - nextBg = 0 -) - -func (l Logger) NextColor() Logger { - l.fg = 0 - l.bg = 0 - for l.fg == l.bg { - l.bg = bgColors[nextBg] - l.fg = fgColors[nextFg] - incColor() - } - return l + IFormatter } -func incColor() { - nextFg++ - if nextFg >= len(fgColors) { - nextFg = 0 - nextBg++ - if nextBg >= len(bgColors) { - nextBg++ - } - } -} - -func (l Logger) WithFields(data map[string]interface{}) Logger { - newLogger := Logger{ - level: l.level, - writer: l.writer, - data: map[string]interface{}{}, - fg: l.fg, - bg: l.bg, +func (l logger) WithFields(data map[string]interface{}) logger { + newLogger := logger{ + level: l.level, + writer: l.writer, + data: map[string]interface{}{}, + IFormatter: l.IFormatter, } for n, v := range l.data { newLogger.data[n] = v @@ -115,47 +47,47 @@ func (l Logger) WithFields(data map[string]interface{}) Logger { return newLogger } -func (l Logger) Fatalf(format string, args ...interface{}) { +func (l logger) Fatalf(format string, args ...interface{}) { l.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprintf(format, args...)) } -func (l Logger) Fatal(args ...interface{}) { +func (l logger) Fatal(args ...interface{}) { l.WithFields(map[string]interface{}{"call_stack": errors.Stack(3)}).log(LevelFatal, 1, fmt.Sprint(args...)) } -func (l Logger) Errorf(format string, args ...interface{}) { +func (l logger) Errorf(format string, args ...interface{}) { l.log(LevelError, 1, fmt.Sprintf(format, args...)) } -func (l Logger) Error(args ...interface{}) { +func (l logger) Error(args ...interface{}) { l.log(LevelError, 1, fmt.Sprint(args...)) } -func (l Logger) Warnf(format string, args ...interface{}) { +func (l logger) Warnf(format string, args ...interface{}) { l.log(LevelWarn, 1, fmt.Sprintf(format, args...)) } -func (l Logger) Warn(args ...interface{}) { +func (l logger) Warn(args ...interface{}) { l.log(LevelWarn, 1, fmt.Sprint(args...)) } -func (l Logger) Infof(format string, args ...interface{}) { +func (l logger) Infof(format string, args ...interface{}) { l.log(LevelInfo, 1, fmt.Sprintf(format, args...)) } -func (l Logger) Info(args ...interface{}) { +func (l logger) Info(args ...interface{}) { l.log(LevelInfo, 1, fmt.Sprint(args...)) } -func (l Logger) Debugf(format string, args ...interface{}) { +func (l logger) Debugf(format string, args ...interface{}) { l.log(LevelDebug, 1, fmt.Sprintf(format, args...)) } -func (l Logger) Debug(args ...interface{}) { +func (l logger) Debug(args ...interface{}) { l.log(LevelDebug, 1, fmt.Sprint(args...)) } -func (l Logger) log(level Level, skip int, msg string) { +func (l logger) log(level Level, skip int, msg string) { if level <= l.level && l.writer != nil { entry := Entry{ Timestamp: time.Now(), @@ -164,50 +96,7 @@ func (l Logger) log(level Level, skip int, msg string) { Data: l.data, Message: strings.ReplaceAll(msg, "\n", ";"), } - - // jsonEntry, err := json.Marshal(entry) - // if err != nil { - // l.writer.Write([]byte(fmt.Sprintf("failed to marshal entry: %v: %+v\n", err, entry))) - // } - // l.writer.Write(append(jsonEntry, []byte("\n")...)) - - source := fmt.Sprintf("%s/%s:%d", entry.Caller.Package, entry.Caller.File, entry.Caller.Line) - if len(source) > 40 { - source = source[len(source)-40:] - } - - buffer := bytes.NewBuffer(nil) - red := color.New(color.FgRed).FprintfFunc() - magenta := color.New(color.FgMagenta).FprintfFunc() - yellow := color.New(color.FgYellow).FprintfFunc() - blue := color.New(color.FgBlue).FprintfFunc() - green := color.New(color.FgGreen).FprintfFunc() - cyan := color.New(color.FgCyan).FprintfFunc() - blue(buffer, entry.Timestamp.Format("2006-01-02 15:04:05")) - levelString := fmt.Sprintf(" %5.5s", entry.Level) - switch entry.Level { - case LevelFatal: - red(buffer, levelString) - case LevelError: - red(buffer, levelString) - case LevelWarn: - magenta(buffer, levelString) - case LevelInfo: - yellow(buffer, levelString) - case LevelDebug: - green(buffer, levelString) - } - cyan(buffer, fmt.Sprintf(" %-40.40s| ", source)) - - base := color.New(l.fg, l.bg).FprintfFunc() - base(buffer, entry.Message) //buffer.Write([]byte(entry.Message)) - - if len(entry.Data) > 0 { - jsonData, _ := json.Marshal(entry.Data) - green(buffer, " "+string(jsonData)) - } - buffer.WriteString("\n") - l.writer.Write(buffer.Bytes()) + l.writer.Write(l.Format(entry)) } } @@ -218,85 +107,3 @@ type Entry struct { Data map[string]interface{} `json:"data"` Message string `json:"message"` } - -// func sendRaygunError(fields map[string]interface{}, errToSend error) { -// if os.Getenv("DEBUGGING") == "true" { -// // Don't log raygun errors on debug -// return -// } - -// raygun, err := RaygunReporter() -// if err != nil || raygun == nil { -// logger.Errorf("Unable to create Raygun client: " + err.Error()) -// return -// } - -// env := getEnvironment() -// tags := []string{env} -// //todo: raygun.Version(globals.BuildVersion) -// //todo: tags = append(tags, globals.BuildVersion) - -// if logger.apiRequest != nil { -// methodAndPath := logger.apiRequest.HTTPMethod + ": " + logger.apiRequest.Path -// tags = append(tags, methodAndPath) -// fields["body"] = logger.apiRequest.Body -// fields["query"] = logger.apiRequest.QueryStringParameters -// fields["identity"] = logger.apiRequest.RequestContext.Identity -// } - -// raygun.Tags(tags) -// if logger.currentRequestID != nil { -// fields["request_id"] = logger.currentRequestID -// } - -// fields["env"] = env -// raygun.CustomData(fields) -// raygun.Request(fakeHttpRequest()) - -// if errToSend == nil { -// errToSend = errors.New("") -// } -// err = raygun.SendError(errToSend) -// if err != nil { -// logger.Errorf("Failed to send raygun error: " + err.Error()) -// } -// } - -// func fakeHttpRequest() *http.Request { -// if logger.apiRequest == nil { -// return nil -// } - -// requestURL := url.URL{ -// Path: logger.apiRequest.Path, -// Host: logger.apiRequest.Headers["Host"], -// } -// request := http.Request{ -// Method: logger.apiRequest.HTTPMethod, -// URL: &requestURL, -// Header: logger.apiRequest.MultiValueHeaders, -// } -// return &request -// } - -// func RaygunReporter() (*raygun4go.Client, error) { -// key := "AwdpHhwOF1lTT6AppyEHA" - -// // TODO Raygun per environment -// //env := getEnvironment() -// //if env == "dev" { -// // key = "q98iTyE5CcF7raZsIiLA" -// //} else if env == "stage" { -// // key = "TA54mzcv9cBWBLmfwIQMDg" -// //} else if env == "prod" { -// // key = "BXdraqiPKBXImqP4siK1w" -// //} - -// raygun, err := raygun4go.New("uAfrica V3", key) -// if err != nil || raygun == nil { -// logger.Errorf("Unable to create Raygun client:" + err.Error()) -// return nil, err -// } - -// return raygun, nil -// } diff --git a/logger/logs_test.go b/logger/logs_test.go index 8ebb965bd9b07f0309aa027cbc73caed5d74ed7c..768865dfe0574bedac6160528ab6ccf46cbda018 100644 --- a/logger/logs_test.go +++ b/logger/logs_test.go @@ -43,4 +43,15 @@ func TestColor(t *testing.T) { // Mix up with multiple attributes success := color.New(color.Bold, color.FgGreen).FprintlnFunc() success(os.Stdout, " don't forget this...") + + logger.SetGlobalFormat(logger.NewConsole()) + logger.SetGlobalLevel(logger.LevelDebug) + logger.Debugf("Main logger") + l := logger.New() + l.Debugf("Logger 1") + l = logger.New() + l.Debugf("Logger 2") + // l = logger.New() + // l.IFormatter = l.IFormatter.NextColor() + // l.Debugf("Logger 3") } diff --git a/logger/stack.go b/logger/stack.go new file mode 100644 index 0000000000000000000000000000000000000000..0a178d000125578a2d882b4a20a2a67cd101c970 --- /dev/null +++ b/logger/stack.go @@ -0,0 +1,91 @@ +package logger + +import ( + "bufio" + "runtime/debug" + "strconv" + "strings" + + "gitlab.com/uafrica/go-utils/errors" +) + +type Stack struct { + Routine int64 //go routine nr that crashed... + Callers []errors.CallerInfo +} + +func CallStack() Stack { + stack := Stack{ + Callers: []errors.CallerInfo{}, + } + + //get the call stack + s := bufio.NewScanner(strings.NewReader(string(debug.Stack()))) + //expect stack to look like this: + // "goroutine 14 [running]:\nruntime/debug.Stack()\n\t/usr/local/go/src/runtime/debug/stack.go:24 +0x88\ngitlab.com/uafrica/go-utils/api.Api.Handler.func3.1(0x1400009ff60)\n\t/Users/jansemmelink/uafrica/go-utils/api/lambda.go:210 +0x48\npanic({0x100780d20, 0x100b98a50})\n\t/usr/local/go/src/runtime/panic.go:1038 +0x21c\ngitlab.com/uafrica/go-utils/examples/core/api/users.Crash({{{0x1008237e0, 0x1400040af30}, {0x4, {0x1008186e0, 0x14000010020}, 0x1400040af90, 0x25, 0x2f}, {0xc04f2cb513fbdc28, 0x103ccb5b4c, ...}}, ...}, ...)\n\t/Users/jansemmelink/uafrica/go-utils/examples/core/api/users/users.go:115 +0x20\nreflect.Value.call({0x10076bba0, 0x10080cdc8, 0x13}, {0x10059bb1c, 0x4}, {0x140000a0730, 0x2, 0x2})\n\t/usr/local/go/src/reflect/value.go:543 +0x584\nreflect.Value.Call({0x10076bba0, 0x10080cdc8, 0x13}, {0x140000a0730, 0x2, 0x2})\n\t/usr/local/go/src/reflect/value.go:339 +0x8c\ngitlab.com/uafrica/go-utils/api.Api.Handler.func3({{0x10082f730, 0x100780960}, {0x0, 0x0}, {0x0, 0x0}, {0x10076bba0, 0x10080cdc8, 0x13}}, {0x140000a0730, ...})\n\t/Users/jansemmelink/uafrica/go-utils/api/lambda.go:214 +0x84\ngitlab.com/uafrica/go-utils/api.Api.Handler({{0x10082b3f0, 0x140003b6480}, {0x10059b80a, 0x3}, {0x140003b6360}, {0x1005a3606, 0x12}, 0x10080cf98, {0x1008190e0, 0x100bdd308}, ...}, ...)\n\t/Users/jansemmelink/uafrica/go-utils/api/lambda.go:216 +0x1238\ngitlab.com/uafrica/go-utils/api.Api.ServeHTTP({{0x10082b3f0, 0x140003b6480}, {0x10059b80a, 0x3}, {0x140003b6360}, {0x1005a3606, 0x12}, 0x10080cf98, {0x1008190e0, 0x100bdd308}, ...}, ...)\n\t/Users/jansemmelink/uafrica/go-utils/api/local.go:81 +0x6ac\nnet/http.serverHandler.ServeHTTP({0x1400015a620}, {0x1008200e8, 0x1400015aa80}, 0x140003c6900)\n\t/usr/local/go/src/net/http/server.go:2878 +0x444\nnet/http.(*conn).serve(0x14000279220, {0x1008237e0, 0x140003b6780})\n\t/usr/local/go/src/net/http/server.go:1929 +0xb6c\ncreated by net/http.(*Server).Serve\n\t/usr/local/go/src/net/http/server.go:3033 +0x4b8\n" + //i.e. multiple lines ending with "\n" each, e.g.: + //------------------------------------------------------------------------------------------------------------ + // goroutine 37 [running]: + // runtime/debug.Stack() + // /usr/local/go/src/runtime/debug/stack.go:24 +0x88 + // gitlab.com/uafrica/go-utils/api.Api.Handler.func3.1(0x1400042cb00, 0x14000095f68) + // /Users/jansemmelink/uafrica/go-utils/api/lambda.go:216 +0x50 + // panic({0x100c08d20, 0x101020a70}) + // /usr/local/go/src/runtime/panic.go:1038 +0x21c + // gitlab.com/uafrica/go-utils/examples/core/api/users.Crash({{{0x100cab7c0, 0x140004843c0}, {0x4, {0x100ca06c0, 0x14000138010}, 0x14000484420, 0x20, 0x28}, {0xc04f2d6c3023b330, 0x227861e6a, ...}}, ...}, ...) + // /Users/jansemmelink/uafrica/go-utils/examples/core/api/users/users.go:115 +0x20 + // ... + //------------------------------------------------------------------------------------------------------------ + + //get go routine nr from first line: "gorouting <nr> [running]:" + if s.Scan() { + p := strings.SplitN(s.Text(), " ", 3) + if len(p) >= 2 && p[0] == "goroutine" { + routineNr, err := strconv.ParseInt(p[1], 10, 64) + if err == nil { + stack.Routine = routineNr + } + } + } + + //next expect line pairs for each level of the stack + for { + //read first line in this pair, expecting <package>.<funcName>(<args>) + if !s.Scan() { + break + } + line1 := s.Text() + + if !s.Scan() { + break + } + line2 := s.Text() + + //fmt.Printf(" STACK LINE: %s %s\n", line1, line2) + + //split line 1 on any bracket or comma to get "<package>.<funcName>"["<arg>" ...] + //func may have multiple '.', so do not split on that yet! + line1Fields := strings.FieldsFunc(line1, func(c rune) bool { return strings.Contains("(), ", string(c)) }) + + //split line 2 <file>:<line> +0x##... + line2Fields := strings.FieldsFunc(line2, func(c rune) bool { return strings.Contains(": ", string(c)) }) + lineNr, _ := strconv.ParseInt(line2Fields[1], 10, 64) + caller := errors.NewCaller(line1Fields[0], line2Fields[0], int(lineNr)) + + //skip first levels that refer to capturing the stack + ci := caller.Info() + if len(stack.Callers) == 0 { + if ci.Package == "runtime/debug" || + ci.Package == "gitlab.com/uafrica/go-utils/logger" || + ci.Package == "gitlab.com/uafrica/go-utils/errors" || + ci.Package == "" { + continue + } + if _, err := strconv.ParseInt(ci.Function, 10, 64); err == nil { + continue //typical defer function without a name used to catch the crash + } + } + stack.Callers = append(stack.Callers, caller.Info()) + } + return stack +} diff --git a/queues/audit.go b/queues/audit.go new file mode 100644 index 0000000000000000000000000000000000000000..16d50098946c8aecc76aa2b26bffb9011e13fdda --- /dev/null +++ b/queues/audit.go @@ -0,0 +1,55 @@ +package queues + +import ( + "time" + + "gitlab.com/uafrica/go-utils/audit" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/service" +) + +//create auditor that push to a queue using the specified producer +func Auditor(queueName string, messageType string, producer service.ProducerLogger) audit.Auditor { + if producer == nil { + panic(errors.Errorf("cannot create auditor with producer=nil")) + } + if queueName == "" { + queueName = "AUDIT" + } + if messageType == "" { + messageType = "audit" + } + return auditor{ + producer: producer, + queueName: queueName, + messageType: messageType, + } +} + +type auditor struct { + producer service.ProducerLogger + queueName string + messageType string +} + +func (a auditor) WriteEvent(requestID string, event audit.Event) error { + _, err := service.NewEvent(a.producer, a.queueName). + RequestID(requestID). + Type(a.messageType). + Send(event) + if err != nil { + return errors.Wrapf(err, "failed to write audit event") + } + return nil +} + +func (a auditor) WriteValues(startTime, endTime time.Time, requestID string, values map[string]interface{}) error { + _, err := service.NewEvent(a.producer, a.queueName). + RequestID(requestID). + Type(a.messageType). + Send(values) + if err != nil { + return errors.Wrapf(err, "failed to write audit values") + } + return nil +} diff --git a/queues/check.go b/queues/check.go new file mode 100644 index 0000000000000000000000000000000000000000..f0148eef81b12737ba3b85d1082f0ecf99711956 --- /dev/null +++ b/queues/check.go @@ -0,0 +1,5 @@ +package queues + +type ICheck interface { + Check(Context) (interface{}, error) +} diff --git a/queues/consumer.go b/queues/consumer.go index 7549b85ea66e253c1a1a4665181b2ed773468431..a476ddc1a994ea154851c499b652c753fb148e5e 100644 --- a/queues/consumer.go +++ b/queues/consumer.go @@ -2,8 +2,10 @@ package queues import "gitlab.com/uafrica/go-utils/service" -type IConsumer interface { - WithDb(dbConn service.IDatabaseConnector) IConsumer +//IConsumer is the interface implemented by both mem and sqs consumer +type Consumer interface { + WithStarter(name string, starter service.IStarter) Consumer + WithErrorReporter(reporter service.IErrorReporter) Consumer Run() ProcessFile(filename string) error } diff --git a/queues/context.go b/queues/context.go index 335bb870f959d97d9cb2180b3377b8bf77d1a29c..c3321fecabf9238b4dd28da4616ede30bbb3dc99 100644 --- a/queues/context.go +++ b/queues/context.go @@ -4,34 +4,52 @@ import ( "context" "encoding/json" "reflect" - "time" - "github.com/uptrace/bun" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/logger" "gitlab.com/uafrica/go-utils/service" ) -type IContext interface { - context.Context - logger.ILogger - IProducer - StartTime() time.Time - MillisecondsSinceStart() int64 - //DB() *bun.DB +//Context within a consumer to process an event +type Context interface { + service.Context + Event() service.Event //the event start started this context in the consumer + GetRecord(recordType reflect.Type) (interface{}, error) //extract struct value from event data } -type Context struct { +var contextInterfaceType = reflect.TypeOf((*Context)(nil)).Elem() + +type queuesContext struct { service.Context - IProducer //todo: would be nice to have a method to requeue same event with delay and incrementing attempt nr - Event Event - RequestID string - DB *bun.DB + event service.Event +} + +func NewContext(service service.Service, event service.Event) (Context, error) { + baseCtx := context.Background() + serviceContext, err := service.NewContext(baseCtx, event.RequestIDValue, map[string]interface{}{ + "message_type": event.TypeName, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create service context") + } + + ctx := queuesContext{ + Context: serviceContext, + event: event, + } + return ctx, nil +} + +func (ctx queuesContext) Event() service.Event { + return ctx.event +} + +func (ctx queuesContext) RequestID() string { + return ctx.event.RequestIDValue } -func (ctx Context) GetRecord(recordType reflect.Type) (interface{}, error) { +func (ctx queuesContext) GetRecord(recordType reflect.Type) (interface{}, error) { recordValuePtr := reflect.New(recordType) - err := json.Unmarshal([]byte(ctx.Event.BodyJSON), recordValuePtr.Interface()) + err := json.Unmarshal([]byte(ctx.event.BodyJSON), recordValuePtr.Interface()) if err != nil { return nil, errors.Wrapf(err, "failed to JSON decode message body") } diff --git a/queues/handler.go b/queues/handler.go index aa29674f912e4c9204c356874c429ad2fd4ad3bb..36ba9ec40d196177c7ce54f6e638471ba3935f0a 100644 --- a/queues/handler.go +++ b/queues/handler.go @@ -23,8 +23,9 @@ func NewHandler(fnc interface{}) (Handler, error) { } //arg[0] must implement interface sqs.IContext - if _, ok := reflect.New(fncType.In(0)).Interface().(IContext); !ok { - return h, errors.Errorf("first arg %v does not implement sqs.IContext", fncType.In(0)) + if fncType.In(0) != contextInterfaceType && + !fncType.In(0).Implements(contextInterfaceType) { + return h, errors.Errorf("first arg %v does not implement %v", fncType.In(0), contextInterfaceType) } //arg[1] must be a struct for the message record body. It may be an empty struct, but diff --git a/queues/mem/consumer.go b/queues/mem/consumer.go index eb314a15de8ee7a79558f02aa231a23a1f923288..dd88a468c0c7ea3318c6d893ae0991354c5b2c72 100644 --- a/queues/mem/consumer.go +++ b/queues/mem/consumer.go @@ -1,7 +1,6 @@ package mem import ( - "context" "encoding/json" "fmt" "math/rand" @@ -11,39 +10,54 @@ import ( "time" "github.com/google/uuid" - "github.com/uptrace/bun" + "gitlab.com/uafrica/go-utils/audit" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/logger" "gitlab.com/uafrica/go-utils/queues" "gitlab.com/uafrica/go-utils/service" ) -func NewConsumer(routes map[string]interface{}) *Consumer { +func NewConsumer(routes map[string]interface{}) *consumer { router, err := queues.NewRouter(routes) if err != nil { panic(fmt.Sprintf("cannot create router: %+v", err)) } - return &Consumer{ - ILogger: logger.New().WithFields(map[string]interface{}{"env": "dev"}).NextColor(), - router: router, - queues: map[string]*queue{}, + //l := logger.New().WithFields(map[string]interface{}{"env": "dev"}) + //l.IFormatter = l.IFormatter.NextColor() + return &consumer{ + Service: service.New(), + //Logger: l, + router: router, + queues: map[string]*queue{}, } } -type Consumer struct { +type consumer struct { sync.Mutex - logger.ILogger + service.Service + //logger.Logger router queues.Router - dbConn service.IDatabaseConnector queues map[string]*queue } -func (consumer *Consumer) WithDb(dbConn service.IDatabaseConnector) queues.IConsumer { - consumer.dbConn = dbConn +//wrap Service.WithStarter to return cron, else cannot be chained +func (consumer *consumer) WithStarter(name string, starter service.IStarter) queues.Consumer { + consumer.Service = consumer.Service.WithStarter(name, starter) return consumer } -func (consumer *Consumer) Queue(name string) (*queue, error) { +//wrap Service.WithErrorReporter to return api, else cannot be chained +func (consumer *consumer) WithErrorReporter(reporter service.IErrorReporter) queues.Consumer { + consumer.Service = consumer.Service.WithErrorReporter(reporter) + return consumer +} + +//wrap else cannot be chained +func (consumer *consumer) WithAuditor(auditor audit.Auditor) queues.Consumer { + consumer.Service = consumer.Service.WithAuditor(auditor) + return consumer +} + +func (consumer *consumer) Queue(name string) (*queue, error) { consumer.Lock() defer consumer.Unlock() q, ok := consumer.queues[name] @@ -51,7 +65,7 @@ func (consumer *Consumer) Queue(name string) (*queue, error) { q = &queue{ consumer: consumer, name: name, - ch: make(chan queues.Event), + ch: make(chan service.Event), } go q.run() consumer.queues[name] = q @@ -61,20 +75,20 @@ func (consumer *Consumer) Queue(name string) (*queue, error) { //do not call this - when using local producer, the consumer is automatically running //for each queue you send to, and processing from q.run() -func (consumer *Consumer) Run() { +func (consumer *consumer) Run() { panic(errors.Errorf("DO NOT RUN LOCAL CONSUMER")) } -func (consumer *Consumer) ProcessFile(filename string) error { +func (consumer *consumer) ProcessFile(filename string) error { f, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "failed to open queue event file %s", filename) } defer f.Close() - var event queues.Event + var event service.Event if err := json.NewDecoder(f).Decode(&event); err != nil { - return errors.Wrapf(err, "failed to read queues.Event from file %s", filename) + return errors.Wrapf(err, "failed to read service.Event from file %s", filename) } q := queue{ @@ -92,9 +106,9 @@ func (consumer *Consumer) ProcessFile(filename string) error { } type queue struct { - consumer *Consumer + consumer *consumer name string - ch chan queues.Event + ch chan service.Event } func (q *queue) run() { @@ -106,40 +120,19 @@ func (q *queue) run() { } } -func (q *queue) process(event queues.Event) error { +func (q *queue) process(event service.Event) error { //todo: create context with logger rand.Seed(time.Now().Unix()) //report handler crashes - //defer sqs.crashReporter.Catch(ctx) - - var db *bun.DB - if q.consumer.dbConn != nil { - var err error - db, err = q.consumer.dbConn.Connect() - if err != nil { - return errors.Wrapf(err, "failed to connect to db") - } - } - - baseCtx := context.Background() - ctx := queues.Context{ - Context: service.NewContext(baseCtx, map[string]interface{}{ - "env": "dev", - "request_id": event.RequestID, - "message_type": event.TypeName, - }), - IProducer: q, //todo: q can only send back into this queue... may need to send to other queues! - Event: event, - RequestID: event.RequestIDValue, - DB: db, + // if q.crashReporter != nil { + // defer q.crashReporter.Catch(ctx) + // } + ctx, err := queues.NewContext(q.consumer.Service, event) + if err != nil { + return err } - ctx.WithFields(map[string]interface{}{ - "params": event.ParamValues, - "body": event.BodyJSON, - }).Infof("Queue(%s) Recv SQS Event: %v", q.name, event) - //routing on messageType sqsHandler, err := q.consumer.router.Route(event.TypeName) if err != nil { @@ -161,6 +154,17 @@ func (q *queue) process(event queues.Event) error { return errors.Wrapf(err, "invalid message body") } + ctx.WithFields(map[string]interface{}{ + "params": event.ParamValues, + "body": event.BodyJSON, + }).Infof("RECV(%s) Queue(%s).Type(%s).Due(%s): (%T)%v", + "---", //not yet available here - not part of event, and in SQS I think it is passed in SQS layer, so need to extend local channel to include this along with event + q.name, + event.TypeName, + event.DueTime, + recordStruct, + recordStruct) + ctx.Debugf("message (%T) %+v", recordStruct, recordStruct) args = append(args, reflect.ValueOf(recordStruct)) @@ -168,11 +172,11 @@ func (q *queue) process(event queues.Event) error { if len(results) > 0 && !results[0].IsNil() { return errors.Wrapf(results[0].Interface().(error), "handler failed") } - ctx.Debugf("handler success") + ctx.Debugf("Handler done") return nil } //queue.process() -func (q *queue) Send(event queues.Event) (msgID string, err error) { +func (q *queue) Send(event service.Event) (msgID string, err error) { event.MessageID = uuid.New().String() q.ch <- event return event.MessageID, nil diff --git a/queues/mem/producer.go b/queues/mem/producer.go index 0f90a67df5941ec8078cf495bc9284d9400f4b93..99977c171d7b249b7f6e3afc0d0549824c612afa 100644 --- a/queues/mem/producer.go +++ b/queues/mem/producer.go @@ -2,11 +2,11 @@ package mem import ( "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/queues" + "gitlab.com/uafrica/go-utils/service" ) //can only produce locally if also consuming local -func NewProducer(consumer *Consumer) queues.IProducer { +func NewProducer(consumer *consumer) service.Producer { if consumer == nil { panic(errors.Errorf("cannot product locally without consumer")) } @@ -16,10 +16,10 @@ func NewProducer(consumer *Consumer) queues.IProducer { } type producer struct { - consumer *Consumer + consumer *consumer } -func (producer *producer) Send(event queues.Event) (string, error) { +func (producer *producer) Send(event service.Event) (string, error) { q, err := producer.consumer.Queue(event.QueueName) if err != nil { return "", errors.Wrapf(err, "failed to get/create queue(%s)", event.QueueName) diff --git a/queues/producer.go b/queues/producer.go deleted file mode 100644 index 1f9b09f71df3bfa88c999aec8b44452ef74c7b2a..0000000000000000000000000000000000000000 --- a/queues/producer.go +++ /dev/null @@ -1,5 +0,0 @@ -package queues - -type IProducer interface { - Send(event Event) (msgID string, err error) -} diff --git a/queues/sqs/consumer.go b/queues/sqs/consumer.go index 8b9e191f6b2152e6ccf46b9e19bc43daa9c164c5..c14ecde081af1d35cf0cac4d068d8c91cb86077c 100644 --- a/queues/sqs/consumer.go +++ b/queues/sqs/consumer.go @@ -15,14 +15,13 @@ import ( "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-lambda-go/lambdacontext" "github.com/google/uuid" - "github.com/uptrace/bun" + "gitlab.com/uafrica/go-utils/audit" "gitlab.com/uafrica/go-utils/errors" - "gitlab.com/uafrica/go-utils/logger" "gitlab.com/uafrica/go-utils/queues" "gitlab.com/uafrica/go-utils/service" ) -func NewConsumer(requestIDHeaderKey string, routes map[string]interface{}) queues.IConsumer { +func NewConsumer(requestIDHeaderKey string, routes map[string]interface{}) queues.Consumer { env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod if env == "" { env = "dev" @@ -42,27 +41,42 @@ func NewConsumer(requestIDHeaderKey string, routes map[string]interface{}) queue } return consumer{ - ILogger: logger.New().WithFields(map[string]interface{}{"env": env}), + Service: service.New(), + //Logger: logger.New().WithFields(map[string]interface{}{"env": env}), env: env, router: router, requestIDHeaderKey: requestIDHeaderKey, ConstantMessageType: sqsMessageType, producer: NewProducer(requestIDHeaderKey), + checks: map[string]queues.ICheck{}, } } type consumer struct { - logger.ILogger //for logging outside of context + service.Service env string router queues.Router requestIDHeaderKey string ConstantMessageType string //from os.Getenv("SQS_MESSAGE_TYPE") - dbConn service.IDatabaseConnector - producer queues.IProducer + producer service.Producer + checks map[string]queues.ICheck } -func (consumer consumer) WithDb(dbConn service.IDatabaseConnector) queues.IConsumer { - consumer.dbConn = dbConn +//wrap Service.WithStarter to return cron, else cannot be chained +func (consumer consumer) WithStarter(name string, starter service.IStarter) queues.Consumer { + consumer.Service = consumer.Service.WithStarter(name, starter) + return consumer +} + +//wrap Service.WithErrorReporter to return api, else cannot be chained +func (consumer consumer) WithErrorReporter(reporter service.IErrorReporter) queues.Consumer { + consumer.Service = consumer.Service.WithErrorReporter(reporter) + return consumer +} + +//wrap else cannot be chained +func (consumer consumer) WithAuditor(auditor audit.Auditor) queues.Consumer { + consumer.Service = consumer.Service.WithAuditor(auditor) return consumer } @@ -104,16 +118,9 @@ func (consumer consumer) Handler(baseCtx context.Context, lambdaEvent events.SQS rand.Seed(time.Now().Unix()) //report handler crashes - //defer sqs.crashReporter.Catch(ctx) - - var db *bun.DB - if consumer.dbConn != nil { - var err error - db, err = consumer.dbConn.Connect() - if err != nil { - return errors.Wrapf(err, "failed to connect to db") - } - } + // if consumer.crashReporter != nil { + // defer sqs.crashReporter.Catch(ctx) + // } if consumer.ConstantMessageType != "" { //legacy mode for fixed message type as used in shiplogic @@ -146,30 +153,25 @@ func (consumer consumer) Handler(baseCtx context.Context, lambdaEvent events.SQS messageType = *messageTypeAttr.StringValue } - ctx := queues.Context{ - Context: service.NewContext(baseCtx, map[string]interface{}{ - "env": consumer.env, - "request_id": requestID, - "message_type": messageType, - }), - IProducer: consumer.producer, //needed so handler can queue other events or requeue this event - Event: queues.Event{ - //producer: nil, - MessageID: message.MessageId, - QueueName: "N/A", //not sure how to get queue name from lambda Event... would be good to log it, may be in os.Getenv(???)? - TypeName: messageType, - DueTime: time.Now(), - RequestIDValue: requestID, - BodyJSON: message.Body, - }, - RequestID: requestID, - DB: db, + event := service.Event{ + //producer: nil, + MessageID: message.MessageId, + QueueName: "N/A", //not sure how to get queue name from lambda Event... would be good to log it, may be in os.Getenv(???)? + TypeName: messageType, + DueTime: time.Now(), + RequestIDValue: requestID, + BodyJSON: message.Body, + } + + ctx, err := queues.NewContext(consumer.Service, event) + if err != nil { + return err } ctx.WithFields(map[string]interface{}{ "message_index": messageIndex, "message": message, - }).Infof("Queue(%s) Start SQS Handler Event: %v", ctx.Event.QueueName, ctx.Event) + }).Infof("Queue(%s) Start SQS Handler Event: %v", ctx.Event().QueueName, ctx.Event) //routing on messageType sqsHandler, err := consumer.router.Route(messageType) @@ -201,6 +203,7 @@ func (consumer consumer) Handler(baseCtx context.Context, lambdaEvent events.SQS results := handler.FuncValue.Call(args) if len(results) > 0 && !results[0].IsNil() { ctx.Errorf("handler failed: %+v", results[0].Interface().(error)) + consumer.Service.ReportError(ctx.Data(), err) } } } diff --git a/queues/sqs/producer.go b/queues/sqs/producer.go index e5de43248faa42b9e3d353c52c245e1a9631ebd8..8f009fa48788ad4c642af98b4529041019b9c1d9 100644 --- a/queues/sqs/producer.go +++ b/queues/sqs/producer.go @@ -11,10 +11,10 @@ import ( "github.com/aws/aws-sdk-go/service/sqs" "gitlab.com/uafrica/go-utils/errors" "gitlab.com/uafrica/go-utils/logger" - "gitlab.com/uafrica/go-utils/queues" + "gitlab.com/uafrica/go-utils/service" ) -func NewProducer(requestIDHeaderKey string) queues.IProducer { +func NewProducer(requestIDHeaderKey string) service.Producer { region := os.Getenv("AWS_REGION") if region == "" { panic(errors.Errorf("environment AWS_REGION is not defined")) @@ -26,7 +26,7 @@ func NewProducer(requestIDHeaderKey string) queues.IProducer { region: region, requestIDHeaderKey: requestIDHeaderKey, session: nil, - queues: map[string]*Messenger{}, + queues: map[string]*QueueProducer{}, } } @@ -35,11 +35,12 @@ type producer struct { region string requestIDHeaderKey string session *session.Session - queues map[string]*Messenger + queues map[string]*QueueProducer } // Note: Calling code needs SQS IAM permissions -func (producer *producer) Send(event queues.Event) (string, error) { +func (producer *producer) Send(event service.Event) (string, error) { + logger.Debugf("producer=%T=%v", producer, producer) messenger, ok := producer.queues[event.QueueName] if !ok { producer.Lock() @@ -62,7 +63,8 @@ func (producer *producer) Send(event queues.Event) (string, error) { return "", errors.Wrapf(err, "failed to create AWS session") } - messenger = &Messenger{ + messenger = &QueueProducer{ + producer: producer, session: sess, service: sqs.New(sess), queueURL: queueURL, @@ -78,16 +80,16 @@ func (producer *producer) Send(event queues.Event) (string, error) { } } -// Messenger sends an arbitrary message via SQS to a particular queue URL -type Messenger struct { +// QueueProducer sends an arbitrary message via SQS to a particular queue URL +type QueueProducer struct { producer *producer session *session.Session service *sqs.SQS queueURL string } -func (m *Messenger) Send(event queues.Event) (string, error) { - logger.Debugf("Sending event %+v", event) +func (m *QueueProducer) Send(event service.Event) (string, error) { + //logger.Debugf("Sending event %+v", event) //add params as message attributes msgAttrs := make(map[string]*sqs.MessageAttributeValue) diff --git a/reflection/reflection.go b/reflection/reflection.go new file mode 100644 index 0000000000000000000000000000000000000000..14faf28a7641715e420fb3a7fa3a396f117cf0ac --- /dev/null +++ b/reflection/reflection.go @@ -0,0 +1,92 @@ +package reflection + +import ( + "reflect" + "time" + + "gitlab.com/uafrica/go-utils/logger" +) + +func SetPointerTime(field reflect.Value, value *time.Time) { + if !isFieldValid(field, false) { + return // Field doesn't exist + } + + if field.Kind() != reflect.Ptr { + logger.Error("Field need to be *Field") + return + } + field.Set(reflect.ValueOf(value)) +} + +func SetInt64(field reflect.Value, value int64) { + if !isFieldValid(field, false) { + return // Field doesn't exist + } + if field.Kind() != reflect.Int64 { + logger.Error("Claims: Field is not of type Int64") + return + } + if field.OverflowInt(value) { + logger.Error("Claims: Int overflow") + return + } + field.SetInt(value) +} + +func SetPointerInt64(field reflect.Value, value *int64) { + if !isFieldValid(field, false) { + return // Field doesn't exist + } + + if field.Kind() != reflect.Ptr { + logger.Error("Field need to be *Int64") + return + } + + field.Set(reflect.ValueOf(value)) +} + +func SetString(field reflect.Value, value string) { + if !isFieldValid(field, false) { + return // Field doesn't exist + } + if field.Kind() != reflect.String { + logger.Error("Claims: Field is not of type String: %v", field.Kind()) + return + } + field.SetString(value) +} + +func GetStringValue(field reflect.Value) string { + if !isFieldValid(field, true) { + return "" + } + + if field.Kind() == reflect.String { + return field.String() + } + if field.Kind() == reflect.Ptr && !field.IsNil() { + return field.Elem().String() + } + return "" +} + +func GetInt64Value(field reflect.Value) int64 { + if !isFieldValid(field, true) { + return 0 + } + + if field.Kind() == reflect.Int64 { + return field.Int() + } + return 0 +} + +func isFieldValid(field reflect.Value, readonly bool) bool { + if readonly { + return field.IsValid() + } + + return field.IsValid() && field.CanSet() +} diff --git a/reflection/set.go b/reflection/set.go new file mode 100644 index 0000000000000000000000000000000000000000..d9a323145a5f324260d076ca4c83007ff42a425d --- /dev/null +++ b/reflection/set.go @@ -0,0 +1,101 @@ +package reflection + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "gitlab.com/uafrica/go-utils/errors" +) + +func SetValue(tgt reflect.Value, src interface{}) error { + if reflect.TypeOf(src) == tgt.Type() { + tgt.Set(reflect.ValueOf(src)) + return nil + } + + //need some kind of type conversion + switch tgt.Type().Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + //setting a signed integer + var i64 int64 + strValue := fmt.Sprintf("%v", src) + if strValue != "" { + var err error + i64, err = strconv.ParseInt(strValue, 10, 64) + if err != nil { + return errors.Wrapf(err, "\"%s\" is not a number", strValue) + } + } + switch tgt.Type().Kind() { + case reflect.Int: + tgt.Set(reflect.ValueOf(int(i64))) + case reflect.Int8: + tgt.Set(reflect.ValueOf(int8(i64))) + case reflect.Int16: + tgt.Set(reflect.ValueOf(int16(i64))) + case reflect.Int32: + tgt.Set(reflect.ValueOf(int32(i64))) + case reflect.Int64: + tgt.Set(reflect.ValueOf(i64)) + } + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + //parse to int for this struct field + var u64 uint64 + strValue := fmt.Sprintf("%v", src) + if strValue != "" { + var err error + u64, err = strconv.ParseUint(strValue, 10, 64) + if err != nil { + return errors.Errorf("\"%s\" is not a number", strValue) + } + } + + switch tgt.Type().Kind() { + case reflect.Uint: + tgt.Set(reflect.ValueOf(uint(u64))) + case reflect.Uint8: + tgt.Set(reflect.ValueOf(uint8(u64))) + case reflect.Uint16: + tgt.Set(reflect.ValueOf(uint16(u64))) + case reflect.Uint32: + tgt.Set(reflect.ValueOf(uint32(u64))) + case reflect.Uint64: + tgt.Set(reflect.ValueOf(u64)) + } + + case reflect.Bool: + strValue := strings.ToLower(fmt.Sprintf("%v", src)) + if strValue == "true" || strValue == "yes" || strValue == "1" { + tgt.Set(reflect.ValueOf(true)) + } else { + tgt.Set(reflect.ValueOf(false)) + } + + case reflect.String: + tgt.Set(reflect.ValueOf(fmt.Sprintf("%v", src))) + + case reflect.Float32: + strValue := fmt.Sprintf("%v", src) + if f64, err := strconv.ParseFloat(strValue, 32); err != nil { + return errors.Wrapf(err, "\"%s\" is not a valid number", strValue) + } else { + tgt.Set(reflect.ValueOf(float32(f64))) + } + + case reflect.Float64: + strValue := fmt.Sprintf("%v", src) + if f64, err := strconv.ParseFloat(strValue, 64); err != nil { + return errors.Wrapf(err, "\"%s\" is not a valid number", strValue) + } else { + tgt.Set(reflect.ValueOf(f64)) + } + + default: + return errors.Errorf("unsupported type %v", tgt.Type().Kind()) + } //switch param struct field + return nil + +} diff --git a/service/context.go b/service/context.go index da6d76918b452aab81809346f3ff80639ae76779..d53caf3b3a8b71693d5d81904cbb0d3388d1dcc0 100644 --- a/service/context.go +++ b/service/context.go @@ -2,11 +2,56 @@ package service import ( "context" + "reflect" + "regexp" "time" + "gitlab.com/uafrica/go-utils/audit" + "gitlab.com/uafrica/go-utils/errors" "gitlab.com/uafrica/go-utils/logger" + "gitlab.com/uafrica/go-utils/string_utils" ) +type Context interface { + context.Context + logger.Logger + Producer + audit.Auditor + + RequestID() string + MillisecondsSinceStart() int64 + StartTime() time.Time + + //set claim values - things that cannot change - typically UserID, AccountID, Username, ... + //the fieldName must be public, i.e. start with uppercase A-Z, no underscores, its not a tag name :-) + //once set, it will override any field in params/body struct recursively with this Golang field name (before validation) + //Set will fail if the value is already set on this context, i.e. the value cannot change + //Call this in your app Start(), before params/body is extracted + //value may only be of type int64 or string for now... + ClaimSet(fieldName string, value interface{}) error + + //Get can retrieve any claim value + ClaimGet(fieldName string) (interface{}, bool) + + //Claim() return all values so you can iterate over them, but not change them... + //note: a context.Context also support its own set of values, which you can use as you like + //but the idea is that you use this instead, which we apply to params/body + Claim() map[string]interface{} + + //context data (names must be snake_case) + //unlike claim, any type of value may be stored + //but like claims, value can never change + WithValue(name string, value interface{}) Context + Set(name string, value interface{}) error + Get(name string) (interface{}, bool) + //Value(name string) interface{} //return nil if not set, inherited from context.Context and overloaded to retrieve local first, just like Get() + ValueOrDefault(name string, defaultValue interface{}) interface{} + Data() map[string]interface{} + + //write an audit event + AuditChange(eventType string, orgValue, newValue interface{}) +} + //values: are added to context and logger //these values are logged for every log event in this context //values can be added later using with value, but won't be logged @@ -14,48 +59,172 @@ import ( // authentication may set the user_id etc... and other package may retrieve it but not change it type valueKey string -func NewContext(base context.Context, values map[string]interface{}) Context { +func (s service) NewContext(base context.Context, requestID string, values map[string]interface{}) (Context, error) { + if values == nil { + values = map[string]interface{}{} + } + values["request-id"] = requestID + for n, v := range values { base = context.WithValue(base, valueKey(n), v) } - return Context{ + l := logger.New().WithFields(values) + l.IFormatter = l.IFormatter.NextColor() + + ctx := &serviceContext{ Context: base, - Logger: logger.New().WithFields(values).NextColor(), + Logger: l, + Producer: s.Producer, + Auditor: s.Auditor, startTime: time.Now(), + requestID: requestID, + data: map[string]interface{}{}, + claim: map[string]interface{}{}, } -} -// type IContext interface { -// context.Context -// logger.ILogger -// StartTime() time.Time -// MillisecondsSinceStart() int64 -// ValueOrDefault(name string, defaultValue interface{}) interface{} -// WithValue(name string, value interface{}) Context -// } + for starterName, starter := range s.starters { + var starterData interface{} + starterData, err := starter.Start(ctx) + if err != nil { + ctx.Errorf("Start(%s) failed: %+v ...", starterName, err) + return nil, errors.Wrapf(err, "%s", starterName) + } + if err = ctx.Set(starterName, starterData); err != nil { + ctx.Errorf("Start(%s) failed to set (%T)%+v: %+v ...", starterName, starterData, starterData, err) + return nil, errors.Wrapf(err, "failed to set starter(%s) data=(%T)%+v", starterName, starterData, starterData) + } + ctx.Debugf("Start(%s)=(%T)%+v", starterName, starterData, starterData) + } + return ctx, nil +} -type Context struct { +type serviceContext struct { context.Context logger.Logger + Producer + audit.Auditor startTime time.Time + requestID string + claim map[string]interface{} + data map[string]interface{} +} + +func (ctx serviceContext) RequestID() string { + return ctx.requestID +} + +const claimFieldNamePattern = `[A-Z][a-zA-Z0-9]*` + +var claimFieldNameRegex = regexp.MustCompile("^" + claimFieldNamePattern + "$") + +func (ctx *serviceContext) ClaimSet(fieldName string, value interface{}) error { + if !claimFieldNameRegex.MatchString(fieldName) { + return errors.Errorf("invalid claim field name \"%s\"", fieldName) + } + if oldValue, exists := ctx.claim[fieldName]; exists { + return errors.Errorf("ClaimSet(%s=(%T)%v) failed because already set to (%T)%v", fieldName, value, value, oldValue, oldValue) + } + switch reflect.TypeOf(value).Kind() { + case reflect.Int64: + case reflect.String: + default: + panic(errors.Errorf("claim(%s)=(%T)%v is neither sting nor int64", fieldName, value, value)) + } + ctx.claim[fieldName] = value + return nil +} + +func (ctx serviceContext) ClaimGet(fieldName string) (interface{}, bool) { + if cv, ok := ctx.claim[fieldName]; ok { + return cv, true + } + return nil, false +} + +func (ctx serviceContext) Claim() map[string]interface{} { + claim := map[string]interface{}{} + for n, v := range ctx.claim { + claim[n] = v + } + return claim +} + +func (ctx *serviceContext) WithValue(name string, value interface{}) Context { + ctx.Context = context.WithValue(ctx.Context, valueKey(name), value) + return ctx } -func (ctx Context) MillisecondsSinceStart() int64 { +func (ctx *serviceContext) Set(name string, value interface{}) error { + if !string_utils.IsSnakeCase(name) { + return errors.Errorf("invalid context value name \"%s\"", name) + } + if oldValue, exists := ctx.data[name]; exists { + return errors.Errorf("Set(%s=(%T)%v) failed because already set to (%T)%v", name, value, value, oldValue, oldValue) + } + ctx.data[name] = value + return nil +} + +func (ctx *serviceContext) Get(name string) (interface{}, bool) { + if cv, ok := ctx.data[name]; ok { + return cv, true + } + //alternative: try value from context.Context + if cv := ctx.Context.Value(name); cv != nil { + return cv, true + } + return nil, false +} + +//Value override context.Context.Value to retrieve first from our own data +func (ctx *serviceContext) Value(key interface{}) interface{} { + if name, ok := key.(string); ok { + if cv, ok := ctx.data[name]; ok { + return cv + } + } + //alternative: try value from context.Context + if cv := ctx.Context.Value(key); cv != nil { + return cv + } + return nil +} + +func (ctx *serviceContext) Data() map[string]interface{} { + data := map[string]interface{}{} + for n, v := range ctx.data { + data[n] = v + } + return data +} + +func (ctx *serviceContext) MillisecondsSinceStart() int64 { return time.Since(ctx.startTime).Milliseconds() } -func (ctx Context) StartTime() time.Time { +func (ctx *serviceContext) StartTime() time.Time { return ctx.startTime } -func (ctx Context) ValueOrDefault(name string, defaultValue interface{}) interface{} { +func (ctx *serviceContext) ValueOrDefault(name string, defaultValue interface{}) interface{} { if value := ctx.Value(valueKey(name)); value != nil { return value } return defaultValue } -func (ctx Context) WithValue(name string, value interface{}) Context { - ctx.Context = context.WithValue(ctx.Context, valueKey(name), value) - return ctx +func (ctx *serviceContext) AuditChange(eventType string, orgValue, newValue interface{}) { + event, err := audit.NewEvent( + ctx.Claim()["username"].(string), //use username as source (will default to "SYSTEM" if undefined) + eventType, + orgValue, + newValue, + ) + if err != nil { + ctx.Errorf("failed to define audit event: %+v", err) + return + } + if err := ctx.Auditor.WriteEvent(ctx.requestID, event); err != nil { + ctx.Errorf("failed to audit change: %+v", err) + } } diff --git a/service/database.go b/service/database.go deleted file mode 100644 index 3670ee57c9dab156075ab1bd3ce6ffca1a0e27d4..0000000000000000000000000000000000000000 --- a/service/database.go +++ /dev/null @@ -1,7 +0,0 @@ -package service - -import "github.com/uptrace/bun" - -type IDatabaseConnector interface { - Connect() (*bun.DB, error) -} diff --git a/queues/event.go b/service/event.go similarity index 86% rename from queues/event.go rename to service/event.go index 6f4e0f960798c8004ead9eb06b77ddb421001675..e68e84d02d9d934e538a9e88e4a06b682f1bce3f 100644 --- a/queues/event.go +++ b/service/event.go @@ -1,4 +1,4 @@ -package queues +package service import ( "encoding/json" @@ -9,12 +9,12 @@ import ( "gitlab.com/uafrica/go-utils/logger" ) -type IProducerLogger interface { - IProducer - logger.ILogger +type ProducerLogger interface { + Producer + logger.Logger } -func NewEvent(producer IProducerLogger, queueName string) Event { +func NewEvent(producer ProducerLogger, queueName string) Event { if producer == nil { panic(errors.Errorf("NewEvent(producer=nil)")) } @@ -31,7 +31,7 @@ func NewEvent(producer IProducerLogger, queueName string) Event { } type Event struct { - producer IProducerLogger + producer ProducerLogger MessageID string //assigned by implementation (AWS/mem/..) QueueName string //queue determine sequencing, items in same queue are delivered one-after-the-other, other queues may deliver concurrent to this queue TypeName string //type determines which handler processes the event @@ -82,9 +82,9 @@ func (event Event) Params(params map[string]string) Event { } func (event Event) Send(value interface{}) (string, error) { - event.producer.Debugf("Queue(%s) Send SQS Event: %v", - event.QueueName, - event) + if event.producer == nil { + return "", errors.Errorf("Send with producer==nil") + } if value != nil { jsonBody, err := json.Marshal(value) if err != nil { @@ -92,12 +92,20 @@ func (event Event) Send(value interface{}) (string, error) { } event.BodyJSON = string(jsonBody) } - if event.producer == nil { - return "", errors.Errorf("Send with producer==nil") - } + event.producer.Debugf("Queue(%s) Sending SQS Event: %v", + event.QueueName, + event) + if msgID, err := event.producer.Send(event); err != nil { return "", errors.Wrapf(err, "failed to send event") } else { + event.producer.Infof("SENT(%s) Queue(%s).Type(%s).Due(%s): (%T)%v", + msgID, + event.QueueName, + event.TypeName, + event.DueTime, + value, + value) return msgID, nil } } diff --git a/service/producer.go b/service/producer.go new file mode 100644 index 0000000000000000000000000000000000000000..050c56f8134cd77432b3383e827d163933f60aa8 --- /dev/null +++ b/service/producer.go @@ -0,0 +1,7 @@ +package service + +//Producer sends an event for async processing +type Producer interface { + Send(event Event) (msgID string, err error) + //todo: method to request an event after some delay with incrementing attempt nr +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..92e45a245ab2b9dac1b34223eb5bda9117b1683b --- /dev/null +++ b/service/service.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "os" + + "gitlab.com/uafrica/go-utils/audit" + "gitlab.com/uafrica/go-utils/errors" + "gitlab.com/uafrica/go-utils/logger" + "gitlab.com/uafrica/go-utils/string_utils" +) + +type Service interface { + logger.Logger + IErrorReporter + audit.Auditor + WithStarter(name string, starter IStarter) Service + WithProducer(producer Producer) Service + WithAuditor(auditor audit.Auditor) Service + WithErrorReporter(reporter IErrorReporter) Service + NewContext(base context.Context, requestID string, values map[string]interface{}) (Context, error) +} + +func New() Service { + env := os.Getenv("ENVIRONMENT") //todo: support config loading for local dev and env for lambda in prod + if env == "" { + env = "dev" + } + return service{ + Logger: logger.New().WithFields(map[string]interface{}{"env": env}), + IErrorReporter: DoNotReportErrors{}, + Auditor: audit.None(), + env: env, + starters: map[string]IStarter{}, + } +} + +type service struct { + logger.Logger //for logging outside of context + Producer //for sending async events + IErrorReporter + audit.Auditor + env string + starters map[string]IStarter +} + +func (s service) Env() string { + return s.env +} + +//adds a starter function to call in each new context +//they will be called in the sequence they were added (before api/cron/queue checks) +//and they do not have details about the event +//if starter returns error, processing fails +//if starter succeeds, and return !=nil data, it is stored against the name +// so your handler can retieve it with: +// checkData := ctx.Value(name).(expectedType) +// or +// checkData,ok := ctx.Value(name).(expectedType) +// if !ok { ... } +//you can implement one starter that does everything and return a struct or +//implement one for your db, one for rate limit, one for ... +//the name must be snake-case, e.g. "this_is_my_starter_name" +func (s service) WithStarter(name string, starter IStarter) Service { + if !string_utils.IsSnakeCase(name) { + panic(errors.Errorf("invalid starter name=\"%s\", expecting snake_case names only", name)) + } + if starter == nil { + panic(errors.Errorf("starter(%s)==nil", name)) + } + if _, ok := s.starters[name]; ok { + panic(errors.Errorf("starter(%s) already defined", name)) + } + s.starters[name] = starter + return s +} + +func (s service) WithProducer(producer Producer) Service { + if producer != nil { + s.Producer = producer + } + return s +} + +func (s service) WithErrorReporter(reporter IErrorReporter) Service { + if reporter == nil { + panic(errors.Errorf("ErrorReporter==nil")) + } + s.IErrorReporter = reporter + return s +} + +func (s service) WithAuditor(auditor audit.Auditor) Service { + if auditor != nil { + s.Auditor = auditor + } + return s +} + +type IErrorReporter interface { + ReportError(fields map[string]interface{}, err error) +} + +type DoNotReportErrors struct{} + +func (DoNotReportErrors) ReportError(fields map[string]interface{}, err error) {} diff --git a/service/start.go b/service/start.go new file mode 100644 index 0000000000000000000000000000000000000000..c30c11733164a66a642b6d89f1ce1f264525e465 --- /dev/null +++ b/service/start.go @@ -0,0 +1,8 @@ +package service + +type IStarter interface { + //called at the start of api/cron/queues processing, before checks, e.g. to ensure we have db connection + //i.e. setup things that does not depend on the request/event details + //if you need the request details, you need to implement a check for each of the api, cron and/or queue as needed, not a Start() method. + Start(ctx Context) (interface{}, error) +} diff --git a/string_utils/snake.go b/string_utils/snake.go new file mode 100644 index 0000000000000000000000000000000000000000..358d7fa5e2e503e1f417fc507bd659cbbb30f8f2 --- /dev/null +++ b/string_utils/snake.go @@ -0,0 +1,11 @@ +package string_utils + +import "regexp" + +const snakeCasePattern = `[a-z]([a-z0-9_]*[a-z0-9])*` + +var snakeCaseRegex = regexp.MustCompile("^" + snakeCasePattern + "$") + +func IsSnakeCase(name string) bool { + return snakeCaseRegex.MatchString(name) +}