@grafana/runtime: User storage (#95834)

This commit is contained in:
Andres Martinez Gotor 2024-11-28 15:49:34 +01:00 committed by GitHub
parent fb0fff6de1
commit a41902330f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 299 additions and 0 deletions

View File

@ -56,3 +56,4 @@ export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard }
export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermission } from './utils/rbac';
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
export { usePluginUserStorage } from './utils/userStorage';

View File

@ -0,0 +1,145 @@
import { cloneDeep } from 'lodash';
import { of } from 'rxjs';
import { config } from '../config';
import { BackendSrvRequest, FetchError, FetchResponse, BackendSrv } from '../services';
import { usePluginUserStorage } from './userStorage';
const request = jest.fn<Promise<FetchResponse | FetchError>, BackendSrvRequest[]>();
const backendSrv = {
fetch: (options: BackendSrvRequest) => {
return of(request(options));
},
} as unknown as BackendSrv;
jest.mock('../services', () => ({
...jest.requireActual('../services'),
getBackendSrv: () => backendSrv,
}));
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
usePluginContext: jest.fn().mockReturnValue({ meta: { id: 'plugin-id' } }),
}));
describe('userStorage', () => {
const originalGetItem = Storage.prototype.getItem;
const originalSetItem = Storage.prototype.setItem;
const originalConfig = cloneDeep(config);
beforeEach(() => {
config.featureToggles.userStorageAPI = true;
config.bootData.user.isSignedIn = true;
config.bootData.user.uid = 'abc';
request.mockReset();
Storage.prototype.setItem = jest.fn();
Storage.prototype.getItem = jest.fn();
});
afterEach(() => {
Storage.prototype.setItem = originalSetItem;
Storage.prototype.getItem = originalGetItem;
config.featureToggles = originalConfig.featureToggles;
config.bootData = originalConfig.bootData;
});
describe('UserStorageAPI.getItem', () => {
it('use localStorage if the feature flag is disabled', async () => {
config.featureToggles.userStorageAPI = false;
const storage = usePluginUserStorage();
storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
});
it('use localStorage if the user is not logged in', async () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
});
it('use localStorage if the user storage is not found', async () => {
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
const storage = usePluginUserStorage();
await storage.getItem('key');
expect(localStorage.getItem).toHaveBeenCalled();
});
it('returns the value from the user storage', async () => {
request.mockReturnValue(
Promise.resolve({ status: 200, data: { spec: { data: { key: 'value' } } } } as FetchResponse)
);
const storage = usePluginUserStorage();
const value = await storage.getItem('key');
expect(value).toBe('value');
});
});
describe('setItem', () => {
it('use localStorage if the feature flag is disabled', async () => {
config.featureToggles.userStorageAPI = false;
const storage = usePluginUserStorage();
storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalled();
});
it('use localStorage if the user is not logged in', async () => {
config.bootData.user.isSignedIn = false;
const storage = usePluginUserStorage();
storage.setItem('key', 'value');
expect(localStorage.setItem).toHaveBeenCalled();
});
it('creates a new user storage if it does not exist', async () => {
request.mockReturnValueOnce(Promise.reject({ status: 404 } as FetchError));
const storage = usePluginUserStorage();
await storage.setItem('key', 'value');
expect(request).toHaveBeenCalledWith({
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
method: 'GET',
showErrorAlert: false,
});
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/',
method: 'POST',
data: {
metadata: { labels: { service: 'plugin-id', user: 'abc' }, name: 'plugin-id:abc' },
spec: {
data: { key: 'value' },
},
},
})
);
});
it('updates the user storage if it exists', async () => {
request.mockReturnValueOnce(
Promise.resolve({
status: 200,
data: { metadata: { name: 'service:abc' }, spec: { data: { key: 'value' } } },
} as FetchResponse)
);
const storage = usePluginUserStorage();
await storage.setItem('key', 'new-value');
expect(request).toHaveBeenCalledWith({
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
method: 'GET',
showErrorAlert: false,
});
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
method: 'PATCH',
data: {
spec: {
data: { key: 'new-value' },
},
},
})
);
});
});
});

View File

