diff --git a/allsrv/client_http.go b/allsrv/client_http.go new file mode 100644 index 0000000..1fb82f8 --- /dev/null +++ b/allsrv/client_http.go @@ -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 +} diff --git a/allsrv/errors.go b/allsrv/errors.go index 2086298..1198922 100644 --- a/allsrv/errors.go +++ b/allsrv/errors.go @@ -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. @@ -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{ diff --git a/allsrv/server_v2.go b/allsrv/server_v2.go index c333d83..3c56747 100644 --- a/allsrv/server_v2.go +++ b/allsrv/server_v2.go @@ -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()), diff --git a/allsrv/server_v2_test.go b/allsrv/server_v2_test.go index c2f5d88..ec2297a 100644 --- a/allsrv/server_v2_test.go +++ b/allsrv/server_v2_test.go @@ -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 { diff --git a/allsrv/svc.go b/allsrv/svc.go index 9ad36ca..7152798 100644 --- a/allsrv/svc.go +++ b/allsrv/svc.go @@ -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) } @@ -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) } diff --git a/allsrv/svc_suite_test.go b/allsrv/svc_suite_test.go index 29f17b9..1826130 100644 --- a/allsrv/svc_suite_test.go +++ b/allsrv/svc_suite_test.go @@ -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 @@ -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{ @@ -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 { diff --git a/allsrv/svc_test.go b/allsrv/svc_test.go index 839dfc4..6e576fd 100644 --- a/allsrv/svc_test.go +++ b/allsrv/svc_test.go @@ -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 }