diff options
| author | Shulhan <ms@kilabit.info> | 2024-04-23 19:15:54 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2024-04-24 14:57:02 +0700 |
| commit | 072a5866613aab933d03bef7df5a2dcb3a0855e4 (patch) | |
| tree | 79f185033d4e43fde44ded7bb4e7e59d047e4941 | |
| parent | 225b6372d0592c2291ff4b9301a68c80d4660d77 (diff) | |
| download | pakakeh.go-072a5866613aab933d03bef7df5a2dcb3a0855e4.tar.xz | |
lib/http: refactoring "multipart/form-data" parameters in ClientRequest
Previously, ClientRequest with type RequestTypeMultipartForm pass the
type "map[string][]byte" in Params.
This type hold the file upload, where key is the file name and []byte is
content of file.
Unfortunately, this model does not correct because a
"multipart/form-data" can contains different field name and file name,
for example
--boundary
Content-Disposition: form-data; name="field0"; filename="file0"
Content-Type: application/octet-stream
<Content of file0>
This changes fix this by changing the parameter type for
RequestTypeMultipartForm to [*multipart.Form], which affect several
functions including [Client.PutFormData] and [GenerateFormData].
We also add new function [CreateMultipartFileHeader] to help creating
[multipart.FileHeader] from raw bytes, that can be assigned to
[*multipart.Form].
| -rw-r--r-- | lib/http/client.go | 58 | ||||
| -rw-r--r-- | lib/http/client_example_test.go | 9 | ||||
| -rw-r--r-- | lib/http/client_request.go | 11 | ||||
| -rw-r--r-- | lib/http/multipart_form.go | 168 | ||||
| -rw-r--r-- | lib/http/multipart_form_test.go | 84 | ||||
| -rw-r--r-- | lib/http/testdata/GenerateFormData_test.txt | 22 |
6 files changed, 295 insertions, 57 deletions
diff --git a/lib/http/client.go b/lib/http/client.go index 10ee1a2c..751b1de3 100644 --- a/lib/http/client.go +++ b/lib/http/client.go @@ -21,7 +21,6 @@ import ( "net" "net/http" "path" - "sort" "strings" "time" @@ -193,7 +192,7 @@ out: // - If Type is [RequestTypeForm] and Params is [url.Values] it // will be send as URL encoded in the body. // - If Type is [RequestTypeMultipartForm] and Params type is -// map[string][]byte, then it will send as multipart form in the +// [*multipart.Form], then it will send as multipart form in the // body. // - If Type is [RequestTypeJSON] and Params is not nil, the Params // will be encoded as JSON in the body. @@ -224,15 +223,15 @@ func (client *Client) GenerateHTTPRequest(req ClientRequest) (httpReq *http.Requ case RequestTypeMultipartForm: var ( - paramsAsMultipart map[string][]byte - ok bool + mpform *multipart.Form + ok bool ) - paramsAsMultipart, ok = req.Params.(map[string][]byte) + mpform, ok = req.Params.(*multipart.Form) if ok { var strBody string - contentType, strBody, err = GenerateFormData(paramsAsMultipart) + contentType, strBody, err = GenerateFormData(mpform) if err != nil { return nil, fmt.Errorf(`%s: %w`, logp, err) } @@ -331,7 +330,7 @@ func (client *Client) PostFormData(req ClientRequest) (res *ClientResponse, err var ( logp = `PostFormData` - params map[string][]byte + params *multipart.Form ) req.contentType = req.Type.String() @@ -405,8 +404,9 @@ func (client *Client) PutForm(req ClientRequest) (*ClientResponse, error) { // "multipart/form-data". func (client *Client) PutFormData(req ClientRequest) (res *ClientResponse, err error) { var ( - logp = `PutFormData` - params map[string][]byte + logp = `PutFormData` + + params *multipart.Form ) req.contentType = req.Type.String() @@ -574,43 +574,3 @@ func (client *Client) uncompress(res *http.Response, body []byte) ( return out, err } - -// GenerateFormData generate multipart/form-data body from params. -func GenerateFormData(params map[string][]byte) (contentType, body string, err error) { - var ( - sb = new(strings.Builder) - w = multipart.NewWriter(sb) - listKey = make([]string, 0, len(params)) - - k string - ) - for k = range params { - listKey = append(listKey, k) - } - sort.Strings(listKey) - - var ( - part io.Writer - v []byte - ) - for _, k = range listKey { - part, err = w.CreateFormField(k) - if err != nil { - return "", "", err - } - v = params[k] - _, err = part.Write(v) - if err != nil { - return "", "", err - } - } - - err = w.Close() - if err != nil { - return "", "", err - } - - contentType = w.FormDataContentType() - - return contentType, sb.String(), nil -} diff --git a/lib/http/client_example_test.go b/lib/http/client_example_test.go index 7b72cba1..22b24285 100644 --- a/lib/http/client_example_test.go +++ b/lib/http/client_example_test.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" "log" + "mime/multipart" "strings" libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" @@ -15,9 +16,11 @@ func ExampleGenerateFormData() { // NOTE: do not do this on real code. rand.Reader = mock.NewRandReader([]byte(`randomseed`)) - var data = map[string][]byte{ - `name`: []byte(`test.txt`), - `size`: []byte(`42`), + var data = &multipart.Form{ + Value: map[string][]string{ + `name`: []string{`test.txt`}, + `size`: []string{`42`}, + }, } var ( diff --git a/lib/http/client_request.go b/lib/http/client_request.go index 454aeae1..55cf3ab3 100644 --- a/lib/http/client_request.go +++ b/lib/http/client_request.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "strings" @@ -111,7 +112,7 @@ func (creq *ClientRequest) toHTTPRequest(client *Client) (httpReq *http.Request, } case RequestTypeMultipartForm: - paramsAsMultipart, ok := creq.Params.(map[string][]byte) + paramsAsMultipart, ok := creq.Params.(*multipart.Form) if ok { contentType, strBody, err = GenerateFormData(paramsAsMultipart) if err != nil { @@ -170,16 +171,16 @@ func (creq *ClientRequest) paramsAsURLEncoded() string { return params.Encode() } -// paramsAsMultipart convert the Params as "map[string][]byte" and return the -// content type and body. -func (creq *ClientRequest) paramsAsMultipart() (params map[string][]byte) { +// paramsAsMultipart convert the Params as [*multipart.Form] or nil if its +// empty. +func (creq *ClientRequest) paramsAsMultipart() (params *multipart.Form) { if creq.Params == nil { return nil } var ok bool - params, ok = creq.Params.(map[string][]byte) + params, ok = creq.Params.(*multipart.Form) if !ok { return nil } diff --git a/lib/http/multipart_form.go b/lib/http/multipart_form.go new file mode 100644 index 00000000..bd535cfe --- /dev/null +++ b/lib/http/multipart_form.go @@ -0,0 +1,168 @@ +// Copyright 2024, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package http + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "sort" + "strings" +) + +// CreateMultipartFileHeader create [multipart.FileHeader] from raw content +// with optional filename. +func CreateMultipartFileHeader(filename string, content []byte) (fh *multipart.FileHeader, err error) { + var ( + logp = `NewMultipartFormFile` + boundary = `__boundary__` + fieldname = `__fieldname__` + + buf bytes.Buffer + ) + + var wrt = multipart.NewWriter(&buf) + + err = wrt.SetBoundary(boundary) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + var formfile io.Writer + + formfile, err = wrt.CreateFormFile(fieldname, filename) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + _, err = formfile.Write(content) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + err = wrt.Close() + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + var ( + rdr = multipart.NewReader(&buf, boundary) + + form *multipart.Form + ) + + form, err = rdr.ReadForm(0) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + var ( + listFH []*multipart.FileHeader + ok bool + ) + + listFH, ok = form.File[fieldname] + if !ok { + return nil, fmt.Errorf(`%s: missing generated file %s`, logp, filename) + } + if len(listFH) == 0 { + return nil, fmt.Errorf(`%s: empty generated FileHeader`, logp) + } + + fh = listFH[0] + + return fh, nil +} + +// GenerateFormData generate "multipart/form-data" body from mpform. +func GenerateFormData(mpform *multipart.Form) (contentType, body string, err error) { + if mpform == nil { + return ``, ``, nil + } + + var ( + logp = `GenerateFormData` + sb = new(strings.Builder) + w = multipart.NewWriter(sb) + listKey = make([]string, 0, len(mpform.File)) + + k string + ) + + // Generate files part. + + for k = range mpform.File { + listKey = append(listKey, k) + } + sort.Strings(listKey) + + var ( + listFH []*multipart.FileHeader + fh *multipart.FileHeader + part io.Writer + file multipart.File + content []byte + ) + for k, listFH = range mpform.File { + for _, fh = range listFH { + part, err = w.CreateFormFile(k, fh.Filename) + if err != nil { + return ``, ``, fmt.Errorf(`%s: %w`, logp, err) + } + + file, err = fh.Open() + if err != nil { + return ``, ``, fmt.Errorf(`%s: %w`, logp, err) + } + + content, err = io.ReadAll(file) + if err != nil { + return ``, ``, fmt.Errorf(`%s: %w`, logp, err) + } + + _, err = part.Write(content) + if err != nil { + return ``, ``, fmt.Errorf(`%s: %w`, logp, err) + } + } + } + + // Generate values part. + + listKey = listKey[:0] + for k = range mpform.Value { + listKey = append(listKey, k) + } + sort.Strings(listKey) + + var ( + listValue []string + v string + ) + for _, k = range listKey { + listValue = mpform.Value[k] + for _, v = range listValue { + part, err = w.CreateFormField(k) + if err != nil { + return ``, ``, err + } + + _, err = part.Write([]byte(v)) + if err != nil { + return ``, ``, err + } + } + } + + err = w.Close() + if err != nil { + return ``, ``, err + } + + contentType = w.FormDataContentType() + + return contentType, sb.String(), nil +} diff --git a/lib/http/multipart_form_test.go b/lib/http/multipart_form_test.go new file mode 100644 index 00000000..8562acc7 --- /dev/null +++ b/lib/http/multipart_form_test.go @@ -0,0 +1,84 @@ +// Copyright 2024, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package http + +import ( + "crypto/rand" + "mime/multipart" + "strings" + "testing" + + "git.sr.ht/~shulhan/pakakeh.go/lib/test" + "git.sr.ht/~shulhan/pakakeh.go/lib/test/mock" +) + +func TestGenerateFormData(t *testing.T) { + type testcase struct { + form multipart.Form + tagOutput string + field2filename map[string]string + } + + rand.Reader = mock.NewRandReader([]byte(`randomseed`)) + + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/GenerateFormData_test.txt`) + if err != nil { + t.Fatal(err) + } + + var listcase = []testcase{{ + form: multipart.Form{ + Value: map[string][]string{ + `field1`: []string{`value1`, `value1.1`}, + }, + File: map[string][]*multipart.FileHeader{}, + }, + field2filename: map[string]string{ + `field0`: `file0`, + }, + tagOutput: `file0`, + }} + + var ( + tcase testcase + listFH []*multipart.FileHeader + fh *multipart.FileHeader + + fieldname string + filename string + gotContentType string + gotBody string + tag string + ) + + for _, tcase = range listcase { + for fieldname, filename = range tcase.field2filename { + fh, err = CreateMultipartFileHeader(filename, tdata.Input[filename]) + if err != nil { + t.Fatal(err) + } + + listFH = tcase.form.File[fieldname] + listFH = append(listFH, fh) + tcase.form.File[fieldname] = listFH + } + + gotContentType, gotBody, err = GenerateFormData(&tcase.form) + if err != nil { + t.Fatal(err) + } + + tag = tcase.tagOutput + `.ContentType` + test.Assert(t, tag, string(tdata.Output[tag]), gotContentType) + + gotBody = strings.ReplaceAll(gotBody, "\r\n", "\n") + tag = tcase.tagOutput + `.Body` + test.Assert(t, tag, string(tdata.Output[tag]), gotBody) + } +} diff --git a/lib/http/testdata/GenerateFormData_test.txt b/lib/http/testdata/GenerateFormData_test.txt new file mode 100644 index 00000000..b209e07f --- /dev/null +++ b/lib/http/testdata/GenerateFormData_test.txt @@ -0,0 +1,22 @@ + +>>> file0 +Content of file0. + +<<< file0.ContentType +multipart/form-data; boundary=616e646f6d73656564616e646f6d73656564616e646f6d73656564616e64 + +<<< file0.Body +--616e646f6d73656564616e646f6d73656564616e646f6d73656564616e64 +Content-Disposition: form-data; name="field0"; filename="file0" +Content-Type: application/octet-stream + +Content of file0. +--616e646f6d73656564616e646f6d73656564616e646f6d73656564616e64 +Content-Disposition: form-data; name="field1" + +value1 +--616e646f6d73656564616e646f6d73656564616e646f6d73656564616e64 +Content-Disposition: form-data; name="field1" + +value1.1 +--616e646f6d73656564616e646f6d73656564616e646f6d73656564616e64-- |
