Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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 **URL** of the Stratus environment's API you wish 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 **URL** of the Stratus environment's UI 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
102 changes: 102 additions & 0 deletions src/core/DataSourceBase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { DataSourceInstanceSettings } from "@grafana/data";
import { of } from "rxjs";

type MockedBackendSrv = jest.Mocked<{
get: jest.Mock;
post: jest.Mock;
fetch: jest.Mock;
}>;

describe('DataSourceBase', () => {
let instanceSettings: DataSourceInstanceSettings;
let backendSrv: MockedBackendSrv;
let DataSourceBaseStub: any;
let dataSource: any;

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');
DataSourceBaseStub = DataSourceBase;
backendSrv = {
get: jest.fn(),
post: jest.fn(),
fetch: jest.fn().mockReturnValue(of({ data: 'test' }))
};
instanceSettings = {
url: 'http://api-example.com',
} as DataSourceInstanceSettings;

dataSource = new DataSourceBaseStub(
instanceSettings,
backendSrv,
{}
);
});

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(backendSrv.fetch).toHaveBeenCalledWith({
method: 'GET',
url: '/test-endpoint',
params: { param1: 'value1' },
});
expect(response).toEqual('test');
});

it('should send GET request with API ingress when useApiIngress is true', async () => {
dataSource.apiSession = mockApiSessionUtils;

const response = await dataSource.get('/test-endpoint', { param1: 'value1' }, true);


expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(backendSrv.fetch).toHaveBeenCalledWith({

method: 'GET',
url: 'http://api-ingress.com/test-endpoint',
params: { param1: 'value1', 'x-ni-api-key': 'api-key-secret' }
}
);
expect(response).toEqual('test');
});
});

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

expect(backendSrv.fetch).toHaveBeenCalledWith({
method: 'POST',
url: '/test-endpoint',
data: { option1: 'optionValue' },
});
expect(response).toEqual('test');
});

it('should send POST request with API ingress when useApiIngress is true', async () => {
dataSource.apiSession = mockApiSessionUtils;

const response = await dataSource.post('/test-endpoint', { option1: 'optionValue' }, {}, true);

expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled();
expect(backendSrv.fetch).toHaveBeenCalledWith({
method: 'POST',
url: 'http://api-ingress.com/test-endpoint',
data: { option1: 'optionValue' },
headers: { 'x-ni-api-key': 'api-key-secret' },
});
expect(response).toEqual('test');
});
});
});
49 changes: 46 additions & 3 deletions src/core/DataSourceBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ 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 './api-session.utils';

export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends DataSourceJsonData = DataSourceJsonData> extends DataSourceApi<TQuery, TOptions> {
appEvents: EventBus;
apiSession: ApiSessionUtils;

constructor(
readonly instanceSettings: DataSourceInstanceSettings<TOptions>,
Expand All @@ -22,6 +24,7 @@ export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends
) {
super(instanceSettings);
this.appEvents = getAppEvents();
this.apiSession = new ApiSessionUtils(instanceSettings, backendSrv);
}

abstract defaultQuery: Partial<TQuery> & Omit<TQuery, 'refId'>;
Expand All @@ -43,11 +46,31 @@ export abstract class DataSourceBase<TQuery extends DataQuery, TOptions extends
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`.
*/
async get<T>(url: string, params?: Record<string, any>, useApiIngress = false) {
if (useApiIngress) {
const apiSession = await this.apiSession.createApiSession();
if (apiSession) {
url = apiSession.endpoint + url.replace(this.instanceSettings.url ?? '', '');
params = {
...params,
'x-ni-api-key': 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,9 +80,29 @@ 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> = {}) {
async post<T>(
url: string,
body: Record<string, any>,
options: Partial<BackendSrvRequest> = {},
useApiIngress = false
) {
if (useApiIngress) {
const apiSession = await this.apiSession.createApiSession();
if (apiSession) {
options = {
...options,
headers: {
...options.headers,
'x-ni-api-key': apiSession.sessionKey.secret,
},
};
url = apiSession.endpoint + url.replace(this.instanceSettings.url ?? '', '');
}
}

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

Expand Down
110 changes: 110 additions & 0 deletions src/core/api-session.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { AppEvents, DataSourceInstanceSettings } from '@grafana/data';
import { BackendSrv } from '@grafana/runtime';

jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(),
}));
jest.mock('./utils', () => ({
post: jest.fn(),
}));
jest.resetModules();

let post, getAppEvents, ApiSessionUtils;
let mockGetAppEvents: jest.Mock;
let mockPost: jest.Mock;

describe('ApiSessionUtils', () => {
let instanceSettings: DataSourceInstanceSettings;
let backendSrv: BackendSrv;
let appEvents: { publish: any; };
let apiSessionUtils: any;

beforeEach(async () => {
// Dynamically import dependencies after mocks
ApiSessionUtils = (await import('./api-session.utils')).ApiSessionUtils;
getAppEvents = (await import('@grafana/runtime')).getAppEvents;
post = (await import('./utils')).post;

// Reset static cache before each test
(ApiSessionUtils as any)._sessionCache = undefined;

mockGetAppEvents = getAppEvents as jest.Mock;
mockPost = post as jest.Mock;

instanceSettings = { url: 'http://api-example.com' } as DataSourceInstanceSettings;
backendSrv = {} as BackendSrv;
appEvents = { publish: jest.fn() };
mockGetAppEvents.mockReturnValue(appEvents);

apiSessionUtils = new ApiSessionUtils(instanceSettings, backendSrv);

mockPost.mockClear();
appEvents.publish.mockClear();
});

describe('createApiSession', () => {
const createMockSession = (expiryOffset: number) => ({
endpoint: 'http://test-endpoint',
sessionKey: {
expiry: new Date(Date.now() + expiryOffset).toISOString(),
secret: 'test-secret',
},
});

it('should create a new session if cache is empty', async () => {
const newSession = createMockSession(600_000); // 10 minutes expiry
mockPost.mockResolvedValue(newSession);

const session = await apiSessionUtils.createApiSession();

expect(mockPost).toHaveBeenCalledTimes(1);
expect(session).toBe(newSession);
});

it('should return a valid cached session', async () => {
const validSession = createMockSession(600_000); // 10 minutes expiry, well outside 5 min buffer
mockPost.mockResolvedValue(validSession);

const session1 = await apiSessionUtils.createApiSession();
const session2 = await apiSessionUtils.createApiSession();

expect(mockPost).toHaveBeenCalledTimes(1);
expect(session1).toBe(validSession);
expect(session2).toBe(validSession);
});

it('should create a new session if cached session is expired', async () => {
const expiredSession = createMockSession(240_000); // 4 minutes expiry (inside 5-minute buffer)
const newSession = createMockSession(600_000);
mockPost.mockResolvedValueOnce(expiredSession)
.mockResolvedValueOnce(newSession);

const session1 = await apiSessionUtils.createApiSession();
const session2 = await apiSessionUtils.createApiSession();

expect(mockPost).toHaveBeenCalledTimes(2);
expect(session1).toBe(expiredSession);
expect(session2).toBe(newSession);
});

it('should handle errors during session creation and publish an event', async () => {
const error = new Error('Network error');
const errorMessage = `The query to create an API session failed. ${error?.message}.`

mockPost.mockRejectedValue(error);

await expect(apiSessionUtils.createApiSession()).rejects.toThrow(
errorMessage
);

expect(appEvents.publish).toHaveBeenCalledWith({
type: AppEvents.alertError.name,
payload: [
'Error creating session',
errorMessage
],
});
});
});
});
Loading