Skip to content

Commit

Permalink
Introduce interface to ease forward/access HTTP headers (#562)
Browse files Browse the repository at this point in the history
A new interface ForwardHTTPHeaders that allows to typeswitch/typecast to 
that interface instead and simplify the code.
From plugin authors perspective we have documentation for Forward OAuth 
identity for the logged-in user, Forward cookies for the logged-in user and 
soon for Forward user header and we provides instruction for accessing these 
forwarded headers by access the request.Headers field. This field is different 
for different request types:
- For QueryDataRequest and CheckHealthRequest there's a map[string]string 
(they're suppose to hold environment context/metadata
- For CallResourceRequest there's a map[string][]string (suppose to hold HTTP 
headers since it's HTTP proxy over gRPC basically.
With these changes, rather than accessing request.Headers directly to access 
HTTP headers it's suggested that they use request.GetHTTPHeader(<key>) 
instead, e.g. request.GetHTTPHeader(backend.OAuthIdentityTokenHeaderName) or request.GetHTTPHeader(backend.OAuthIdentityIDTokenHeaderName), to access 
Forwarded OAuth Identity headers.
Using request.GetHTTPHeader(<key>) or request.GetHTTPHeaders() would also 
automatically handle canonical/non-canonical form problems.
  • Loading branch information
marefr authored Dec 15, 2022
1 parent e7064e9 commit 1e4af2c
Show file tree
Hide file tree
Showing 8 changed files with 796 additions and 12 deletions.
49 changes: 47 additions & 2 deletions backend/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"time"

"github.com/grafana/grafana-plugin-sdk-go/data"
Expand Down Expand Up @@ -37,9 +38,50 @@ func (fn QueryDataHandlerFunc) QueryData(ctx context.Context, req *QueryDataRequ
// QueryDataRequest contains a single request which contains multiple queries.
// It is the input type for a QueryData call.
type QueryDataRequest struct {
// PluginContext the contextual information for the request.
PluginContext PluginContext
Headers map[string]string
Queries []DataQuery

// Headers the environment/metadata information for the request.
//
// To access forwarded HTTP headers please use
// GetHTTPHeaders or GetHTTPHeader.
Headers map[string]string

// Queries the data queries for the request.
Queries []DataQuery
}

// SetHTTPHeader sets the header entries associated with key to the
// single element value. It replaces any existing values
// associated with key. The key is case insensitive; it is
// canonicalized by textproto.CanonicalMIMEHeaderKey.
func (req *QueryDataRequest) SetHTTPHeader(key, value string) {
if req.Headers == nil {
req.Headers = map[string]string{}
}

setHTTPHeaderInStringMap(req.Headers, key, value)
}

// DeleteHTTPHeader deletes the values associated with key.
// The key is case insensitive; it is canonicalized by
// CanonicalHeaderKey.
func (req *QueryDataRequest) DeleteHTTPHeader(key string) {
deleteHTTPHeaderInStringMap(req.Headers, key)
}

// GetHTTPHeader gets the first value associated with the given key. If
// there are no values associated with the key, Get returns "".
// It is case insensitive; textproto.CanonicalMIMEHeaderKey is
// used to canonicalize the provided key. Get assumes that all
// keys are stored in canonical form.
func (req *QueryDataRequest) GetHTTPHeader(key string) string {
return req.GetHTTPHeaders().Get(key)
}

// GetHTTPHeaders returns HTTP headers.
func (req *QueryDataRequest) GetHTTPHeaders() http.Header {
return getHTTPHeadersFromStringMap(req.Headers)
}

// DataQuery represents a single query as sent from the frontend.
Expand Down Expand Up @@ -119,6 +161,7 @@ type DataResponse struct {
Status Status
}

// ErrDataResponse returns an error DataResponse given status and message.
func ErrDataResponse(status Status, message string) DataResponse {
return DataResponse{
Error: errors.New(message),
Expand Down Expand Up @@ -149,3 +192,5 @@ type TimeRange struct {
func (tr TimeRange) Duration() time.Duration {
return tr.To.Sub(tr.From)
}

var _ ForwardHTTPHeaders = (*QueryDataRequest)(nil)
105 changes: 105 additions & 0 deletions backend/data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package backend

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestQueryDataRequest(t *testing.T) {
req := &QueryDataRequest{}
const customHeaderName = "X-Custom"

t.Run("Legacy headers", func(t *testing.T) {
req.Headers = map[string]string{
"Authorization": "a",
"X-ID-Token": "b",
"Cookie": "c",
customHeaderName: "d",
}

t.Run("GetHTTPHeaders canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", headers.Get(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", headers.Get(CookiesHeaderName))
require.Empty(t, headers.Get(customHeaderName))
})

t.Run("GetHTTPHeader canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", req.GetHTTPHeader(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", req.GetHTTPHeader(CookiesHeaderName))
require.Empty(t, req.GetHTTPHeader(customHeaderName))
})

t.Run("DeleteHTTPHeader canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(OAuthIdentityTokenHeaderName)
req.DeleteHTTPHeader(OAuthIdentityIDTokenHeaderName)
req.DeleteHTTPHeader(CookiesHeaderName)
req.DeleteHTTPHeader(customHeaderName)
require.Empty(t, req.Headers)
})
})

t.Run("SetHTTPHeader canonical form", func(t *testing.T) {
req.SetHTTPHeader(OAuthIdentityTokenHeaderName, "a")
req.SetHTTPHeader(OAuthIdentityIDTokenHeaderName, "b")
req.SetHTTPHeader(CookiesHeaderName, "c")
req.SetHTTPHeader(customHeaderName, "d")

t.Run("GetHTTPHeaders canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", headers.Get(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", headers.Get(CookiesHeaderName))
require.Equal(t, "d", headers.Get(customHeaderName))
})

t.Run("GetHTTPHeader canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", req.GetHTTPHeader(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", req.GetHTTPHeader(CookiesHeaderName))
require.Equal(t, "d", req.GetHTTPHeader(customHeaderName))
})

t.Run("DeleteHTTPHeader canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(OAuthIdentityTokenHeaderName)
req.DeleteHTTPHeader(OAuthIdentityIDTokenHeaderName)
req.DeleteHTTPHeader(CookiesHeaderName)
req.DeleteHTTPHeader(customHeaderName)
require.Empty(t, req.Headers)
})
})

t.Run("SetHTTPHeader non-canonical form", func(t *testing.T) {
req.SetHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName), "a")
req.SetHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName), "b")
req.SetHTTPHeader(strings.ToLower(CookiesHeaderName), "c")
req.SetHTTPHeader(strings.ToLower(customHeaderName), "d")

t.Run("GetHTTPHeaders non-canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(strings.ToLower(OAuthIdentityTokenHeaderName)))
require.Equal(t, "b", headers.Get(strings.ToLower(OAuthIdentityIDTokenHeaderName)))
require.Equal(t, "c", headers.Get(strings.ToLower(CookiesHeaderName)))
require.Equal(t, "d", headers.Get(strings.ToLower(customHeaderName)))
})

t.Run("GetHTTPHeader non-canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName)))
require.Equal(t, "b", req.GetHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName)))
require.Equal(t, "c", req.GetHTTPHeader(strings.ToLower(CookiesHeaderName)))
require.Equal(t, "d", req.GetHTTPHeader(strings.ToLower(customHeaderName)))
})

