From c51e7d2f96eb6b9baf1276a5b745b8dc6dbeb3ac Mon Sep 17 00:00:00 2001 From: Dennis Gloss Date: Sat, 7 Dec 2024 20:18:08 +0100 Subject: [PATCH] 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()