Repository: grafana/grafana-plugin-sdk-go (go)

Issue:
Allow to set the same header multiple time in Prometheus Datascources
**What would you like to be added**:

We use [prom-label-proxy](https://github.com/prometheus-community/prom-label-proxy) to setup some multi-tenant Prometheus. Launched with:

```shell
prom-label-proxy \
  --insecure-listen-address=0.0.0.0:8080 \
  --upstream=http://prometheus-stack-kube-prom-prometheus.prometheus-stack:9090/ \
  --label=namespace \
  --internal-listen-address=0.0.0.0:9090 \
  --enable-label-apis=true \
  --error-on-replace=true \
  --header-name=X-Namespace \
```

We have a tenant which needs access to 2 namespaces `ns1` and `ns2`.

This can be done by setting the header twice:

```
X-Namespace: ns1
X-Namespace: ns2
```

But this doesn't work when set from UI:

![image](https://github.com/grafana/grafana-plugin-sdk-go/assets/741106/6f607952-5f1e-4e2b-bda9-db2643c2cea1)

**Why is this needed**:

See above

**Code references**:

We need to change `.Set` to `.Add` in:
https://github.com/grafana/grafana-plugin-sdk-go/blob/fdcf6a0555f0a34b06b2f213f29c782996ff668b/backend/httpclient/custom_headers_middleware.go#L20-L22

But `opts.Headers` type should be changed to. A new middleware is probably needed.

Pull request:
feat(httpclient): support appending the same header multiple times
<!--

Thank you for sending a pull request! Here are some tips:

1. If this is your first time, please read our contribution guide at https://github.com/grafana/grafana-plugin-sdk-go/blob/master/CONTRIBUTING.md

2. Ensure you include and run the appropriate tests as part of your Pull Request.

3. If the Pull Request is a work in progress, make use of GitHub's "Draft PR" feature and mark it as such.

4. If you can not merge your Pull Request due to a merge conflict, Rebase it. This gets it in sync with the master branch.

-->

**What this PR does / why we need it**:

See https://github.com/grafana/grafana-plugin-sdk-go/issues/829


**Which issue(s) this PR fixes**:

<!--

* Automatically closes linked issue when the Pull Request is merged.

Usage: "Fixes #<issue number>", or "Fixes (paste link of issue)"

-->

Fixes #829

**Special notes for your reviewer**:


Patch:
diff --git a/backend/http_settings.go b/backend/http_settings.go
index 7720f6c..5fa844c 100644
--- a/backend/http_settings.go
+++ b/backend/http_settings.go
@@ -3,6 +3,7 @@ package backend
 import (
 	"encoding/json"
 	"fmt"
+	"net/http"
 	"time"
 
 	"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
@@ -16,7 +17,7 @@ type HTTPSettings struct {
 	BasicAuthEnabled  bool
 	BasicAuthUser     string
 	BasicAuthPassword string
-	Headers           map[string]string
+	Header            http.Header
 
 	Timeout               time.Duration
 	DialTimeout           time.Duration
@@ -52,7 +53,7 @@ type HTTPSettings struct {
 // HTTPClientOptions creates and returns httpclient.Options.
 func (s *HTTPSettings) HTTPClientOptions() httpclient.Options {
 	opts := httpclient.Options{
-		Headers:       s.Headers,
+		Header:        s.Header,
 		Labels:        map[string]string{},
 		CustomOptions: map[string]interface{}{},
 	}
@@ -104,7 +105,7 @@ func (s *HTTPSettings) HTTPClientOptions() httpclient.Options {
 //gocyclo:ignore
 func parseHTTPSettings(jsonData json.RawMessage, secureJSONData map[string]string) (*HTTPSettings, error) {
 	s := &HTTPSettings{
-		Headers: map[string]string{},
+		Header: http.Header{},
 	}
 
 	var dat map[string]interface{}
@@ -273,7 +274,7 @@ func parseHTTPSettings(jsonData json.RawMessage, secureJSONData map[string]strin
 
 		if key, exists := dat[headerNameSuffix]; exists {
 			if value, exists := secureJSONData[headerValueSuffix]; exists {
-				s.Headers[key.(string)] = value
+				s.Header.Add(key.(string), value)
 			}
 		} else {
 			// No (more) header values are available
diff --git a/backend/httpclient/custom_headers_middleware.go b/backend/httpclient/custom_headers_middleware.go
index 27771a2..85d0c4d 100644
--- a/backend/httpclient/custom_headers_middleware.go
+++ b/backend/httpclient/custom_headers_middleware.go
@@ -12,17 +12,19 @@ const CustomHeadersMiddlewareName = "CustomHeaders"
 // If opts.Headers is empty, next will be returned.
 func CustomHeadersMiddleware() Middleware {
 	return NamedMiddlewareFunc(CustomHeadersMiddlewareName, func(opts Options, next http.RoundTripper) http.RoundTripper {
-		if len(opts.Headers) == 0 {
+		if len(opts.Header) == 0 {
 			return next
 		}
 
 		return RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
-			for key, value := range opts.Headers {
+			for key, values := range opts.Header {
 				// According to https://pkg.go.dev/net/http#Request.Header, Host is a special case
 				if http.CanonicalHeaderKey(key) == "Host" {
-					req.Host = value
+					req.Host = values[0]
 				} else {
-					req.Header.Set(key, value)
+					for _, value := range values {
+						req.Header.Add(key, value)
+					}
 				}
 			}
 
diff --git a/backend/httpclient/options.go b/backend/httpclient/options.go
index 0c494dd..5ec9952 100644
--- a/backend/httpclient/options.go
+++ b/backend/httpclient/options.go
@@ -38,7 +38,7 @@ type Options struct {
 	ProxyOptions *proxy.Options
 
 	// Headers custom headers.
-	Headers map[string]string
+	Header http.Header
 
 	// CustomOptions allows custom options to be provided.
 	CustomOptions map[string]interface{}
diff --git a/build/common.go b/build/common.go
index bf967a5..5e2a0a6 100644
--- a/build/common.go
+++ b/build/common.go
@@ -294,7 +294,7 @@ func ensureWatchConfig() error {
 	return os.WriteFile(".bra.toml", config, 0600)
 }
 
-// Watch rebuilds the plugin backend when files change.
+// Watch rebuilds the plugin backend debug version when files change.
 func Watch() error {
 	if err := ensureWatchConfig(); err != nil {
 		return err
diff --git a/build/tmpl/bra.toml b/build/tmpl/bra.toml
index c6b4191..89989fa 100644
--- a/build/tmpl/bra.toml
+++ b/build/tmpl/bra.toml
@@ -3,7 +3,7 @@
 # see https://github.com/unknwon/bra/blob/master/templates/default.bra.toml for more configuration options.
 [run]
 init_cmds = [
-  ["mage", "-v", "build:backend"],
+  ["mage", "-v", "build:debug"],
   ["mage", "-v" , "reloadPlugin"]
 ]
 watch_all = true
@@ -17,6 +17,6 @@ watch_dirs = [
 watch_exts = [".go", ".json"]
 build_delay = 2000
 cmds = [
-  ["mage", "-v", "build:backend"],
+  ["mage", "-v", "build:debug"],
   ["mage", "-v" , "reloadPlugin"]
-]
\ No newline at end of file
+]
diff --git a/data/converters/converters.go b/data/converters/converters.go
index 2ba9674..bb0bd6e 100644
--- a/data/converters/converters.go
+++ b/data/converters/converters.go
@@ -2,6 +2,7 @@
 package converters
 
 import (
+	"encoding/json"
 	"fmt"
 	"strconv"
 	"time"
@@ -229,6 +230,10 @@ var JSONValueToFloat64 = data.FieldConverter{
 			fV = float64(iiV)
 			return fV, nil
 		}
+		nn, ok := v.(json.Number)
+		if ok {
+			return nn.Float64()
+		}
 		sV, ok := v.(string)
 		if ok {
 			return strconv.ParseFloat(sV, 64)
@@ -254,6 +259,14 @@ var JSONValueToInt64 = data.FieldConverter{
 		if ok {
 			return int64(fV), nil
 		}
+		ii, ok := v.(int)
+		if ok {
+			return int64(ii), nil
+		}
+		nn, ok := v.(json.Number)
+		if ok {
+			return nn.Int64()
+		}
 		return nil, toConversionError("float64", v)
 	},
 }
diff --git a/experimental/apis/data/v0alpha1/client.go b/experimental/apis/data/v0alpha1/client.go
index b84ae19..37e0124 100644
--- a/experimental/apis/data/v0alpha1/client.go
+++ b/experimental/apis/data/v0alpha1/client.go
@@ -46,16 +46,18 @@ func (c *simpleHTTPClient) QueryData(ctx context.Context, query QueryDataRequest
 	}
 	req.Header.Set("Content-Type", "application/json")
 
-	rsp, err := c.client.Do(req)
-	if err != nil {
-		return rsp.StatusCode, nil, err
-	}
-	defer rsp.Body.Close()
-
 	qdr := &backend.QueryDataResponse{}
-	iter, err := jsoniter.Parse(jsoniter.ConfigCompatibleWithStandardLibrary, rsp.Body, 1024*10)
-	if err == nil {
-		err = iter.ReadVal(qdr)
+	code := http.StatusNotFound // 404 for network requests etc
+	rsp, err := c.client.Do(req)
+	if rsp != nil {
+		code = rsp.StatusCode
+		defer rsp.Body.Close()
+		if err == nil {
+			iter, e2 := jsoniter.Parse(jsoniter.ConfigCompatibleWithStandardLibrary, rsp.Body, 1024*10)
+			if e2 == nil {
+				err = iter.ReadVal(qdr)
+			}
+		}
 	}
-	return rsp.StatusCode, qdr, err
+	return code, qdr, err
 }
diff --git a/experimental/apis/data/v0alpha1/query.go b/experimental/apis/data/v0alpha1/query.go
index a382f0d..f079d0e 100644
--- a/experimental/apis/data/v0alpha1/query.go
+++ b/experimental/apis/data/v0alpha1/query.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"unsafe"
 
+	"github.com/grafana/grafana-plugin-sdk-go/backend"
 	"github.com/grafana/grafana-plugin-sdk-go/data"
 	"github.com/grafana/grafana-plugin-sdk-go/data/converters"
 	"github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
@@ -51,35 +52,59 @@ func (g *DataQuery) Set(key string, val any) *DataQuery {
 	case "refId":
 		g.RefID, _ = val.(string)
 	case "resultAssertions":
+		v, ok := val.(ResultAssertions)
+		if ok {
+			g.ResultAssertions = &v
+			return g
+		}
 		body, err := json.Marshal(val)
-		if err != nil {
-			_ = json.Unmarshal(body, &g.ResultAssertions)
+		if err == nil {
+			err = json.Unmarshal(body, &g.ResultAssertions)
+			if err != nil {
+				backend.Logger.Warn("error reading resultAssertions from value. %w", err)
+			}
 		}
 	case "timeRange":
+		v, ok := val.(TimeRange)
+		if ok {
+			g.TimeRange = &v
+			return g
+		}
 		body, err := json.Marshal(val)
-		if err != nil {
-			_ = json.Unmarshal(body, &g.TimeRange)
+		if err == nil {
+			err = json.Unmarshal(body, &g.TimeRange)
+			if err != nil {
+				backend.Logger.Warn("error reading timeRange from value. %w", err)
+			}
 		}
 	case "datasource":
+		v, ok := val.(DataSourceRef)
+		if ok {
+			g.Datasource = &v
+			return g
+		}
 		body, err := json.Marshal(val)
-		if err != nil {
-			_ = json.Unmarshal(body, &g.Datasource)
+		if err == nil {
+			err = json.Unmarshal(body, &g.Datasource)
+			if err != nil {
+				backend.Logger.Warn("error reading datasource from value. %w", err)
+			}
 		}
 	case "datasourceId":
 		v, err := converters.JSONValueToInt64.Converter(val)
-		if err != nil {
+		if err == nil {
 			g.DatasourceID, _ = v.(int64)
 		}
 	case "queryType":
 		g.QueryType, _ = val.(string)
 	case "maxDataPoints":
 		v, err := converters.JSONValueToInt64.Converter(val)
-		if err != nil {
+		if err == nil {
 			g.MaxDataPoints, _ = v.(int64)
 		}
 	case "intervalMs":
 		v, err := converters.JSONValueToFloat64.Converter(val)
-		if err != nil {
+		if err == nil {
 			g.IntervalMS, _ = v.(float64)
 		}
 	case "hide":


Test patch:
diff --git a/backend/http_settings_test.go b/backend/http_settings_test.go
index e5835d3..b49bd43 100644
--- a/backend/http_settings_test.go
+++ b/backend/http_settings_test.go
@@ -2,6 +2,7 @@ package backend
 
 import (
 	"encoding/json"
+	"net/http"
 	"testing"
 	"time"
 
@@ -44,7 +45,8 @@ func TestParseHTTPSettings(t *testing.T) {
 			"sigV4ExternalId": "ext123",
 			"sigV4Profile": "ghi",
 			"httpHeaderName1": "X-HeaderOne",
-			"httpHeaderName2": "X-HeaderTwo"
+			"httpHeaderName2": "X-HeaderTwo",
+			"httpHeaderName3": "X-HeaderTwo"
 		}`
 		secureData := map[string]string{
 			"basicAuthPassword": "pwd",
@@ -55,6 +57,7 @@ func TestParseHTTPSettings(t *testing.T) {
 			"sigV4SecretKey":    "sigV4SecretKey5",
 			"httpHeaderValue1":  "SecretOne",
 			"httpHeaderValue2":  "SecretTwo",
+			"httpHeaderValue3":  "SecretThree",
 		}
 		var jsonMap map[string]interface{}
 		err := json.Unmarshal([]byte(jsonStr), &jsonMap)
@@ -68,9 +71,9 @@ func TestParseHTTPSettings(t *testing.T) {
 			BasicAuthEnabled:  true,
 			BasicAuthUser:     "user",
 			BasicAuthPassword: "pwd",
-			Headers: map[string]string{
-				"X-HeaderOne": "SecretOne",
-				"X-HeaderTwo": "SecretTwo",
+			Header: http.Header{
+				"X-Headerone": {"SecretOne"},
+				"X-Headertwo": {"SecretTwo", "SecretThree"},
 			},
 			Timeout:               10 * time.Second,
 			DialTimeout:           10 * time.Second,
@@ -108,9 +111,9 @@ func TestParseHTTPSettings(t *testing.T) {
 					User:     "user",
 					Password: "pwd",
 				},
-				Headers: map[string]string{
-					"X-HeaderOne": "SecretOne",
-					"X-HeaderTwo": "SecretTwo",
+				Header: http.Header{
+					"X-Headerone": {"SecretOne"},
+					"X-Headertwo": {"SecretTwo", "SecretThree"},
 				},
 				Timeouts: &httpclient.TimeoutOptions{
 					Timeout:               10 * time.Second,
diff --git a/backend/httpclient/custom_headers_middleware_test.go b/backend/httpclient/custom_headers_middleware_test.go
index 14b94bb..ac7e18e 100644
--- a/backend/httpclient/custom_headers_middleware_test.go
+++ b/backend/httpclient/custom_headers_middleware_test.go
@@ -34,10 +34,10 @@ func TestCustomHeadersMiddleware(t *testing.T) {
 		ctx := &testContext{}
 		finalRoundTripper := ctx.createRoundTripper("final")
 		customHeaders := CustomHeadersMiddleware()
-		rt := customHeaders.CreateMiddleware(Options{Headers: map[string]string{
-			"X-HeaderOne":   "ValueOne",
-			"X-HeaderTwo":   "ValueTwo",
-			"X-HeaderThree": "ValueThree",
+		rt := customHeaders.CreateMiddleware(Options{Header: http.Header{
+			"X-Headerone":   {"ValueOne"},
+			"X-Headertwo":   {"ValueTwo"},
+			"X-Headerthree": {"ValueThree", "ValueThreeAgain"},
 		}}, finalRoundTripper)
 		require.NotNil(t, rt)
 		middlewareName, ok := customHeaders.(MiddlewareName)
@@ -55,17 +55,17 @@ func TestCustomHeadersMiddleware(t *testing.T) {
 		require.Len(t, ctx.callChain, 1)
 		require.ElementsMatch(t, []string{"final"}, ctx.callChain)
 
-		require.Equal(t, "ValueOne", req.Header.Get("X-HeaderOne"))
-		require.Equal(t, "ValueTwo", req.Header.Get("X-HeaderTwo"))
-		require.Equal(t, "ValueThree", req.Header.Get("X-HeaderThree"))
+		require.Equal(t, []string{"ValueOne"}, req.Header.Values("X-Headerone"))
+		require.Equal(t, []string{"ValueTwo"}, req.Header.Values("X-Headertwo"))
+		require.Equal(t, []string{"ValueThree", "ValueThreeAgain"}, req.Header.Values("X-Headerthree"))
 	})
 
 	t.Run("With custom Host header set should apply Host to the request", func(t *testing.T) {
 		ctx := &testContext{}
 		finalRoundTripper := ctx.createRoundTripper("final")
 		customHeaders := CustomHeadersMiddleware()
-		rt := customHeaders.CreateMiddleware(Options{Headers: map[string]string{
-			"Host": "example.com",
+		rt := customHeaders.CreateMiddleware(Options{Header: http.Header{
+			"Host": {"example.com"},
 		}}, finalRoundTripper)
 		require.NotNil(t, rt)
 		middlewareName, ok := customHeaders.(MiddlewareName)
diff --git a/experimental/apis/data/v0alpha1/client_test.go b/experimental/apis/data/v0alpha1/client_test.go
index b7915b0..d604e1a 100644
--- a/experimental/apis/data/v0alpha1/client_test.go
+++ b/experimental/apis/data/v0alpha1/client_test.go
@@ -11,6 +11,13 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestClientWithBadURL(t *testing.T) {
+	client := v0alpha1.NewQueryDataClient("http://localhostXYZ:998/api/ds/query", nil, nil)
+	code, _, err := client.QueryData(context.Background(), v0alpha1.QueryDataRequest{})
+	require.Error(t, err)
+	require.Equal(t, 404, code)
+}
+
 func TestQueryClient(t *testing.T) {
 	t.Skip()
 
@@ -30,7 +37,7 @@ func TestQueryClient(t *testing.T) {
 					"uid": "PD8C576611E62080A"
 				},
 				"csvContent": "a,b,c\n1,hello,true",
-				"hide": true
+				"hide": false
 			}
 		]
 	}`
diff --git a/experimental/apis/data/v0alpha1/query_test.go b/experimental/apis/data/v0alpha1/query_test.go
index 51ba48a..fd1efd7 100644
--- a/experimental/apis/data/v0alpha1/query_test.go
+++ b/experimental/apis/data/v0alpha1/query_test.go
@@ -115,4 +115,15 @@ func TestQueryBuilders(t *testing.T) {
 	})
 	require.Equal(t, "D", testQ3.QueryType)
 	require.Equal(t, "E", testQ3.GetString("extra"))
+
+	testQ3.Set("datasource", &DataSourceRef{Type: "TYPE", UID: "UID"})
+	require.NotNil(t, testQ3.Datasource)
+	require.Equal(t, "TYPE", testQ3.Datasource.Type)
+	require.Equal(t, "UID", testQ3.Datasource.UID)
+
+	testQ3.Set("datasource", map[string]any{"uid": "XYZ"})
+	require.Equal(t, "XYZ", testQ3.Datasource.UID)
+
+	testQ3.Set("maxDataPoints", 100)
+	require.Equal(t, int64(100), testQ3.MaxDataPoints)
 }
diff --git a/experimental/authclient/authclient_test.go b/experimental/authclient/authclient_test.go
index d13b868..12bb17b 100644
--- a/experimental/authclient/authclient_test.go
+++ b/experimental/authclient/authclient_test.go
@@ -31,7 +31,7 @@ func TestNew(t *testing.T) {
 		t.Run("client credentials", func(t *testing.T) {
 			t.Run("valid client credentials", func(t *testing.T) {
 				hc, err := authclient.New(httpclient.Options{
-					Headers: map[string]string{"h1": "v1"},
+					Header: http.Header{"H1": {"v1"}},
 				}, authclient.AuthOptions{
 					AuthMethod: authclient.AuthMethodOAuth2,
 					OAuth2Options: &authclient.OAuth2Options{
@@ -54,7 +54,7 @@ func TestNew(t *testing.T) {
 			})
 			t.Run("valid client credentials with basic auth settings", func(t *testing.T) {
 				hc, err := authclient.New(httpclient.Options{
-					Headers:   map[string]string{"h1": "v1"},
+					Header:    http.Header{"H1": {"v1"}},
 					BasicAuth: &httpclient.BasicAuthOptions{User: "userFoo", Password: "pwdBar"},
 				}, authclient.AuthOptions{
 					AuthMethod: authclient.AuthMethodOAuth2,
@@ -81,7 +81,7 @@ func TestNew(t *testing.T) {
 			t.Run("invalid private key", func(t *testing.T) {
 				privateKey := generateKey(t, true)
 				hc, err := authclient.New(httpclient.Options{
-					Headers: map[string]string{"h1": "v1"},
+					Header: http.Header{"H1": {"v1"}},
 				}, authclient.AuthOptions{
 					AuthMethod: authclient.AuthMethodOAuth2,
 					OAuth2Options: &authclient.OAuth2Options{
@@ -103,7 +103,7 @@ func TestNew(t *testing.T) {
 			t.Run("valid private key", func(t *testing.T) {
 				privateKey := generateKey(t, false)
 				hc, err := authclient.New(httpclient.Options{
-					Headers: map[string]string{"h1": "v1"},
+					Header: http.Header{"H1": {"v1"}},
 				}, authclient.AuthOptions{
 					AuthMethod: authclient.AuthMethodOAuth2,
 					OAuth2Options: &authclient.OAuth2Options{
@@ -134,7 +134,7 @@ func getOAuthServer(t *testing.T) *httptest.Server {
 	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		oAuth2TokenValue := "foo"
 		t.Run("ensure custom headers propagated correctly", func(t *testing.T) {
-			require.Equal(t, "v1", r.Header.Get("h1"))
+			require.Equal(t, "v1", r.Header.Get("H1"))
 		})
 		if r.URL.String() == handlerToken {
 			w.Header().Set("Content-Type", "application/json")