1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: feature link backend stub (#9893)

This commit is contained in:
Mateusz Kwasniewski 2025-05-06 09:31:45 +02:00 committed by GitHub
parent 1166d00e6d
commit 002233e7f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 594 additions and 0 deletions

View File

@ -65,6 +65,7 @@ import { UserUnsubscribeStore } from '../features/user-subscriptions/user-unsubs
import { UserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model';
import { UniqueConnectionStore } from '../features/unique-connection/unique-connection-store';
import { UniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model';
import FakeFeatureLinkStore from '../features/feature-links/fake-feature-link-store';
export const createStores = (
config: IUnleashConfig,
@ -201,6 +202,7 @@ export const createStores = (
releasePlanMilestoneStore: new ReleasePlanMilestoneStore(db, config),
releasePlanMilestoneStrategyStore:
new ReleasePlanMilestoneStrategyStore(db, config),
featureLinkStore: new FakeFeatureLinkStore(),
};
};

View File

@ -0,0 +1,17 @@
import type { IUnleashConfig } from '../../types';
import FeatureLinkService from './feature-link-service';
import FakeFeatureLinkStore from './fake-feature-link-store';
import { createFakeEventsService } from '../events/createEventsService';
export const createFakeFeatureLinkService = (config: IUnleashConfig) => {
const eventService = createFakeEventsService(config);
const featureLinkStore = new FakeFeatureLinkStore();
const featureLinkService = new FeatureLinkService(
{ featureLinkStore },
config,
eventService,
);
return { featureLinkService, featureLinkStore };
};

View File

@ -0,0 +1,53 @@
import { NotFoundError } from '../../error';
import type {
IFeatureLink,
IFeatureLinkStore,
} from './feature-link-store-type';
export default class FakeFeatureLinkStore implements IFeatureLinkStore {
private links: IFeatureLink[] = [];
async create(link: Omit<IFeatureLink, 'id'>): Promise<IFeatureLink> {
const newLink: IFeatureLink = {
...link,
id: String(Math.random()),
};
this.links.push(newLink);
return newLink;
}
async delete(id: string): Promise<void> {
const index = this.links.findIndex((link) => link.id === id);
if (index !== -1) {
this.links.splice(index, 1);
}
}
async deleteAll(): Promise<void> {
this.links = [];
}
destroy(): void {}
async exists(id: string): Promise<boolean> {
return this.links.some((link) => link.id === id);
}
async get(id: string): Promise<IFeatureLink> {
const link = this.links.find((link) => link.id === id);
if (link) {
return link;
}
throw new NotFoundError('Could not find feature link');
}
async getAll(): Promise<IFeatureLink[]> {
return this.links;
}
async update(link: IFeatureLink): Promise<IFeatureLink> {
await this.delete(link.id);
this.links.push(link);
return link;
}
}

View File

@ -0,0 +1,158 @@
import type { Response } from 'express';
import Controller from '../../routes/controller';
import type { IAuthRequest } from '../../routes/unleash-types';
import type { IUnleashConfig } from '../../types/option';
import type { WithTransactional } from '../../db/transaction';
import type FeatureLinkService from './feature-link-service';
import { UPDATE_FEATURE } from '../../types/permissions';
import type { OpenApiService } from '../../services/openapi-service';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import type { IFeatureLink } from './feature-link-store-type';
interface FeatureLinkServices {
transactionalFeatureLinkService: WithTransactional<FeatureLinkService>;
openApiService: OpenApiService;
}
const PATH = '/:projectId/features/:featureName/link';
const PATH_LINK = '/:projectId/features/:featureName/link/:linkId';
export default class FeatureLinkController extends Controller {
private transactionalFeatureLinkService: WithTransactional<FeatureLinkService>;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
transactionalFeatureLinkService,
openApiService,
}: FeatureLinkServices,
) {
super(config);
this.transactionalFeatureLinkService = transactionalFeatureLinkService;
this.openApiService = openApiService;
this.route({
method: 'post',
path: PATH,
handler: this.createFeatureLink,
permission: UPDATE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Unstable'],
operationId: 'createFeatureLink',
summary: 'Create a feature link',
description: 'Create a new link for a feature.',
responses: {
204: emptyResponse,
...getStandardResponses(400, 401, 403, 415),
},
requestBody: createRequestSchema('featureLinkSchema'),
}),
],
});
this.route({
method: 'put',
path: PATH_LINK,
handler: this.updateFeatureLink,
permission: UPDATE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Unstable'],
operationId: 'updateFeatureLink',
summary: 'Update a feature link',
description: 'Update an existing feature link.',
responses: {
204: emptyResponse,
...getStandardResponses(400, 401, 403, 404, 415),
},
requestBody: createRequestSchema('featureLinkSchema'),
}),
],
});
this.route({
method: 'delete',
path: PATH_LINK,
handler: this.deleteFeatureLink,
acceptAnyContentType: true,
permission: UPDATE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Unstable'],
operationId: 'deleteFeatureLink',
summary: 'Delete a feature link',
description: 'Delete a feature link by id.',
responses: {
204: emptyResponse,
...getStandardResponses(401, 403, 404),
},
}),
],
});
}
async createFeatureLink(
req: IAuthRequest<
{ projectId: string; featureName: string },
unknown,
Omit<IFeatureLink, 'id' | 'createdAt'>
>,
res: Response,
): Promise<void> {
const { projectId, featureName } = req.params;
await this.transactionalFeatureLinkService.transactional((service) =>
service.createLink(
projectId,
{ ...req.body, featureName },
req.audit,
),
);
res.status(204).end();
}
async updateFeatureLink(
req: IAuthRequest<
{ projectId: string; linkId: string; featureName: string },
unknown,
Omit<IFeatureLink, 'id'>
>,
res: Response,
): Promise<void> {
const { projectId, linkId, featureName } = req.params;
await this.transactionalFeatureLinkService.transactional((service) =>
service.updateLink(
{ projectId, linkId },
{ ...req.body, featureName },
req.audit,
),
);
res.status(204).end();
}
async deleteFeatureLink(
req: IAuthRequest<
{ projectId: string; linkId: string },
unknown,
Omit<IFeatureLink, 'id'>
>,
res: Response,
): Promise<void> {
const { projectId, linkId } = req.params;
await this.transactionalFeatureLinkService.transactional((service) =>
service.deleteLink({ projectId, linkId }, req.audit),
);
res.status(204).end();
}
}

