[Spaces] Content summary API (#182921)

## Summary

Added space content summary `GET` API endpoint which returns summary of
the different saved objects that exist within each space.

Request `GET kbn:/internal/spaces/a-space/content_summary`
Response
```
{
  "summary": [
    {
      "count": 1,
      "type": "config",
      "displayName": "Config"
    },
    {
      "count": 2,
      "type": "dashboard",
      "displayName": "Dashboard",
      "icon": "dashboardApp"
    },
    {
      "count": 4,
      "type": "index-pattern",
      "displayName": "data view",
      "icon": "indexPatternApp"
    },
    {
      "count": 2,
      "type": "tag",
      "displayName": "Tag",
      "icon": "tag"
    }
  ],
  "total": 9
}
```

Request `GET kbn:/internal/spaces/not-a-space/content_summary`
Response
```
{
  "statusCode": 404,
  "error": "Not Found",
  "message": "Not Found"
}
```


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed.
[Report](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5903)

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

__Fixes: https://github.com/elastic/kibana/issues/182406__
This commit is contained in:
elena-shostak 2024-05-13 12:40:18 +02:00 committed by GitHub
parent e61faa64ba
commit a48646cc96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 679 additions and 0 deletions

View file

@ -0,0 +1,414 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { capitalize } from 'lodash';
import * as Rx from 'rxjs';
import type { SavedObjectsNamespaceType, SavedObjectsType } from '@kbn/core/server';
import { kibanaResponseFactory } from '@kbn/core/server';
import {
coreMock,
httpServerMock,
httpServiceMock,
savedObjectsClientMock,
savedObjectsTypeRegistryMock,
} from '@kbn/core/server/mocks';
import type { SpaceContentTypeSummaryItem } from './get_content_summary';
import { initGetSpaceContentSummaryApi } from './get_content_summary';
import { spacesConfig } from '../../../lib/__fixtures__';
import { SpacesClientService } from '../../../spaces_client';
import { SpacesService } from '../../../spaces_service';
import {
createMockSavedObjectsRepository,
createSpaces,
mockRouteContext,
mockRouteContextWithInvalidLicense,
} from '../__fixtures__';
interface SetupParams {
importableAndExportableTypesMock: SavedObjectsType[];
}
describe('GET /internal/spaces/{spaceId}/content_summary', () => {
const spacesSavedObjects = createSpaces();
const setup = async (params?: SetupParams) => {
const httpService = httpServiceMock.createSetupContract();
const router = httpServiceMock.createRouter();
const coreStart = coreMock.createStart();
const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects);
const clientService = new SpacesClientService(jest.fn());
clientService
.setup({ config$: Rx.of(spacesConfig) })
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
const savedObjectsClient = savedObjectsClientMock.create();
const typeRegistry = savedObjectsTypeRegistryMock.create();
typeRegistry.getImportableAndExportableTypes.mockReturnValue(
params?.importableAndExportableTypesMock ?? [
// don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic)
{
name: 'dashboard',
namespaceType: 'multiple',
hidden: false,
mappings: { properties: {} },
},
{
name: 'globaltype',
namespaceType: 'agnostic',
hidden: false,
mappings: { properties: {} },
},
]
);
typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) =>
typeRegistry
.getImportableAndExportableTypes()
.some((t) => t.name === type && t.namespaceType === 'agnostic')
);
const service = new SpacesService();
service.setup({
basePath: httpService.basePath,
});
const clientServiceStart = clientService.start(coreStart);
const spacesServiceStart = service.start({
basePath: coreStart.http.basePath,
spacesClientService: clientServiceStart,
});
const routeContext = {
...mockRouteContext,
core: {
savedObjects: {
getClient: () => savedObjectsClient,
typeRegistry,
},
},
};
initGetSpaceContentSummaryApi({
router,
getSpacesService: () => spacesServiceStart,
});
const [[config, routeHandler]] = router.get.mock.calls;
return {
config,
routeHandler,
savedObjectsClient,
typeRegistry,
routeContext,
};
};
it('correctly defines route.', async () => {
const { config } = await setup();
const paramsSchema = (config.validate as any).params;
expect(config.options).toEqual({ tags: ['access:manageSpaces'] });
expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot(
`"[spaceId]: expected value of type [string] but got [undefined]"`
);
expect(() => paramsSchema.validate({ spaceId: '' })).toThrowErrorMatchingInlineSnapshot(
`"[spaceId]: value has length [0] but it must have a minimum length of [1]."`
);
expect(() => paramsSchema.validate({ spaceId: '*' })).toThrowErrorMatchingInlineSnapshot(
`"[spaceId]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed."`
);
});
it('returns http/403 when the license is invalid.', async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'get',
});
const response = await routeHandler(
mockRouteContextWithInvalidLicense,
request,
kibanaResponseFactory
);
expect(response.status).toEqual(403);
expect(response.payload).toEqual({
message: 'License is invalid for spaces',
});
});
it('returns http/404 when retrieving a non-existent space.', async () => {
const { routeHandler, routeContext } = await setup();
const request = httpServerMock.createKibanaRequest({
params: {
spaceId: 'not-a-space',
},
method: 'get',
});
const response = await routeHandler(routeContext, request, kibanaResponseFactory);
expect(response.status).toEqual(404);
});
it('returns http/200 with non agnostic namespace types.', async () => {
const importableAndExportableTypesMock = [
{
name: 'dashboard',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
management: {
displayName: 'dashboardDisplayName',
icon: 'dashboardIcon',
},
mappings: { properties: {} },
},
{
name: 'query',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
mappings: { properties: {} },
},
{
name: 'globaltype',
namespaceType: 'agnostic' as SavedObjectsNamespaceType,
hidden: false,
mappings: { properties: {} },
},
];
const { routeHandler, routeContext, savedObjectsClient } = await setup({
importableAndExportableTypesMock,
});
const request = httpServerMock.createKibanaRequest({
params: {
spaceId: 'a-space',
},
method: 'get',
});
const mockAggregationResult = {
total: 6,
aggregations: {
typesAggregation: {
buckets: [
{
key: 'dashboard',
doc_count: 5,
},
{
key: 'query',
doc_count: 1,
},
],
},
},
};
const findMock = savedObjectsClient.find as jest.Mock;
findMock.mockReturnValue(mockAggregationResult);
const response = await routeHandler(routeContext, request, kibanaResponseFactory);
expect(findMock).toBeCalledWith({
type: ['dashboard', 'query'],
namespaces: ['a-space'],
perPage: 0,
aggs: {
typesAggregation: {
terms: {
field: 'type',
size: 2,
},
},
},
});
expect(response.status).toEqual(200);
expect(response.payload?.summary).toHaveLength(2);
});
it('returns http/200 with correct meta information.', async () => {
const importableAndExportableTypesMock = [
{
name: 'dashboard',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
management: {
displayName: 'dashboardDisplayName',
icon: 'dashboardIcon',
},
mappings: { properties: {} },
},
{
name: 'query',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
mappings: { properties: {} },
},
];
const { routeHandler, routeContext, savedObjectsClient } = await setup({
importableAndExportableTypesMock,
});
const request = httpServerMock.createKibanaRequest({
params: {
spaceId: 'a-space',
},
method: 'get',
});
const mockAggregationResult = {
total: 10,
aggregations: {
typesAggregation: {
buckets: [
{
key: 'dashboard',
doc_count: 5,
},
{
key: 'query',
doc_count: 5,
},
],
},
},
};
const findMock = savedObjectsClient.find as jest.Mock;
findMock.mockReturnValue(mockAggregationResult);
const response = await routeHandler(routeContext, request, kibanaResponseFactory);
expect(findMock).toBeCalledWith({
type: ['dashboard', 'query'],
namespaces: ['a-space'],
perPage: 0,
aggs: {
typesAggregation: {
terms: {
field: 'type',
size: 2,
},
},
},
});
expect(response.status).toEqual(200);
expect(response.payload!.summary).toHaveLength(2);
const [dashboardType, queryType] = importableAndExportableTypesMock;
const [dashboardTypeSummary, queryTypeSummary] = response.payload!.summary;
expect(dashboardTypeSummary.displayName).toEqual(dashboardType.management?.displayName);
expect(dashboardTypeSummary.icon).toEqual(dashboardType.management?.icon);
expect(queryTypeSummary.displayName).toEqual(capitalize(queryType.name));
expect(queryTypeSummary.icon).toBe(undefined);
});
it('returns http/200 with data sorted by displayName.', async () => {
const importableAndExportableTypesMock = [
{
name: 'dashboard',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
management: {
displayName: 'Test Display Dashboard Name',
},
mappings: { properties: {} },
},
{
name: 'query',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
management: {
displayName: 'My Display Name',
},
mappings: { properties: {} },
},
{
name: 'search',
namespaceType: 'multiple' as SavedObjectsNamespaceType,
hidden: false,
mappings: { properties: {} },
},
];
const { routeHandler, routeContext, savedObjectsClient } = await setup({
importableAndExportableTypesMock,
});
const request = httpServerMock.createKibanaRequest({
params: {
spaceId: 'a-space',
},
method: 'get',
});
const mockAggregationResult = {
total: 15,
aggregations: {
typesAggregation: {
buckets: [
{
key: 'dashboard',
doc_count: 5,
},
{
key: 'query',
doc_count: 5,
},
{
key: 'search',
doc_count: 5,
},
],
},
},
};
const findMock = savedObjectsClient.find as jest.Mock;
findMock.mockReturnValue(mockAggregationResult);
const response = await routeHandler(routeContext, request, kibanaResponseFactory);
expect(findMock).toBeCalledWith({
type: ['dashboard', 'query', 'search'],
namespaces: ['a-space'],
perPage: 0,
aggs: {
typesAggregation: {
terms: {
field: 'type',
size: 3,
},
},
},
});
expect(response.status).toEqual(200);
expect(response.payload!.summary).toHaveLength(3);
const types = response.payload!.summary.map((item: SpaceContentTypeSummaryItem) => item.type);
expect(types).toEqual(['query', 'search', 'dashboard']);
});
});

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { capitalize, sortBy } from 'lodash';
import { schema } from '@kbn/config-schema';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import type { InternalRouteDeps } from '.';
import { wrapError } from '../../../lib/errors';
import { SPACE_ID_REGEX } from '../../../lib/space_schema';
import { createLicensedRouteHandler } from '../../lib';
interface SpaceContentTypeMetaInfo {
displayName: string;
icon?: string;
}
interface TypesAggregation {
typesAggregation: {
buckets: Array<{ doc_count: number; key: string }>;
};
}
type SpaceContentTypesMetaData = Record<string, SpaceContentTypeMetaInfo>;
export interface SpaceContentTypeSummaryItem extends SpaceContentTypeMetaInfo {
count: number;
type: string;
}
export function initGetSpaceContentSummaryApi(deps: InternalRouteDeps) {
const { router, getSpacesService } = deps;
router.get(
{
path: '/internal/spaces/{spaceId}/content_summary',
options: {
tags: ['access:manageSpaces'],
},
validate: {
params: schema.object({
spaceId: schema.string({
validate: (value) => {
if (!SPACE_ID_REGEX.test(value)) {
return `lower case, a-z, 0-9, "_", and "-" are allowed.`;
}
},
minLength: 1,
}),
}),
},
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const spaceId = request.params.spaceId;
const spacesClient = getSpacesService().createSpacesClient(request);
await spacesClient.get(spaceId);
const { getClient, typeRegistry } = (await context.core).savedObjects;
const client = getClient();
const types = typeRegistry
.getImportableAndExportableTypes()
.filter((type) => !typeRegistry.isNamespaceAgnostic(type.name));
const searchTypeNames = types.map((type) => type.name);
const data = await client.find<unknown, TypesAggregation>({
type: searchTypeNames,
perPage: 0,
namespaces: [spaceId],
aggs: {
typesAggregation: {
terms: {
field: 'type',
size: types.length,
},
},
},
});
const typesMetaInfo = types.reduce<SpaceContentTypesMetaData>((acc, currentType) => {
acc[currentType.name] = {
displayName: currentType.management?.displayName ?? capitalize(currentType.name),
icon: currentType.management?.icon,
};
return acc;
}, {});
const summary = sortBy(
data.aggregations?.typesAggregation.buckets.map<SpaceContentTypeSummaryItem>((item) => ({
count: item.doc_count,
type: item.key,
...typesMetaInfo[item.key],
})),
(item) => item.displayName.toLowerCase()
);
return response.ok({ body: { summary, total: data.total } });
} catch (error) {
if (SavedObjectsErrorHelpers.isNotFoundError(error)) {
return response.notFound();
}
return response.customError(wrapError(error));
}
})
);
}

