From c51e7d2f96eb6b9baf1276a5b745b8dc6dbeb3ac Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sat, 7 Dec 2024 20:18:08 +0100 Subject: [PATCH 1/5] Request wrapper --- README.md | 17 +++-- fetch.go | 12 ++-- fetch_test.go | 20 +++--- request.go | 14 ++++ respond.go | 1 + response.go | 4 +- to_handler.go | 145 ++++++++++++++++++------------------------ to_handler_it_test.go | 13 ++-- to_handler_test.go | 67 ++++++------------- 9 files changed, 131 insertions(+), 162 deletions(-) create mode 100644 request.go diff --git a/README.md b/README.md index 6012608..79853bb 100644 --- a/README.md +++ b/README.md @@ -307,17 +307,16 @@ http.HandleFunc("GET /default-pet", fetch.ToHandlerFunc(func(_ fetch.Empty) (Pet return Pet{Name: "Teddy"}, nil })) ``` -If you need to access path value or HTTP header use tags below: +If you need to access http request attributes wrap the input with `fetch.Request`: ```go -type PetRequest struct { - Ctx context.Context // http.Request.Context() will be inserted into any field with context.Context type. - ID int `pathval:"id"` // {id} wildcard will be inserted into ID field. - Auth string `header:"Authorization"` // Authorization header will be inserted into Auth field. - Name string // untagged fields will be unmarshalled from the request body. +type Pet struct { + Name string } -http.HandleFunc("GET /pets/{id}", fetch.ToHandlerFunc(func(in PetRequest) (fetch.Empty, error) { - fmt.Println("Pet's id from url:", in.ID) - fmt.Println("Authorization header:", in.Auth) +http.HandleFunc("POST /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[Pet]) (fetch.Empty, error) { + fmt.Println("Pet's id from url:", in.PathValues["id"]) + fmt.Println("Request context:", in.Context) + fmt.Println("Authorization header:", in.Headers["Authorization"]) + fmt.Println("Pet:", in.Body) return fetch.Empty{}, nil })) ``` diff --git a/fetch.go b/fetch.go index 56d6e55..b87bb67 100644 --- a/fetch.go +++ b/fetch.go @@ -29,7 +29,7 @@ func Get[T any](url string, config ...Config) (T, error) { config = []Config{{}} } config[0].Method = http.MethodGet - return Request[T](url, config...) + return Do[T](url, config...) } // GetJ is a wrapper for Get[fetch.J] @@ -60,7 +60,7 @@ func requestWithBody[T any](url string, method string, body any, config ...Confi return t, nonHttpErr("invalid body: ", err) } config[0].Body = b - return Request[T](url, config...) + return Do[T](url, config...) } func bodyToString(v any) (string, error) { @@ -78,7 +78,7 @@ func Delete[T any](url string, config ...Config) (T, error) { config = []Config{{}} } config[0].Method = http.MethodDelete - return Request[T](url, config...) + return Do[T](url, config...) } func Head[T any](url string, config ...Config) (T, error) { @@ -86,7 +86,7 @@ func Head[T any](url string, config ...Config) (T, error) { config = []Config{{}} } config[0].Method = http.MethodHead - return Request[T](url, config...) + return Do[T](url, config...) } func Options[T any](url string, config ...Config) (T, error) { @@ -94,10 +94,10 @@ func Options[T any](url string, config ...Config) (T, error) { config = []Config{{}} } config[0].Method = http.MethodOptions - return Request[T](url, config...) + return Do[T](url, config...) } -func Request[T any](url string, config ...Config) (T, error) { +func Do[T any](url string, config ...Config) (T, error) { var cfg Config if len(config) > 0 { cfg = config[0] diff --git a/fetch_test.go b/fetch_test.go index 9b32c94..d32e3ce 100644 --- a/fetch_test.go +++ b/fetch_test.go @@ -13,7 +13,7 @@ func TestMain(m *testing.M) { } func TestRequestString(t *testing.T) { - res, err := Request[string]("my.ip") + res, err := Do[string]("my.ip") if err != nil { t.Fatal(err) } @@ -23,7 +23,7 @@ func TestRequestString(t *testing.T) { } func TestRequestBytes(t *testing.T) { - res, err := Request[[]byte]("array.int") + res, err := Do[[]byte]("array.int") if err != nil { t.Fatal(err) } @@ -34,7 +34,7 @@ func TestRequestBytes(t *testing.T) { } func TestRequestArray(t *testing.T) { - res, err := Request[[]int]("array.int") + res, err := Do[[]int]("array.int") if err != nil { t.Fatal(err) } @@ -44,7 +44,7 @@ func TestRequestArray(t *testing.T) { } func TestRequestAny(t *testing.T) { - res, err := Request[any]("key.value") + res, err := Do[any]("key.value") if err != nil { t.Fatal(err) } @@ -56,7 +56,7 @@ func TestRequestAny(t *testing.T) { t.Errorf("map wasn't parsed") } - res2, err := Request[any]("array.int") + res2, err := Do[any]("array.int") if err != nil { t.Fatal(err) } @@ -73,7 +73,7 @@ func TestRequest_ResponseT(t *testing.T) { type TestStruct struct { Key string } - res, err := Request[Response[TestStruct]]("key.value") + res, err := Do[Response[TestStruct]]("key.value") if err != nil { t.Error(err) } @@ -89,7 +89,7 @@ func TestRequest_ResponseT(t *testing.T) { t.Errorf("wrong body") } - res2, err := Request[Response[string]]("my.ip") + res2, err := Do[Response[string]]("my.ip") if err != nil { t.Fatal(err) } @@ -99,7 +99,7 @@ func TestRequest_ResponseT(t *testing.T) { } func TestRequest_ResponseEmpty(t *testing.T) { - res, err := Request[ResponseEmpty]("key.value") + res, err := Do[ResponseEmpty]("key.value") if err != nil { t.Error(err) } @@ -110,14 +110,14 @@ func TestRequest_ResponseEmpty(t *testing.T) { t.Errorf("wrong headers") } - _, err = Request[ResponseEmpty]("400.error") + _, err = Do[ResponseEmpty]("400.error") if err == nil || err.(*Error).Body != "Bad Request" { t.Errorf("Even with ResponseEmpty error should read the body") } } func TestRequest_Error(t *testing.T) { - _, err := Request[string]("400.error") + _, err := Do[string]("400.error") if err == nil { t.Fatal(err) } diff --git a/request.go b/request.go new file mode 100644 index 0000000..67cc68d --- /dev/null +++ b/request.go @@ -0,0 +1,14 @@ +package fetch + +import ( + "context" +) + +// Request can be used in ApplyFunc as a wrapper +// for the input entity to access http attributes. +type Request[T any] struct { + Context context.Context + PathValues map[string]string + Headers map[string]string + Body T +} diff --git a/respond.go b/respond.go index 7a80feb..fe187b1 100644 --- a/respond.go +++ b/respond.go @@ -54,6 +54,7 @@ func Respond(w http.ResponseWriter, body any, config ...RespondConfig) error { if cfg.ErrorStatus == 0 { cfg.ErrorStatus = 500 } + // todo handle ResponseEmpty, Response var err error if !isValidHTTPStatus(cfg.Status) { err := fmt.Errorf("RespondConfig.Status is invalid") diff --git a/response.go b/response.go index d90fe52..f3fa384 100644 --- a/response.go +++ b/response.go @@ -47,6 +47,6 @@ func uniqueHeaders(headers map[string][]string) map[string]string { return h } -// Empty represents an empty request body or empty response body, skipping JSON handling. -// Can be used to fit the signature of ApplyFunc. +// Empty represents an empty response or request body, skipping JSON handling. +// Can be used in any HTTP method or to fit the signature of ApplyFunc. type Empty struct{} diff --git a/to_handler.go b/to_handler.go index 86ddb9c..d07d9d4 100644 --- a/to_handler.go +++ b/to_handler.go @@ -1,12 +1,11 @@ package fetch import ( - "context" "fmt" "io" "net/http" "reflect" - "strconv" + "strings" ) type handleTag = string @@ -44,29 +43,24 @@ type HandlerConfig struct { Middleware func(w http.ResponseWriter, r *http.Request) bool } +func (cfg HandlerConfig) respondError(w http.ResponseWriter, err error) { + cfg.ErrorHook(err) + err = RespondError(w, 400, err) + if err != nil { + cfg.ErrorHook(err) + } +} + // ApplyFunc represents a simple function to be converted to http.Handler with // In type as a request body and Out type as a response body. type ApplyFunc[In any, Out any] func(in In) (Out, error) /* - ToHandlerFunc converts ApplyFunc into http.HandlerFunc, - which can be used later in http.ServeMux#HandleFunc. - It unmarshals the HTTP request body into the ApplyFunc argument and - then marshals the returned value into the HTTP response body. - To insert PathValue into a field of the input entity, specify `pathval` tag - to match the pattern's wildcard: - - type Pet struct { - Id int `pathval:"id"` - } - -` header` tag can be used to insert HTTP headers into struct field. - - type Pet struct { - Content string `header:"Content-Type"` - } - - Any field with context.Context type will have http.Request.Context() inserted into. +ToHandlerFunc converts ApplyFunc into http.HandlerFunc, +which can be used later in http.ServeMux#HandleFunc. +It unmarshals the HTTP request body into the ApplyFunc argument and +then marshals the returned value into the HTTP response body. +To access HTTP request attributes, wrap your input in fetch.Request. */ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -75,29 +69,43 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { return } var in In - if st, _ := isStructType(in); st != reflect.TypeOf(Empty{}) { - reqBody, err := io.ReadAll(r.Body) - if err != nil { - cfg.ErrorHook(err) - err = RespondError(w, 400, err) + if isRequestWrapper(in) { + typeOf := reflect.TypeOf(in) + resType, ok := typeOf.FieldByName("Body") + if !ok { + panic("field Body is not found in Request") + } + resInstance := reflect.New(resType.Type).Interface() + if !isEmptyType(resInstance) { + reqBody, err := io.ReadAll(r.Body) if err != nil { - cfg.ErrorHook(err) + cfg.respondError(w, err) + return } - return - } - if len(reqBody) > 0 || shouldValidateInput(in) { - in, err = Unmarshal[In](string(reqBody)) + err = parseBodyInto(reqBody, resInstance) if err != nil { - cfg.ErrorHook(fmt.Errorf("failed to unmarshal request body: %s", err)) - err = RespondError(w, 400, err) - if err != nil { - cfg.ErrorHook(err) - } + cfg.respondError(w, fmt.Errorf("failed to parse request body: %s", err)) return } } + valueOf := reflect.Indirect(reflect.ValueOf(&in)) + valueOf.FieldByName("Context").Set(reflect.ValueOf(r.Context())) + valueOf.FieldByName("PathValues").Set(reflect.ValueOf(extractPathValues(r))) + valueOf.FieldByName("Headers").Set(reflect.ValueOf(uniqueHeaders(r.Header))) + valueOf.FieldByName("Body").Set(reflect.ValueOf(resInstance).Elem()) + } else if !isEmptyType(in) { + reqBody, err := io.ReadAll(r.Body) + if err != nil { + cfg.respondError(w, err) + return + } + err = parseBodyInto(reqBody, &in) + if err != nil { + cfg.respondError(w, fmt.Errorf("failed to parse request body: %s", err)) + return + } } - in = enrichEntity(in, r) + out, err := apply(in) if err != nil { err = RespondError(w, 500, err) @@ -113,21 +121,19 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { } } -// Input entity just might have a field with pathval tag -// and nothing else, we don't need to unmarshal it. -// In case it has some untagged fields, then it must be validated. -func shouldValidateInput(v any) bool { - if t, ok := isStructType(v); ok { - for i := 0; i < t.NumField(); i++ { - tag := t.Field(i).Tag - if tag.Get(headerTag) == "" && tag.Get(pathvalTag) == "" && t.Field(i).Type != reflect.TypeFor[context.Context]() { - return true +func extractPathValues(r *http.Request) map[string]string { + parts := strings.Split(r.Pattern, "/") + result := make(map[string]string) + for _, part := range parts { + if len(part) > 2 && strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + wildcard := part[1 : len(part)-1] + v := r.PathValue(wildcard) + if v != "" { + result[wildcard] = v } } - return false - } else { - return false } + return result } func isStructType(v any) (reflect.Type, bool) { @@ -150,40 +156,15 @@ func isStructType(v any) (reflect.Type, bool) { } } -func enrichEntity[T any](entity T, r *http.Request) T { - typeOf, ok := isStructType(entity) +func isRequestWrapper(v any) bool { + typeOf := reflect.TypeOf(v) + return typeOf != nil && typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Request[") +} + +func isEmptyType(v any) bool { + st, ok := isStructType(v) if !ok { - return entity - } - var elem reflect.Value - if reflect.TypeOf(entity).Kind() == reflect.Pointer { - elem = reflect.ValueOf(entity).Elem() - } else { // struct - elem = reflect.ValueOf(&entity).Elem() - } - for i := 0; i < typeOf.NumField(); i++ { - field := typeOf.Field(i) - if field.Type == reflect.TypeFor[context.Context]() { - elem.Field(i).Set(reflect.ValueOf(r.Context())) - } - if header := field.Tag.Get(headerTag); header != "" { - elem.Field(i).SetString(r.Header.Get(header)) - } - if pathval := field.Tag.Get(pathvalTag); pathval != "" { - pathvar := r.PathValue(pathval) - if pathvar != "" { - switch field.Type.Kind() { - case reflect.String: - elem.Field(i).SetString(pathvar) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - valInt64, err := strconv.ParseInt(pathvar, 10, 64) - if err != nil { - continue - } - elem.Field(i).SetInt(valInt64) - } - } - } + return false } - return entity + return st == reflect.TypeOf(Empty{}) } diff --git a/to_handler_it_test.go b/to_handler_it_test.go index 05d08ff..9674053 100644 --- a/to_handler_it_test.go +++ b/to_handler_it_test.go @@ -11,19 +11,20 @@ func TestToHandlerFunc(t *testing.T) { mock = false defer func() { mock = true }() type Pet struct { - Id string `pathval:"id"` + Id string Name string Saved bool } mux := http.NewServeMux() - mux.HandleFunc("/pets/{id}", ToHandlerFunc(func(in *Pet) (*Pet, error) { - assert(t, in.Id, "1") - if in.Name != "Lola" { + mux.HandleFunc("/pets/{id}", ToHandlerFunc(func(in Request[Pet]) (*Pet, error) { + if in.PathValues["id"] != "1" { + t.Errorf("expected path value") + } + if in.Body.Name != "Lola" { t.Errorf("request: name isn't Lola") } - in.Saved = true - return in, nil + return &Pet{Name: "Lola", Id: "1", Saved: true}, nil })) server := &http.Server{Addr: ":7349", Handler: mux} go server.ListenAndServe() diff --git a/to_handler_test.go b/to_handler_test.go index 05fbaf0..1bdac3f 100644 --- a/to_handler_test.go +++ b/to_handler_test.go @@ -2,7 +2,6 @@ package fetch import ( "bytes" - "context" "net/http" "testing" ) @@ -34,14 +33,14 @@ func TestToHandlerFunc_EmptyOut(t *testing.T) { func TestToHandlerFunc_MultiplePathValue(t *testing.T) { type Pet struct { - Category string `pathval:"category"` - Id string `pathval:"id"` + Category string + Id string Name string } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Category, "cats") - assert(t, in.Id, "1") - assert(t, in.Name, "Charles") + f := ToHandlerFunc(func(in Request[Pet]) (Empty, error) { + if in.PathValues["category"] != "cats" || in.PathValues["id"] != "1" { + t.Errorf("wrong path value, got %v", in) + } return Empty{}, nil }) mw := newMockWriter() @@ -53,40 +52,20 @@ func TestToHandlerFunc_MultiplePathValue(t *testing.T) { assert(t, mw.status, 200) } -func TestToHandlerFunc_PathvalParseInt(t *testing.T) { - type Pet struct { - Id int `pathval:"id"` - Name string - } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Id, 1) - assert(t, in.Name, "Charles") - return Empty{}, nil - }) +func TestToHandlerFunc_ExtractPathValues(t *testing.T) { mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /ids/{id}", f) - r, err := http.NewRequest("POST", "/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) - assert(t, err, nil) - mux.ServeHTTP(mw, r) - assert(t, mw.status, 200) -} - -func TestToHandlerFunc_GetWithPathvalAndNothingToUnmarshal(t *testing.T) { - type Pet struct { - Id int `pathval:"id"` - } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Id, 1) - return Empty{}, nil + mux.HandleFunc("POST /categories/{category}/ids/{id}", func(w http.ResponseWriter, r *http.Request) { + res := extractPathValues(r) + if len(res) != 2 || res["category"] != "cats" || res["id"] != "1" { + t.Errorf("extractPathValues(r) got: %+v", res) + } + w.WriteHeader(422) }) - mw := newMockWriter() - mux := http.NewServeMux() - mux.HandleFunc("GET /ids/{id}", f) - r, err := http.NewRequest("GET", "/ids/1", bytes.NewBuffer([]byte(``))) + r, err := http.NewRequest("POST", "/categories/cats/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) assert(t, err, nil) mux.ServeHTTP(mw, r) - assert(t, mw.status, 200) + assert(t, mw.status, 422) } func TestToHandlerFunc_J(t *testing.T) { @@ -105,11 +84,10 @@ func TestToHandlerFunc_J(t *testing.T) { } func TestToHandlerFunc_Header(t *testing.T) { - type Pet struct { - Content string `header:"Content"` - } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Content, "mycontent") + f := ToHandlerFunc(func(in Request[Empty]) (Empty, error) { + if in.Headers["Content"] != "mycontent" { + t.Errorf("wrong in %v", in) + } return Empty{}, nil }) mw := newMockWriter() @@ -123,13 +101,8 @@ func TestToHandlerFunc_Header(t *testing.T) { } func TestToHandlerFunc_Context(t *testing.T) { - type Pet struct { - Context context.Context - Name string - } - f := ToHandlerFunc(func(in Pet) (Empty, error) { + f := ToHandlerFunc(func(in Request[Empty]) (Empty, error) { assert(t, in.Context.Err(), nil) - assert(t, in.Name, "Lola") return Empty{}, nil }) mw := newMockWriter() From fe95e720e9946ad8830c9d0ff7aa985678b18d0a Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sat, 7 Dec 2024 20:20:44 +0100 Subject: [PATCH 2/5] ci, update go-version --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 92d12b5..e5566ee 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.21' + go-version: '1.22' - name: Run Go tests run: go test ./... -v \ No newline at end of file From 47e75851f5ae1e428a1de067b214bcdde2ffbd15 Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sat, 7 Dec 2024 21:37:32 +0100 Subject: [PATCH 3/5] use http.Request within fetch.Request --- .gitignore | 3 +-- Makefile | 6 ++++++ README.md | 4 ++-- error.go | 22 +++++++++----------- fetch.go | 22 +++++++++++++------- fetch_test.go | 4 ++-- request.go | 14 ------------- respond.go | 21 ++++++++++++++++--- respond_test.go | 21 +++++++++++++++++++ to_handler.go | 25 +---------------------- to_handler_it_test.go | 2 +- to_handler_test.go | 20 ++---------------- response.go => wrappers.go | 42 +++++++++++++++++++++++--------------- 13 files changed, 104 insertions(+), 102 deletions(-) create mode 100644 Makefile delete mode 100644 request.go rename response.go => wrappers.go (56%) diff --git a/.gitignore b/.gitignore index 17e962d..0047ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ cmd/ tmp* # Develop tools .idea/ -.vscode/ -Makefile \ No newline at end of file +.vscode/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b7bfe25 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ + + +test21: + docker run --rm -v $$PWD:/usr/src/myapp -w /usr/src/myapp golang:1.21 go test ./... +test22: + docker run --rm -v $$PWD:/usr/src/myapp -w /usr/src/myapp golang:1.22 go test ./... \ No newline at end of file diff --git a/README.md b/README.md index 79853bb..904f8a3 100644 --- a/README.md +++ b/README.md @@ -313,8 +313,8 @@ type Pet struct { Name string } http.HandleFunc("POST /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[Pet]) (fetch.Empty, error) { - fmt.Println("Pet's id from url:", in.PathValues["id"]) - fmt.Println("Request context:", in.Context) + fmt.Println("Pet's id from url:", in.PathValue("id")) + fmt.Println("Request context:", in.Context()) fmt.Println("Authorization header:", in.Headers["Authorization"]) fmt.Println("Pet:", in.Body) return fetch.Empty{}, nil diff --git a/error.go b/error.go index a8cbd95..fa30d79 100644 --- a/error.go +++ b/error.go @@ -8,12 +8,11 @@ import ( ) type Error struct { - inner error - Msg string - Status int - Headers map[string]string - DuplicateHeaders map[string][]string - Body string + inner error + Msg string + Status int + Headers map[string]string + Body string } func (e *Error) Error() string { @@ -39,12 +38,11 @@ func httpErr(prefix string, err error, r *http.Response, body []byte) *Error { return nonHttpErr(prefix, err) } return &Error{ - inner: err, - Msg: prefix + err.Error(), - Status: r.StatusCode, - Headers: uniqueHeaders(r.Header), - DuplicateHeaders: r.Header, - Body: string(body), + inner: err, + Msg: prefix + err.Error(), + Status: r.StatusCode, + Headers: uniqueHeaders(r.Header), + Body: string(body), } } diff --git a/fetch.go b/fetch.go index b87bb67..02213c2 100644 --- a/fetch.go +++ b/fetch.go @@ -164,14 +164,13 @@ func Do[T any](url string, config ...Config) (T, error) { var t T typeOf := reflect.TypeOf(t) - if typeOf != nil && typeOf == reflect.TypeFor[Empty]() && firstDigit(res.StatusCode) == 2 { + if isEmptyType(t) && firstDigit(res.StatusCode) == 2 { return t, nil } - if typeOf != nil && typeOf == reflect.TypeFor[ResponseEmpty]() && firstDigit(res.StatusCode) == 2 { - re := any(&t).(*ResponseEmpty) + if isResponseWithEmpty(t) && firstDigit(res.StatusCode) == 2 { + re := any(&t).(*Response[Empty]) re.Status = res.StatusCode re.Headers = uniqueHeaders(res.Header) - re.DuplicateHeaders = res.Header return t, nil } @@ -184,7 +183,7 @@ func Do[T any](url string, config ...Config) (T, error) { return t, httpErr(fmt.Sprintf("http status=%d, body=", res.StatusCode), errors.New(string(body)), res, body) } - if typeOf != nil && typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Response[") { + if isResponseWrapper(t) { resType, ok := typeOf.FieldByName("Body") if !ok { panic("field Body is not found in Response") @@ -199,9 +198,7 @@ func Do[T any](url string, config ...Config) (T, error) { valueOf := reflect.Indirect(reflect.ValueOf(&t)) valueOf.FieldByName("Status").SetInt(int64(res.StatusCode)) - valueOf.FieldByName("DuplicateHeaders").Set(reflect.ValueOf(res.Header)) valueOf.FieldByName("Headers").Set(reflect.ValueOf(uniqueHeaders(res.Header))) - valueOf.FieldByName("BodyBytes").SetBytes(body) valueOf.FieldByName("Body").Set(reflect.ValueOf(resInstance).Elem()) return t, nil @@ -257,6 +254,17 @@ func firstDigit(n int) int { return i } +func isResponseWrapper(v any) bool { + if v == nil { + return false + } + typeOf := reflect.TypeOf(v) + return typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Response[") +} +func isResponseWithEmpty(v any) bool { + return reflect.TypeOf(v) == reflect.TypeFor[Response[Empty]]() +} + func hasContentType(c Config) bool { for k := range c.Headers { if strings.ToLower(k) == "content-type" { diff --git a/fetch_test.go b/fetch_test.go index d32e3ce..ce292dc 100644 --- a/fetch_test.go +++ b/fetch_test.go @@ -99,7 +99,7 @@ func TestRequest_ResponseT(t *testing.T) { } func TestRequest_ResponseEmpty(t *testing.T) { - res, err := Do[ResponseEmpty]("key.value") + res, err := Do[Response[Empty]]("key.value") if err != nil { t.Error(err) } @@ -110,7 +110,7 @@ func TestRequest_ResponseEmpty(t *testing.T) { t.Errorf("wrong headers") } - _, err = Do[ResponseEmpty]("400.error") + _, err = Do[Response[Empty]]("400.error") if err == nil || err.(*Error).Body != "Bad Request" { t.Errorf("Even with ResponseEmpty error should read the body") } diff --git a/request.go b/request.go deleted file mode 100644 index 67cc68d..0000000 --- a/request.go +++ /dev/null @@ -1,14 +0,0 @@ -package fetch - -import ( - "context" -) - -// Request can be used in ApplyFunc as a wrapper -// for the input entity to access http attributes. -type Request[T any] struct { - Context context.Context - PathValues map[string]string - Headers map[string]string - Body T -} diff --git a/respond.go b/respond.go index fe187b1..e0c0899 100644 --- a/respond.go +++ b/respond.go @@ -3,6 +3,7 @@ package fetch import ( "fmt" "net/http" + "reflect" "strings" ) @@ -54,7 +55,17 @@ func Respond(w http.ResponseWriter, body any, config ...RespondConfig) error { if cfg.ErrorStatus == 0 { cfg.ErrorStatus = 500 } - // todo handle ResponseEmpty, Response + if isResponseWrapper(body) { + wrapper := reflect.ValueOf(body) + status := wrapper.FieldByName("Status").Int() + cfg.Status = int(status) + mapRange := wrapper.FieldByName("Headers").MapRange() + headers := make(map[string]string) + for mapRange.Next() { + headers[mapRange.Key().String()] = mapRange.Value().String() + } + cfg.Headers = headers + } var err error if !isValidHTTPStatus(cfg.Status) { err := fmt.Errorf("RespondConfig.Status is invalid") @@ -74,14 +85,18 @@ func Respond(w http.ResponseWriter, body any, config ...RespondConfig) error { bodyStr = u case []byte: bodyStr = string(u) - case Empty, *Empty: + case Empty, *Empty, Response[Empty]: bodyStr = "" default: isString = false } } if !isString { - bodyStr, err = Marshal(body) + if isResponseWrapper(body) { + bodyStr, err = Marshal(reflect.ValueOf(body).FieldByName("Body").Interface()) + } else { + bodyStr, err = Marshal(body) + } if err != nil { _ = respond(w, cfg.ErrorStatus, fmt.Sprintf(respondErrorFormat, err), isRespondErrorFormatJSON, cfg) return fmt.Errorf("failed to marshal response body: %s", err) diff --git a/respond_test.go b/respond_test.go index 0c3b598..b1d7b13 100644 --- a/respond_test.go +++ b/respond_test.go @@ -109,6 +109,27 @@ func TestSetRespondErrorFormat_InvalidFormats(t *testing.T) { }) } +func TestRespondResponseEmpty(t *testing.T) { + mw := newMockWriter() + err := Respond(mw, Response[Empty]{Status: 204}) + assert(t, err, nil) + if mw.status != 204 || len(mw.body) > 0 { + t.Errorf("wrong writer: %+v", mw) + } +} + +func TestRespondResponse(t *testing.T) { + type Pet struct { + Name string + } + mw := newMockWriter() + err := Respond(mw, Response[Pet]{Status: 201, Body: Pet{Name: "Lola"}}) + assert(t, err, nil) + if mw.status != 201 || string(mw.body) != `{"name":"Lola"}` { + t.Errorf("wrong writer: %+v, %s", mw, string(mw.body)) + } +} + func TestRespondError(t *testing.T) { mw := newMockWriter() err := RespondError(mw, 400, fmt.Errorf("wrong")) diff --git a/to_handler.go b/to_handler.go index d07d9d4..798dc40 100644 --- a/to_handler.go +++ b/to_handler.go @@ -8,13 +8,6 @@ import ( "strings" ) -type handleTag = string - -const ( - pathvalTag handleTag = "pathval" - headerTag handleTag = "header" -) - var defaultHandlerConfig = HandlerConfig{ ErrorHook: func(err error) { fmt.Printf("fetch.Handle failed to respond: %s\n", err) @@ -89,8 +82,7 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { } } valueOf := reflect.Indirect(reflect.ValueOf(&in)) - valueOf.FieldByName("Context").Set(reflect.ValueOf(r.Context())) - valueOf.FieldByName("PathValues").Set(reflect.ValueOf(extractPathValues(r))) + valueOf.FieldByName("Request").Set(reflect.ValueOf(r)) valueOf.FieldByName("Headers").Set(reflect.ValueOf(uniqueHeaders(r.Header))) valueOf.FieldByName("Body").Set(reflect.ValueOf(resInstance).Elem()) } else if !isEmptyType(in) { @@ -121,21 +113,6 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { } } -func extractPathValues(r *http.Request) map[string]string { - parts := strings.Split(r.Pattern, "/") - result := make(map[string]string) - for _, part := range parts { - if len(part) > 2 && strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { - wildcard := part[1 : len(part)-1] - v := r.PathValue(wildcard) - if v != "" { - result[wildcard] = v - } - } - } - return result -} - func isStructType(v any) (reflect.Type, bool) { typeOf := reflect.TypeOf(v) if v == nil { diff --git a/to_handler_it_test.go b/to_handler_it_test.go index 9674053..07c0ef0 100644 --- a/to_handler_it_test.go +++ b/to_handler_it_test.go @@ -18,7 +18,7 @@ func TestToHandlerFunc(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/pets/{id}", ToHandlerFunc(func(in Request[Pet]) (*Pet, error) { - if in.PathValues["id"] != "1" { + if in.PathValue("id") != "1" { t.Errorf("expected path value") } if in.Body.Name != "Lola" { diff --git a/to_handler_test.go b/to_handler_test.go index 1bdac3f..7fdaf85 100644 --- a/to_handler_test.go +++ b/to_handler_test.go @@ -38,7 +38,7 @@ func TestToHandlerFunc_MultiplePathValue(t *testing.T) { Name string } f := ToHandlerFunc(func(in Request[Pet]) (Empty, error) { - if in.PathValues["category"] != "cats" || in.PathValues["id"] != "1" { + if in.PathValue("category") != "cats" || in.PathValue("id") != "1" { t.Errorf("wrong path value, got %v", in) } return Empty{}, nil @@ -52,22 +52,6 @@ func TestToHandlerFunc_MultiplePathValue(t *testing.T) { assert(t, mw.status, 200) } -func TestToHandlerFunc_ExtractPathValues(t *testing.T) { - mw := newMockWriter() - mux := http.NewServeMux() - mux.HandleFunc("POST /categories/{category}/ids/{id}", func(w http.ResponseWriter, r *http.Request) { - res := extractPathValues(r) - if len(res) != 2 || res["category"] != "cats" || res["id"] != "1" { - t.Errorf("extractPathValues(r) got: %+v", res) - } - w.WriteHeader(422) - }) - r, err := http.NewRequest("POST", "/categories/cats/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) - assert(t, err, nil) - mux.ServeHTTP(mw, r) - assert(t, mw.status, 422) -} - func TestToHandlerFunc_J(t *testing.T) { f := ToHandlerFunc(func(in J) (J, error) { assert(t, in.Q("name").String(), "Lola") @@ -102,7 +86,7 @@ func TestToHandlerFunc_Header(t *testing.T) { func TestToHandlerFunc_Context(t *testing.T) { f := ToHandlerFunc(func(in Request[Empty]) (Empty, error) { - assert(t, in.Context.Err(), nil) + assert(t, in.Context().Err(), nil) return Empty{}, nil }) mw := newMockWriter() diff --git a/response.go b/wrappers.go similarity index 56% rename from response.go rename to wrappers.go index f3fa384..d1d7090 100644 --- a/response.go +++ b/wrappers.go @@ -1,5 +1,7 @@ package fetch +import "net/http" + /* Response is a wrapper type for (generic) ReturnType to be used in the HTTP methods. It allows you to access HTTP attributes @@ -18,22 +20,9 @@ e.g. fmt.Println(res.Body.FirstName) */ type Response[T any] struct { - Status int - // HTTP headers are not unique. - // In the majority of the cases Headers is enough. - // Headers are filled with the last value from DuplicateHeaders. - DuplicateHeaders map[string][]string - Headers map[string]string - Body T - BodyBytes []byte -} - -// ResponseEmpty is a special ResponseType that completely ignores the HTTP body. -// Can be used as the (generic) ReturnType for any HTTP method. -type ResponseEmpty struct { - Status int - Headers map[string]string - DuplicateHeaders map[string][]string + Status int + Headers map[string]string + Body T } func uniqueHeaders(headers map[string][]string) map[string]string { @@ -47,6 +36,25 @@ func uniqueHeaders(headers map[string][]string) map[string]string { return h } +/* +Request can be used in ApplyFunc as a wrapper +for the input entity to access http attributes. +e.g. + + type Pet struct { + Name string + } + http.HandleFunc("POST /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[Pet]) (fetch.Empty, error) { + in.Context() + return fetch.Empty{}, nil + })) +*/ +type Request[T any] struct { + *http.Request + Headers map[string]string + Body T +} + // Empty represents an empty response or request body, skipping JSON handling. -// Can be used in any HTTP method or to fit the signature of ApplyFunc. +// Can be used with the wrappers Response and Request or to fit the signature of ApplyFunc. type Empty struct{} From 644d775cea789adeb86b25aab68569d469844602 Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sun, 8 Dec 2024 01:11:39 +0100 Subject: [PATCH 4/5] downgrade to go1.21 --- .github/workflows/go.yml | 2 +- README.md | 29 ++++++++++++-------- error.go | 2 +- fetch.go | 11 -------- go.mod | 2 +- j.go | 2 +- parse.go | 2 +- to_handler.go | 34 ----------------------- to_handler_it_test.go | 7 ++--- to_handler_test.go | 50 ++++++++++++++++++---------------- wrappers.go | 59 +++++++++++++++++++++++++++++++++++++++- 11 files changed, 109 insertions(+), 91 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e5566ee..92d12b5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.21' - name: Run Go tests run: go test ./... -v \ No newline at end of file diff --git a/README.md b/README.md index 904f8a3..f61f389 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## Installing -This is a zero-dependency package. It requires Go version 1.22 or above. +This is a zero-dependency package. It requires Go version 1.21 or above. ```shell go get github.com/glossd/fetch ``` @@ -108,9 +108,9 @@ if resp.Status == 200 { fmt.Println("Response headers", resp.Headers) } ``` -If you don't need the HTTP body you can use `fetch.ResponseEmpty` +If you don't need the HTTP body you can use `fetch.Empty` or `fetch.Response[fetch.Empty]` to access http attributes ```go -res, err := fetch.Delete[fetch.ResponseEmpty]("https://petstore.swagger.io/v2/pet/10") +res, err := fetch.Delete[fetch.Response[fetch.Empty]]("https://petstore.swagger.io/v2/pet/10") if err != nil { panic(err) } @@ -287,13 +287,13 @@ type Config struct { ``` ## HTTP Handlers -`fetch.ToHandlerFunc` converts `func(in) (out, error)` signature function into `http.HandlerFunc`. -It unmarshals the HTTP request body into the function argument then marshals the returned value into the HTTP response body. +`fetch.ToHandlerFunc` converts `func(in) (out, error)` signature function into `http.HandlerFunc`. It does all the json and http handling for you. +The HTTP request body unmarshalls into the function argument. The return value is marshaled into the HTTP response body. ```go type Pet struct { Name string } -http.HandleFunc("POST /pets", fetch.ToHandlerFunc(func(in Pet) (*Pet, error) { +http.HandleFunc("/pets", fetch.ToHandlerFunc(func(in Pet) (*Pet, error) { if in.Name == "" { return nil, fmt.Errorf("name can't be empty") } @@ -303,21 +303,28 @@ http.ListenAndServe(":8080", nil) ``` If you don't need request or response body, use `fetch.Empty` to fit the function signature. ```go -http.HandleFunc("GET /default-pet", fetch.ToHandlerFunc(func(_ fetch.Empty) (Pet, error) { +http.HandleFunc("/default-pet", fetch.ToHandlerFunc(func(_ fetch.Empty) (Pet, error) { return Pet{Name: "Teddy"}, nil })) ``` -If you need to access http request attributes wrap the input with `fetch.Request`: +If you need to access http request attributes wrap the input with `fetch.Request`. `http.Request` will be embedded to the input. ```go type Pet struct { Name string } -http.HandleFunc("POST /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[Pet]) (fetch.Empty, error) { - fmt.Println("Pet's id from url:", in.PathValue("id")) +http.HandleFunc("/pets", fetch.ToHandlerFunc(func(in fetch.Request[Pet]) (*fetch.Empty, error) { fmt.Println("Request context:", in.Context()) fmt.Println("Authorization header:", in.Headers["Authorization"]) fmt.Println("Pet:", in.Body) - return fetch.Empty{}, nil + fmt.Println("Pet's name:", in.Body.Name) + return nil, nil +})) +``` +If you have go1.22 and above you can access the wildcards as well. +```go +http.HandleFunc("GET /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[fetch.Empty]) (*fetch.Empty, error) { + fmt.Println("id from url:", in.PathValue("id")) + return nil, nil })) ``` The error format can be customized with the `fetch.SetRespondErrorFormat` global setter. diff --git a/error.go b/error.go index fa30d79..09290c0 100644 --- a/error.go +++ b/error.go @@ -88,5 +88,5 @@ func jqerr(format string, a ...any) *JQError { } func IsJQError(v any) bool { - return reflect.TypeOf(v) == reflect.TypeFor[*JQError]() + return reflect.TypeOf(v) == reflectTypeFor[*JQError]() } diff --git a/fetch.go b/fetch.go index 02213c2..642aedb 100644 --- a/fetch.go +++ b/fetch.go @@ -254,17 +254,6 @@ func firstDigit(n int) int { return i } -func isResponseWrapper(v any) bool { - if v == nil { - return false - } - typeOf := reflect.TypeOf(v) - return typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Response[") -} -func isResponseWithEmpty(v any) bool { - return reflect.TypeOf(v) == reflect.TypeFor[Response[Empty]]() -} - func hasContentType(c Config) bool { for k := range c.Headers { if strings.ToLower(k) == "content-type" { diff --git a/go.mod b/go.mod index 2a8db1d..8852527 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/glossd/fetch -go 1.22 +go 1.21 diff --git a/j.go b/j.go index c0ef67e..1807884 100644 --- a/j.go +++ b/j.go @@ -296,7 +296,7 @@ func (n Nil) AsBoolean() (bool, bool) { return false, false } func (n Nil) IsNil() bool { return true } func isJNil(v any) bool { - return v == nil || reflect.TypeOf(v) == reflect.TypeFor[Nil]() + return v == nil || reflect.TypeOf(v) == reflectTypeFor[Nil]() } func nextSep(pattern string) (int, string) { diff --git a/parse.go b/parse.go index 4b8e631..28b10b3 100644 --- a/parse.go +++ b/parse.go @@ -48,7 +48,7 @@ func UnmarshalInto(j string, v any) error { rve := rv.Elem() var isAny = rve.Kind() == reflect.Interface && rve.NumMethod() == 0 - if isAny || rve.Type() == reflect.TypeFor[J]() { + if isAny || rve.Type() == reflectTypeFor[J]() { var a any err := json.Unmarshal([]byte(j), &a) if err != nil { diff --git a/to_handler.go b/to_handler.go index 798dc40..ff79a5d 100644 --- a/to_handler.go +++ b/to_handler.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "reflect" - "strings" ) var defaultHandlerConfig = HandlerConfig{ @@ -112,36 +111,3 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { } } } - -func isStructType(v any) (reflect.Type, bool) { - typeOf := reflect.TypeOf(v) - if v == nil { - return typeOf, false - } - switch typeOf.Kind() { - case reflect.Pointer: - valueOf := reflect.ValueOf(v) - if valueOf.IsNil() { - return typeOf, false - } - t := reflect.ValueOf(v).Elem().Type() - return t, t.Kind() == reflect.Struct - case reflect.Struct: - return typeOf, true - default: - return typeOf, false - } -} - -func isRequestWrapper(v any) bool { - typeOf := reflect.TypeOf(v) - return typeOf != nil && typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Request[") -} - -func isEmptyType(v any) bool { - st, ok := isStructType(v) - if !ok { - return false - } - return st == reflect.TypeOf(Empty{}) -} diff --git a/to_handler_it_test.go b/to_handler_it_test.go index 07c0ef0..49cc8f9 100644 --- a/to_handler_it_test.go +++ b/to_handler_it_test.go @@ -17,10 +17,7 @@ func TestToHandlerFunc(t *testing.T) { } mux := http.NewServeMux() - mux.HandleFunc("/pets/{id}", ToHandlerFunc(func(in Request[Pet]) (*Pet, error) { - if in.PathValue("id") != "1" { - t.Errorf("expected path value") - } + mux.HandleFunc("/pets", ToHandlerFunc(func(in Request[Pet]) (*Pet, error) { if in.Body.Name != "Lola" { t.Errorf("request: name isn't Lola") } @@ -31,7 +28,7 @@ func TestToHandlerFunc(t *testing.T) { defer server.Shutdown(context.Background()) time.Sleep(time.Millisecond) - res, err := Post[Pet]("http://localhost:7349/pets/1", Pet{Name: "Lola"}) + res, err := Post[Pet]("http://localhost:7349/pets", Pet{Name: "Lola"}) assert(t, err, nil) assert(t, res.Id, "1") assert(t, res.Name, "Lola") diff --git a/to_handler_test.go b/to_handler_test.go index 7fdaf85..3d89a6c 100644 --- a/to_handler_test.go +++ b/to_handler_test.go @@ -31,26 +31,28 @@ func TestToHandlerFunc_EmptyOut(t *testing.T) { assert(t, string(mw.body), ``) } -func TestToHandlerFunc_MultiplePathValue(t *testing.T) { - type Pet struct { - Category string - Id string - Name string - } - f := ToHandlerFunc(func(in Request[Pet]) (Empty, error) { - if in.PathValue("category") != "cats" || in.PathValue("id") != "1" { - t.Errorf("wrong path value, got %v", in) - } - return Empty{}, nil - }) - mw := newMockWriter() - mux := http.NewServeMux() - mux.HandleFunc("POST /categories/{category}/ids/{id}", f) - r, err := http.NewRequest("POST", "/categories/cats/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) - assert(t, err, nil) - mux.ServeHTTP(mw, r) - assert(t, mw.status, 200) -} +// This test should fail to compile on go1.21 and successfully run on go1.22. +// Don't forget to update go.mod to 1.22 before running. +//func TestToHandlerFunc_MultiplePathValue(t *testing.T) { +// type Pet struct { +// Category string +// Id string +// Name string +// } +// f := ToHandlerFunc(func(in Request[Pet]) (Empty, error) { +// if in.PathValue("category") != "cats" || in.PathValue("id") != "1" { +// t.Errorf("wrong request, got %v", in) +// } +// return Empty{}, nil +// }) +// mw := newMockWriter() +// mux := http.NewServeMux() +// mux.HandleFunc("/categories/{category}/ids/{id}", f) +// r, err := http.NewRequest("POST", "/categories/cats/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) +// assert(t, err, nil) +// mux.ServeHTTP(mw, r) +// assert(t, mw.status, 200) +//} func TestToHandlerFunc_J(t *testing.T) { f := ToHandlerFunc(func(in J) (J, error) { @@ -59,7 +61,7 @@ func TestToHandlerFunc_J(t *testing.T) { }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /j", f) + mux.HandleFunc("/j", f) r, err := http.NewRequest("POST", "/j", bytes.NewBuffer([]byte(`{"name":"Lola"}`))) assert(t, err, nil) mux.ServeHTTP(mw, r) @@ -76,7 +78,7 @@ func TestToHandlerFunc_Header(t *testing.T) { }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /pets", f) + mux.HandleFunc("/pets", f) r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{}`))) r.Header.Set("Content", "mycontent") assert(t, err, nil) @@ -91,7 +93,7 @@ func TestToHandlerFunc_Context(t *testing.T) { }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /pets", f) + mux.HandleFunc("/pets", f) r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{"name":"Lola"}`))) assert(t, err, nil) mux.ServeHTTP(mw, r) @@ -114,7 +116,7 @@ func TestToHandlerFunc_Middleware(t *testing.T) { }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /pets", f) + mux.HandleFunc("/pets", f) r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{}`))) assert(t, err, nil) mux.ServeHTTP(mw, r) diff --git a/wrappers.go b/wrappers.go index d1d7090..2aed170 100644 --- a/wrappers.go +++ b/wrappers.go @@ -1,6 +1,10 @@ package fetch -import "net/http" +import ( + "net/http" + "reflect" + "strings" +) /* Response is a wrapper type for (generic) ReturnType to be used in @@ -58,3 +62,56 @@ type Request[T any] struct { // Empty represents an empty response or request body, skipping JSON handling. // Can be used with the wrappers Response and Request or to fit the signature of ApplyFunc. type Empty struct{} + +func isResponseWrapper(v any) bool { + if v == nil { + return false + } + typeOf := reflect.TypeOf(v) + return typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Response[") +} +func isResponseWithEmpty(v any) bool { + return reflect.TypeOf(v) == reflectTypeFor[Response[Empty]]() +} + +func isRequestWrapper(v any) bool { + typeOf := reflect.TypeOf(v) + return typeOf != nil && typeOf.PkgPath() == "github.com/glossd/fetch" && strings.HasPrefix(typeOf.Name(), "Request[") +} + +func isEmptyType(v any) bool { + st, ok := isStructType(v) + if !ok { + return false + } + return st == reflect.TypeOf(Empty{}) +} + +func isStructType(v any) (reflect.Type, bool) { + typeOf := reflect.TypeOf(v) + if v == nil { + return typeOf, false + } + switch typeOf.Kind() { + case reflect.Pointer: + valueOf := reflect.ValueOf(v) + if valueOf.IsNil() { + return typeOf, false + } + t := reflect.ValueOf(v).Elem().Type() + return t, t.Kind() == reflect.Struct + case reflect.Struct: + return typeOf, true + default: + return typeOf, false + } +} + +// reflect.TypeFor was introduced in go1.22 +func reflectTypeFor[T any]() reflect.Type { + var v T + if t := reflect.TypeOf(v); t != nil { + return t + } + return reflect.TypeOf((*T)(nil)).Elem() +} From 91e49307689a311ebdc9a4b37f216b12da5aa3dc Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sun, 8 Dec 2024 01:31:54 +0100 Subject: [PATCH 5/5] more tests --- README.md | 6 ++++++ to_handler.go | 6 +++++- to_handler_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f61f389..566e92d 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,12 @@ http.HandleFunc("GET /pets/{id}", fetch.ToHandlerFunc(func(in fetch.Request[fetc return nil, nil })) ``` +To customize http attributes of the response, wrap the output with `fetch.Response` +```go +http.HandleFunc("/pets", fetch.ToHandlerFunc(func(_ fetch.Empty) (fetch.Response[*Pet], error) { + return Response[*Pet]{Status: 201, Body: &Pet{Name: "Lola"}}, nil +})) +``` The error format can be customized with the `fetch.SetRespondErrorFormat` global setter. To log http errors with your logger call `SetDefaultHandlerConfig` ```go diff --git a/to_handler.go b/to_handler.go index ff79a5d..02c6a94 100644 --- a/to_handler.go +++ b/to_handler.go @@ -99,7 +99,11 @@ func ToHandlerFunc[In any, Out any](apply ApplyFunc[In, Out]) http.HandlerFunc { out, err := apply(in) if err != nil { - err = RespondError(w, 500, err) + status := 500 + if erro, ok := err.(*Error); ok { + status = erro.Status + } + err = RespondError(w, status, err) if err != nil { cfg.ErrorHook(err) } diff --git a/to_handler_test.go b/to_handler_test.go index 3d89a6c..92b0249 100644 --- a/to_handler_test.go +++ b/to_handler_test.go @@ -100,6 +100,37 @@ func TestToHandlerFunc_Context(t *testing.T) { assert(t, mw.status, 200) } +func TestToHandlerFunc_ErrorStatus(t *testing.T) { + f := ToHandlerFunc(func(in Request[Empty]) (*Empty, error) { + return nil, &Error{Status: 403} + }) + mw := newMockWriter() + mux := http.NewServeMux() + mux.HandleFunc("/pets", f) + r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{"name":"Lola"}`))) + assert(t, err, nil) + mux.ServeHTTP(mw, r) + assert(t, mw.status, 403) +} + +func TestToHandlerFunc_Response(t *testing.T) { + type Pet struct { + Name string + } + f := ToHandlerFunc(func(in Empty) (Response[*Pet], error) { + return Response[*Pet]{Status: 201, Body: &Pet{Name: "Lola"}}, nil + }) + mw := newMockWriter() + mux := http.NewServeMux() + mux.HandleFunc("/pets", f) + r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(``))) + assert(t, err, nil) + mux.ServeHTTP(mw, r) + if mw.status != 201 || string(mw.body) != `{"name":"Lola"}` { + t.Errorf("wrong writer: %+v", mw) + } +} + func TestToHandlerFunc_Middleware(t *testing.T) { SetDefaultHandlerConfig(HandlerConfig{ Middleware: func(w http.ResponseWriter, r *http.Request) bool {