Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0825eb2
feat: Implement ApiSession and ApiSessionUtils with session managemen…
richie-ni Oct 13, 2025
b35328e
Merge branch 'main' into users/richie/create-api-session-key
richie-ni Oct 14, 2025
8cb87c6
feat(api-session): Enhance ApiSessionUtils with session caching and i…
richie-ni Oct 15, 2025
59aa1d2
fix(api-session): Update session key references to use 'session' inst…
richie-ni Oct 21, 2025
47d2cc5
fix(api-session): Update mock session endpoint in tests to use 'http:…
richie-ni Oct 21, 2025
7ac0a9b
fix(api-session): Improve error handling in createApiSession method w…
richie-ni Oct 21, 2025
4b336c4
docs: Update README and example.yaml for clearer API and UI ingress i…
richie-ni Oct 22, 2025
a68cc73
fix(example.yaml): Remove trailing space in SystemLink Products datas…
richie-ni Oct 22, 2025
abc0587
test(DataSourceBase): Add unit tests for GET and POST methods with AP…
richie-ni Oct 22, 2025
518c547
fix(README.md): Adjust table formatting for clarity in contribution g…
richie-ni Oct 22, 2025
6240ce4
refactor(api-session): Convert session cache to static property and u…
richie-ni Oct 22, 2025
ce60ea7
fix(ApiSessionUtils): Simplify session creation logic and update erro…
richie-ni Oct 22, 2025
ceba513
fix(README.md): Update instructions to navigate to the Application Ta…
richie-ni Oct 22, 2025
485bca1
fix(api-session): Rename 'session' to 'sessionKey' for consistency in…
richie-ni Oct 22, 2025
568cc4f
fix(comments): Restructure ApiSessionUtils and update session handlin…
richie-ni Oct 23, 2025
1a2f21f
Added test cases
richie-ni Oct 23, 2025
b0028e4
fix(test): Update POST request parameters to include body and options…
richie-ni Oct 23, 2025
e9530ad
refactor(api-session): Improve ApiSessionUtils constructor and update…
richie-ni Oct 24, 2025
f4d4eb9
fix(api-session): Update createApiSession return type to NonNullable …
richie-ni Oct 27, 2025
50c4009
test(DataSourceBase): Add POST request test for API ingress with no o…
richie-ni Oct 27, 2025
0dbf679
Merge branch 'main' into users/richie/create-api-session-key
richie-ni Oct 28, 2025
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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@ connect to a SystemLink service running in the cloud.
there by clicking the gear icon in the sidebar.
2. Select **Add data source**. Search for the plugin in the list and click on it
to enter the data source settings view.
3. Fill in the **URL** field with the API URL of the Stratus environment you
want to use (e.g. https://test-api.lifecyclesolutions.ni.com).
4. For authentication, click the **Add header** button and add a custom header
with the name `x-ni-api-key` and value set to [an API
key](https://ni-staging.zoominsoftware.io/docs/en-US/bundle/systemlink-enterprise/page/creating-an-api-key.html)
for the SLE instance.
3. **Using API Ingress**

1. Enter the API ingress **URL** of the Stratus environment you want to access (e.g., `https://test-api.lifecyclesolutions.ni.com`).
2. For authentication, click the **Add header** button. Create a custom header with the name `x-ni-api-key` and set its value to your [API key](https://ni-staging.zoominsoftware.io/docs/en-US/bundle/systemlink-enterprise/page/creating-an-api-key.html) for the SLE instance.

4. **Using UI Ingress**

1. Enter the UI ingress **URL** of the Stratus environment you want to access (e.g., `https://test.lifecyclesolutions.ni.com`).
2. Log in to the URL in your browser and navigate to the **Application Tab** to copy the cookie value.
3. For authentication, enable the **With Credentials** toggle, click the **Add header** button, and create a custom header with the name `cookie` and set its value to the copied browser cookie.

5. Click **Save & test**. You should see **Success** pop up if the data source
was configured correctly and the API key grants the necessary privileges.

Expand Down
10 changes: 9 additions & 1 deletion provisioning/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ apiVersion: 1

config: &config
access: proxy
# Enable this to send credentials (cookies, headers, or TLS certs) with cross-site requests.
# Necessary for session cookie authentication.
withCredentials: true

# Use API URL for API Key authentication (e.g. 'https://dev-api.lifecyclesolutions.ni.com/')
# Use UI URL for session cookie authentication (e.g. 'https://dev.lifecyclesolutions.ni.com/')
url: MY_SYSTEMLINK_API_URL
jsonData:
httpHeaderName1: 'x-ni-api-key'
httpHeaderName1: "x-ni-api-key" # Use for API Ingress
httpHeaderName2: "cookie" # Use for UI Ingress
secureJsonData:
httpHeaderValue1: MY_SYSTEMLINK_API_KEY
httpHeaderValue2: MY_SYSTEMLINK_UI_SESSION_COOKIE # e.g. 'session-id=*; apt.sid=*; apt.uid=*'

datasources:
- name: SystemLink Tags
Expand Down
154 changes: 154 additions & 0 deletions src/core/DataSourceBase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { DataQuery, DataSourceInstanceSettings, TestDataSourceResponse } from "@grafana/data";
import { BackendSrv } from "@grafana/runtime";

jest.mock('./utils', () => ({
get: jest.fn(),
post: jest.fn(),
}));

describe('DataSourceBase', () => {
let backendSrv: BackendSrv;
let dataSource: any;
let mockGet: jest.Mock;
let mockPost: jest.Mock;

const mockApiSession = {
endpoint: 'http://api-ingress.com',
sessionKey: { secret: 'api-key-secret' }
};
const mockApiSessionUtils = {
createApiSession: jest.fn().mockResolvedValue(mockApiSession)
};

beforeAll(async () => {
jest.resetModules();

const { DataSourceBase } = await import('./DataSourceBase');
const utils = await import('./utils');

mockGet = utils.get as jest.Mock;
mockPost = utils.post as jest.Mock;
mockGet.mockResolvedValue('test');
mockPost.mockResolvedValue('test');

backendSrv = {} as BackendSrv;

dataSource = setupMockDataSource(DataSourceBase);
dataSource.apiSessionUtils = mockApiSessionUtils;
});

describe('get', () => {
it('should send GET request with correct parameters when useApiIngress is not set', async () => {
const response = await dataSource.get('/test-endpoint', { param1: 'value1' });

expect(mockGet).toHaveBeenCalledWith(
backendSrv,
'/test-endpoint',
{ param1: 'value1' },
);
expect(response).toEqual('test');
});

it('should send GET request with API ingress when useApiIngress is true', async () => {
const response = await dataSource.get('/test-endpoint', { param1: 'value1' }, true);

expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(mockGet).toHaveBeenCalledWith(
backendSrv,
"http://api-ingress.com/test-endpoint",
{
'param1': 'value1',
'x-ni-api-key': 'api-key-secret'
},
);
expect(response).toEqual('test');
});

it('should not send GET request if no API session is returned', async () => {
jest.clearAllMocks();
mockApiSessionUtils.createApiSession.mockRejectedValueOnce(new Error('No session created'));

await expect(dataSource.get('/test-endpoint', { param1: 'value1' }, true))
.rejects.toThrow('No session created');
expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(mockGet).not.toHaveBeenCalled();
});
});

describe('post', () => {
it('should send POST request with correct parameters when useApiIngress is not set', async () => {
const response = await dataSource.post('/test-endpoint', { body: 'body' }, { options: 'optionValue' });

expect(mockPost).toHaveBeenCalledWith(
backendSrv,
'/test-endpoint',
{ body: 'body' },
{ options: 'optionValue' }
);
expect(response).toEqual('test');
});

it('should send POST request with API ingress when useApiIngress is true', async () => {
const response = await dataSource.post(
'/test-endpoint',
{ body: 'body' },
{
headers: {
testHeader: 'headerValue'
}
},
true
);

expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith(
backendSrv,
"http://api-ingress.com/test-endpoint",
{ body: 'body' },
{

headers: {
testHeader: 'headerValue',
"x-ni-api-key": "api-key-secret"
}

},
);
expect(response).toEqual('test');
});

it('should not send POST request if no API session is returned', async () => {
jest.clearAllMocks();
mockApiSessionUtils.createApiSession.mockRejectedValueOnce(new Error('No session created'));

await expect(dataSource.post('/test-endpoint', { options: 'optionValue' }, {}, true))
.rejects.toThrow('No session created');
expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
});
});

function setupMockDataSource(DataSourceBase: any) {
const instanceSettings = {
url: 'http://api-example.com',
} as DataSourceInstanceSettings;
class MockDataSource extends DataSourceBase<DataQuery> {
public constructor() {
super(instanceSettings, backendSrv, {} as any);
}
public defaultQuery = {};
public runQuery(_query: DataQuery, _options: any) {
return Promise.resolve({
fields: [],
});
}
public shouldRunQuery() {
return true;
}
testDatasource(): Promise<TestDataSourceResponse> {
throw new Error("Method not implemented.");
}
}
return new MockDataSource();
}
});
80 changes: 64 additions & 16 deletions src/core/DataSourceBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,30 @@ import { BackendSrv, BackendSrvRequest, TemplateSrv, getAppEvents } from '@grafa
import { DataQuery } from '@grafana/schema';
import { QuerySystemsResponse, QuerySystemsRequest, Workspace } from './types';
import { get, post } from './utils';
import { ApiSessionUtils } from '../shared/api-session.utils';

export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends DataSourceJsonData = DataSourceJsonData> extends DataSourceApi<TQuery, TOptions> {
appEvents: EventBus;
public readonly apiKeyHeader = 'x-ni-api-key';
public appEvents: EventBus;
public apiSessionUtils: ApiSessionUtils;

constructor(
readonly instanceSettings: DataSourceInstanceSettings<TOptions>,
readonly backendSrv: BackendSrv,
readonly templateSrv: TemplateSrv
public constructor(
public readonly instanceSettings: DataSourceInstanceSettings<TOptions>,
public readonly backendSrv: BackendSrv,
public readonly templateSrv: TemplateSrv
) {
super(instanceSettings);
this.appEvents = getAppEvents();
this.apiSessionUtils = new ApiSessionUtils(instanceSettings, backendSrv, this.appEvents);
}

abstract defaultQuery: Partial<TQuery> & Omit<TQuery, 'refId'>;
public abstract defaultQuery: Partial<TQuery> & Omit<TQuery, 'refId'>;

abstract runQuery(query: TQuery, options: DataQueryRequest): Promise<DataFrameDTO>;
public abstract runQuery(query: TQuery, options: DataQueryRequest): Promise<DataFrameDTO>;

abstract shouldRunQuery(query: TQuery): boolean;
public abstract shouldRunQuery(query: TQuery): boolean;

query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> {
public query(request: DataQueryRequest<TQuery>): Promise<DataQueryResponse> {
const promises = request.targets
.map(this.prepareQuery, this)
.filter(this.shouldRunQuery, this)
Expand All @@ -39,15 +43,34 @@ export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends
return Promise.all(promises).then(data => ({ data }));
}

prepareQuery(query: TQuery): TQuery {
public prepareQuery(query: TQuery): TQuery {
return { ...this.defaultQuery, ...query };
}

get<T>(url: string, params?: Record<string, any>) {
/**
* Sends a GET request to the specified URL with optional query parameters.
*
* @template T - The expected response type.
* @param url - The endpoint URL for the GET request.
* @param params - Optional query parameters as a key-value map.
* @param useApiIngress - If true, uses API ingress bypassing the UI ingress for the request.
* @returns A promise resolving to the response of type `T`.
*/
public async get<T>(url: string, params?: Record<string, any>, useApiIngress = false) {
if (useApiIngress) {
const apiSession = await this.apiSessionUtils.createApiSession();
if (apiSession) {
url = this.constructApiUrl(apiSession.endpoint, url);
params = {
...params,
[this.apiKeyHeader]: apiSession.sessionKey.secret,
};
}
}

return get<T>(this.backendSrv, url, params);
}


/**
* Sends a POST request to the specified URL with the provided request body and options.
*
Expand All @@ -57,15 +80,35 @@ export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends
* @param options - Optional configuration for the request. This can include:
* - `showingErrorAlert` (boolean): If true, displays an error alert on request failure.
* - Any other properties supported by {@link BackendSrvRequest}, such as headers, credentials, etc.
* @param useApiIngress - If true, uses API ingress bypassing the UI ingress for the request.
* @returns A promise resolving to the response of type `T`.
*/
post<T>(url: string, body: Record<string, any>, options: Partial<BackendSrvRequest> = {}) {
public async post<T>(
url: string,
body: Record<string, any>,
options: Partial<BackendSrvRequest> = {},
useApiIngress = false
) {
if (useApiIngress) {
const apiSession = await this.apiSessionUtils.createApiSession();
if (apiSession) {
url = this.constructApiUrl(apiSession.endpoint, url);
options = {
...options,
headers: {
...options.headers,
[this.apiKeyHeader]: apiSession.sessionKey.secret,
},
};
}
}

return post<T>(this.backendSrv, url, body, options);
}

static Workspaces: Workspace[];
public static Workspaces: Workspace[];

async getWorkspaces(): Promise<Workspace[]> {
public async getWorkspaces(): Promise<Workspace[]> {
if (DataSourceBase.Workspaces) {
return DataSourceBase.Workspaces;
}
Expand All @@ -77,9 +120,14 @@ export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends
return (DataSourceBase.Workspaces = response.workspaces);
}

async getSystems(body: QuerySystemsRequest): Promise<QuerySystemsResponse> {
public async getSystems(body: QuerySystemsRequest): Promise<QuerySystemsResponse> {
return await this.post<QuerySystemsResponse>(
this.instanceSettings.url + '/nisysmgmt/v1/query-systems', body
)
}

private constructApiUrl(apiEndpoint: string, url: string): string {
const webserverUrl = this.instanceSettings.url ?? '';
return apiEndpoint + url.replace(webserverUrl, '');
}
}
Loading