Skip to content

Commit

Permalink
Request wrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
glossd committed Dec 7, 2024
1 parent 37195c4 commit c51e7d2
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 162 deletions.
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}))
```
Expand Down
12 changes: 6 additions & 6 deletions fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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) {
Expand All @@ -78,26 +78,26 @@ 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) {
if len(config) == 0 {
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) {
if len(config) == 0 {
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]
Expand Down
20 changes: 10 additions & 10 deletions fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
14 changes: 14 additions & 0 deletions request.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
145 changes: 63 additions & 82 deletions to_handler.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package fetch

import (
"context"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
)

type handleTag = string
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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, "/")

Check failure on line 125 in to_handler.go

View workflow job for this annotation

GitHub Actions / test

r.Pattern undefined (type *http.Request has no field or method 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) {
Expand All @@ -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{})
}
Loading

0 comments on commit c51e7d2

Please sign in to comment.