Skip to content
Open
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
207 changes: 207 additions & 0 deletions client/src/model/datasource-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { DatasourceSpec, UnknownSpec } from '@perses-dev/spec';
import { buildProxyUrl, createTestDatasourceConnection } from './datasource-api';

describe('buildProxyUrl', () => {
beforeEach(() => {
delete window.PERSES_APP_CONFIG;
});

test.each([
{
title: 'should build global datasource proxy url',
input: { name: 'datasourceA' },
expected: '/proxy/globaldatasources/datasourceA',
},
{
title: 'should build unsaved global datasource proxy url',
input: {},
expected: '/proxy/unsaved/globaldatasources',
},
{
title: 'should build project datasource proxy url',
input: { project: 'projectA', name: 'datasourceA' },
expected: '/proxy/projects/projectA/datasources/datasourceA',
},
{
title: 'should build unsaved project datasource proxy url',
input: { project: 'projectA' },
expected: '/proxy/unsaved/projects/projectA/datasources',
},
{
title: 'should build dashboard datasource proxy url',
input: { project: 'projectA', dashboard: 'dashboardA', name: 'datasourceA' },
expected: '/proxy/projects/projectA/dashboards/dashboardA/datasources/datasourceA',
},
{
title: 'should build unsaved dashboard datasource proxy url',
input: { project: 'projectA', dashboard: 'dashboardA' },
expected: '/proxy/unsaved/projects/projectA/dashboards/dashboardA/datasources',
},
])('$title', ({ input, expected }) => {
expect(buildProxyUrl(input)).toEqual(expected);
});

it('should URL-encode special characters in project, dashboard, and name', () => {
expect(buildProxyUrl({ project: 'my project', dashboard: 'my/dashboard', name: 'my datasource' })).toEqual(
'/proxy/projects/my%20project/dashboards/my%2Fdashboard/datasources/my%20datasource'
);
});

it('should prepend api_prefix from window.PERSES_APP_CONFIG', () => {
window.PERSES_APP_CONFIG = { api_prefix: '/api/v1' };
expect(buildProxyUrl({ name: 'datasourceA' })).toEqual('/api/v1/proxy/globaldatasources/datasourceA');
});

it('should use empty prefix when api_prefix is absent', () => {
window.PERSES_APP_CONFIG = {};
expect(buildProxyUrl({ name: 'datasourceA' })).toEqual('/proxy/globaldatasources/datasourceA');
});
});

describe('createTestDatasourceConnection', () => {
const mockFetch = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
delete window.PERSES_APP_CONFIG;
globalThis.fetch = mockFetch;
});

const makeSpec = (pluginSpec: UnknownSpec): DatasourceSpec => ({
default: false,
plugin: { kind: 'TestDatasource', spec: pluginSpec },
});

describe('direct URL mode', () => {
it('calls the directUrl + healthCheckPath with GET', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ directUrl: 'http://localhost:9090' });

await testConnection(spec, '/api/v1/query');

expect(mockFetch).toHaveBeenCalledWith('http://localhost:9090/api/v1/query', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
});

it('does not produce a double slash when directUrl ends with a trailing slash', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ directUrl: 'http://localhost:9090/' });

await testConnection(spec, '/api/v1/query');

expect(mockFetch).toHaveBeenCalledWith('http://localhost:9090/api/v1/query', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
});

it('normalizes healthCheckPath that lacks a leading slash', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ directUrl: 'http://localhost:9090' });

await testConnection(spec, 'api/v1/query');

expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/v1/query'), expect.anything());
});
});

describe('proxy mode', () => {
it('POSTs to the unsaved global proxy endpoint', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } });

await testConnection(spec, '/api/v1/query');

expect(mockFetch).toHaveBeenCalledWith(
'/proxy/unsaved/globaldatasources/api/v1/query',
expect.objectContaining({ method: 'POST' })
);
});

it('POSTs to the unsaved project proxy endpoint', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection({ project: 'myProject' });
const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } });

await testConnection(spec, '/api/v1/query');

expect(mockFetch).toHaveBeenCalledWith(
'/proxy/unsaved/projects/myProject/datasources/api/v1/query',
expect.objectContaining({ method: 'POST' })
);
});

it('POSTs to the unsaved dashboard proxy endpoint', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection({ project: 'myProject', dashboard: 'myDashboard' });
const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } });

await testConnection(spec, '/api/v1/query');

expect(mockFetch).toHaveBeenCalledWith(
'/proxy/unsaved/projects/myProject/dashboards/myDashboard/datasources/api/v1/query',
expect.objectContaining({ method: 'POST' })
);
});

it('includes the full spec in the POST body', async () => {
mockFetch.mockResolvedValue({ ok: true });
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ proxy: { kind: 'HTTPProxy', spec: { url: 'http://localhost:9090' } } });

await testConnection(spec, '/api/v1/query');

const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(callBody).toMatchObject({ method: 'GET', body: null, spec });
});
});

describe('unsupported spec', () => {
it('throws for a spec with neither directUrl nor proxy', async () => {
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ someOtherField: 'value' });

await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i);
});

it('throws for an empty HTTP spec (directUrl undefined, no proxy)', async () => {
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({});

await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i);
});

it('throws for a spec with directUrl set to undefined', async () => {
const testConnection = createTestDatasourceConnection();
const spec = makeSpec({ directUrl: undefined });

await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i);
});

it('throws for a non-object spec', async () => {
const testConnection = createTestDatasourceConnection();
const spec = makeSpec('not-an-object' as unknown as UnknownSpec);

await expect(testConnection(spec, '/health')).rejects.toThrow(/unsupported datasource spec type/i);
});
});
});
73 changes: 72 additions & 1 deletion client/src/model/datasource-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { DatasourceSpec, HTTPDatasourceSpec } from '@perses-dev/spec';
import { fetch } from '../util';
import { DatasourceResource, DatasourceSelector, GlobalDatasourceResource } from './datasource';

