[Maps as code] Refactor MapsStorage class to allow custom content management transforms (#224297)

Refactor the [MapsStorage
class](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/maps/server/content_management/maps_storage.ts)
to stop extending from SOContentStorage. Instead, the MapsStorage class
will define its own get, create, update, delete, mSearch, and search
methods. Conceptually, this will be very similar to the
https://github.com/elastic/kibana/pull/221985.
Closes #222586
This commit is contained in:
Ola Pawlus 2025-06-24 13:49:56 +02:00 committed by GitHub
parent e5503c7b97
commit 21200b848e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 439 additions and 34 deletions

View file

@ -9,7 +9,7 @@ export { LATEST_VERSION, CONTENT_ID } from './constants';
export type { MapContentType } from './types';
export type { MapCrudTypes, MapAttributes, MapItem } from './latest';
export type { MapCrudTypes, MapAttributes, MapItem, MapsSearchOut } from './latest';
// Today "v1" === "latest" so the export under MapV1 namespace is not really useful
// We leave it as a reference for future version when it will be needed to export/support older types

View file

@ -8,3 +8,4 @@
import { MapCrudTypes } from './types';
export type { MapCrudTypes, MapAttributes } from './types';
export type MapItem = MapCrudTypes['Item'];
export type MapsSearchOut = MapCrudTypes['SearchOut'];

View file

@ -5,29 +5,60 @@
* 2.0.
*/
import { SOContentStorage, tagsToFindOptions } from '@kbn/content-management-utils';
import { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
import type { Logger } from '@kbn/logging';
import { CONTENT_ID } from '../../common/content_management';
import { StorageContext } from '@kbn/content-management-plugin/server';
import { SavedObject, SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
import Boom from '@hapi/boom';
import { CreateResult, SearchQuery, DeleteResult } from '@kbn/content-management-plugin/common';
import type { MapAttributes, MapItem, MapsSearchOut } from '../../common/content_management';
import { MAP_SAVED_OBJECT_TYPE } from '../../common';
import {
MapsSavedObjectAttributes,
MapsGetOut,
MapsSearchOptions,
MapsCreateOptions,
MapsCreateOut,
MapsUpdateOptions,
MapsUpdateOut,
} from './schema/v1/types';
import { savedObjectToItem, itemToSavedObject } from './schema/v1/transform_utils';
import { cmServicesDefinition } from './schema/cm_services';
import type { MapCrudTypes } from '../../common/content_management';
const searchArgsToSOFindOptions = (args: MapCrudTypes['SearchIn']): SavedObjectsFindOptions => {
const { query, contentTypeId, options } = args;
const savedObjectClientFromRequest = async (ctx: StorageContext) => {
if (!ctx.requestHandlerContext) {
throw new Error('Storage context.requestHandlerContext missing.');
}
const { savedObjects } = await ctx.requestHandlerContext.core;
return savedObjects.client;
};
const searchArgsToSOFindOptions = (
query: SearchQuery,
options: MapsSearchOptions
): SavedObjectsFindOptions => {
const hasReference = query.tags?.included?.map((tagId) => ({
type: 'tag',
id: tagId,
}));
const hasNoReference = query.tags?.excluded?.map((tagId) => ({
type: 'tag',
id: tagId,
}));
return {
type: contentTypeId,
type: MAP_SAVED_OBJECT_TYPE,
searchFields: options?.onlyTitle ? ['title'] : ['title^3', 'description'],
fields: ['description', 'title'],
search: query.text,
perPage: query.limit,
page: query.cursor ? +query.cursor : undefined,
defaultSearchOperator: 'AND',
...tagsToFindOptions(query.tags),
hasReference,
hasNoReference,
};
};
export class MapsStorage extends SOContentStorage<MapCrudTypes> {
export class MapsStorage {
constructor({
logger,
throwOnResultValidationError,
@ -35,20 +66,280 @@ export class MapsStorage extends SOContentStorage<MapCrudTypes> {
logger: Logger;
throwOnResultValidationError: boolean;
}) {
super({
savedObjectType: CONTENT_ID,
cmServicesDefinition,
searchArgsToSOFindOptions,
enableMSearch: true,
allowedSavedObjectAttributes: [
'title',
'description',
'mapStateJSON',
'layerListJSON',
'uiStateJSON',
],
logger,
throwOnResultValidationError,
});
this.logger = logger;
this.throwOnResultValidationError = throwOnResultValidationError ?? false;
}
private logger: Logger;
private throwOnResultValidationError: boolean;
async get(ctx: StorageContext, id: string): Promise<MapsGetOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const {
saved_object: savedObject,
alias_purpose: aliasPurpose,
alias_target_id: aliasTargetId,
outcome,
} = await soClient.resolve<MapsSavedObjectAttributes>(MAP_SAVED_OBJECT_TYPE, id);
const response = {
item: savedObject,
meta: { aliasPurpose, aliasTargetId, outcome },
};
const validationError = transforms.get.out.result.validate(response);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
const { value, error: resultError } = transforms.get.out.result.down<MapsGetOut, MapsGetOut>(
response,
undefined,
{ validate: false }
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async bulkGet(): Promise<never> {
// Not implemented
throw new Error(`[bulkGet] has not been implemented. See MapsStorage class.`);
}
async create(
ctx: StorageContext,
data: MapAttributes,
options: MapsCreateOptions
): Promise<MapsCreateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.create.in.data.up<
MapAttributes,
MapAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.create.in.options.up<
MapsCreateOptions,
MapsCreateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, references: soReferences } = itemToSavedObject({
attributes: dataToLatest,
references: options.references,
});
// Save data in DB
const savedObject = await soClient.create<MapsSavedObjectAttributes>(
MAP_SAVED_OBJECT_TYPE,
soAttributes,
{ ...optionsToLatest, references: soReferences }
);
const item = savedObjectToItem(savedObject, false);
const validationError = transforms.create.out.result.validate({ item });
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.create.out.result.down<CreateResult<MapItem>>(
{ item },
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async update(
ctx: StorageContext,
id: string,
data: MapAttributes,
options: MapsUpdateOptions
): Promise<MapsUpdateOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
// Validate input (data & options) & UP transform them to the latest version
const { value: dataToLatest, error: dataError } = transforms.update.in.data.up<
MapAttributes,
MapAttributes
>(data);
if (dataError) {
throw Boom.badRequest(`Invalid data. ${dataError.message}`);
}
const { value: optionsToLatest, error: optionsError } = transforms.update.in.options.up<
MapsUpdateOptions,
MapsUpdateOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid options. ${optionsError.message}`);
}
const { attributes: soAttributes, references: soReferences } = itemToSavedObject({
attributes: dataToLatest,
references: options.references,
});
// Save data in DB
const partialSavedObject = await soClient.update<MapsSavedObjectAttributes>(
MAP_SAVED_OBJECT_TYPE,
id,
soAttributes,
{ ...optionsToLatest, references: soReferences }
);
const item = savedObjectToItem(partialSavedObject, true);
const validationError = transforms.update.out.result.validate({ item });
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.update.out.result.down<
MapsUpdateOut,
MapsUpdateOut
>(
{ item },
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
async delete(
ctx: StorageContext,
id: string,
// force is necessary to delete saved objects that exist in multiple namespaces
options?: { force: boolean }
): Promise<DeleteResult> {
const soClient = await savedObjectClientFromRequest(ctx);
await soClient.delete(MAP_SAVED_OBJECT_TYPE, id, { force: options?.force ?? false });
return { success: true };
}
async search(
ctx: StorageContext,
query: SearchQuery,
options: MapsSearchOptions
): Promise<MapsSearchOut> {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const soClient = await savedObjectClientFromRequest(ctx);
const { value: optionsToLatest, error: optionsError } = transforms.search.in.options.up<
MapsSearchOptions,
MapsSearchOptions
>(options);
if (optionsError) {
throw Boom.badRequest(`Invalid payload. ${optionsError.message}`);
}
const soQuery = searchArgsToSOFindOptions(query, optionsToLatest);
const soResponse = await soClient.find<MapsSavedObjectAttributes>(soQuery);
const hits = await Promise.all(
soResponse.saved_objects
.map(async (so) => {
const item = savedObjectToItem(so, false);
return item;
})
.filter((item) => item !== null)
);
const response = {
hits,
pagination: {
total: soResponse.total,
},
};
const validationError = transforms.search.out.result.validate(response);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
const { value, error: resultError } = transforms.search.out.result.down<
MapsSearchOut,
MapsSearchOut
>(response, undefined, { validate: false });
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
}
mSearch = {
savedObjectType: MAP_SAVED_OBJECT_TYPE,
toItemResult: (
ctx: StorageContext,
savedObject: SavedObject<MapsSavedObjectAttributes>
): MapItem => {
const transforms = ctx.utils.getTransforms(cmServicesDefinition);
const contentItem = savedObjectToItem(savedObject, false);
const validationError = transforms.mSearch.out.result.validate(contentItem);
if (validationError) {
if (this.throwOnResultValidationError) {
throw Boom.badRequest(`Invalid response. ${validationError.message}`);
} else {
this.logger.warn(`Invalid response. ${validationError.message}`);
}
}
// Validate DB response and DOWN transform to the request version
const { value, error: resultError } = transforms.mSearch.out.result.down<MapItem, MapItem>(
contentItem,
undefined, // do not override version
{ validate: false } // validation is done above
);
if (resultError) {
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
}
return value;
},
};
}

View file

@ -11,9 +11,10 @@ import {
objectTypeToGetResultSchema,
createOptionsSchemas,
createResultSchema,
updateOptionsSchema,
} from '@kbn/content-management-utils';
const mapAttributesSchema = schema.object(
export const mapAttributesSchema = schema.object(
{
title: schema.string(),
description: schema.maybe(schema.nullable(schema.string())),
@ -24,9 +25,9 @@ const mapAttributesSchema = schema.object(
{ unknowns: 'forbid' }
);
const mapSavedObjectSchema = savedObjectSchema(mapAttributesSchema);
export const mapSavedObjectSchema = savedObjectSchema(mapAttributesSchema);
const searchOptionsSchema = schema.maybe(
export const searchOptionsSchema = schema.maybe(
schema.object(
{
onlyTitle: schema.maybe(schema.boolean()),
@ -35,10 +36,31 @@ const searchOptionsSchema = schema.maybe(
)
);
const createOptionsSchema = schema.object({
export const mapsSearchOptionsSchema = schema.maybe(
schema.object(
{
onlyTitle: schema.maybe(schema.boolean()),
},
{ unknowns: 'forbid' }
)
);
export const createOptionsSchema = schema.object({
references: schema.maybe(createOptionsSchemas.references),
});
export const mapsCreateOptionsSchema = schema.object({
references: schema.maybe(createOptionsSchemas.references),
});
export const mapsUpdateOptionsSchema = schema.object({
references: updateOptionsSchema.references,
});
export const mapsGetResultSchema = objectTypeToGetResultSchema(mapSavedObjectSchema);
export const mapsCreateResultSchema = createResultSchema(mapSavedObjectSchema);
// Content management service definition.
// We need it for BWC support between different versions of the content
export const serviceDefinition: ServicesDefinition = {
@ -60,14 +82,14 @@ export const serviceDefinition: ServicesDefinition = {
},
out: {
result: {
schema: createResultSchema(mapSavedObjectSchema),
schema: mapsCreateResultSchema,
},
},
},
update: {
in: {
options: {
schema: createOptionsSchema, // same as create
schema: mapsUpdateOptionsSchema, // Is still the same as create?
},
data: {
schema: mapAttributesSchema,

View file

@ -0,0 +1,65 @@
/*
* 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 type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server';
import type { MapItem, MapAttributes } from '../../../../common/content_management';
import type { MapsCreateOptions, MapsSavedObjectAttributes } from './types';
type PartialSavedObject<T> = Omit<SavedObject<Partial<T>>, 'references'> & {
references: SavedObjectReference[] | undefined;
};
interface PartialMapsItem {
attributes: Partial<MapItem['attributes']>;
references: SavedObjectReference[] | undefined;
}
export function savedObjectToItem(
savedObject: SavedObject<MapsSavedObjectAttributes>,
partial: false
): MapItem;
export function savedObjectToItem(
savedObject: PartialSavedObject<MapsSavedObjectAttributes>,
partial: true
): PartialMapsItem;
// export function savedObjectToItem(
// savedObject:
// | SavedObject<MapsSavedObjectAttributes>
// | PartialSavedObject<MapsSavedObjectAttributes>,
// partial: boolean
// ): MapItem | PartialMapsItem {
// return savedObject;
// }
export function savedObjectToItem(
savedObject:
| SavedObject<MapsSavedObjectAttributes>
| PartialSavedObject<MapsSavedObjectAttributes>,
partial: boolean
): MapItem | PartialMapsItem {
const normalizedAttributes = {
...savedObject.attributes,
description: savedObject.attributes?.description ?? undefined,
mapStateJSON: savedObject.attributes?.mapStateJSON ?? undefined,
layerListJSON: savedObject.attributes?.layerListJSON ?? undefined,
uiStateJSON: savedObject.attributes?.uiStateJSON ?? undefined,
};
return {
...savedObject,
attributes: normalizedAttributes,
};
}
export function itemToSavedObject(item: {
attributes: MapAttributes;
references?: MapsCreateOptions['references'];
}) {
return item as SavedObject<MapsSavedObjectAttributes>;
}

View file

@ -0,0 +1,26 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import {
mapAttributesSchema,
mapsGetResultSchema,
mapsCreateOptionsSchema,
mapsCreateResultSchema,
mapsSearchOptionsSchema,
mapsUpdateOptionsSchema,
} from './cm_services';
export type MapsSavedObjectAttributes = TypeOf<typeof mapAttributesSchema>;
export type MapsCreateOptions = TypeOf<typeof mapsCreateOptionsSchema>;
export type MapsUpdateOptions = TypeOf<typeof mapsUpdateOptionsSchema>;
export type MapsSearchOptions = TypeOf<typeof mapsSearchOptionsSchema>;
export type MapsGetOut = TypeOf<typeof mapsGetResultSchema>;
export type MapsCreateOut = TypeOf<typeof mapsCreateResultSchema>;
export type MapsUpdateOut = TypeOf<typeof mapsCreateResultSchema>;

View file

@ -48,7 +48,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await listingTable.expectItemsCount('map', 2);
const itemNames = await listingTable.getAllItemsNames();
expect(itemNames).to.eql(['map 3 (tag-1 and tag-3)', 'map 2 (tag-3)']);
expect(itemNames).to.eql(['map 2 (tag-3)', 'map 3 (tag-1 and tag-3)']);
});
it('allows to filter by multiple tags', async () => {
@ -56,7 +56,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await listingTable.expectItemsCount('map', 3);
const itemNames = await listingTable.getAllItemsNames();
expect(itemNames).to.eql(['map 3 (tag-1 and tag-3)', 'map 1 (tag-2)', 'map 2 (tag-3)']);
expect(itemNames).to.eql(['map 1 (tag-2)', 'map 2 (tag-3)', 'map 3 (tag-1 and tag-3)']);
});
});