diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 4d0e8e1999749..f8f29935ae1d2 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -40,6 +40,10 @@ as uiSettings within the code. |Console provides the user with tools for storing and executing requests against Elasticsearch. +|{kib-repo}blob/{branch}/src/plugins/content_management[contentManagement] +|WARNING: Missing README. + + |{kib-repo}blob/{branch}/src/plugins/controls/README.mdx[controls] |The Controls plugin contains Embeddables which can be used to add user-friendly interactivity to apps. diff --git a/src/plugins/content_management/common/constants.ts b/src/plugins/content_management/common/constants.ts new file mode 100644 index 0000000000000..90298e1729d89 --- /dev/null +++ b/src/plugins/content_management/common/constants.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'contentManagement'; + +export const API_ENDPOINT = '/api/content_management/rpc'; diff --git a/src/plugins/content_management/common/index.ts b/src/plugins/content_management/common/index.ts new file mode 100644 index 0000000000000..ed9b3e5d61635 --- /dev/null +++ b/src/plugins/content_management/common/index.ts @@ -0,0 +1,17 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { PLUGIN_ID, API_ENDPOINT } from './constants'; + +export { schemas as rpcSchemas, procedureNames } from './rpc'; + +export { contentSchema } from './schemas'; + +export type { GetIn, CreateIn, ProcedureName, ProcedureSchemas } from './rpc'; + +export type { Ref, Content, InternalFields, CommonFields } from './schemas'; diff --git a/src/plugins/content_management/common/rpc.ts b/src/plugins/content_management/common/rpc.ts new file mode 100644 index 0000000000000..2bfe5dc93a355 --- /dev/null +++ b/src/plugins/content_management/common/rpc.ts @@ -0,0 +1,72 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema, Type } from '@kbn/config-schema'; + +export interface ProcedureSchemas { + in?: Type | false; + out?: Type | false; +} + +export const procedureNames = ['get', 'create'] as const; + +export type ProcedureName = typeof procedureNames[number]; + +// --------------------------------- +// API +// --------------------------------- + +// ------- GET -------- +const getSchemas: ProcedureSchemas = { + in: schema.object( + { + contentType: schema.string(), + id: schema.string(), + options: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }, + { unknowns: 'forbid' } + ), + // --> "out" will be specified by each storage layer + out: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}; + +export interface GetIn { + id: string; + contentType: T; + options?: Options; +} + +// -- Create content +const createSchemas: ProcedureSchemas = { + in: schema.object( + { + contentType: schema.string(), + data: schema.object({}, { unknowns: 'allow' }), + options: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }, + { unknowns: 'forbid' } + ), + // Here we could enforce that an "id" field is returned + out: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}; + +export interface CreateIn< + T extends string = string, + Data extends object = Record, + Options extends object = any +> { + contentType: T; + data: Data; + options?: Options; +} + +export const schemas: { + [key in ProcedureName]: ProcedureSchemas; +} = { + get: getSchemas, + create: createSchemas, +}; diff --git a/src/plugins/content_management/common/schemas.ts b/src/plugins/content_management/common/schemas.ts new file mode 100644 index 0000000000000..1b0d4f097974f --- /dev/null +++ b/src/plugins/content_management/common/schemas.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema, TypeOf } from '@kbn/config-schema'; + +/** + * Interface to represent a reference field (allows to populate() content) + */ +export const refSchema = schema.object( + { + $id: schema.string(), + }, + { unknowns: 'forbid' } +); + +export type Ref = TypeOf; + +/** + * Fields that _all_ content must have (fields editable by the user) + */ +export const commonFieldsProps = { + title: schema.string(), + description: schema.maybe(schema.string()), +}; + +const commonFieldsSchema = schema.object({ ...commonFieldsProps }, { unknowns: 'forbid' }); + +export type CommonFields = TypeOf; + +/** + * Fields that all content must have (fields *not* editable by the user) + */ +const internalFieldsProps = { + id: schema.string(), + type: schema.string(), + meta: schema.object( + { + updatedAt: schema.string(), + createdAt: schema.string(), + updatedBy: refSchema, + createdBy: refSchema, + }, + { unknowns: 'forbid' } + ), +}; + +export const internalFieldsSchema = schema.object( + { ...internalFieldsProps }, + { unknowns: 'forbid' } +); + +export type InternalFields = TypeOf; + +/** + * Base type for all content (in the search index) + */ +export const contentSchema = schema.object( + { + ...internalFieldsProps, + ...commonFieldsProps, + }, + { unknowns: 'forbid' } +); + +export type Content = TypeOf; diff --git a/src/plugins/content_management/kibana.json b/src/plugins/content_management/kibana.json new file mode 100644 index 0000000000000..e9727db717be4 --- /dev/null +++ b/src/plugins/content_management/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "contentManagement", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["management", "esUiShared"], + "requiredBundles": [], + "optionalPlugins": [], + "owner": { + "name": "@elastic/kibana-global-experience", + "githubTeam": "@elastic/kibana-global-experience" + }, + "description": "Content management app" +} diff --git a/src/plugins/content_management/public/demo-app/app.tsx b/src/plugins/content_management/public/demo-app/app.tsx new file mode 100644 index 0000000000000..208cd43fe706e --- /dev/null +++ b/src/plugins/content_management/public/demo-app/app.tsx @@ -0,0 +1,41 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import type { FC } from 'react'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; + +import { EuiSpacer } from '@elastic/eui'; +import { CreateContentSection, ContentDetailsSection } from './components'; + +export const App: FC = () => { + return ( + + Todo]} + /> + + {/* Search */} + {/* + */} + + {/* Content details */} + + + + {/* Content preview */} + {/* + */} + + {/* Create memory content */} + + + + ); +}; diff --git a/src/plugins/content_management/public/demo-app/components/content_details_section.tsx b/src/plugins/content_management/public/demo-app/components/content_details_section.tsx new file mode 100644 index 0000000000000..d2f4a244391b2 --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/content_details_section.tsx @@ -0,0 +1,94 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { FC, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public'; + +import { useApp } from '../context'; + +export const ContentDetailsSection: FC = () => { + const { rpc } = useApp(); + + const [contentType, setContentType] = useState('foo'); + const [contentId, setContentId] = useState(''); + const [content, setContent] = useState>({}); + + const isIdEmpty = contentId.trim() === ''; + + useDebounce( + () => { + const load = async () => { + const res = await rpc.get({ contentType, id: contentId }); + setContent(res as Record); + }; + + if (!isIdEmpty) { + load(); + } + }, + 500, + [rpc, contentType, contentId, isIdEmpty] + ); + + return ( + <> + +

Content details

+
+ + + + + { + setContentType(e.currentTarget.value); + }} + fullWidth + /> + + + + { + setContentId(e.currentTarget.value); + }} + fullWidth + /> + + + + + + + + ); +}; diff --git a/src/plugins/content_management/public/demo-app/components/content_preview.tsx b/src/plugins/content_management/public/demo-app/components/content_preview.tsx new file mode 100644 index 0000000000000..442d18eab5b26 --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/content_preview.tsx @@ -0,0 +1,71 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// import React, { FC, useState } from 'react'; +// import useDebounce from 'react-use/lib/useDebounce'; +// import { +// EuiDescriptionListTitle, +// EuiDescriptionListDescription, +// EuiSplitPanel, +// EuiText, +// EuiCode, +// } from '@elastic/eui'; + +// import { Content } from '../../../common'; +// import { useApp } from '../context'; + +// export const ContentPreview: FC<{ type: string; id: string }> = ({ type, id }) => { +// const { rpc } = useApp(); +// const [content, setContent] = useState(null); +// const isIdEmpty = id.trim() === ''; + +// useDebounce( +// () => { +// const load = async () => { +// const res = await rpc.getPreview(type, { id }); +// setContent(res); +// }; + +// if (!isIdEmpty) { +// load(); +// } +// }, +// 500, +// [rpc, type, id, isIdEmpty] +// ); + +// if (isIdEmpty) { +// return Provide an id to load the content; +// } + +// if (!content) { +// return Loading...; +// } + +// return ( +// +// +// +// {content.title} +// {Boolean(content.description) && ( +// {content.description} +// )} +// +// +// +// +//