/**
Expand All @@ -19,14 +21,83 @@ import { DatasourceResource, DatasourceSelector, GlobalDatasourceResource } from
export interface BuildDatasourceProxyUrlParams {
project?: string;
dashboard?: string;
name: string;
/** Omit to generate an unsaved-datasource proxy URL */
name?: string;
}

/**
* Function type for building datasource proxy URLs
*/
export type BuildDatasourceProxyUrlFunc = (params: BuildDatasourceProxyUrlParams) => string;

/**
* Builds a proxy URL for a datasource. When name is omitted the URL targets the
* unsaved-datasource proxy endpoint (used for testing connectivity before saving).
* Reads api_prefix from window.PERSES_APP_CONFIG if available.
*/
export function buildProxyUrl({ project, dashboard, name }: BuildDatasourceProxyUrlParams): string {
const apiPrefix = (typeof window !== 'undefined' && window.PERSES_APP_CONFIG?.api_prefix) || '';
let url = `${!project && !dashboard ? 'globaldatasources' : 'datasources'}`;
if (dashboard) url = `dashboards/${encodeURIComponent(dashboard)}/${url}`;
if (project) url = `projects/${encodeURIComponent(project)}/${url}`;
url = name === undefined ? `unsaved/${url}` : `${url}/${encodeURIComponent(name)}`;
return `${apiPrefix}/proxy/${url}`;
}

interface UnsavedDatasourceProxyBody {
method: string;
body?: Uint8Array | null;
spec: DatasourceSpec;
}

function hasDirectUrl(pluginSpec: unknown): pluginSpec is { directUrl: string } {
return (
typeof pluginSpec === 'object' &&
pluginSpec !== null &&
typeof (pluginSpec as Record<string, unknown>)['directUrl'] === 'string'
);
}

function hasHTTPProxy(
pluginSpec: unknown
): pluginSpec is HTTPDatasourceSpec & { proxy: NonNullable<HTTPDatasourceSpec['proxy']> } {
if (typeof pluginSpec !== 'object' || pluginSpec === null) return false;
const proxy = (pluginSpec as Record<string, unknown>)['proxy'];
return typeof proxy === 'object' && proxy !== null;
}

/**
* Creates a function that tests connectivity for an unsaved datasource.
* Supports directUrl (direct mode) and HTTPProxy (proxy mode).
* Throws for any spec that does not match either shape.
*/
export function createTestDatasourceConnection({ project, dashboard }: { project?: string; dashboard?: string } = {}): (
spec: DatasourceSpec,
healthCheckPath: string
) => Promise<void> {
return async (spec: DatasourceSpec, healthCheckPath: string): Promise<void> => {
const normalizedPath = healthCheckPath.startsWith('/') ? healthCheckPath : `/${healthCheckPath}`;
const pluginSpec = spec.plugin.spec;

if (hasDirectUrl(pluginSpec)) {
await fetch(`${pluginSpec.directUrl.replace(/\/$/, '')}${normalizedPath}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
} else if (hasHTTPProxy(pluginSpec)) {
const proxyUrl = buildProxyUrl({ project, dashboard });
const body: UnsavedDatasourceProxyBody = { method: 'GET', spec, body: null };
await fetch(`${proxyUrl}${normalizedPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
throw new Error(`Unsupported datasource spec type for plugin kind '${spec.plugin.kind}'`);
}
};
}

/**
* The external API contract for fetching datasource resources.
* This defines the interface that must be implemented to provide
Expand Down
20 changes: 20 additions & 0 deletions client/src/types/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

declare global {
interface Window {
PERSES_APP_CONFIG?: { api_prefix?: string };
}
}

export {};
16 changes: 13 additions & 3 deletions dashboards/src/components/Datasources/DatasourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ import PencilIcon from 'mdi-material-ui/Pencil';
import TrashIcon from 'mdi-material-ui/TrashCan';
import { DatasourceSpec } from '@perses-dev/spec';
import { DatasourceEditorForm, ValidationProvider } from '@perses-dev/plugin-system';
import { ReactElement, useState } from 'react';
import { ReactElement, useMemo, useState } from 'react';
import { useImmer } from 'use-immer';
import { Action, DatasourceDefinition } from '@perses-dev/client';
import { useDiscardChangesConfirmationDialog } from '../../context';
import { Action, createTestDatasourceConnection, DatasourceDefinition } from '@perses-dev/client';
import { useDashboard, useDiscardChangesConfirmationDialog } from '../../context';

export function DatasourceEditor(props: {
datasources: Record<string, DatasourceSpec>;
Expand All @@ -42,6 +42,15 @@ export function DatasourceEditor(props: {
const [datasources, setDatasources] = useImmer(props.datasources);
const [datasourceFormAction, setDatasourceFormAction] = useState<Action>('update');
const [datasourceEdit, setDatasourceEdit] = useState<DatasourceDefinition | null>(null);
const { dashboard } = useDashboard();
const testConnection = useMemo(
() =>
createTestDatasourceConnection({
project: dashboard.metadata.project,
dashboard: dashboard.metadata.name,
}),
[dashboard.metadata.project, dashboard.metadata.name]
);
const defaultSpec: DatasourceSpec = {
default: false,
plugin: {
Expand Down Expand Up @@ -113,6 +122,7 @@ export function DatasourceEditor(props: {
onClose={() => {
setDatasourceEdit(null);
}}
testConnection={testConnection}
/>
</ValidationProvider>
) : (
Expand Down
Loading
Loading