Skip to content

Commit

Permalink
chore(allsrv): add http client and fixup missing pieces
Browse files Browse the repository at this point in the history
Here we've added the HTTP client. Again, we're pulling from the standard library
because it's a trivial example. Even with this, we're able to put together a client
that speaks the languae of our domain, and fulfills the behavior of our SVC.
We've provided a fair degree of confidence by utilizing the same `SVC` test
suite we had with the `Service` implementation itself. To top it all off,
we're able to refactor our tests a bit to reuse the constructor for the
`SVC` dependency, leaving us with a standardized setup.

With standardized tests you benefit of reusing the tests. Additionally,
any new contributor only needs to understand a single test setup,
and then writes a testcase. Its extremely straightforward after the
initial onboarding.

Last commit message, I spoke of adding a CLI companion to this. With the HTTP
client we've just created, go on and create a CLI and put it under test
with the same test suite :yaaaaaaaaaas:!
  • Loading branch information
jsteenb2 committed Jul 8, 2024
1 parent 6c865eb commit e5878dc
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 10 deletions.
192 changes: 192 additions & 0 deletions allsrv/client_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package allsrv

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)

type ClientHTTP struct {
addr string
c *http.Client
}

var _ SVC = (*ClientHTTP)(nil)

func NewClientHTTP(addr string, c *http.Client) *ClientHTTP {
return &ClientHTTP{
addr: addr,
c: c,
}
}

func (c *ClientHTTP) CreateFoo(ctx context.Context, f Foo) (Foo, error) {
req, err := jsonReq(ctx, "POST", c.fooPath(""), toReqCreateFooV1(f))
if err != nil {
return Foo{}, InternalErr(err.Error())
}
return returnsFooReq(c.c, req)
}

func (c *ClientHTTP) ReadFoo(ctx context.Context, id string) (Foo, error) {
if id == "" {
return Foo{}, errIDRequired
}

req, err := http.NewRequestWithContext(ctx, "GET", c.fooPath(id), nil)
if err != nil {
return Foo{}, InternalErr(err.Error())
}
return returnsFooReq(c.c, req)
}

func (c *ClientHTTP) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) {
req, err := jsonReq(ctx, "PATCH", c.fooPath(f.ID), toReqUpdateFooV1(f))
if err != nil {
return Foo{}, InternalErr(err.Error())
}
return returnsFooReq(c.c, req)
}

func (c *ClientHTTP) DelFoo(ctx context.Context, id string) error {
if id == "" {
return errIDRequired
}

req, err := http.NewRequestWithContext(ctx, "DELETE", c.fooPath(id), nil)
if err != nil {
return InternalErr(err.Error())
}

_, err = doReq[any](c.c, req)
return err
}

func (c *ClientHTTP) fooPath(id string) string {
u := c.addr + "/v1/foos"
if id == "" {
return u
}
return u + "/" + id
}

func jsonReq(ctx context.Context, method, path string, v any) (*http.Request, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
return nil, InvalidErr("failed to marshal payload: " + err.Error())
}

req, err := http.NewRequestWithContext(ctx, method, path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")

return req, nil
}

func returnsFooReq(c *http.Client, req *http.Request) (Foo, error) {
data, err := doReq[ResourceFooAttrs](c, req)
if err != nil {
return Foo{}, err
}
return toFoo(data), nil
}

func doReq[Attr Attrs](c *http.Client, req *http.Request) (Data[Attr], error) {
resp, err := c.Do(req)
if err != nil {
return *new(Data[Attr]), InternalErr(err.Error())
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()

if resp.Header.Get("Content-Type") != "application/json" {
b, err := io.ReadAll(io.LimitReader(resp.Body, 500<<10))
if err != nil {
return *new(Data[Attr]), InternalErr("failed to read response body: ", err.Error())
}
return *new(Data[Attr]), InternalErr("invalid content type received; content=" + string(b))
}
// TODO(berg): handle unexpected status code (502|503|etc)

var respBody RespBody[Attr]
err = json.NewDecoder(resp.Body).Decode(&respBody)
if err != nil {
return *new(Data[Attr]), InternalErr(err.Error())
}

var errs []error
for _, respErr := range respBody.Errs {
errs = append(errs, toErr(respErr))
}
if len(errs) == 1 {
return *new(Data[Attr]), errs[0]
}
if len(errs) > 1 {
return *new(Data[Attr]), errors.Join(errs...)
}

if respBody.Data == nil {
return *new(Data[Attr]), nil
}

return *respBody.Data, nil
}

func toReqCreateFooV1(f Foo) ReqCreateFooV1 {
return ReqCreateFooV1{
Data: Data[FooCreateAttrs]{
Type: "foo",
Attrs: FooCreateAttrs{
Name: f.Name,
Note: f.Note,
},
},
}
}

func toReqUpdateFooV1(f FooUpd) ReqUpdateFooV1 {
return ReqUpdateFooV1{
Data: Data[FooUpdAttrs]{
Type: "foo",
ID: f.ID,
Attrs: FooUpdAttrs{
Name: f.Name,
Note: f.Note,
},
},
}
}

func toFoo(d Data[ResourceFooAttrs]) Foo {
return Foo{
ID: d.ID,
Name: d.Attrs.Name,
Note: d.Attrs.Note,
CreatedAt: toTime(d.Attrs.CreatedAt),
UpdatedAt: toTime(d.Attrs.UpdatedAt),
}
}

func toErr(respErr RespErr) error {
out := Err{
Type: respErr.Code,
Msg: respErr.Msg,
}
if respErr.Source != nil {
out.Fields = append(out.Fields, "err_source", *respErr.Source)
}
return out
}

func toTime(in string) time.Time {
t, _ := time.Parse(time.RFC3339, in)
return t
}
12 changes: 12 additions & 0 deletions allsrv/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ var errTypeStrs = [...]string{
errTypeInternal: "internal",
}

var (
errIDRequired = InvalidErr("id is requierd")
)

// Err provides a lightly structured error that we can attach behavior. Additionally,
// the use of options makes it possible for us to enrich our logging infra without
// blowing up the message cardinality.
Expand Down Expand Up @@ -53,6 +57,14 @@ func InvalidErr(msg string, fields ...any) error {
}
}

