Skip to content

Commit

Permalink
✨ add feature parse content-type application/x-ndjson
Browse files Browse the repository at this point in the history
  • Loading branch information
Khúc Ngọc Huy committed Sep 25, 2023
1 parent 64b8b08 commit c893a4d
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 40 deletions.
126 changes: 92 additions & 34 deletions ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ var (
decoderPoolMap = map[string]*sync.Pool{}
// tags is used to classify parser's pool
tags = []string{queryTag, bodyTag, reqHeaderTag, paramsTag}
// find the new line when parsing the body using method BodyParser with type application/x-ndjson
delimiterEnter = []byte("\n")
)

func init() {
Expand Down Expand Up @@ -373,56 +375,112 @@ func decoderBuilder(parserConfig ParserConfig) interface{} {
func (c *Ctx) BodyParser(out interface{}) error {
// Get content-type
ctype := utils.ToLower(c.app.getString(c.fasthttp.Request.Header.ContentType()))

ctype = utils.ParseVendorSpecificContentType(ctype)

// Parse body accordingly
if strings.HasPrefix(ctype, MIMEApplicationJSON) {
switch {
case strings.HasPrefix(ctype, MIMEApplicationJSON):
return c.app.config.JSONDecoder(c.Body(), out)
case strings.HasPrefix(ctype, MIMEApplicationForm):
data, err := c.parseFormBody(c.fasthttp.PostArgs(), out)
if err != nil {
return err
}
return c.parseToStruct(bodyTag, out, data)
case strings.HasPrefix(ctype, MIMEMultipartForm):
data, err := c.fasthttp.MultipartForm()
if err != nil {
return err
}
return c.parseToStruct(bodyTag, out, data.Value)
case strings.HasPrefix(ctype, MIMETextXML), strings.HasPrefix(ctype, MIMEApplicationXML):
if err := xml.Unmarshal(c.Body(), out); err != nil {
return fmt.Errorf("failed to unmarshal: %w", err)
}
return nil
case strings.HasPrefix(ctype, MIMEApplicationXNDJSON):
return c.parseXNDJSONBody(out)
default: // No suitable content type found
return ErrUnprocessableEntity
}
if strings.HasPrefix(ctype, MIMEApplicationForm) {
data := make(map[string][]string)
var err error
}

c.fasthttp.PostArgs().VisitAll(func(key, val []byte) {
if err != nil {
return
}
// parseFormBody binds the request body to a struct.
// It supports decoding the following content types based on the Content-Type header: application/x-ndjson
// return error if the data is not an array
func (c *Ctx) parseFormBody(args *fasthttp.Args, out interface{}) (map[string][]string, error) {
data := make(map[string][]string)
var err error

args.VisitAll(func(key, val []byte) {
if err != nil {
return
}

k := c.app.getString(key)
v := c.app.getString(val)
k := string(key)
v := string(val)

if strings.Contains(k, "[") {
k, err = parseParamSquareBrackets(k)
}
if strings.Contains(k, "[") {
k, err = parseParamSquareBrackets(k)
}

if c.app.config.EnableSplittingOnParsers && strings.Contains(v, ",") && equalFieldType(out, reflect.Slice, k, bodyTag) {
values := strings.Split(v, ",")
for i := 0; i < len(values); i++ {
data[k] = append(data[k], values[i])
}
} else {
data[k] = append(data[k], v)
if c.app.config.EnableSplittingOnParsers && strings.Contains(v, ",") && equalFieldType(out, reflect.Slice, k, bodyTag) {
values := strings.Split(v, ",")
for i := 0; i < len(values); i++ {
data[k] = append(data[k], values[i])
}
})
} else {
data[k] = append(data[k], v)
}
})

return c.parseToStruct(bodyTag, out, data)
return data, err
}

// parseXNDJSONBody binds the request body to an array struct.
// It supports decoding the following content types based on the Content-Type header: application/x-ndjson
// return error if the data is not an array
func (c *Ctx) parseXNDJSONBody(out interface{}) error {
// validate output type is array using reflect
outValue := reflect.ValueOf(out)
if outValue.Kind() != reflect.Ptr || outValue.IsNil() {
return fmt.Errorf("output must be a non-nil pointer")
}
if strings.HasPrefix(ctype, MIMEMultipartForm) {
data, err := c.fasthttp.MultipartForm()
outPtrValue := outValue.Elem()
if !outPtrValue.CanSet() {
return fmt.Errorf("cannot set value for output")
}

outType := outPtrValue.Type()
if outType.Kind() != reflect.Slice && outType.Kind() != reflect.Array {
return fmt.Errorf("output type must be an array")
}

body := c.Body()
length := bytes.Count(body, delimiterEnter)

if length > 0 {
sliceValue := reflect.MakeSlice(outType, 0, length)
for _, v := range bytes.Split(body, delimiterEnter) {
if len(v) > 0 {
targetSlice := reflect.New(outType.Elem()).Interface()
err := c.app.config.JSONDecoder(v, targetSlice)
if err != nil {
return err
}
sliceValue = reflect.Append(sliceValue, reflect.ValueOf(targetSlice).Elem())
}
}
outPtrValue.Set(sliceValue)
} else {
targetSlice := reflect.New(outType.Elem()).Interface()
err := c.app.config.JSONDecoder(body, targetSlice)
if err != nil {
return err
}
return c.parseToStruct(bodyTag, out, data.Value)
outPtrValue.Set(reflect.Append(outPtrValue, reflect.ValueOf(targetSlice).Elem()))
}
if strings.HasPrefix(ctype, MIMETextXML) || strings.HasPrefix(ctype, MIMEApplicationXML) {
if err := xml.Unmarshal(c.Body(), out); err != nil {
return fmt.Errorf("failed to unmarshal: %w", err)
}
return nil
}
// No suitable content type found
return ErrUnprocessableEntity
return nil
}

// ClearCookie expires a specific cookie by key on the client side.
Expand Down
72 changes: 72 additions & 0 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"compress/zlib"
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
Expand Down Expand Up @@ -571,6 +572,7 @@ func Test_Ctx_BodyParser(t *testing.T) {
}

testDecodeParser(MIMEApplicationJSON, `{"name":"john"}`)
testDecodeParser(MIMETextXML, `<Demo><name>john</name></Demo>`)
testDecodeParser(MIMEApplicationXML, `<Demo><name>john</name></Demo>`)
testDecodeParser(MIMEApplicationForm, "name=john")
testDecodeParser(MIMEMultipartForm+`;boundary="b"`, "--b\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\njohn\r\n--b--")
Expand Down Expand Up @@ -710,6 +712,76 @@ func Benchmark_Ctx_BodyParser_JSON(b *testing.B) {
utils.AssertEqual(b, "john", d.Name)
}

// go test -run Test_Ctx_BodyParser_NDJSON
func Test_Ctx_BodyParser_NDJSON(t *testing.T) {
t.Parallel()
app := New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)

type Demo struct {
Name string `json:"name" xml:"name" form:"name" query:"name"`
}

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
c.Request().SetBody([]byte(""))
c.Request().Header.SetContentLength(len(c.Body()))
var out []Demo
utils.AssertEqual(t, true, c.BodyParser(&out) != nil)

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
c.Request().SetBody([]byte("{\"name\":\"john\"}"))
c.Request().Header.SetContentLength(len(c.Body()))
var out2 []Demo
utils.AssertEqual(t, nil, c.BodyParser(&out2))
utils.AssertEqual(t, 1, len(out2))
utils.AssertEqual(t, "john", out2[0].Name)

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
c.Request().SetBody([]byte("{\"name\":\"john\"}\n"))
c.Request().Header.SetContentLength(len(c.Body()))
var out3 []Demo
utils.AssertEqual(t, nil, c.BodyParser(&out3))
utils.AssertEqual(t, 1, len(out3))
utils.AssertEqual(t, "john", out3[0].Name)

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
c.Request().SetBody([]byte("{\"name\":\"john\"}\n{\"name\":\"doe\"}\n"))
c.Request().Header.SetContentLength(len(c.Body()))
var errType Demo
utils.AssertEqual(t, true, c.BodyParser(errType) != nil)
var out4 []Demo
utils.AssertEqual(t, nil, c.BodyParser(&out4))
utils.AssertEqual(t, 2, len(out4))
utils.AssertEqual(t, "john", out4[0].Name)
utils.AssertEqual(t, "doe", out4[1].Name)

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
c.Request().SetBody([]byte("{\"name\":\"john\\n doe\"}"))
c.Request().Header.SetContentLength(len(c.Body()))
var out5 []Demo
utils.AssertEqual(t, nil, c.BodyParser(&out5))
utils.AssertEqual(t, 1, len(out5))
utils.AssertEqual(t, true, strings.Index(out5[0].Name, "\n") > 0)

c.Request().Reset()
c.Request().Header.SetContentType(MIMEApplicationXNDJSON)
demo := Demo{Name: "john\n doe"}
data, err := json.Marshal(demo)
utils.AssertEqual(t, nil, err)
c.Request().SetBody(data)
c.Request().Header.SetContentLength(len(c.Body()))
var out6 []Demo
utils.AssertEqual(t, nil, c.BodyParser(&out6))
utils.AssertEqual(t, 1, len(out6))
utils.AssertEqual(t, true, strings.Index(out6[0].Name, "\n") > 0)
}

// go test -v -run=^$ -bench=Benchmark_Ctx_BodyParser_XML -benchmem -count=4
func Benchmark_Ctx_BodyParser_XML(b *testing.B) {
app := New()
Expand Down
13 changes: 7 additions & 6 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,12 +603,13 @@ const (

// MIME types that are commonly used
const (
MIMETextXML = "text/xml"
MIMETextHTML = "text/html"
MIMETextPlain = "text/plain"
MIMETextJavaScript = "text/javascript"
MIMEApplicationXML = "application/xml"
MIMEApplicationJSON = "application/json"
MIMETextXML = "text/xml"
MIMETextHTML = "text/html"
MIMETextPlain = "text/plain"
MIMETextJavaScript = "text/javascript"
MIMEApplicationXML = "application/xml"
MIMEApplicationJSON = "application/json"
MIMEApplicationXNDJSON = "application/x-ndjson"
// Deprecated: use MIMETextJavaScript instead
MIMEApplicationJavaScript = "application/javascript"
MIMEApplicationForm = "application/x-www-form-urlencoded"
Expand Down

0 comments on commit c893a4d

Please sign in to comment.