Skip to content

Commit

Permalink
feature: Custom HTTP Headers
Browse files Browse the repository at this point in the history
  • Loading branch information
SpencerTorres committed Dec 15, 2023
1 parent 1904720 commit 3339f6c
Show file tree
Hide file tree
Showing 13 changed files with 704 additions and 154 deletions.
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
41 changes: 38 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,30 @@ 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)
headerValue := header["value"].(string)
if headerName != "" && headerValue != "" {
httpHeaders[headerName] = headerValue
}
}
}

for k, v := range secureJsonData {
if v != "" && strings.HasPrefix(k, secureHeaderKeyPrefix) {
headerName := 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' } });
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 }
];
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

0 comments on commit 3339f6c

Please sign in to comment.