From 8735d264cd948028f6118fba6548140d2e13a31e Mon Sep 17 00:00:00 2001 From: Dennis Gloss <31706897+glossd@users.noreply.github.com> Date: Sun, 8 Dec 2024 01:38:38 +0100 Subject: [PATCH] Downgrade to go1.21, Request wrapper (#34) * Request wrapper * ci, update go-version * use http.Request within fetch.Request * downgrade to go1.21 * more tests --- .gitignore | 3 +- Makefile | 6 ++ README.md | 46 +++++++----- error.go | 24 +++--- fetch.go | 23 +++--- fetch_test.go | 20 ++--- go.mod | 2 +- j.go | 2 +- parse.go | 2 +- respond.go | 20 ++++- respond_test.go | 21 ++++++ response.go | 52 ------------- to_handler.go | 166 ++++++++++++------------------------------ to_handler_it_test.go | 12 ++- to_handler_test.go | 126 +++++++++++++++----------------- wrappers.go | 117 +++++++++++++++++++++++++++++ 16 files changed, 336 insertions(+), 306 deletions(-) create mode 100644 Makefile delete mode 100644 response.go create mode 100644 wrappers.go 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 6012608..566e92d 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,22 +303,34 @@ 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 path value or HTTP header use tags below: +If you need to access http request attributes wrap the input with `fetch.Request`. `http.Request` will be embedded to the input. ```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) - return fetch.Empty{}, nil +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) + 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 +})) +``` +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. diff --git a/error.go b/error.go index a8cbd95..09290c0 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), } } @@ -90,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 56d6e55..642aedb 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] @@ -164,14 +164,13 @@ func Request[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 Request[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 Request[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 diff --git a/fetch_test.go b/fetch_test.go index 9b32c94..ce292dc 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[Response[Empty]]("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[Response[Empty]]("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/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/respond.go b/respond.go index 7a80feb..e0c0899 100644 --- a/respond.go +++ b/respond.go @@ -3,6 +3,7 @@ package fetch import ( "fmt" "net/http" + "reflect" "strings" ) @@ -54,6 +55,17 @@ func Respond(w http.ResponseWriter, body any, config ...RespondConfig) error { if cfg.ErrorStatus == 0 { cfg.ErrorStatus = 500 } + 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") @@ -73,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/response.go b/response.go deleted file mode 100644 index d90fe52..0000000 --- a/response.go +++ /dev/null @@ -1,52 +0,0 @@ -package fetch - -/* -Response is a wrapper type for (generic) ReturnType to be used in -the HTTP methods. It allows you to access HTTP attributes -of the HTTP response and unmarshal the HTTP body. -e.g. - - type User struct { - FirstName string - } - res, err := Get[Response[User]]("/users/1") - if err != nil {panic(err)} - if res.Status != 202 { - panic("unexpected status") - } - // Body is User type - 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 -} - -func uniqueHeaders(headers map[string][]string) map[string]string { - h := make(map[string]string, len(headers)) - for key, val := range headers { - if len(val) > 0 { - // it takes the last element intentionally. - h[key] = val[len(val)-1] - } - } - return h -} - -// Empty represents an empty request body or empty response body, skipping JSON handling. -// Can be used to fit the signature of ApplyFunc. -type Empty struct{} diff --git a/to_handler.go b/to_handler.go index 86ddb9c..02c6a94 100644 --- a/to_handler.go +++ b/to_handler.go @@ -1,19 +1,10 @@ package fetch import ( - "context" "fmt" "io" "net/http" "reflect" - "strconv" -) - -type handleTag = string - -const ( - pathvalTag handleTag = "pathval" - headerTag handleTag = "header" ) var defaultHandlerConfig = HandlerConfig{ @@ -44,29 +35,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,32 +61,49 @@ 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("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) { + 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) + status := 500 + if erro, ok := err.(*Error); ok { + status = erro.Status + } + err = RespondError(w, status, err) if err != nil { cfg.ErrorHook(err) } @@ -112,78 +115,3 @@ 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 - } - } - return false - } else { - return false - } -} - -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 enrichEntity[T any](entity T, r *http.Request) T { - typeOf, ok := isStructType(entity) - 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 entity -} diff --git a/to_handler_it_test.go b/to_handler_it_test.go index 05d08ff..49cc8f9 100644 --- a/to_handler_it_test.go +++ b/to_handler_it_test.go @@ -11,26 +11,24 @@ 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", ToHandlerFunc(func(in Request[Pet]) (*Pet, error) { + 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() 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 05fbaf0..92b0249 100644 --- a/to_handler_test.go +++ b/to_handler_test.go @@ -2,7 +2,6 @@ package fetch import ( "bytes" - "context" "net/http" "testing" ) @@ -32,113 +31,104 @@ func TestToHandlerFunc_EmptyOut(t *testing.T) { assert(t, string(mw.body), ``) } -func TestToHandlerFunc_MultiplePathValue(t *testing.T) { - type Pet struct { - Category string `pathval:"category"` - Id string `pathval:"id"` - Name string - } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Category, "cats") - assert(t, in.Id, "1") - assert(t, in.Name, "Charles") - return Empty{}, nil +// 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) { + assert(t, in.Q("name").String(), "Lola") + return M{"status": "ok"}, 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"}`))) + mux.HandleFunc("/j", f) + r, err := http.NewRequest("POST", "/j", bytes.NewBuffer([]byte(`{"name":"Lola"}`))) assert(t, err, nil) mux.ServeHTTP(mw, r) assert(t, mw.status, 200) + assert(t, string(mw.body), `{"status":"ok"}`) } -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") +func TestToHandlerFunc_Header(t *testing.T) { + f := ToHandlerFunc(func(in Request[Empty]) (Empty, error) { + if in.Headers["Content"] != "mycontent" { + t.Errorf("wrong in %v", in) + } return Empty{}, nil }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("POST /ids/{id}", f) - r, err := http.NewRequest("POST", "/ids/1", bytes.NewBuffer([]byte(`{"name":"Charles"}`))) + mux.HandleFunc("/pets", f) + r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{}`))) + r.Header.Set("Content", "mycontent") 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) +func TestToHandlerFunc_Context(t *testing.T) { + f := ToHandlerFunc(func(in Request[Empty]) (Empty, error) { + assert(t, in.Context().Err(), nil) return Empty{}, nil }) mw := newMockWriter() mux := http.NewServeMux() - mux.HandleFunc("GET /ids/{id}", f) - r, err := http.NewRequest("GET", "/ids/1", bytes.NewBuffer([]byte(``))) + 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, 200) } -func TestToHandlerFunc_J(t *testing.T) { - f := ToHandlerFunc(func(in J) (J, error) { - assert(t, in.Q("name").String(), "Lola") - return M{"status": "ok"}, nil +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("POST /j", f) - r, err := http.NewRequest("POST", "/j", bytes.NewBuffer([]byte(`{"name":"Lola"}`))) + 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, 200) - assert(t, string(mw.body), `{"status":"ok"}`) + assert(t, mw.status, 403) } -func TestToHandlerFunc_Header(t *testing.T) { +func TestToHandlerFunc_Response(t *testing.T) { type Pet struct { - Content string `header:"Content"` + Name string } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Content, "mycontent") - return Empty{}, nil + 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("POST /pets", f) - r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(`{}`))) - r.Header.Set("Content", "mycontent") + mux.HandleFunc("/pets", f) + r, err := http.NewRequest("POST", "/pets", bytes.NewBuffer([]byte(``))) assert(t, err, nil) mux.ServeHTTP(mw, r) - assert(t, mw.status, 200) -} - -func TestToHandlerFunc_Context(t *testing.T) { - type Pet struct { - Context context.Context - Name string + if mw.status != 201 || string(mw.body) != `{"name":"Lola"}` { + t.Errorf("wrong writer: %+v", mw) } - f := ToHandlerFunc(func(in Pet) (Empty, error) { - assert(t, in.Context.Err(), nil) - assert(t, in.Name, "Lola") - return Empty{}, nil - }) - mw := newMockWriter() - mux := http.NewServeMux() - mux.HandleFunc("POST /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, 200) } func TestToHandlerFunc_Middleware(t *testing.T) { @@ -157,7 +147,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 new file mode 100644 index 0000000..2aed170 --- /dev/null +++ b/wrappers.go @@ -0,0 +1,117 @@ +package fetch + +import ( + "net/http" + "reflect" + "strings" +) + +/* +Response is a wrapper type for (generic) ReturnType to be used in +the HTTP methods. It allows you to access HTTP attributes +of the HTTP response and unmarshal the HTTP body. +e.g. + + type User struct { + FirstName string + } + res, err := Get[Response[User]]("/users/1") + if err != nil {panic(err)} + if res.Status != 202 { + panic("unexpected status") + } + // Body is User type + fmt.Println(res.Body.FirstName) +*/ +type Response[T any] struct { + Status int + Headers map[string]string + Body T +} + +func uniqueHeaders(headers map[string][]string) map[string]string { + h := make(map[string]string, len(headers)) + for key, val := range headers { + if len(val) > 0 { + // it takes the last element intentionally. + h[key] = val[len(val)-1] + } + } + 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 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() +}