Skip to content

Commit

Permalink
add client-side feature usage API (elastic#75486)
Browse files Browse the repository at this point in the history
* add client-side feature_usage API

* use route context for notify feature usage route
  • Loading branch information
pgayvallet committed Aug 31, 2020
1 parent ee7c071 commit 0a8dca9
Show file tree
Hide file tree
Showing 14 changed files with 384 additions and 3 deletions.
3 changes: 3 additions & 0 deletions x-pack/plugins/licensing/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
import { BehaviorSubject } from 'rxjs';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { licenseMock } from '../common/licensing.mock';
import { featureUsageMock } from './services/feature_usage_service.mock';

const createSetupMock = () => {
const license = licenseMock.createLicense();
const mock: jest.Mocked<LicensingPluginSetup> = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
featureUsage: featureUsageMock.createSetup(),
};
mock.refresh.mockResolvedValue(license);

Expand All @@ -23,6 +25,7 @@ const createStartMock = () => {
const mock: jest.Mocked<LicensingPluginStart> = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
featureUsage: featureUsageMock.createStart(),
};
mock.refresh.mockResolvedValue(license);

Expand Down
5 changes: 4 additions & 1 deletion x-pack/plugins/licensing/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import { Observable, Subject, Subscription } from 'rxjs';

import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';

import { ILicense } from '../common/types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { createLicenseUpdate } from '../common/license_update';
import { License } from '../common/license';
import { mountExpiredBanner } from './expired_banner';
import { FeatureUsageService } from './services';

export const licensingSessionStorageKey = 'xpack.licensing';

Expand Down Expand Up @@ -39,6 +39,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl

private refresh?: () => Promise<ILicense>;
private license$?: Observable<ILicense>;
private featureUsage = new FeatureUsageService();

constructor(
context: PluginInitializerContext,
Expand Down Expand Up @@ -116,6 +117,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
return {
refresh: refreshManually,
license$,
featureUsage: this.featureUsage.setup({ http: core.http }),
};
}

Expand All @@ -127,6 +129,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
return {
refresh: this.refresh,
license$: this.license$,
featureUsage: this.featureUsage.start({ http: core.http }),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import {
FeatureUsageService,
FeatureUsageServiceSetup,
FeatureUsageServiceStart,
} from './feature_usage_service';

const createSetupMock = (): jest.Mocked<FeatureUsageServiceSetup> => {
const mock = {
register: jest.fn(),
};

return mock;
};

const createStartMock = (): jest.Mocked<FeatureUsageServiceStart> => {
const mock = {
notifyUsage: jest.fn(),
};

return mock;
};

const createServiceMock = (): jest.Mocked<PublicMethodsOf<FeatureUsageService>> => {
const mock = {
setup: jest.fn(),
start: jest.fn(),
};

mock.setup.mockImplementation(() => createSetupMock());
mock.start.mockImplementation(() => createStartMock());

return mock;
};

export const featureUsageMock = {
create: createServiceMock,
createSetup: createSetupMock,
createStart: createStartMock,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { httpServiceMock } from '../../../../../src/core/public/mocks';
import { FeatureUsageService } from './feature_usage_service';

describe('FeatureUsageService', () => {
let http: ReturnType<typeof httpServiceMock.createSetupContract>;
let service: FeatureUsageService;

beforeEach(() => {
http = httpServiceMock.createSetupContract();
service = new FeatureUsageService();
});

describe('#setup', () => {
describe('#register', () => {
it('calls the endpoint with the correct parameters', async () => {
const setup = service.setup({ http });
await setup.register('my-feature', 'platinum');
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/register', {
body: JSON.stringify({
featureName: 'my-feature',
licenseType: 'platinum',
}),
});
});
});
});

describe('#start', () => {
describe('#notifyUsage', () => {
it('calls the endpoint with the correct parameters', async () => {
service.setup({ http });
const start = service.start({ http });
await start.notifyUsage('my-feature', 42);

expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/notify', {
body: JSON.stringify({
featureName: 'my-feature',
lastUsed: 42,
}),
});
});

it('correctly convert dates', async () => {
service.setup({ http });
const start = service.start({ http });

const now = new Date();

await start.notifyUsage('my-feature', now);

expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith('/internal/licensing/feature_usage/notify', {
body: JSON.stringify({
featureName: 'my-feature',
lastUsed: now.getTime(),
}),
});
});
});
});
});
68 changes: 68 additions & 0 deletions x-pack/plugins/licensing/public/services/feature_usage_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import isDate from 'lodash/isDate';
import type { HttpSetup, HttpStart } from 'src/core/public';
import { LicenseType } from '../../common/types';