+// Type {content.type} +//

+//
+//
+//
+// ); +// }; + +export {}; diff --git a/src/plugins/content_management/public/demo-app/components/content_preview_section.tsx b/src/plugins/content_management/public/demo-app/components/content_preview_section.tsx new file mode 100644 index 0000000000000..43cafb4dfc3a5 --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/content_preview_section.tsx @@ -0,0 +1,60 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +// import React, { FC, useState } from 'react'; +// import { +// EuiFieldText, +// EuiFlexGroup, +// EuiFlexItem, +// EuiFormRow, +// EuiSpacer, +// EuiTitle, +// } from '@elastic/eui'; + +// import { ContentPreview } from './content_preview'; + +// export const ContentPreviewSection: FC = () => { +// const [contentType, setContentType] = useState('foo'); +// const [contentId, setContentId] = useState(''); + +// return ( +// <> +// +//

Content preview

+//
+// +// +// +// +// { +// setContentType(e.currentTarget.value); +// }} +// fullWidth +// /> +// + +// +// { +// setContentId(e.currentTarget.value); +// }} +// fullWidth +// /> +// +// +// +// +// +// +// +// ); +// }; + +export {}; diff --git a/src/plugins/content_management/public/demo-app/components/create_content_section.tsx b/src/plugins/content_management/public/demo-app/components/create_content_section.tsx new file mode 100644 index 0000000000000..4ef70dd88e7ff --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/create_content_section.tsx @@ -0,0 +1,104 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useState } from 'react'; +import { + EuiButton, + EuiCallOut, + EuiCode, + EuiCodeBlock, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { useApp } from '../context'; + +export const CreateContentSection: FC = () => { + const [title, setContentType] = useState(''); + const [description, setContentId] = useState(''); + const [contentCreated, setContentCreated] = useState<{ + id: string; + title: string; + description: string; + } | null>(null); + + const { rpc } = useApp(); + + const createContent = async () => { + const content = { title, description }; + + setContentCreated(null); + const created = await rpc.create({ contentType: 'foo', data: content }); + setContentCreated(created as any); + }; + + return ( + <> + +

Create

+
+ + Create a new content} + style={{ maxWidth: '100%' }} + description={ +

+ Create a new foo type content. This content is persisted in memory. +

+ } + > + + { + setContentType(e.currentTarget.value); + }} + fullWidth + /> + + + + { + setContentId(e.currentTarget.value); + }} + fullWidth + /> + + + + {contentCreated !== null && ( + <> + + {contentCreated.id} + + + + )} + + + + + Send + + + +
+ + ); +}; diff --git a/src/plugins/content_management/public/demo-app/components/index.ts b/src/plugins/content_management/public/demo-app/components/index.ts new file mode 100644 index 0000000000000..58cd3ec80bcca --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/index.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ContentDetailsSection } from './content_details_section'; + +// export { ContentPreviewSection } from './content_preview_section'; + +export { CreateContentSection } from './create_content_section'; + +// export { SearchContentSection } from './search_content_section'; diff --git a/src/plugins/content_management/public/demo-app/components/search_content_section.tsx b/src/plugins/content_management/public/demo-app/components/search_content_section.tsx new file mode 100644 index 0000000000000..11812f5e53a7f --- /dev/null +++ b/src/plugins/content_management/public/demo-app/components/search_content_section.tsx @@ -0,0 +1,106 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// import React, { FC, useEffect, useState, useCallback } from 'react'; +// import { EuiButton, EuiInMemoryTable, EuiSpacer, EuiTitle } from '@elastic/eui'; + +// import { Content } from '../../../common'; +// import { useApp } from '../context'; + +// export const SearchContentSection: FC = () => { +// const { rpc } = useApp(); +// const [items, setItems] = useState([]); +// const [isLoading, setIsLoading] = useState(false); + +// const sendSearch = useCallback(async () => { +// setIsLoading(true); + +// const { hits } = await rpc.search(); +// setItems(hits); + +// setIsLoading(false); +// }, [rpc]); + +// const columns = [ +// { +// field: 'id', +// name: 'Id', +// sortable: false, +// truncateText: false, +// }, +// { +// field: 'type', +// name: 'Type', +// sortable: true, +// truncateText: false, +// }, +// { +// field: 'title', +// name: 'Title & descr', +// truncateText: true, +// render: (_: string, { title, description }: { title: string; description?: string }) => ( +//

+// {title} +//
+// {description} +//

+// ), +// }, +// { +// field: 'meta.updatedAt', +// name: 'Last update', +// sortable: true, +// truncateText: false, +// }, +// ]; + +// const renderToolsRight = () => { +// return [ +// { +// sendSearch(); +// }} +// isDisabled={isLoading} +// > +// Refresh +// , +// ]; +// }; + +// const search = { +// toolsRight: renderToolsRight(), +// box: { +// incremental: true, +// }, +// }; + +// useEffect(() => { +// sendSearch(); +// }, [sendSearch]); + +// return ( +// <> +// +//

Search

