-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(allsrv): add http client and fixup missing pieces
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
Showing
7 changed files
with
256 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters