From 9944a46075ca9912f241d4e217e6d7872435b9ea Mon Sep 17 00:00:00 2001 From: Iman Tumorang Date: Wed, 8 Jan 2020 17:50:15 +0700 Subject: [PATCH] feat(cache-control): add cache control handler (#13) * add cache control * feat(cache-control): add cache control handler * chore(cache-control): remove unused function * fix(expiry): add condition for expired response * chore(example): add example how to use --- README.md | 9 +- cache/cache.go | 13 +- cache/inmem/inmem.go | 5 +- control/cache-control.go | 31 ++ control/cacheheader/directive.go | 529 +++++++++++++++++++++++++++++++ control/cacheheader/lex.go | 93 ++++++ control/cacheheader/reason.go | 79 +++++ control/cacheheader/reponse.go | 360 +++++++++++++++++++++ control/cacheheader/warning.go | 90 ++++++ go.mod | 5 +- go.sum | 2 + roundtriper.go | 121 +++++-- sample/inmem/main.go | 15 +- 13 files changed, 1316 insertions(+), 36 deletions(-) create mode 100644 control/cache-control.go create mode 100644 control/cacheheader/directive.go create mode 100644 control/cacheheader/lex.go create mode 100644 control/cacheheader/reason.go create mode 100644 control/cacheheader/reponse.go create mode 100644 control/cacheheader/warning.go diff --git a/README.md b/README.md index 45fd5b6..f367932 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,13 @@ for i:=0; i< 10; i++ { //TODO(bxcodec) -## Contribution +## Inspirations and Thanks +- [pquerna/cachecontrol](github.com/pquerna/cachecontrol) for the Cache-Header Extraction + + +## Contribution --- -To contrib to this project, you can open a PR or an issue. \ No newline at end of file +To contrib to this project, you can open a PR or an issue. + diff --git a/cache/cache.go b/cache/cache.go index dd7123e..b5d1369 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -16,19 +16,17 @@ var ( // Interactor ... type Interactor interface { - Set(key string, value CachedResponse) error + Set(key string, value CachedResponse, duration time.Duration) error Get(key string) (CachedResponse, error) Delete(key string) error } // CachedResponse represent the cacher struct item type CachedResponse struct { - // StatusCode int `json:"statusCode"` - DumpedResponse []byte `json:"response"` - // DumpedBody []byte `json:"body"` - RequestURI string `json:"requestUri"` - RequestMethod string `json:"requestMethod"` - CachedTime time.Time `json:"cachedTime"` + DumpedResponse []byte `json:"response"` // The dumped response body + RequestURI string `json:"requestUri"` // The requestURI of the response + RequestMethod string `json:"requestMethod"` // The HTTP Method that call the request for this response + CachedTime time.Time `json:"cachedTime"` // The timestamp when this response is Cached } // Validate will validate the cached response @@ -48,6 +46,5 @@ func (c *CachedResponse) Validate() (err error) { if c.CachedTime.IsZero() { return ErrInvalidCachedResponse } - return } diff --git a/cache/inmem/inmem.go b/cache/inmem/inmem.go index d5f51ca..3105d2e 100644 --- a/cache/inmem/inmem.go +++ b/cache/inmem/inmem.go @@ -1,6 +1,8 @@ package inmem import ( + "time" + memcache "github.com/bxcodec/gotcha/cache" "github.com/bxcodec/hache/cache" ) @@ -16,7 +18,8 @@ func NewCache(c memcache.Cache) cache.Interactor { } } -func (i *inmemCache) Set(key string, value cache.CachedResponse) (err error) { +func (i *inmemCache) Set(key string, value cache.CachedResponse, duration time.Duration) (err error) { + // TODO(bxcodec): add custom duration here based on user response result on the fly return i.cache.Set(key, value) } diff --git a/control/cache-control.go b/control/cache-control.go new file mode 100644 index 0000000..2a0b8c1 --- /dev/null +++ b/control/cache-control.go @@ -0,0 +1,31 @@ +package control + +import ( + // "github.com/pquerna/cachecontrol/cacheobject" + cacheobject "github.com/bxcodec/hache/control/cacheheader" + "net/http" + "time" +) + +type Options struct { + // Set to True for a prviate cache, which is not shared amoung users (eg, in a browser) + // Set to False for a "shared" cache, which is more common in a server context. + PrivateCache bool +} + +// Given an HTTP Request, the future Status Code, and an ResponseWriter, +// determine the possible reasons a response SHOULD NOT be cached. +func CachableResponseWriter(req *http.Request, + statusCode int, + resp http.ResponseWriter, + opts Options) ([]cacheobject.Reason, time.Time, error) { + return cacheobject.UsingRequestResponse(req, statusCode, resp.Header(), opts.PrivateCache) +} + +// Given an HTTP Request and Response, determine the possible reasons a response SHOULD NOT +// be cached. +func CachableResponse(req *http.Request, + resp *http.Response, + opts Options) ([]cacheobject.Reason, time.Time, error) { + return cacheobject.UsingRequestResponse(req, resp.StatusCode, resp.Header, opts.PrivateCache) +} diff --git a/control/cacheheader/directive.go b/control/cacheheader/directive.go new file mode 100644 index 0000000..9ac65fb --- /dev/null +++ b/control/cacheheader/directive.go @@ -0,0 +1,529 @@ +package cacheheader + +import ( + "errors" + "math" + "net/http" + "net/textproto" + "strconv" + "strings" +) + +// TODO(bxcodec): more extensions from here: http://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml + +var ( + ErrQuoteMismatch = errors.New("Missing closing quote") + ErrMaxAgeDeltaSeconds = errors.New("Failed to parse delta-seconds in `max-age`") + ErrSMaxAgeDeltaSeconds = errors.New("Failed to parse delta-seconds in `s-maxage`") + ErrMaxStaleDeltaSeconds = errors.New("Failed to parse delta-seconds in `min-fresh`") + ErrMinFreshDeltaSeconds = errors.New("Failed to parse delta-seconds in `min-fresh`") + ErrNoCacheNoArgs = errors.New("Unexpected argument to `no-cache`") + ErrNoStoreNoArgs = errors.New("Unexpected argument to `no-store`") + ErrNoTransformNoArgs = errors.New("Unexpected argument to `no-transform`") + ErrOnlyIfCachedNoArgs = errors.New("Unexpected argument to `only-if-cached`") + ErrMustRevalidateNoArgs = errors.New("Unexpected argument to `must-revalidate`") + ErrPublicNoArgs = errors.New("Unexpected argument to `public`") + ErrProxyRevalidateNoArgs = errors.New("Unexpected argument to `proxy-revalidate`") + // Experimental + ErrImmutableNoArgs = errors.New("Unexpected argument to `immutable`") + ErrStaleIfErrorDeltaSeconds = errors.New("Failed to parse delta-seconds in `stale-if-error`") + ErrStaleWhileRevalidateDeltaSeconds = errors.New("Failed to parse delta-seconds in `stale-while-revalidate`") +) + +func whitespace(b byte) bool { + if b == '\t' || b == ' ' { + return true + } + return false +} + +func parse(value string, cd cacheDirective) error { + var err error = nil + i := 0 + + for i < len(value) && err == nil { + // eat leading whitespace or commas + if whitespace(value[i]) || value[i] == ',' { + i++ + continue + } + + j := i + 1 + + for j < len(value) { + if !isToken(value[j]) { + break + } + j++ + } + + token := strings.ToLower(value[i:j]) + tokenHasFields := hasFieldNames(token) + /* + println("GOT TOKEN:") + println(" i -> ", i) + println(" j -> ", j) + println(" token -> ", token) + */ + + if j+1 < len(value) && value[j] == '=' { + k := j + 1 + // minimum size two bytes of "", but we let httpUnquote handle it. + if k < len(value) && value[k] == '"' { + eaten, result := httpUnquote(value[k:]) + if eaten == -1 { + return ErrQuoteMismatch + } + i = k + eaten + + err = cd.addPair(token, result) + } else { + z := k + for z < len(value) { + if tokenHasFields { + if whitespace(value[z]) { + break + } + } else { + if whitespace(value[z]) || value[z] == ',' { + break + } + } + z++ + } + i = z + + result := value[k:z] + if result != "" && result[len(result)-1] == ',' { + result = result[:len(result)-1] + } + + err = cd.addPair(token, result) + } + } else { + if token != "," { + err = cd.addToken(token) + } + i = j + } + } + + return err +} + +// DeltaSeconds specifies a non-negative integer, representing +// time in seconds: http://tools.ietf.org/html/rfc7234#section-1.2.1 +// +// When set to -1, this means unset. +// +type DeltaSeconds int32 + +// Parser for delta-seconds, a uint31, more or less: +// http://tools.ietf.org/html/rfc7234#section-1.2.1 +func parseDeltaSeconds(v string) (DeltaSeconds, error) { + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + if numError, ok := err.(*strconv.NumError); ok { + if numError.Err == strconv.ErrRange { + return DeltaSeconds(math.MaxInt32), nil + } + } + return DeltaSeconds(-1), err + } else { + if n > math.MaxInt32 { + return DeltaSeconds(math.MaxInt32), nil + } else { + return DeltaSeconds(n), nil + } + } +} + +// Fields present in a header. +type FieldNames map[string]bool + +// internal interface for shared methods of RequestCacheDirectives and ResponseCacheDirectives +type cacheDirective interface { + addToken(s string) error + addPair(s string, v string) error +} + +// LOW LEVEL API: Repersentation of possible request directives in a `Cache-Control` header: http://tools.ietf.org/html/rfc7234#section-5.2.1 +// +// Note: Many fields will be `nil` in practice. +// +type RequestCacheDirectives struct { + + // max-age(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.1 + // + // The "max-age" request directive indicates that the client is + // unwilling to accept a response whose age is greater than the + // specified number of seconds. Unless the max-stale request directive + // is also present, the client is not willing to accept a stale + // response. + MaxAge DeltaSeconds + + // max-stale(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.2 + // + // The "max-stale" request directive indicates that the client is + // willing to accept a response that has exceeded its freshness + // lifetime. If max-stale is assigned a value, then the client is + // willing to accept a response that has exceeded its freshness lifetime + // by no more than the specified number of seconds. If no value is + // assigned to max-stale, then the client is willing to accept a stale + // response of any age. + MaxStale DeltaSeconds + + // min-fresh(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.1.3 + // + // The "min-fresh" request directive indicates that the client is + // willing to accept a response whose freshness lifetime is no less than + // its current age plus the specified time in seconds. That is, the + // client wants a response that will still be fresh for at least the + // specified number of seconds. + MinFresh DeltaSeconds + + // no-cache(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.4 + // + // The "no-cache" request directive indicates that a cache MUST NOT use + // a stored response to satisfy the request without successful + // validation on the origin server. + NoCache bool + + // no-store(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.5 + // + // The "no-store" request directive indicates that a cache MUST NOT + // store any part of either this request or any response to it. This + // directive applies to both private and shared caches. + NoStore bool + + // no-transform(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.6 + // + // The "no-transform" request directive indicates that an intermediary + // (whether or not it implements a cache) MUST NOT transform the + // payload, as defined in Section 5.7.2 of RFC7230. + NoTransform bool + + // only-if-cached(bool): http://tools.ietf.org/html/rfc7234#section-5.2.1.7 + // + // The "only-if-cached" request directive indicates that the client only + // wishes to obtain a stored response. + OnlyIfCached bool + + // Extensions: http://tools.ietf.org/html/rfc7234#section-5.2.3 + // + // The Cache-Control header field can be extended through the use of one + // or more cache-extension tokens, each with an optional value. A cache + // MUST ignore unrecognized cache directives. + Extensions []string +} + +func (cd *RequestCacheDirectives) addToken(token string) error { + var err error = nil + + switch token { + case "max-age": + err = ErrMaxAgeDeltaSeconds + case "max-stale": + err = ErrMaxStaleDeltaSeconds + case "min-fresh": + err = ErrMinFreshDeltaSeconds + case "no-cache": + cd.NoCache = true + case "no-store": + cd.NoStore = true + case "no-transform": + cd.NoTransform = true + case "only-if-cached": + cd.OnlyIfCached = true + default: + cd.Extensions = append(cd.Extensions, token) + } + return err +} + +func (cd *RequestCacheDirectives) addPair(token string, v string) error { + var err error = nil + + switch token { + case "max-age": + cd.MaxAge, err = parseDeltaSeconds(v) + if err != nil { + err = ErrMaxAgeDeltaSeconds + } + case "max-stale": + cd.MaxStale, err = parseDeltaSeconds(v) + if err != nil { + err = ErrMaxStaleDeltaSeconds + } + case "min-fresh": + cd.MinFresh, err = parseDeltaSeconds(v) + if err != nil { + err = ErrMinFreshDeltaSeconds + } + case "no-cache": + err = ErrNoCacheNoArgs + case "no-store": + err = ErrNoStoreNoArgs + case "no-transform": + err = ErrNoTransformNoArgs + case "only-if-cached": + err = ErrOnlyIfCachedNoArgs + default: + // TODO(pquerna): this sucks, making user re-parse + cd.Extensions = append(cd.Extensions, token+"="+v) + } + + return err +} + +// LOW LEVEL API: Parses a Cache Control Header from a Request into a set of directives. +func ParseRequestCacheControl(value string) (*RequestCacheDirectives, error) { + cd := &RequestCacheDirectives{ + MaxAge: -1, + MaxStale: -1, + MinFresh: -1, + } + + err := parse(value, cd) + if err != nil { + return nil, err + } + return cd, nil +} + +// LOW LEVEL API: Repersentation of possible response directives in a `Cache-Control` header: http://tools.ietf.org/html/rfc7234#section-5.2.2 +// +// Note: Many fields will be `nil` in practice. +// +type ResponseCacheDirectives struct { + + // must-revalidate(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.1 + // + // The "must-revalidate" response directive indicates that once it has + // become stale, a cache MUST NOT use the response to satisfy subsequent + // requests without successful validation on the origin server. + MustRevalidate bool + + // no-cache(FieldName): http://tools.ietf.org/html/rfc7234#section-5.2.2.2 + // + // The "no-cache" response directive indicates that the response MUST + // NOT be used to satisfy a subsequent request without successful + // validation on the origin server. + // + // If the no-cache response directive specifies one or more field-names, + // then a cache MAY use the response to satisfy a subsequent request, + // subject to any other restrictions on caching. However, any header + // fields in the response that have the field-name(s) listed MUST NOT be + // sent in the response to a subsequent request without successful + // revalidation with the origin server. + NoCache FieldNames + + // no-cache(cast-to-bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.2 + // + // While the RFC defines optional field-names on a no-cache directive, + // many applications only want to know if any no-cache directives were + // present at all. + NoCachePresent bool + + // no-store(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.3 + // + // The "no-store" request directive indicates that a cache MUST NOT + // store any part of either this request or any response to it. This + // directive applies to both private and shared caches. + NoStore bool + + // no-transform(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.4 + // + // The "no-transform" response directive indicates that an intermediary + // (regardless of whether it implements a cache) MUST NOT transform the + // payload, as defined in Section 5.7.2 of RFC7230. + NoTransform bool + + // public(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.5 + // + // The "public" response directive indicates that any cache MAY store + // the response, even if the response would normally be non-cacheable or + // cacheable only within a private cache. + Public bool + + // private(FieldName): http://tools.ietf.org/html/rfc7234#section-5.2.2.6 + // + // The "private" response directive indicates that the response message + // is intended for a single user and MUST NOT be stored by a shared + // cache. A private cache MAY store the response and reuse it for later + // requests, even if the response would normally be non-cacheable. + // + // If the private response directive specifies one or more field-names, + // this requirement is limited to the field-values associated with the + // listed response header fields. That is, a shared cache MUST NOT + // store the specified field-names(s), whereas it MAY store the + // remainder of the response message. + Private FieldNames + + // private(cast-to-bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.6 + // + // While the RFC defines optional field-names on a private directive, + // many applications only want to know if any private directives were + // present at all. + PrivatePresent bool + + // proxy-revalidate(bool): http://tools.ietf.org/html/rfc7234#section-5.2.2.7 + // + // The "proxy-revalidate" response directive has the same meaning as the + // must-revalidate response directive, except that it does not apply to + // private caches. + ProxyRevalidate bool + + // max-age(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.2.8 + // + // The "max-age" response directive indicates that the response is to be + // considered stale after its age is greater than the specified number + // of seconds. + MaxAge DeltaSeconds + + // s-maxage(delta seconds): http://tools.ietf.org/html/rfc7234#section-5.2.2.9 + // + // The "s-maxage" response directive indicates that, in shared caches, + // the maximum age specified by this directive overrides the maximum age + // specified by either the max-age directive or the Expires header + // field. The s-maxage directive also implies the semantics of the + // proxy-revalidate response directive. + SMaxAge DeltaSeconds + + //// + // Experimental features + // - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Extension_Cache-Control_directives + // - https://www.fastly.com/blog/stale-while-revalidate-stale-if-error-available-today + //// + + // immutable(cast-to-bool): experimental feature + Immutable bool + + // stale-if-error(delta seconds): experimental feature + StaleIfError DeltaSeconds + + // stale-while-revalidate(delta seconds): experimental feature + StaleWhileRevalidate DeltaSeconds + + // Extensions: http://tools.ietf.org/html/rfc7234#section-5.2.3 + // + // The Cache-Control header field can be extended through the use of one + // or more cache-extension tokens, each with an optional value. A cache + // MUST ignore unrecognized cache directives. + Extensions []string +} + +// LOW LEVEL API: Parses a Cache Control Header from a Response into a set of directives. +func ParseResponseCacheControl(value string) (*ResponseCacheDirectives, error) { + cd := &ResponseCacheDirectives{ + MaxAge: -1, + SMaxAge: -1, + // Exerimantal stale timeouts + StaleIfError: -1, + StaleWhileRevalidate: -1, + } + + err := parse(value, cd) + if err != nil { + return nil, err + } + return cd, nil +} + +func (cd *ResponseCacheDirectives) addToken(token string) error { + var err error = nil + switch token { + case "must-revalidate": + cd.MustRevalidate = true + case "no-cache": + cd.NoCachePresent = true + case "no-store": + cd.NoStore = true + case "no-transform": + cd.NoTransform = true + case "public": + cd.Public = true + case "private": + cd.PrivatePresent = true + case "proxy-revalidate": + cd.ProxyRevalidate = true + case "max-age": + err = ErrMaxAgeDeltaSeconds + case "s-maxage": + err = ErrSMaxAgeDeltaSeconds + // Experimental + case "immutable": + cd.Immutable = true + case "stale-if-error": + err = ErrMaxAgeDeltaSeconds + case "stale-while-revalidate": + err = ErrMaxAgeDeltaSeconds + default: + cd.Extensions = append(cd.Extensions, token) + } + return err +} + +func hasFieldNames(token string) bool { + switch token { + case "no-cache": + return true + case "private": + return true + } + return false +} + +func (cd *ResponseCacheDirectives) addPair(token string, v string) error { + var err error = nil + + switch token { + case "must-revalidate": + err = ErrMustRevalidateNoArgs + case "no-cache": + cd.NoCachePresent = true + tokens := strings.Split(v, ",") + if cd.NoCache == nil { + cd.NoCache = make(FieldNames) + } + for _, t := range tokens { + k := http.CanonicalHeaderKey(textproto.TrimString(t)) + cd.NoCache[k] = true + } + case "no-store": + err = ErrNoStoreNoArgs + case "no-transform": + err = ErrNoTransformNoArgs + case "public": + err = ErrPublicNoArgs + case "private": + cd.PrivatePresent = true + tokens := strings.Split(v, ",") + if cd.Private == nil { + cd.Private = make(FieldNames) + } + for _, t := range tokens { + k := http.CanonicalHeaderKey(textproto.TrimString(t)) + cd.Private[k] = true + } + case "proxy-revalidate": + err = ErrProxyRevalidateNoArgs + case "max-age": + cd.MaxAge, err = parseDeltaSeconds(v) + case "s-maxage": + cd.SMaxAge, err = parseDeltaSeconds(v) + // Experimental + case "immutable": + err = ErrImmutableNoArgs + case "stale-if-error": + cd.StaleIfError, err = parseDeltaSeconds(v) + case "stale-while-revalidate": + cd.StaleWhileRevalidate, err = parseDeltaSeconds(v) + default: + // TODO(pquerna): this sucks, making user re-parse, and its technically not 'quoted' like the original, + // but this is still easier, just a SplitN on "=" + cd.Extensions = append(cd.Extensions, token+"="+v) + } + + return err +} diff --git a/control/cacheheader/lex.go b/control/cacheheader/lex.go new file mode 100644 index 0000000..e3d221e --- /dev/null +++ b/control/cacheheader/lex.go @@ -0,0 +1,93 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cacheheader + +// This file deals with lexical matters of HTTP + +func isSeparator(c byte) bool { + switch c { + case '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t': + return true + } + return false +} + +func isCtl(c byte) bool { return (0 <= c && c <= 31) || c == 127 } + +func isChar(c byte) bool { return 0 <= c && c <= 127 } + +func isAnyText(c byte) bool { return !isCtl(c) } + +func isQdText(c byte) bool { return isAnyText(c) && c != '"' } + +func isToken(c byte) bool { return isChar(c) && !isCtl(c) && !isSeparator(c) } + +// Valid escaped sequences are not specified in RFC 2616, so for now, we assume +// that they coincide with the common sense ones used by GO. Malformed +// characters should probably not be treated as errors by a robust (forgiving) +// parser, so we replace them with the '?' character. +func httpUnquotePair(b byte) byte { + // skip the first byte, which should always be '\' + switch b { + case 'a': + return '\a' + case 'b': + return '\b' + case 'f': + return '\f' + case 'n': + return '\n' + case 'r': + return '\r' + case 't': + return '\t' + case 'v': + return '\v' + case '\\': + return '\\' + case '\'': + return '\'' + case '"': + return '"' + } + return '?' +} + +// raw must begin with a valid quoted string. Only the first quoted string is +// parsed and is unquoted in result. eaten is the number of bytes parsed, or -1 +// upon failure. +func httpUnquote(raw string) (eaten int, result string) { + buf := make([]byte, len(raw)) + if raw[0] != '"' { + return -1, "" + } + eaten = 1 + j := 0 // # of bytes written in buf + for i := 1; i < len(raw); i++ { + switch b := raw[i]; b { + case '"': + eaten++ + buf = buf[0:j] + return i + 1, string(buf) + case '\\': + if len(raw) < i+2 { + return -1, "" + } + buf[j] = httpUnquotePair(raw[i+1]) + eaten += 2 + j++ + i++ + default: + if isQdText(b) { + buf[j] = b + } else { + buf[j] = '?' + } + eaten++ + j++ + } + } + return -1, "" +} \ No newline at end of file diff --git a/control/cacheheader/reason.go b/control/cacheheader/reason.go new file mode 100644 index 0000000..0e9ff8f --- /dev/null +++ b/control/cacheheader/reason.go @@ -0,0 +1,79 @@ + +package cacheheader + +// Repersents a potential Reason to not cache an object. +// +// Applications may wish to ignore specific reasons, which will make them non-RFC +// compliant, but this type gives them specific cases they can choose to ignore, +// making them compliant in as many cases as they can. +type Reason int + +const ( + + // The request method was POST and an Expiration header was not supplied. + ReasonRequestMethodPOST Reason = iota + + // The request method was PUT and PUTs are not cachable. + ReasonRequestMethodPUT + + // The request method was DELETE and DELETEs are not cachable. + ReasonRequestMethodDELETE + + // The request method was CONNECT and CONNECTs are not cachable. + ReasonRequestMethodCONNECT + + // The request method was OPTIONS and OPTIONS are not cachable. + ReasonRequestMethodOPTIONS + + // The request method was TRACE and TRACEs are not cachable. + ReasonRequestMethodTRACE + + // The request method was not recognized by cachecontrol, and should not be cached. + ReasonRequestMethodUnkown + + // The request included an Cache-Control: no-store header + ReasonRequestNoStore + + // The request included an Authorization header without an explicit Public or Expiration time: http://tools.ietf.org/html/rfc7234#section-3.2 + ReasonRequestAuthorizationHeader + + // The response included an Cache-Control: no-store header + ReasonResponseNoStore + + // The response included an Cache-Control: private header and this is not a Private cache + ReasonResponsePrivate + + // The response failed to meet at least one of the conditions specified in RFC 7234 section 3: http://tools.ietf.org/html/rfc7234#section-3 + ReasonResponseUncachableByDefault +) + +func (r Reason) String() string { + switch r { + case ReasonRequestMethodPOST: + return "ReasonRequestMethodPOST" + case ReasonRequestMethodPUT: + return "ReasonRequestMethodPUT" + case ReasonRequestMethodDELETE: + return "ReasonRequestMethodDELETE" + case ReasonRequestMethodCONNECT: + return "ReasonRequestMethodCONNECT" + case ReasonRequestMethodOPTIONS: + return "ReasonRequestMethodOPTIONS" + case ReasonRequestMethodTRACE: + return "ReasonRequestMethodTRACE" + case ReasonRequestMethodUnkown: + return "ReasonRequestMethodUnkown" + case ReasonRequestNoStore: + return "ReasonRequestNoStore" + case ReasonRequestAuthorizationHeader: + return "ReasonRequestAuthorizationHeader" + case ReasonResponseNoStore: + return "ReasonResponseNoStore" + case ReasonResponsePrivate: + return "ReasonResponsePrivate" + case ReasonResponseUncachableByDefault: + return "ReasonResponseUncachableByDefault" + } + + panic(r) +} \ No newline at end of file diff --git a/control/cacheheader/reponse.go b/control/cacheheader/reponse.go new file mode 100644 index 0000000..9d91e40 --- /dev/null +++ b/control/cacheheader/reponse.go @@ -0,0 +1,360 @@ +package cacheheader + +import ( + "net/http" + "time" +) + +// LOW LEVEL API: Repersents a potentially cachable HTTP object. +// +// This struct is designed to be serialized efficiently, so in a high +// performance caching server, things like Date-Strings don't need to be +// parsed for every use of a cached object. +type Object struct { + CacheIsPrivate bool + + RespDirectives *ResponseCacheDirectives + RespHeaders http.Header + RespStatusCode int + RespExpiresHeader time.Time + RespDateHeader time.Time + RespLastModifiedHeader time.Time + + ReqDirectives *RequestCacheDirectives + ReqHeaders http.Header + ReqMethod string + + NowUTC time.Time +} + +// LOW LEVEL API: Repersents the results of examinig an Object with +// CachableObject and ExpirationObject. +// +// TODO(pquerna): decide if this is a good idea or bad +type ObjectResults struct { + OutReasons []Reason + OutWarnings []Warning + OutExpirationTime time.Time + OutErr error +} + +// LOW LEVEL API: Check if a object is cachable. +func CachableObject(obj *Object, rv *ObjectResults) { + rv.OutReasons = nil + rv.OutWarnings = nil + rv.OutErr = nil + + switch obj.ReqMethod { + case "GET": + break + case "HEAD": + break + case "POST": + /** + POST: http://tools.ietf.org/html/rfc7231#section-4.3.3 + Responses to POST requests are only cacheable when they include + explicit freshness information (see Section 4.2.1 of [RFC7234]). + However, POST caching is not widely implemented. For cases where an + origin server wishes the client to be able to cache the result of a + POST in a way that can be reused by a later GET, the origin server + MAY send a 200 (OK) response containing the result and a + Content-Location header field that has the same value as the POST's + effective request URI (Section 3.1.4.2). + */ + if !hasFreshness(obj.ReqDirectives, obj.RespDirectives, obj.RespHeaders, obj.RespExpiresHeader, obj.CacheIsPrivate) { + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodPOST) + } + + case "PUT": + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodPUT) + + case "DELETE": + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodDELETE) + + case "CONNECT": + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodCONNECT) + + case "OPTIONS": + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodOPTIONS) + + case "TRACE": + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodTRACE) + + // HTTP Extension Methods: http://www.iana.org/assignments/http-methods/http-methods.xhtml + // + // To my knowledge, none of them are cachable. Please open a ticket if this is not the case! + // + default: + rv.OutReasons = append(rv.OutReasons, ReasonRequestMethodUnkown) + } + + if obj.ReqDirectives.NoStore { + rv.OutReasons = append(rv.OutReasons, ReasonRequestNoStore) + } + + // Storing Responses to Authenticated Requests: http://tools.ietf.org/html/rfc7234#section-3.2 + authz := obj.ReqHeaders.Get("Authorization") + if authz != "" { + if obj.RespDirectives.MustRevalidate || + obj.RespDirectives.Public || + obj.RespDirectives.SMaxAge != -1 { + // Expires of some kind present, this is potentially OK. + } else { + rv.OutReasons = append(rv.OutReasons, ReasonRequestAuthorizationHeader) + } + } + + if obj.RespDirectives.PrivatePresent && !obj.CacheIsPrivate { + rv.OutReasons = append(rv.OutReasons, ReasonResponsePrivate) + } + + if obj.RespDirectives.NoStore { + rv.OutReasons = append(rv.OutReasons, ReasonResponseNoStore) + } + + /* + the response either: + * contains an Expires header field (see Section 5.3), or + * contains a max-age response directive (see Section 5.2.2.8), or + * contains a s-maxage response directive (see Section 5.2.2.9) + and the cache is shared, or + * contains a Cache Control Extension (see Section 5.2.3) that + allows it to be cached, or + * has a status code that is defined as cacheable by default (see + Section 4.2.2), or + * contains a public response directive (see Section 5.2.2.5). + */ + + expires := obj.RespHeaders.Get("Expires") != "" + statusCachable := cachableStatusCode(obj.RespStatusCode) + + if expires || + obj.RespDirectives.MaxAge != -1 || + (obj.RespDirectives.SMaxAge != -1 && !obj.CacheIsPrivate) || + statusCachable || + obj.RespDirectives.Public { + /* cachable by default, at least one of the above conditions was true */ + } else { + rv.OutReasons = append(rv.OutReasons, ReasonResponseUncachableByDefault) + } +} + +var twentyFourHours = time.Duration(24 * time.Hour) + +const debug = false + +// LOW LEVEL API: Update an objects expiration time. +func ExpirationObject(obj *Object, rv *ObjectResults) { + /** + * Okay, lets calculate Freshness/Expiration now. woo: + * http://tools.ietf.org/html/rfc7234#section-4.2 + */ + + /* + o If the cache is shared and the s-maxage response directive + (Section 5.2.2.9) is present, use its value, or + o If the max-age response directive (Section 5.2.2.8) is present, + use its value, or + o If the Expires response header field (Section 5.3) is present, use + its value minus the value of the Date response header field, or + o Otherwise, no explicit expiration time is present in the response. + A heuristic freshness lifetime might be applicable; see + Section 4.2.2. + */ + + var expiresTime time.Time + + if obj.RespDirectives.SMaxAge != -1 && !obj.CacheIsPrivate { + expiresTime = obj.NowUTC.Add(time.Second * time.Duration(obj.RespDirectives.SMaxAge)) + } else if obj.RespDirectives.MaxAge != -1 { + expiresTime = obj.NowUTC.UTC().Add(time.Second * time.Duration(obj.RespDirectives.MaxAge)) + } else if !obj.RespExpiresHeader.IsZero() { + serverDate := obj.RespDateHeader + if serverDate.IsZero() { + // common enough case when a Date: header has not yet been added to an + // active response. + serverDate = obj.NowUTC + } + expiresTime = obj.NowUTC.Add(obj.RespExpiresHeader.Sub(serverDate)) + } else if !obj.RespLastModifiedHeader.IsZero() { + // heuristic freshness lifetime + rv.OutWarnings = append(rv.OutWarnings, WarningHeuristicExpiration) + + // http://httpd.apache.org/docs/2.4/mod/mod_cache.html#cachelastmodifiedfactor + // CacheMaxExpire defaults to 24 hours + // CacheLastModifiedFactor: is 0.1 + // + // expiry-period = MIN(time-since-last-modified-date * factor, 24 hours) + // + // obj.NowUTC + + since := obj.RespLastModifiedHeader.Sub(obj.NowUTC) + since = time.Duration(float64(since) * -0.1) + + if since > twentyFourHours { + expiresTime = obj.NowUTC.Add(twentyFourHours) + } else { + expiresTime = obj.NowUTC.Add(since) + } + + if debug { + println("Now UTC: ", obj.NowUTC.String()) + println("Last-Modified: ", obj.RespLastModifiedHeader.String()) + println("Since: ", since.String()) + println("TwentyFourHours: ", twentyFourHours.String()) + println("Expiration: ", expiresTime.String()) + } + } else { + // TODO(pquerna): what should the default behavoir be for expiration time? + } + + rv.OutExpirationTime = expiresTime +} + +// Evaluate cachability based on an HTTP request, and parts of the response. +func UsingRequestResponse(req *http.Request, + statusCode int, + respHeaders http.Header, + privateCache bool) ([]Reason, time.Time, error) { + reasons, time, _, _, err := UsingRequestResponseWithObject(req, statusCode, respHeaders, privateCache) + return reasons, time, err +} + +// Evaluate cachability based on an HTTP request, and parts of the response. +// Returns the parsed Object as well. +func UsingRequestResponseWithObject(req *http.Request, + statusCode int, + respHeaders http.Header, + privateCache bool) ([]Reason, time.Time, []Warning, *Object, error) { + var reqHeaders http.Header + var reqMethod string + + var reqDir *RequestCacheDirectives = nil + respDir, err := ParseResponseCacheControl(respHeaders.Get("Cache-Control")) + if err != nil { + return nil, time.Time{}, nil, nil, err + } + + if req != nil { + reqDir, err = ParseRequestCacheControl(req.Header.Get("Cache-Control")) + if err != nil { + return nil, time.Time{}, nil, nil, err + } + reqHeaders = req.Header + reqMethod = req.Method + } + + var expiresHeader time.Time + var dateHeader time.Time + var lastModifiedHeader time.Time + + if respHeaders.Get("Expires") != "" { + expiresHeader, err = http.ParseTime(respHeaders.Get("Expires")) + if err != nil { + // sometimes servers will return `Expires: 0` or `Expires: -1` to + // indicate expired content + expiresHeader = time.Time{} + } + expiresHeader = expiresHeader.UTC() + } + + if respHeaders.Get("Date") != "" { + dateHeader, err = http.ParseTime(respHeaders.Get("Date")) + if err != nil { + return nil, time.Time{}, nil, nil, err + } + dateHeader = dateHeader.UTC() + } + + if respHeaders.Get("Last-Modified") != "" { + lastModifiedHeader, err = http.ParseTime(respHeaders.Get("Last-Modified")) + if err != nil { + return nil, time.Time{}, nil, nil, err + } + lastModifiedHeader = lastModifiedHeader.UTC() + } + + obj := Object{ + CacheIsPrivate: privateCache, + + RespDirectives: respDir, + RespHeaders: respHeaders, + RespStatusCode: statusCode, + RespExpiresHeader: expiresHeader, + RespDateHeader: dateHeader, + RespLastModifiedHeader: lastModifiedHeader, + + ReqDirectives: reqDir, + ReqHeaders: reqHeaders, + ReqMethod: reqMethod, + + NowUTC: time.Now().UTC(), + } + rv := ObjectResults{} + + CachableObject(&obj, &rv) + if rv.OutErr != nil { + return nil, time.Time{}, nil, nil, rv.OutErr + } + + ExpirationObject(&obj, &rv) + if rv.OutErr != nil { + return nil, time.Time{}, nil, nil, rv.OutErr + } + + return rv.OutReasons, rv.OutExpirationTime, rv.OutWarnings, &obj, nil +} + +// calculate if a freshness directive is present: http://tools.ietf.org/html/rfc7234#section-4.2.1 +func hasFreshness(reqDir *RequestCacheDirectives, respDir *ResponseCacheDirectives, respHeaders http.Header, respExpires time.Time, privateCache bool) bool { + if !privateCache && respDir.SMaxAge != -1 { + return true + } + + if respDir.MaxAge != -1 { + return true + } + + if !respExpires.IsZero() || respHeaders.Get("Expires") != "" { + return true + } + + return false +} + +func cachableStatusCode(statusCode int) bool { + /* + Responses with status codes that are defined as cacheable by default + (e.g., 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, and 501 in + this specification) can be reused by a cache with heuristic + expiration unless otherwise indicated by the method definition or + explicit cache controls [RFC7234]; all other status codes are not + cacheable by default. + */ + switch statusCode { + case 200: + return true + case 203: + return true + case 204: + return true + case 206: + return true + case 300: + return true + case 301: + return true + case 404: + return true + case 405: + return true + case 410: + return true + case 414: + return true + case 501: + return true + default: + return false + } +} diff --git a/control/cacheheader/warning.go b/control/cacheheader/warning.go new file mode 100644 index 0000000..523d7d8 --- /dev/null +++ b/control/cacheheader/warning.go @@ -0,0 +1,90 @@ +package cacheheader + +import ( + "fmt" + "net/http" + "time" +) + +// Repersents an HTTP Warning: http://tools.ietf.org/html/rfc7234#section-5.5 +type Warning int + +const ( + // Response is Stale + // A cache SHOULD generate this whenever the sent response is stale. + WarningResponseIsStale Warning = 110 + + // Revalidation Failed + // A cache SHOULD generate this when sending a stale + // response because an attempt to validate the response failed, due to an + // inability to reach the server. + WarningRevalidationFailed Warning = 111 + + // Disconnected Operation + // A cache SHOULD generate this if it is intentionally disconnected from + // the rest of the network for a period of time. + WarningDisconnectedOperation Warning = 112 + + // Heuristic Expiration + // + // A cache SHOULD generate this if it heuristically chose a freshness + // lifetime greater than 24 hours and the response's age is greater than + // 24 hours. + WarningHeuristicExpiration Warning = 113 + + // Miscellaneous Warning + // + // The warning text can include arbitrary information to be presented to + // a human user or logged. A system receiving this warning MUST NOT + // take any automated action, besides presenting the warning to the + // user. + WarningMiscellaneousWarning Warning = 199 + + // Transformation Applied + // + // This Warning code MUST be added by a proxy if it applies any + // transformation to the representation, such as changing the + // content-coding, media-type, or modifying the representation data, + // unless this Warning code already appears in the response. + WarningTransformationApplied Warning = 214 + + // Miscellaneous Persistent Warning + // + // The warning text can include arbitrary information to be presented to + // a human user or logged. A system receiving this warning MUST NOT + // take any automated action. + WarningMiscellaneousPersistentWarning Warning = 299 +) + +func (w Warning) HeaderString(agent string, date time.Time) string { + if agent == "" { + agent = "-" + } else { + // TODO(pquerna): this doesn't escape agent if it contains bad things. + agent = `"` + agent + `"` + } + return fmt.Sprintf(`%d %s "%s" %s`, w, agent, w.String(), date.Format(http.TimeFormat)) +} + +func (w Warning) String() string { + switch w { + case WarningResponseIsStale: + return "Response is Stale" + case WarningRevalidationFailed: + return "Revalidation Failed" + case WarningDisconnectedOperation: + return "Disconnected Operation" + case WarningHeuristicExpiration: + return "Heuristic Expiration" + case WarningMiscellaneousWarning: + // TODO(pquerna): ideally had a better way to override this one code. + return "Miscellaneous Warning" + case WarningTransformationApplied: + return "Transformation Applied" + case WarningMiscellaneousPersistentWarning: + // TODO(pquerna): same as WarningMiscellaneousWarning + return "Miscellaneous Persistent Warning" + } + + panic(w) +} diff --git a/go.mod b/go.mod index 61d4c76..16ff647 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/bxcodec/hache go 1.13 -require github.com/bxcodec/gotcha v1.0.0-beta.2 +require ( + github.com/bxcodec/gotcha v1.0.0-beta.2 + github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 +) diff --git a/go.sum b/go.sum index b2f2f26..ee15858 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/bxcodec/gotcha v1.0.0-beta.2 h1:0jY/Mx6O5jzM2fkcz84zzyy67hLu/bKGJEFTtcRmw5I= github.com/bxcodec/gotcha v1.0.0-beta.2/go.mod h1:MEL9PRYL9Squu1zxreMIzJU6xtMouPmQybWEtXrL1nk= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= +github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= diff --git a/roundtriper.go b/roundtriper.go index b8e1b93..dfe416c 100644 --- a/roundtriper.go +++ b/roundtriper.go @@ -11,6 +11,7 @@ import ( "time" "github.com/bxcodec/hache/cache" + cacheControl "github.com/bxcodec/hache/control/cacheheader" ) // Headers @@ -38,28 +39,98 @@ func NewRoundtrip(defaultRoundTripper http.RoundTripper, cacheActor cache.Intera } } +func validateTheCacheControl(req *http.Request, resp *http.Response) (validationResult cacheControl.ObjectResults, err error) { + reqDir, err := cacheControl.ParseRequestCacheControl(req.Header.Get("Cache-Control")) + if err != nil { + return + } + + resDir, err := cacheControl.ParseResponseCacheControl(resp.Header.Get("Cache-Control")) + if err != nil { + return + } + + expiry := resp.Header.Get("Expires") + expiresHeader, err := http.ParseTime(expiry) + if err != nil && expiry != "" && + // https://stackoverflow.com/questions/11357430/http-expires-header-values-0-and-1 + expiry != "-1" && expiry != "0" { + return + } + + dateHeaderStr := resp.Header.Get("Date") + dateHeader, err := http.ParseTime(dateHeaderStr) + if err != nil && dateHeaderStr != "" { + return + } + + lastModifiedStr := resp.Header.Get("Last-Modified") + lastModifiedHeader, err := http.ParseTime(lastModifiedStr) + if err != nil && lastModifiedStr != "" { + return + } + + obj := cacheControl.Object{ + RespDirectives: resDir, + RespHeaders: resp.Header, + RespStatusCode: resp.StatusCode, + RespExpiresHeader: expiresHeader, + RespDateHeader: dateHeader, + RespLastModifiedHeader: lastModifiedHeader, + ReqDirectives: reqDir, + ReqHeaders: req.Header, + ReqMethod: req.Method, + NowUTC: time.Now().UTC(), + } + + validationResult = cacheControl.ObjectResults{} + cacheControl.CachableObject(&obj, &validationResult) + cacheControl.ExpirationObject(&obj, &validationResult) + + return validationResult, nil +} + // RoundTrip the implementation of http.RoundTripper func (r *RoundTrip) RoundTrip(req *http.Request) (resp *http.Response, err error) { - if allowedFromCache(req) { + if allowedFromCache(req.Header) { resp, cachedItem, err := getCachedResponse(r.CacheInteractor, req) if resp != nil && err == nil { buildTheCachedResponseHeader(resp, cachedItem) return resp, err } } + err = nil resp, err = r.DefaultRoundTripper.RoundTrip(req) if err != nil { return } - if !allowedToCache(req, resp) { + // Only cache the response of with Success Status + if resp.StatusCode >= http.StatusMultipleChoices || + resp.StatusCode < http.StatusOK || + resp.StatusCode == http.StatusNoContent { + return + } + + validationResult, err := validateTheCacheControl(req, resp) + if err != nil { + return + } + + if validationResult.OutErr != nil { + return + } + + // reasons to not to cache + if len(validationResult.OutReasons) > 0 { return } err = storeRespToCache(r.CacheInteractor, req, resp) if err != nil { - log.Println(err) + log.Printf("Can't store the response to database, plase check. Err: %v\n", err) + err = nil // set err back to nil to make the call still success. } return @@ -77,7 +148,8 @@ func storeRespToCache(cacheInteractor cache.Interactor, req *http.Request, resp return } cachedResp.DumpedResponse = dumpedResponse - err = cacheInteractor.Set(getCacheKey(req), cachedResp) + + err = cacheInteractor.Set(getCacheKey(req), cachedResp, 0) return } @@ -93,6 +165,20 @@ func getCachedResponse(cacheInteractor cache.Interactor, req *http.Request) (res return } + validationResult, err := validateTheCacheControl(req, resp) + if err != nil { + return + } + + if validationResult.OutErr != nil { + return + } + + if time.Now().After(validationResult.OutExpirationTime) { + err = fmt.Errorf("cached-item already expired") + return + } + return } @@ -112,41 +198,32 @@ func buildTheCachedResponseHeader(resp *http.Response, cachedResp cache.CachedRe // TODO: (bxcodec) add more headers related to cache } -// check the header if the response will cached or not -func allowedToCache(req *http.Request, resp *http.Response) (ok bool) { +func allowedToCache(header http.Header, method string) (ok bool) { // A request with authorization header must not be cached // https://tools.ietf.org/html/rfc7234#section-3.2 // Unless configured by user to cache request by authorization - if ok = (!CacheAuthorizedRequest && req.Header.Get(HeaderAuthorization) == ""); !ok { + if ok = (!CacheAuthorizedRequest && header.Get(HeaderAuthorization) == ""); !ok { return } // check if the request method allowed to be cached - if ok = requestMethodValid(req); !ok { + if strings.ToLower(method) != "get" { return } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Preventing_caching - if ok = strings.ToLower(req.Header.Get(HeaderCacheControl)) != "no-store"; !ok { + if ok = strings.ToLower(header.Get(HeaderCacheControl)) != "no-store"; !ok { return } - if ok = strings.ToLower(resp.Header.Get(HeaderCacheControl)) != "no-store"; !ok { + if ok = strings.ToLower(header.Get(HeaderCacheControl)) != "no-store"; !ok { return } - // Only cache the response of with code 200 - if ok = resp.StatusCode == http.StatusOK; !ok { - return - } - return + return true } -func allowedFromCache(req *http.Request) (ok bool) { +func allowedFromCache(header http.Header) (ok bool) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Cacheability - return !strings.Contains(strings.ToLower(req.Header.Get(HeaderCacheControl)), "no-cache") || - !strings.Contains(strings.ToLower(req.Header.Get(HeaderCacheControl)), "no-store") -} - -func requestMethodValid(req *http.Request) bool { - return strings.ToLower(req.Method) == "get" + return !strings.Contains(strings.ToLower(header.Get(HeaderCacheControl)), "no-cache") || + !strings.Contains(strings.ToLower(header.Get(HeaderCacheControl)), "no-store") } diff --git a/sample/inmem/main.go b/sample/inmem/main.go index 9824ceb..e7895b5 100644 --- a/sample/inmem/main.go +++ b/sample/inmem/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io/ioutil" "log" "net/http" "time" @@ -18,7 +19,7 @@ func main() { for i := 0; i < 10; i++ { startTime := time.Now() - req, err := http.NewRequest("GET", "https://bxcodec.io", nil) + req, err := http.NewRequest("GET", "https://google.com", nil) if err != nil { log.Fatal((err)) } @@ -26,7 +27,17 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Printf("Response time: %vms\n", time.Since(startTime).Microseconds()) + fmt.Printf("Response time: %v micro-second\n", time.Since(startTime).Microseconds()) fmt.Println("Status Code", res.StatusCode) + fmt.Println("Header", res.Header) + // printBody(res) } } + +func printBody(resp *http.Response) { + jbyt, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + fmt.Printf("ResponseBody: \t%s\n", string(jbyt)) +}