t.Run("DeleteHTTPHeader non-canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName))
req.DeleteHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName))
req.DeleteHTTPHeader(strings.ToLower(CookiesHeaderName))
req.DeleteHTTPHeader(strings.ToLower(customHeaderName))
require.Empty(t, req.Headers)
})
})
}
55 changes: 52 additions & 3 deletions backend/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package backend

import (
"context"
"net/http"
"strconv"
)

Expand Down Expand Up @@ -53,14 +54,58 @@ func (hs HealthStatus) String() string {

// CheckHealthRequest contains the healthcheck request
type CheckHealthRequest struct {
// PluginContext the contextual information for the request.
PluginContext PluginContext
Headers map[string]string

// Headers the environment/metadata information for the request.
//
// To access forwarded HTTP headers please use
// GetHTTPHeaders or GetHTTPHeader.
Headers map[string]string
}

// SetHTTPHeader sets the header entries associated with key to the
// single element value. It replaces any existing values
// associated with key. The key is case insensitive; it is
// canonicalized by textproto.CanonicalMIMEHeaderKey.
func (req *CheckHealthRequest) SetHTTPHeader(key, value string) {
if req.Headers == nil {
req.Headers = map[string]string{}
}

setHTTPHeaderInStringMap(req.Headers, key, value)
}

// DeleteHTTPHeader deletes the values associated with key.
// The key is case insensitive; it is canonicalized by
// CanonicalHeaderKey.
func (req *CheckHealthRequest) DeleteHTTPHeader(key string) {
deleteHTTPHeaderInStringMap(req.Headers, key)
}

// GetHTTPHeader gets the first value associated with the given key. If
// there are no values associated with the key, Get returns "".
// It is case insensitive; textproto.CanonicalMIMEHeaderKey is
// used to canonicalize the provided key. Get assumes that all
// keys are stored in canonical form.
func (req *CheckHealthRequest) GetHTTPHeader(key string) string {
return req.GetHTTPHeaders().Get(key)
}

// GetHTTPHeaders returns HTTP headers.
func (req *CheckHealthRequest) GetHTTPHeaders() http.Header {
return getHTTPHeadersFromStringMap(req.Headers)
}

// CheckHealthResult contains the healthcheck response
type CheckHealthResult struct {
Status HealthStatus
Message string
// Status the HealthStatus of the healthcheck.
Status HealthStatus

// Message the message of the healthcheck, if any.
Message string

// JSONDetails the details of the healthcheck, if any, encoded as JSON bytes.
JSONDetails []byte
}

Expand All @@ -82,10 +127,14 @@ func (fn CollectMetricsHandlerFunc) CollectMetrics(ctx context.Context, req *Col

// CollectMetricsRequest contains the metrics request
type CollectMetricsRequest struct {
// PluginContext the contextual information for the request.
PluginContext PluginContext
}

// CollectMetricsResult collect metrics result.
type CollectMetricsResult struct {
// PrometheusMetrics the Prometheus metrics encoded as bytes.
PrometheusMetrics []byte
}

var _ ForwardHTTPHeaders = (*CheckHealthRequest)(nil)
105 changes: 105 additions & 0 deletions backend/diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package backend

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestCheckHealthRequest(t *testing.T) {
req := &CheckHealthRequest{}
const customHeaderName = "X-Custom"

t.Run("Legacy headers", func(t *testing.T) {
req.Headers = map[string]string{
"Authorization": "a",
"X-ID-Token": "b",
"Cookie": "c",
customHeaderName: "d",
}

t.Run("GetHTTPHeaders canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", headers.Get(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", headers.Get(CookiesHeaderName))
require.Empty(t, headers.Get(customHeaderName))
})

t.Run("GetHTTPHeader canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", req.GetHTTPHeader(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", req.GetHTTPHeader(CookiesHeaderName))
require.Empty(t, req.GetHTTPHeader(customHeaderName))
})

t.Run("DeleteHTTPHeader canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(OAuthIdentityTokenHeaderName)
req.DeleteHTTPHeader(OAuthIdentityIDTokenHeaderName)
req.DeleteHTTPHeader(CookiesHeaderName)
req.DeleteHTTPHeader(customHeaderName)
require.Empty(t, req.Headers)
})
})

t.Run("SetHTTPHeader canonical form", func(t *testing.T) {
req.SetHTTPHeader(OAuthIdentityTokenHeaderName, "a")
req.SetHTTPHeader(OAuthIdentityIDTokenHeaderName, "b")
req.SetHTTPHeader(CookiesHeaderName, "c")
req.SetHTTPHeader(customHeaderName, "d")

t.Run("GetHTTPHeaders canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", headers.Get(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", headers.Get(CookiesHeaderName))
require.Equal(t, "d", headers.Get(customHeaderName))
})

t.Run("GetHTTPHeader canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(OAuthIdentityTokenHeaderName))
require.Equal(t, "b", req.GetHTTPHeader(OAuthIdentityIDTokenHeaderName))
require.Equal(t, "c", req.GetHTTPHeader(CookiesHeaderName))
require.Equal(t, "d", req.GetHTTPHeader(customHeaderName))
})

t.Run("DeleteHTTPHeader canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(OAuthIdentityTokenHeaderName)
req.DeleteHTTPHeader(OAuthIdentityIDTokenHeaderName)
req.DeleteHTTPHeader(CookiesHeaderName)
req.DeleteHTTPHeader(customHeaderName)
require.Empty(t, req.Headers)
})
})