func InternalErr(msg string, fields ...any) error {
return Err{
Type: errTypeInternal,
Msg: msg,
Fields: fields,
}
}

// NotFoundErr creates a not found error.
func NotFoundErr(msg string, fields ...any) error {
return Err{
Expand Down
2 changes: 1 addition & 1 deletion allsrv/server_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ func handler[Attr Attrs](successCode int, fn func(ctx context.Context, req *http
status = e.Status
}
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(RespBody[Attr]{
Meta: getMeta(r.Context()),
Expand Down
12 changes: 12 additions & 0 deletions allsrv/server_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ import (
"github.com/jsteenb2/mess/allsrv"
)

func TestServerV2HttpClient(t *testing.T) {
testSVC(t, func(t *testing.T, opts svcTestOpts) svcDeps {
svc := newInmemSVC(t, opts)
srv := httptest.NewServer(allsrv.NewServerV2(svc))
t.Cleanup(srv.Close)

return svcDeps{
svc: allsrv.NewClientHTTP(srv.URL, &http.Client{Timeout: time.Second}),
}
})
}

func TestServerV2(t *testing.T) {
type (
inputs struct {
Expand Down
6 changes: 6 additions & 0 deletions allsrv/svc.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ func (s *Service) CreateFoo(ctx context.Context, f Foo) (Foo, error) {
}

func (s *Service) ReadFoo(ctx context.Context, id string) (Foo, error) {
if id == "" {
return Foo{}, errIDRequired
}
return s.db.ReadFoo(ctx, id)
}

Expand All @@ -125,5 +128,8 @@ func (s *Service) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) {
}

func (s *Service) DelFoo(ctx context.Context, id string) error {
if id == "" {
return errIDRequired
}
return s.db.DelFoo(ctx, id)
}
22 changes: 21 additions & 1 deletion allsrv/svc_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
var start = time.Time{}.Add(time.Hour).UTC()

type (
svcInitFn func(t *testing.T, options svcTestOpts) svcDeps
svcInitFn func(t *testing.T, opts svcTestOpts) svcDeps

svcDeps struct {
svc allsrv.SVC
Expand Down Expand Up @@ -196,6 +196,16 @@ func testSVCRead(t *testing.T, initFn svcInitFn) {
wantFoo(fooTwo)
},
},
{
name: "with an empty string id should fail",
input: inputs{
id: "",
},
want: func(t *testing.T, got allsrv.Foo, readErr error) {
require.Error(t, readErr)
assert.True(t, allsrv.IsInvalidErr(readErr), "got_err="+readErr.Error())
},
},
{
name: "with id for non-existent foo should fail",
input: inputs{
Expand Down Expand Up @@ -405,6 +415,16 @@ func testSVCDel(t *testing.T, initFn svcInitFn) {
assert.True(t, allsrv.IsNotFoundErr(delErr))
},
},
{
name: "without id should fail",
input: inputs{
id: "",
},
want: func(t *testing.T, svc allsrv.SVC, delErr error) {
require.Error(t, delErr)
assert.True(t, allsrv.IsInvalidErr(delErr), "got_err="+delErr.Error())
},
},
}

for _, tt := range tests {
Expand Down
20 changes: 12 additions & 8 deletions allsrv/svc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import (
)

func TestService(t *testing.T) {
testSVC(t, func(t *testing.T, fields svcTestOpts) svcDeps {
db := new(allsrv.InmemDB)
fields.prepDB(t, db)
testSVC(t, func(t *testing.T, opts svcTestOpts) svcDeps {
return svcDeps{svc: newInmemSVC(t, opts)}
})
}

var svc allsrv.SVC = allsrv.NewService(db, fields.svcOpts...)
svc = allsrv.SVCLogging(newTestLogger(t))(svc)
svc = allsrv.ObserveSVC(metrics.Default())(svc)
func newInmemSVC(t *testing.T, opts svcTestOpts) allsrv.SVC {
db := new(allsrv.InmemDB)
opts.prepDB(t, db)

return svcDeps{svc: svc}
})
var svc allsrv.SVC = allsrv.NewService(db, opts.svcOpts...)
svc = allsrv.SVCLogging(newTestLogger(t))(svc)
svc = allsrv.ObserveSVC(metrics.Default())(svc)

return svc
}

0 comments on commit e5878dc

Please sign in to comment.