diff --git a/.travis.yml b/.travis.yml index cfb48bb..7ee5ebe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,11 +11,15 @@ branches: go: - 1.8 - - 1.8.x + - 1.9 - tip go_import_path: aahframework.org/ahttp.v0 +install: + - git config --global http.https://aahframework.org.followRedirects true + - go get -t -v ./... + script: - bash <(curl -s https://aahframework.org/go-test) diff --git a/README.md b/README.md index 8b9dff8..6f07137 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ahttp - aah framework -[![Build Status](https://travis-ci.org/go-aah/ahttp.svg?branch=master)](https://travis-ci.org/go-aah/ahttp) [![codecov](https://codecov.io/gh/go-aah/ahttp/branch/master/graph/badge.svg)](https://codecov.io/gh/go-aah/ahttp/branch/master) [![Go Report Card](https://goreportcard.com/badge/aahframework.org/ahttp.v0-unstable)](https://goreportcard.com/report/aahframework.org/ahttp.v0-unstable) [![Version](https://img.shields.io/badge/version-0.9-blue.svg)](https://github.com/go-aah/ahttp/releases/latest) [![GoDoc](https://godoc.org/aahframework.org/ahttp.v0-unstable?status.svg)](https://godoc.org/aahframework.org/ahttp.v0-unstable) [![License](https://img.shields.io/github/license/go-aah/ahttp.svg)](LICENSE) [![Twitter](https://img.shields.io/badge/twitter-@aahframework-55acee.svg)](https://twitter.com/aahframework) +[![Build Status](https://travis-ci.org/go-aah/ahttp.svg?branch=master)](https://travis-ci.org/go-aah/ahttp) [![codecov](https://codecov.io/gh/go-aah/ahttp/branch/master/graph/badge.svg)](https://codecov.io/gh/go-aah/ahttp/branch/master) [![Go Report Card](https://goreportcard.com/badge/aahframework.org/ahttp.v0-unstable)](https://goreportcard.com/report/aahframework.org/ahttp.v0-unstable) [![Version](https://img.shields.io/badge/version-0.10-blue.svg)](https://github.com/go-aah/ahttp/releases/latest) [![GoDoc](https://godoc.org/aahframework.org/ahttp.v0-unstable?status.svg)](https://godoc.org/aahframework.org/ahttp.v0-unstable) [![License](https://img.shields.io/github/license/go-aah/ahttp.svg)](LICENSE) [![Twitter](https://img.shields.io/badge/twitter-@aahframework-55acee.svg)](https://twitter.com/aahframework) -***v0.9 [released](https://github.com/go-aah/ahttp/releases/latest) and tagged on Jul 21, 2017*** +***v0.10 [released](https://github.com/go-aah/ahttp/releases/latest) and tagged on Sep 01, 2017*** HTTP Library built to process, manipulate Request and Response (headers, body, gzip, etc). diff --git a/ahttp.go b/ahttp.go index 7e6d7fc..3014658 100644 --- a/ahttp.go +++ b/ahttp.go @@ -11,9 +11,6 @@ import ( "net/http" ) -// Version no. of aah framework ahttp library -const Version = "0.9" - // HTTP Method names const ( MethodGet = http.MethodGet diff --git a/content_type.go b/content_type.go index 72149d7..6d629af 100644 --- a/content_type.go +++ b/content_type.go @@ -55,7 +55,7 @@ type ( // E.g.: // contentType.IsEqual("application/json") func (c *ContentType) IsEqual(contentType string) bool { - return strings.HasPrefix(c.Raw(), strings.ToLower(contentType)) + return strings.HasPrefix(c.String(), strings.ToLower(contentType)) } // Charset method returns charset of content-type diff --git a/content_type_test.go b/content_type_test.go index 9b14f0a..768abfc 100644 --- a/content_type_test.go +++ b/content_type_test.go @@ -97,7 +97,7 @@ func TestHTTPParseContentType(t *testing.T) { req5 := createRawHTTPRequest(HeaderContentType, "text/html;charset") contentType = ParseContentType(req5) - assert.Equal(t, "", contentType.Mime) - assert.Equal(t, "", contentType.String()) + assert.True(t, (contentType.Mime == "" || contentType.Mime == "text/html")) + assert.True(t, (contentType.String() == "" || contentType.String() == "text/html")) assert.Equal(t, "iso-8859-1", contentType.Charset("iso-8859-1")) } diff --git a/gzip_response.go b/gzip_response.go index 71121bb..2c2928a 100644 --- a/gzip_response.go +++ b/gzip_response.go @@ -46,7 +46,7 @@ var ( // GetGzipResponseWriter wraps `http.ResponseWriter`, returns aah framework response // writer that allows to advantage of response process. -// Deprecated use `AcquireResponseWriter` instead. +// Deprecated use `WrapGzipWriter` instead. func GetGzipResponseWriter(w ResponseWriter) ResponseWriter { gr := grPool.Get().(*GzipResponse) gr.gw = acquireGzipWriter(w) diff --git a/header.go b/header.go index 4c18b54..3680421 100644 --- a/header.go +++ b/header.go @@ -58,8 +58,10 @@ const ( HeaderLocation = "Location" HeaderOrigin = "Origin" HeaderMethod = "Method" + HeaderPublicKeyPins = "Public-Key-Pins" HeaderRange = "Range" HeaderReferer = "Referer" + HeaderReferrerPolicy = "Referrer-Policy" HeaderRetryAfter = "Retry-After" HeaderServer = "Server" HeaderSetCookie = "Set-Cookie" @@ -80,6 +82,7 @@ const ( HeaderXForwardedServer = "X-Forwarded-Server" HeaderXFrameOptions = "X-Frame-Options" HeaderXHTTPMethodOverride = "X-HTTP-Method-Override" + HeaderXPermittedCrossDomainPolicies = "X-Permitted-Cross-Domain-Policies" HeaderXRealIP = "X-Real-Ip" HeaderXRequestedWith = "X-Requested-With" HeaderXRequestID = "X-Request-Id" diff --git a/request.go b/request.go index 9a2c39e..bef424b 100644 --- a/request.go +++ b/request.go @@ -5,11 +5,15 @@ package ahttp import ( + "errors" "fmt" + "io" "mime/multipart" "net" "net/http" "net/url" + "os" + "path/filepath" "strings" "sync" @@ -17,8 +21,9 @@ import ( ) const ( - jsonpReqParamKey = "callback" - ajaxHeaderValue = "XMLHttpRequest" + jsonpReqParamKey = "callback" + ajaxHeaderValue = "XMLHttpRequest" + websocketHeaderValue = "websocket" ) var requestPool = &sync.Pool{New: func() interface{} { return &Request{} }} @@ -35,6 +40,9 @@ type ( // Host value of the HTTP 'Host' header (e.g. 'example.com:8080'). Host string + // Proto value of the current HTTP request protocol. (e.g. HTTP/1.1, HTTP/2.0) + Proto string + // Method request method e.g. `GET`, `POST`, etc. Method string @@ -60,10 +68,6 @@ type ( // Most quailfied one based on quality factor. AcceptEncoding *AcceptSpec - // Payload holds the value from HTTP request for `Content-Type` - // JSON and XML. - Payload []byte - // Params contains values from Path, Query, Form and File. Params *Params @@ -88,7 +92,7 @@ type ( // Raw an object of Go HTTP server, direct interaction with // raw object is not encouraged. // - // Raw field to be unexported on v1 release, use `Req.Unwarp()` instead. + // DEPRECATED: Raw field to be unexported on v1 release, use `Req.Unwarp()` instead. Raw *http.Request } @@ -110,6 +114,7 @@ type ( func ParseRequest(r *http.Request, req *Request) *Request { req.Scheme = identifyScheme(r) req.Host = host(r) + req.Proto = r.Proto req.Method = r.Method req.Path = r.URL.Path req.Header = r.Header @@ -132,12 +137,12 @@ func ParseRequest(r *http.Request, req *Request) *Request { // Cookie method returns a named cookie from HTTP request otherwise error. func (r *Request) Cookie(name string) (*http.Cookie, error) { - return r.Raw.Cookie(name) + return r.Unwrap().Cookie(name) } // Cookies method returns all the cookies from HTTP request. func (r *Request) Cookies() []*http.Cookie { - return r.Raw.Cookies() + return r.Unwrap().Cookies() } // IsJSONP method returns true if request URL query string has "callback=function_name". @@ -146,12 +151,17 @@ func (r *Request) IsJSONP() bool { return !ess.IsStrEmpty(r.QueryValue(jsonpReqParamKey)) } -// IsAJAX methods returns true if the request header `X-Requested-With` is +// IsAJAX method returns true if request header `X-Requested-With` is // `XMLHttpRequest` otherwise false. func (r *Request) IsAJAX() bool { return r.Header.Get(HeaderXRequestedWith) == ajaxHeaderValue } +// IsWebSocket method returns true if request is WebSocket otherwise false. +func (r *Request) IsWebSocket() bool { + return r.Header.Get(HeaderUpgrade) == websocketHeaderValue +} + // PathValue method returns value for given Path param key otherwise empty string. // For eg.: /users/:userId => PathValue("userId") func (r *Request) PathValue(key string) string { @@ -187,22 +197,78 @@ func (r *Request) FormFile(key string) (multipart.File, *multipart.FileHeader, e return r.Params.FormFile(key) } -//Unwrap returns the underlying http.Request +// Body method returns the HTTP request body. +func (r *Request) Body() io.ReadCloser { + return r.Unwrap().Body +} + +// Unwrap method returns the underlying *http.Request. func (r *Request) Unwrap() *http.Request { return r.Raw } +// SaveFile method saves an uploaded multipart file for given key from the HTTP +// request into given destination +func (r *Request) SaveFile(key, dstFile string) (int64, error) { + if ess.IsStrEmpty(dstFile) || ess.IsStrEmpty(key) { + return 0, errors.New("ahttp: key or dstFile is empty") + } + + if ess.IsDir(dstFile) { + return 0, errors.New("ahttp: dstFile should not be a directory") + } + + uploadedFile, _, err := r.FormFile(key) + if err != nil { + return 0, err + } + defer ess.CloseQuietly(uploadedFile) + + return saveFile(uploadedFile, dstFile) +} + +// SaveFiles method saves an uploaded multipart file(s) for the given key +// from the HTTP request into given destination directory. It uses the filename +// as uploaded filename from the request +func (r *Request) SaveFiles(key, dstPath string) ([]int64, []error) { + if !ess.IsDir(dstPath) { + return []int64{0}, []error{fmt.Errorf("ahttp: destination path, '%s' is not a directory", dstPath)} + } + + if ess.IsStrEmpty(key) { + return []int64{0}, []error{fmt.Errorf("ahttp: form file key, '%s' is empty", key)} + } + + var errs []error + var sizes []int64 + for _, file := range r.Params.File[key] { + uploadedFile, err := file.Open() + if err != nil { + sizes = append(sizes, 0) + errs = append(errs, err) + continue + } + + if size, err := saveFile(uploadedFile, filepath.Join(dstPath, file.Filename)); err != nil { + sizes = append(sizes, size) + errs = append(errs, err) + } + ess.CloseQuietly(uploadedFile) + } + return sizes, errs +} + // Reset method resets request instance for reuse. func (r *Request) Reset() { r.Scheme = "" r.Host = "" + r.Proto = "" r.Method = "" r.Path = "" r.Header = nil r.ContentType = nil r.AcceptContentType = nil r.AcceptEncoding = nil - r.Payload = nil r.Params = nil r.Referer = "" r.UserAgent = "" @@ -269,7 +335,7 @@ func (p *Params) FormFile(key string) (multipart.File, *multipart.FileHeader, er f, err := fh[0].Open() return f, fh[0], err } - return nil, nil, fmt.Errorf("error file is missing: %s", key) + return nil, nil, fmt.Errorf("ahttp: no such key/file: %s", key) } return nil, nil, nil } @@ -351,3 +417,13 @@ func isGzipAccepted(req *Request, r *http.Request) bool { } return false } + +func saveFile(r io.Reader, destFile string) (int64, error) { + f, err := os.OpenFile(destFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) + if err != nil { + return 0, fmt.Errorf("ahttp: %s", err) + } + defer ess.CloseQuietly(f) + + return io.Copy(f, r) +} diff --git a/request_test.go b/request_test.go index 55114db..107c930 100644 --- a/request_test.go +++ b/request_test.go @@ -5,14 +5,17 @@ package ahttp import ( + "bytes" "crypto/tls" "mime/multipart" "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" + "aahframework.org/essentials.v0" "aahframework.org/test.v0/assert" ) @@ -87,6 +90,7 @@ func TestHTTPParseRequest(t *testing.T) { assert.Nil(t, err) assert.False(t, aahReq.IsJSONP()) assert.False(t, aahReq.IsAJAX()) + assert.False(t, aahReq.IsWebSocket()) // Reset it aahReq.Reset() @@ -131,6 +135,7 @@ func TestHTTPRequestParams(t *testing.T) { aahReq2.Params.Form = req2.Form params2 := aahReq2.Params + assert.NotNil(t, aahReq2.Body()) assert.Equal(t, "welcome", params2.FormValue("username")) assert.Equal(t, "welcome@welcome.com", params2.FormValue("email")) assert.Equal(t, "Test1", params2.FormArrayValue("names")[0]) @@ -143,7 +148,7 @@ func TestHTTPRequestParams(t *testing.T) { req3.Header.Add(HeaderContentType, ContentTypeMultipartForm.String()) aahReq3 := AcquireRequest(req3) aahReq3.Params.File = make(map[string][]*multipart.FileHeader) - aahReq3.Params.File["testfile.txt"] = []*multipart.FileHeader{&multipart.FileHeader{Filename: "testfile.txt"}} + aahReq3.Params.File["testfile.txt"] = []*multipart.FileHeader{{Filename: "testfile.txt"}} f, fh, err := aahReq3.FormFile("testfile.txt") assert.Nil(t, f) assert.Equal(t, "testfile.txt", fh.Filename) @@ -190,6 +195,166 @@ func TestRequestSchemeDerived(t *testing.T) { assert.Equal(t, "http", scheme4) } +func TestRequestSaveFile(t *testing.T) { + aahReq, path, teardown := setUpRequestSaveFile(t) + defer teardown() + + size, err := aahReq.SaveFile("framework", path) + assert.Nil(t, err) + assert.Equal(t, int64(0), size) + _, err = os.Stat(path) + assert.Nil(t, err) +} + +func TestRequestSaveFileFailsValidation(t *testing.T) { + aahReq, path, teardown := setUpRequestSaveFile(t) + defer teardown() + + // Empty keys should error out + _, err := aahReq.SaveFile("", path) + assert.NotNil(t, err) + assert.Equal(t, "ahttp: key or dstFile is empty", err.Error()) + + // Empty path should error out + _, err = aahReq.SaveFile("framework", "") + assert.NotNil(t, err) + assert.Equal(t, "ahttp: key or dstFile is empty", err.Error()) + + // If "path" is a directory, it should error out + _, err = aahReq.SaveFile("framework", "testdata") + assert.NotNil(t, err) + assert.Equal(t, "ahttp: dstFile should not be a directory", err.Error()) +} + +func TestRequestSaveFileFailsForNotFoundFile(t *testing.T) { + aahReq, path, teardown := setUpRequestSaveFile(t) + defer teardown() + + _, err := aahReq.SaveFile("unknown-key", path) + assert.NotNil(t, err) + assert.Equal(t, "ahttp: no such key/file: unknown-key", err.Error()) +} + +func TestRequestSaveFileCannotCreateFile(t *testing.T) { + aahReq, _, teardown := setUpRequestSaveFile(t) + defer teardown() + + _, err := aahReq.SaveFile("framework", "/root/aah.txt") + assert.NotNil(t, err) + assert.True(t, strings.HasPrefix(err.Error(), "ahttp: open /root/aah.txt")) +} + +func TestRequestSaveFiles(t *testing.T) { + aahReq, dir, teardown := setUpRequestSaveFiles(t) + defer teardown() + + sizes, errs := aahReq.SaveFiles("framework", dir) + assert.Nil(t, errs) + assert.Nil(t, sizes) + _, err := os.Stat(dir + "/aah") + assert.Nil(t, err) + _, err = os.Stat(dir + "/aah2") + assert.Nil(t, err) +} + +func TestRequestSaveFilesFailsVaildation(t *testing.T) { + aahReq, dir, teardown := setUpRequestSaveFiles(t) + defer teardown() + + // Empty key + sizes, errs := aahReq.SaveFiles("", dir) + assert.NotNil(t, errs) + assert.Equal(t, "ahttp: form file key, '' is empty", errs[0].Error()) + assert.Equal(t, int64(0), sizes[0]) + + // Empty directory + sizes, errs = aahReq.SaveFiles("key", "") + assert.NotNil(t, errs) + assert.Equal(t, "ahttp: destination path, '' is not a directory", errs[0].Error()) + assert.Equal(t, int64(0), sizes[0]) +} + +func TestRequestSaveFilesCannotCreateFile(t *testing.T) { + aahReq, _, teardown := setUpRequestSaveFiles(t) + defer teardown() + + sizes, errs := aahReq.SaveFiles("framework", "/root") + assert.NotNil(t, errs) + assert.Equal(t, int64(0), sizes[0]) + + errMsg := errs[0].Error() + assert.True(t, ("ahttp: open /root/aah: permission denied" == errMsg || + "ahttp: destination path, '/root' is not a directory" == errMsg)) +} + +func TestRequestSaveFileForExistingFile(t *testing.T) { + var buf bytes.Buffer + + size, err := saveFile(&buf, "testdata/file1.txt") + assert.NotNil(t, err) + assert.Equal(t, "ahttp: open testdata/file1.txt: file exists", err.Error()) + assert.Equal(t, int64(0), size) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// test unexported methods +//___________________________________ + func createRequestWithHost(host, remote string) *http.Request { return &http.Request{Host: host, RemoteAddr: remote, Header: http.Header{}} } + +func setUpRequestSaveFile(t *testing.T) (*Request, string, func()) { + buf := new(bytes.Buffer) + multipartWriter := multipart.NewWriter(buf) + _, err := multipartWriter.CreateFormFile("framework", "aah") + assert.Nil(t, err) + + ess.CloseQuietly(multipartWriter) + + req, _ := http.NewRequest("POST", "http://localhost:8080", buf) + req.Header.Add(HeaderContentType, multipartWriter.FormDataContentType()) + aahReq := AcquireRequest(req) + aahReq.Params.File = make(map[string][]*multipart.FileHeader) + + _, header, err := req.FormFile("framework") + assert.Nil(t, err) + + aahReq.Params.File["framework"] = []*multipart.FileHeader{header} + + path := "testdata/aah.txt" + + return aahReq, path, func() { + _ = os.Remove(path) //Teardown + } +} + +func setUpRequestSaveFiles(t *testing.T) (*Request, string, func()) { + buf := new(bytes.Buffer) + multipartWriter := multipart.NewWriter(buf) + _, err := multipartWriter.CreateFormFile("framework", "aah") + assert.Nil(t, err) + _, err = multipartWriter.CreateFormFile("framework2", "aah2") + assert.Nil(t, err) + + ess.CloseQuietly(multipartWriter) + + req, _ := http.NewRequest("POST", "http://localhost:8080", buf) + req.Header.Add(HeaderContentType, multipartWriter.FormDataContentType()) + aahReq := AcquireRequest(req) + aahReq.Params.File = make(map[string][]*multipart.FileHeader) + + _, header, err := req.FormFile("framework") + assert.Nil(t, err) + _, header2, err := req.FormFile("framework2") + assert.Nil(t, err) + + aahReq.Params.File["framework"] = []*multipart.FileHeader{header, header2} + + dir := "testdata/upload" + + _ = ess.MkDirAll(dir, 0755) + return aahReq, dir, func() { + _ = os.RemoveAll(dir) + } +} diff --git a/response.go b/response.go index a18c0f6..6dc4807 100644 --- a/response.go +++ b/response.go @@ -171,7 +171,7 @@ func (r *Response) Reset() { //___________________________________ func (r *Response) setContentTypeIfNotSet(b []byte) { - if _, found := r.Header()[HeaderContentType]; !found { + if ct := r.Header().Get(HeaderContentType); ess.IsStrEmpty(ct) { r.Header().Set(HeaderContentType, http.DetectContentType(b)) } } diff --git a/version.go b/version.go new file mode 100644 index 0000000..8ccc555 --- /dev/null +++ b/version.go @@ -0,0 +1,8 @@ +// Copyright (c) Jeevanandam M (https://github.com/jeevatkm) +// go-aah/ahttp source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package ahttp + +// Version no. of aah framework ahttp library +const Version = "0.10"