View file

@ -6,6 +6,7 @@
*/
import { initGetActiveSpaceApi } from './get_active_space';
import { initGetSpaceContentSummaryApi } from './get_content_summary';
import type { SpacesServiceStart } from '../../../spaces_service/spaces_service';
import type { SpacesRouter } from '../../../types';
@ -16,4 +17,5 @@ export interface InternalRouteDeps {
export function initInternalSpacesApi(deps: InternalRouteDeps) {
initGetActiveSpaceApi(deps);
initGetSpaceContentSummaryApi(deps);
}

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
const sampleDashboard = {
contentTypeId: 'dashboard',
data: {
kibanaSavedObjectMeta: {},
title: 'Sample dashboard',
},
options: {
references: [],
overwrite: true,
},
version: 2,
};
const sampleIndexPattern = {
contentTypeId: 'index-pattern',
data: {
fieldAttrs: '{}',
title: 'index-pattern-1',
timeFieldName: '@timestamp',
sourceFilters: '[]',
fields: '[]',
fieldFormatMap: '{}',
typeMeta: '{}',
runtimeFieldMap: '{}',
name: 'index-pattern-1',
},
options: { id: 'index-pattern-1' },
version: 1,
};
const ATestSpace = 'ab-space';
const BTestSpace = 'ac-space';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const spacesService = getService('spaces');
describe('GET /internal/spaces/{spaceId}/content_summary', () => {
before(async () => {
await spacesService.create({
id: ATestSpace,
name: 'AB Space',
disabledFeatures: [],
color: '#AABBCC',
});
await spacesService.create({
id: BTestSpace,
name: 'AC Space',
disabledFeatures: [],
color: '#AABBCC',
});
});
after(async () => {
await spacesService.delete('ab-space');
await spacesService.delete('ac-space');
});
it(`returns content summary for ${ATestSpace} space`, async () => {
await supertest
.post(`/s/${ATestSpace}/api/content_management/rpc/create`)
.set('kbn-xsrf', 'xxx')
.send(sampleDashboard);
await supertest
.post(`/s/${ATestSpace}/api/content_management/rpc/create`)
.set('kbn-xsrf', 'xxx')
.send(sampleDashboard);
await supertest
.get(`/internal/spaces/${ATestSpace}/content_summary`)
.set('kbn-xsrf', 'xxx')
.expect(200)
.then((response) => {
const { summary, total } = response.body;
expect(summary).to.eql([
{
count: 2,
type: 'dashboard',
displayName: 'Dashboard',
icon: 'dashboardApp',
},
]);
expect(total).to.eql(2);
});
});
it(`returns content summary for ${BTestSpace} space`, async () => {
await supertest
.post(`/s/${BTestSpace}/api/content_management/rpc/create`)
.set('kbn-xsrf', 'xxx')
.send(sampleDashboard);
await supertest
.post(`/s/${BTestSpace}/api/content_management/rpc/create`)
.set('kbn-xsrf', 'foo')
.set('x-elastic-internal-origin', 'foo')
.send(sampleIndexPattern);
await supertest
.get(`/internal/spaces/${BTestSpace}/content_summary`)
.set('kbn-xsrf', 'xxx')
.expect(200)
.then((response) => {
const { summary, total } = response.body;
expect(summary).to.eql([
{
count: 1,
type: 'dashboard',
displayName: 'Dashboard',
icon: 'dashboardApp',
},
{
count: 1,
displayName: 'data view',
icon: 'indexPatternApp',
type: 'index-pattern',
},
]);
expect(total).to.eql(2);
});
});
it('returns 404 when the space is not found', async () => {
await supertest
.get('/internal/spaces/not-found-space/content_summary')
.set('kbn-xsrf', 'xxx')
.expect(404, {
statusCode: 404,
error: 'Not Found',
message: 'Not Found',
});
});
});
}

View file

@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./get_active_space'));
loadTestFile(require.resolve('./saved_objects'));
loadTestFile(require.resolve('./space_attributes'));
loadTestFile(require.resolve('./get_content_summary'));
});
}