View File

@ -0,0 +1,60 @@
import { createFakeFeatureLinkService } from './createFeatureLinkService';
import type { IAuditUser, IUnleashConfig } from '../../types';
import getLogger from '../../../test/fixtures/no-logger';
import { NotFoundError } from '../../error';
test('create, update and delete feature link', async () => {
const { featureLinkStore, featureLinkService } =
createFakeFeatureLinkService({
getLogger,
} as unknown as IUnleashConfig);
const link = await featureLinkService.createLink(
'default',
{ featureName: 'feature', url: 'example.com', title: 'some title' },
{} as IAuditUser,
);
expect(link).toMatchObject({
featureName: 'feature',
url: 'example.com',
title: 'some title',
});
const newLink = await featureLinkService.updateLink(
{ projectId: 'default', linkId: link.id },
{ title: 'new title', url: 'example1.com', featureName: 'feature' },
{} as IAuditUser,
);
expect(newLink).toMatchObject({
featureName: 'feature',
url: 'example1.com',
title: 'new title',
});
await featureLinkService.deleteLink(
{ projectId: 'default', linkId: link.id },
{} as IAuditUser,
);
expect(await featureLinkStore.getAll()).toMatchObject([]);
});
test('cannot delete/update non existent link', async () => {
const { featureLinkStore, featureLinkService } =
createFakeFeatureLinkService({
getLogger,
} as unknown as IUnleashConfig);
await expect(
featureLinkService.updateLink(
{ projectId: 'default', linkId: 'nonexitent' },
{ title: 'new title', url: 'example1.com', featureName: 'feature' },
{} as IAuditUser,
),
).rejects.toThrow(NotFoundError);
await expect(
featureLinkService.deleteLink(
{ projectId: 'default', linkId: 'nonexitent' },
{} as IAuditUser,
),
).rejects.toThrow(NotFoundError);
});

View File