t.Run("SetHTTPHeader non-canonical form", func(t *testing.T) {
req.SetHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName), "a")
req.SetHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName), "b")
req.SetHTTPHeader(strings.ToLower(CookiesHeaderName), "c")
req.SetHTTPHeader(strings.ToLower(customHeaderName), "d")

t.Run("GetHTTPHeaders non-canonical form", func(t *testing.T) {
headers := req.GetHTTPHeaders()
require.Equal(t, "a", headers.Get(strings.ToLower(OAuthIdentityTokenHeaderName)))
require.Equal(t, "b", headers.Get(strings.ToLower(OAuthIdentityIDTokenHeaderName)))
require.Equal(t, "c", headers.Get(strings.ToLower(CookiesHeaderName)))
require.Equal(t, "d", headers.Get(strings.ToLower(customHeaderName)))
})

t.Run("GetHTTPHeader non-canonical form", func(t *testing.T) {
require.Equal(t, "a", req.GetHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName)))
require.Equal(t, "b", req.GetHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName)))
require.Equal(t, "c", req.GetHTTPHeader(strings.ToLower(CookiesHeaderName)))
require.Equal(t, "d", req.GetHTTPHeader(strings.ToLower(customHeaderName)))
})

t.Run("DeleteHTTPHeader non-canonical form", func(t *testing.T) {
req.DeleteHTTPHeader(strings.ToLower(OAuthIdentityTokenHeaderName))
req.DeleteHTTPHeader(strings.ToLower(OAuthIdentityIDTokenHeaderName))
req.DeleteHTTPHeader(strings.ToLower(CookiesHeaderName))
req.DeleteHTTPHeader(strings.ToLower(customHeaderName))
require.Empty(t, req.Headers)
})
})
}
Loading

0 comments on commit 1e4af2c

Please sign in to comment.