Skip to content

Commit

Permalink
feat: HTTP caching according to RFC 7234 is supported by pipeline han…
Browse files Browse the repository at this point in the history
…dlers and the httpendpoint provider (#307)
  • Loading branch information
dadrus authored Nov 8, 2022
1 parent 5f44818 commit c5349c1
Show file tree
Hide file tree
Showing 15 changed files with 346 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ serve:
tls:
key: /path/to/key/file.pem
cert: /path/to/cert/file.pem
min_version: TLS1.2
cipher_suites:
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
trusted_proxies:
- 192.168.1.0/24
Expand All @@ -89,6 +95,7 @@ serve:
tls:
key: /path/to/key/file.pem
cert: /path/to/cert/file.pem
min_version: TLS1.2
log:
level: debug
Expand Down Expand Up @@ -174,6 +181,7 @@ pipeline:
jwks_endpoint:
url: http://foo/token
method: GET
enable_http_cache: true
jwt_source:
- header: Authorization
schema: Bearer
Expand Down Expand Up @@ -289,6 +297,7 @@ rules:
endpoints:
- url: http://foo.bar/ruleset1
expected_path_prefix: /foo/bar
enable_http_cache: false
- url: http://foo.bar/ruleset2
retry:
give_up_after: 5s
Expand All @@ -301,5 +310,16 @@ rules:
in: header
header:
X-Customer-Header: Some Value
cloud_blob:
watch_interval: 1m
buckets:
- url: gs://my-bucket
prefix: service1
rule_path_match_prefix: /service1
- url: azblob://my-bucket
prefix: service2
rule_path_match_prefix: /service2
- url: s3://my-bucket/my-rule-set
----

Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ HTTP headers to be sent to the endpoint.
+
CAUTION: These headers are not analyzed by heimdall and are just forwarded to the endpoint. E.g. if you configure the `Content-Encoding` to something like `gzip`, the service behind the used endpoint might fail to answer, as it would expect the body to be compressed.

* *`enable_http_cache`* _bool_ (optional)
+
Whether HTTP caching according to [RFC 7234](https://www.rfc-editor.org/rfc/rfc7234) should be used. Defaults to `false` if not otherwise stated in the description of the configuration type, making use of the `endpoint` property. If set to `true` heimdall will strictly follow the requirements from RFC 7234 and cache the responses if possible and reuse these if still valid.

NOTE: If the endpoint referenced by the URL does not provide any explicit expiration time, no heuristic freshness lifetime is calculated. Heimdall treats such responses as not cacheable.

.Endpoint configuration
====
Expand All @@ -310,6 +316,7 @@ auth:
headers:
X-My-First-Header: foobar
X-My-Second-Header: barfoo
enable_http_cache: true
----
====
Expand Down
6 changes: 4 additions & 2 deletions docs/content/docs/configuration/rules/providers.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The loading and removal of rules happens as follows:
* in case of network issues, like dns errors, timeouts and alike, the rule sets previously received from the corresponding endpoints are preserved.
* in any other case related to network communication (e.g. not 200 status code, empty response body, unsupported format, network issues, etc.), the corresponding rules are removed if these were previously loaded.

The configuration of this provider goes into the `http_endpoint` property. In contrast to the link:{{< relref "#_filesystem" >}}[Filesystem] provider it can be configured with as many endpoints to load rules from as required for the particular use case.
The configuration of this provider goes into the `http_endpoint` property. In contrast to the link:{{< relref "#_filesystem" >}}[Filesystem] provider it can be configured with as many endpoints to load rule sets from as required for the particular use case.

Following configuration options are supported:

Expand All @@ -68,12 +68,14 @@ Whether the configured `endpoints` should be polled for updates. Defaults to `0s

* *`endpoints`*: _RuleSetEndpoint array_ (mandatory)
+
Each entry of that array supports all the properties defined by link:{{< relref "/docs/configuration/reference/configuration_types.adoc#_endpoint" >}}[Endpoint], except `method`, which is always `GET`. As with the link:{{< relref "/docs/configuration/reference/configuration_types.adoc#_endpoint" >}}[Endpoint] type, at least the `url` must be defined. Following properties are defined in addition:
Each entry of that array supports all the properties defined by link:{{< relref "/docs/configuration/reference/configuration_types.adoc#_endpoint" >}}[Endpoint], except `method`, which is always `GET`. enable_http_cacheAs with the link:{{< relref "/docs/configuration/reference/configuration_types.adoc#_endpoint" >}}[Endpoint] type, at least the `url` must be configured. Following properties are defined in addition:
+
** *`rule_path_match_prefix`*: _string_ (optional)
+
This property can be used to create kind of a namespace for the rule sets retrieved from the different endpoints. If set, the provider checks whether the urls specified in all rules retrieved from the referenced endpoint have the defined path prefix. If not, a warning is emitted and the rule set is ignored. This can be used to ensure a rule retrieved from one endpoint does not collide with a rule from another endpoint.

NOTE: HTTP caching according to [RFC 7234](https://www.rfc-editor.org/rfc/rfc7234) is enabled by default. It can be disabled by setting `enable_http_cache` to `false`.

This provider doesn't need any additional configuration for a rule set. So the contents of files can be just a list of rules as described in link:{{< relref "rule_configuration.adoc#_rule_set" >}}[Rule Sets].

.Minimal possible configuration
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require (
github.com/knadh/koanf v1.4.4
github.com/mitchellh/mapstructure v1.5.0
github.com/ory/ladon v1.2.0
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021
github.com/rs/zerolog v1.28.0
github.com/santhosh-tekuri/jsonschema/v5 v5.0.2
github.com/spf13/cobra v1.6.1
Expand Down
5 changes: 1 addition & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,6 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNE
github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20221025165401-cb5011b539fe h1:eJUWWwcoq0m/pD0HdB2TwMixgnoWiey69IfYIpIb3YA=
github.com/dop251/goja v0.0.0-20221025165401-cb5011b539fe/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
github.com/dop251/goja v0.0.0-20221106173738-3b8a68ca89b4 h1:arM6Tq1Ba+a9FWuq3S6Qgrfd5MD0slQdMnCKI2VclFg=
github.com/dop251/goja v0.0.0-20221106173738-3b8a68ca89b4/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
Expand Down Expand Up @@ -1272,6 +1270,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021 h1:0XM1XL/OFFJjXsYXlG30spTkV/E9+gmd5GD1w2HE8xM=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/alertmanager v0.24.0/go.mod h1:r6fy/D7FRuZh5YbnX6J3MBY0eI4Pb5yPYS7/bPSXXqI=
Expand Down Expand Up @@ -1703,8 +1702,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 h1:QfTh0HpN6hlw6D3vu8DAwC8pBIwikq0AI1evdm+FksE=
golang.org/x/exp v0.0.0-20221031165847-c99f073a8326/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
Expand Down
2 changes: 2 additions & 0 deletions internal/config/test_data/test_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ pipeline:
jwks_endpoint:
url: http://foo/token
method: GET
enable_http_cache: true
jwt_source:
- header: Authorization
schema: Bearer
Expand Down Expand Up @@ -287,6 +288,7 @@ rules:
endpoints:
- url: http://foo.bar/rules.yaml
rule_path_match_prefix: /foo
enable_http_cache: true
- url: http://bar.foo/rules.yaml
headers:
bla: bla
Expand Down
16 changes: 11 additions & 5 deletions internal/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

"github.com/dadrus/heimdall/internal/heimdall"
"github.com/dadrus/heimdall/internal/httpcache"
"github.com/dadrus/heimdall/internal/x"
"github.com/dadrus/heimdall/internal/x/errorchain"
)

type Endpoint struct {
URL string `mapstructure:"url"`
Method string `mapstructure:"method"`
Retry *Retry `mapstructure:"retry"`
AuthStrategy AuthenticationStrategy `mapstructure:"auth"`
Headers map[string]string `mapstructure:"headers"`
URL string `mapstructure:"url"`
Method string `mapstructure:"method"`
Retry *Retry `mapstructure:"retry"`
AuthStrategy AuthenticationStrategy `mapstructure:"auth"`
Headers map[string]string `mapstructure:"headers"`
HTTPCacheEnabled *bool `mapstructure:"enable_http_cache"`
}

type Retry struct {
Expand Down Expand Up @@ -64,6 +66,10 @@ func (e Endpoint) CreateClient(peerName string) *http.Client {
httpretry.ExponentialBackoff(e.Retry.MaxDelay, e.Retry.GiveUpAfter, 0)))
}

if e.HTTPCacheEnabled != nil && *e.HTTPCacheEnabled {
client.Transport = &httpcache.RoundTripper{Transport: client.Transport}
}

return client
}

Expand Down
43 changes: 41 additions & 2 deletions internal/endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

"github.com/dadrus/heimdall/internal/heimdall"
"github.com/dadrus/heimdall/internal/httpcache"
"github.com/dadrus/heimdall/internal/x"
)

Expand Down Expand Up @@ -60,13 +61,15 @@ func TestEndpointCreateClient(t *testing.T) {

peerName := "foobar"

tBool := true

for _, tc := range []struct {
uc string
endpoint Endpoint
assert func(t *testing.T, client *http.Client)
}{
{
uc: "for endpoint without configured retry policy",
uc: "for endpoint without configured retry policy and without http cache",
endpoint: Endpoint{URL: "http://foo.bar"},
assert: func(t *testing.T, client *http.Client) {
t.Helper()
Expand All @@ -76,7 +79,20 @@ func TestEndpointCreateClient(t *testing.T) {
},
},
{
uc: "for endpoint with configured retry policy",
uc: "for endpoint without configured retry policy, but with http cache",
endpoint: Endpoint{URL: "http://foo.bar", HTTPCacheEnabled: &tBool},
assert: func(t *testing.T, client *http.Client) {
t.Helper()

cacheTransport, ok := client.Transport.(*httpcache.RoundTripper)
require.True(t, ok)

_, ok = cacheTransport.Transport.(*otelhttp.Transport)
require.True(t, ok)
},
},
{
uc: "for endpoint with configured retry policy and without http cache",
endpoint: Endpoint{
URL: "http://foo.bar",
Retry: &Retry{GiveUpAfter: 2 * time.Second, MaxDelay: 10 * time.Second},
Expand All @@ -90,6 +106,29 @@ func TestEndpointCreateClient(t *testing.T) {
assert.NotNil(t, rrt.ShouldRetry)
assert.NotNil(t, rrt.CalculateBackoff)

_, ok = rrt.Next.(*otelhttp.Transport)
require.True(t, ok)
},
},
{
uc: "for endpoint with configured retry policy and with http cache",
endpoint: Endpoint{
URL: "http://foo.bar",
Retry: &Retry{GiveUpAfter: 2 * time.Second, MaxDelay: 10 * time.Second},
HTTPCacheEnabled: &tBool,
},
assert: func(t *testing.T, client *http.Client) {
t.Helper()

cacheTransport, ok := client.Transport.(*httpcache.RoundTripper)
require.True(t, ok)

rrt, ok := cacheTransport.Transport.(*httpretry.RetryRoundtripper)
require.True(t, ok)
assert.NotZero(t, rrt.MaxRetryCount)
assert.NotNil(t, rrt.ShouldRetry)
assert.NotNil(t, rrt.CalculateBackoff)

_, ok = rrt.Next.(*otelhttp.Transport)
require.True(t, ok)
},
Expand Down
90 changes: 90 additions & 0 deletions internal/httpcache/round_tripper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package httpcache

import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httputil"
"strings"
"time"

"github.com/pquerna/cachecontrol"

"github.com/dadrus/heimdall/internal/cache"
)

var (
ErrInvalidCacheEntry = errors.New("invalid cache entry")
ErrNoCacheEntry = errors.New("no cache entry")
)

type RoundTripper struct {
Transport http.RoundTripper
}

func (rt *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rt.cachedResponse(req)
if err == nil {
return resp, nil
}

resp, err = rt.Transport.RoundTrip(req)
if err != nil {
return nil, err
}

rt.cacheResponse(req, resp)

return resp, nil
}

func (rt *RoundTripper) cachedResponse(req *http.Request) (*http.Response, error) {
cch := cache.Ctx(req.Context())

cachedValue := cch.Get(cacheKey(req))
if cachedValue == nil {
return nil, ErrNoCacheEntry
}

respDump, ok := cachedValue.([]byte)
if !ok {
return nil, ErrInvalidCacheEntry
}

return http.ReadResponse(bufio.NewReader(bytes.NewReader(respDump)), req)
}

func (rt *RoundTripper) cacheResponse(req *http.Request, resp *http.Response) {
defaultExpirationTime := time.Time{}

reasons, expires, err := cachecontrol.CachableResponse(req, resp, cachecontrol.Options{PrivateCache: true})
if err != nil || len(reasons) != 0 || expires == defaultExpirationTime {
return
}

respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return
}

cch := cache.Ctx(req.Context())
cch.Set(cacheKey(req), respDump, time.Until(expires))
}

func cacheKey(req *http.Request) string {
hash := sha256.New()

hash.Write([]byte("RFC 7234"))
hash.Write([]byte(req.URL.String()))
hash.Write([]byte(req.Method))

value := req.Header.Get("Authorization")
if len(value) != 0 {
hash.Write([]byte(strings.TrimSpace(value)))
}

return hex.EncodeToString(hash.Sum(nil))
}
Loading

0 comments on commit c5349c1

Please sign in to comment.