From a1a7280125d1f99aed103bb29c2798cc13ccb472 Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Mon, 1 Jul 2024 16:07:03 +0700 Subject: [PATCH 1/2] feat: add echo transport --- go.work | 1 + go.work.sum | 5 +- transport/echo/client.go | 204 ++++++++ transport/echo/client_test.go | 343 ++++++++++++++ transport/echo/doc.go | 2 + transport/echo/encode_decode.go | 19 + transport/echo/example_test.go | 36 ++ transport/echo/go.mod | 21 + transport/echo/go.sum | 35 ++ transport/echo/request_response_funcs.go | 135 ++++++ transport/echo/request_response_funcs_test.go | 32 ++ transport/echo/server.go | 225 +++++++++ transport/echo/server_test.go | 439 ++++++++++++++++++ 13 files changed, 1495 insertions(+), 2 deletions(-) create mode 100644 transport/echo/client.go create mode 100644 transport/echo/client_test.go create mode 100644 transport/echo/doc.go create mode 100644 transport/echo/encode_decode.go create mode 100644 transport/echo/example_test.go create mode 100644 transport/echo/go.mod create mode 100644 transport/echo/go.sum create mode 100644 transport/echo/request_response_funcs.go create mode 100644 transport/echo/request_response_funcs_test.go create mode 100644 transport/echo/server.go create mode 100644 transport/echo/server_test.go diff --git a/go.work b/go.work index 02a014f..1df4188 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.21.6 use ( ./core ./example + ./transport/echo ./transport/http ./transport/jetstream ) diff --git a/go.work.sum b/go.work.sum index 59ed050..8818754 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,7 +1,8 @@ -github.com/kikihakiem/gkit/transport/http v0.4.0/go.mod h1:EYkrYOEhjhUybgnT8BfpophqnYpuro/wu/d/wXDRKO0= -github.com/kikihakiem/gkit/transport/jetstream v0.3.0/go.mod h1:NIiGOWXz6C0SzZfWCG8c/QXg2NcZuVlZv/peLmNmt5A= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= go.etcd.io/gofail v0.1.0/go.mod h1:VZBCXYGZhHAinaBiiqYvuDynvahNsAyLFwB3kEHKz1M= go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= diff --git a/transport/echo/client.go b/transport/echo/client.go new file mode 100644 index 0000000..64b0a40 --- /dev/null +++ b/transport/echo/client.go @@ -0,0 +1,204 @@ +package echo + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + gkit "github.com/kikihakiem/gkit/core" +) + +// HTTPClient is an interface that models *http.Client. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client wraps a URL and provides a method that implements endpoint.Endpoint. +type Client[Req, Res any] struct { + client HTTPClient + req gkit.EncodeDecodeFunc[Req, *http.Request] + dec gkit.EncodeDecodeFunc[*http.Response, Res] + before []RequestFunc + after []ClientResponseFunc + finalizer []ClientFinalizerFunc + bufferedStream bool +} + +// NewClient constructs a usable Client for a single remote method. +func NewClient[Req, Res any](method string, tgt *url.URL, enc EncodeClientRequestFunc[Req], dec gkit.EncodeDecodeFunc[*http.Response, Res], options ...ClientOption[Req, Res]) *Client[Req, Res] { + return NewExplicitClient(makeCreateRequestFunc(method, tgt, enc), dec, options...) +} + +// NewExplicitClient is like NewClient but uses a CreateRequestFunc instead of a +// method, target URL, and EncodeRequestFunc, which allows for more control over +// the outgoing HTTP request. +func NewExplicitClient[Req, Res any](req gkit.EncodeDecodeFunc[Req, *http.Request], dec gkit.EncodeDecodeFunc[*http.Response, Res], options ...ClientOption[Req, Res]) *Client[Req, Res] { + c := &Client[Req, Res]{ + client: http.DefaultClient, + req: req, + dec: dec, + } + for _, option := range options { + option(c) + } + return c +} + +// ClientOption sets an optional parameter for clients. +type ClientOption[Req, Res any] gkit.Option[*Client[Req, Res]] + +// SetClient sets the underlying HTTP client used for requests. +// By default, http.DefaultClient is used. +func SetClient[Req, Res any](client HTTPClient) ClientOption[Req, Res] { + return func(c *Client[Req, Res]) { c.client = client } +} + +// ClientBefore adds one or more RequestFuncs to be applied to the outgoing HTTP +// request before it's invoked. +func ClientBefore[Req, Res any](before ...RequestFunc) ClientOption[Req, Res] { + return func(c *Client[Req, Res]) { c.before = append(c.before, before...) } +} + +// ClientAfter adds one or more ClientResponseFuncs, which are applied to the +// incoming HTTP response prior to it being decoded. This is useful for +// obtaining anything off of the response and adding it into the context prior +// to decoding. +func ClientAfter[Req, Res any](after ...ClientResponseFunc) ClientOption[Req, Res] { + return func(c *Client[Req, Res]) { c.after = append(c.after, after...) } +} + +// ClientFinalizer adds one or more ClientFinalizerFuncs to be executed at the +// end of every HTTP request. Finalizers are executed in the order in which they +// were added. By default, no finalizer is registered. +func ClientFinalizer[Req, Res any](f ...ClientFinalizerFunc) ClientOption[Req, Res] { + return func(s *Client[Req, Res]) { s.finalizer = append(s.finalizer, f...) } +} + +// BufferedStream sets whether the HTTP response body is left open, allowing it +// to be read from later. Useful for transporting a file as a buffered stream. +// That body has to be drained and closed to properly end the request. +func BufferedStream[Req, Res any](buffered bool) ClientOption[Req, Res] { + return func(c *Client[Req, Res]) { c.bufferedStream = buffered } +} + +// Endpoint returns a usable Go kit endpoint that calls the remote HTTP endpoint. +func (c Client[Req, Res]) Endpoint() gkit.Endpoint[Req, Res] { + return func(ctx context.Context, request Req) (Res, error) { + ctx, cancel := context.WithCancel(ctx) + + var ( + resp *http.Response + response Res + err error + ) + if c.finalizer != nil { + defer func() { + if resp != nil { + ctx = context.WithValue(ctx, ContextKeyResponseHeaders, resp.Header) + ctx = context.WithValue(ctx, ContextKeyResponseSize, resp.ContentLength) + } + for _, f := range c.finalizer { + f(ctx, err) + } + }() + } + + req, err := c.req(ctx, request) + if err != nil { + cancel() + return response, err + } + + for _, f := range c.before { + ctx = f(ctx, req) + } + + resp, err = c.client.Do(req.WithContext(ctx)) + if err != nil { + cancel() + return response, err + } + + // If the caller asked for a buffered stream, we don't cancel the + // context when the endpoint returns. Instead, we should call the + // cancel func when closing the response body. + if c.bufferedStream { + resp.Body = bodyWithCancel{ReadCloser: resp.Body, cancel: cancel} + } else { + defer resp.Body.Close() + defer cancel() + } + + for _, f := range c.after { + ctx = f(ctx, resp) + } + + response, err = c.dec(ctx, resp) + if err != nil { + return response, err + } + + return response, nil + } +} + +// bodyWithCancel is a wrapper for an io.ReadCloser with also a +// cancel function which is called when the Close is used +type bodyWithCancel struct { + io.ReadCloser + + cancel context.CancelFunc +} + +func (bwc bodyWithCancel) Close() error { + bwc.ReadCloser.Close() + bwc.cancel() + return nil +} + +// ClientFinalizerFunc can be used to perform work at the end of a client HTTP +// request, after the response is returned. The principal +// intended use is for error logging. Additional response parameters are +// provided in the context under keys with the ContextKeyResponse prefix. +// Note: err may be nil. There maybe also no additional response parameters +// depending on when an error occurs. +type ClientFinalizerFunc func(ctx context.Context, err error) + +// EncodeJSONRequest is an EncodeRequestFunc that serializes the request as a +// JSON object to the Request body. Many JSON-over-HTTP services can use it as +// a sensible default. TODO: If the request implements Headerer, the provided headers +// will be applied to the request. +func EncodeJSONRequest[Req any](c context.Context, r *http.Request, request Req) error { + r.Header.Set("Content-Type", "application/json; charset=utf-8") + + if headerer, ok := any(request).(Headerer); ok { + for k := range headerer.Headers() { + r.Header.Set(k, headerer.Headers().Get(k)) + } + } + + var b bytes.Buffer + r.Body = io.NopCloser(&b) + + return json.NewEncoder(&b).Encode(request) +} + +type EncodeClientRequestFunc[Req any] func(context.Context, *http.Request, Req) error + +func makeCreateRequestFunc[Req any](method string, target *url.URL, enc EncodeClientRequestFunc[Req]) gkit.EncodeDecodeFunc[Req, *http.Request] { + return func(ctx context.Context, request Req) (*http.Request, error) { + req, err := http.NewRequest(method, target.String(), nil) + if err != nil { + return nil, err + } + + if err = enc(ctx, req, request); err != nil { + return nil, err + } + + return req, nil + } +} diff --git a/transport/echo/client_test.go b/transport/echo/client_test.go new file mode 100644 index 0000000..78f9040 --- /dev/null +++ b/transport/echo/client_test.go @@ -0,0 +1,343 @@ +//go:build unit + +package echo_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + echotransport "github.com/kikihakiem/gkit/transport/echo" +) + +type TestResponse struct { + Body io.ReadCloser + String string +} + +func TestHTTPClient(t *testing.T) { + var ( + testbody = "testbody" + encode = func(context.Context, *http.Request, struct{}) error { return nil } + decode = func(_ context.Context, r *http.Response) (TestResponse, error) { + buffer := make([]byte, len(testbody)) + r.Body.Read(buffer) + return TestResponse{r.Body, string(buffer)}, nil + } + headers = make(chan string, 1) + headerKey = "X-Foo" + headerVal = "abcde" + afterHeaderKey = "X-The-Dude" + afterHeaderVal = "Abides" + afterVal = "" + afterFunc = func(ctx context.Context, r *http.Response) context.Context { + afterVal = r.Header.Get(afterHeaderKey) + return ctx + } + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Get(headerKey) + w.Header().Set(afterHeaderKey, afterHeaderVal) + w.WriteHeader(http.StatusOK) + w.Write([]byte(testbody)) + })) + + client := echotransport.NewClient[struct{}, TestResponse]( + "GET", + mustParse(server.URL), + encode, + decode, + echotransport.ClientBefore[struct{}, TestResponse](echotransport.SetRequestHeader(headerKey, headerVal)), + echotransport.ClientAfter[struct{}, TestResponse](afterFunc), + ) + + res, err := client.Endpoint()(context.Background(), struct{}{}) + if err != nil { + t.Fatal(err) + } + + var have string + select { + case have = <-headers: + case <-time.After(time.Millisecond): + t.Fatalf("timeout waiting for %s", headerKey) + } + // Check that Request Header was successfully received + if want := headerVal; want != have { + t.Errorf("want %q, have %q", want, have) + } + + // Check that Response header set from server was received in SetClientAfter + if want, have := afterVal, afterHeaderVal; want != have { + t.Errorf("want %q, have %q", want, have) + } + + if want, have := testbody, res.String; want != have { + t.Errorf("want %q, have %q", want, have) + } + + // Check that response body was closed + b := make([]byte, 1) + _, err = res.Body.Read(b) + if err == nil { + t.Fatal("wanted error, got none") + } + if doNotWant, have := io.EOF, err; doNotWant == have { + t.Errorf("do not want %q, have %q", doNotWant, have) + } +} + +func TestHTTPClientBufferedStream(t *testing.T) { + // bodysize has a size big enought to make the resopnse.Body not an instant read + // so if the response is cancelled it wount be all readed and the test would fail + // The 6000 has not a particular meaning, it big enough to fulfill the usecase. + const bodysize = 6000 + var ( + testbody = string(make([]byte, bodysize)) + encode = func(context.Context, *http.Request, struct{}) error { return nil } + decode = func(_ context.Context, r *http.Response) (TestResponse, error) { + return TestResponse{r.Body, ""}, nil + } + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testbody)) + })) + + client := echotransport.NewClient[struct{}, TestResponse]( + "GET", + mustParse(server.URL), + encode, + decode, + echotransport.BufferedStream[struct{}, TestResponse](true), + ) + + res, err := client.Endpoint()(context.Background(), struct{}{}) + if err != nil { + t.Fatal(err) + } + + defer res.Body.Close() + // Faking work + time.Sleep(time.Second * 1) + + // Check that response body was NOT closed + b := make([]byte, len(testbody)) + _, err = res.Body.Read(b) + if want, have := io.EOF, err; have != want { + t.Fatalf("want %q, have %q", want, have) + } + if want, have := testbody, string(b); want != have { + t.Errorf("want %q, have %q", want, have) + } +} + +func TestClientFinalizer(t *testing.T) { + var ( + headerKey = "X-Henlo-Lizer" + headerVal = "Helllo you stinky lizard" + responseBody = "go eat a fly ugly\n" + done = make(chan struct{}) + encode = func(context.Context, *http.Request, struct{}) error { return nil } + decode = func(_ context.Context, r *http.Response) (TestResponse, error) { + return TestResponse{r.Body, ""}, nil + } + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(headerKey, headerVal) + w.Write([]byte(responseBody)) + })) + defer server.Close() + + client := echotransport.NewClient[struct{}, TestResponse]( + "GET", + mustParse(server.URL), + encode, + decode, + echotransport.ClientFinalizer[struct{}, TestResponse](func(ctx context.Context, err error) { + responseHeader := ctx.Value(echotransport.ContextKeyResponseHeaders).(http.Header) + if want, have := headerVal, responseHeader.Get(headerKey); want != have { + t.Errorf("%s: want %q, have %q", headerKey, want, have) + } + + responseSize := ctx.Value(echotransport.ContextKeyResponseSize).(int64) + if want, have := int64(len(responseBody)), responseSize; want != have { + t.Errorf("response size: want %d, have %d", want, have) + } + + close(done) + }), + ) + + _, err := client.Endpoint()(context.Background(), struct{}{}) + if err != nil { + t.Fatal(err) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for finalizer") + } +} + +func TestEncodeJSONRequest(t *testing.T) { + var ( + header http.Header + body string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil && err != io.EOF { + t.Fatal(err) + } + header = r.Header + + body = string(b) + })) + + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + client := echotransport.NewClient[any]( + "POST", + serverURL, + echotransport.EncodeJSONRequest, + func(context.Context, *http.Response) (any, error) { return nil, nil }, + ).Endpoint() + + for _, test := range []struct { + value any + body string + }{ + {nil, "null\n"}, + {12, "12\n"}, + {1.2, "1.2\n"}, + {true, "true\n"}, + {"test", "\"test\"\n"}, + {enhancedRequest{Foo: "foo"}, "{\"foo\":\"foo\"}\n"}, + } { + if _, err := client(context.Background(), test.value); err != nil { + t.Error(err) + continue + } + + if body != test.body { + t.Errorf("%v: actual %#v, expected %#v", test.value, body, test.body) + } + + if _, err := client(context.Background(), enhancedRequest{Foo: "foo"}); err != nil { + t.Fatal(err) + } + + if _, ok := header["X-Edward"]; !ok { + t.Fatalf("X-Edward value: actual %v, expected %v", nil, []string{"Snowden"}) + } + + if v := header.Get("X-Edward"); v != "Snowden" { + t.Errorf("X-Edward string: actual %v, expected %v", v, "Snowden") + } + } +} + +func TestSetClient(t *testing.T) { + var ( + encode = func(context.Context, *http.Request, any) error { return nil } + decode = func(_ context.Context, r *http.Response) (string, error) { + t, err := io.ReadAll(r.Body) + if err != nil { + return "", err + } + return string(t), nil + } + ) + + testHttpClient := httpClientFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Request: req, + Body: io.NopCloser(bytes.NewBufferString("hello, world!")), + }, nil + }) + + client := echotransport.NewClient[any, string]( + "GET", + &url.URL{}, + encode, + decode, + echotransport.SetClient[any, string](testHttpClient), + ).Endpoint() + + resp, err := client(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if resp != "hello, world!" { + t.Fatal("Expected response to be 'hello, world!' string") + } +} + +func TestNewExplicitClient(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%d", r.ContentLength) + })) + defer srv.Close() + + req := func(ctx context.Context, request any) (*http.Request, error) { + req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(request.(string))) + return req, nil + } + + dec := func(_ context.Context, resp *http.Response) (response any, err error) { + buf, err := io.ReadAll(resp.Body) + resp.Body.Close() + return string(buf), err + } + + client := echotransport.NewExplicitClient(req, dec) + + request := "hello world" + response, err := client.Endpoint()(context.Background(), request) + if err != nil { + t.Fatal(err) + } + + if want, have := "11", response.(string); want != have { + t.Fatalf("want %q, have %q", want, have) + } +} + +func mustParse(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} + +type enhancedRequest struct { + Foo string `json:"foo"` +} + +func (e enhancedRequest) Headers() http.Header { return http.Header{"X-Edward": []string{"Snowden"}} } + +type httpClientFunc func(req *http.Request) (*http.Response, error) + +func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/transport/echo/doc.go b/transport/echo/doc.go new file mode 100644 index 0000000..642d626 --- /dev/null +++ b/transport/echo/doc.go @@ -0,0 +1,2 @@ +// package echo provides a general purpose HTTP binding for gkit.Endpoint. +package echo diff --git a/transport/echo/encode_decode.go b/transport/echo/encode_decode.go new file mode 100644 index 0000000..7031c41 --- /dev/null +++ b/transport/echo/encode_decode.go @@ -0,0 +1,19 @@ +package echo + +import ( + "context" + + "github.com/labstack/echo/v4" +) + +// EncodeRequestFunc encodes the passed request object into the HTTP request +// object. It's designed to be used in HTTP clients, for client-side +// endpoints. One straightforward EncodeRequestFunc could be something that JSON +// encodes the object directly to the request body. +type EncodeRequestFunc[Req any] func(context.Context, echo.Context, Req) error + +// EncodeResponseFunc encodes the passed response object to the HTTP response +// writer. It's designed to be used in HTTP servers, for server-side +// endpoints. One straightforward EncodeResponseFunc could be something that +// JSON encodes the object directly to the response body. +type EncodeResponseFunc[Res any] func(context.Context, echo.Context, Res) error diff --git a/transport/echo/example_test.go b/transport/echo/example_test.go new file mode 100644 index 0000000..abe887a --- /dev/null +++ b/transport/echo/example_test.go @@ -0,0 +1,36 @@ +//go:build unit + +package echo_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + echotransport "github.com/kikihakiem/gkit/transport/echo" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestPopulateRequestContext(t *testing.T) { + handler := echotransport.NewServer( + func(ctx context.Context, request struct{}) (response struct{}, err error) { + assert.Equal(t, http.MethodPatch, ctx.Value(echotransport.ContextKeyRequestMethod).(string)) + assert.Equal(t, "/search", ctx.Value(echotransport.ContextKeyRequestPath).(string)) + assert.Equal(t, "/search?q=sympatico", ctx.Value(echotransport.ContextKeyRequestURI).(string)) + assert.Equal(t, "a1b2c3d4e5", ctx.Value(echotransport.ContextKeyRequestXRequestID).(string)) + return struct{}{}, nil + }, + func(context.Context, echo.Context) (struct{}, error) { return struct{}{}, nil }, + func(context.Context, echo.Context, struct{}) error { return nil }, + echotransport.ServerBefore[struct{}, struct{}](echotransport.PopulateRequestContext), + ) + + e := echo.New() + req := httptest.NewRequest(http.MethodPatch, "/search?q=sympatico", nil) + req.Header.Set("X-Request-Id", "a1b2c3d4e5") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + handler.ServeHTTP(c) +} diff --git a/transport/echo/go.mod b/transport/echo/go.mod new file mode 100644 index 0000000..9517514 --- /dev/null +++ b/transport/echo/go.mod @@ -0,0 +1,21 @@ +module github.com/kikihakiem/gkit/transport/echo + +go 1.21.6 + +require ( + github.com/kikihakiem/gkit/core v0.4.0 + github.com/kikihakiem/gkit/transport/http v0.5.0 + github.com/labstack/echo/v4 v4.12.0 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/transport/echo/go.sum b/transport/echo/go.sum new file mode 100644 index 0000000..1c3ba2a --- /dev/null +++ b/transport/echo/go.sum @@ -0,0 +1,35 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kikihakiem/gkit/core v0.4.0 h1:NHiKDWJXkBGT2ZBd+n5vaK/iBa9aBCC1/cdZzeZYl3c= +github.com/kikihakiem/gkit/core v0.4.0/go.mod h1:PjK77BVx0+eVzPqx4U9I0FT0ljRSenyKBDiM5KO4/oY= +github.com/kikihakiem/gkit/transport/http v0.5.0 h1:n4FakQrRMi8++ofUPSB/uCnRcMg1MYE4eK2Rto30i9A= +github.com/kikihakiem/gkit/transport/http v0.5.0/go.mod h1:EYkrYOEhjhUybgnT8BfpophqnYpuro/wu/d/wXDRKO0= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/transport/echo/request_response_funcs.go b/transport/echo/request_response_funcs.go new file mode 100644 index 0000000..9e1028d --- /dev/null +++ b/transport/echo/request_response_funcs.go @@ -0,0 +1,135 @@ +package echo + +import ( + "context" + "net/http" + + "github.com/labstack/echo/v4" +) + +// RequestFunc may take information from an HTTP request and put it into a +// request context. In Servers, RequestFuncs are executed prior to invoking the +// endpoint. In Clients, RequestFuncs are executed after creating the request +// but prior to invoking the HTTP client. +type RequestFunc func(context.Context, *http.Request) context.Context + +// ServerResponseFunc may take information from a request context and use it to +// manipulate a ResponseWriter. ServerResponseFuncs are only executed in +// servers, after invoking the endpoint but prior to writing a response. +type ServerResponseFunc func(context.Context, http.ResponseWriter) context.Context + +// ClientResponseFunc may take information from an HTTP request and make the +// response available for consumption. ClientResponseFuncs are only executed in +// clients, after a request has been made, but prior to it being decoded. +type ClientResponseFunc func(context.Context, *http.Response) context.Context + +// SetContentType returns a ServerResponseFunc that sets the Content-Type header +// to the provided value. +func SetContentType(contentType string) ServerResponseFunc { + return SetResponseHeader("Content-Type", contentType) +} + +// SetResponseHeader returns a ServerResponseFunc that sets the given header. +func SetResponseHeader(key, val string) ServerResponseFunc { + return func(ctx context.Context, w http.ResponseWriter) context.Context { + w.Header().Set(key, val) + return ctx + } +} + +// SetRequestHeader returns a RequestFunc that sets the given header. +func SetRequestHeader(key, val string) RequestFunc { + return func(ctx context.Context, r *http.Request) context.Context { + r.Header.Set(key, val) + return ctx + } +} + +// PopulateRequestContext is a RequestFunc that populates several values into +// the context from the HTTP request. Those values may be extracted using the +// corresponding ContextKey type in this package. +func PopulateRequestContext(ctx context.Context, c echo.Context) context.Context { + for k, v := range map[contextKey]string{ + ContextKeyRequestMethod: c.Request().Method, + ContextKeyRequestURI: c.Request().RequestURI, + ContextKeyRequestPath: c.Request().URL.Path, + ContextKeyRequestProto: c.Request().Proto, + ContextKeyRequestHost: c.Request().Host, + ContextKeyRequestRemoteAddr: c.Request().RemoteAddr, + ContextKeyRequestXForwardedFor: c.Request().Header.Get("X-Forwarded-For"), + ContextKeyRequestXForwardedProto: c.Request().Header.Get("X-Forwarded-Proto"), + ContextKeyRequestAuthorization: c.Request().Header.Get("Authorization"), + ContextKeyRequestReferer: c.Request().Header.Get("Referer"), + ContextKeyRequestUserAgent: c.Request().Header.Get("User-Agent"), + ContextKeyRequestXRequestID: c.Request().Header.Get("X-Request-Id"), + ContextKeyRequestAccept: c.Request().Header.Get("Accept"), + } { + ctx = context.WithValue(ctx, k, v) + } + return ctx +} + +type contextKey int + +const ( + // ContextKeyRequestMethod is populated in the context by + // PopulateRequestContext. Its value is r.Method. + ContextKeyRequestMethod contextKey = iota + + // ContextKeyRequestURI is populated in the context by + // PopulateRequestContext. Its value is r.RequestURI. + ContextKeyRequestURI + + // ContextKeyRequestPath is populated in the context by + // PopulateRequestContext. Its value is r.URL.Path. + ContextKeyRequestPath + + // ContextKeyRequestProto is populated in the context by + // PopulateRequestContext. Its value is r.Proto. + ContextKeyRequestProto + + // ContextKeyRequestHost is populated in the context by + // PopulateRequestContext. Its value is r.Host. + ContextKeyRequestHost + + // ContextKeyRequestRemoteAddr is populated in the context by + // PopulateRequestContext. Its value is r.RemoteAddr. + ContextKeyRequestRemoteAddr + + // ContextKeyRequestXForwardedFor is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("X-Forwarded-For"). + ContextKeyRequestXForwardedFor + + // ContextKeyRequestXForwardedProto is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("X-Forwarded-Proto"). + ContextKeyRequestXForwardedProto + + // ContextKeyRequestAuthorization is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("Authorization"). + ContextKeyRequestAuthorization + + // ContextKeyRequestReferer is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("Referer"). + ContextKeyRequestReferer + + // ContextKeyRequestUserAgent is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("User-Agent"). + ContextKeyRequestUserAgent + + // ContextKeyRequestXRequestID is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("X-Request-Id"). + ContextKeyRequestXRequestID + + // ContextKeyRequestAccept is populated in the context by + // PopulateRequestContext. Its value is r.Header.Get("Accept"). + ContextKeyRequestAccept + + // ContextKeyResponseHeaders is populated in the context whenever a + // ServerFinalizerFunc is specified. Its value is of type http.Header, and + // is captured only once the entire response has been written. + ContextKeyResponseHeaders + + // ContextKeyResponseSize is populated in the context whenever a + // ServerFinalizerFunc is specified. Its value is of type int64. + ContextKeyResponseSize +) diff --git a/transport/echo/request_response_funcs_test.go b/transport/echo/request_response_funcs_test.go new file mode 100644 index 0000000..a88cf87 --- /dev/null +++ b/transport/echo/request_response_funcs_test.go @@ -0,0 +1,32 @@ +//go:build unit + +package echo_test + +import ( + "context" + "net/http/httptest" + "testing" + + echotransport "github.com/kikihakiem/gkit/transport/echo" +) + +func TestSetHeader(t *testing.T) { + const ( + key = "X-Foo" + val = "12345" + ) + r := httptest.NewRecorder() + echotransport.SetResponseHeader(key, val)(context.Background(), r) + if want, have := val, r.Header().Get(key); want != have { + t.Errorf("want %q, have %q", want, have) + } +} + +func TestSetContentType(t *testing.T) { + const contentType = "application/json" + r := httptest.NewRecorder() + echotransport.SetContentType(contentType)(context.Background(), r) + if want, have := contentType, r.Header().Get("Content-Type"); want != have { + t.Errorf("want %q, have %q", want, have) + } +} diff --git a/transport/echo/server.go b/transport/echo/server.go new file mode 100644 index 0000000..9bf7cd4 --- /dev/null +++ b/transport/echo/server.go @@ -0,0 +1,225 @@ +package echo + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/labstack/echo/v4" + + gkit "github.com/kikihakiem/gkit/core" +) + +// Server wraps an endpoint and implements echo.HandlerFunc. +type Server[Req, Res any] struct { + e gkit.Endpoint[Req, Res] + dec gkit.EncodeDecodeFunc[echo.Context, Req] + enc EncodeResponseFunc[Res] + before []gkit.BeforeRequestFunc[echo.Context] + after []gkit.AfterResponseFunc[echo.Context] + errorEncoder gkit.ErrorEncoder[echo.Context] + finalizer []ServerFinalizerFunc + errorHandler gkit.ErrorHandler +} + +// NewServer constructs a new HTTP server, which implements echo.HandlerFunc and wraps +// the provided endpoint. +func NewServer[Req, Res any]( + e gkit.Endpoint[Req, Res], + dec gkit.EncodeDecodeFunc[echo.Context, Req], + enc EncodeResponseFunc[Res], + options ...ServerOption[Req, Res], +) *Server[Req, Res] { + s := &Server[Req, Res]{ + e: e, + dec: dec, + enc: enc, + errorEncoder: DefaultErrorEncoder, + errorHandler: gkit.LogErrorHandler(nil), + } + for _, option := range options { + option(s) + } + return s +} + +// ServerOption sets an optional parameter for servers. +type ServerOption[Req, Res any] gkit.Option[*Server[Req, Res]] + +// ServerBefore functions are executed on the HTTP request object before the +// request is decoded. +func ServerBefore[Req, Res any](before ...gkit.BeforeRequestFunc[echo.Context]) ServerOption[Req, Res] { + return func(s *Server[Req, Res]) { s.before = append(s.before, before...) } +} + +// ServerAfter functions are executed on the HTTP response writer after the +// endpoint is invoked, but before anything is written to the client. +func ServerAfter[Req, Res any](after ...gkit.AfterResponseFunc[echo.Context]) ServerOption[Req, Res] { + return func(s *Server[Req, Res]) { s.after = append(s.after, after...) } +} + +// ServerErrorEncoder is used to encode errors to the echo.Context +// whenever they're encountered in the processing of a request. Clients can +// use this to provide custom error formatting and response codes. By default, +// errors will be written with the DefaultErrorEncoder. +func ServerErrorEncoder[Req, Res any](ee gkit.ErrorEncoder[echo.Context]) ServerOption[Req, Res] { + return func(s *Server[Req, Res]) { s.errorEncoder = ee } +} + +// ServerErrorHandler is used to handle non-terminal errors. By default, non-terminal errors +// are ignored. This is intended as a diagnostic measure. Finer-grained control +// of error handling, including logging in more detail, should be performed in a +// custom ServerErrorEncoder or ServerFinalizer, both of which have access to +// the context. +func ServerErrorHandler[Req, Res any](errorHandler gkit.ErrorHandler) ServerOption[Req, Res] { + return func(s *Server[Req, Res]) { s.errorHandler = errorHandler } +} + +// ServerFinalizer is executed at the end of every HTTP request. +// By default, no finalizer is registered. +func ServerFinalizer[Req, Res any](f ...ServerFinalizerFunc) ServerOption[Req, Res] { + return func(s *Server[Req, Res]) { s.finalizer = append(s.finalizer, f...) } +} + +// ServeHTTP implements echo.HandlerFunc. +func (s Server[Req, Res]) ServeHTTP(c echo.Context) error { + ctx := c.Request().Context() + + if len(s.finalizer) > 0 { + defer func() { + ctx = context.WithValue(ctx, ContextKeyResponseHeaders, c.Response().Header()) + ctx = context.WithValue(ctx, ContextKeyResponseSize, c.Response().Size) + + for _, f := range s.finalizer { + f(ctx, c.Response().Status, c) + } + }() + } + + for _, f := range s.before { + ctx = f(ctx, c) + } + + request, err := s.dec(ctx, c) + if err != nil { + s.errorHandler.Handle(ctx, err) + s.errorEncoder(ctx, c, err) + return err + } + + response, err := s.e(ctx, request) + if err != nil { + s.errorHandler.Handle(ctx, err) + s.errorEncoder(ctx, c, err) + return err + } + + for _, f := range s.after { + ctx = f(ctx, c, err) + } + + if err := s.enc(ctx, c, response); err != nil { + s.errorHandler.Handle(ctx, err) + s.errorEncoder(ctx, c, err) + return err + } + + return nil +} + +// ErrorEncoder is responsible for encoding an error to the ResponseWriter. +// Users are encouraged to use custom ErrorEncoders to encode HTTP errors to +// their clients, and will likely want to pass and check for their own error +// types. See the example shipping/handling service. +type ErrorEncoder func(ctx context.Context, err error, c echo.Context) + +// ServerFinalizerFunc can be used to perform work at the end of an HTTP +// request, after the response has been written to the client. The principal +// intended use is for request logging. In addition to the response code +// provided in the function signature, additional response parameters are +// provided in the context under keys with the ContextKeyResponse prefix. +type ServerFinalizerFunc func(ctx context.Context, code int, c echo.Context) + +// DecodeJSONRequest is a DecodeRequestFunc that deserialize JSON to domain object. +func DecodeJSONRequest[Req any](_ context.Context, c echo.Context) (Req, error) { + var req Req + + err := c.Bind(&req) + if err != nil { + return req, err + } + + return req, nil +} + +// EncodeJSONResponse is a EncodeResponseFunc that serializes the response as a +// JSON object to the ResponseWriter. Many JSON-over-HTTP services can use it as +// a sensible default. TODO: If the response implements Headerer, the provided headers +// will be applied to the response. If the response implements StatusCoder, the +// provided StatusCode will be used instead of 200. +func EncodeJSONResponse[Res any](_ context.Context, c echo.Context, response Res) error { + if headerer, ok := any(response).(Headerer); ok { + for k, values := range headerer.Headers() { + for _, v := range values { + c.Response().Header().Add(k, v) + } + } + } + + code := http.StatusOK + if sc, ok := any(response).(StatusCoder); ok { + code = sc.StatusCode() + } + + if code == http.StatusNoContent { + return c.NoContent(code) + } + + return c.JSON(code, response) +} + +// DefaultErrorEncoder writes the error to the ResponseWriter, by default a +// content type of text/plain, a body of the plain text of the error, and a +// status code of 500. If the error implements Headerer, the provided headers +// will be applied to the response. If the error implements json.Marshaler, and +// the marshaling succeeds, a content type of application/json and the JSON +// encoded form of the error will be used. If the error implements StatusCoder, +// the provided StatusCode will be used instead of 500. +func DefaultErrorEncoder(_ context.Context, c echo.Context, err error) { + contentType, body := "text/plain; charset=utf-8", []byte(err.Error()) + + if marshaler, ok := err.(json.Marshaler); ok { + if jsonBody, marshalErr := marshaler.MarshalJSON(); marshalErr == nil { + contentType, body = "application/json; charset=utf-8", jsonBody + } + } + + if headerer, ok := err.(Headerer); ok { + for k, values := range headerer.Headers() { + for _, v := range values { + c.Response().Header().Add(k, v) + } + } + } + + code := http.StatusInternalServerError + if sc, ok := err.(StatusCoder); ok { + code = sc.StatusCode() + } + + c.Blob(code, contentType, body) //nolint:errcheck +} + +// StatusCoder is checked by DefaultErrorEncoder. If an error value implements +// StatusCoder, the StatusCode will be used when encoding the error. By default, +// StatusInternalServerError (500) is used. +type StatusCoder interface { + StatusCode() int +} + +// Headerer is checked by DefaultErrorEncoder. If an error value implements +// Headerer, the provided headers will be applied to the response writer, after +// the Content-Type is set. +type Headerer interface { + Headers() http.Header +} diff --git a/transport/echo/server_test.go b/transport/echo/server_test.go new file mode 100644 index 0000000..6ca1c19 --- /dev/null +++ b/transport/echo/server_test.go @@ -0,0 +1,439 @@ +package echo_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + gkit "github.com/kikihakiem/gkit/core" + echotransport "github.com/kikihakiem/gkit/transport/echo" + "github.com/labstack/echo/v4" +) + +type emptyStruct struct{} + +func TestServerBadDecode(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, errors.New("dang") }, + func(context.Context, echo.Context, any) error { return nil }, + ) + rec, _ := handleWith(handler) + if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { + t.Errorf("want %d, have %d", want, have) + } +} + +func TestServerBadEndpoint(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return emptyStruct{}, errors.New("dang") }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context, any) error { return nil }, + ) + rec, _ := handleWith(handler) + if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { + t.Errorf("want %d, have %d", want, have) + } +} + +func TestServerBadEncode(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context, any) error { return errors.New("dang") }, + ) + rec, _ := handleWith(handler) + if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { + t.Errorf("want %d, have %d", want, have) + } +} + +func TestServerErrorEncoder(t *testing.T) { + errTeapot := errors.New("teapot") + code := func(err error) int { + if errors.Is(err, errTeapot) { + return http.StatusTeapot + } + return http.StatusInternalServerError + } + handler := echotransport.NewServer( + func(context.Context, emptyStruct) (emptyStruct, error) { return emptyStruct{}, errTeapot }, + func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context, emptyStruct) error { return nil }, + echotransport.ServerErrorEncoder[emptyStruct, emptyStruct](func(_ context.Context, c echo.Context, err error) { c.Response().WriteHeader(code(err)) }), + ) + rec, _ := handleWith(handler) + if want, have := http.StatusTeapot, rec.Result().StatusCode; want != have { + t.Errorf("want %d, have %d", want, have) + } +} + +func TestServerErrorHandler(t *testing.T) { + errTeapot := errors.New("teapot") + msgChan := make(chan string, 1) + handler := echotransport.NewServer( + func(context.Context, emptyStruct) (emptyStruct, error) { return emptyStruct{}, errTeapot }, + func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context, emptyStruct) error { return nil }, + echotransport.ServerErrorHandler[emptyStruct, emptyStruct](gkit.ErrorHandlerFunc(func(ctx context.Context, err error) { + msgChan <- err.Error() + })), + ) + handleWith(handler) + if want, have := errTeapot.Error(), <-msgChan; want != have { + t.Errorf("want %s, have %s", want, have) + } +} + +func TestServerHappyPath(t *testing.T) { + step, response := testServer(t) + step() + resp := <-response + + if want, have := http.StatusOK, resp.Result().StatusCode; want != have { + t.Errorf("want %d, have %d (%s)", want, have, resp.Body.String()) + } +} + +func TestMultipleServerBefore(t *testing.T) { + var ( + headerKey = "X-Henlo-Lizer" + headerVal = "Helllo you stinky lizard" + statusCode = http.StatusTeapot + responseBody = "go eat a fly ugly\n" + done = make(chan emptyStruct) + ) + handler := echotransport.NewServer( + gkit.NopEndpoint, + func(context.Context, echo.Context) (emptyStruct, error) { + return emptyStruct{}, nil + }, + func(_ context.Context, c echo.Context, _ emptyStruct) error { + c.Response().Header().Set(headerKey, headerVal) + c.Response().WriteHeader(statusCode) + c.Response().Write([]byte(responseBody)) + return nil + }, + echotransport.ServerBefore[emptyStruct, emptyStruct](func(ctx context.Context, r echo.Context) context.Context { + ctx = context.WithValue(ctx, "one", 1) + + return ctx + }), + echotransport.ServerBefore[emptyStruct, emptyStruct](func(ctx context.Context, r echo.Context) context.Context { + if _, ok := ctx.Value("one").(int); !ok { + t.Error("Value was not set properly when multiple ServerBefores are used") + } + + close(done) + return ctx + }), + ) + + handleWith(handler) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for finalizer") + } +} + +func TestMultipleServerAfter(t *testing.T) { + var ( + headerKey = "X-Henlo-Lizer" + headerVal = "Helllo you stinky lizard" + statusCode = http.StatusTeapot + responseBody = "go eat a fly ugly\n" + done = make(chan emptyStruct) + ) + handler := echotransport.NewServer( + gkit.NopEndpoint, + func(context.Context, echo.Context) (emptyStruct, error) { + return emptyStruct{}, nil + }, + func(_ context.Context, c echo.Context, _ emptyStruct) error { + c.Response().Header().Set(headerKey, headerVal) + c.Response().WriteHeader(statusCode) + c.Response().Write([]byte(responseBody)) + return nil + }, + echotransport.ServerAfter[emptyStruct, emptyStruct](func(ctx context.Context, _ echo.Context, _ error) context.Context { + ctx = context.WithValue(ctx, "one", 1) + + return ctx + }), + echotransport.ServerAfter[emptyStruct, emptyStruct](func(ctx context.Context, _ echo.Context, _ error) context.Context { + if _, ok := ctx.Value("one").(int); !ok { + t.Error("Value was not set properly when multiple ServerAfters are used") + } + + close(done) + return ctx + }), + ) + + handleWith(handler) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for finalizer") + } +} + +func TestServerFinalizer(t *testing.T) { + var ( + headerKey = "X-Henlo-Lizer" + headerVal = "Helllo you stinky lizard" + statusCode = http.StatusTeapot + responseBody = "go eat a fly ugly\n" + done = make(chan emptyStruct) + ) + handler := echotransport.NewServer( + gkit.NopEndpoint, + func(context.Context, echo.Context) (emptyStruct, error) { + return emptyStruct{}, nil + }, + func(_ context.Context, c echo.Context, _ emptyStruct) error { + c.Response().Header().Set(headerKey, headerVal) + c.Response().WriteHeader(statusCode) + c.Response().Write([]byte(responseBody)) + return nil + }, + echotransport.ServerFinalizer[emptyStruct, emptyStruct](func(ctx context.Context, code int, _ echo.Context) { + if want, have := statusCode, code; want != have { + t.Errorf("StatusCode: want %d, have %d", want, have) + } + + responseHeader := ctx.Value(echotransport.ContextKeyResponseHeaders).(http.Header) + if want, have := headerVal, responseHeader.Get(headerKey); want != have { + t.Errorf("%s: want %q, have %q", headerKey, want, have) + } + + responseSize := ctx.Value(echotransport.ContextKeyResponseSize).(int64) + if want, have := int64(len(responseBody)), responseSize; want != have { + t.Errorf("response size: want %d, have %d", want, have) + } + + close(done) + }), + ) + + handleWith(handler) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for finalizer") + } +} + +type enhancedResponse struct { + Foo string `json:"foo"` +} + +func (e enhancedResponse) StatusCode() int { return http.StatusPaymentRequired } +func (e enhancedResponse) Headers() http.Header { return http.Header{"X-Edward": []string{"Snowden"}} } + +func TestEncodeJSONResponse(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return enhancedResponse{Foo: "bar"}, nil }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + echotransport.EncodeJSONResponse, + ) + + rec, err := handleWith(handler) + if err != nil { + t.Fatal(err) + } + if want, have := http.StatusPaymentRequired, rec.Result().StatusCode; want != have { + t.Errorf("StatusCode: want %d, have %d", want, have) + } + + if want, have := `{"foo":"bar"}`, strings.TrimSpace(rec.Body.String()); want != have { + t.Errorf("Body: want %s, have %s", want, have) + } +} + +type multiHeaderResponse emptyStruct + +func (_ multiHeaderResponse) Headers() http.Header { + return http.Header{"Vary": []string{"Origin", "User-Agent"}} +} + +func TestAddMultipleHeaders(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return multiHeaderResponse{}, nil }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + echotransport.EncodeJSONResponse, + ) + + rec, err := handleWith(handler) + if err != nil { + t.Fatal(err) + } + expect := map[string]map[string]emptyStruct{"Vary": {"Origin": emptyStruct{}, "User-Agent": emptyStruct{}}} + for k, vls := range rec.Header() { + for _, v := range vls { + delete((expect[k]), v) + } + if len(expect[k]) != 0 { + t.Errorf("Header: unexpected header %s: %v", k, expect[k]) + } + } +} + +type multiHeaderResponseError struct { + multiHeaderResponse + msg string +} + +func (m multiHeaderResponseError) Error() string { + return m.msg +} + +func TestAddMultipleHeadersErrorEncoder(t *testing.T) { + errStr := "oh no" + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { + return nil, multiHeaderResponseError{msg: errStr} + }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + echotransport.EncodeJSONResponse, + ) + + rec, _ := handleWith(handler) + + expect := map[string]map[string]emptyStruct{"Vary": {"Origin": emptyStruct{}, "User-Agent": emptyStruct{}}} + for k, vls := range rec.Header() { + for _, v := range vls { + delete((expect[k]), v) + } + if len(expect[k]) != 0 { + t.Errorf("Header: unexpected header %s: %v", k, expect[k]) + } + } + + if b := rec.Body.String(); errStr != string(b) { + t.Errorf("ErrorEncoder: got: %q, expected: %q", b, errStr) + } +} + +type noContentResponse emptyStruct + +func (e noContentResponse) StatusCode() int { return http.StatusNoContent } + +func TestEncodeNoContent(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, interface{}) (interface{}, error) { return noContentResponse{}, nil }, + func(context.Context, echo.Context) (interface{}, error) { return emptyStruct{}, nil }, + echotransport.EncodeJSONResponse, + ) + + rec, err := handleWith(handler) + if err != nil { + t.Fatal(err) + } + if want, have := http.StatusNoContent, rec.Result().StatusCode; want != have { + t.Errorf("StatusCode: want %d, have %d", want, have) + } + + if want, have := 0, len(rec.Body.String()); want != have { + t.Errorf("Body: want no content, have %d bytes", have) + } +} + +type enhancedError emptyStruct + +func (e enhancedError) Error() string { return "enhanced error" } +func (e enhancedError) StatusCode() int { return http.StatusTeapot } +func (e enhancedError) MarshalJSON() ([]byte, error) { return []byte(`{"err":"enhanced"}`), nil } +func (e enhancedError) Headers() http.Header { return http.Header{"X-Enhanced": []string{"1"}} } + +func TestEnhancedError(t *testing.T) { + handler := echotransport.NewServer( + func(context.Context, any) (any, error) { return nil, enhancedError{} }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, + func(_ context.Context, c echo.Context, _ any) error { return nil }, + ) + + rec, _ := handleWith(handler) + + if want, have := http.StatusTeapot, rec.Result().StatusCode; want != have { + t.Errorf("StatusCode: want %d, have %d", want, have) + } + if want, have := "1", rec.Header().Get("X-Enhanced"); want != have { + t.Errorf("X-Enhanced: want %q, have %q", want, have) + } + + if want, have := `{"err":"enhanced"}`, strings.TrimSpace(rec.Body.String()); want != have { + t.Errorf("Body: want %s, have %s", want, have) + } +} + +type fooRequest struct { + FromJSONBody string `json:"foo"` + FromPathParam int `param:"id"` +} + +func TestDecodeJSONRequest(t *testing.T) { + handler := echotransport.NewServer( + func(ctx context.Context, request fooRequest) (any, error) { + if want, have := "bar", request.FromJSONBody; want != have { + t.Errorf("Expected %s got %s", want, have) + } + if want, have := 123, request.FromPathParam; want != have { + t.Errorf("Expected %d got %d", want, have) + } + return nil, nil + }, + echotransport.DecodeJSONRequest, + echotransport.EncodeJSONResponse, + ) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/entities/123", strings.NewReader(`{"foo": "bar"}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("123") + handler.ServeHTTP(c) +} + +func testServer(t *testing.T) (step func(), resp <-chan *httptest.ResponseRecorder) { + var ( + stepch = make(chan bool) + endpoint = func(context.Context, emptyStruct) (emptyStruct, error) { <-stepch; return emptyStruct{}, nil } + response = make(chan *httptest.ResponseRecorder) + handler = echotransport.NewServer( + endpoint, + func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, + func(context.Context, echo.Context, emptyStruct) error { return nil }, + echotransport.ServerBefore[emptyStruct, emptyStruct](func(ctx context.Context, _ echo.Context) context.Context { return ctx }), + echotransport.ServerAfter[emptyStruct, emptyStruct](func(ctx context.Context, _ echo.Context, _ error) context.Context { return ctx }), + ) + ) + go func() { + rec, err := handleWith(handler) + if err != nil { + t.Error(err) + return + } + response <- rec + }() + return func() { stepch <- true }, response +} + +func handleWith[Req, Res any](handler *echotransport.Server[Req, Res]) (*httptest.ResponseRecorder, error) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/dummy", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + return rec, handler.ServeHTTP(c) +} From c2e6043d48c218beb5ba5b1bf2ecbe04ce4cfc5f Mon Sep 17 00:00:00 2001 From: Kiki L Hakiem Date: Mon, 1 Jul 2024 16:53:21 +0700 Subject: [PATCH 2/2] refactor: cleanup codes --- transport/echo/client.go | 204 ----------- transport/echo/client_test.go | 343 ------------------ transport/echo/encode_decode.go | 6 - transport/echo/example_test.go | 4 +- transport/echo/request_response_funcs.go | 18 +- transport/echo/request_response_funcs_test.go | 43 ++- transport/echo/server.go | 40 +- transport/echo/server_test.go | 96 +++-- transport/http/client.go | 2 +- transport/http/server.go | 2 +- 10 files changed, 131 insertions(+), 627 deletions(-) delete mode 100644 transport/echo/client.go delete mode 100644 transport/echo/client_test.go diff --git a/transport/echo/client.go b/transport/echo/client.go deleted file mode 100644 index 64b0a40..0000000 --- a/transport/echo/client.go +++ /dev/null @@ -1,204 +0,0 @@ -package echo - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/url" - - gkit "github.com/kikihakiem/gkit/core" -) - -// HTTPClient is an interface that models *http.Client. -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -// Client wraps a URL and provides a method that implements endpoint.Endpoint. -type Client[Req, Res any] struct { - client HTTPClient - req gkit.EncodeDecodeFunc[Req, *http.Request] - dec gkit.EncodeDecodeFunc[*http.Response, Res] - before []RequestFunc - after []ClientResponseFunc - finalizer []ClientFinalizerFunc - bufferedStream bool -} - -// NewClient constructs a usable Client for a single remote method. -func NewClient[Req, Res any](method string, tgt *url.URL, enc EncodeClientRequestFunc[Req], dec gkit.EncodeDecodeFunc[*http.Response, Res], options ...ClientOption[Req, Res]) *Client[Req, Res] { - return NewExplicitClient(makeCreateRequestFunc(method, tgt, enc), dec, options...) -} - -// NewExplicitClient is like NewClient but uses a CreateRequestFunc instead of a -// method, target URL, and EncodeRequestFunc, which allows for more control over -// the outgoing HTTP request. -func NewExplicitClient[Req, Res any](req gkit.EncodeDecodeFunc[Req, *http.Request], dec gkit.EncodeDecodeFunc[*http.Response, Res], options ...ClientOption[Req, Res]) *Client[Req, Res] { - c := &Client[Req, Res]{ - client: http.DefaultClient, - req: req, - dec: dec, - } - for _, option := range options { - option(c) - } - return c -} - -// ClientOption sets an optional parameter for clients. -type ClientOption[Req, Res any] gkit.Option[*Client[Req, Res]] - -// SetClient sets the underlying HTTP client used for requests. -// By default, http.DefaultClient is used. -func SetClient[Req, Res any](client HTTPClient) ClientOption[Req, Res] { - return func(c *Client[Req, Res]) { c.client = client } -} - -// ClientBefore adds one or more RequestFuncs to be applied to the outgoing HTTP -// request before it's invoked. -func ClientBefore[Req, Res any](before ...RequestFunc) ClientOption[Req, Res] { - return func(c *Client[Req, Res]) { c.before = append(c.before, before...) } -} - -// ClientAfter adds one or more ClientResponseFuncs, which are applied to the -// incoming HTTP response prior to it being decoded. This is useful for -// obtaining anything off of the response and adding it into the context prior -// to decoding. -func ClientAfter[Req, Res any](after ...ClientResponseFunc) ClientOption[Req, Res] { - return func(c *Client[Req, Res]) { c.after = append(c.after, after...) } -} - -// ClientFinalizer adds one or more ClientFinalizerFuncs to be executed at the -// end of every HTTP request. Finalizers are executed in the order in which they -// were added. By default, no finalizer is registered. -func ClientFinalizer[Req, Res any](f ...ClientFinalizerFunc) ClientOption[Req, Res] { - return func(s *Client[Req, Res]) { s.finalizer = append(s.finalizer, f...) } -} - -// BufferedStream sets whether the HTTP response body is left open, allowing it -// to be read from later. Useful for transporting a file as a buffered stream. -// That body has to be drained and closed to properly end the request. -func BufferedStream[Req, Res any](buffered bool) ClientOption[Req, Res] { - return func(c *Client[Req, Res]) { c.bufferedStream = buffered } -} - -// Endpoint returns a usable Go kit endpoint that calls the remote HTTP endpoint. -func (c Client[Req, Res]) Endpoint() gkit.Endpoint[Req, Res] { - return func(ctx context.Context, request Req) (Res, error) { - ctx, cancel := context.WithCancel(ctx) - - var ( - resp *http.Response - response Res - err error - ) - if c.finalizer != nil { - defer func() { - if resp != nil { - ctx = context.WithValue(ctx, ContextKeyResponseHeaders, resp.Header) - ctx = context.WithValue(ctx, ContextKeyResponseSize, resp.ContentLength) - } - for _, f := range c.finalizer { - f(ctx, err) - } - }() - } - - req, err := c.req(ctx, request) - if err != nil { - cancel() - return response, err - } - - for _, f := range c.before { - ctx = f(ctx, req) - } - - resp, err = c.client.Do(req.WithContext(ctx)) - if err != nil { - cancel() - return response, err - } - - // If the caller asked for a buffered stream, we don't cancel the - // context when the endpoint returns. Instead, we should call the - // cancel func when closing the response body. - if c.bufferedStream { - resp.Body = bodyWithCancel{ReadCloser: resp.Body, cancel: cancel} - } else { - defer resp.Body.Close() - defer cancel() - } - - for _, f := range c.after { - ctx = f(ctx, resp) - } - - response, err = c.dec(ctx, resp) - if err != nil { - return response, err - } - - return response, nil - } -} - -// bodyWithCancel is a wrapper for an io.ReadCloser with also a -// cancel function which is called when the Close is used -type bodyWithCancel struct { - io.ReadCloser - - cancel context.CancelFunc -} - -func (bwc bodyWithCancel) Close() error { - bwc.ReadCloser.Close() - bwc.cancel() - return nil -} - -// ClientFinalizerFunc can be used to perform work at the end of a client HTTP -// request, after the response is returned. The principal -// intended use is for error logging. Additional response parameters are -// provided in the context under keys with the ContextKeyResponse prefix. -// Note: err may be nil. There maybe also no additional response parameters -// depending on when an error occurs. -type ClientFinalizerFunc func(ctx context.Context, err error) - -// EncodeJSONRequest is an EncodeRequestFunc that serializes the request as a -// JSON object to the Request body. Many JSON-over-HTTP services can use it as -// a sensible default. TODO: If the request implements Headerer, the provided headers -// will be applied to the request. -func EncodeJSONRequest[Req any](c context.Context, r *http.Request, request Req) error { - r.Header.Set("Content-Type", "application/json; charset=utf-8") - - if headerer, ok := any(request).(Headerer); ok { - for k := range headerer.Headers() { - r.Header.Set(k, headerer.Headers().Get(k)) - } - } - - var b bytes.Buffer - r.Body = io.NopCloser(&b) - - return json.NewEncoder(&b).Encode(request) -} - -type EncodeClientRequestFunc[Req any] func(context.Context, *http.Request, Req) error - -func makeCreateRequestFunc[Req any](method string, target *url.URL, enc EncodeClientRequestFunc[Req]) gkit.EncodeDecodeFunc[Req, *http.Request] { - return func(ctx context.Context, request Req) (*http.Request, error) { - req, err := http.NewRequest(method, target.String(), nil) - if err != nil { - return nil, err - } - - if err = enc(ctx, req, request); err != nil { - return nil, err - } - - return req, nil - } -} diff --git a/transport/echo/client_test.go b/transport/echo/client_test.go deleted file mode 100644 index 78f9040..0000000 --- a/transport/echo/client_test.go +++ /dev/null @@ -1,343 +0,0 @@ -//go:build unit - -package echo_test - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - echotransport "github.com/kikihakiem/gkit/transport/echo" -) - -type TestResponse struct { - Body io.ReadCloser - String string -} - -func TestHTTPClient(t *testing.T) { - var ( - testbody = "testbody" - encode = func(context.Context, *http.Request, struct{}) error { return nil } - decode = func(_ context.Context, r *http.Response) (TestResponse, error) { - buffer := make([]byte, len(testbody)) - r.Body.Read(buffer) - return TestResponse{r.Body, string(buffer)}, nil - } - headers = make(chan string, 1) - headerKey = "X-Foo" - headerVal = "abcde" - afterHeaderKey = "X-The-Dude" - afterHeaderVal = "Abides" - afterVal = "" - afterFunc = func(ctx context.Context, r *http.Response) context.Context { - afterVal = r.Header.Get(afterHeaderKey) - return ctx - } - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - headers <- r.Header.Get(headerKey) - w.Header().Set(afterHeaderKey, afterHeaderVal) - w.WriteHeader(http.StatusOK) - w.Write([]byte(testbody)) - })) - - client := echotransport.NewClient[struct{}, TestResponse]( - "GET", - mustParse(server.URL), - encode, - decode, - echotransport.ClientBefore[struct{}, TestResponse](echotransport.SetRequestHeader(headerKey, headerVal)), - echotransport.ClientAfter[struct{}, TestResponse](afterFunc), - ) - - res, err := client.Endpoint()(context.Background(), struct{}{}) - if err != nil { - t.Fatal(err) - } - - var have string - select { - case have = <-headers: - case <-time.After(time.Millisecond): - t.Fatalf("timeout waiting for %s", headerKey) - } - // Check that Request Header was successfully received - if want := headerVal; want != have { - t.Errorf("want %q, have %q", want, have) - } - - // Check that Response header set from server was received in SetClientAfter - if want, have := afterVal, afterHeaderVal; want != have { - t.Errorf("want %q, have %q", want, have) - } - - if want, have := testbody, res.String; want != have { - t.Errorf("want %q, have %q", want, have) - } - - // Check that response body was closed - b := make([]byte, 1) - _, err = res.Body.Read(b) - if err == nil { - t.Fatal("wanted error, got none") - } - if doNotWant, have := io.EOF, err; doNotWant == have { - t.Errorf("do not want %q, have %q", doNotWant, have) - } -} - -func TestHTTPClientBufferedStream(t *testing.T) { - // bodysize has a size big enought to make the resopnse.Body not an instant read - // so if the response is cancelled it wount be all readed and the test would fail - // The 6000 has not a particular meaning, it big enough to fulfill the usecase. - const bodysize = 6000 - var ( - testbody = string(make([]byte, bodysize)) - encode = func(context.Context, *http.Request, struct{}) error { return nil } - decode = func(_ context.Context, r *http.Response) (TestResponse, error) { - return TestResponse{r.Body, ""}, nil - } - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(testbody)) - })) - - client := echotransport.NewClient[struct{}, TestResponse]( - "GET", - mustParse(server.URL), - encode, - decode, - echotransport.BufferedStream[struct{}, TestResponse](true), - ) - - res, err := client.Endpoint()(context.Background(), struct{}{}) - if err != nil { - t.Fatal(err) - } - - defer res.Body.Close() - // Faking work - time.Sleep(time.Second * 1) - - // Check that response body was NOT closed - b := make([]byte, len(testbody)) - _, err = res.Body.Read(b) - if want, have := io.EOF, err; have != want { - t.Fatalf("want %q, have %q", want, have) - } - if want, have := testbody, string(b); want != have { - t.Errorf("want %q, have %q", want, have) - } -} - -func TestClientFinalizer(t *testing.T) { - var ( - headerKey = "X-Henlo-Lizer" - headerVal = "Helllo you stinky lizard" - responseBody = "go eat a fly ugly\n" - done = make(chan struct{}) - encode = func(context.Context, *http.Request, struct{}) error { return nil } - decode = func(_ context.Context, r *http.Response) (TestResponse, error) { - return TestResponse{r.Body, ""}, nil - } - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set(headerKey, headerVal) - w.Write([]byte(responseBody)) - })) - defer server.Close() - - client := echotransport.NewClient[struct{}, TestResponse]( - "GET", - mustParse(server.URL), - encode, - decode, - echotransport.ClientFinalizer[struct{}, TestResponse](func(ctx context.Context, err error) { - responseHeader := ctx.Value(echotransport.ContextKeyResponseHeaders).(http.Header) - if want, have := headerVal, responseHeader.Get(headerKey); want != have { - t.Errorf("%s: want %q, have %q", headerKey, want, have) - } - - responseSize := ctx.Value(echotransport.ContextKeyResponseSize).(int64) - if want, have := int64(len(responseBody)), responseSize; want != have { - t.Errorf("response size: want %d, have %d", want, have) - } - - close(done) - }), - ) - - _, err := client.Endpoint()(context.Background(), struct{}{}) - if err != nil { - t.Fatal(err) - } - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timeout waiting for finalizer") - } -} - -func TestEncodeJSONRequest(t *testing.T) { - var ( - header http.Header - body string - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(r.Body) - if err != nil && err != io.EOF { - t.Fatal(err) - } - header = r.Header - - body = string(b) - })) - - defer server.Close() - - serverURL, err := url.Parse(server.URL) - if err != nil { - t.Fatal(err) - } - - client := echotransport.NewClient[any]( - "POST", - serverURL, - echotransport.EncodeJSONRequest, - func(context.Context, *http.Response) (any, error) { return nil, nil }, - ).Endpoint() - - for _, test := range []struct { - value any - body string - }{ - {nil, "null\n"}, - {12, "12\n"}, - {1.2, "1.2\n"}, - {true, "true\n"}, - {"test", "\"test\"\n"}, - {enhancedRequest{Foo: "foo"}, "{\"foo\":\"foo\"}\n"}, - } { - if _, err := client(context.Background(), test.value); err != nil { - t.Error(err) - continue - } - - if body != test.body { - t.Errorf("%v: actual %#v, expected %#v", test.value, body, test.body) - } - - if _, err := client(context.Background(), enhancedRequest{Foo: "foo"}); err != nil { - t.Fatal(err) - } - - if _, ok := header["X-Edward"]; !ok { - t.Fatalf("X-Edward value: actual %v, expected %v", nil, []string{"Snowden"}) - } - - if v := header.Get("X-Edward"); v != "Snowden" { - t.Errorf("X-Edward string: actual %v, expected %v", v, "Snowden") - } - } -} - -func TestSetClient(t *testing.T) { - var ( - encode = func(context.Context, *http.Request, any) error { return nil } - decode = func(_ context.Context, r *http.Response) (string, error) { - t, err := io.ReadAll(r.Body) - if err != nil { - return "", err - } - return string(t), nil - } - ) - - testHttpClient := httpClientFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Request: req, - Body: io.NopCloser(bytes.NewBufferString("hello, world!")), - }, nil - }) - - client := echotransport.NewClient[any, string]( - "GET", - &url.URL{}, - encode, - decode, - echotransport.SetClient[any, string](testHttpClient), - ).Endpoint() - - resp, err := client(context.Background(), nil) - if err != nil { - t.Fatal(err) - } - if resp != "hello, world!" { - t.Fatal("Expected response to be 'hello, world!' string") - } -} - -func TestNewExplicitClient(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "%d", r.ContentLength) - })) - defer srv.Close() - - req := func(ctx context.Context, request any) (*http.Request, error) { - req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(request.(string))) - return req, nil - } - - dec := func(_ context.Context, resp *http.Response) (response any, err error) { - buf, err := io.ReadAll(resp.Body) - resp.Body.Close() - return string(buf), err - } - - client := echotransport.NewExplicitClient(req, dec) - - request := "hello world" - response, err := client.Endpoint()(context.Background(), request) - if err != nil { - t.Fatal(err) - } - - if want, have := "11", response.(string); want != have { - t.Fatalf("want %q, have %q", want, have) - } -} - -func mustParse(s string) *url.URL { - u, err := url.Parse(s) - if err != nil { - panic(err) - } - return u -} - -type enhancedRequest struct { - Foo string `json:"foo"` -} - -func (e enhancedRequest) Headers() http.Header { return http.Header{"X-Edward": []string{"Snowden"}} } - -type httpClientFunc func(req *http.Request) (*http.Response, error) - -func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { - return f(req) -} diff --git a/transport/echo/encode_decode.go b/transport/echo/encode_decode.go index 7031c41..3436e97 100644 --- a/transport/echo/encode_decode.go +++ b/transport/echo/encode_decode.go @@ -6,12 +6,6 @@ import ( "github.com/labstack/echo/v4" ) -// EncodeRequestFunc encodes the passed request object into the HTTP request -// object. It's designed to be used in HTTP clients, for client-side -// endpoints. One straightforward EncodeRequestFunc could be something that JSON -// encodes the object directly to the request body. -type EncodeRequestFunc[Req any] func(context.Context, echo.Context, Req) error - // EncodeResponseFunc encodes the passed response object to the HTTP response // writer. It's designed to be used in HTTP servers, for server-side // endpoints. One straightforward EncodeResponseFunc could be something that diff --git a/transport/echo/example_test.go b/transport/echo/example_test.go index abe887a..ffcb0b5 100644 --- a/transport/echo/example_test.go +++ b/transport/echo/example_test.go @@ -14,7 +14,7 @@ import ( ) func TestPopulateRequestContext(t *testing.T) { - handler := echotransport.NewServer( + handler := echotransport.NewHandler( func(ctx context.Context, request struct{}) (response struct{}, err error) { assert.Equal(t, http.MethodPatch, ctx.Value(echotransport.ContextKeyRequestMethod).(string)) assert.Equal(t, "/search", ctx.Value(echotransport.ContextKeyRequestPath).(string)) @@ -32,5 +32,5 @@ func TestPopulateRequestContext(t *testing.T) { req.Header.Set("X-Request-Id", "a1b2c3d4e5") rec := httptest.NewRecorder() c := e.NewContext(req, rec) - handler.ServeHTTP(c) + handler.Handle(c) } diff --git a/transport/echo/request_response_funcs.go b/transport/echo/request_response_funcs.go index 9e1028d..0dac42a 100644 --- a/transport/echo/request_response_funcs.go +++ b/transport/echo/request_response_funcs.go @@ -2,26 +2,24 @@ package echo import ( "context" - "net/http" "github.com/labstack/echo/v4" ) // RequestFunc may take information from an HTTP request and put it into a // request context. In Servers, RequestFuncs are executed prior to invoking the -// endpoint. In Clients, RequestFuncs are executed after creating the request -// but prior to invoking the HTTP client. -type RequestFunc func(context.Context, *http.Request) context.Context +// endpoint. +type RequestFunc func(context.Context, echo.Context) context.Context // ServerResponseFunc may take information from a request context and use it to // manipulate a ResponseWriter. ServerResponseFuncs are only executed in // servers, after invoking the endpoint but prior to writing a response. -type ServerResponseFunc func(context.Context, http.ResponseWriter) context.Context +type ServerResponseFunc func(context.Context, echo.Context) context.Context // ClientResponseFunc may take information from an HTTP request and make the // response available for consumption. ClientResponseFuncs are only executed in // clients, after a request has been made, but prior to it being decoded. -type ClientResponseFunc func(context.Context, *http.Response) context.Context +type ClientResponseFunc func(context.Context, echo.Context) context.Context // SetContentType returns a ServerResponseFunc that sets the Content-Type header // to the provided value. @@ -31,16 +29,16 @@ func SetContentType(contentType string) ServerResponseFunc { // SetResponseHeader returns a ServerResponseFunc that sets the given header. func SetResponseHeader(key, val string) ServerResponseFunc { - return func(ctx context.Context, w http.ResponseWriter) context.Context { - w.Header().Set(key, val) + return func(ctx context.Context, c echo.Context) context.Context { + c.Response().Header().Set(key, val) return ctx } } // SetRequestHeader returns a RequestFunc that sets the given header. func SetRequestHeader(key, val string) RequestFunc { - return func(ctx context.Context, r *http.Request) context.Context { - r.Header.Set(key, val) + return func(ctx context.Context, c echo.Context) context.Context { + c.Request().Header.Set(key, val) return ctx } } diff --git a/transport/echo/request_response_funcs_test.go b/transport/echo/request_response_funcs_test.go index a88cf87..8cfd3f6 100644 --- a/transport/echo/request_response_funcs_test.go +++ b/transport/echo/request_response_funcs_test.go @@ -4,29 +4,56 @@ package echo_test import ( "context" + "net/http" "net/http/httptest" "testing" echotransport "github.com/kikihakiem/gkit/transport/echo" + "github.com/labstack/echo/v4" ) -func TestSetHeader(t *testing.T) { +func TestSetResponseHeader(t *testing.T) { const ( key = "X-Foo" val = "12345" ) - r := httptest.NewRecorder() - echotransport.SetResponseHeader(key, val)(context.Background(), r) - if want, have := val, r.Header().Get(key); want != have { + + req := httptest.NewRequest(http.MethodPost, "/dummy", nil) + rec := httptest.NewRecorder() + + e := echo.New() + c := e.NewContext(req, rec) + + echotransport.SetResponseHeader(key, val)(context.Background(), c) + if want, have := val, c.Response().Header().Get(key); want != have { t.Errorf("want %q, have %q", want, have) } } func TestSetContentType(t *testing.T) { - const contentType = "application/json" - r := httptest.NewRecorder() - echotransport.SetContentType(contentType)(context.Background(), r) - if want, have := contentType, r.Header().Get("Content-Type"); want != have { + const contentType = echo.MIMEApplicationJSON + req := httptest.NewRequest(http.MethodPost, "/dummy", nil) + rec := httptest.NewRecorder() + + e := echo.New() + c := e.NewContext(req, rec) + + echotransport.SetContentType(contentType)(context.Background(), c) + if want, have := contentType, c.Response().Header().Get("Content-Type"); want != have { + t.Errorf("want %q, have %q", want, have) + } +} + +func TestSetRequestHeader(t *testing.T) { + const contentType = echo.MIMEApplicationJSON + req := httptest.NewRequest(http.MethodPost, "/dummy", nil) + rec := httptest.NewRecorder() + + e := echo.New() + c := e.NewContext(req, rec) + + echotransport.SetRequestHeader(echo.HeaderAccept, echo.MIMEApplicationJSON)(context.Background(), c) + if want, have := contentType, c.Request().Header.Get("Accept"); want != have { t.Errorf("want %q, have %q", want, have) } } diff --git a/transport/echo/server.go b/transport/echo/server.go index 9bf7cd4..f85357a 100644 --- a/transport/echo/server.go +++ b/transport/echo/server.go @@ -10,8 +10,8 @@ import ( gkit "github.com/kikihakiem/gkit/core" ) -// Server wraps an endpoint and implements echo.HandlerFunc. -type Server[Req, Res any] struct { +// Handler wraps an endpoint and implements echo.HandlerFunc. +type Handler[Req, Res any] struct { e gkit.Endpoint[Req, Res] dec gkit.EncodeDecodeFunc[echo.Context, Req] enc EncodeResponseFunc[Res] @@ -22,15 +22,15 @@ type Server[Req, Res any] struct { errorHandler gkit.ErrorHandler } -// NewServer constructs a new HTTP server, which implements echo.HandlerFunc and wraps +// NewHandler constructs a new HTTP server, which implements echo.HandlerFunc and wraps // the provided endpoint. -func NewServer[Req, Res any]( +func NewHandler[Req, Res any]( e gkit.Endpoint[Req, Res], dec gkit.EncodeDecodeFunc[echo.Context, Req], enc EncodeResponseFunc[Res], options ...ServerOption[Req, Res], -) *Server[Req, Res] { - s := &Server[Req, Res]{ +) *Handler[Req, Res] { + s := &Handler[Req, Res]{ e: e, dec: dec, enc: enc, @@ -43,19 +43,29 @@ func NewServer[Req, Res any]( return s } +// NewHandlerFunc simply returns the handler function. +func NewHandlerFunc[Req, Res any]( + e gkit.Endpoint[Req, Res], + dec gkit.EncodeDecodeFunc[echo.Context, Req], + enc EncodeResponseFunc[Res], + options ...ServerOption[Req, Res], +) echo.HandlerFunc { + return NewHandler(e, dec, enc, options...).Handle +} + // ServerOption sets an optional parameter for servers. -type ServerOption[Req, Res any] gkit.Option[*Server[Req, Res]] +type ServerOption[Req, Res any] gkit.Option[*Handler[Req, Res]] // ServerBefore functions are executed on the HTTP request object before the // request is decoded. func ServerBefore[Req, Res any](before ...gkit.BeforeRequestFunc[echo.Context]) ServerOption[Req, Res] { - return func(s *Server[Req, Res]) { s.before = append(s.before, before...) } + return func(s *Handler[Req, Res]) { s.before = append(s.before, before...) } } // ServerAfter functions are executed on the HTTP response writer after the // endpoint is invoked, but before anything is written to the client. func ServerAfter[Req, Res any](after ...gkit.AfterResponseFunc[echo.Context]) ServerOption[Req, Res] { - return func(s *Server[Req, Res]) { s.after = append(s.after, after...) } + return func(s *Handler[Req, Res]) { s.after = append(s.after, after...) } } // ServerErrorEncoder is used to encode errors to the echo.Context @@ -63,7 +73,7 @@ func ServerAfter[Req, Res any](after ...gkit.AfterResponseFunc[echo.Context]) Se // use this to provide custom error formatting and response codes. By default, // errors will be written with the DefaultErrorEncoder. func ServerErrorEncoder[Req, Res any](ee gkit.ErrorEncoder[echo.Context]) ServerOption[Req, Res] { - return func(s *Server[Req, Res]) { s.errorEncoder = ee } + return func(s *Handler[Req, Res]) { s.errorEncoder = ee } } // ServerErrorHandler is used to handle non-terminal errors. By default, non-terminal errors @@ -72,17 +82,17 @@ func ServerErrorEncoder[Req, Res any](ee gkit.ErrorEncoder[echo.Context]) Server // custom ServerErrorEncoder or ServerFinalizer, both of which have access to // the context. func ServerErrorHandler[Req, Res any](errorHandler gkit.ErrorHandler) ServerOption[Req, Res] { - return func(s *Server[Req, Res]) { s.errorHandler = errorHandler } + return func(s *Handler[Req, Res]) { s.errorHandler = errorHandler } } // ServerFinalizer is executed at the end of every HTTP request. // By default, no finalizer is registered. func ServerFinalizer[Req, Res any](f ...ServerFinalizerFunc) ServerOption[Req, Res] { - return func(s *Server[Req, Res]) { s.finalizer = append(s.finalizer, f...) } + return func(s *Handler[Req, Res]) { s.finalizer = append(s.finalizer, f...) } } -// ServeHTTP implements echo.HandlerFunc. -func (s Server[Req, Res]) ServeHTTP(c echo.Context) error { +// Handle implements echo.HandlerFunc. +func (s Handler[Req, Res]) Handle(c echo.Context) error { ctx := c.Request().Context() if len(s.finalizer) > 0 { @@ -154,7 +164,7 @@ func DecodeJSONRequest[Req any](_ context.Context, c echo.Context) (Req, error) // EncodeJSONResponse is a EncodeResponseFunc that serializes the response as a // JSON object to the ResponseWriter. Many JSON-over-HTTP services can use it as -// a sensible default. TODO: If the response implements Headerer, the provided headers +// a sensible default. If the response implements Headerer, the provided headers // will be applied to the response. If the response implements StatusCoder, the // provided StatusCode will be used instead of 200. func EncodeJSONResponse[Res any](_ context.Context, c echo.Context, response Res) error { diff --git a/transport/echo/server_test.go b/transport/echo/server_test.go index 6ca1c19..ca6ff43 100644 --- a/transport/echo/server_test.go +++ b/transport/echo/server_test.go @@ -1,3 +1,5 @@ +//go:build unit + package echo_test import ( @@ -17,36 +19,36 @@ import ( type emptyStruct struct{} func TestServerBadDecode(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, errors.New("dang") }, func(context.Context, echo.Context, any) error { return nil }, ) - rec, _ := handleWith(handler) + rec, _ := handleWith[any, any](handlerFunc) if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { t.Errorf("want %d, have %d", want, have) } } func TestServerBadEndpoint(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return emptyStruct{}, errors.New("dang") }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context, any) error { return nil }, ) - rec, _ := handleWith(handler) + rec, _ := handleWith[any, any](handlerFunc) if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { t.Errorf("want %d, have %d", want, have) } } func TestServerBadEncode(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context, any) error { return errors.New("dang") }, ) - rec, _ := handleWith(handler) + rec, _ := handleWith[any, any](handlerFunc) if want, have := http.StatusInternalServerError, rec.Result().StatusCode; want != have { t.Errorf("want %d, have %d", want, have) } @@ -60,13 +62,13 @@ func TestServerErrorEncoder(t *testing.T) { } return http.StatusInternalServerError } - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, emptyStruct) (emptyStruct, error) { return emptyStruct{}, errTeapot }, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context, emptyStruct) error { return nil }, echotransport.ServerErrorEncoder[emptyStruct, emptyStruct](func(_ context.Context, c echo.Context, err error) { c.Response().WriteHeader(code(err)) }), ) - rec, _ := handleWith(handler) + rec, _ := handleWith[emptyStruct, emptyStruct](handlerFunc) if want, have := http.StatusTeapot, rec.Result().StatusCode; want != have { t.Errorf("want %d, have %d", want, have) } @@ -75,7 +77,7 @@ func TestServerErrorEncoder(t *testing.T) { func TestServerErrorHandler(t *testing.T) { errTeapot := errors.New("teapot") msgChan := make(chan string, 1) - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, emptyStruct) (emptyStruct, error) { return emptyStruct{}, errTeapot }, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context, emptyStruct) error { return nil }, @@ -83,7 +85,7 @@ func TestServerErrorHandler(t *testing.T) { msgChan <- err.Error() })), ) - handleWith(handler) + handleWith[emptyStruct, emptyStruct](handlerFunc) if want, have := errTeapot.Error(), <-msgChan; want != have { t.Errorf("want %s, have %s", want, have) } @@ -107,7 +109,7 @@ func TestMultipleServerBefore(t *testing.T) { responseBody = "go eat a fly ugly\n" done = make(chan emptyStruct) ) - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( gkit.NopEndpoint, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil @@ -133,7 +135,7 @@ func TestMultipleServerBefore(t *testing.T) { }), ) - handleWith(handler) + handleWith[emptyStruct, emptyStruct](handlerFunc) select { case <-done: @@ -150,7 +152,7 @@ func TestMultipleServerAfter(t *testing.T) { responseBody = "go eat a fly ugly\n" done = make(chan emptyStruct) ) - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( gkit.NopEndpoint, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil @@ -176,7 +178,7 @@ func TestMultipleServerAfter(t *testing.T) { }), ) - handleWith(handler) + handleWith[emptyStruct, emptyStruct](handlerFunc) select { case <-done: @@ -193,7 +195,7 @@ func TestServerFinalizer(t *testing.T) { responseBody = "go eat a fly ugly\n" done = make(chan emptyStruct) ) - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( gkit.NopEndpoint, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil @@ -223,7 +225,7 @@ func TestServerFinalizer(t *testing.T) { }), ) - handleWith(handler) + handleWith[emptyStruct, emptyStruct](handlerFunc) select { case <-done: @@ -240,13 +242,13 @@ func (e enhancedResponse) StatusCode() int { return http.StatusPaymentRequi func (e enhancedResponse) Headers() http.Header { return http.Header{"X-Edward": []string{"Snowden"}} } func TestEncodeJSONResponse(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return enhancedResponse{Foo: "bar"}, nil }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, echotransport.EncodeJSONResponse, ) - rec, err := handleWith(handler) + rec, err := handleWith[any, any](handlerFunc) if err != nil { t.Fatal(err) } @@ -266,13 +268,13 @@ func (_ multiHeaderResponse) Headers() http.Header { } func TestAddMultipleHeaders(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return multiHeaderResponse{}, nil }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, echotransport.EncodeJSONResponse, ) - rec, err := handleWith(handler) + rec, err := handleWith[any, any](handlerFunc) if err != nil { t.Fatal(err) } @@ -298,7 +300,7 @@ func (m multiHeaderResponseError) Error() string { func TestAddMultipleHeadersErrorEncoder(t *testing.T) { errStr := "oh no" - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return nil, multiHeaderResponseError{msg: errStr} }, @@ -306,7 +308,7 @@ func TestAddMultipleHeadersErrorEncoder(t *testing.T) { echotransport.EncodeJSONResponse, ) - rec, _ := handleWith(handler) + rec, _ := handleWith[any, any](handlerFunc) expect := map[string]map[string]emptyStruct{"Vary": {"Origin": emptyStruct{}, "User-Agent": emptyStruct{}}} for k, vls := range rec.Header() { @@ -328,13 +330,13 @@ type noContentResponse emptyStruct func (e noContentResponse) StatusCode() int { return http.StatusNoContent } func TestEncodeNoContent(t *testing.T) { - handler := echotransport.NewServer( - func(context.Context, interface{}) (interface{}, error) { return noContentResponse{}, nil }, - func(context.Context, echo.Context) (interface{}, error) { return emptyStruct{}, nil }, + handlerFunc := echotransport.NewHandlerFunc( + func(context.Context, any) (any, error) { return noContentResponse{}, nil }, + func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, echotransport.EncodeJSONResponse, ) - rec, err := handleWith(handler) + rec, err := handleWith[any, any](handlerFunc) if err != nil { t.Fatal(err) } @@ -355,13 +357,13 @@ func (e enhancedError) MarshalJSON() ([]byte, error) { return []byte(`{"err":"en func (e enhancedError) Headers() http.Header { return http.Header{"X-Enhanced": []string{"1"}} } func TestEnhancedError(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(context.Context, any) (any, error) { return nil, enhancedError{} }, func(context.Context, echo.Context) (any, error) { return emptyStruct{}, nil }, func(_ context.Context, c echo.Context, _ any) error { return nil }, ) - rec, _ := handleWith(handler) + rec, _ := handleWith[any, any](handlerFunc) if want, have := http.StatusTeapot, rec.Result().StatusCode; want != have { t.Errorf("StatusCode: want %d, have %d", want, have) @@ -381,7 +383,7 @@ type fooRequest struct { } func TestDecodeJSONRequest(t *testing.T) { - handler := echotransport.NewServer( + handlerFunc := echotransport.NewHandlerFunc( func(ctx context.Context, request fooRequest) (any, error) { if want, have := "bar", request.FromJSONBody; want != have { t.Errorf("Expected %s got %s", want, have) @@ -403,15 +405,35 @@ func TestDecodeJSONRequest(t *testing.T) { c := e.NewContext(req, rec) c.SetParamNames("id") c.SetParamValues("123") - handler.ServeHTTP(c) + handlerFunc(c) +} + +func TestDecodeJSONRequestError(t *testing.T) { + handlerFunc := echotransport.NewHandlerFunc( + func(ctx context.Context, request fooRequest) (any, error) { return nil, nil }, + echotransport.DecodeJSONRequest, + echotransport.EncodeJSONResponse, + ) + + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/entities/123", strings.NewReader(`{"foo": "bar"}`)) + // intentionally leave the content-type header unset + // req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + err := handlerFunc(c) + if err == nil { + t.Fatal("expecting an error, got nil") + } } func testServer(t *testing.T) (step func(), resp <-chan *httptest.ResponseRecorder) { var ( - stepch = make(chan bool) - endpoint = func(context.Context, emptyStruct) (emptyStruct, error) { <-stepch; return emptyStruct{}, nil } - response = make(chan *httptest.ResponseRecorder) - handler = echotransport.NewServer( + stepch = make(chan bool) + endpoint = func(context.Context, emptyStruct) (emptyStruct, error) { <-stepch; return emptyStruct{}, nil } + response = make(chan *httptest.ResponseRecorder) + handlerFunc = echotransport.NewHandlerFunc( endpoint, func(context.Context, echo.Context) (emptyStruct, error) { return emptyStruct{}, nil }, func(context.Context, echo.Context, emptyStruct) error { return nil }, @@ -420,7 +442,7 @@ func testServer(t *testing.T) (step func(), resp <-chan *httptest.ResponseRecord ) ) go func() { - rec, err := handleWith(handler) + rec, err := handleWith[emptyStruct, emptyStruct](handlerFunc) if err != nil { t.Error(err) return @@ -430,10 +452,10 @@ func testServer(t *testing.T) (step func(), resp <-chan *httptest.ResponseRecord return func() { stepch <- true }, response } -func handleWith[Req, Res any](handler *echotransport.Server[Req, Res]) (*httptest.ResponseRecorder, error) { +func handleWith[Req, Res any](handlerFunc echo.HandlerFunc) (*httptest.ResponseRecorder, error) { e := echo.New() req := httptest.NewRequest(http.MethodPost, "/dummy", nil) rec := httptest.NewRecorder() c := e.NewContext(req, rec) - return rec, handler.ServeHTTP(c) + return rec, handlerFunc(c) } diff --git a/transport/http/client.go b/transport/http/client.go index 2cb0f4b..565cdfc 100644 --- a/transport/http/client.go +++ b/transport/http/client.go @@ -169,7 +169,7 @@ type ClientFinalizerFunc func(ctx context.Context, err error) // EncodeJSONRequest is an EncodeRequestFunc that serializes the request as a // JSON object to the Request body. Many JSON-over-HTTP services can use it as -// a sensible default. TODO: If the request implements Headerer, the provided headers +// a sensible default. If the request implements Headerer, the provided headers // will be applied to the request. func EncodeJSONRequest[Req any](c context.Context, r *http.Request, request Req) error { r.Header.Set("Content-Type", "application/json; charset=utf-8") diff --git a/transport/http/server.go b/transport/http/server.go index 9ab863a..631fc08 100644 --- a/transport/http/server.go +++ b/transport/http/server.go @@ -162,7 +162,7 @@ func DecodeJSONRequest[Req any](_ context.Context, r *http.Request) (Req, error) // EncodeJSONResponse is a EncodeResponseFunc that serializes the response as a // JSON object to the ResponseWriter. Many JSON-over-HTTP services can use it as -// a sensible default. TODO: If the response implements Headerer, the provided headers +// a sensible default. If the response implements Headerer, the provided headers // will be applied to the response. If the response implements StatusCoder, the // provided StatusCode will be used instead of 200. func EncodeJSONResponse[Res any](_ context.Context, w http.ResponseWriter, response Res) error {