@ -0,0 +1,153 @@
import { get } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { usePluginContext } from '@grafana/data';
import { config } from '../config';
import { BackendSrvRequest, getBackendSrv } from '../services';
const baseURL = `/apis/userstorage.grafana.app/v0alpha1/namespaces/${config.namespace}/user-storage`;
interface RequestOptions extends BackendSrvRequest {
manageError?: (err: unknown) => { error: unknown };
showErrorAlert?: boolean;
// rtk codegen sets this
body?: BackendSrvRequest['data'];
}
export type UserStorageSpec = {
data: { [key: string]: string };
};
async function apiRequest<T>(requestOptions: RequestOptions) {
try {
const { data: responseData, ...meta } = await lastValueFrom(
getBackendSrv().fetch<T>({
...requestOptions,
url: baseURL + requestOptions.url,
data: requestOptions.body,
})
);
return { data: responseData, meta };
} catch (error) {
return requestOptions.manageError ? requestOptions.manageError(error) : { error };
}
}
/**
* A class for interacting with the backend user storage.
* Unexported because it is currently only be used through the useUserStorage hook.
*/
class UserStorage {
private service: string;
private resourceName: string;
private userUID: string;
private canUseUserStorage: boolean;
private storageSpec: UserStorageSpec | null | undefined;
constructor(service: string) {
this.service = service;
this.userUID = config.bootData.user.uid === '' ? config.bootData.user.id.toString() : config.bootData.user.uid;
this.resourceName = `${service}:${this.userUID}`;
this.canUseUserStorage = config.featureToggles.userStorageAPI === true && config.bootData.user.isSignedIn;
}
private async init() {
if (this.storageSpec !== undefined) {
return;
}
const userStorage = await apiRequest<{ spec: UserStorageSpec }>({
url: `/${this.resourceName}`,
method: 'GET',
showErrorAlert: false,
});
if ('error' in userStorage) {
if (get(userStorage, 'error.status') !== 404) {
console.error('Failed to get user storage', userStorage.error);
}
// No user storage found, return null
this.storageSpec = null;
} else {
this.storageSpec = userStorage.data.spec;
}
}
async getItem(key: string): Promise<string | null> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
return localStorage.getItem(this.resourceName);
}
// Ensure this.storageSpec is initialized
await this.init();
if (!this.storageSpec) {
// Also, fallback to localStorage for backward compatibility once userStorageAPI is enabled
return localStorage.getItem(this.resourceName);
}
return this.storageSpec.data[key];
}
async setItem(key: string, value: string): Promise<void> {
if (!this.canUseUserStorage) {
// Fallback to localStorage
localStorage.setItem(key, value);
return;
}
const newData = { data: { [key]: value } };
// Ensure this.storageSpec is initialized
await this.init();
if (!this.storageSpec) {
// No user storage found, create a new one
await apiRequest<UserStorageSpec>({
url: `/`,
method: 'POST',
body: {
metadata: { name: this.resourceName, labels: { user: this.userUID, service: this.service } },
spec: newData,
},
});
this.storageSpec = newData;
return;
}
// Update existing user storage
this.storageSpec.data[key] = value;
await apiRequest<UserStorageSpec>({
headers: { 'Content-Type': 'application/merge-patch+json' },
url: `/${this.resourceName}`,
method: 'PATCH',
body: { spec: newData },
});
}
}
export interface PluginUserStorage {
/**
* Retrieves an item from the backend user storage or local storage if not enabled.
* @param key - The key of the item to retrieve.
* @returns A promise that resolves to the item value or null if not found.
*/
getItem(key: string): Promise<string | null>;
/**
* Sets an item in the backend user storage or local storage if not enabled.
* @param key - The key of the item to set.
* @param value - The value of the item to set.
* @returns A promise that resolves when the item is set.
*/
setItem(key: string, value: string): Promise<void>;
}
/**
* A hook for interacting with the backend user storage (or local storage if not enabled).
* @returns An scoped object for a plugin and a user with getItem and setItem functions.
* @alpha Experimental
*/
export function usePluginUserStorage(): PluginUserStorage {
const context = usePluginContext();
if (!context) {
throw new Error(`No PluginContext found. The usePluginUserStorage() hook can only be used from a plugin.`);
}
return new UserStorage(context?.meta.id);
}