Changed endpoint name, GET->POST, request/response signature, and perform batch queries against db

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2025-09-29 14:42:57 +02:00
parent f79e0d9d77
commit 233bb3086c
13 changed files with 343 additions and 324 deletions
@@ -25,8 +25,6 @@ import {
mockApis,
registerMswTestHooks,
} from '@backstage/test-utils';
import { NotFoundError } from '@backstage/errors';
import { parseDataLoaderKey } from '@backstage/plugin-user-settings-common';
import { createDeferred } from '@backstage/types';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@@ -187,7 +185,7 @@ describe('Persistent Storage API', () => {
return res(ctx.json(data));
},
),
rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => {
rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => {
serverCall.resolve();
return res(ctx.json([]));
}),
@@ -234,7 +232,7 @@ describe('Persistent Storage API', () => {
return res(ctx.status(204));
},
),
rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => {
rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => {
serverCall.resolve();
return res(ctx.json([]));
}),
@@ -283,10 +281,9 @@ describe('Persistent Storage API', () => {
return res(ctx.json({ value }));
},
),
rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => {
const { bucket, key } = parseDataLoaderKey(
req.url.searchParams.get('items') as string,
);
rest.post(`${mockBaseUrl}/multiget`, async (req, res, ctx) => {
const payload = await req.json();
const { bucket, key } = payload.items[0];
expect(bucket).toEqual('clash.profile/something');
expect(key).toEqual('deep/test2');
@@ -333,12 +330,11 @@ describe('Persistent Storage API', () => {
});
server.use(
rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => {
const { bucket, key } = parseDataLoaderKey(
req.url.searchParams.get('items') as string,
);
expect(bucket).toEqual('Test.Mock.Thing');
expect(key).toEqual('key');
rest.post(`${mockBaseUrl}/multiget`, async (req, res, ctx) => {
const payload = await req.json();
expect(payload).toEqual({
items: [{ bucket: 'Test.Mock.Thing', key: 'key' }],
});
return res(ctx.text('{ invalid: json string }'));
}),
);
@@ -369,8 +365,8 @@ describe('Persistent Storage API', () => {
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
server.use(
rest.get(`${mockBaseUrl}/multi`, async (_req, res, ctx) => {
return res(ctx.json([{ bucket: 'freeze', key: 'key', value: data }]));
rest.post(`${mockBaseUrl}/multiget`, async (_req, res, ctx) => {
return res(ctx.json({ items: [{ value: data }] }));
}),
);
@@ -415,21 +411,21 @@ describe('Persistent Storage API', () => {
let serverCalls = 0;
server.use(
rest.get(`${mockBaseUrl}/multi`, async (req, res, ctx) => {
rest.post(`${mockBaseUrl}/multiget`, async (req, res, ctx) => {
++serverCalls;
const result = req.url.searchParams
.getAll('items')
.map(item => parseDataLoaderKey(item))
.map(({ key }) => {
if (key === 'key1') {
return { bucket: 'multiget', key, value: data1 };
} else if (key === 'key2') {
return { bucket: 'multiget', key, value: data2 };
}
return { bucket: 'multiget', key, error: new NotFoundError() };
});
const payload = (await req.json()) as {
items: { bucket: string; key: string }[];
};
const result = payload.items.map(item => {
if (item.key === 'key1') {
return { value: data1 };
} else if (item.key === 'key2') {
return { value: data2 };
}
return null;
});
return res(ctx.json(result));
return res(ctx.json({ items: result }));
}),
);
@@ -23,15 +23,12 @@ import {
StorageApi,
StorageValueSnapshot,
} from '@backstage/core-plugin-api';
import { deserializeError, ResponseError } from '@backstage/errors';
import { ResponseError } from '@backstage/errors';
import { JsonValue, Observable } from '@backstage/types';
import { SignalApi, SignalSubscriber } from '@backstage/plugin-signals-react';
import ObservableImpl from 'zen-observable';
import {
isMultiUserSettingError,
MultiUserSetting,
parseDataLoaderKey,
stringifyDataLoaderKey,
MultiGetResponse,
UserSettingsSignal,
} from '@backstage/plugin-user-settings-common';
import DataLoader from 'dataloader';
@@ -47,6 +44,12 @@ const buckets = new Map<string, UserSettingsStorage>();
const DATALOADER_CACHE_TTL_MS = 2 * 1000; // 2 seconds cache
const DATALOADER_WINDOW_MS = 10; // 10 ms
type DataLoaderType = DataLoader<
{ bucket: string; key: string },
StorageValueSnapshot<JsonValue>,
string
>;
/**
* An implementation of the storage API, that uses the user-settings backend to
* persist the data in the DB.
@@ -70,7 +73,7 @@ export class UserSettingsStorage implements StorageApi {
private readonly identityApi: IdentityApi;
private readonly fallback: WebStorage;
private readonly signalApi?: SignalApi;
private readonly userSettingsLoader: DataLoader<string, any>;
private readonly userSettingsLoader: DataLoaderType;
private constructor(
namespace: string,
@@ -80,7 +83,7 @@ export class UserSettingsStorage implements StorageApi {
identityApi: IdentityApi,
fallback: WebStorage,
signalApi?: SignalApi,
userSettingsLoader?: DataLoader<string, any>,
userSettingsLoader?: DataLoaderType,
) {
this.namespace = namespace;
this.fetchApi = fetchApi;
@@ -92,24 +95,32 @@ export class UserSettingsStorage implements StorageApi {
this.userSettingsLoader =
userSettingsLoader ??
new DataLoader<string, any>(
new DataLoader(
async bucketAndKeyList => this.getMulti(bucketAndKeyList),
{
name: 'UserSettingsStorage.userSettingsLoader',
cacheMap: new CacheMap<string, Promise<unknown>>(
DATALOADER_CACHE_TTL_MS,
),
cacheMap: new CacheMap<
string,
Promise<StorageValueSnapshot<JsonValue>>
>(DATALOADER_CACHE_TTL_MS),
cacheKeyFn: bucketAndKey => this.stringifyDataLoaderKey(bucketAndKey),
maxBatchSize: 100,
batchScheduleFn: cb => setTimeout(cb, DATALOADER_WINDOW_MS),
},
);
}
private stringifyDataLoaderKey(key: string) {
return stringifyDataLoaderKey(this.namespace, key);
private stringifyDataLoaderKey({
bucket,
key,
}: {
bucket: string;
key: string;
}) {
return `${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`;
}
private clearCacheKey(key: string) {
this.userSettingsLoader.clear(this.stringifyDataLoaderKey(key));
this.userSettingsLoader.clear({ bucket: this.namespace, key });
}
static create(options: {
@@ -197,11 +208,14 @@ export class UserSettingsStorage implements StorageApi {
const { value } = await response.json();
this.userSettingsLoader.prime(this.stringifyDataLoaderKey(key), {
key,
presence: 'present',
value,
});
this.userSettingsLoader.prime(
{ bucket: this.namespace, key },
{
key,
presence: 'present',
value,
},
);
this.notifyChanges({ key, value, presence: 'present' });
}
@@ -219,7 +233,7 @@ export class UserSettingsStorage implements StorageApi {
const updateSnapshot = () => {
Promise.resolve()
.then(() =>
this.userSettingsLoader.load(this.stringifyDataLoaderKey(key)),
this.userSettingsLoader.load({ bucket: this.namespace, key }),
)
.then(snapshot => subscriber.next(snapshot))
.catch(error => this.errorApi.post(error));
@@ -256,53 +270,47 @@ export class UserSettingsStorage implements StorageApi {
}
private async getMulti(
bucketAndKeyList: readonly string[],
bucketAndKeyList: readonly { bucket: string; key: string }[],
): Promise<StorageValueSnapshot<JsonValue>[]> {
if (bucketAndKeyList.length === 0) return [];
if (!(await this.isSignedIn())) {
// This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown
return bucketAndKeyList.map(bucketAndKey =>
this.fallback.snapshot(parseDataLoaderKey(bucketAndKey).key),
this.fallback.snapshot(bucketAndKey.key),
);
}
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
const url = new URL(`${baseUrl}/multi`);
for (const bucketAndKey of bucketAndKeyList) {
url.searchParams.append('items', bucketAndKey);
}
const response = await this.fetchApi.fetch(url);
if (response.status === 404) {
return bucketAndKeyList.map(bucketAndKey => ({
key: parseDataLoaderKey(bucketAndKey).key,
presence: 'absent',
}));
}
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
try {
const values = await response.json();
const baseUrl = await this.discoveryApi.getBaseUrl('user-settings');
const response = await this.fetchApi.fetch(`${baseUrl}/multiget`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ items: bucketAndKeyList }),
});
return (values as MultiUserSetting[]).map(
(setting): StorageValueSnapshot<JsonValue> => {
if (isMultiUserSettingError(setting)) {
if (setting.error.name === 'NotFoundError') {
return {
key: setting.key,
presence: 'absent',
};
}
throw deserializeError(setting.error);
if (response.status === 404) {
return bucketAndKeyList.map(bucketAndKey => ({
key: bucketAndKey.key,
presence: 'absent',
}));
}
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const { items: values } = (await response.json()) as MultiGetResponse;
return bucketAndKeyList.map(
({ key }, i): StorageValueSnapshot<JsonValue> => {
if (!values[i]) {
return { key, presence: 'absent' };
}
return {
key: setting.key,
key,
presence: 'present',
value: JSON.parse(JSON.stringify(setting.value), (_key, val) => {
value: JSON.parse(JSON.stringify(values[i].value), (_key, val) => {
if (typeof val === 'object' && val !== null) {
Object.freeze(val);
}
@@ -311,10 +319,10 @@ export class UserSettingsStorage implements StorageApi {
};
},
);
} catch {
// If the value is not valid JSON, we return an unknown presence. This should never happen
} catch (e) {
this.errorApi.post(new Error(`Failed to fetch user settings, ${e}`));
return bucketAndKeyList.map(bucketAndKey => ({
key: parseDataLoaderKey(bucketAndKey).key,
key: bucketAndKey.key,
presence: 'absent',
}));
}