@ -0,0 +1,108 @@
import type { Logger } from '../../logger';
import {
type IUnleashConfig,
type IAuditUser,
FeatureLinkAddedEvent,
FeatureLinkUpdatedEvent,
FeatureLinkRemovedEvent,
} from '../../types';
import type {
IFeatureLink,
IFeatureLinkStore,
} from './feature-link-store-type';
import type EventService from '../events/event-service';
import { NotFoundError } from '../../error';
interface IFeatureLinkStoreObj {
featureLinkStore: IFeatureLinkStore;
}
export default class FeatureLinkService {
private logger: Logger;
private featureLinkStore: IFeatureLinkStore;
private eventService: EventService;
constructor(
stores: IFeatureLinkStoreObj,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
eventService: EventService,
) {
this.logger = getLogger('feature-links/feature-link-service.ts');
this.featureLinkStore = stores.featureLinkStore;
this.eventService = eventService;
}
async getAll(): Promise<IFeatureLink[]> {
return this.featureLinkStore.getAll();
}
async createLink(
projectId: string,
newLink: Omit<IFeatureLink, 'id'>,
auditUser: IAuditUser,
): Promise<IFeatureLink> {
const link = await this.featureLinkStore.create(newLink);
await this.eventService.storeEvent(
new FeatureLinkAddedEvent({
featureName: newLink.featureName,
project: projectId,
data: { url: newLink.url, title: newLink.title },
auditUser,
}),
);
return link;
}
async updateLink(
{ projectId, linkId }: { projectId: string; linkId: string },
updatedLink: Omit<IFeatureLink, 'id'>,
auditUser: IAuditUser,
): Promise<IFeatureLink> {
const preData = await this.featureLinkStore.get(linkId);
if (!preData) {
throw new NotFoundError(`Could not find link with id ${linkId}`);
}
const link = await this.featureLinkStore.update({
...updatedLink,
id: linkId,
});
await this.eventService.storeEvent(
new FeatureLinkUpdatedEvent({
featureName: updatedLink.featureName,
project: projectId,
data: { url: link.url, title: link.title },
preData: { url: preData.url, title: preData.title },
auditUser,
}),
);
return link;
}
async deleteLink(
{ projectId, linkId }: { projectId: string; linkId: string },
auditUser: IAuditUser,
): Promise<void> {
const link = await this.featureLinkStore.get(linkId);
if (!link) {
throw new NotFoundError(`Could not find link with id ${linkId}`);
}
await this.featureLinkStore.delete(linkId);
await this.eventService.storeEvent(
new FeatureLinkRemovedEvent({
featureName: link.featureName,
project: projectId,
preData: { url: link.url, title: link.title },
auditUser,
}),
);
}
}

View File

@ -0,0 +1,13 @@
import type { Store } from '../../types/stores/store';
export interface IFeatureLink {
id: string;
featureName: string;
url: string;
title?: string;
}
export interface IFeatureLinkStore extends Store<IFeatureLink, string> {
create(link: Omit<IFeatureLink, 'id'>): Promise<IFeatureLink>;
update(link: IFeatureLink): Promise<IFeatureLink>;
}

View File

