Skip to content

Commit eb49404

Browse files
committed
chore(allsrv): add github.com/jsteenb2/allsrvc SDK module
We fixup our http client to make use of the [github.com/jsteenb2/allsrvc](https://github.com/jsteenb2/allsrvc) SDK. As you, we can clean up a good bit of duplication by utilizing the SDK as a source of truth for the API types. We've broken up the SDK from the service/server module. Effectively breaking one of the thorniest problems large organizations with a large go ecosystem face. When we leave the SDK inside the service module, its forces all the depdencies of the service onto any SDK consumer. This creates a series of problems. 1. The SDK creates a ton of bloat in the user's module. 2. The SDK undergoes a lot of version changes when coupled to the service module version. 3. Circular module dependencies are real, and can cause a LOT of pain. * Check out [perseus](https://github.com/CrowdStrike/perseus) to help visualize this! 4. If you do it this way, then other teams will also do it this way, putting tremendous pressure on your CI/build pipelines. Instead of exporting an SDK from your service, opt for a separate module for the SDK. This radically changes the game. You can use the SDK module in the `Service` module to remain *DRY*. However, **DO NOT** import the `Service` module into the SDK module! Now that we have the tiny SDK module, we're able to obtain some important data to help us track who is hitting our API. We now get access to the `Origin` and `User-Agent` of the callee. Here is an example of a log that adds the [version of the module](https://github.com/jsteenb2/allsrvc/blob/main/client.go#L21-L30) as part of `User-Agent` and `Origin` headers when communicating with the server: ```json { "time": "2024-07-06T20:46:58.614226-05:00", "level": "ERROR", "source": { "function": "github.com/jsteenb2/mess/allsrv.(*svcMWLogger).CreateFoo", "file": "github.com/jsteenb2/mess/allsrv/svc_mw_logging.go", "line": 32 }, "msg": "failed to create foo", "input_name": "name1", "input_note": "note1", "took_ms": "0s", "origin": "allsrvc", "user_agent": "allsrvc (github.com/jsteenb2/allsrvc) / v0.4.0", "trace_id": "b9106e52-907b-4bc4-af91-6596e98d3795", "err": "foo name1 exists", "err_fields": { "name": "name1", "existing_foo_id": "3a826632-ec30-4852-b4a6-e4a4497ddda8", "err_kind": "exists", "stack_trace": [ "github.com/jsteenb2/mess/allsrv/svc.go:97[(*Service).CreateFoo]", "github.com/jsteenb2/mess/allsrv/db_inmem.go:20[(*InmemDB).CreateFoo]" ] } } ``` With this information, we're in a good position to make proactive changes to remove our own blockers. Excellent stuff! Additionally, we've imported our SDK into the `Service` module to *DRY* up the HTTP API contract. No need to duplicate these things as the server is dependent on the http client's JSON API types. This is awesome, as we're still able to keep things DRY, without all the downside of the SDK depending on the Service (i.e. dependency bloat). Lastly, we update the CLI to include basic auth. Try exercising the new updates. Use the CLI to issue some CRUD commands against the server. Start the server first with: ```shell go run ./allsrv/cmd/allsrvc | jq ``` Then you can install the CLI and make sure to add `$GOBIN` to your `$PATH`: ```shell go install ./allsrv/cmd/allsrvc ``` Now issue a request to create a foo: ```shell allsrvc create --name first --note "some note" ``` Now issue another create a foo with the same name: ```shell allsrvc create --name first --note "some other note" ``` The previous command should fail. Check out the output from the `allsrvc` CLI as well as the logs from the server. Enjoy those beautiful logs! This marks the end of our time with the `allsrv` package! Refs: [SDK module - github.com/jsteenb2/allsrvc](https://github.com/jsteenb2/allsrvc) Refs: [Setting version in SDK via debug.BuildInfo](https://github.com/jsteenb2/allsrvc/blob/main/client.go#L21-L30) Refs: [Perseus module tracker](https://github.com/CrowdStrike/perseus)
1 parent 76de44a commit eb49404

File tree

7 files changed

+312
-416
lines changed

7 files changed

+312
-416
lines changed

allsrv/client_http.go

Lines changed: 62 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,182 +1,107 @@
11
package allsrv
22

33
import (
4-
"bytes"
54
"context"
6-
"encoding/json"
7-
"io"
85
"net/http"
96
"time"
107

118
"github.com/jsteenb2/errors"
9+
10+
"github.com/jsteenb2/allsrvc"
1211
)
1312

1413
type ClientHTTP struct {
15-
addr string
16-
c *http.Client
14+
c *allsrvc.ClientHTTP
1715
}
1816

1917
var _ SVC = (*ClientHTTP)(nil)
2018

21-
func NewClientHTTP(addr string, c *http.Client) *ClientHTTP {
19+
func NewClientHTTP(addr, origin string, c *http.Client, opts ...func(*allsrvc.ClientHTTP)) *ClientHTTP {
2220
return &ClientHTTP{
23-
addr: addr,
24-
c: c,
21+
c: allsrvc.NewClientHTTP(addr, origin, c, opts...),
2522
}
2623
}
2724

2825
func (c *ClientHTTP) CreateFoo(ctx context.Context, f Foo) (Foo, error) {
29-
req, err := jsonReq(ctx, "POST", c.fooPath(""), toReqCreateFooV1(f))
26+
resp, err := c.c.CreateFoo(ctx, allsrvc.FooCreateAttrs{
27+
Name: f.Name,
28+
Note: f.Note,
29+
})
3030
if err != nil {
3131
return Foo{}, InternalErr(err.Error())
3232
}
33-
return returnsFooReq(c.c, req)
33+
newFoo, err := takeRespFoo(resp)
34+
return newFoo, errors.Wrap(err)
3435
}
3536

3637
func (c *ClientHTTP) ReadFoo(ctx context.Context, id string) (Foo, error) {
37-
if id == "" {
38-
return Foo{}, errIDRequired
39-
}
40-
41-
req, err := http.NewRequestWithContext(ctx, "GET", c.fooPath(id), nil)
38+
resp, err := c.c.ReadFoo(ctx, id)
4239
if err != nil {
43-
return Foo{}, InternalErr(err.Error())
40+
if errors.Is(err, allsrvc.ErrIDRequired) {
41+
return Foo{}, errIDRequired
42+
}
4443
}
45-
return returnsFooReq(c.c, req)
44+
45+
newFoo, err := takeRespFoo(resp)
46+
return newFoo, errors.Wrap(err)
4647
}
4748

4849
func (c *ClientHTTP) UpdateFoo(ctx context.Context, f FooUpd) (Foo, error) {
49-
req, err := jsonReq(ctx, "PATCH", c.fooPath(f.ID), toReqUpdateFooV1(f))
50+
resp, err := c.c.UpdateFoo(ctx, f.ID, allsrvc.FooUpdAttrs{
51+
Name: f.Name,
52+
Note: f.Note,
53+
})
5054
if err != nil {
5155
return Foo{}, InternalErr(err.Error())
5256
}
53-
return returnsFooReq(c.c, req)
57+
newFoo, err := takeRespFoo(resp)
58+
return newFoo, errors.Wrap(err)
5459
}
5560

5661
func (c *ClientHTTP) DelFoo(ctx context.Context, id string) error {
57-
if id == "" {
58-
return errIDRequired
59-
}
60-
61-
req, err := http.NewRequestWithContext(ctx, "DELETE", c.fooPath(id), nil)
62+
resp, err := c.c.DelFoo(ctx, id)
6263
if err != nil {
63-
return InternalErr(err.Error())
64-
}
65-
66-
_, err = doReq[any](c.c, req)
67-
return err
68-
}
69-
70-
func (c *ClientHTTP) fooPath(id string) string {
71-
u := c.addr + "/v1/foos"
72-
if id == "" {
73-
return u
74-
}
75-
return u + "/" + id
76-
}
77-
78-
func jsonReq(ctx context.Context, method, path string, v any) (*http.Request, error) {
79-
var buf bytes.Buffer
80-
if err := json.NewEncoder(&buf).Encode(v); err != nil {
81-
return nil, InvalidErr("failed to marshal payload: " + err.Error())
82-
}
83-
84-
req, err := http.NewRequestWithContext(ctx, method, path, &buf)
85-
if err != nil {
86-
return nil, err
87-
}
88-
req.Header.Set("Content-Type", "application/json")
89-
90-
return req, nil
91-
}
92-
93-
func returnsFooReq(c *http.Client, req *http.Request) (Foo, error) {
94-
data, err := doReq[ResourceFooAttrs](c, req)
95-
if err != nil {
96-
return Foo{}, err
97-
}
98-
return toFoo(data), nil
99-
}
100-
101-
func doReq[Attr Attrs](c *http.Client, req *http.Request) (Data[Attr], error) {
102-
resp, err := c.Do(req)
103-
if err != nil {
104-
return *new(Data[Attr]), InternalErr(err.Error())
105-
}
106-
defer func() {
107-
io.Copy(io.Discard, resp.Body)
108-
resp.Body.Close()
109-
}()
110-
111-
if resp.Header.Get("Content-Type") != "application/json" {
112-
b, err := io.ReadAll(io.LimitReader(resp.Body, 500<<10))
113-
if err != nil {
114-
return *new(Data[Attr]), InternalErr("failed to read response body: ", err.Error())
64+
if errors.Is(err, allsrvc.ErrIDRequired) {
65+
return errIDRequired
11566
}
116-
return *new(Data[Attr]), InternalErr("invalid content type received; content=" + string(b))
117-
}
118-
// TODO(berg): handle unexpected status code (502|503|etc)
119-
120-
var respBody RespBody[Attr]
121-
err = json.NewDecoder(resp.Body).Decode(&respBody)
122-
if err != nil {
123-
return *new(Data[Attr]), InternalErr(err.Error())
12467
}
68+
69+
return errors.Wrap(convertSDKErrors(resp.Errs))
70+
}
12571

126-
var errs []error
127-
for _, respErr := range respBody.Errs {
128-
errs = append(errs, toErr(respErr))
72+
func takeRespFoo(respBody allsrvc.RespBody[allsrvc.ResourceFooAttrs]) (Foo, error) {
73+
if err := convertSDKErrors(respBody.Errs); err != nil {
74+
return Foo{}, errors.Wrap(err)
12975
}
130-
if len(errs) == 1 {
131-
return *new(Data[Attr]), errs[0]
132-
}
133-
if len(errs) > 1 {
134-
return *new(Data[Attr]), errors.Join(errs)
135-
}
136-
76+
13777
if respBody.Data == nil {
138-
return *new(Data[Attr]), nil
139-
}
140-
141-
return *respBody.Data, nil
142-
}
143-
144-
func toReqCreateFooV1(f Foo) ReqCreateFooV1 {
145-
return ReqCreateFooV1{
146-
Data: Data[FooCreateAttrs]{
147-
Type: "foo",
148-
Attrs: FooCreateAttrs{
149-
Name: f.Name,
150-
Note: f.Note,
151-
},
152-
},
78+
return Foo{}, nil
15379
}
154-
}
155-
156-
func toReqUpdateFooV1(f FooUpd) ReqUpdateFooV1 {
157-
return ReqUpdateFooV1{
158-
Data: Data[FooUpdAttrs]{
159-
Type: "foo",
160-
ID: f.ID,
161-
Attrs: FooUpdAttrs{
162-
Name: f.Name,
163-
Note: f.Note,
164-
},
165-
},
80+
81+
f := Foo{
82+
ID: respBody.Data.ID,
83+
Name: respBody.Data.Attrs.Name,
84+
Note: respBody.Data.Attrs.Note,
85+
CreatedAt: toTime(respBody.Data.Attrs.CreatedAt),
86+
UpdatedAt: toTime(respBody.Data.Attrs.UpdatedAt),
16687
}
88+
89+
return f, nil
16790
}
16891

169-
func toFoo(d Data[ResourceFooAttrs]) Foo {
170-
return Foo{
171-
ID: d.ID,
172-
Name: d.Attrs.Name,
173-
Note: d.Attrs.Note,
174-
CreatedAt: toTime(d.Attrs.CreatedAt),
175-
UpdatedAt: toTime(d.Attrs.UpdatedAt),
92+
func convertSDKErrors(errs []allsrvc.RespErr) error {
93+
// TODO(@berg): update this to slices pkg when 1.23 lands
94+
switch out := toSlc(errs, toErr); {
95+
case len(out) == 1:
96+
return out[0]
97+
case len(out) > 1:
98+
return errors.Join(out)
99+
default:
100+
return nil
176101
}
177102
}
178103

179-
func toErr(respErr RespErr) error {
104+
func toErr(respErr allsrvc.RespErr) error {
180105
errFn := InternalErr
181106
switch respErr.Code {
182107
case errCodeExist:
@@ -199,3 +124,11 @@ func toTime(in string) time.Time {
199124
t, _ := time.Parse(time.RFC3339, in)
200125
return t
201126
}
127+
128+
func toSlc[In, Out any](in []In, to func(In) Out) []Out {
129+
out := make([]Out, len(in))
130+
for _, v := range in {
131+
out = append(out, to(v))
132+
}
133+
return out
134+
}

allsrv/cmd/allsrvc/main.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/spf13/cobra"
1010

11+
"github.com/jsteenb2/allsrvc"
1112
"github.com/jsteenb2/mess/allsrv"
1213
)
1314

@@ -23,16 +24,24 @@ func newCmd() *cobra.Command {
2324
return c.cmd()
2425
}
2526

27+
const name = "allsrvc"
28+
2629
type cli struct {
30+
// base flags
2731
addr string
32+
pass string
33+
user string
34+
35+
// foo flags
2836
id string
2937
name string
3038
note string
3139
}
3240

3341
func (c *cli) cmd() *cobra.Command {
3442
cmd := cobra.Command{
35-
Use: "allsrvc",
43+
Use: name,
44+
SilenceUsage: true,
3645
}
3746

3847
cmd.AddCommand(
@@ -65,7 +74,7 @@ func (c *cli) cmdCreateFoo() *cobra.Command {
6574
return json.NewEncoder(cmd.OutOrStderr()).Encode(f)
6675
},
6776
}
68-
cmd.Flags().StringVar(&c.addr, "addr", "http://localhost:8091", "addr for foo svc")
77+
c.registerCommonFlags(&cmd)
6978
cmd.Flags().StringVar(&c.name, "name", "", "name of the new foo")
7079
cmd.Flags().StringVar(&c.note, "note", "", "optional foo note")
7180

@@ -88,8 +97,7 @@ func (c *cli) cmdReadFoo() *cobra.Command {
8897
return json.NewEncoder(cmd.OutOrStderr()).Encode(f)
8998
},
9099
}
91-
cmd.Flags().StringVar(&c.addr, "addr", "http://localhost:8091", "addr for foo svc")
92-
100+
c.registerCommonFlags(&cmd)
93101
return &cmd
94102
}
95103

@@ -119,7 +127,7 @@ func (c *cli) cmdUpdateFoo() *cobra.Command {
119127
return json.NewEncoder(cmd.OutOrStderr()).Encode(f)
120128
},
121129
}
122-
cmd.Flags().StringVar(&c.addr, "addr", "http://localhost:8091", "addr for foo svc")
130+
c.registerCommonFlags(&cmd)
123131
cmd.Flags().StringVar(&c.id, "id", "", "id of the foo resource")
124132
cmd.Flags().StringVar(&c.name, "name", "", "optional foo name")
125133
cmd.Flags().StringVar(&c.note, "note", "", "optional foo note")
@@ -137,11 +145,21 @@ func (c *cli) cmdRmFoo() *cobra.Command {
137145
return client.DelFoo(cmd.Context(), args[0])
138146
},
139147
}
140-
cmd.Flags().StringVar(&c.addr, "addr", "http://localhost:8091", "addr for foo svc")
141-
148+
c.registerCommonFlags(&cmd)
142149
return &cmd
143150
}
144151

145152
func (c *cli) newClient() *allsrv.ClientHTTP {
146-
return allsrv.NewClientHTTP(c.addr, &http.Client{Timeout: 5 * time.Second})
153+
return allsrv.NewClientHTTP(
154+
c.addr,
155+
name,
156+
&http.Client{Timeout: 5 * time.Second},
157+
allsrvc.WithBasicAuth(c.user, c.pass),
158+
)
159+
}
160+
161+
func (c *cli) registerCommonFlags(cmd *cobra.Command) {
162+
cmd.Flags().StringVar(&c.addr, "addr", "http://localhost:8091", "addr for foo svc")
163+
cmd.Flags().StringVar(&c.user, "user", "admin", "user for basic auth")
164+
cmd.Flags().StringVar(&c.pass, "password", "pass", "password for basic auth")
147165
}

0 commit comments

Comments
 (0)