Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/client logging #34

Merged
merged 9 commits into from
Sep 26, 2024
149 changes: 149 additions & 0 deletions docs/modules/client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Client

The client module enables your application to interact with other web services over HTTP using the [Resty](https://github.com/go-resty/resty) library.

This module simplifies the process of creating an HTTP client. It reads the base URL and timeout configurations from a config file. To create a client, you need to add the module to your application as follows:

```go

app := chaki.New()

app.Use(
// ...
client.Module(),
// ...
)

//...
```

To create a client, you can use the following code:

```go
type exampleClient struct {
*client.Base
}

func newClient(f *client.Factory) *exampleClient {
return &exampleClient{
Base: f.Get("example-client"),
}
}
```

the `example-client` name should match the name in the config file to configure the client from the config file:
```yaml
client:
example-client:
baseUrl: "http://super-duper-client-url.com"
timeout: 500ms
```

To create a request, you should use the following code. This ensures that tracing and other metadata are used on the request as all metadata is under context.

```go
func (cl *exampleClient) SendHello(ctx context.Context) (string, error) {
resp := &response.Response[string]{}

request := cl.Request(ctx) // this gives you an *resty.Request, to work with.

if _, err := request.
SetResult(resp).
Get("/hello"); err != nil {
return "", err
}

return resp.Data, nil
}
```

If you want to log every outgoing request and incoming response, you can simply set `logging` key to `true` on config.
```yaml
client:
example-client:
baseUrl: "http://super-duper-client-url.com"
timeout: 500ms
logging: true
```
---
## Error Handler

By default, Chaki provides a built-in error handler to encapsulate incoming errors. The source code can be found in `modules/client/errors.go`. To avoid log chaos, error cases are not logged by default.

To access the details of the errors, you can cast the error type into `GenericClientError` type as follows:
```go

_, err := cl.SendSomeRequest()
genericError := client.GenericClientError{}
errors.As(err, genericError)
logger.From(ctx).Error(genericError.ClientName)

```

### Providing error handler
You can provide a custom error handler to handle errors in a more specific way. The error handler function should accept a `context.Context` and a `*resty.Response` as parameters.
```go
func newClient(f *client.Factory) *exampleClient {
return &exampleClient{
Base: f.Get("example-client", client.WithErrDecoder(customErrorDecoder)),
}
}

func customErrorDecoder(_ context.Context, res *resty.Response) error {
if res.StatusCode() == 404 {
return fmt.Errorf("not found")
}
return nil
}
```

---

## Wrappers

You can add wrappers to clients to extend their functionality. Chaki provides a default wrapper that adds the following headers to requests if the corresponding values are present in the context:
```go
CorrelationIDKey = "x-correlationId"
ExecutorUserKey = "x-executor-user"
AgentNameKey = "x-agentname"
OwnerKey = "x-owner"
```

### Providing an wrapper

You can wrap the existing client as follows.
```go


type user struct {
publicUsername string
publicTag string
}

func HeaderWrapper() client.DriverWrapper {
return func(restyClient *resty.Client) *resty.Client {
return restyClient.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error {
ctx := r.Context()

h := map[string]string{}

if v := ctx.Value("user"); v != nil {
user := v.(user)
h["publicUsername"] = user.publicUsername
h["publicTag"] = user.publicTag
}

r.SetHeaders(h)
return nil
})
}
}

func newClient(f *client.Factory) *exampleClient {
return &exampleClient{
Base: f.Get("example-client",
client.WithDriverWrappers(HeaderWrapper())),
}
}

```
2 changes: 1 addition & 1 deletion example/client-server-with-otel/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type exampleClient struct {

func newClient(f *client.Factory) *exampleClient {
return &exampleClient{
Base: f.Get("example-client", client.DefaultErrDecoder),
Base: f.Get("example-client"),
}
}

Expand Down
4 changes: 3 additions & 1 deletion example/client-server/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ type exampleClient struct {

func newClient(f *client.Factory) *exampleClient {
return &exampleClient{
Base: f.Get("example-client", client.DefaultErrDecoder),
Base: f.Get("example-client",
client.WithErrDecoder(customErrorDecoder),
client.WithDriverWrappers(HeaderWrapper())),
}
}

Expand Down
30 changes: 30 additions & 0 deletions example/client-server/client/client_wrapper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"github.com/Trendyol/chaki/modules/client"
"github.com/go-resty/resty/v2"
)

type user struct {
publicUsername string
publicTag string
}

func HeaderWrapper() client.DriverWrapper {
return func(restyClient *resty.Client) *resty.Client {
return restyClient.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error {
ctx := r.Context()

h := map[string]string{}

if v := ctx.Value("user"); v != nil {
user := v.(user)
h["publicUsername"] = user.publicUsername
h["publicTag"] = user.publicTag
}

r.SetHeaders(h)
return nil
})
}
}
15 changes: 15 additions & 0 deletions example/client-server/client/error_decoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package main

import (
"context"
"fmt"

"github.com/go-resty/resty/v2"
)

func customErrorDecoder(_ context.Context, res *resty.Response) error {
if res.StatusCode() == 404 {
return fmt.Errorf("not found")
}
return nil
}
3 changes: 3 additions & 0 deletions example/client-server/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/Trendyol/chaki/modules/server"
"github.com/Trendyol/chaki/modules/server/controller"
"github.com/Trendyol/chaki/modules/server/route"
"github.com/Trendyol/chaki/modules/swagger"
)

