Skip to content

Commit

Permalink
chore(allsrv): add the service layer to consolidate all domain logic
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jsteenb2 committed Jul 10, 2024
1 parent 8aeed09 commit 1fb7dbf
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 126 deletions.
55 changes: 35 additions & 20 deletions allsrv/cmd/allsrv/main.go
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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)
}
}
Expand Down
6 changes: 6 additions & 0 deletions allsrv/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 4 additions & 4 deletions allsrv/observe_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ 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")
return rec(d.next.CreateFoo(ctx, f))
}

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")
Expand All @@ -47,15 +47,15 @@ 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")
return rec(d.next.UpdateFoo(ctx, f))
}

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")
Expand Down
61 changes: 30 additions & 31 deletions allsrv/server.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package allsrv

import (
"context"
"encoding/json"
"log"
"net/http"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
}
Expand Down
16 changes: 8 additions & 8 deletions allsrv/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}))
Expand All @@ -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",
Expand All @@ -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",
}))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading

0 comments on commit 1fb7dbf

Please sign in to comment.