+//
+// + +// +// +// ); +// }; + +export {}; diff --git a/src/plugins/content_management/public/demo-app/context.tsx b/src/plugins/content_management/public/demo-app/context.tsx new file mode 100644 index 0000000000000..a3a6711764285 --- /dev/null +++ b/src/plugins/content_management/public/demo-app/context.tsx @@ -0,0 +1,29 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { createContext, FC, useContext } from 'react'; + +import { RpcClient } from '../rpc'; + +export interface Context { + rpc: RpcClient; +} + +const AppContext = createContext(null); + +export const ContextProvider: FC = ({ children, ...ctx }) => { + return {children}; +}; + +export const useApp = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error(`Ctx missing`); + } + return ctx; +}; diff --git a/src/plugins/content_management/public/demo-app/index.ts b/src/plugins/content_management/public/demo-app/index.ts new file mode 100644 index 0000000000000..9cf89a27d1d8f --- /dev/null +++ b/src/plugins/content_management/public/demo-app/index.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { App } from './app'; + +export type { Context } from './context'; diff --git a/src/plugins/content_management/public/demo-app/mount_app.tsx b/src/plugins/content_management/public/demo-app/mount_app.tsx new file mode 100755 index 0000000000000..666a8a48dd6f5 --- /dev/null +++ b/src/plugins/content_management/public/demo-app/mount_app.tsx @@ -0,0 +1,35 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { CoreStart } from '@kbn/core/public'; +import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; + +import { App } from './app'; +import { ContextProvider, Context } from './context'; + +export const mountApp = ( + coreStart: CoreStart, + ctx: Context, + { element }: ManagementAppMountParams +) => { + ReactDOM.render( + + + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/content_management/public/index.ts b/src/plugins/content_management/public/index.ts new file mode 100644 index 0000000000000..786c4f212481f --- /dev/null +++ b/src/plugins/content_management/public/index.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ContentManagementPlugin } from './plugin'; + +export function plugin() { + return new ContentManagementPlugin(); +} + +export type { ContentManagementPublicStart } from './types'; diff --git a/src/plugins/content_management/public/plugin.ts b/src/plugins/content_management/public/plugin.ts new file mode 100644 index 0000000000000..a4d63d4c833fc --- /dev/null +++ b/src/plugins/content_management/public/plugin.ts @@ -0,0 +1,59 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CoreSetup, Plugin } from '@kbn/core/public'; +import { ManagementAppMountParams, ManagementSetup } from '@kbn/management-plugin/public'; +import { PLUGIN_ID } from '../common'; +import { RpcClient } from './rpc'; +import type { Context } from './demo-app'; +import { ContentManagementPublicStart } from './types'; + +interface SetupDependencies { + management: ManagementSetup; +} + +export class ContentManagementPlugin implements Plugin { + private rpcClient: RpcClient | undefined; + + public setup(core: CoreSetup, { management }: SetupDependencies): void { + const httpClient = { + post: core.http.post, + }; + + const rpcClient = new RpcClient(httpClient); + this.rpcClient = rpcClient; + + management.sections.section.kibana.registerApp({ + id: PLUGIN_ID, + title: 'Content Management', + order: 1, + async mount(params: ManagementAppMountParams) { + if (!rpcClient) { + throw new Error('Rcp client has not been initialized'); + } + + const { mountApp } = await import('./demo-app/mount_app'); + const [coreStart] = await core.getStartServices(); + const ctx: Context = { + rpc: rpcClient, + }; + return mountApp(coreStart, ctx, params); + }, + }); + } + + public start(): ContentManagementPublicStart { + if (!this.rpcClient) { + throw new Error('Rcp client has not been initialized'); + } + + return { + rpc: this.rpcClient, + }; + } +} diff --git a/src/plugins/content_management/public/rpc/index.ts b/src/plugins/content_management/public/rpc/index.ts new file mode 100644 index 0000000000000..da7d96888124b --- /dev/null +++ b/src/plugins/content_management/public/rpc/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { RpcClient } from './rpc_client'; diff --git a/src/plugins/content_management/public/rpc/rpc_client.ts b/src/plugins/content_management/public/rpc/rpc_client.ts new file mode 100644 index 0000000000000..522ee031aa088 --- /dev/null +++ b/src/plugins/content_management/public/rpc/rpc_client.ts @@ -0,0 +1,41 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { HttpSetup } from '@kbn/core/public'; + +import { API_ENDPOINT } from '../../common'; +import type { GetIn, CreateIn, ProcedureName } from '../../common'; + +export class RpcClient { + constructor(private http: { post: HttpSetup['post'] }) {} + + // -------------------- + // Public API + // -------------------- + /** Get a single content */ + public async get( + input: I, + // Temporay mechanism to register hooks + // This will have to be declared on the client side registry + hooks?: { post?: (input: any) => any } + ): Promise { + const result = await this.sendMessage('get', input); + return hooks?.post ? hooks.post(result) : result; + } + + public create(input: I): Promise { + return this.sendMessage('create', input); + } + + private sendMessage = async (name: ProcedureName, input: any): Promise => { + const { result } = await this.http.post<{ result: any }>(`${API_ENDPOINT}/${name}`, { + body: JSON.stringify(input), + }); + return result; + }; +} diff --git a/src/plugins/content_management/public/types.ts b/src/plugins/content_management/public/types.ts new file mode 100644 index 0000000000000..ead2ca3303041 --- /dev/null +++ b/src/plugins/content_management/public/types.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RpcClient } from './rpc'; + +export interface ContentManagementPublicStart { + rpc: RpcClient; +} diff --git a/src/plugins/content_management/server/core/core.ts b/src/plugins/content_management/server/core/core.ts new file mode 100644 index 0000000000000..fba7df4dfb1f3 --- /dev/null +++ b/src/plugins/content_management/server/core/core.ts @@ -0,0 +1,62 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Logger, ElasticsearchClient } from '@kbn/core/server'; + +import { ContentCrud } from './crud'; +import { EventBus } from './event_bus'; +import { init as initEventListeners } from './event_listeners'; +import { ContentRegistry } from './registry'; +import { ContentSearchIndex } from './search'; + +export interface ContentCoreApi { + register: ContentRegistry['register']; + crud: (contentType: string) => ContentCrud; + eventBus: EventBus; + searchIndexer: ContentSearchIndex; +} + +export class ContentCore { + private contentRegistry: ContentRegistry; + private eventBus: EventBus; + private searchIndex: ContentSearchIndex; + + constructor({ logger }: { logger: Logger }) { + this.contentRegistry = new ContentRegistry(); + this.eventBus = new EventBus(); + this.searchIndex = new ContentSearchIndex({ logger }); + } + + setup(): { contentRegistry: ContentRegistry; api: ContentCoreApi } { + const crud = (contentType: string) => { + return new ContentCrud(contentType, { + contentRegistry: this.contentRegistry, + eventBus: this.eventBus, + }); + }; + + initEventListeners({ + eventBus: this.eventBus, + searchIndex: this.searchIndex, + contentRegistry: this.contentRegistry, + }); + + return { + contentRegistry: this.contentRegistry, + api: { + register: this.contentRegistry.register.bind(this.contentRegistry), + crud, + eventBus: this.eventBus, + searchIndexer: this.searchIndex, + }, + }; + } + + start({ esClient }: { esClient: ElasticsearchClient }) { + this.searchIndex.start({ esClient }); + } +} diff --git a/src/plugins/content_management/server/core/crud.ts b/src/plugins/content_management/server/core/crud.ts new file mode 100644 index 0000000000000..ffa4af1f947db --- /dev/null +++ b/src/plugins/content_management/server/core/crud.ts @@ -0,0 +1,91 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { EventBus } from './event_bus'; +import type { ContentRegistry } from './registry'; +import type { ContentStorage, StorageContext } from './types'; + +export class ContentCrud implements ContentStorage { + private storage: ContentStorage; + private eventBus: EventBus; + public contentType: string; + + constructor( + contentType: string, + deps: { + contentRegistry: ContentRegistry; + eventBus: EventBus; + } + ) { + this.contentType = contentType; + this.storage = deps.contentRegistry.getStorage(contentType); + this.eventBus = deps.eventBus; + } + + public async get(ctx: StorageContext, contentId: string, options?: unknown) { + this.eventBus.emit({ + type: 'getItemStart', + contentId, + contentType: this.contentType, + }); + + return this.storage + .get(ctx, contentId, options) + .then((res) => { + this.eventBus.emit({ + type: 'getItemSuccess', + contentId, + contentType: this.contentType, + data: res, + }); + + return res; + }) + .catch((e) => { + this.eventBus.emit({ + type: 'getItemError', + contentId, + contentType: this.contentType, + error: e, + }); + + throw e; + }); + } + + // public mget(ids: string[], options?: unknown) { + // return this.storage.mget(ids, options); + // } + + public async create(ctx: StorageContext, fields: object, options?: unknown) { + const result = await this.storage.create(ctx, fields, options); + + this.eventBus.emit({ + type: 'createItemSuccess', + contentType: this.contentType, + data: result, + }); + + return result; + } + + // public update>( + // id: string, + // fields: T, + // options?: unknown + // ): Promise> { + // return this.storage.update(id, fields, options); + // } + + // public delete(id: string, options?: unknown) { + // return this.storage.delete(id, options); + // } + + // public search(options: O) { + // return this.storage.search(options); + // } +} diff --git a/src/plugins/content_management/server/core/event_bus.ts b/src/plugins/content_management/server/core/event_bus.ts new file mode 100644 index 0000000000000..95b98310bed6e --- /dev/null +++ b/src/plugins/content_management/server/core/event_bus.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Subject } from 'rxjs'; +import type { Subscription } from 'rxjs'; + +import type { ContentEvent, ContentEventType } from './event_types'; + +type EventListener = (arg: ContentEvent) => void; + +export class EventBus { + private _events$: Subject; + private eventListeners = new Map(); + private eventsSubscription: Subscription; + + constructor() { + this._events$ = new Subject(); + + this.eventsSubscription = this._events$.subscribe((event) => { + const listeners = this.eventListeners.get(event.type); + + if (listeners && listeners[event.contentType]) { + listeners[event.contentType].forEach((cb) => { + cb(event); + }); + } + }); + } + + async on( + type: `${ContentType}.${ContentEventType}`, + cb: EventListener + ) { + const [contentType, eventType] = type.split('.') as [ContentType, ContentEventType]; + + if (!this.eventListeners.has(eventType)) { + this.eventListeners.set(eventType, {}); + } + + const eventTypeListeners = this.eventListeners.get(eventType)!; + + if (eventTypeListeners[contentType] === undefined) { + eventTypeListeners[contentType] = []; + } + + eventTypeListeners[contentType].push(cb); + } + + emit(event: ContentEvent) { + this._events$.next(event); + } + + public get events$() { + return this._events$.asObservable(); + } + + stop() { + this.eventsSubscription.unsubscribe(); + } +} diff --git a/src/plugins/content_management/server/core/event_listeners.ts b/src/plugins/content_management/server/core/event_listeners.ts new file mode 100644 index 0000000000000..2b50069c78e09 --- /dev/null +++ b/src/plugins/content_management/server/core/event_listeners.ts @@ -0,0 +1,36 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { EventBus } from './event_bus'; +import type { ContentRegistry } from './registry'; +import type { ContentSearchIndex } from './search'; + +export const init = ({ + eventBus, + contentRegistry, + searchIndex, +}: { + eventBus: EventBus; + contentRegistry: ContentRegistry; + searchIndex: ContentSearchIndex; +}) => { + // const onContentCreated = (event: CreateItemSuccess): void => { + // // Index the data + // const serializer = contentRegistry.getConfig(event.contentType)?.toSearchContentSerializer; + // const content: Content = serializer ? serializer(event.data) : (event.data as Content); + // const validation = contentSchema.getSchema().validate(content); + // if (validation.error) { + // throw new Error(`Can't index content [${event.contentType}] created, invalid Content.`); + // } + // searchIndex.index(content); + // }; + // eventBus.events$.subscribe((event) => { + // if (event.type === 'createItemSuccess') { + // onContentCreated(event); + // } + // }); +}; diff --git a/src/plugins/content_management/server/core/event_types.ts b/src/plugins/content_management/server/core/event_types.ts new file mode 100644 index 0000000000000..3c92021fb8aba --- /dev/null +++ b/src/plugins/content_management/server/core/event_types.ts @@ -0,0 +1,37 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface GetItemStart { + type: 'getItemStart'; + contentId: string; + contentType: string; +} + +export interface GetItemSuccess { + type: 'getItemSuccess'; + contentId: string; + contentType: string; + data: unknown; +} + +export interface GetItemError { + type: 'getItemError'; + contentId: string; + contentType: string; + error: unknown; +} + +export interface CreateItemSuccess { + type: 'createItemSuccess'; + contentType: string; + data: object; +} + +export type ContentEvent = GetItemStart | GetItemSuccess | GetItemError | CreateItemSuccess; + +export type ContentEventType = ContentEvent['type']; diff --git a/src/plugins/content_management/server/core/index.ts b/src/plugins/content_management/server/core/index.ts new file mode 100644 index 0000000000000..c968d1bfea58b --- /dev/null +++ b/src/plugins/content_management/server/core/index.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ContentCore } from './core'; + +export type { ContentCoreApi } from './core'; + +export type { ContentStorage, ContentConfig, StorageContext, ContentSchemas } from './types'; + +export type { ContentRegistry } from './registry'; diff --git a/src/plugins/content_management/server/core/registry.ts b/src/plugins/content_management/server/core/registry.ts new file mode 100644 index 0000000000000..86bead140b7b8 --- /dev/null +++ b/src/plugins/content_management/server/core/registry.ts @@ -0,0 +1,36 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ContentStorage, ContentConfig } from './types'; + +export class ContentRegistry { + private contents = new Map>(); + + register( + contentType: string, + config: ContentConfig + ) { + if (this.contents.has(contentType)) { + throw new Error(`Content [${contentType}] is already registered`); + } + + this.contents.set(contentType, config); + } + + getStorage(contentType: string) { + const contentConfig = this.contents.get(contentType); + if (!contentConfig) { + throw new Error(`Content [${contentType}] is not registered.`); + } + return contentConfig.storage; + } + + getConfig(contentType: string) { + return this.contents.get(contentType); + } +} diff --git a/src/plugins/content_management/server/core/search/index.ts b/src/plugins/content_management/server/core/search/index.ts new file mode 100644 index 0000000000000..54007dc67e742 --- /dev/null +++ b/src/plugins/content_management/server/core/search/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ContentSearchIndex } from './search_index'; diff --git a/src/plugins/content_management/server/core/search/search_index.ts b/src/plugins/content_management/server/core/search/search_index.ts new file mode 100644 index 0000000000000..157ac2327c5eb --- /dev/null +++ b/src/plugins/content_management/server/core/search/search_index.ts @@ -0,0 +1,151 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ElasticsearchClient, Logger } from '@kbn/core/server'; +import type { Content } from '../../../common'; + +interface Dependencies { + logger: Logger; +} + +const indexName = '.kibana-content-mgt'; + +const buildId = (type: string, id: string) => `${type}#${id}`; + +export class ContentSearchIndex { + private esClient: ElasticsearchClient | undefined; + private readonly logger: Logger; + + constructor({ logger }: Dependencies) { + this.logger = logger; + } + + start({ esClient }: { esClient: ElasticsearchClient }) { + this.esClient = esClient; + this.createIndexIfNotExist(indexName); + } + + index(content: Content) { + const { id, title, description, type, meta } = content; + + const document = { + id, + title, + description, + type, + meta, + }; + + return this.getEsClient() + .index({ + index: indexName, + id: buildId(type, id), + document, + }) + .catch((e) => { + // console.log(e); // Temp for debugging + this.logger.error(new Error(`Could not add content to search index.`, { cause: e })); + }); + } + + getById(type: string, id: string) { + return this.getEsClient().search({ + query: { + ids: { + values: [buildId(type, id)], + }, + }, + index: indexName, + }); + } + + search(searchRequest: estypes.SearchRequest) { + return this.getEsClient().search({ + ...searchRequest, + index: indexName, + }); + } + + private async createIndexIfNotExist( + index: string + ): Promise<'created' | 'already_exists' | 'error'> { + try { + return await this.getEsClient() + .indices.get({ + index, + }) + .then(() => { + this.logger.info(`Content search index already exists.`); + return 'already_exists' as const; + }) + .catch(async (e) => { + if ((e.meta?.body?.status ?? e.meta?.statusCode) === 404) { + this.logger.info(`Creating content search index [${index}]...`); + + await this.esClient!.indices.create({ + index, + mappings: { + dynamic: 'strict', + properties: { + id: { type: 'keyword', index: false }, + title: { type: 'text' }, + description: { type: 'text' }, + type: { type: 'keyword' }, + meta: { + type: 'object', + dynamic: 'false', + properties: { + updatedAt: { + type: 'date', + }, + updatedBy: { + type: 'object', + dynamic: 'false', + properties: { + $id: { + type: 'keyword', + }, + }, + }, + createdAt: { + type: 'date', + }, + createdBy: { + type: 'object', + dynamic: 'false', + properties: { + $id: { + type: 'keyword', + }, + }, + }, + }, + }, + }, + }, + }); + return 'created' as const; + } + throw e; + }); + } catch (e) { + this.logger.error(e); + return 'error' as const; + } + } + + private getEsClient() { + if (!this.esClient) { + throw new Error( + `Missing ElasticsearchClient. Make sure that ContentSearchIndex is initialized.` + ); + } + return this.esClient; + } +} diff --git a/src/plugins/content_management/server/core/types.ts b/src/plugins/content_management/server/core/types.ts new file mode 100644 index 0000000000000..c108ba946cd09 --- /dev/null +++ b/src/plugins/content_management/server/core/types.ts @@ -0,0 +1,81 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Type } from '@kbn/config-schema'; +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; + +export interface StorageContext { + requestHandlerContext?: RequestHandlerContext; +} + +export interface ContentStorage { + /** Get a single item */ + get(ctx: StorageContext, id: string, options: unknown): Promise; + + /** Get multiple items */ + // TODO + // mget(ids: string[], options: unknown): Promise; + + /** Create an item */ + create(ctx: StorageContext, fields: object, options: unknown): Promise; + + /** Update an item */ + // TODO + // update(id: string, fields: object, options: unknown): Promise; + + /** Delete an item */ + // TODO + // delete(id: string, options: unknown): Promise<{ status: 'success' | 'error' }>; +} + +export interface ContentConfig { + /** The storage layer for the content.*/ + storage: S; + schemas: { + content: { + // TODO + // list: { + // in: { + // options?: Type; + // }; + // out: { + // result: Type; + // }; + // }; + get: { + in?: { + options?: Type; + }; + out: { + result: Type; + }; + }; + create: { + in: { + data: Type; + options?: Type; + }; + out: { + result: Type; + }; + }; + // TODO + // update: { + // in: { + // data: Type; + // options?: Type; + // }; + // out: { + // result: Type; + // }; + // }; + }; + }; +} + +export type ContentSchemas = ContentConfig['schemas']['content']; diff --git a/src/plugins/content_management/server/demo/foo_storage.ts b/src/plugins/content_management/server/demo/foo_storage.ts new file mode 100644 index 0000000000000..c508d13e8be09 --- /dev/null +++ b/src/plugins/content_management/server/demo/foo_storage.ts @@ -0,0 +1,59 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as uuid from 'uuid'; +import moment from 'moment'; + +import { ContentStorage, StorageContext } from '../core'; +import type { FooContent } from './types'; + +const getTimestamp = () => moment().toISOString(); + +const generateMeta = (): FooContent['meta'] => { + const now = getTimestamp(); + + return { + updatedAt: now, + createdAt: now, + updatedBy: { $id: 'foo' }, + createdBy: { $id: 'foo' }, + }; +}; + +export class FooStorage implements ContentStorage { + private db: Map = new Map(); + private contentType = 'foo' as const; + + async get(ctx: StorageContext, id: string): Promise { + const content = this.db.get(id); + + if (!content) { + throw new Error(`Content [${id}] not found.`); + } + + return content; + } + + async create( + ctx: StorageContext, + fields: Pick + ): Promise { + const id = uuid.v4(); + + const content: FooContent = { + ...fields, + id, + type: this.contentType, + meta: generateMeta(), + }; + + this.db.set(id, content); + + return content; + } +} diff --git a/src/plugins/content_management/server/demo/index.ts b/src/plugins/content_management/server/demo/index.ts new file mode 100644 index 0000000000000..447106ac2c191 --- /dev/null +++ b/src/plugins/content_management/server/demo/index.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { FooStorage } from './foo_storage'; + +export type { FooContent } from './types'; diff --git a/src/plugins/content_management/server/demo/types.ts b/src/plugins/content_management/server/demo/types.ts new file mode 100644 index 0000000000000..bd113dd963259 --- /dev/null +++ b/src/plugins/content_management/server/demo/types.ts @@ -0,0 +1,25 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface FooContent { + id: string; + title: string; + type: 'foo'; + description?: string; + foo: boolean; + meta: { + updatedAt: string; + createdAt: string; + updatedBy: { + $id: string; + }; + createdBy: { + $id: string; + }; + }; +} diff --git a/src/plugins/content_management/server/error_wrapper.ts b/src/plugins/content_management/server/error_wrapper.ts new file mode 100644 index 0000000000000..9dc98810a0832 --- /dev/null +++ b/src/plugins/content_management/server/error_wrapper.ts @@ -0,0 +1,25 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { boomify, isBoom } from '@hapi/boom'; +import { ResponseError, CustomHttpResponseOptions } from '@kbn/core/server'; + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) + ? error + : boomify(error, { statusCode: error.status ?? error.statusCode }); + const statusCode = boom.output.statusCode; + return { + body: { + message: boom, + ...(statusCode !== 500 && error.body ? { attributes: { body: error.body } } : {}), + }, + headers: boom.output.headers as { [key: string]: string }, + statusCode, + }; +} diff --git a/src/plugins/content_management/server/index.ts b/src/plugins/content_management/server/index.ts new file mode 100644 index 0000000000000..01bfc26f43b3c --- /dev/null +++ b/src/plugins/content_management/server/index.ts @@ -0,0 +1,24 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { PluginInitializerContext } from '@kbn/core/server'; + +import { ContentManagementPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new ContentManagementPlugin(initializerContext); +} + +export type { + ContentCore, + ContentStorage, + ContentConfig, + ContentSchemas, + StorageContext, +} from './core'; + +export type { ContentManagementSetup } from './types'; diff --git a/src/plugins/content_management/server/plugin.ts b/src/plugins/content_management/server/plugin.ts new file mode 100755 index 0000000000000..5bfa49144fa93 --- /dev/null +++ b/src/plugins/content_management/server/plugin.ts @@ -0,0 +1,95 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import type { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, + Logger, +} from '@kbn/core/server'; +import { ContentCore, ContentCoreApi } from './core'; +import { wrapError } from './error_wrapper'; +import { initRpcRoutes, FunctionHandler, initRpcHandlers } from './rpc'; +import type { Context as RpcContext } from './rpc'; +import { FooStorage } from './demo'; +import { procedureNames } from '../common'; + +export class ContentManagementPlugin implements Plugin { + private readonly logger: Logger; + private contentCore: ContentCore; + private coreApi: ContentCoreApi | undefined; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.contentCore = new ContentCore({ logger: this.logger }); + } + + public setup(core: CoreSetup) { + const { api, contentRegistry } = this.contentCore.setup(); + this.coreApi = api; + + const fnHandler = new FunctionHandler(); + initRpcHandlers({ fnHandler }); + + const router = core.http.createRouter(); + + initRpcRoutes(procedureNames, { + router, + logger: this.logger, + wrapError, + fnHandler, + context: { core: this.coreApi, contentRegistry }, + }); + + // --------------- DEMO ------------------- + // Add a "in memory" content + const storage = new FooStorage(); + this.coreApi.register('foo', { + storage, + schemas: { + content: { + get: { + out: { + result: schema.any(), + }, + }, + create: { + in: { + data: schema.any(), + }, + out: { + result: schema.any(), + }, + }, + }, + }, + }); + + // const addContent = async () => { + // // Add dummy content + // await storage.create({ + // title: 'Foo', + // description: 'Some description', + // foo: false, + // }); + // }; + // addContent(); + // ---------------------------------------- + + return { + ...this.coreApi, + }; + } + + public start(core: CoreStart) { + const esClient = core.elasticsearch.client.asInternalUser; + this.contentCore.start({ esClient }); + } +} diff --git a/src/plugins/content_management/server/rpc/function_handler.ts b/src/plugins/content_management/server/rpc/function_handler.ts new file mode 100644 index 0000000000000..8a6e60033b609 --- /dev/null +++ b/src/plugins/content_management/server/rpc/function_handler.ts @@ -0,0 +1,73 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { ProcedureSchemas } from '../../common'; + +export interface ProcedureConfig { + fn: (context: Context, input?: I) => Promise; + schemas?: ProcedureSchemas; +} + +interface Registry { + [name: string]: ProcedureConfig; +} + +export class FunctionHandler { + private registry: Registry = {}; + + register = ProcedureConfig>( + name: Names, + config: Config + ) { + this.registry[name] = config; + } + + async call( + { + name, + input, + }: { + name: Names; + input?: I; + }, + context: Context + ): Promise<{ result: O }> { + const handler: ProcedureConfig = this.registry[name]; + + if (!handler) throw new Error(`Handler missing for ${name}`); + + const { fn, schemas } = handler; + + // 1. Validate input + if (schemas?.in) { + const validation = schemas.in.getSchema().validate(input); + if (validation.error) { + const message = `${validation.error.message}. ${JSON.stringify(validation.error)}`; + throw new Error(message); + } + } else if (input !== undefined) { + throw new Error(`Input schema missing for [${name}] procedure.`); + } + + // 2. Execute procedure + const result = await fn(context, input); + + // 3. Validate output + if (handler.schemas?.out) { + const validation = handler.schemas.out.getSchema().validate(result); + if (validation.error) { + throw validation.error; + } + } else { + if (result !== undefined) { + throw new Error(`Output schema missing for [${name}] procedure.`); + } + } + + return { result }; + } +} diff --git a/src/plugins/content_management/server/rpc/handlers.ts b/src/plugins/content_management/server/rpc/handlers.ts new file mode 100644 index 0000000000000..17d74c03701dd --- /dev/null +++ b/src/plugins/content_management/server/rpc/handlers.ts @@ -0,0 +1,126 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { CreateIn, GetIn, ProcedureName } from '../../common'; +import { rpcSchemas } from '../../common'; +import { StorageContext } from '../core'; +import type { FunctionHandler, ProcedureConfig } from './function_handler'; +import { Context } from './types'; + +export function initRpcHandlers({ + fnHandler, +}: { + fnHandler: FunctionHandler; +}) { + fnHandler.register>('get', { + schemas: rpcSchemas.get, + fn: async (ctx, input) => { + if (!input) { + throw new Error(`Input data missing for procedur [get].`); + } + + if (!input.contentType) { + throw new Error(`Content type not provided in input procedure [get].`); + } + + const contentConfig = ctx.contentRegistry.getConfig(input.contentType); + + if (!contentConfig) { + throw new Error(`Invalid contentType [${input.contentType}]`); + } + + const { get: schemas } = contentConfig.schemas.content; + + if (input.options) { + // Validate the options provided + if (!schemas.in?.options) { + throw new Error(`Schema missing for rpc call [get.in.options].`); + } + const validation = schemas.in.options.getSchema().validate(input.options); + if (validation.error) { + throw validation.error; + } + } + + // Execute CRUD + const storageContext: StorageContext = { + requestHandlerContext: ctx.requestHandlerContext, + }; + + const crudInstance = ctx.core.crud(input.contentType); + const result = await crudInstance.get(storageContext, input.id, input.options); + + // Validate result + const validation = schemas.out.result.getSchema().validate(result); + if (validation.error) { + throw validation.error; + } + + return result; + }, + }); + + fnHandler.register>('create', { + schemas: rpcSchemas.create, + fn: async (ctx, input) => { + if (!input) { + throw new Error(`Input data missing for procedur [get].`); + } + + if (!input.contentType) { + throw new Error(`Content type not provided in input procedure [get].`); + } + + const contentConfig = ctx.contentRegistry.getConfig(input.contentType); + + if (!contentConfig) { + throw new Error(`Invalid contentType [${input.contentType}]`); + } + + const { create: schemas } = contentConfig.schemas.content; + + // Validate data to be stored + if (schemas.in.data) { + const validation = schemas.in.data.getSchema().validate(input.data); + if (validation.error) { + const message = `${validation.error.message}. ${JSON.stringify(validation.error)}`; + throw new Error(message); + } + } else { + throw new Error('Schema missing for rpc call [create.in.data].'); + } + + // Validate the possible options + if (input.options) { + if (!schemas.in?.options) { + throw new Error('Schema missing for rpc call [create.in.options].'); + } + const validation = schemas.in.options.getSchema().validate(input.options); + if (validation.error) { + const message = `${validation.error.message}. ${JSON.stringify(validation.error)}`; + throw new Error(message); + } + } + + const storageContext: StorageContext = { + requestHandlerContext: ctx.requestHandlerContext, + }; + + const crudInstance = ctx.core.crud(input.contentType); + const result = crudInstance.create(storageContext, input.data, input.options); + + // Validate result + const validation = schemas.out.result.getSchema().validate(result); + if (validation.error) { + throw validation.error; + } + + return result; + }, + }); +} diff --git a/src/plugins/content_management/server/rpc/index.ts b/src/plugins/content_management/server/rpc/index.ts new file mode 100644 index 0000000000000..886a2e3483af9 --- /dev/null +++ b/src/plugins/content_management/server/rpc/index.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { FunctionHandler } from './function_handler'; +export { initRpcRoutes } from './routes'; +export { initRpcHandlers } from './handlers'; +export type { Context } from './types'; diff --git a/src/plugins/content_management/server/rpc/routes.ts b/src/plugins/content_management/server/rpc/routes.ts new file mode 100644 index 0000000000000..d36542e111eb7 --- /dev/null +++ b/src/plugins/content_management/server/rpc/routes.ts @@ -0,0 +1,72 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema } from '@kbn/config-schema'; +import type { Logger, IRouter, ResponseError, CustomHttpResponseOptions } from '@kbn/core/server'; + +import type { FunctionHandler } from './function_handler'; +import type { Context } from './types'; + +export function initRpcRoutes( + procedureNames: readonly string[], + { + router, + wrapError, + fnHandler, + context: rpcContext, + }: { + router: IRouter; + logger: Logger; + wrapError: (error: any) => CustomHttpResponseOptions; + fnHandler: FunctionHandler; + context: Context; + } +) { + if (procedureNames.length === 0) { + throw new Error(`No function names declared to validate RPC routes.`); + } + + /** + * @apiGroup ContentManagement + * + * @api {post} /content_management/rpc/{call} Execute RPC call + * @apiName RPC + */ + router.post( + { + path: '/api/content_management/rpc/{name}', + validate: { + params: schema.object({ + // @ts-ignore We validate above that procedureNames has at least one item + // so we can ignore the "Target requires 1 element(s) but source may have fewer." TS error + name: schema.oneOf(procedureNames.map((fnName) => schema.literal(fnName))), + }), + body: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }, + }, + async (_context, request, response) => { + try { + const context = { ...rpcContext, requestHandlerContext: _context }; + const { name } = request.params; + + const result = await fnHandler.call( + { + name, + input: request.body, + }, + context + ); + + return response.ok({ + body: result, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + } + ); +} diff --git a/src/plugins/content_management/server/rpc/types.ts b/src/plugins/content_management/server/rpc/types.ts new file mode 100644 index 0000000000000..c136f7ebed537 --- /dev/null +++ b/src/plugins/content_management/server/rpc/types.ts @@ -0,0 +1,15 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { ContentCoreApi, ContentRegistry } from '../core'; + +export interface Context { + core: ContentCoreApi; + contentRegistry: ContentRegistry; + requestHandlerContext?: RequestHandlerContext; +} diff --git a/src/plugins/content_management/server/types.ts b/src/plugins/content_management/server/types.ts new file mode 100644 index 0000000000000..ddd9a39b68f1b --- /dev/null +++ b/src/plugins/content_management/server/types.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ContentCoreApi } from './core'; + +export type ContentManagementSetup = ContentCoreApi; diff --git a/src/plugins/content_management/tsconfig.json b/src/plugins/content_management/tsconfig.json new file mode 100644 index 0000000000000..b138aaa0656d2 --- /dev/null +++ b/src/plugins/content_management/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + }, + "include": ["common/**/*", "public/**/*", "server/**/*", ".storybook/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/config-schema", + "@kbn/i18n-react", + "@kbn/shared-ux-page-kibana-template", + "@kbn/management-plugin", + "@kbn/es-ui-shared-plugin", + "@kbn/core-http-request-handler-context-server" + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index fd9ea4a507cc5..526af85558a9e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -140,6 +140,8 @@ "@kbn/console-plugin/*": ["src/plugins/console/*"], "@kbn/content-management-content-editor": ["packages/content-management/content_editor"], "@kbn/content-management-content-editor/*": ["packages/content-management/content_editor/*"], + "@kbn/content-management-plugin": ["src/plugins/content_management"], + "@kbn/content-management-plugin/*": ["src/plugins/content_management/*"], "@kbn/content-management-table-list": ["packages/content-management/table_list"], "@kbn/content-management-table-list/*": ["packages/content-management/table_list/*"], "@kbn/controls-example-plugin": ["examples/controls_example"], diff --git a/x-pack/plugins/maps/common/poc_content_management/index.ts b/x-pack/plugins/maps/common/poc_content_management/index.ts new file mode 100644 index 0000000000000..2872d4d57778f --- /dev/null +++ b/x-pack/plugins/maps/common/poc_content_management/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { MapContentType, MapGetIn, MapCreateIn } from './types'; diff --git a/x-pack/plugins/maps/common/poc_content_management/types.ts b/x-pack/plugins/maps/common/poc_content_management/types.ts new file mode 100644 index 0000000000000..c7579bf2fcfad --- /dev/null +++ b/x-pack/plugins/maps/common/poc_content_management/types.ts @@ -0,0 +1,24 @@ +/* + * 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 { SavedObjectsCreateOptions } from '@kbn/core-saved-objects-api-browser'; +import type { CreateIn, GetIn } from '@kbn/content-management-plugin/common'; + +import { MapSavedObjectAttributes } from '../map_saved_object_type'; + +export type MapContentType = 'map'; + +export type MapGetIn = GetIn; + +export type MapCreateIn = CreateIn< + MapContentType, + MapSavedObjectAttributes, + Pick< + SavedObjectsCreateOptions, + 'migrationVersion' | 'coreMigrationVersion' | 'references' | 'overwrite' + > +>; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 2e1ad348a47b1..071f616fbb0b3 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -26,7 +26,8 @@ "mapsEms", "savedObjects", "share", - "presentationUtil" + "presentationUtil", + "contentManagement" ], "optionalPlugins": [ "cloud", diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 0e4ee6d913e32..0f8ddc69ecb93 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -68,6 +68,7 @@ export const getSpacesApi = () => pluginsStart.spaces; export const getTheme = () => coreStart.theme; export const getApplication = () => coreStart.application; export const getUsageCollection = () => pluginsStart.usageCollection; +export const getContentManagement = () => pluginsStart.contentManagement; export const isScreenshotMode = () => { return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false; }; diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index ce1d59f5b328c..cfcf6d426dff5 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -9,12 +9,20 @@ import { SavedObjectReference } from '@kbn/core/types'; import type { ResolvedSimpleSavedObject } from '@kbn/core/public'; import { AttributeService } from '@kbn/embeddable-plugin/public'; import { checkForDuplicateTitle, OnSaveProps } from '@kbn/saved-objects-plugin/public'; + import { MapSavedObjectAttributes } from '../common/map_saved_object_type'; import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; import { getMapEmbeddableDisplayName } from '../common/i18n_getters'; -import { getCoreOverlays, getEmbeddableService, getSavedObjectsClient } from './kibana_services'; +import { + getCoreOverlays, + getEmbeddableService, + getSavedObjectsClient, + getContentManagement, +} from './kibana_services'; import { extractReferences, injectReferences } from '../common/migrations/references'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; +import type { MapCreateIn, MapGetIn } from '../common/poc_content_management'; +import { MapGetOut, postGet } from './poc_content_management'; export interface SharingSavedObjectProps { outcome?: ResolvedSimpleSavedObject['outcome']; @@ -69,11 +77,13 @@ export function getMapAttributeService(): MapAttributeService { updatedAttributes, { references } ) - : getSavedObjectsClient().create( - MAP_SAVED_OBJECT_TYPE, - updatedAttributes, - { references } - )); + : getContentManagement().rpc.create({ + contentType: MAP_SAVED_OBJECT_TYPE, + data: updatedAttributes, + options: { + references, + }, + })); return { id: savedObject.id }; }, unwrapMethod: async ( @@ -87,9 +97,12 @@ export function getMapAttributeService(): MapAttributeService { outcome, alias_target_id: aliasTargetId, alias_purpose: aliasPurpose, - } = await getSavedObjectsClient().resolve( - MAP_SAVED_OBJECT_TYPE, - savedObjectId + } = await getContentManagement().rpc.get( + { + contentType: MAP_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + { post: postGet } // Temp hook config to parse the response from RPC ); if (savedObject.error) { diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 9890676f2cec5..6d5d91fe36a52 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -42,6 +42,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LensPublicSetup } from '@kbn/lens-plugin/public'; import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; +import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import { createRegionMapFn, regionMapRenderer, @@ -115,6 +116,7 @@ export interface MapsPluginStartDependencies { mapsEms: MapsEmsPluginPublicStart; screenshotMode?: ScreenshotModePluginSetup; usageCollection?: UsageCollectionSetup; + contentManagement: ContentManagementPublicStart; } /** diff --git a/x-pack/plugins/maps/public/poc_content_management/index.ts b/x-pack/plugins/maps/public/poc_content_management/index.ts new file mode 100644 index 0000000000000..1bbcd51f861df --- /dev/null +++ b/x-pack/plugins/maps/public/poc_content_management/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { postGet } from './serializers'; + +export type { MapGetOut } from './types'; diff --git a/x-pack/plugins/maps/public/poc_content_management/legacy_saved_object.ts b/x-pack/plugins/maps/public/poc_content_management/legacy_saved_object.ts new file mode 100644 index 0000000000000..a6d2cba0b69f3 --- /dev/null +++ b/x-pack/plugins/maps/public/poc_content_management/legacy_saved_object.ts @@ -0,0 +1,70 @@ +/* + * 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 { set } from '@kbn/safer-lodash-set'; +import { get, has } from 'lodash'; +import type { SavedObject as SavedObjectType } from '@kbn/core-saved-objects-common'; + +/** + * Core internal implementation of {@link SimpleSavedObject} + * + * @internal Should use the {@link SimpleSavedObject} interface instead + * @deprecated See https://github.com/elastic/kibana/issues/149098 + */ +export class SimpleSavedObjectImpl { + public attributes: T; + public _version?: SavedObjectType['version']; + public id: SavedObjectType['id']; + public type: SavedObjectType['type']; + public migrationVersion: SavedObjectType['migrationVersion']; + public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; + public error: SavedObjectType['error']; + public references: SavedObjectType['references']; + public updatedAt: SavedObjectType['updated_at']; + public createdAt: SavedObjectType['created_at']; + public namespaces: SavedObjectType['namespaces']; + + constructor({ + id, + type, + version, + attributes, + error, + references, + migrationVersion, + coreMigrationVersion, + namespaces, + updated_at: updatedAt, + created_at: createdAt, + }: SavedObjectType) { + this.id = id; + this.type = type; + this.attributes = attributes || ({} as T); + this.references = references || []; + this._version = version; + this.migrationVersion = migrationVersion; + this.coreMigrationVersion = coreMigrationVersion; + this.namespaces = namespaces; + this.updatedAt = updatedAt; + this.createdAt = createdAt; + if (error) { + this.error = error; + } + } + + public get(key: string): any { + return get(this.attributes, key); + } + + public set(key: string, value: any): T { + return set(this.attributes as any, key, value); + } + + public has(key: string): boolean { + return has(this.attributes, key); + } +} diff --git a/x-pack/plugins/maps/public/poc_content_management/serializers.ts b/x-pack/plugins/maps/public/poc_content_management/serializers.ts new file mode 100644 index 0000000000000..1ea7d5fd4a81d --- /dev/null +++ b/x-pack/plugins/maps/public/poc_content_management/serializers.ts @@ -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 { SimpleSavedObjectImpl } from './legacy_saved_object'; +import { MapGetOut } from './types'; + +/** + * Serializer to convert the response from the Saved object get() method (server) + * to the legacy SO get() client side. + * + * @param soResult Result from the RPC "get" call + * @returns The serialized result + */ +export const postGet = (soResult: any): MapGetOut => { + const simpleSavedObject = new SimpleSavedObjectImpl(soResult.saved_object); + return { + saved_object: simpleSavedObject, + outcome: soResult.outcome, + alias_target_id: soResult.alias_target_id, + alias_purpose: soResult.alias_purpose, + }; +}; diff --git a/x-pack/plugins/maps/public/poc_content_management/types.ts b/x-pack/plugins/maps/public/poc_content_management/types.ts new file mode 100644 index 0000000000000..6f951c35ac486 --- /dev/null +++ b/x-pack/plugins/maps/public/poc_content_management/types.ts @@ -0,0 +1,16 @@ +/* + * 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 { ResolvedSimpleSavedObject } from '@kbn/core-saved-objects-api-browser'; +import type { SimpleSavedObjectImpl } from './legacy_saved_object'; + +export interface MapGetOut { + saved_object: SimpleSavedObjectImpl; + outcome: ResolvedSimpleSavedObject['outcome']; + alias_purpose?: ResolvedSimpleSavedObject['alias_purpose']; + alias_target_id?: string; +} diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 0fe317beef05e..473e42520c6a3 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { i18n } from '@kbn/i18n'; import { CoreSetup, @@ -30,6 +29,7 @@ import { setupEmbeddable } from './embeddable'; import { setupSavedObjects } from './saved_objects'; import { registerIntegrations } from './register_integrations'; import { StartDeps, SetupDeps } from './types'; +import { getContentConfiguration } from './poc_content_management'; export class MapsPlugin implements Plugin { readonly _initializerContext: PluginInitializerContext; @@ -201,6 +201,8 @@ export class MapsPlugin implements Plugin { setupEmbeddable(plugins.embeddable, getFilterMigrations, getDataViewMigrations); + plugins.contentManagement.register('map', getContentConfiguration()); + return { config: config$, }; diff --git a/x-pack/plugins/maps/server/poc_content_management/index.ts b/x-pack/plugins/maps/server/poc_content_management/index.ts new file mode 100644 index 0000000000000..5dcf38932d9e2 --- /dev/null +++ b/x-pack/plugins/maps/server/poc_content_management/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getContentConfiguration } from './map_content_registry'; diff --git a/x-pack/plugins/maps/server/poc_content_management/map_content_registry.ts b/x-pack/plugins/maps/server/poc_content_management/map_content_registry.ts new file mode 100644 index 0000000000000..5e6b2cdb7d256 --- /dev/null +++ b/x-pack/plugins/maps/server/poc_content_management/map_content_registry.ts @@ -0,0 +1,20 @@ +/* + * 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 { ContentConfig } from '@kbn/content-management-plugin/server'; + +import { MapsStorage } from './maps_storage'; +import { contentSchemas } from './schemas'; + +export const getContentConfiguration = (): ContentConfig => { + return { + storage: new MapsStorage(), + schemas: { + content: contentSchemas, + }, + }; +}; diff --git a/x-pack/plugins/maps/server/poc_content_management/maps_storage.ts b/x-pack/plugins/maps/server/poc_content_management/maps_storage.ts new file mode 100644 index 0000000000000..d584da33d12b4 --- /dev/null +++ b/x-pack/plugins/maps/server/poc_content_management/maps_storage.ts @@ -0,0 +1,48 @@ +/* + * 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 { ContentStorage, StorageContext } from '@kbn/content-management-plugin/server'; + +import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; +import type { MapGetIn, MapCreateIn, MapContentType } from '../../common/poc_content_management'; + +const getSavedObjectClientFromRequest = async (ctx: StorageContext) => { + if (!ctx.requestHandlerContext) { + throw new Error('Storage context.requestHandlerContext missing.'); + } + + const { savedObjects } = await ctx.requestHandlerContext.core; + return savedObjects.client; +}; + +const SO_TYPE: MapContentType = 'map'; + +export class MapsStorage implements ContentStorage { + constructor() {} + + async get(ctx: StorageContext, id: string, options: MapGetIn['options']): Promise { + const soClient = await getSavedObjectClientFromRequest(ctx); + return soClient.resolve(SO_TYPE, id); + } + + async create( + ctx: StorageContext, + attributes: MapSavedObjectAttributes, + options: MapCreateIn['options'] + ): Promise { + const { migrationVersion, coreMigrationVersion, references, overwrite } = options!; + + const createOptions = { + overwrite, + migrationVersion, + coreMigrationVersion, + references, + }; + + const soClient = await getSavedObjectClientFromRequest(ctx); + return soClient.create(SO_TYPE, attributes, createOptions); + } +} diff --git a/x-pack/plugins/maps/server/poc_content_management/schemas.ts b/x-pack/plugins/maps/server/poc_content_management/schemas.ts new file mode 100644 index 0000000000000..0b1d954946050 --- /dev/null +++ b/x-pack/plugins/maps/server/poc_content_management/schemas.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { ContentSchemas } from '@kbn/content-management-plugin/server'; + +const mapSchema = schema.object({ + title: schema.string(), + description: schema.string(), + layerListJSON: schema.string(), + mapStateJSON: schema.string(), + uiStateJSON: schema.string(), +}); + +const savedObjectOptions = { + references: schema.maybe(schema.arrayOf(schema.string())), +}; + +export const contentSchemas: ContentSchemas = { + get: { + in: { + options: schema.maybe( + schema.object({ + ...savedObjectOptions, + }) + ), + }, + out: { + result: schema.any(), // This will have to be a proper Maps Saved object schema + }, + }, + create: { + in: { + data: mapSchema, + options: schema.maybe( + schema.object({ + ...savedObjectOptions, + }) + ), + }, + out: { + result: schema.any(), // This will be a proper schema of a map created + }, + }, +}; diff --git a/x-pack/plugins/maps/server/types.ts b/x-pack/plugins/maps/server/types.ts index 912e9edb72e7d..2f943eded12ae 100644 --- a/x-pack/plugins/maps/server/types.ts +++ b/x-pack/plugins/maps/server/types.ts @@ -16,6 +16,7 @@ import { PluginStart as DataPluginStart, } from '@kbn/data-plugin/server'; import { CustomIntegrationsPluginSetup } from '@kbn/custom-integrations-plugin/server'; +import type { ContentManagementSetup } from '@kbn/content-management-plugin/server'; export interface SetupDeps { data: DataPluginSetup; @@ -26,6 +27,7 @@ export interface SetupDeps { mapsEms: MapsEmsPluginServerSetup; embeddable: EmbeddableSetup; customIntegrations: CustomIntegrationsPluginSetup; + contentManagement: ContentManagementSetup; } export interface StartDeps { diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index e7ecc76396133..5795411b35288 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -64,6 +64,8 @@ "@kbn/config-schema", "@kbn/field-types", "@kbn/controls-plugin", + "@kbn/content-management-plugin", + "@kbn/core-saved-objects-common", ], "exclude": [ "target/**/*",