init
This commit is contained in:
76
core/appcontext/context.go
Normal file
76
core/appcontext/context.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package appcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type customContextType string
|
||||
|
||||
const (
|
||||
AppCtx customContextType = "appCtx"
|
||||
)
|
||||
|
||||
type AppExtContext struct {
|
||||
UserEmail string // Email of the user
|
||||
RequestID string // RequestID - used to track logs across a request-response cycle
|
||||
Locale string // Locale for language
|
||||
Application string // application for dynamic application auth
|
||||
}
|
||||
|
||||
type AppContext struct {
|
||||
context.Context
|
||||
AppExtContext
|
||||
}
|
||||
|
||||
func GetAppCtx(ctx context.Context) (AppExtContext, bool) {
|
||||
if ctx == nil {
|
||||
return AppExtContext{}, false
|
||||
}
|
||||
appCtx, exists := ctx.Value(AppCtx).(AppExtContext)
|
||||
return appCtx, exists
|
||||
}
|
||||
|
||||
func WithAppCtx(ctx context.Context, appCtx AppExtContext) context.Context {
|
||||
return context.WithValue(ctx, AppCtx, appCtx)
|
||||
}
|
||||
|
||||
func UpgradeCtx(ctx context.Context) AppContext {
|
||||
var appCtx AppContext
|
||||
tCtx, _ := GetAppCtx(ctx)
|
||||
|
||||
appCtx.Context = ctx
|
||||
appCtx.AppExtContext = tCtx
|
||||
return appCtx
|
||||
}
|
||||
|
||||
func NewAppContext() AppContext {
|
||||
return AppContext{
|
||||
Context: context.Background(),
|
||||
AppExtContext: AppExtContext{},
|
||||
}
|
||||
}
|
||||
|
||||
func CopyAppContext(ctx context.Context) AppContext {
|
||||
appCtx, _ := GetAppCtx(ctx)
|
||||
return AppContext{
|
||||
Context: context.Background(),
|
||||
AppExtContext: appCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func New(id ...string) AppContext {
|
||||
var requestID string
|
||||
if len(id) > 0 {
|
||||
requestID = id[0]
|
||||
}
|
||||
if len(requestID) == 0 {
|
||||
requestID = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
appCtx := AppExtContext{
|
||||
RequestID: requestID,
|
||||
}
|
||||
ctx := UpgradeCtx(WithAppCtx(context.Background(), appCtx))
|
||||
return ctx
|
||||
}
|
||||
61
core/appserver/brotli.go
Normal file
61
core/appserver/brotli.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package appserver
|
||||
|
||||
import (
|
||||
"github.com/andybalholm/brotli"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type brotliResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
w *brotli.Writer
|
||||
statusCode int
|
||||
headerWritten bool
|
||||
}
|
||||
|
||||
var (
|
||||
poolbr = sync.Pool{
|
||||
New: func() interface{} {
|
||||
w := brotli.NewWriterLevel(nil, brotli.BestSpeed)
|
||||
return &brotliResponseWriter{
|
||||
w: w,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func (br *brotliResponseWriter) WriteHeader(statusCode int) {
|
||||
br.statusCode = statusCode
|
||||
br.headerWritten = true
|
||||
|
||||
if br.statusCode != http.StatusNotModified && br.statusCode != http.StatusNoContent {
|
||||
br.ResponseWriter.Header().Del("Content-Length")
|
||||
br.ResponseWriter.Header().Set("Content-Encoding", "br")
|
||||
}
|
||||
|
||||
br.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
func (br *brotliResponseWriter) Write(b []byte) (int, error) {
|
||||
if _, ok := br.Header()["Content-Type"]; !ok {
|
||||
// If no content type, apply sniffing algorithm to un-gzipped body.
|
||||
br.ResponseWriter.Header().Set("Content-Type", http.DetectContentType(b))
|
||||
}
|
||||
|
||||
if !br.headerWritten {
|
||||
// This is exactly what Go would also do if it hasn't been written yet.
|
||||
br.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
return br.w.Write(b)
|
||||
}
|
||||
|
||||
func (br *brotliResponseWriter) Flush() {
|
||||
if br.w != nil {
|
||||
br.w.Flush()
|
||||
}
|
||||
|
||||
if fw, ok := br.ResponseWriter.(http.Flusher); ok {
|
||||
fw.Flush()
|
||||
}
|
||||
}
|
||||
62
core/appserver/gzip.go
Normal file
62
core/appserver/gzip.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package appserver
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
w *gzip.Writer
|
||||
statusCode int
|
||||
headerWritten bool
|
||||
}
|
||||
|
||||
var (
|
||||
pool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
w, _ := gzip.NewWriterLevel(nil, gzip.BestSpeed)
|
||||
return &gzipResponseWriter{
|
||||
w: w,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func (gzr *gzipResponseWriter) WriteHeader(statusCode int) {
|
||||
gzr.statusCode = statusCode
|
||||
gzr.headerWritten = true
|
||||
|
||||
if gzr.statusCode != http.StatusNotModified && gzr.statusCode != http.StatusNoContent {
|
||||
gzr.ResponseWriter.Header().Del("Content-Length")
|
||||
gzr.ResponseWriter.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
|
||||
gzr.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (gzr *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
if _, ok := gzr.Header()["Content-Type"]; !ok {
|
||||
// If no content type, apply sniffing algorithm to un-gzipped body.
|
||||
gzr.ResponseWriter.Header().Set("Content-Type", http.DetectContentType(b))
|
||||
}
|
||||
|
||||
if !gzr.headerWritten {
|
||||
// This is exactly what Go would also do if it hasn't been written yet.
|
||||
gzr.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
return gzr.w.Write(b)
|
||||
}
|
||||
|
||||
func (gzr *gzipResponseWriter) Flush() {
|
||||
if gzr.w != nil {
|
||||
gzr.w.Flush()
|
||||
}
|
||||
|
||||
if fw, ok := gzr.ResponseWriter.(http.Flusher); ok {
|
||||
fw.Flush()
|
||||
}
|
||||
}
|
||||
108
core/appserver/httpServer.go
Normal file
108
core/appserver/httpServer.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package appserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/context"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/log"
|
||||
"go.elastic.co/apm/module/apmgorilla"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
)
|
||||
|
||||
var (
|
||||
AppName string
|
||||
)
|
||||
|
||||
func (s *server) Start(ctx appcontext.AppContext) {
|
||||
allowedOrigins := handlers.AllowedOrigins([]string{"*"}) // Allowing all origin as of now
|
||||
|
||||
allowedHeaders := handlers.AllowedHeaders([]string{
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
"contentType",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"Client-Security-Token",
|
||||
"X-CSRF-Token",
|
||||
"X-Auth-Token",
|
||||
"processData",
|
||||
"Authorization",
|
||||
"Access-Control-Request-Headers",
|
||||
"Access-Control-Request-Method",
|
||||
"Connection",
|
||||
"Host",
|
||||
"Origin",
|
||||
"User-Agent",
|
||||
"Referer",
|
||||
"Cache-Control",
|
||||
"X-header",
|
||||
"X-Requested-With",
|
||||
"timezone",
|
||||
"locale",
|
||||
"gzip-compress",
|
||||
"task",
|
||||
"access_token",
|
||||
"application",
|
||||
})
|
||||
|
||||
allowedMethods := handlers.AllowedMethods([]string{
|
||||
"POST",
|
||||
"GET",
|
||||
"DELETE",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"OPTIONS"})
|
||||
|
||||
allowCredential := handlers.AllowCredentials()
|
||||
|
||||
serverHandler := handlers.CORS(
|
||||
allowedHeaders,
|
||||
allowedMethods,
|
||||
allowedOrigins,
|
||||
allowCredential)(
|
||||
context.ClearHandler(
|
||||
s.newRouter(s.subRoute),
|
||||
),
|
||||
)
|
||||
log.GenericInfo(ctx, "Starting Server",
|
||||
log.FieldsMap{
|
||||
"Port": s.port,
|
||||
"SubRoute": s.subRoute,
|
||||
"App": AppName,
|
||||
})
|
||||
|
||||
err := http.ListenAndServe(":"+s.port, serverHandler)
|
||||
if err != nil {
|
||||
log.GenericError(ctx, errors.New("failed to start appserver"),
|
||||
log.FieldsMap{
|
||||
"Port": s.port,
|
||||
"SubRoute": s.subRoute,
|
||||
"App": AppName,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handles all incoming request who matches registered routes against the request.
|
||||
func (s *server) newRouter(subRoute string) *mux.Router {
|
||||
muxRouter := mux.NewRouter().StrictSlash(true)
|
||||
muxRouter.HandleFunc(subRoute+"/debug/pprof", pprof.Index)
|
||||
muxRouter.HandleFunc(subRoute+"/debug/pprof/cmdline", pprof.Cmdline)
|
||||
muxRouter.HandleFunc(subRoute+"/debug/pprof/profile", pprof.Profile)
|
||||
muxRouter.HandleFunc(subRoute+"/debug/pprof/symbol", pprof.Symbol)
|
||||
muxRouter.HandleFunc(subRoute+"/debug/pprof/trace", pprof.Trace)
|
||||
muxRouter.Handle(subRoute+"/debug/pprof/goroutine", pprof.Handler("goroutine"))
|
||||
muxRouter.Handle(subRoute+"/debug/pprof/heap", pprof.Handler("heap"))
|
||||
muxRouter.Handle(subRoute+"/debug/pprof/thread/create", pprof.Handler("threadcreate"))
|
||||
muxRouter.Handle(subRoute+"/debug/pprof/block", pprof.Handler("block"))
|
||||
muxRouter.Use(SetTraceID, apmgorilla.Middleware())
|
||||
for _, r := range s.routes {
|
||||
muxRouter.HandleFunc(subRoute+r.Pattern, r.HandlerFunc).Methods(r.Method)
|
||||
}
|
||||
|
||||
return muxRouter
|
||||
}
|
||||
195
core/appserver/middleware.go
Normal file
195
core/appserver/middleware.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package appserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/felixge/httpsnoop"
|
||||
"github.com/google/uuid"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/log"
|
||||
"go.elastic.co/apm"
|
||||
"go.elastic.co/apm/module/apmhttp"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const TraceID = "traceid"
|
||||
|
||||
func SetTraceID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var traceID apm.TraceID
|
||||
if values := r.Header[apmhttp.W3CTraceparentHeader]; len(values) == 1 && values[0] != "" {
|
||||
if c, err := apmhttp.ParseTraceparentHeader(values[0]); err == nil {
|
||||
traceID = c.Trace
|
||||
}
|
||||
}
|
||||
if err := traceID.Validate(); err != nil {
|
||||
uuidId := uuid.New()
|
||||
var spanID apm.SpanID
|
||||
var traceOptions apm.TraceOptions
|
||||
copy(traceID[:], uuidId[:])
|
||||
copy(spanID[:], traceID[8:])
|
||||
traceContext := apm.TraceContext{
|
||||
Trace: traceID,
|
||||
Span: spanID,
|
||||
Options: traceOptions.WithRecorded(true),
|
||||
}
|
||||
r.Header.Set(apmhttp.W3CTraceparentHeader, apmhttp.FormatTraceparentHeader(traceContext))
|
||||
}
|
||||
|
||||
w.Header().Set(TraceID, traceID.String())
|
||||
r.Header.Set(requestID, traceID.String())
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func enableCompression(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") && !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next(w, r)
|
||||
return
|
||||
} else if !strings.Contains(r.Header.Get("Accept-Encoding"), "br") {
|
||||
gzr := pool.Get().(*gzipResponseWriter)
|
||||
gzr.statusCode = 0
|
||||
gzr.headerWritten = false
|
||||
gzr.ResponseWriter = w
|
||||
gzr.w.Reset(w)
|
||||
defer func() {
|
||||
// gzr.w.Close will write a footer even if no data has been written.
|
||||
// StatusNotModified and StatusNoContent expect an empty body so don't close it.
|
||||
if gzr.statusCode != http.StatusNotModified && gzr.statusCode != http.StatusNoContent {
|
||||
if err := gzr.w.Close(); err != nil {
|
||||
ctx := appcontext.UpgradeCtx(r.Context())
|
||||
log.GenericError(ctx, err, nil)
|
||||
}
|
||||
}
|
||||
pool.Put(gzr)
|
||||
}()
|
||||
next(gzr, r)
|
||||
return
|
||||
}
|
||||
br := poolbr.Get().(*brotliResponseWriter)
|
||||
br.statusCode = 0
|
||||
br.headerWritten = false
|
||||
br.ResponseWriter = w
|
||||
br.w.Reset(w)
|
||||
|
||||
defer func() {
|
||||
// brotli.w.Close will write a footer even if no data has been written.x
|
||||
// StatusNotModified and StatusNoContent expect an empty body so don't close it.
|
||||
if br.statusCode != http.StatusNotModified && br.statusCode != http.StatusNoContent {
|
||||
if err := br.w.Close(); err != nil {
|
||||
ctx := appcontext.UpgradeCtx(r.Context())
|
||||
log.GenericError(ctx, err, nil)
|
||||
}
|
||||
}
|
||||
poolbr.Put(br)
|
||||
}()
|
||||
next(br, r)
|
||||
}
|
||||
}
|
||||
|
||||
func recovery(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
ctx := appcontext.UpgradeCtx(r.Context())
|
||||
rec := recover()
|
||||
if rec != nil {
|
||||
span, _ := apm.StartSpan(ctx.Context, "recovery", "custom")
|
||||
defer span.End()
|
||||
trace := string(debug.Stack())
|
||||
trace = strings.Replace(trace, "\n", " ", -1)
|
||||
trace = strings.Replace(trace, "\t", " ", -1)
|
||||
log.GenericError(ctx, fmt.Errorf("%v", rec),
|
||||
log.FieldsMap{
|
||||
"msg": "recovering from panic",
|
||||
"stackTrace": trace,
|
||||
})
|
||||
jsonBody, _ := json.Marshal(map[string]string{
|
||||
"error": "There was an internal server error",
|
||||
})
|
||||
e := apm.DefaultTracer.Recovered(rec)
|
||||
e.SetSpan(span)
|
||||
e.Send()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write(jsonBody)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func logRequest(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
m := httpsnoop.CaptureMetrics(handler, w, r)
|
||||
log.HTTPLog(constructHTTPLog(r, m, time.Since(start)))
|
||||
}
|
||||
}
|
||||
|
||||
func constructHTTPLog(r *http.Request, m httpsnoop.Metrics, duration time.Duration) string {
|
||||
ctx := r.Context().Value(appcontext.AppCtx)
|
||||
|
||||
rawBody, _ := ioutil.ReadAll(r.Body)
|
||||
if len(rawBody) > 0 {
|
||||
r.Body = ioutil.NopCloser(bytes.NewBuffer(rawBody))
|
||||
}
|
||||
var jsonBody interface{}
|
||||
// For Testing
|
||||
json.Unmarshal(rawBody, &jsonBody)
|
||||
bodyJsonByte, _ := json.Marshal(jsonBody)
|
||||
if ctx != nil {
|
||||
tCtx := ctx.(appcontext.AppExtContext)
|
||||
return fmt.Sprintf("|%s|%s|%s|%s|%s|%d|%d|%s|%s|%s|",
|
||||
tCtx.UserEmail,
|
||||
"requestId="+tCtx.RequestID,
|
||||
r.RemoteAddr,
|
||||
r.Method,
|
||||
r.URL,
|
||||
m.Code,
|
||||
m.Written,
|
||||
r.UserAgent(),
|
||||
duration,
|
||||
"Body:"+string(bodyJsonByte),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf("|%s|%s|%s|%d|%d|%s|%s|%s|",
|
||||
r.RemoteAddr,
|
||||
r.Method,
|
||||
r.URL,
|
||||
m.Code,
|
||||
m.Written,
|
||||
r.UserAgent(),
|
||||
duration,
|
||||
"Body:"+string(rawBody),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func createContext(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header
|
||||
ctx := r.Context()
|
||||
reqID := header.Get(requestID)
|
||||
if reqID == "" {
|
||||
reqID = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
email, app := header.Get(userEmail), header.Get(application)
|
||||
locale := header.Get(locale)
|
||||
|
||||
tempCtx := appcontext.AppExtContext{
|
||||
RequestID: reqID,
|
||||
UserEmail: email,
|
||||
Locale: locale,
|
||||
Application: app,
|
||||
}
|
||||
|
||||
ctx = appcontext.WithAppCtx(ctx, tempCtx)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
19
core/appserver/routedef.go
Normal file
19
core/appserver/routedef.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package appserver
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *server) AddNoAuthRoutes(methodName string, methodType string, mRoute string, handlerFunc http.HandlerFunc) {
|
||||
r := route{
|
||||
Name: methodName,
|
||||
Method: methodType,
|
||||
Pattern: mRoute,
|
||||
HandlerFunc: useMiddleware(handlerFunc, recovery, enableCompression, logRequest, createContext)}
|
||||
s.routes = append(s.routes, r)
|
||||
}
|
||||
|
||||
func useMiddleware(h http.HandlerFunc, middleware ...func(http.HandlerFunc) http.HandlerFunc) http.HandlerFunc {
|
||||
for _, m := range middleware {
|
||||
h = m(h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
40
core/appserver/server.go
Normal file
40
core/appserver/server.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package appserver
|
||||
|
||||
import (
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type route struct {
|
||||
Name string
|
||||
Method string
|
||||
Pattern string
|
||||
Modules []string
|
||||
ResourcesPermissionMap interface{}
|
||||
HandlerFunc http.HandlerFunc
|
||||
}
|
||||
|
||||
type server struct {
|
||||
port string
|
||||
subRoute string
|
||||
routes []route
|
||||
}
|
||||
|
||||
const (
|
||||
requestID = "requestId"
|
||||
userEmail = "email"
|
||||
application = "application"
|
||||
locale = "locale"
|
||||
)
|
||||
|
||||
type AppServer interface {
|
||||
Start(ctx appcontext.AppContext)
|
||||
AddNoAuthRoutes(methodName string, methodType string, mRoute string, handlerFunc http.HandlerFunc)
|
||||
}
|
||||
|
||||
func NewAppServer(port, subRoute string) AppServer {
|
||||
return &server{
|
||||
port: port,
|
||||
subRoute: subRoute,
|
||||
}
|
||||
}
|
||||
260
core/log/log.go
Normal file
260
core/log/log.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
//INFO level 1
|
||||
INFO = iota
|
||||
//HTTP level 2
|
||||
HTTP
|
||||
//ERROR level 3
|
||||
ERROR
|
||||
//TRACE level 4
|
||||
TRACE
|
||||
//WARNING level 5
|
||||
WARNING
|
||||
)
|
||||
|
||||
var (
|
||||
setLevel = WARNING
|
||||
trace *log.Logger
|
||||
info *log.Logger
|
||||
warning *log.Logger
|
||||
httplog *log.Logger
|
||||
errorlog *log.Logger
|
||||
)
|
||||
|
||||
const (
|
||||
clusterType = "CLUSTER_TYPE"
|
||||
clusterTypeLocal = "local"
|
||||
clusterTypeDev = "dev1"
|
||||
)
|
||||
|
||||
// FieldsMap map of key value pair to log
|
||||
type FieldsMap map[string]interface{}
|
||||
|
||||
func init() {
|
||||
logInit(os.Stdout,
|
||||
os.Stdout,
|
||||
os.Stdout,
|
||||
os.Stdout,
|
||||
os.Stderr)
|
||||
}
|
||||
|
||||
func logInit(traceHandle, infoHandle, warningHandle, httpHandle, errorHandle io.Writer) {
|
||||
|
||||
flagWithClusterType := log.LUTC | log.LstdFlags | log.Lshortfile
|
||||
flagWithoutClusterType := log.LUTC | log.LstdFlags
|
||||
|
||||
var flag int
|
||||
|
||||
if os.Getenv(clusterType) == clusterTypeLocal || os.Getenv(clusterType) == clusterTypeDev {
|
||||
flag = flagWithClusterType
|
||||
} else {
|
||||
flag = flagWithoutClusterType
|
||||
}
|
||||
|
||||
trace = log.New(traceHandle, "TRACE|", flag)
|
||||
|
||||
info = log.New(infoHandle, "INFO|", flag)
|
||||
|
||||
warning = log.New(warningHandle, "WARNING|", flag)
|
||||
|
||||
httplog = log.New(httpHandle, "HTTP|", flag)
|
||||
|
||||
errorlog = log.New(errorHandle, "ERROR|", flagWithClusterType)
|
||||
}
|
||||
|
||||
func doLog(cLog *log.Logger, level, callDepth int, v ...interface{}) {
|
||||
if level <= setLevel {
|
||||
if level == ERROR {
|
||||
cLog.SetOutput(os.Stderr)
|
||||
cLog.SetFlags(log.Llongfile)
|
||||
}
|
||||
cLog.Output(callDepth, fmt.Sprintln(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func generatePrefix(ctx appcontext.AppContext) string {
|
||||
// TODO: Add departmentName once that is implemented fully
|
||||
return strings.Join([]string{ctx.UserEmail}, ":")
|
||||
}
|
||||
|
||||
func generateTrackingIDs(ctx appcontext.AppContext) (retString string) {
|
||||
requestID := ctx.RequestID
|
||||
|
||||
if requestID != "" {
|
||||
retString = "requestId=" + requestID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trace system gives facility to helps you isolate your system problems by monitoring selected events Ex. entry and exit
|
||||
func traceLog(v ...interface{}) {
|
||||
doLog(trace, TRACE, 4, v...)
|
||||
}
|
||||
|
||||
// Info dedicated for logging valuable information
|
||||
func infoLog(v ...interface{}) {
|
||||
doLog(info, INFO, 4, v...)
|
||||
}
|
||||
|
||||
// Warning for critical error
|
||||
func warningLog(v ...interface{}) {
|
||||
doLog(warning, WARNING, 4, v...)
|
||||
}
|
||||
|
||||
// Error logging error
|
||||
func errorLog(v ...interface{}) {
|
||||
doLog(errorlog, ERROR, 4, v...)
|
||||
}
|
||||
|
||||
func fatalLog(v ...interface{}) {
|
||||
doLog(errorlog, ERROR, 4, v...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func HTTPLog(logMessage string) {
|
||||
doLog(httplog, HTTP, 6, logMessage)
|
||||
}
|
||||
|
||||
func GenericTrace(ctx appcontext.AppContext, traceMessage string, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
if os.Getenv("SERVICE_TRACE") == "true" {
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
msg := fmt.Sprintf("|%s|%s|",
|
||||
prefix,
|
||||
trackingIDs)
|
||||
if fields != nil && len(fields) > 0 {
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
traceLog(msg, traceMessage, "|", fieldsString)
|
||||
} else {
|
||||
traceLog(msg, traceMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenericInfo(ctx appcontext.AppContext, infoMessage string, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
msg := fmt.Sprintf("|%s|%s|",
|
||||
prefix,
|
||||
trackingIDs)
|
||||
if fields != nil && len(fields) > 0 {
|
||||
infoLog(msg, infoMessage, "|", fieldsString)
|
||||
} else {
|
||||
infoLog(msg, infoMessage)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func GenericWarning(ctx appcontext.AppContext, warnMessage string, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
if os.Getenv("SERVICE_WARN") == "true" {
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
msg := fmt.Sprintf("|%s|%s|",
|
||||
prefix,
|
||||
trackingIDs)
|
||||
if fields != nil && len(fields) > 0 {
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
warningLog(msg, warnMessage, "|", fieldsString)
|
||||
} else {
|
||||
warningLog(msg, warnMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenericError(ctx appcontext.AppContext, e error, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
msg := ""
|
||||
if e != nil {
|
||||
msg = fmt.Sprintf("|%s|%s|%s", prefix, trackingIDs, e.Error())
|
||||
} else {
|
||||
msg = fmt.Sprintf("|%s|%s", prefix, trackingIDs)
|
||||
}
|
||||
|
||||
if fields != nil && len(fields) > 0 {
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
errorLog(msg, "|", fieldsString)
|
||||
} else {
|
||||
errorLog(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func CriticalError(ctx appcontext.AppContext, e error, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
msg := ""
|
||||
if e != nil {
|
||||
msg = fmt.Sprintf("|%s|%s|%s", prefix, trackingIDs, e.Error())
|
||||
} else {
|
||||
msg = fmt.Sprintf("|%s|%s", prefix, trackingIDs)
|
||||
}
|
||||
|
||||
if fields != nil && len(fields) > 0 {
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
errorLog(msg, "|", fieldsString)
|
||||
} else {
|
||||
errorLog(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func FatalLog(ctx appcontext.AppContext, e error, data ...FieldsMap) {
|
||||
var fields FieldsMap
|
||||
if len(data) > 0 {
|
||||
fields = data[0]
|
||||
}
|
||||
prefix := generatePrefix(ctx)
|
||||
trackingIDs := generateTrackingIDs(ctx)
|
||||
|
||||
msg := ""
|
||||
if e != nil {
|
||||
msg = fmt.Sprintf("|%s|%s|%s", prefix, trackingIDs, e.Error())
|
||||
} else {
|
||||
msg = fmt.Sprintf("|%s|%s", prefix, trackingIDs)
|
||||
}
|
||||
|
||||
if fields != nil && len(fields) > 0 {
|
||||
fieldsBytes, _ := json.Marshal(fields)
|
||||
fieldsString := string(fieldsBytes)
|
||||
fatalLog(msg, "|", fieldsString)
|
||||
} else {
|
||||
fatalLog(msg)
|
||||
}
|
||||
}
|
||||
239
core/mongomanager/connections.go
Normal file
239
core/mongomanager/connections.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package mongomanager
|
||||
|
||||
import (
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
func (client *mongoService) Disconnect(ctx appcontext.AppContext) error {
|
||||
return client.DB.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func (client *mongoService) CreateOne(ctx appcontext.AppContext, database, collectionName string, d interface{}) (*mongo.InsertOneResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
insertOneResult, err := collection.InsertOne(ctx.Context, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return insertOneResult, nil
|
||||
}
|
||||
|
||||
// CreateMany - inserts many data into mongo database
|
||||
func (client *mongoService) CreateMany(ctx appcontext.AppContext, database, collectionName string, d []interface{}) (*mongo.InsertManyResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
insertManyRslt, err := collection.InsertMany(ctx.Context, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return insertManyRslt, nil
|
||||
}
|
||||
|
||||
// ReadOne - reads single document from mongo database
|
||||
func (client *mongoService) ReadOne(ctx appcontext.AppContext, database, collectionName string, filter, data interface{}) error {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
err := collection.FindOne(ctx.Context, filter).Decode(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadAll - reads multiple documents from mongo database
|
||||
func (client *mongoService) ReadAll(ctx appcontext.AppContext, database, collectionName string, filter, data interface{}, opts ...*options.FindOptions) error {
|
||||
var findOptions *options.FindOptions
|
||||
if len(opts) > 0 {
|
||||
findOptions = opts[0]
|
||||
}
|
||||
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
cursor, err := collection.Find(ctx.Context, filter, findOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cursor.Close(ctx.Context)
|
||||
|
||||
err = cursor.All(ctx.Context, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update - updates data into mongo database
|
||||
func (client *mongoService) Update(ctx appcontext.AppContext, database, collectionName string, filter, update interface{}, options ...*options.UpdateOptions) (*mongo.UpdateResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
updateResult, err := collection.UpdateOne(ctx.Context, filter, update, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updateResult, nil
|
||||
}
|
||||
|
||||
// ReplaceOne - replace one document into mongo database
|
||||
func (client *mongoService) ReplaceOne(ctx appcontext.AppContext, database, collectionName string, filter, replacement interface{}, opts ...*options.ReplaceOptions) (*mongo.UpdateResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
updateResult, err := collection.ReplaceOne(ctx.Context, filter, replacement, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updateResult, nil
|
||||
}
|
||||
|
||||
// UpdateAndReturn - updates data into mongo database and returns the updated document
|
||||
func (client *mongoService) UpdateAndReturn(ctx appcontext.AppContext, database, collectionName string, filter, update, data interface{}) error {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
after := options.After
|
||||
|
||||
opts := options.FindOneAndUpdateOptions{
|
||||
ReturnDocument: &after,
|
||||
}
|
||||
|
||||
err := collection.FindOneAndUpdate(ctx.Context, filter, update, &opts).Decode(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAll - updates multiple documents into mongo database
|
||||
func (client *mongoService) UpdateAll(ctx appcontext.AppContext, database, collectionName string, filter, update interface{}, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
updateResult, err := collection.UpdateMany(ctx.Context, filter, update, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updateResult, nil
|
||||
}
|
||||
|
||||
func (client *mongoService) Upsert(ctx appcontext.AppContext, database, collectionName string, filter,
|
||||
update interface{}, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
updateOptions := options.Update()
|
||||
if len(opts) >= 1 {
|
||||
updateOptions = opts[0]
|
||||
}
|
||||
updateOptions.SetUpsert(true)
|
||||
updateResult, err := collection.UpdateOne(ctx.Context, filter, update, updateOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updateResult, nil
|
||||
}
|
||||
|
||||
// Delete - removes single doc data from the database
|
||||
func (client *mongoService) Delete(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (*mongo.DeleteResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
deleteResult, err := collection.DeleteOne(ctx.Context, filter)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
// DeleteAll - removes all doc data from the database
|
||||
func (client *mongoService) DeleteAll(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (*mongo.DeleteResult, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
deleteResult, err := collection.DeleteMany(ctx.Context, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
// CountDocuments returns document count of a collection
|
||||
func (client *mongoService) CountDocuments(ctx appcontext.AppContext, database, collectionName string, filter interface{}, opts ...*options.CountOptions) (int64, error) {
|
||||
var countOptions *options.CountOptions
|
||||
if len(opts) > 0 {
|
||||
countOptions = opts[0]
|
||||
}
|
||||
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
count, err := collection.CountDocuments(ctx.Context, filter, countOptions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Exist verifies if document is present or not
|
||||
// if it returns error then there is a connection error else boolean value specifies whether doc is present or not
|
||||
func (client *mongoService) Exist(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (bool, error) {
|
||||
var i interface{}
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
err := collection.FindOne(ctx.Context, filter).Decode(&i)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetDistinct gets the distinct values for the field name provided
|
||||
func (client *mongoService) GetDistinct(ctx appcontext.AppContext, database, collectionName, fieldName string, filter interface{}) (interface{}, error) {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
result, err := collection.Distinct(ctx.Context, fieldName, filter, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AggregateAll executes aggregation query on a collection
|
||||
// query []bson.M, data is a pointer to an array
|
||||
func (client *mongoService) AggregateAll(ctx appcontext.AppContext, database, collectionName string, query, data interface{}, options ...*options.AggregateOptions) error {
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
cursor, err := collection.Aggregate(ctx.Context, query, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cursor.All(ctx.Context, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// FindOneAndUpdate executes a findAndModify command to update at most one document in the collection and returns the
|
||||
// document as it appeared before updating.
|
||||
func (client *mongoService) FindOneAndUpdate(ctx appcontext.AppContext, database, collectionName string, filter, update, data interface{},
|
||||
opts ...*options.FindOneAndUpdateOptions) error {
|
||||
after := options.After
|
||||
option := &options.FindOneAndUpdateOptions{
|
||||
ReturnDocument: &after,
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
option = opts[0]
|
||||
}
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
result := collection.FindOneAndUpdate(ctx.Context, filter, update, option)
|
||||
if result.Err() != nil {
|
||||
return result.Err()
|
||||
}
|
||||
|
||||
decodeErr := result.Decode(data)
|
||||
if decodeErr != nil {
|
||||
return decodeErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *mongoService) BulkWrite(ctx appcontext.AppContext, database, collectionName string, operations []mongo.WriteModel, bulkOption *options.BulkWriteOptions) (*mongo.BulkWriteResult, error) {
|
||||
var err error
|
||||
collection := client.DB.Database(database).Collection(collectionName)
|
||||
result, err := collection.BulkWrite(ctx.Context, operations, bulkOption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
|
||||
}
|
||||
89
core/mongomanager/mongomanager.go
Normal file
89
core/mongomanager/mongomanager.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package mongomanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/appcontext"
|
||||
"gitlab.com/arkadooti.sarkar/go-boilerplate/core/log"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mongoService struct {
|
||||
DB *mongo.Client
|
||||
}
|
||||
|
||||
type MongoDB interface {
|
||||
Disconnect(ctx appcontext.AppContext) error
|
||||
CreateOne(ctx appcontext.AppContext, database, collectionName string, d interface{}) (*mongo.InsertOneResult, error)
|
||||
CreateMany(ctx appcontext.AppContext, database, collectionName string, d []interface{}) (*mongo.InsertManyResult, error)
|
||||
ReadOne(ctx appcontext.AppContext, database, collectionName string, filter, data interface{}) error
|
||||
ReadAll(ctx appcontext.AppContext, database, collectionName string, filter, data interface{}, opts ...*options.FindOptions) error
|
||||
Update(ctx appcontext.AppContext, database, collectionName string, filter, update interface{}, options ...*options.UpdateOptions) (*mongo.UpdateResult, error)
|
||||
ReplaceOne(ctx appcontext.AppContext, database, collectionName string, filter, replacement interface{}, opts ...*options.ReplaceOptions) (*mongo.UpdateResult, error)
|
||||
Upsert(ctx appcontext.AppContext, database, collectionName string, filter, update interface{}, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error)
|
||||
UpdateAndReturn(ctx appcontext.AppContext, database, collectionName string, filter, update, data interface{}) error
|
||||
UpdateAll(ctx appcontext.AppContext, database, collectionName string, filter, update interface{}, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error)
|
||||
Delete(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (*mongo.DeleteResult, error)
|
||||
DeleteAll(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (*mongo.DeleteResult, error)
|
||||
CountDocuments(ctx appcontext.AppContext, database, collectionName string, filter interface{}, opts ...*options.CountOptions) (int64, error)
|
||||
Exist(ctx appcontext.AppContext, database, collectionName string, filter interface{}) (bool, error)
|
||||
GetDistinct(ctx appcontext.AppContext, database, collectionName, fieldName string, filter interface{}) (interface{}, error)
|
||||
AggregateAll(ctx appcontext.AppContext, database, collectionName string, query, data interface{}, options ...*options.AggregateOptions) error
|
||||
FindOneAndUpdate(ctx appcontext.AppContext, database, collectionName string, filter, update, data interface{}, opts ...*options.FindOneAndUpdateOptions) error
|
||||
BulkWrite(ctx appcontext.AppContext, database, collectionName string, operations []mongo.WriteModel, bulkOption *options.BulkWriteOptions) (*mongo.BulkWriteResult, error)
|
||||
}
|
||||
|
||||
func NewMongoClient(url, appName string) (MongoDB, error) {
|
||||
ctx := appcontext.NewAppContext()
|
||||
client, err := connect(url, appName)
|
||||
if err != nil {
|
||||
log.GenericError(ctx, errors.New("can't connect to db: "+err.Error()), nil)
|
||||
return &mongoService{DB: client}, err
|
||||
}
|
||||
return &mongoService{DB: client}, nil
|
||||
}
|
||||
|
||||
func connect(host, appName string) (*mongo.Client, error) {
|
||||
var client *mongo.Client
|
||||
|
||||
servSelecTimeout := time.Duration(15) * time.Second
|
||||
connTimeout := time.Duration(10) * time.Second
|
||||
idleTime := time.Duration(2) * time.Minute
|
||||
socketTimeout := time.Duration(2) * time.Minute
|
||||
maxPooling := uint64(100)
|
||||
|
||||
clientOptions := &options.ClientOptions{
|
||||
AppName: &appName,
|
||||
ServerSelectionTimeout: &servSelecTimeout,
|
||||
ConnectTimeout: &connTimeout,
|
||||
MaxConnIdleTime: &idleTime,
|
||||
MaxPoolSize: &maxPooling,
|
||||
SocketTimeout: &socketTimeout,
|
||||
}
|
||||
|
||||
clientOptions = clientOptions.ApplyURI(host)
|
||||
|
||||
err := clientOptions.Validate()
|
||||
if err != nil {
|
||||
return client, err
|
||||
}
|
||||
|
||||
client, err = mongo.NewClient(clientOptions)
|
||||
if err != nil {
|
||||
return client, err
|
||||
}
|
||||
|
||||
err = client.Connect(context.TODO())
|
||||
if err != nil {
|
||||
return client, err
|
||||
}
|
||||
|
||||
err = client.Ping(context.TODO(), nil)
|
||||
if err != nil {
|
||||
return client, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
Reference in New Issue
Block a user