Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom HTTP Headers #633

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/plugin/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func (h *Clickhouse) Connect(config backend.DataSourceInstanceSettings, message
Compression: &clickhouse.Compression{
Method: compression,
},
HttpHeaders: settings.HttpHeaders,
DialTimeout: time.Duration(t) * time.Second,
ReadTimeout: time.Duration(qt) * time.Second,
Protocol: protocol,
Expand Down
42 changes: 39 additions & 3 deletions pkg/plugin/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plugin
import (
"encoding/json"
"fmt"
"github.com/ClickHouse/clickhouse-go/v2"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -34,7 +35,8 @@ type Settings struct {
DialTimeout string `json:"dialTimeout,omitempty"`
QueryTimeout string `json:"queryTimeout,omitempty"`

CustomSettings []CustomSetting `json:"customSettings"`
HttpHeaders map[string]string `json:"-"`
CustomSettings []CustomSetting `json:"customSettings"`
ProxyOptions *proxy.Options
}

Expand All @@ -43,6 +45,8 @@ type CustomSetting struct {
Value string `json:"value"`
}

const secureHeaderKeyPrefix = "secureHttpHeaders."

func (settings *Settings) isValid() (err error) {
if settings.Host == "" {
return ErrorMessageInvalidHost
Expand Down Expand Up @@ -187,8 +191,12 @@ func LoadSettings(config backend.DataSourceInstanceSettings) (settings Settings,
settings.TlsClientKey = tlsClientKey
}

// proxy options are only able to be loaded via environment variables
// currently, so we pass `nil` here so they are loaded with defaults
if settings.Protocol == clickhouse.HTTP.String() {
settings.HttpHeaders = loadHttpHeaders(jsonData, config.DecryptedSecureJSONData)
}

// proxy options are currently only able to load via environment variables,
// so we pass `nil` here so that they are loaded with defaults
proxyOpts, err := config.ProxyOptions(nil)

if err == nil && proxyOpts != nil {
Expand All @@ -203,3 +211,31 @@ func LoadSettings(config backend.DataSourceInstanceSettings) (settings Settings,

return settings, settings.isValid()
}

// loadHttpHeaders loads secure and plain text headers from the config
func loadHttpHeaders(jsonData map[string]interface{}, secureJsonData map[string]string) map[string]string {
httpHeaders := make(map[string]string)

if jsonData["httpHeaders"] != nil {
httpHeadersRaw := jsonData["httpHeaders"].([]interface{})

for _, rawHeader := range httpHeadersRaw {
header, _ := rawHeader.(map[string]interface{})
headerName, _ := header["name"].(string)
headerName = strings.TrimSpace(headerName)
headerValue, _ := header["value"].(string)
if headerName != "" && headerValue != "" {
httpHeaders[headerName] = headerValue
}
}
}

for k, v := range secureJsonData {
if v != "" && strings.HasPrefix(k, secureHeaderKeyPrefix) {
headerName := strings.TrimSpace(k[len(secureHeaderKeyPrefix):])
httpHeaders[headerName] = v
}
}

return httpHeaders
}
26 changes: 23 additions & 3 deletions pkg/plugin/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package plugin
import (
"errors"
"fmt"
"github.com/ClickHouse/clickhouse-go/v2"
"reflect"
"testing"
"time"
Expand All @@ -27,15 +28,29 @@ func TestLoadSettings(t *testing.T) {
name: "should parse and set all json fields correctly",
args: args{
config: backend.DataSourceInstanceSettings{
UID: "ds-uid",
JSONData: []byte(`{ "host": "foo", "port": 443, "path": "custom-path", "username": "baz", "defaultDatabase":"example", "tlsSkipVerify": true, "tlsAuth" : true, "tlsAuthWithCACert": true, "dialTimeout": "10", "enableSecureSocksProxy": true}`),
DecryptedSecureJSONData: map[string]string{"password": "bar", "tlsCACert": "caCert", "tlsClientCert": "clientCert", "tlsClientKey": "clientKey", "secureSocksProxyPassword": "test"},
UID: "ds-uid",
JSONData: []byte(`{
"host": "foo", "port": 443,
"path": "custom-path", "protocol": "http",
"username": "baz",
"defaultDatabase":"example", "tlsSkipVerify": true, "tlsAuth" : true,
"tlsAuthWithCACert": true, "dialTimeout": "10", "enableSecureSocksProxy": true,
"httpHeaders": [{ "name": " test-plain-1 ", "value": "value-1", "secure": false }]
}`),
DecryptedSecureJSONData: map[string]string{
"password": "bar",
"tlsCACert": "caCert", "tlsClientCert": "clientCert", "tlsClientKey": "clientKey",
"secureSocksProxyPassword": "test",
"secureHttpHeaders. test-secure-2 ": "value-2",
"secureHttpHeaders.test-secure-3": "value-3",
},
},
},
wantSettings: Settings{
Host: "foo",
Port: 443,
Path: "custom-path",
Protocol: clickhouse.HTTP.String(),
Username: "baz",
DefaultDatabase: "example",
InsecureSkipVerify: true,
Expand All @@ -47,6 +62,11 @@ func TestLoadSettings(t *testing.T) {
TlsClientKey: "clientKey",
DialTimeout: "10",
QueryTimeout: "60",
HttpHeaders: map[string]string{
"test-plain-1": "value-1",
"test-secure-2": "value-2",
"test-secure-3": "value-3",
},
ProxyOptions: &proxy.Options{
Enabled: true,
Auth: &proxy.AuthOptions{
Expand Down
2 changes: 1 addition & 1 deletion src/__mocks__/ConfigEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const mockConfigEditorProps = (overrides?: Partial<CHConfig>): ConfigEdit
port: 443,
path: '',
username: 'user',
protocol: 'native',
protocol: 'http',
...overrides,
},
},
Expand Down
123 changes: 123 additions & 0 deletions src/components/configEditor/HttpHeadersConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';
import { render, fireEvent, renderHook } from '@testing-library/react';
import { HttpHeadersConfig, useConfiguredSecureHttpHeaders } from './HttpHeadersConfig';
import { selectors as allSelectors } from 'selectors';
import { CHHttpHeader } from 'types/config';
import { KeyValue } from '@grafana/data';

describe('HttpHeadersConfig', () => {
const selectors = allSelectors.components.Config.HttpHeaderConfig;

it('should render', () => {
const result = render(<HttpHeadersConfig headers={[]} secureFields={{}} onHttpHeadersChange={() => {}} />);
expect(result.container.firstChild).not.toBeNull();
});

it('should not call onHttpHeadersChange when header is added', () => {
const onHttpHeadersChange = jest.fn();
const result = render(
<HttpHeadersConfig
headers={[]}
secureFields={{}}
onHttpHeadersChange={onHttpHeadersChange}
/>
);
expect(result.container.firstChild).not.toBeNull();

const addHeaderButton = result.getByTestId(selectors.addHeaderButton);
expect(addHeaderButton).toBeInTheDocument();
fireEvent.click(addHeaderButton);

expect(onHttpHeadersChange).toHaveBeenCalledTimes(0);
});

it('should call onHttpHeadersChange when header is updated', () => {
const onHttpHeadersChange = jest.fn();
const result = render(
<HttpHeadersConfig
headers={[]}
secureFields={{}}
onHttpHeadersChange={onHttpHeadersChange}
/>
);
expect(result.container.firstChild).not.toBeNull();

const addHeaderButton = result.getByTestId(selectors.addHeaderButton);
expect(addHeaderButton).toBeInTheDocument();
fireEvent.click(addHeaderButton);

const headerEditor = result.getByTestId(selectors.headerEditor);
expect(headerEditor).toBeInTheDocument();

const headerNameInput = result.getByTestId(selectors.headerNameInput);
expect(headerNameInput).toBeInTheDocument();
fireEvent.change(headerNameInput, { target: { value: 'x-test ' } }); // with space in name
fireEvent.blur(headerNameInput);
expect(headerNameInput).toHaveValue('x-test ');
expect(onHttpHeadersChange).toHaveBeenCalledTimes(1);

const headerValueInput = result.getByTestId(selectors.headerValueInput);
expect(headerValueInput).toBeInTheDocument();
fireEvent.change(headerValueInput, { target: { value: 'test value' } });
fireEvent.blur(headerValueInput);
expect(headerValueInput).toHaveValue('test value');
expect(onHttpHeadersChange).toHaveBeenCalledTimes(2);

const headerSecureSwitch = result.getByTestId(selectors.headerSecureSwitch);
expect(headerSecureSwitch).toBeInTheDocument();
fireEvent.click(headerSecureSwitch);
fireEvent.blur(headerSecureSwitch);
expect(onHttpHeadersChange).toHaveBeenCalledTimes(3);

const expected: CHHttpHeader[] = [
{ name: 'x-test', value: 'test value', secure: true } // without space in name
];
expect(onHttpHeadersChange).toHaveBeenCalledWith(expect.objectContaining(expected));
});

it('should call onHttpHeadersChange when header is removed', () => {
const onHttpHeadersChange = jest.fn();
const result = render(
<HttpHeadersConfig
headers={[
{ name: 'x-test', value: 'test value', secure: false },
{ name: 'x-test-2', value: 'test value 2', secure: false }
]}
secureFields={{}}
onHttpHeadersChange={onHttpHeadersChange}
/>
);
expect(result.container.firstChild).not.toBeNull();

const removeHeaderButton = result.getAllByTestId(selectors.removeHeaderButton)[0]; // Get 1st
expect(removeHeaderButton).toBeInTheDocument();
fireEvent.click(removeHeaderButton);

const expected: CHHttpHeader[] = [
{ name: 'x-test-2', value: 'test value 2', secure: false }
];
expect(onHttpHeadersChange).toHaveBeenCalledTimes(1);
expect(onHttpHeadersChange).toHaveBeenCalledWith(expect.objectContaining(expected));
});
});


describe('useConfiguredSecureHttpHeaders', () => {
it('returns unique set of secure header keys', async () => {
const fields: KeyValue<boolean> = {
'otherKey': true,
'otherOtherKey': false,
'secureHttpHeaders.a': true,
'secureHttpHeaders.b': true,
'secureHttpHeaders.c': false,
};

const hook = renderHook(() => useConfiguredSecureHttpHeaders(fields));
const result = hook.result.current;

expect(result.size).toBe(2);
expect(result.has('a')).toBe(true);
expect(result.has('b')).toBe(true);
expect(result.has('c')).toBe(false);
});
});
Loading
Loading