From 42c577f80a91beb8ab72948d4a17b243a3bd60d5 Mon Sep 17 00:00:00 2001 From: optclblast Date: Fri, 3 May 2024 00:11:28 +0300 Subject: [PATCH] init --- backend/cmd/commands/commands.go | 13 +++ backend/cmd/main.go | 55 +++++++++ backend/go.mod | 19 +++ backend/go.sum | 23 ++++ backend/internal/factory/service.go | 1 + .../internal/interface/controllers/ping.go | 29 +++++ .../internal/interface/controllers/root.go | 13 +++ backend/internal/interface/rest/errors.go | 23 ++++ backend/internal/interface/rest/server.go | 108 ++++++++++++++++++ backend/internal/logger/logger.go | 88 ++++++++++++++ backend/internal/logger/slogdiscard.go | 32 ++++++ backend/internal/logger/slogpretty.go | 97 ++++++++++++++++ backend/internal/service/service.go | 41 +++++++ backend/internal/sqlutils/tx.go | 85 ++++++++++++++ 14 files changed, 627 insertions(+) create mode 100644 backend/cmd/commands/commands.go create mode 100644 backend/cmd/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/factory/service.go create mode 100644 backend/internal/interface/controllers/ping.go create mode 100644 backend/internal/interface/controllers/root.go create mode 100644 backend/internal/interface/rest/errors.go create mode 100644 backend/internal/interface/rest/server.go create mode 100644 backend/internal/logger/logger.go create mode 100644 backend/internal/logger/slogdiscard.go create mode 100644 backend/internal/logger/slogpretty.go create mode 100644 backend/internal/service/service.go create mode 100644 backend/internal/sqlutils/tx.go diff --git a/backend/cmd/commands/commands.go b/backend/cmd/commands/commands.go new file mode 100644 index 0000000..551d43d --- /dev/null +++ b/backend/cmd/commands/commands.go @@ -0,0 +1,13 @@ +package commands + +import "github.com/urfave/cli/v2" + +var commandPool []*cli.Command + +func Register(c *cli.Command) { + commandPool = append(commandPool, c) +} + +func Commands() []*cli.Command { + return commandPool +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..6b3285e --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/emochka2007/block-accounting/cmd/commands" + + cli "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "biocom-ioannes", + Version: "0.0.1a", + Commands: commands.Commands(), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "log-level", + Value: "debug", + }, + &cli.StringFlag{ + Name: "address", + Value: "localhost:8080", + }, + &cli.StringFlag{ + Name: "db-host", + }, + &cli.StringFlag{ + Name: "db-user", + }, + &cli.StringFlag{ + Name: "db-password", + }, + }, + Action: func(c *cli.Context) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // todo build config + + // todo build service + + // todo run service + + return nil + }, + } + + if err := app.Run(os.Args); err != nil { + panic(err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..536df6d --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,19 @@ +module github.com/emochka2007/block-accounting + +go 1.22.2 + +require ( + github.com/fatih/color v1.16.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/urfave/cli/v2 v2.27.2 + golang.org/x/sync v0.7.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..9da23ad --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,23 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/internal/factory/service.go b/backend/internal/factory/service.go new file mode 100644 index 0000000..7312cd2 --- /dev/null +++ b/backend/internal/factory/service.go @@ -0,0 +1 @@ +package factory diff --git a/backend/internal/interface/controllers/ping.go b/backend/internal/interface/controllers/ping.go new file mode 100644 index 0000000..5be0092 --- /dev/null +++ b/backend/internal/interface/controllers/ping.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "context" + "log/slog" + "net/http" +) + +type PingController interface { + HandlePing(ctx context.Context, req *http.Request, w http.ResponseWriter) error +} + +type pingController struct { + log *slog.Logger +} + +func NewPingController( + log *slog.Logger, +) PingController { + return &pingController{ + log: log, + } +} + +func (c *pingController) HandlePing(ctx context.Context, req *http.Request, w http.ResponseWriter) error { + _, err := w.Write([]byte("pong")) + + return err +} diff --git a/backend/internal/interface/controllers/root.go b/backend/internal/interface/controllers/root.go new file mode 100644 index 0000000..15fb840 --- /dev/null +++ b/backend/internal/interface/controllers/root.go @@ -0,0 +1,13 @@ +package controllers + +type RootController struct { + Ping PingController +} + +func NewRootController( + ping PingController, +) *RootController { + return &RootController{ + Ping: ping, + } +} diff --git a/backend/internal/interface/rest/errors.go b/backend/internal/interface/rest/errors.go new file mode 100644 index 0000000..52b95a0 --- /dev/null +++ b/backend/internal/interface/rest/errors.go @@ -0,0 +1,23 @@ +package rest + +import "net/http" + +type apiError struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func buildApiError(code int, message string) apiError { + return apiError{ + Code: code, + Error: message, + } +} + +func mapError(_ error) apiError { + // todo map typed errors + switch { + default: + return buildApiError(http.StatusInternalServerError, "Internal Server Error") + } +} diff --git a/backend/internal/interface/rest/server.go b/backend/internal/interface/rest/server.go new file mode 100644 index 0000000..1f3ed8f --- /dev/null +++ b/backend/internal/interface/rest/server.go @@ -0,0 +1,108 @@ +package rest + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "sync" + + "github.com/emochka2007/block-accounting/internal/interface/controllers" + "github.com/emochka2007/block-accounting/internal/logger" + "github.com/go-chi/chi/v5" + mw "github.com/go-chi/chi/v5/middleware" +) + +type Server struct { + *chi.Mux + + ctx context.Context + + log *slog.Logger + addr string + tls bool + controllers *controllers.RootController + + closeMu sync.RWMutex + closed bool +} + +func NewServer( + log *slog.Logger, + addr string, + controllers *controllers.RootController, +) *Server { + s := &Server{ + log: log, + addr: addr, + controllers: controllers, + } + + s.buildRouter() + + return s +} + +func (s *Server) Serve(ctx context.Context) error { + s.ctx = ctx + + if s.tls { + return http.ListenAndServeTLS(s.addr, "/todo", "/todo", s) + } + + return http.ListenAndServe(s.addr, s) +} + +func (s *Server) Close() { + s.closeMu.Lock() + defer s.closeMu.Unlock() + + s.closed = true +} + +func (s *Server) buildRouter() { + s.Mux = chi.NewRouter() + + s.With(mw.Recoverer) + s.With(mw.RequestID) + s.With(s.handleMw) + + s.Get("/ping", s.handlePing) + + // todo build rest api router +} + +func (s *Server) responseError(w http.ResponseWriter, e error) { + apiErr := mapError(e) + + out, err := json.Marshal(apiErr) + if err != nil { + s.log.Error("error marshal api error", logger.Err(err)) + + return + } + + w.WriteHeader(apiErr.Code) + w.Write(out) +} + +func (s *Server) handleMw(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + s.closeMu.RLock() + defer s.closeMu.Unlock() + + if s.closed { // keep mutex closed + return + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} + +func (s *Server) handlePing(w http.ResponseWriter, req *http.Request) { + if err := s.controllers.Ping.HandlePing(s.ctx, req, w); err != nil { + s.responseError(w, err) + } +} diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..135a991 --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,88 @@ +package logger + +import ( + "io" + "log/slog" + "os" +) + +type LoggerBuilder struct { + local bool + addSource bool + lvl slog.Level + writers []io.Writer +} + +func (b *LoggerBuilder) WithWriter(w io.Writer) *LoggerBuilder { + b.writers = append(b.writers, w) + + return b +} + +func (b *LoggerBuilder) WithLevel(l slog.Level) *LoggerBuilder { + b.lvl = l + + return b +} + +func (b *LoggerBuilder) Local() *LoggerBuilder { + b.local = true + + return b +} + +func (b *LoggerBuilder) WithSource() *LoggerBuilder { + b.addSource = true + + return b +} + +func (b *LoggerBuilder) Build() *slog.Logger { + w := io.MultiWriter(b.writers...) + + if b.local { + opts := PrettyHandlerOptions{ + SlogOpts: &slog.HandlerOptions{ + Level: b.lvl, + AddSource: b.addSource, + }, + } + + handler := opts.NewPrettyHandler(w) + + return slog.New(handler) + } + + return slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: b.lvl, + AddSource: b.addSource, + }), + ) +} + +func newLogger(lvl slog.Level, w io.Writer) *slog.Logger { + return slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}), + ) +} + +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} + +func MapLevel(lvl string) slog.Level { + switch lvl { + case "dev", "local", "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/backend/internal/logger/slogdiscard.go b/backend/internal/logger/slogdiscard.go new file mode 100644 index 0000000..b96f44e --- /dev/null +++ b/backend/internal/logger/slogdiscard.go @@ -0,0 +1,32 @@ +package logger + +import ( + "context" + "log/slog" +) + +func NewDiscardLogger() *slog.Logger { + return slog.New(NewDiscardHandler()) +} + +type DiscardHandler struct{} + +func NewDiscardHandler() *DiscardHandler { + return &DiscardHandler{} +} + +func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { + return nil +} + +func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return h +} + +func (h *DiscardHandler) WithGroup(_ string) slog.Handler { + return h +} + +func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { + return false +} diff --git a/backend/internal/logger/slogpretty.go b/backend/internal/logger/slogpretty.go new file mode 100644 index 0000000..d6d56b9 --- /dev/null +++ b/backend/internal/logger/slogpretty.go @@ -0,0 +1,97 @@ +package logger + +import ( + "context" + "encoding/json" + "io" + stdlog "log" + "log/slog" + + "github.com/fatih/color" +) + +type PrettyHandlerOptions struct { + SlogOpts *slog.HandlerOptions +} + +type PrettyHandler struct { + opts PrettyHandlerOptions + slog.Handler + l *stdlog.Logger + attrs []slog.Attr +} + +func (opts PrettyHandlerOptions) NewPrettyHandler( + out io.Writer, +) *PrettyHandler { + h := &PrettyHandler{ + Handler: slog.NewJSONHandler(out, opts.SlogOpts), + l: stdlog.New(out, "", 0), + } + + return h +} + +func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { + level := r.Level.String() + ":" + + switch r.Level { + case slog.LevelDebug: + level = color.MagentaString(level) + case slog.LevelInfo: + level = color.BlueString(level) + case slog.LevelWarn: + level = color.YellowString(level) + case slog.LevelError: + level = color.RedString(level) + } + + fields := make(map[string]interface{}, r.NumAttrs()) + + r.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + + return true + }) + + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + + var b []byte + var err error + + if len(fields) > 0 { + b, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return err + } + } + + timeStr := r.Time.Format("[15:05:05.000]") + msg := color.CyanString(r.Message) + + h.l.Println( + timeStr, + level, + msg, + color.WhiteString(string(b)), + ) + + return nil +} + +func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &PrettyHandler{ + Handler: h.Handler, + l: h.l, + attrs: attrs, + } +} + +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + return &PrettyHandler{ + Handler: h.Handler.WithGroup(name), + l: h.l, + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go new file mode 100644 index 0000000..c0dbbb1 --- /dev/null +++ b/backend/internal/service/service.go @@ -0,0 +1,41 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/emochka2007/block-accounting/internal/interface/rest" + "golang.org/x/sync/errgroup" +) + +type Service interface { + Run(ctx context.Context) error + Stop() +} + +type service struct { + log *slog.Logger + rest *rest.Server +} + +func NewService( + log *slog.Logger, + rest *rest.Server, +) Service { + return &service{ + log: log, + } +} + +func (s *service) Run(ctx context.Context) error { + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + return s.rest.Serve(ctx) + }) + + return g.Wait() +} + +func (s *service) Stop() { +} diff --git a/backend/internal/sqlutils/tx.go b/backend/internal/sqlutils/tx.go new file mode 100644 index 0000000..ad0f9c6 --- /dev/null +++ b/backend/internal/sqlutils/tx.go @@ -0,0 +1,85 @@ +package sqltools + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +type DBTX interface { + Query(query string, args ...any) (*sql.Rows, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + Exec(query string, args ...any) (sql.Result, error) + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +type TransactionalStorage interface { + Transaction(ctx context.Context, db *sql.DB, f func(ctx context.Context) error) error + Conn(ctx context.Context) DBTX +} + +type txCtxKey struct{} + +func Transaction(ctx context.Context, db *sql.DB, fn func(context.Context) error) error { + var err error + + var tx *sql.Tx = new(sql.Tx) + + hasExternalTx := hasExternalTransaction(ctx) + + defer func() { + if hasExternalTx { + if err != nil { + err = fmt.Errorf("error perform operation. %w", err) + return + } + + return + } + + if err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + err = errors.Join(fmt.Errorf("error rollback transaction. %w", rbErr), err) + return + } + + err = fmt.Errorf("error execute transactional operation. %w", err) + + return + } + + if commitErr := tx.Commit(); commitErr != nil { + if rbErr := tx.Rollback(); rbErr != nil { + err = errors.Join(fmt.Errorf("error rollback transaction. %w", rbErr), commitErr, err) + + return + } + + err = fmt.Errorf("error commit transaction. %w", err) + } + }() + + if !hasExternalTx { + tx, err = db.BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, + }) + if err != nil { + return fmt.Errorf("error begin transaction. %w", err) + } + + ctx = context.WithValue(ctx, txCtxKey{}, tx) + } + + return fn(ctx) +} + +func hasExternalTransaction(ctx context.Context) bool { + if _, ok := ctx.Value(txCtxKey{}).(*sql.Tx); ok { + return true + } + + return false +}