func main() {
Expand All @@ -17,6 +18,8 @@ func main() {
app.Use(
client.Module(),
server.Module(),

swagger.Module(),
)

app.Provide(
Expand Down
15 changes: 12 additions & 3 deletions modules/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ func NewFactory(cfg *config.Config, wrappers []DriverWrapper) *Factory {
}
}

func (f *Factory) Get(name string, errDecoder ErrDecoder, driverWrappers ...DriverWrapper) *Base {
func (f *Factory) Get(name string, opts ...Option) *Base {
cOpts := &options{
errDecoder: DefaultErrDecoder(name),
driverWrappers: []DriverWrapper{},
}

for _, opt := range opts {
opt.Apply(cOpts)
}

return &Base{
driver: newDriverBuilder(f.cfg.Of("client").Of(name)).
AddErrDecoder(errDecoder).
AddErrDecoder(cOpts.errDecoder).
AddUpdaters(f.baseWrappers...).
AddUpdaters(driverWrappers...).
AddUpdaters(cOpts.driverWrappers...).
build(),
name: name,
}
Expand Down
20 changes: 18 additions & 2 deletions modules/client/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ type driverBuilder struct {
}

func newDriverBuilder(cfg *config.Config) *driverBuilder {
setDefaults(cfg)

d := resty.New().
SetBaseURL(cfg.GetString("baseurl")).
SetTimeout(cfg.GetDuration("timeout")).

// Debug mode provides a logging, but it's not in the same format with our logger.
SetDebug(cfg.GetBool("debug"))
return &driverBuilder{
cfg: cfg,
d: resty.New().SetBaseURL(cfg.GetString("baseurl")),
d: d,
}
}

Expand All @@ -32,7 +40,9 @@ func (b *driverBuilder) AddUpdaters(wrappers ...DriverWrapper) *driverBuilder {
}

func (b *driverBuilder) build() *resty.Client {
b.useLogging()
if b.cfg.GetBool("logging") {
b.useLogging()
}

for _, upd := range b.updaters {
b.d = upd(b.d)
Expand Down Expand Up @@ -68,3 +78,9 @@ func (b *driverBuilder) useLogging() {
return nil
})
}

func setDefaults(cfg *config.Config) {
cfg.SetDefault("timeout", "5s")
cfg.SetDefault("debug", false)
cfg.SetDefault("logging", false)
}
71 changes: 66 additions & 5 deletions modules/client/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,77 @@ package client

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/go-resty/resty/v2"
"github.com/gofiber/fiber/v2"
)

type ErrDecoder func(context.Context, *resty.Response) error

func DefaultErrDecoder(_ context.Context, res *resty.Response) error {
if res.StatusCode() > 300 {
return fiber.NewError(res.StatusCode(), "client error")
type GenericClientError struct {
ClientName string
StatusCode int
RawBody []byte
ParsedBody interface{}
}

func (e GenericClientError) Error() string {
msg := fmt.Sprintf("Error on client %s (Status %d)", e.ClientName, e.StatusCode)
if details := e.extractErrorDetails(); details != "" {
msg += ": " + details
}
return msg
}

func (e GenericClientError) extractErrorDetails() string {
var details []string

var extract func(interface{})
extract = func(v interface{}) {
switch value := v.(type) {
case string:
details = append(details, strings.TrimSpace(value))
case map[string]interface{}:
for _, v := range value {
extract(v)
}
case []interface{}:
for _, v := range value {
extract(v)
}
}
}

extract(e.ParsedBody)

if len(details) == 0 && len(e.RawBody) > 0 {
return strings.TrimSpace(string(e.RawBody))
}

return strings.Join(details, "; ")
}

func DefaultErrDecoder(name string) ErrDecoder {
return func(_ context.Context, res *resty.Response) error {
if res.IsSuccess() {
return nil
}

apiErr := GenericClientError{
ClientName: name,
StatusCode: res.StatusCode(),
RawBody: res.Body(),
}

var jsonBody interface{}
if err := json.Unmarshal(res.Body(), &jsonBody); err == nil {
apiErr.ParsedBody = jsonBody
} else {
apiErr.ParsedBody = string(res.Body())
}

return apiErr
}
return nil
}
Loading