@ -0,0 +1,63 @@
import {
type IUnleashTest,
setupAppWithAuth,
} from '../../../test/e2e/helpers/test-helper';
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import type { IEventStore, IFeatureLinkStore } from '../../types';
import getLogger from '../../../test/fixtures/no-logger';
import type { FeatureLinkSchema } from '../../openapi/spec/feature-link-schema';
let app: IUnleashTest;
let db: ITestDb;
let featureLinkStore: IFeatureLinkStore;
let eventStore: IEventStore;
beforeAll(async () => {
db = await dbInit('feature_link', getLogger, {
dbInitMethod: 'legacy' as const,
});
app = await setupAppWithAuth(
db.stores,
{
experimental: {
flags: {},
},
},
db.rawDatabase,
);
eventStore = db.stores.eventStore;
featureLinkStore = db.stores.featureLinkStore;
await app.request
.post(`/auth/demo/login`)
.send({
email: 'user@getunleash.io',
})
.expect(200);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
beforeEach(async () => {
await featureLinkStore.deleteAll();
});
const addLink = async (
featureName: string,
link: FeatureLinkSchema,
expectedCode = 204,
) => {
return app.request
.post(`/api/admin/projects/default/features/${featureName}/link`)
.send(link)
.expect(expectedCode);
};
test('should add feature links', async () => {
await app.createFeature('my_feature');
await addLink('my_feature', { url: 'example.com', title: 'feature link' });
});

View File

@ -14,3 +14,4 @@ export * from './playground/createPlaygroundService';
export * from './personal-dashboard/createPersonalDashboardService';
export * from './user-subscriptions/createUserSubscriptionsService';
export * from './context/createContextService';
export * from './feature-links/createFeatureLinkService';

View File

@ -49,6 +49,7 @@ import {
type ProjectFlagCreatorsSchema,
} from '../../openapi/spec/project-flag-creators-schema';
import ProjectStatusController from '../project-status/project-status-controller';
import FeatureLinkController from '../feature-links/feature-link-controller';
export default class ProjectController extends Controller {
private projectService: ProjectService;
@ -248,6 +249,7 @@ export default class ProjectController extends Controller {
this.use('/', new ProjectInsightsController(config, services).router);
this.use('/', new ProjectStatusController(config, services).router);
this.use('/', new FeatureLifecycleController(config, services).router);
this.use('/', new FeatureLinkController(config, services).router);
}
async getProjects(

View File

@ -0,0 +1,26 @@
import type { FromSchema } from 'json-schema-to-ts';
export const featureLinkSchema = {
$id: '#/components/schemas/featureLinkSchema',
type: 'object',
required: ['url'],
properties: {
url: {
type: 'string',
example: 'https://github.com/search?q=cleanupReminder&type=code',
description: 'The URL the feature is linked to',
},
title: {
type: 'string',
example: 'Github cleanup',
description: 'The description of the link',
nullable: true,
},
},
description: 'The link to any URL related to the feature',
components: {
schemas: {},
},
} as const;
export type FeatureLinkSchema = FromSchema<typeof featureLinkSchema>;

View File

@ -85,6 +85,7 @@ export * from './feature-events-schema';
export * from './feature-lifecycle-completed-schema';
export * from './feature-lifecycle-count-schema';
export * from './feature-lifecycle-schema';
export * from './feature-link-schema';
export * from './feature-metrics-schema';
export * from './feature-schema';
export * from './feature-search-environment-schema';

View File

@ -158,6 +158,7 @@ import {
createFakeContextService,
} from '../features/context/createContextService';
import { UniqueConnectionService } from '../features/unique-connection/unique-connection-service';
import { createFakeFeatureLinkService } from '../features/feature-links/createFeatureLinkService';
export const createServices = (
stores: IUnleashStores,
@ -424,6 +425,10 @@ export const createServices = (
? withTransactional(createUserSubscriptionsService(config), db)
: withFakeTransactional(createFakeUserSubscriptionsService(config));
const transactionalFeatureLinkService = withFakeTransactional(
createFakeFeatureLinkService(config).featureLinkService,
);
return {
transactionalAccessService,
accessService,
@ -493,6 +498,7 @@ export const createServices = (
transactionalUserSubscriptionsService,
uniqueConnectionService,
featureLifecycleReadModel,
transactionalFeatureLinkService,
};
};

View File

@ -18,6 +18,9 @@ export const APPLICATION_CREATED = 'application-created' as const;
export const FEATURE_CREATED = 'feature-created' as const;
export const FEATURE_DELETED = 'feature-deleted' as const;
export const FEATURE_UPDATED = 'feature-updated' as const;
export const FEATURE_LINK_ADDED = 'feature-link-added' as const;
export const FEATURE_LINK_REMOVED = 'feature-link-removed' as const;
export const FEATURE_LINK_UPDATED = 'feature-link-updated' as const;
export const FEATURE_DEPENDENCY_ADDED = 'feature-dependency-added' as const;
export const FEATURE_DEPENDENCY_REMOVED = 'feature-dependency-removed' as const;
export const FEATURE_DEPENDENCIES_REMOVED =
@ -242,6 +245,9 @@ export const IEventTypes = [
FEATURE_TYPE_UPDATED,
FEATURE_COMPLETED,
FEATURE_UNCOMPLETED,
FEATURE_LINK_ADDED,
FEATURE_LINK_REMOVED,
FEATURE_LINK_UPDATED,
STRATEGY_ORDER_CHANGED,
DROP_FEATURE_TAGS,
FEATURE_UNTAGGED,
@ -1023,6 +1029,77 @@ export class FeatureMetadataUpdateEvent extends BaseEvent {
}
}
export class FeatureLinkAddedEvent extends BaseEvent {
readonly project: string;
readonly featureName: string;
readonly data: { url: string; title?: string };
readonly preData: null;
constructor(p: {
featureName: string;
project: string;
data: { url: string; title?: string };
auditUser: IAuditUser;
}) {
super(FEATURE_LINK_ADDED, p.auditUser);
const { project, featureName, data } = p;
this.project = project;
this.featureName = featureName;
this.data = data;
this.preData = null;
}
}
export class FeatureLinkUpdatedEvent extends BaseEvent {
readonly project: string;
readonly featureName: string;
readonly data: { url: string; title?: string };
readonly preData: { url: string; title?: string };
constructor(p: {
featureName: string;
project: string;
data: { url: string; title?: string };
preData: { url: string; title?: string };
auditUser: IAuditUser;
}) {
super(FEATURE_LINK_UPDATED, p.auditUser);
const { project, featureName, data, preData } = p;
this.project = project;
this.featureName = featureName;
this.data = data;
this.preData = preData;
}
}
export class FeatureLinkRemovedEvent extends BaseEvent {
readonly project: string;
readonly featureName: string;
readonly preData: { url: string; title?: string };
readonly data: null;
constructor(p: {
featureName: string;
project: string;
preData: { url: string; title?: string };
auditUser: IAuditUser;
}) {
super(FEATURE_LINK_REMOVED, p.auditUser);
const { project, featureName, preData } = p;
this.project = project;
this.featureName = featureName;
this.data = null;
this.preData = preData;
}
}
export class FeatureStrategyAddEvent extends BaseEvent {
readonly project: string;

View File

@ -61,6 +61,7 @@ import type { ProjectStatusService } from '../features/project-status/project-st
import type { UserSubscriptionsService } from '../features/user-subscriptions/user-subscriptions-service';
import type { UniqueConnectionService } from '../features/unique-connection/unique-connection-service';
import type { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type';
import type FeatureLinkService from '../features/feature-links/feature-link-service';
export interface IUnleashServices {
transactionalAccessService: WithTransactional<AccessService>;
@ -133,4 +134,5 @@ export interface IUnleashServices {
transactionalUserSubscriptionsService: WithTransactional<UserSubscriptionsService>;
uniqueConnectionService: UniqueConnectionService;
featureLifecycleReadModel: IFeatureLifecycleReadModel;
transactionalFeatureLinkService: WithTransactional<FeatureLinkService>;
}

View File

@ -59,6 +59,7 @@ import { ReleasePlanStore } from '../features/release-plans/release-plan-store';
import { ReleasePlanTemplateStore } from '../features/release-plans/release-plan-template-store';
import { ReleasePlanMilestoneStore } from '../features/release-plans/release-plan-milestone-store';
import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/release-plan-milestone-strategy-store';
import type { IFeatureLinkStore } from '../features/feature-links/feature-link-store-type';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -122,6 +123,7 @@ export interface IUnleashStores {
releasePlanTemplateStore: ReleasePlanTemplateStore;
releasePlanMilestoneStore: ReleasePlanMilestoneStore;
releasePlanMilestoneStrategyStore: ReleasePlanMilestoneStrategyStore;
featureLinkStore: IFeatureLinkStore;
}
export {
@ -183,4 +185,5 @@ export {
ReleasePlanTemplateStore,
ReleasePlanMilestoneStore,
ReleasePlanMilestoneStrategyStore,
type IFeatureLinkStore,
};

View File

@ -62,6 +62,7 @@ import { FakeUserUnsubscribeStore } from '../../lib/features/user-subscriptions/
import { FakeUserSubscriptionsReadModel } from '../../lib/features/user-subscriptions/fake-user-subscriptions-read-model';
import { FakeUniqueConnectionStore } from '../../lib/features/unique-connection/fake-unique-connection-store';
import { UniqueConnectionReadModel } from '../../lib/features/unique-connection/unique-connection-read-model';
import FakeFeatureLinkStore from '../../lib/features/feature-links/fake-feature-link-store';
const db = {
select: () => ({
@ -138,6 +139,7 @@ const createStores: () => IUnleashStores = () => {
releasePlanTemplateStore: {} as ReleasePlanTemplateStore,
releasePlanMilestoneStrategyStore:
{} as ReleasePlanMilestoneStrategyStore,
featureLinkStore: new FakeFeatureLinkStore(),
};
};