/** @public */
export interface FeatureUsageServiceSetup {
/**
* Register a feature to be able to notify of it's usages using the {@link FeatureUsageServiceStart | service start contract}.
*/
register(featureName: string, licenseType: LicenseType): Promise<void>;
}

/** @public */
export interface FeatureUsageServiceStart {
/**
* Notify of a registered feature usage at given time.
*
* @param featureName - the name of the feature to notify usage of
* @param usedAt - Either a `Date` or an unix timestamp with ms. If not specified, it will be set to the current time.
*/
notifyUsage(featureName: string, usedAt?: Date | number): Promise<void>;
}

interface SetupDeps {
http: HttpSetup;
}

interface StartDeps {
http: HttpStart;
}

/**
* @internal
*/
export class FeatureUsageService {
public setup({ http }: SetupDeps): FeatureUsageServiceSetup {
return {
register: async (featureName, licenseType) => {
await http.post('/internal/licensing/feature_usage/register', {
body: JSON.stringify({
featureName,
licenseType,
}),
});
},
};
}

public start({ http }: StartDeps): FeatureUsageServiceStart {
return {
notifyUsage: async (featureName, usedAt = Date.now()) => {
const lastUsed = isDate(usedAt) ? usedAt.getTime() : usedAt;
await http.post('/internal/licensing/feature_usage/notify', {
body: JSON.stringify({
featureName,
lastUsed,
}),
});
},
};
}
}
11 changes: 11 additions & 0 deletions x-pack/plugins/licensing/public/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export {
FeatureUsageService,
FeatureUsageServiceSetup,
FeatureUsageServiceStart,
} from './feature_usage_service';
9 changes: 9 additions & 0 deletions x-pack/plugins/licensing/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { Observable } from 'rxjs';

import { ILicense } from '../common/types';
import { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services';

/** @public */
export interface LicensingPluginSetup {
Expand All @@ -19,6 +20,10 @@ export interface LicensingPluginSetup {
* @deprecated in favour of the counterpart provided from start contract
*/
refresh(): Promise<ILicense>;
/**
* APIs to register licensed feature usage.
*/
featureUsage: FeatureUsageServiceSetup;
}

/** @public */
Expand All @@ -31,4 +36,8 @@ export interface LicensingPluginStart {
* Triggers licensing information re-fetch.
*/
refresh(): Promise<ILicense>;
/**
* APIs to manage licensed feature usage.
*/
featureUsage: FeatureUsageServiceStart;
}
6 changes: 4 additions & 2 deletions x-pack/plugins/licensing/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
createRouteHandlerContext(license$, core.getStartServices)
);

registerRoutes(core.http.createRouter(), core.getStartServices);
const featureUsageSetup = this.featureUsage.setup();

registerRoutes(core.http.createRouter(), featureUsageSetup, core.getStartServices);
core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$));

this.refresh = refresh;
Expand All @@ -143,7 +145,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
refresh,
license$,
createLicensePoller: this.createLicensePoller.bind(this),
featureUsage: this.featureUsage.setup(),
featureUsage: featureUsageSetup,
};
}

Expand Down
5 changes: 5 additions & 0 deletions x-pack/plugins/licensing/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@

import { IRouter, StartServicesAccessor } from 'src/core/server';
import { LicensingPluginStart } from '../types';
import { FeatureUsageServiceSetup } from '../services';
import { registerInfoRoute } from './info';
import { registerFeatureUsageRoute } from './feature_usage';
import { registerNotifyFeatureUsageRoute, registerRegisterFeatureRoute } from './internal';

export function registerRoutes(
router: IRouter,
featureUsageSetup: FeatureUsageServiceSetup,
getStartServices: StartServicesAccessor<{}, LicensingPluginStart>
) {
registerInfoRoute(router);
registerFeatureUsageRoute(router, getStartServices);
registerRegisterFeatureRoute(router, featureUsageSetup);
registerNotifyFeatureUsageRoute(router);
}
8 changes: 8 additions & 0 deletions x-pack/plugins/licensing/server/routes/internal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

export { registerNotifyFeatureUsageRoute } from './notify_feature_usage';
export { registerRegisterFeatureRoute } from './register_feature';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { IRouter } from 'src/core/server';

export function registerNotifyFeatureUsageRoute(router: IRouter) {
router.post(
{
path: '/internal/licensing/feature_usage/notify',
validate: {
body: schema.object({
featureName: schema.string(),
lastUsed: schema.number(),
}),
},
},
async (context, request, response) => {
const { featureName, lastUsed } = request.body;

context.licensing.featureUsage.notifyUsage(featureName, lastUsed);

return response.ok({
body: {
success: true,
},
});
}
);
}
Loading

0 comments on commit 0a8dca9

Please sign in to comment.