diff --git a/.travis.yml b/.travis.yml index 6abc0bd..d190fc0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: go go: - - 1.12.4 + - 1.12.5 - tip matrix: allow_failures: diff --git a/README.md b/README.md index 659f7d9..ffd359b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # pkg -![Project status](https://img.shields.io/badge/version-3.0.1-green.svg) +![Project status](https://img.shields.io/badge/version-3.1.0-green.svg) [![Build Status](https://travis-ci.org/go-playground/pkg.svg?branch=master)](https://travis-ci.org/go-playground/pkg) [![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master) [![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://godoc.org/github.com/go-playground/pkg) diff --git a/go.mod b/go.mod index dbe90d0..5abb911 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module github.com/go-playground/pkg go 1.11 + +require ( + github.com/go-playground/form v3.1.4+incompatible + gopkg.in/go-playground/assert.v1 v1.2.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c93aa69 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/go-playground/form v3.1.4+incompatible h1:lvKiHVxE2WvzDIoyMnWcjyiBxKt2+uFJyZcPYWsLnjI= +github.com/go-playground/form v3.1.4+incompatible/go.mod h1:lhcKXfTuhRtIZCIKUeJ0b5F207aeQCPbZU09ScKjwWg= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= diff --git a/net/http/accept_encodings.go b/net/http/accept_encodings.go new file mode 100644 index 0000000..16d047d --- /dev/null +++ b/net/http/accept_encodings.go @@ -0,0 +1,11 @@ +package httpext + +// Accept-Encoding values +const ( + Gzip string = "gzip" + Compress string = "compress" + Deflate string = "deflate" + Br string = "br" + Identity string = "identity" + Any string = "*" +) diff --git a/net/http/charset.go b/net/http/charset.go new file mode 100644 index 0000000..bccffa0 --- /dev/null +++ b/net/http/charset.go @@ -0,0 +1,7 @@ +package httpext + +// Charset values +const ( + UTF8 string = "utf-8" + ISO88591 string = "iso-8859-1" +) diff --git a/net/http/form.go b/net/http/form.go new file mode 100644 index 0000000..e1784ba --- /dev/null +++ b/net/http/form.go @@ -0,0 +1,17 @@ +package httpext + +import ( + "net/url" + + "github.com/go-playground/form" +) + +// FormDecoder is the type used for decoding a form for use +type FormDecoder interface { + Decode(interface{}, url.Values) error +} + +var ( + // DefaultDecoder of this package, which is configurable + DefaultDecoder FormDecoder = form.NewDecoder() +) diff --git a/net/http/headers.go b/net/http/headers.go new file mode 100644 index 0000000..e135732 --- /dev/null +++ b/net/http/headers.go @@ -0,0 +1,76 @@ +package httpext + +// HTTP Header keys +const ( + Age string = "Age" + AltSCV string = "Alt-Svc" + Accept string = "Accept" + AcceptCharset string = "Accept-Charset" + AcceptPatch string = "Accept-Patch" + AcceptRanges string = "Accept-Ranges" + AcceptedLanguage string = "Accept-Language" + AcceptEncoding string = "Accept-Encoding" + Authorization string = "Authorization" + CrossOriginResourcePolicy string = "Cross-Origin-Resource-Policy" + CacheControl string = "Cache-Control" + Connection string = "Connection" + ContentDisposition string = "Content-Disposition" + ContentEncoding string = "Content-Encoding" + ContentLength string = "Content-Length" + ContentType string = "Content-Type" + ContentLanguage string = "Content-Language" + ContentLocation string = "Content-Location" + ContentRange string = "Content-Range" + Date string = "Date" + DeltaBase string = "Delta-Base" + ETag string = "ETag" + Expires string = "Expires" + Host string = "Host" + IM string = "IM" + IfMatch string = "If-Match" + IfModifiedSince string = "If-Modified-Since" + IfNoneMatch string = "If-None-Match" + IfRange string = "If-Range" + IfUnmodifiedSince string = "If-Unmodified-Since" + KeepAlive string = "Keep-Alive" + LastModified string = "Last-Modified" + Link string = "Link" + Pragma string = "Pragma" + ProxyAuthenticate string = "Proxy-Authenticate" + ProxyAuthorization string = "Proxy-Authorization" + PublicKeyPins string = "Public-Key-Pins" + RetryAfter string = "Retry-After" + Referer string = "Referer" + Server string = "Server" + SetCookie string = "Set-Cookie" + StrictTransportSecurity string = "Strict-Transport-Security" + Trailer string = "Trailer" + TK string = "Tk" + TransferEncoding string = "Transfer-Encoding" + Location string = "Location" + Upgrade string = "Upgrade" + Vary string = "Vary" + Via string = "Via" + Warning string = "Warning" + WWWAuthenticate string = "WWW-Authenticate" + XForwardedFor string = "X-Forwarded-For" + XForwardedHost string = "X-Forwarded-Host" + XForwardedProto string = "X-Forwarded-Proto" + XRealIP string = "X-Real-Ip" + XContentTypeOptions string = "X-Content-Type-Options" + XFrameOptions string = "X-Frame-Options" + XXSSProtection string = "X-XSS-Protection" + XDNSPrefetchControl string = "X-DNS-Prefetch-Control" + Allow string = "Allow" + Origin string = "Origin" + AccessControlAllowOrigin string = "Access-Control-Allow-Origin" + AccessControlAllowCredentials string = "Access-Control-Allow-Credentials" + AccessControlAllowHeaders string = "Access-Control-Allow-Headers" + AccessControlAllowMethods string = "Access-Control-Allow-Methods" + AccessControlExposeHeaders string = "Access-Control-Expose-Headers" + AccessControlMaxAge string = "Access-Control-Max-Age" + AccessControlRequestHeaders string = "Access-Control-Request-Headers" + AccessControlRequestMethod string = "Access-Control-Request-Method" + TimingAllowOrigin string = "Timing-Allow-Origin" + UserAgent string = "User-Agent" +) diff --git a/net/http/helpers.go b/net/http/helpers.go new file mode 100644 index 0000000..634bc49 --- /dev/null +++ b/net/http/helpers.go @@ -0,0 +1,296 @@ +package httpext + +import ( + "compress/gzip" + "encoding/json" + "encoding/xml" + "io" + "mime" + "net" + "net/http" + "path/filepath" + "strings" + + ioext "github.com/go-playground/pkg/io" +) + +// QueryParamsOption represents the options for including query parameters during Decode helper functions +type QueryParamsOption uint8 + +const ( + QueryParams QueryParamsOption = iota + NoQueryParams +) + +var ( + xmlHeaderBytes = []byte(xml.Header) +) + +func detectContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if t := mime.TypeByExtension(ext); t != "" { + return t + } + switch ext { + case ".md": + return TextMarkdown + default: + return OctetStream + } +} + +// AcceptedLanguages returns an array of accepted languages denoted by +// the Accept-Language header sent by the browser +func AcceptedLanguages(r *http.Request) (languages []string) { + accepted := r.Header.Get(AcceptedLanguage) + if accepted == "" { + return + } + options := strings.Split(accepted, ",") + l := len(options) + languages = make([]string, l) + + for i := 0; i < l; i++ { + locale := strings.SplitN(options[i], ";", 2) + languages[i] = strings.Trim(locale[0], " ") + } + return +} + +// Attachment is a helper method for returning an attachement file +// to be downloaded, if you with to open inline see function Inline +func Attachment(w http.ResponseWriter, r io.Reader, filename string) (err error) { + w.Header().Set(ContentDisposition, "attachment;filename="+filename) + w.Header().Set(ContentType, detectContentType(filename)) + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, r) + return +} + +// Inline is a helper method for returning a file inline to +// be rendered/opened by the browser +func Inline(w http.ResponseWriter, r io.Reader, filename string) (err error) { + w.Header().Set(ContentDisposition, "inline;filename="+filename) + w.Header().Set(ContentType, detectContentType(filename)) + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, r) + return +} + +// ClientIP implements a best effort algorithm to return the real client IP, it parses +// X-Real-IP and X-Forwarded-For in order to work properly with reverse-proxies such us: nginx or haproxy. +func ClientIP(r *http.Request) (clientIP string) { + values := r.Header[XRealIP] + if len(values) > 0 { + clientIP = strings.TrimSpace(values[0]) + if clientIP != "" { + return + } + } + if values = r.Header[XForwardedFor]; len(values) > 0 { + clientIP = values[0] + if index := strings.IndexByte(clientIP, ','); index >= 0 { + clientIP = clientIP[0:index] + } + clientIP = strings.TrimSpace(clientIP) + if clientIP != "" { + return + } + } + clientIP, _, _ = net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + return +} + +// JSON marshals provided interface + returns JSON + status code +func JSON(w http.ResponseWriter, status int, i interface{}) error { + enc := json.NewEncoder(w) + err := enc.Encode(i) + if err != nil { + return err + } + w.Header().Set(ContentType, ApplicationJSON) + w.WriteHeader(status) + return nil +} + +// JSONBytes returns provided JSON response with status code +func JSONBytes(w http.ResponseWriter, status int, b []byte) (err error) { + w.Header().Set(ContentType, ApplicationJSON) + w.WriteHeader(status) + _, err = w.Write(b) + return +} + +// JSONP sends a JSONP response with status code and uses `callback` to construct +// the JSONP payload. +func JSONP(w http.ResponseWriter, status int, i interface{}, callback string) error { + b, err := json.Marshal(i) + if err != nil { + return err + } + w.Header().Set(ContentType, ApplicationJSON) + w.WriteHeader(status) + + if _, err = w.Write([]byte(callback + "(")); err == nil { + if _, err = w.Write(b); err == nil { + _, err = w.Write([]byte(");")) + } + } + return err +} + +// XML marshals provided interface + returns XML + status code +func XML(w http.ResponseWriter, status int, i interface{}) error { + b, err := xml.Marshal(i) + if err != nil { + return err + } + w.Header().Set(ContentType, ApplicationXML) + w.WriteHeader(status) + if _, err = w.Write(xmlHeaderBytes); err == nil { + _, err = w.Write(b) + } + return err +} + +// XMLBytes returns provided XML response with status code +func XMLBytes(w http.ResponseWriter, status int, b []byte) (err error) { + w.Header().Set(ContentType, ApplicationXML) + w.WriteHeader(status) + if _, err = w.Write(xmlHeaderBytes); err == nil { + _, err = w.Write(b) + } + return +} + +// DecodeForm parses the requests form data into the provided struct. +// +// The Content-Type and http method are not checked. +// +// NOTE: when QueryParamsOption=QueryParams the query params will be parsed and included eg. route /user?test=true 'test' +// is added to parsed Form. +func DecodeForm(r *http.Request, qp QueryParamsOption, v interface{}) (err error) { + if err = r.ParseForm(); err == nil { + switch qp { + case QueryParams: + err = DefaultDecoder.Decode(v, r.Form) + case NoQueryParams: + err = DefaultDecoder.Decode(v, r.PostForm) + } + } + return +} + +// DecodeMultipartForm parses the requests form data into the provided struct. +// +// The Content-Type and http method are not checked. +// +// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test' +// is added to parsed MultipartForm. +func DecodeMultipartForm(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) { + if err = r.ParseMultipartForm(maxMemory); err == nil { + switch qp { + case QueryParams: + err = DefaultDecoder.Decode(v, r.Form) + case NoQueryParams: + err = DefaultDecoder.Decode(v, r.MultipartForm.Value) + } + } + return +} + +// DecodeJSON decodes the request body into the provided struct and limits the request size via +// an ioext.LimitReader using the maxMemory param. +// +// The Content-Type e.g. "application/json" and http method are not checked. +// +// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test' +// is added to parsed JSON and replaces any value that may have been present +func DecodeJSON(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) { + var body io.Reader = r.Body + if encoding := r.Header.Get(ContentEncoding); encoding == Gzip { + var gzr *gzip.Reader + gzr, err = gzip.NewReader(r.Body) + if err != nil { + return + } + defer func() { + _ = gzr.Close() + }() + body = gzr + } + err = json.NewDecoder(ioext.LimitReader(body, maxMemory)).Decode(v) + if qp == QueryParams && err == nil { + err = DecodeQueryParams(r, v) + } + return +} + +// DecodeXML decodes the request body into the provided struct and limits the request size via +// an ioext.LimitReader using the maxMemory param. +// +// The Content-Type e.g. "application/xml" and http method are not checked. +// +// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test' +// is added to parsed XML and replaces any value that may have been present +func DecodeXML(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) { + var body io.Reader = r.Body + if encoding := r.Header.Get(ContentEncoding); encoding == Gzip { + var gzr *gzip.Reader + gzr, err = gzip.NewReader(r.Body) + if err != nil { + return + } + defer func() { + _ = gzr.Close() + }() + body = gzr + } + err = xml.NewDecoder(ioext.LimitReader(body, maxMemory)).Decode(v) + if qp == QueryParams && err == nil { + err = DecodeQueryParams(r, v) + } + return +} + +// DecodeQueryParams takes the URL Query params flag. +func DecodeQueryParams(r *http.Request, v interface{}) (err error) { + err = DefaultDecoder.Decode(v, r.URL.Query()) + return +} + +const ( + nakedApplicationJSON string = "application/json" + nakedApplicationXML string = "application/xml" +) + +// Decode takes the request and attempts to discover it's content type via +// the http headers and then decode the request body into the provided struct. +// Example if header was "application/json" would decode using +// json.NewDecoder(ioext.LimitReader(r.Body, maxMemory)).Decode(v). +// +// This default to parsing query params if includeQueryParams=true and no other content type matches. +// +// NOTE: when includeQueryParams=true query params will be parsed and included eg. route /user?test=true 'test' +// is added to parsed XML and replaces any value that may have been present +func Decode(r *http.Request, qp QueryParamsOption, maxMemory int64, v interface{}) (err error) { + typ := r.Header.Get(ContentType) + if idx := strings.Index(typ, ";"); idx != -1 { + typ = typ[:idx] + } + switch typ { + case nakedApplicationJSON: + err = DecodeJSON(r, qp, maxMemory, v) + case nakedApplicationXML: + err = DecodeXML(r, qp, maxMemory, v) + case ApplicationForm: + err = DecodeForm(r, qp, v) + case MultipartForm: + err = DecodeMultipartForm(r, qp, maxMemory, v) + default: + if qp == QueryParams { + err = DecodeQueryParams(r, v) + } + } + return +} diff --git a/net/http/helpers_test.go b/net/http/helpers_test.go new file mode 100644 index 0000000..1d2499b --- /dev/null +++ b/net/http/helpers_test.go @@ -0,0 +1,439 @@ +package httpext + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "encoding/xml" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + . "gopkg.in/go-playground/assert.v1" +) + +func TestAcceptedLanguages(t *testing.T) { + req, _ := http.NewRequest("POST", "/", nil) + req.Header.Set(AcceptedLanguage, "da, en-GB;q=0.8, en;q=0.7") + + languages := AcceptedLanguages(req) + + Equal(t, languages[0], "da") + Equal(t, languages[1], "en-GB") + Equal(t, languages[2], "en") + + req.Header.Del(AcceptedLanguage) + + languages = AcceptedLanguages(req) + Equal(t, len(languages), 0) + + req.Header.Set(AcceptedLanguage, "") + languages = AcceptedLanguages(req) + Equal(t, len(languages), 0) +} + +func TestAttachment(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/dl", func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../../README.md") + if err := Attachment(w, f, "README.md"); err != nil { + panic(err) + } + }) + mux.HandleFunc("/dl-unknown-type", func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../../README.md") + if err := Attachment(w, f, "readme"); err != nil { + panic(err) + } + }) + mux.HandleFunc("/dl-fake-png", func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../../README.md") + if err := Attachment(w, f, "logo.png"); err != nil { + panic(err) + } + }) + + tests := []struct { + name string + code int + disposition string + typ string + url string + }{ + { + code: http.StatusOK, + disposition: "attachment;filename=README.md", + typ: TextMarkdown, + url: "/dl", + }, + { + code: http.StatusOK, + disposition: "attachment;filename=readme", + typ: OctetStream, + url: "/dl-unknown-type", + }, + { + code: http.StatusOK, + disposition: "attachment;filename=logo.png", + typ: ImagePNG, + url: "/dl-fake-png", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tt.url, nil) + Equal(t, err, nil) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if tt.code != w.Code { + t.Errorf("Status Code = %d, want %d", w.Code, tt.code) + } + if tt.disposition != w.Header().Get(ContentDisposition) { + t.Errorf("Content Disaposition = %s, want %s", w.Header().Get(ContentDisposition), tt.disposition) + } + if tt.typ != w.Header().Get(ContentType) { + t.Errorf("Content Type = %s, want %s", w.Header().Get(ContentType), tt.typ) + } + }) + } +} + +func TestInline(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/dl-inline", func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../../README.md") + if err := Inline(w, f, "README.md"); err != nil { + panic(err) + } + }) + mux.HandleFunc("/dl-unknown-type-inline", func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../../README.md") + if err := Inline(w, f, "readme"); err != nil { + panic(err) + } + }) + + tests := []struct { + name string + code int + disposition string + typ string + url string + }{ + { + code: http.StatusOK, + disposition: "inline;filename=README.md", + typ: TextMarkdown, + url: "/dl-inline", + }, + { + code: http.StatusOK, + disposition: "inline;filename=readme", + typ: OctetStream, + url: "/dl-unknown-type-inline", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tt.url, nil) + Equal(t, err, nil) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if tt.code != w.Code { + t.Errorf("Status Code = %d, want %d", w.Code, tt.code) + } + if tt.disposition != w.Header().Get(ContentDisposition) { + t.Errorf("Content Disaposition = %s, want %s", w.Header().Get(ContentDisposition), tt.disposition) + } + if tt.typ != w.Header().Get(ContentType) { + t.Errorf("Content Type = %s, want %s", w.Header().Get(ContentType), tt.typ) + } + }) + } +} + +func TestClientIP(t *testing.T) { + req, _ := http.NewRequest("POST", "/", nil) + req.Header.Set("X-Real-IP", " 10.10.10.10 ") + req.Header.Set("X-Forwarded-For", " 20.20.20.20, 30.30.30.30") + req.RemoteAddr = " 40.40.40.40:42123 " + + Equal(t, ClientIP(req), "10.10.10.10") + + req.Header.Del("X-Real-IP") + Equal(t, ClientIP(req), "20.20.20.20") + + req.Header.Set("X-Forwarded-For", "30.30.30.30 ") + Equal(t, ClientIP(req), "30.30.30.30") + + req.Header.Del("X-Forwarded-For") + Equal(t, ClientIP(req), "40.40.40.40") +} + +func TestJSON(t *testing.T) { + w := httptest.NewRecorder() + type test struct { + Field string `json:"field"` + } + tst := test{Field: "myfield"} + b, err := json.Marshal(tst) + Equal(t, err, nil) + + err = JSON(w, http.StatusOK, tst) + Equal(t, err, nil) + Equal(t, w.Header().Get(ContentType), ApplicationJSON) + Equal(t, w.Body.Bytes(), append([]byte(b), '\n')) + + err = JSON(w, http.StatusOK, func() {}) + NotEqual(t, err, nil) +} + +func TestJSONBytes(t *testing.T) { + w := httptest.NewRecorder() + type test struct { + Field string `json:"field"` + } + tst := test{Field: "myfield"} + b, err := json.Marshal(tst) + Equal(t, err, nil) + + err = JSONBytes(w, http.StatusOK, b) + Equal(t, err, nil) + Equal(t, w.Header().Get(ContentType), ApplicationJSON) + Equal(t, w.Body.Bytes(), []byte(b)) +} + +func TestJSONP(t *testing.T) { + callbackFunc := "CallbackFunc" + w := httptest.NewRecorder() + type test struct { + Field string `json:"field"` + } + tst := test{Field: "myfield"} + err := JSONP(w, http.StatusOK, tst, callbackFunc) + Equal(t, err, nil) + Equal(t, w.Header().Get(ContentType), ApplicationJSON) + + err = JSON(w, http.StatusOK, func() {}) + NotEqual(t, err, nil) +} + +func TestXML(t *testing.T) { + w := httptest.NewRecorder() + type zombie struct { + ID int `json:"id" xml:"id"` + Name string `json:"name" xml:"name"` + } + tst := zombie{1, "Patient Zero"} + xmlData := `1Patient Zero` + err := XML(w, http.StatusOK, tst) + Equal(t, err, nil) + Equal(t, w.Header().Get(ContentType), ApplicationXML) + Equal(t, w.Body.Bytes(), []byte(xml.Header+xmlData)) + + err = JSON(w, http.StatusOK, func() {}) + NotEqual(t, err, nil) +} + +func TestXMLBytes(t *testing.T) { + xmlData := `1Patient Zero` + w := httptest.NewRecorder() + err := XMLBytes(w, http.StatusOK, []byte(xmlData)) + Equal(t, err, nil) + Equal(t, w.Header().Get(ContentType), ApplicationXML) + Equal(t, w.Body.Bytes(), []byte(xml.Header+xmlData)) +} + +func TestDecode(t *testing.T) { + type TestStruct struct { + ID int `form:"id"` + Posted string + MultiPartPosted string + } + + test := new(TestStruct) + + mux := http.NewServeMux() + mux.HandleFunc("/decode-noquery", func(w http.ResponseWriter, r *http.Request) { + err := Decode(r, NoQueryParams, 16<<10, test) + Equal(t, err, nil) + }) + mux.HandleFunc("/decode-query", func(w http.ResponseWriter, r *http.Request) { + err := Decode(r, QueryParams, 16<<10, test) + Equal(t, err, nil) + }) + + // test query params + r, _ := http.NewRequest(http.MethodGet, "/decode-query?id=5", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 5) + Equal(t, test.Posted, "") + Equal(t, test.MultiPartPosted, "") + + // test Form decode + form := url.Values{} + form.Add("Posted", "value") + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-query?id=13", strings.NewReader(form.Encode())) + r.Header.Set(ContentType, ApplicationForm) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 13) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "") + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-noquery?id=14", strings.NewReader(form.Encode())) + r.Header.Set(ContentType, ApplicationForm) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 0) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "") + + // test MultipartForm + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + err := writer.WriteField("MultiPartPosted", "value") + Equal(t, err, nil) + + // Don't forget to close the multipart writer. + // If you don't close it, your request will be missing the terminating boundary. + err = writer.Close() + Equal(t, err, nil) + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-query?id=12", body) + r.Header.Set(ContentType, writer.FormDataContentType()) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 12) + Equal(t, test.Posted, "") + Equal(t, test.MultiPartPosted, "value") + + body = &bytes.Buffer{} + writer = multipart.NewWriter(body) + + err = writer.WriteField("MultiPartPosted", "value") + Equal(t, err, nil) + + // Don't forget to close the multipart writer. + // If you don't close it, your request will be missing the terminating boundary. + err = writer.Close() + Equal(t, err, nil) + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-noquery?id=13", body) + r.Header.Set(ContentType, writer.FormDataContentType()) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 0) + Equal(t, test.Posted, "") + Equal(t, test.MultiPartPosted, "value") + + // test JSON + jsonBody := `{"ID":13,"Posted":"value","MultiPartPosted":"value"}` + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-query?id=13", strings.NewReader(jsonBody)) + r.Header.Set(ContentType, ApplicationJSON) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 13) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") + + var buff bytes.Buffer + gzw := gzip.NewWriter(&buff) + defer func() { + _ = gzw.Close() + }() + _, err = gzw.Write([]byte(jsonBody)) + Equal(t, err, nil) + + err = gzw.Close() + Equal(t, err, nil) + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-query?id=14", &buff) + r.Header.Set(ContentType, ApplicationJSON) + r.Header.Set(ContentEncoding, Gzip) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 14) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-noquery?id=14", strings.NewReader(jsonBody)) + r.Header.Set(ContentType, ApplicationJSON) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 13) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") + + // test XML + xmlBody := `13valuevalue` + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-noquery?id=14", strings.NewReader(xmlBody)) + r.Header.Set(ContentType, ApplicationXML) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 13) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-query?id=14", strings.NewReader(xmlBody)) + r.Header.Set(ContentType, ApplicationXML) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 14) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") + + buff.Reset() + gzw = gzip.NewWriter(&buff) + defer func() { + _ = gzw.Close() + }() + _, err = gzw.Write([]byte(xmlBody)) + Equal(t, err, nil) + + err = gzw.Close() + Equal(t, err, nil) + + test = new(TestStruct) + r, _ = http.NewRequest(http.MethodPost, "/decode-noquery?id=14", &buff) + r.Header.Set(ContentType, ApplicationXML) + r.Header.Set(ContentEncoding, Gzip) + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + Equal(t, w.Code, http.StatusOK) + Equal(t, test.ID, 13) + Equal(t, test.Posted, "value") + Equal(t, test.MultiPartPosted, "value") +} diff --git a/net/http/mime_types.go b/net/http/mime_types.go new file mode 100644 index 0000000..2e60a50 --- /dev/null +++ b/net/http/mime_types.go @@ -0,0 +1,27 @@ +package httpext + +const ( + charsetUTF8 = "; charset=" + UTF8 +) + +// Mime Type values for the Content-Type HTTP header +const ( + ApplicationJSON string = "application/json" + charsetUTF8 + ApplicationJavaScript string = "application/javascript" + ApplicationXML string = "application/xml" + charsetUTF8 + ApplicationForm string = "application/x-www-form-urlencoded" + ApplicationProtobuf string = "application/protobuf" + ApplicationMsgpack string = "application/msgpack" + ApplicationWasm string = "application/wasm" + ApplicationPDF string = "application/pdf" + TextHTML string = "text/html" + charsetUTF8 + TextPlain string = "text/plain" + charsetUTF8 + TextMarkdown string = "text/markdown" + charsetUTF8 + TextCSS string = "text/css" + charsetUTF8 + ImagePNG string = "image/png" + ImageGIF string = "image/gif" + ImageSVG string = "image/svg+xml" + ImageJPEG string = "image/jpeg" + MultipartForm string = "multipart/form-data" + OctetStream string = "application/octet-stream" +) diff --git a/net/http/quality_value.go b/net/http/quality_value.go new file mode 100644 index 0000000..59243e6 --- /dev/null +++ b/net/http/quality_value.go @@ -0,0 +1,20 @@ +package httpext + +import "fmt" + +const ( + // QualityValueFormat is a format string helper for Quality Values + QualityValueFormat = "%s;q=%1.3g" +) + +// QualityValue accepts a value to add/concatenate a quality value to and +// the quality value itself. +func QualityValue(v string, qv float32) string { + if qv > 1 { + qv = 1 // highest possible value + } + if qv < 0.001 { + qv = 0.001 // lowest possible value + } + return fmt.Sprintf(QualityValueFormat, v, qv) +} diff --git a/net/http/quality_value_test.go b/net/http/quality_value_test.go new file mode 100644 index 0000000..353e0c1 --- /dev/null +++ b/net/http/quality_value_test.go @@ -0,0 +1,43 @@ +package httpext + +import "testing" + +func TestQualityValue(t *testing.T) { + type args struct { + v string + qv float32 + } + tests := []struct { + name string + args args + want string + }{ + { + name: "in-range", + args: args{v: "test", qv: 0.5}, + want: "test;q=0.5", + }, + { + name: "in-range-trailing-zeros", + args: args{v: "test", qv: 0.500}, + want: "test;q=0.5", + }, + { + name: "greater-than-range", + args: args{v: "test", qv: 1.500}, + want: "test;q=1", + }, + { + name: "less-than-range", + args: args{v: "test", qv: 0.0000001}, + want: "test;q=0.001", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := QualityValue(tt.args.v, tt.args.qv); got != tt.want { + t.Errorf("QualityValue() = %v, want %v", got, tt.want) + } + }) + } +}