From 1fb7dbfe39181a33c2f015180b90e53facb9ce6f Mon Sep 17 00:00:00 2001 From: Johnny Steenbergen Date: Wed, 24 Jan 2024 21:34:51 -0600 Subject: [PATCH] chore(allsrv): add the service layer to consolidate all domain logic This might seem like a "moving the cheese" change. However, upon closer look we see that the `server_v2` implementation is purely a translation between the HTTP RESTful API and the domain. All traffic speaks to the service, which holds all the logic for the Foo domain. We've effectively decoupled the domain from the transport layer (HTTP). Any additional transport we want to support (gRPC/Thrift/etc) is merely creating the transport implementation. We won't duplicate our logic in each transport layer. Often, when we have consolidated all the business logic, it's very simple to just generate the RPC layer and inject the SVC to transact with the different API integrations. --- allsrv/cmd/allsrv/main.go | 55 +++++++++++------- allsrv/errors.go | 6 ++ allsrv/observe_db.go | 8 +-- allsrv/server.go | 61 ++++++++++---------- allsrv/server_test.go | 16 +++--- allsrv/server_v2.go | 79 ++++++++++--------------- allsrv/server_v2_test.go | 32 ++++++----- allsrv/svc.go | 118 ++++++++++++++++++++++++++++++++++++++ allsrv/svc_mw_logging.go | 96 +++++++++++++++++++++++++++++++ allsrv/svc_observer.go | 72 +++++++++++++++++++++++ 10 files changed, 417 insertions(+), 126 deletions(-) create mode 100644 allsrv/svc.go create mode 100644 allsrv/svc_mw_logging.go create mode 100644 allsrv/svc_observer.go diff --git a/allsrv/cmd/allsrv/main.go b/allsrv/cmd/allsrv/main.go index 7e295b4..ba74c36 100644 --- a/allsrv/cmd/allsrv/main.go +++ b/allsrv/cmd/allsrv/main.go @@ -1,14 +1,19 @@ package main import ( + "cmp" "database/sql" - "log" + "errors" + "log/slog" "net/http" "os" + "strings" + "time" "github.com/golang-migrate/migrate/v4" migsqlite "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/hashicorp/go-metrics" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" @@ -17,36 +22,46 @@ import ( ) func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})) + var db allsrv.DB = new(allsrv.InmemDB) if dsn := os.Getenv("ALLSRV_SQLITE_DSN"); dsn != "" { var err error db, err = newSQLiteDB(dsn) if err != nil { - log.Println("failed to open sqlite db: " + err.Error()) + logger.Error("failed to open sqlite db", "err", err.Error()) os.Exit(1) } + logger.Info("sqlite database opened", "dsn", dsn) } - var svr http.Handler - switch os.Getenv("ALLSRV_SERVER") { - case "v1": - log.Println("starting v1 server") - svr = allsrv.NewServer(db, allsrv.WithBasicAuth("admin", "pass")) - case "v2": - log.Println("starting v2 server") - svr = allsrv.NewServerV2(db, allsrv.WithBasicAuthV2("admin", "pass")) - default: // run both - log.Println("starting combination v1/v2 server") - mux := http.NewServeMux() - allsrv.NewServer(db, allsrv.WithMux(mux), allsrv.WithBasicAuth("admin", "pass")) - allsrv.NewServerV2(db, allsrv.WithMux(mux), allsrv.WithBasicAuthV2("admin", "pass")) - svr = mux + mux := http.NewServeMux() + + selectedSVR := strings.TrimSpace(strings.ToLower(os.Getenv("ALLSRV_SERVER"))) + if selectedSVR != "v2" { + logger.Info("registering v1 server") + allsrv.NewServer(db, allsrv.WithBasicAuth("admin", "pass"), allsrv.WithMux(mux)) + } + if selectedSVR != "v1" { + logger.Info("registering v2 server") + + var svc allsrv.SVC = allsrv.NewService(db) + svc = allsrv.SVCLogging(logger)(svc) + + met, err := metrics.New(metrics.DefaultConfig("allsrv"), metrics.NewInmemSink(5*time.Second, time.Minute)) + if err != nil { + logger.Error("failed to create metrics", "err", err.Error()) + os.Exit(1) + } + svc = allsrv.ObserveSVC(met)(svc) + + allsrv.NewServerV2(svc, allsrv.WithBasicAuthV2("admin", "pass"), allsrv.WithMux(mux)) } - port := ":8091" - log.Println("listening at http://localhost" + port) - if err := http.ListenAndServe(port, svr); err != nil && err != http.ErrServerClosed { - log.Println(err.Error()) + addr := "localhost:" + strings.TrimPrefix(cmp.Or(os.Getenv("ALLSRV_PORT"), "8091"), ":") + logger.Info("listening at " + addr) + if err := http.ListenAndServe(addr, mux); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("shut down error encountered", "err", err.Error(), "addr", addr) os.Exit(1) } } diff --git a/allsrv/errors.go b/allsrv/errors.go index fd35a74..d24051e 100644 --- a/allsrv/errors.go +++ b/allsrv/errors.go @@ -45,6 +45,12 @@ func NotFoundErr(msg string, fields ...any) error { } } +func errFields(err error) []any { + var aErr Err + errors.As(err, &aErr) + return aErr.Fields +} + func IsNotFoundErr(err error) bool { return isErrType(err, errTypeNotFound) } diff --git a/allsrv/observe_db.go b/allsrv/observe_db.go index 5cd38b9..719f59b 100644 --- a/allsrv/observe_db.go +++ b/allsrv/observe_db.go @@ -30,7 +30,7 @@ type dbMW struct { } func (d *dbMW) CreateFoo(ctx context.Context, f Foo) error { - span, ctx := opentracing.StartSpanFromContext(ctx, d.name+"_foo_create") + span, ctx := opentracing.StartSpanFromContext(ctx, "db_"+d.name+"_foo_create") defer span.Finish() rec := d.record("create") @@ -38,7 +38,7 @@ func (d *dbMW) CreateFoo(ctx context.Context, f Foo) error { } func (d *dbMW) ReadFoo(ctx context.Context, id string) (Foo, error) { - span, ctx := opentracing.StartSpanFromContext(ctx, d.name+"_foo_read") + span, ctx := opentracing.StartSpanFromContext(ctx, "db_"+d.name+"_foo_read") defer span.Finish() rec := d.record("read") @@ -47,7 +47,7 @@ func (d *dbMW) ReadFoo(ctx context.Context, id string) (Foo, error) { } func (d *dbMW) UpdateFoo(ctx context.Context, f Foo) error { - span, ctx := opentracing.StartSpanFromContext(ctx, d.name+"_foo_update") + span, ctx := opentracing.StartSpanFromContext(ctx, "db_"+d.name+"_foo_update") defer span.Finish() rec := d.record("update") @@ -55,7 +55,7 @@ func (d *dbMW) UpdateFoo(ctx context.Context, f Foo) error { } func (d *dbMW) DelFoo(ctx context.Context, id string) error { - span, ctx := opentracing.StartSpanFromContext(ctx, d.name+"_foo_delete") + span, ctx := opentracing.StartSpanFromContext(ctx, "db_"+d.name+"_foo_delete") defer span.Finish() rec := d.record("delete") diff --git a/allsrv/server.go b/allsrv/server.go index 5b192cb..74ea8a6 100644 --- a/allsrv/server.go +++ b/allsrv/server.go @@ -1,7 +1,6 @@ package allsrv import ( - "context" "encoding/json" "log" "net/http" @@ -28,16 +27,16 @@ import ( b) how do we know what is intended by the current implementation? ✅6) http/db are coupled to the same type a) what happens when the concerns diverge? aka http wants a shape the db does not? (note: it happens A LOT) - 7) Server only works with HTTP + ✅7) Server only works with HTTP a) what happens when we want to support grpc? thrift? other protocol? b) this setup often leads to copy pasta/weak abstractions that tend to leak ✅8) Errors are opaque and limited - 9) API is very bare bones + ✅9) API is very bare bones a) there is nothing actionable, so how does the consumer know to handle the error? b) if the APIs evolve, how does the consumer distinguish between old and new? - 10) Observability.... + ✅10) Observability.... ✅a) metrics - b) logging + ✅b) logging ✅c) tracing ✅11) hard coding UUID generation into db ✅12) possible race conditions in inmem store @@ -50,17 +49,6 @@ import ( 4) is trivial in scope */ -// Server dependencies -type ( - // DB represents the foo persistence layer. - DB interface { - CreateFoo(ctx context.Context, f Foo) error - ReadFoo(ctx context.Context, id string) (Foo, error) - UpdateFoo(ctx context.Context, f Foo) error - DelFoo(ctx context.Context, id string) error - } -) - type serverOpts struct { authFn func(http.Handler) http.Handler idFn func() string @@ -137,31 +125,32 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } -type Foo struct { - // 6) - ID string `json:"id"` - Name string `json:"name"` - Note string `json:"note"` - CreatedAt time.Time `json:"-"` - UpdatedAt time.Time `json:"-"` +// FooV0 is the API response for the legacy API. +type FooV0 struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` } func (s *Server) createFoo(w http.ResponseWriter, r *http.Request) { - var f Foo + var f FooV0 if err := json.NewDecoder(r.Body).Decode(&f); err != nil { w.WriteHeader(http.StatusForbidden) // 9) return } - f.ID = s.idFn() // 11) - - if err := s.db.CreateFoo(r.Context(), f); err != nil { + newFoo := Foo{ + ID: s.idFn(), // 11) + Name: f.Name, + Note: f.Note, + } + if err := s.db.CreateFoo(r.Context(), newFoo); err != nil { w.WriteHeader(http.StatusInternalServerError) // 9) return } w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(f); err != nil { + if err := json.NewEncoder(w).Encode(newFoo); err != nil { log.Printf("unexpected error writing json value to response body: " + err.Error()) // 8) 10) } } @@ -173,19 +162,29 @@ func (s *Server) readFoo(w http.ResponseWriter, r *http.Request) { return } - if err := json.NewEncoder(w).Encode(f); err != nil { + out := FooV0{ + ID: f.ID, + Name: f.Name, + Note: f.Note, + } + if err := json.NewEncoder(w).Encode(out); err != nil { log.Printf("unexpected error writing json value to response body: " + err.Error()) // 8) 10) } } func (s *Server) updateFoo(w http.ResponseWriter, r *http.Request) { - var f Foo + var f FooV0 if err := json.NewDecoder(r.Body).Decode(&f); err != nil { w.WriteHeader(http.StatusForbidden) // 9) return } - if err := s.db.UpdateFoo(r.Context(), f); err != nil { + updateFoo := Foo{ + ID: f.ID, + Name: f.Name, + Note: f.Note, + } + if err := s.db.UpdateFoo(r.Context(), updateFoo); err != nil { w.WriteHeader(http.StatusInternalServerError) // 9) return } diff --git a/allsrv/server_test.go b/allsrv/server_test.go index 97724c5..6b56a9e 100644 --- a/allsrv/server_test.go +++ b/allsrv/server_test.go @@ -29,7 +29,7 @@ func TestServer(t *testing.T) { ) svr = allsrv.ObserveHandler("allsrv", met)(svr) - req := httptest.NewRequest("POST", "/foo", newJSONBody(t, allsrv.Foo{ + req := httptest.NewRequest("POST", "/foo", newJSONBody(t, allsrv.FooV0{ Name: "first-foo", Note: "some note", })) @@ -39,8 +39,8 @@ func TestServer(t *testing.T) { svr.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code) - expectJSONBody(t, rec.Body, func(t *testing.T, got allsrv.Foo) { - want := allsrv.Foo{ + expectJSONBody(t, rec.Body, func(t *testing.T, got allsrv.FooV0) { + want := allsrv.FooV0{ ID: "id1", Name: "first-foo", Note: "some note", @@ -52,7 +52,7 @@ func TestServer(t *testing.T) { t.Run("when provided invalid basic auth should fail", func(t *testing.T) { svr := allsrv.NewServer(new(allsrv.InmemDB), allsrv.WithBasicAuth("dodgers@stink.com", "PaSsWoRd")) - req := httptest.NewRequest("POST", "/foo", newJSONBody(t, allsrv.Foo{ + req := httptest.NewRequest("POST", "/foo", newJSONBody(t, allsrv.FooV0{ Name: "first-foo", Note: "some note", })) @@ -86,8 +86,8 @@ func TestServer(t *testing.T) { svr.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - expectJSONBody(t, rec.Body, func(t *testing.T, got allsrv.Foo) { - want := allsrv.Foo{ + expectJSONBody(t, rec.Body, func(t *testing.T, got allsrv.FooV0) { + want := allsrv.FooV0{ ID: "reader1", Name: "read", Note: "another note", @@ -123,7 +123,7 @@ func TestServer(t *testing.T) { var svr http.Handler = allsrv.NewServer(db, allsrv.WithBasicAuth("dodgers@stink.com", "PaSsWoRd")) svr = allsrv.ObserveHandler("allsrv", met)(svr) - req := httptest.NewRequest("PUT", "/foo", newJSONBody(t, allsrv.Foo{ + req := httptest.NewRequest("PUT", "/foo", newJSONBody(t, allsrv.FooV0{ ID: "id1", Name: "second_name", Note: "second note", @@ -148,7 +148,7 @@ func TestServer(t *testing.T) { svr := allsrv.NewServer(db, allsrv.WithBasicAuth("dodgers@stink.com", "PaSsWoRd")) - req := httptest.NewRequest("PUT", "/foo", newJSONBody(t, allsrv.Foo{ + req := httptest.NewRequest("PUT", "/foo", newJSONBody(t, allsrv.FooV0{ ID: "id1", Name: "second_name", Note: "second note", diff --git a/allsrv/server_v2.go b/allsrv/server_v2.go index 9b8d65c..c333d83 100644 --- a/allsrv/server_v2.go +++ b/allsrv/server_v2.go @@ -25,36 +25,23 @@ func WithMux(mux *http.ServeMux) SvrOptFn { } } -func WithNowFn(fn func() time.Time) SvrOptFn { - return func(o *serverOpts) { - o.nowFn = fn - } -} - type ServerV2 struct { - db DB // 1) - - mux *http.ServeMux - mw func(next http.Handler) http.Handler - idFn func() string // 11) - nowFn func() time.Time + mux *http.ServeMux + svc SVC + mw func(next http.Handler) http.Handler } -func NewServerV2(db DB, opts ...SvrOptFn) *ServerV2 { +func NewServerV2(svc SVC, opts ...SvrOptFn) *ServerV2 { opt := serverOpts{ - mux: http.NewServeMux(), - idFn: func() string { return uuid.Must(uuid.NewV4()).String() }, - nowFn: func() time.Time { return time.Now().UTC() }, + mux: http.NewServeMux(), } for _, o := range opts { o(&opt) } s := ServerV2{ - db: db, - mux: opt.mux, - idFn: opt.idFn, - nowFn: opt.nowFn, + svc: svc, + mux: opt.mux, } var mw []func(http.Handler) http.Handler @@ -80,7 +67,7 @@ func (s *ServerV2) routes() { // 9) s.mux.Handle("POST /v1/foos", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusCreated, s.createFooV1))) s.mux.Handle("GET /v1/foos/{id}", s.mw(read(s.readFooV1))) - s.mux.Handle("PATCH /v1/foos", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusOK, s.updateFooV1))) + s.mux.Handle("PATCH /v1/foos/{id}", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusOK, s.updateFooV1))) s.mux.Handle("DELETE /v1/foos/{id}", s.mw(del(s.delFooV1))) } @@ -178,15 +165,11 @@ type ( ) func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (*Data[ResourceFooAttrs], []RespErr) { - now := s.nowFn() - newFoo := Foo{ - ID: s.idFn(), - Name: req.Data.Attrs.Name, - Note: req.Data.Attrs.Note, - CreatedAt: now, - UpdatedAt: now, - } - if err := s.db.CreateFoo(ctx, newFoo); err != nil { + newFoo, err := s.svc.CreateFoo(ctx, Foo{ + Name: req.Data.Attrs.Name, + Note: req.Data.Attrs.Note, + }) + if err != nil { respErr := toRespErr(err) if isErrType(err, errTypeExists) { respErr.Source = &RespErrSource{Pointer: "/data/attributes/name"} @@ -199,8 +182,7 @@ func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (*Data[R } func (s *ServerV2) readFooV1(ctx context.Context, r *http.Request) (*Data[ResourceFooAttrs], []RespErr) { - id := r.PathValue("id") - f, err := s.db.ReadFoo(ctx, id) + f, err := s.svc.ReadFoo(ctx, r.PathValue("id")) if err != nil { return nil, []RespErr{toRespErr(err)} } @@ -219,20 +201,11 @@ type ( ) func (s *ServerV2) updateFooV1(ctx context.Context, req ReqUpdateFooV1) (*Data[ResourceFooAttrs], []RespErr) { - existing, err := s.db.ReadFoo(ctx, req.Data.ID) - if err != nil { - return nil, []RespErr{toRespErr(err)} - } - - if newName := req.Data.Attrs.Name; newName != nil { - existing.Name = *newName - } - if newNote := req.Data.Attrs.Note; newNote != nil { - existing.Note = *newNote - } - existing.UpdatedAt = s.nowFn() - - err = s.db.UpdateFoo(ctx, existing) + existing, err := s.svc.UpdateFoo(ctx, FooUpd{ + ID: req.Data.ID, + Name: req.Data.Attrs.Name, + Note: req.Data.Attrs.Note, + }) if err != nil { respErr := toRespErr(err) if isErrType(err, errTypeExists) { @@ -247,7 +220,7 @@ func (s *ServerV2) updateFooV1(ctx context.Context, req ReqUpdateFooV1) (*Data[R func (s *ServerV2) delFooV1(ctx context.Context, r *http.Request) []RespErr { id := r.PathValue("id") - if err := s.db.DelFoo(ctx, id); err != nil { + if err := s.svc.DelFoo(ctx, id); err != nil { return []RespErr{toRespErr(err)} } return nil @@ -325,7 +298,7 @@ func handler[Attr Attrs](successCode int, fn func(ctx context.Context, req *http }) } -func decodeReq(r *http.Request, v any) *RespErr { +func decodeReq[Attr Attrs](r *http.Request, v *ReqBody[Attr]) *RespErr { if err := json.NewDecoder(r.Body).Decode(v); err != nil { respErr := RespErr{ Status: http.StatusBadRequest, @@ -340,6 +313,16 @@ func decodeReq(r *http.Request, v any) *RespErr { } return &respErr } + if r.Method == http.MethodPatch && r.PathValue("id") != v.Data.ID { + return &RespErr{ + Status: http.StatusBadRequest, + Msg: "path id and data id must match", + Source: &RespErrSource{ + Pointer: "/data/id", + }, + Code: errTypeInvalid, + } + } return nil } diff --git a/allsrv/server_v2_test.go b/allsrv/server_v2_test.go index b09fd51..afa6600 100644 --- a/allsrv/server_v2_test.go +++ b/allsrv/server_v2_test.go @@ -26,6 +26,7 @@ func TestServerV2(t *testing.T) { testCase struct { name string prepare func(t *testing.T, db allsrv.DB) + svcOpts []func(*allsrv.Service) svrOpts []allsrv.SvrOptFn inputs inputs want wantFn @@ -41,16 +42,19 @@ func TestServerV2(t *testing.T) { tt.prepare(t, db) } - defaultOpts := []allsrv.SvrOptFn{ - allsrv.WithIDFn(newIDGen(1, 1)), - allsrv.WithNowFn(nowFn(start, time.Hour)), - allsrv.WithMetrics(newTestMetrics(t)), + defaultSVCOpts := []func(*allsrv.Service){ + allsrv.WithSVCIDFn(newIDGen(1, 1)), + allsrv.WithSVCNowFn(nowFn(start, time.Hour)), } - opts := append(defaultOpts, tt.svrOpts...) + svcOpts := append(defaultSVCOpts, tt.svcOpts...) + svc := allsrv.NewService(db, svcOpts...) + + defaultSvrOpts := []allsrv.SvrOptFn{allsrv.WithMetrics(newTestMetrics(t))} + svrOpts := append(defaultSvrOpts, tt.svrOpts...) rec := httptest.NewRecorder() - svr := allsrv.NewServerV2(db, opts...) + svr := allsrv.NewServerV2(svc, svrOpts...) svr.ServeHTTP(rec, tt.inputs.req) tt.want(t, rec, db) @@ -281,12 +285,10 @@ func TestServerV2(t *testing.T) { Note: "some note", CreatedAt: start, }), - svrOpts: []allsrv.SvrOptFn{ - allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd"), - allsrv.WithNowFn(nowFn(start.Add(time.Hour), time.Hour)), - }, + svcOpts: []func(*allsrv.Service){allsrv.WithSVCNowFn(nowFn(start.Add(time.Hour), time.Hour))}, + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd")}, inputs: inputs{ - req: newJSONReq("PATCH", "/v1/foos", + req: newJSONReq("PATCH", "/v1/foos/1", newJSONBody(t, allsrv.ReqUpdateFooV1{ Data: allsrv.Data[allsrv.FooUpdAttrs]{ Type: "foo", @@ -329,9 +331,9 @@ func TestServerV2(t *testing.T) { Name: "first-name", CreatedAt: start, }), - svrOpts: []allsrv.SvrOptFn{allsrv.WithNowFn(nowFn(start.Add(time.Hour), time.Hour))}, + svcOpts: []func(*allsrv.Service){allsrv.WithSVCNowFn(nowFn(start.Add(time.Hour), time.Hour))}, inputs: inputs{ - req: newJSONReq("PATCH", "/v1/foos", + req: newJSONReq("PATCH", "/v1/foos/1", newJSONBody(t, allsrv.ReqUpdateFooV1{ Data: allsrv.Data[allsrv.FooUpdAttrs]{ Type: "foo", @@ -376,7 +378,7 @@ func TestServerV2(t *testing.T) { }), svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd")}, inputs: inputs{ - req: newJSONReq("PATCH", "/v1/foos", + req: newJSONReq("PATCH", "/v1/foos/1", newJSONBody(t, allsrv.ReqUpdateFooV1{ Data: allsrv.Data[allsrv.FooUpdAttrs]{ Type: "foo", @@ -405,7 +407,7 @@ func TestServerV2(t *testing.T) { name: "when updating foo too a name that collides with existing should fail", prepare: createFoos(allsrv.Foo{ID: "1", Name: "start-foo"}, allsrv.Foo{ID: "9000", Name: "existing-foo"}), inputs: inputs{ - req: newJSONReq("PATCH", "/v1/foos", newJSONBody(t, allsrv.ReqUpdateFooV1{ + req: newJSONReq("PATCH", "/v1/foos/1", newJSONBody(t, allsrv.ReqUpdateFooV1{ Data: allsrv.Data[allsrv.FooUpdAttrs]{ Type: "foo", ID: "1", diff --git a/allsrv/svc.go b/allsrv/svc.go new file mode 100644 index 0000000..c8a3687 --- /dev/null +++ b/allsrv/svc.go @@ -0,0 +1,118 @@ +package allsrv + +import ( + "context" + "time" + + "github.com/gofrs/uuid" +) + +// Foo domain types. +type ( + Foo struct { + ID string + Name string + Note string + CreatedAt time.Time + UpdatedAt time.Time + } + + FooUpd struct { + ID string + Name *string + Note *string + } +) + +// SVC defines the service behavior. +type SVC interface { + CreateFoo(ctx context.Context, f Foo) (Foo, error) + ReadFoo(ctx context.Context, id string) (Foo, error) + UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) + DelFoo(ctx context.Context, id string) error +} + +// Service dependencies +type ( + // DB represents the foo persistence layer. + DB interface { + CreateFoo(ctx context.Context, f Foo) error + ReadFoo(ctx context.Context, id string) (Foo, error) + UpdateFoo(ctx context.Context, f Foo) error + DelFoo(ctx context.Context, id string) error + } +) + +// Service is the home for business logic of the foo domain. +type Service struct { + db DB + + idFn func() string + nowFn func() time.Time +} + +func WithSVCIDFn(fn func() string) func(*Service) { + return func(s *Service) { + s.idFn = fn + } +} + +func WithSVCNowFn(fn func() time.Time) func(*Service) { + return func(s *Service) { + s.nowFn = fn + } +} + +func NewService(db DB, opts ...func(*Service)) *Service { + s := Service{ + db: db, + idFn: func() string { return uuid.Must(uuid.NewV4()).String() }, + nowFn: func() time.Time { return time.Now().UTC() }, + } + + for _, o := range opts { + o(&s) + } + + return &s +} + +func (s *Service) CreateFoo(ctx context.Context, f Foo) (Foo, error) { + now := s.nowFn() + f.ID, f.CreatedAt, f.UpdatedAt = s.idFn(), now, now + + if err := s.db.CreateFoo(ctx, f); err != nil { + return Foo{}, err + } + + return f, nil +} + +func (s *Service) ReadFoo(ctx context.Context, id string) (Foo, error) { + return s.db.ReadFoo(ctx, id) +} + +func (s *Service) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) { + existing, err := s.db.ReadFoo(ctx, f.ID) + if err != nil { + return Foo{}, err + } + if newName := f.Name; newName != nil { + existing.Name = *newName + } + if newNote := f.Note; newNote != nil { + existing.Note = *newNote + } + existing.UpdatedAt = s.nowFn() + + err = s.db.UpdateFoo(ctx, existing) + if err != nil { + return Foo{}, err + } + + return existing, nil +} + +func (s *Service) DelFoo(ctx context.Context, id string) error { + return s.db.DelFoo(ctx, id) +} diff --git a/allsrv/svc_mw_logging.go b/allsrv/svc_mw_logging.go new file mode 100644 index 0000000..6997b65 --- /dev/null +++ b/allsrv/svc_mw_logging.go @@ -0,0 +1,96 @@ +package allsrv + +import ( + "context" + "log/slog" + "time" +) + +// SVCLogging wraps the service with logging concerns. +func SVCLogging(logger *slog.Logger) func(SVC) SVC { + return func(next SVC) SVC { + return &svcMWLogger{ + logger: logger, + next: next, + } + } +} + +type svcMWLogger struct { + logger *slog.Logger + next SVC +} + +func (s *svcMWLogger) CreateFoo(ctx context.Context, f Foo) (Foo, error) { + logFn := s.logFn("input_name", f.Name, "input_note", f.Note) + + f, err := s.next.CreateFoo(ctx, f) + logger := logFn(err) + if err != nil { + logger.Error("failed to create foo") + } else { + logger.Info("foo created successfully", "new_foo_id", f.ID) + } + + return f, err +} + +func (s *svcMWLogger) ReadFoo(ctx context.Context, id string) (Foo, error) { + logFn := s.logFn("input_id", id) + + f, err := s.next.ReadFoo(ctx, id) + logger := logFn(err) + if err != nil { + logger.Error("failed to read foo") + } + + return f, err +} + +func (s *svcMWLogger) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) { + fields := []any{"input_id", f.ID} + if f.Name != nil { + fields = append(fields, "input_name", *f.Name) + } + if f.Note != nil { + fields = append(fields, "input_note", *f.Note) + } + + logFn := s.logFn(fields...) + + updatedFoo, err := s.next.UpdateFoo(ctx, f) + logger := logFn(err) + if err != nil { + logger.Error("failed to update foo") + } else { + logger.Info("foo updated successfully") + } + + return updatedFoo, err +} + +func (s *svcMWLogger) DelFoo(ctx context.Context, id string) error { + logFn := s.logFn("input_id", id) + + err := s.next.DelFoo(ctx, id) + logger := logFn(err) + if err != nil { + logger.Error("failed to delete foo") + } else { + logger.Info("foo deleted successfully") + } + + return err +} + +func (s *svcMWLogger) logFn(fields ...any) func(error) *slog.Logger { + start := time.Now() + return func(err error) *slog.Logger { + fields = append(fields, "took_ms", time.Since(start).Round(time.Millisecond).String()) + if err != nil { + fields = append(fields, errFields(err)...) + fields = append(fields, "err", err.Error()) + } + return s.logger.With(fields...) + } +} diff --git a/allsrv/svc_observer.go b/allsrv/svc_observer.go new file mode 100644 index 0000000..c02f737 --- /dev/null +++ b/allsrv/svc_observer.go @@ -0,0 +1,72 @@ +package allsrv + +import ( + "context" + "time" + + "github.com/hashicorp/go-metrics" + "github.com/opentracing/opentracing-go" +) + +// ObserveSVC provides a metrics and spanning middleware. +func ObserveSVC(met *metrics.Metrics) func(next SVC) SVC { + return func(next SVC) SVC { + return &svcObserver{ + met: met, + next: next, + } + } +} + +type svcObserver struct { + met *metrics.Metrics + next SVC +} + +func (s *svcObserver) CreateFoo(ctx context.Context, f Foo) (Foo, error) { + span, ctx := opentracing.StartSpanFromContext(ctx, "svc_foo_create") + defer span.Finish() + + rec := s.record("create") + f, err := s.next.CreateFoo(ctx, f) + return f, rec(err) +} + +func (s *svcObserver) ReadFoo(ctx context.Context, id string) (Foo, error) { + span, ctx := opentracing.StartSpanFromContext(ctx, "svc_foo_read") + defer span.Finish() + + rec := s.record("read") + f, err := s.next.ReadFoo(ctx, id) + return f, rec(err) +} + +func (s *svcObserver) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) { + span, ctx := opentracing.StartSpanFromContext(ctx, "svc_foo_update") + defer span.Finish() + + rec := s.record("update") + updatedFoo, err := s.next.UpdateFoo(ctx, f) + return updatedFoo, rec(err) +} + +func (s *svcObserver) DelFoo(ctx context.Context, id string) error { + span, ctx := opentracing.StartSpanFromContext(ctx, "svc_foo_delete") + defer span.Finish() + + rec := s.record("delete") + return rec(s.next.DelFoo(ctx, id)) +} + +func (s *svcObserver) record(op string) func(error) error { + start := time.Now() + name := []string{metricsPrefix, op} + s.met.IncrCounter(append(name, "reqs"), 1) + return func(err error) error { + if err != nil { + s.met.IncrCounter(append(name, "errs"), 1) + } + s.met.MeasureSince(append(name, "dur"), start) + return err + } +}