diff --git a/x-pack/legacy/plugins/canvas/server/routes/custom_elements.ts b/x-pack/legacy/plugins/canvas/server/routes/custom_elements.ts deleted file mode 100644 index 3fe78befd2f50..0000000000000 --- a/x-pack/legacy/plugins/canvas/server/routes/custom_elements.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import boom from 'boom'; -import { omit } from 'lodash'; -import { SavedObjectsClientContract } from 'src/core/server'; - -import { API_ROUTE_CUSTOM_ELEMENT, CUSTOM_ELEMENT_TYPE } from '../../common/lib/constants'; -import { getId } from '../../public/lib/get_id'; -// @ts-ignore Untyped Local -import { formatResponse as formatRes } from '../lib/format_response'; -import { CustomElement } from '../../types'; - -import { CoreSetup } from '../shim'; - -// Exclude ID attribute for the type used for SavedObjectClient -type CustomElementAttributes = Pick> & { - '@timestamp': string; - '@created': string; -}; - -interface CustomElementRequestFacade { - getSavedObjectsClient: () => SavedObjectsClientContract; -} - -type CustomElementRequest = CustomElementRequestFacade & { - params: { - id: string; - }; - payload: CustomElement; -}; - -type FindCustomElementRequest = CustomElementRequestFacade & { - query: { - name: string; - page: number; - perPage: number; - }; -}; - -export function customElements( - route: CoreSetup['http']['route'], - elasticsearch: CoreSetup['elasticsearch'] -) { - // @ts-ignore: errors not on Cluster type - const { errors: esErrors } = elasticsearch.getCluster('data'); - - const routePrefix = API_ROUTE_CUSTOM_ELEMENT; - const formatResponse = formatRes(esErrors); - - const createCustomElement = (req: CustomElementRequest) => { - const savedObjectsClient = req.getSavedObjectsClient(); - - if (!req.payload) { - return Promise.reject(boom.badRequest('A custom element payload is required')); - } - - const now = new Date().toISOString(); - const { id, ...payload } = req.payload; - return savedObjectsClient.create( - CUSTOM_ELEMENT_TYPE, - { - ...payload, - '@timestamp': now, - '@created': now, - }, - { id: id || getId('custom-element') } - ); - }; - - const updateCustomElement = (req: CustomElementRequest, newPayload?: CustomElement) => { - const savedObjectsClient = req.getSavedObjectsClient(); - const { id } = req.params; - const payload = newPayload ? newPayload : req.payload; - - const now = new Date().toISOString(); - - return savedObjectsClient - .get(CUSTOM_ELEMENT_TYPE, id) - .then(element => { - // TODO: Using create with force over-write because of version conflict issues with update - return savedObjectsClient.create( - CUSTOM_ELEMENT_TYPE, - { - ...element.attributes, - ...omit(payload, 'id'), // never write the id property - '@timestamp': now, // always update the modified time - '@created': element.attributes['@created'], // ensure created is not modified - }, - { overwrite: true, id } - ); - }); - }; - - const deleteCustomElement = (req: CustomElementRequest) => { - const savedObjectsClient = req.getSavedObjectsClient(); - const { id } = req.params; - - return savedObjectsClient.delete(CUSTOM_ELEMENT_TYPE, id); - }; - - const findCustomElement = (req: FindCustomElementRequest) => { - const savedObjectsClient = req.getSavedObjectsClient(); - const { name, page, perPage } = req.query; - - return savedObjectsClient.find({ - type: CUSTOM_ELEMENT_TYPE, - sortField: '@timestamp', - sortOrder: 'desc', - search: name ? `${name}* | ${name}` : '*', - searchFields: ['name'], - fields: ['id', 'name', 'displayName', 'help', 'image', 'content', '@created', '@timestamp'], - page, - perPage, - }); - }; - - const getCustomElementById = (req: CustomElementRequest) => { - const savedObjectsClient = req.getSavedObjectsClient(); - const { id } = req.params; - return savedObjectsClient.get(CUSTOM_ELEMENT_TYPE, id); - }; - - // get custom element by id - route({ - method: 'GET', - path: `${routePrefix}/{id}`, - handler: (req: CustomElementRequest) => - getCustomElementById(req) - .then(obj => ({ id: obj.id, ...obj.attributes })) - .then(formatResponse) - .catch(formatResponse), - }); - - // create custom element - route({ - method: 'POST', - path: routePrefix, - // @ts-ignore config option missing on route method type - config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit - handler: (req: CustomElementRequest) => - createCustomElement(req) - .then(() => ({ ok: true })) - .catch(formatResponse), - }); - - // update custom element - route({ - method: 'PUT', - path: `${routePrefix}/{id}`, - // @ts-ignore config option missing on route method type - config: { payload: { allow: 'application/json', maxBytes: 26214400 } }, // 25MB payload limit - handler: (req: CustomElementRequest) => - updateCustomElement(req) - .then(() => ({ ok: true })) - .catch(formatResponse), - }); - - // delete custom element - route({ - method: 'DELETE', - path: `${routePrefix}/{id}`, - handler: (req: CustomElementRequest) => - deleteCustomElement(req) - .then(() => ({ ok: true })) - .catch(formatResponse), - }); - - // find custom elements - route({ - method: 'GET', - path: `${routePrefix}/find`, - handler: (req: FindCustomElementRequest) => - findCustomElement(req) - .then(formatResponse) - .then(resp => { - return { - total: resp.total, - customElements: resp.saved_objects.map(hit => ({ id: hit.id, ...hit.attributes })), - }; - }) - .catch(() => { - return { - total: 0, - customElements: [], - }; - }), - }); -} diff --git a/x-pack/legacy/plugins/canvas/server/routes/index.ts b/x-pack/legacy/plugins/canvas/server/routes/index.ts index 515d5b5e895ed..2f6b706fc7edb 100644 --- a/x-pack/legacy/plugins/canvas/server/routes/index.ts +++ b/x-pack/legacy/plugins/canvas/server/routes/index.ts @@ -5,12 +5,10 @@ */ import { esFields } from './es_fields'; -import { customElements } from './custom_elements'; import { shareableWorkpads } from './shareables'; import { CoreSetup } from '../shim'; export function routes(setup: CoreSetup): void { - customElements(setup.http.route, setup.elasticsearch); esFields(setup.http.route, setup.elasticsearch); shareableWorkpads(setup.http.route); } diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts new file mode 100644 index 0000000000000..d3a69c01732fa --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon from 'sinon'; +import { + savedObjectsClientMock, + httpServiceMock, + httpServerMock, + loggingServiceMock, +} from 'src/core/server/mocks'; +import { CUSTOM_ELEMENT_TYPE } from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { initializeCreateCustomElementRoute } from './create'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; + +const mockRouteContext = ({ + core: { + savedObjects: { + client: savedObjectsClientMock.create(), + }, + }, +} as unknown) as RequestHandlerContext; + +const mockedUUID = '123abc'; +const now = new Date(); +const nowIso = now.toISOString(); + +jest.mock('uuid/v4', () => jest.fn().mockReturnValue('123abc')); + +describe('POST custom element', () => { + let routeHandler: RequestHandler; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(now); + + const httpService = httpServiceMock.createSetupContract(); + + const router = httpService.createRouter('') as jest.Mocked; + initializeCreateCustomElementRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.post.mock.calls[0][1]; + }); + + afterEach(() => { + clock.restore(); + }); + + it(`returns 200 when the custom element is created`, async () => { + const mockCustomElement = { + displayName: 'My Custom Element', + }; + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + path: 'api/canvas/custom-element', + body: mockCustomElement, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toEqual({ ok: true }); + expect(mockRouteContext.core.savedObjects.client.create).toBeCalledWith( + CUSTOM_ELEMENT_TYPE, + { + ...mockCustomElement, + '@timestamp': nowIso, + '@created': nowIso, + }, + { + id: `custom-element-${mockedUUID}`, + } + ); + }); + + it(`returns bad request if create is unsuccessful`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'post', + path: 'api/canvas/custom-element', + body: {}, + }); + + (mockRouteContext.core.savedObjects.client.create as jest.Mock).mockImplementation(() => { + throw mockRouteContext.core.savedObjects.client.errors.createBadRequestError('bad request'); + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(400); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/create.ts b/x-pack/plugins/canvas/server/routes/custom_elements/create.ts new file mode 100644 index 0000000000000..b882829124696 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/create.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteInitializerDeps } from '../'; +import { + CUSTOM_ELEMENT_TYPE, + API_ROUTE_CUSTOM_ELEMENT, +} from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { getId } from '../../../../../legacy/plugins/canvas/public/lib/get_id'; +import { CustomElementSchema } from './custom_element_schema'; +import { CustomElementAttributes } from './custom_element_attributes'; +import { okResponse } from '../ok_response'; +import { catchErrorHandler } from '../catch_error_handler'; + +export function initializeCreateCustomElementRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.post( + { + path: `${API_ROUTE_CUSTOM_ELEMENT}`, + validate: { + body: CustomElementSchema, + }, + options: { + body: { + maxBytes: 26214400, // 25MB payload limit + accepts: ['application/json'], + }, + }, + }, + catchErrorHandler(async (context, request, response) => { + const customElement = request.body; + + const now = new Date().toISOString(); + const { id, ...payload } = customElement; + + await context.core.savedObjects.client.create( + CUSTOM_ELEMENT_TYPE, + { + ...payload, + '@timestamp': now, + '@created': now, + }, + { id: id || getId('custom-element') } + ); + + return response.ok({ + body: okResponse, + }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_attributes.ts b/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_attributes.ts new file mode 100644 index 0000000000000..e76526eeeb27b --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_attributes.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { CustomElement } from '../../../../../legacy/plugins/canvas/types'; + +// Exclude ID attribute for the type used for SavedObjectClient +export type CustomElementAttributes = Pick> & { + '@timestamp': string; + '@created': string; +}; diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_schema.ts b/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_schema.ts new file mode 100644 index 0000000000000..956dccc5aaea2 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/custom_element_schema.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const CustomElementSchema = schema.object({ + '@created': schema.maybe(schema.string()), + '@timestamp': schema.maybe(schema.string()), + content: schema.string(), + displayName: schema.string(), + help: schema.maybe(schema.string()), + id: schema.string(), + image: schema.maybe(schema.string()), + name: schema.string(), + tags: schema.maybe(schema.arrayOf(schema.string())), +}); + +export const CustomElementUpdateSchema = schema.object({ + displayName: schema.string(), + help: schema.maybe(schema.string()), + image: schema.maybe(schema.string()), + name: schema.string(), +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts new file mode 100644 index 0000000000000..c108f2316db27 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CUSTOM_ELEMENT_TYPE } from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { initializeDeleteCustomElementRoute } from './delete'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + savedObjectsClientMock, + httpServiceMock, + httpServerMock, + loggingServiceMock, +} from 'src/core/server/mocks'; + +const mockRouteContext = ({ + core: { + savedObjects: { + client: savedObjectsClientMock.create(), + }, + }, +} as unknown) as RequestHandlerContext; + +describe('DELETE custom element', () => { + let routeHandler: RequestHandler; + + beforeEach(() => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeDeleteCustomElementRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.delete.mock.calls[0][1]; + }); + + it(`returns 200 ok when the custom element is deleted`, async () => { + const id = 'some-id'; + const request = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `api/canvas/custom-element/${id}`, + params: { + id, + }, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toEqual({ ok: true }); + expect(mockRouteContext.core.savedObjects.client.delete).toBeCalledWith( + CUSTOM_ELEMENT_TYPE, + id + ); + }); + + it(`returns bad request if delete is unsuccessful`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `api/canvas/custom-element/some-id`, + params: { + id: 'some-id', + }, + }); + + (mockRouteContext.core.savedObjects.client.delete as jest.Mock).mockImplementationOnce(() => { + throw mockRouteContext.core.savedObjects.client.errors.createBadRequestError('bad request'); + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(400); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/delete.ts b/x-pack/plugins/canvas/server/routes/custom_elements/delete.ts new file mode 100644 index 0000000000000..5867539b95b53 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/delete.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteInitializerDeps } from '../'; +import { + CUSTOM_ELEMENT_TYPE, + API_ROUTE_CUSTOM_ELEMENT, +} from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { okResponse } from '../ok_response'; +import { catchErrorHandler } from '../catch_error_handler'; + +export function initializeDeleteCustomElementRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.delete( + { + path: `${API_ROUTE_CUSTOM_ELEMENT}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + catchErrorHandler(async (context, request, response) => { + await context.core.savedObjects.client.delete(CUSTOM_ELEMENT_TYPE, request.params.id); + return response.ok({ body: okResponse }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts new file mode 100644 index 0000000000000..6644d3b56c681 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initializeFindCustomElementsRoute } from './find'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + savedObjectsClientMock, + httpServiceMock, + httpServerMock, + loggingServiceMock, +} from 'src/core/server/mocks'; + +const mockRouteContext = ({ + core: { + savedObjects: { + client: savedObjectsClientMock.create(), + }, + }, +} as unknown) as RequestHandlerContext; + +describe('Find custom element', () => { + let routeHandler: RequestHandler; + + beforeEach(() => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeFindCustomElementsRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.get.mock.calls[0][1]; + }); + + it(`returns 200 with the found custom elements`, async () => { + const name = 'something'; + const perPage = 10000; + const mockResults = { + total: 2, + saved_objects: [ + { id: 1, attributes: { key: 'value' } }, + { id: 2, attributes: { key: 'other-value' } }, + ], + }; + + const findMock = mockRouteContext.core.savedObjects.client.find as jest.Mock; + + findMock.mockResolvedValueOnce(mockResults); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: `api/canvas/custom-elements/find`, + query: { + name, + perPage, + }, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(findMock.mock.calls[0][0].search).toBe(`${name}* | ${name}`); + expect(findMock.mock.calls[0][0].perPage).toBe(perPage); + + expect(response.payload).toMatchInlineSnapshot(` + Object { + "customElements": Array [ + Object { + "id": 1, + "key": "value", + }, + Object { + "id": 2, + "key": "other-value", + }, + ], + "total": 2, + } + `); + }); + + it(`returns 200 with empty results on error`, async () => { + (mockRouteContext.core.savedObjects.client.find as jest.Mock).mockImplementationOnce(() => { + throw new Error('generic error'); + }); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: `api/canvas/custom-elements/find`, + query: { + name: 'something', + perPage: 1000, + }, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "customElements": Array [], + "total": 0, + } + `); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/find.ts b/x-pack/plugins/canvas/server/routes/custom_elements/find.ts new file mode 100644 index 0000000000000..5041ceb3e4711 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/find.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { SavedObjectAttributes } from 'src/core/server'; +import { RouteInitializerDeps } from '../'; +import { + CUSTOM_ELEMENT_TYPE, + API_ROUTE_CUSTOM_ELEMENT, +} from '../../../../../legacy/plugins/canvas/common/lib/constants'; + +export function initializeFindCustomElementsRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.get( + { + path: `${API_ROUTE_CUSTOM_ELEMENT}/find`, + validate: { + query: schema.object({ + name: schema.string(), + page: schema.maybe(schema.number()), + perPage: schema.number(), + }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + const { name, page, perPage } = request.query; + + try { + const customElements = await savedObjectsClient.find({ + type: CUSTOM_ELEMENT_TYPE, + sortField: '@timestamp', + sortOrder: 'desc', + search: name ? `${name}* | ${name}` : '*', + searchFields: ['name'], + fields: [ + 'id', + 'name', + 'displayName', + 'help', + 'image', + 'content', + '@created', + '@timestamp', + ], + page, + perPage, + }); + + return response.ok({ + body: { + total: customElements.total, + customElements: customElements.saved_objects.map(hit => ({ + id: hit.id, + ...hit.attributes, + })), + }, + }); + } catch (error) { + return response.ok({ + body: { + total: 0, + customElements: [], + }, + }); + } + } + ); +} diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts new file mode 100644 index 0000000000000..5e8d536f779a9 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CUSTOM_ELEMENT_TYPE } from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { initializeGetCustomElementRoute } from './get'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + savedObjectsClientMock, + httpServiceMock, + httpServerMock, + loggingServiceMock, +} from 'src/core/server/mocks'; + +const mockRouteContext = ({ + core: { + savedObjects: { + client: savedObjectsClientMock.create(), + }, + }, +} as unknown) as RequestHandlerContext; + +describe('GET custom element', () => { + let routeHandler: RequestHandler; + + beforeEach(() => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeGetCustomElementRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.get.mock.calls[0][1]; + }); + + it(`returns 200 when the custom element is found`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: 'api/canvas/custom-element/123', + params: { + id: '123', + }, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: CUSTOM_ELEMENT_TYPE, + attributes: { foo: true }, + references: [], + }); + + mockRouteContext.core.savedObjects.client = savedObjectsClient; + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "foo": true, + "id": "123", + } + `); + + expect(savedObjectsClient.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "canvas-element", + "123", + ], + ] + `); + }); + + it('returns 404 if the custom element is not found', async () => { + const id = '123'; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: 'api/canvas/custom-element/123', + params: { + id, + }, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockImplementation(() => { + throw savedObjectsClient.errors.createGenericNotFoundError(CUSTOM_ELEMENT_TYPE, id); + }); + mockRouteContext.core.savedObjects.client = savedObjectsClient; + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.payload).toMatchInlineSnapshot(` + Object { + "error": "Not Found", + "message": "Saved object [canvas-element/123] not found", + "statusCode": 404, + } + `); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/get.ts b/x-pack/plugins/canvas/server/routes/custom_elements/get.ts new file mode 100644 index 0000000000000..f092b001e141f --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/get.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteInitializerDeps } from '../'; +import { + CUSTOM_ELEMENT_TYPE, + API_ROUTE_CUSTOM_ELEMENT, +} from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { CustomElementAttributes } from './custom_element_attributes'; +import { catchErrorHandler } from '../catch_error_handler'; + +export function initializeGetCustomElementRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.get( + { + path: `${API_ROUTE_CUSTOM_ELEMENT}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + catchErrorHandler(async (context, request, response) => { + const customElement = await context.core.savedObjects.client.get( + CUSTOM_ELEMENT_TYPE, + request.params.id + ); + + return response.ok({ + body: { + id: customElement.id, + ...customElement.attributes, + }, + }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/index.ts b/x-pack/plugins/canvas/server/routes/custom_elements/index.ts new file mode 100644 index 0000000000000..ade641e491371 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteInitializerDeps } from '../'; +import { initializeFindCustomElementsRoute } from './find'; +import { initializeGetCustomElementRoute } from './get'; +import { initializeCreateCustomElementRoute } from './create'; +import { initializeUpdateCustomElementRoute } from './update'; +import { initializeDeleteCustomElementRoute } from './delete'; + +export function initCustomElementsRoutes(deps: RouteInitializerDeps) { + initializeFindCustomElementsRoute(deps); + initializeGetCustomElementRoute(deps); + initializeCreateCustomElementRoute(deps); + initializeUpdateCustomElementRoute(deps); + initializeDeleteCustomElementRoute(deps); +} diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts new file mode 100644 index 0000000000000..f21a9c25b6e64 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon from 'sinon'; +import { CustomElement } from '../../../../../legacy/plugins/canvas/types'; +import { CUSTOM_ELEMENT_TYPE } from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { initializeUpdateCustomElementRoute } from './update'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + savedObjectsClientMock, + httpServiceMock, + httpServerMock, + loggingServiceMock, +} from 'src/core/server/mocks'; +import { okResponse } from '../ok_response'; + +const mockRouteContext = ({ + core: { + savedObjects: { + client: savedObjectsClientMock.create(), + }, + }, +} as unknown) as RequestHandlerContext; + +const now = new Date(); +const nowIso = now.toISOString(); + +jest.mock('uuid/v4', () => jest.fn().mockReturnValue('123abc')); + +type CustomElementPayload = CustomElement & { + '@timestamp': string; + '@created': string; +}; + +const customElement: CustomElementPayload = { + id: 'my-custom-element', + name: 'MyCustomElement', + displayName: 'My Wonderful Custom Element', + content: 'This is content', + tags: ['filter', 'graphic'], + '@created': '2019-02-08T18:35:23.029Z', + '@timestamp': '2019-02-08T18:35:23.029Z', +}; + +describe('PUT custom element', () => { + let routeHandler: RequestHandler; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(now); + + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeUpdateCustomElementRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.put.mock.calls[0][1]; + }); + + afterEach(() => { + jest.resetAllMocks(); + clock.restore(); + }); + + it(`returns 200 ok when the custom element is updated`, async () => { + const updatedCustomElement = { name: 'new name' }; + const { id, ...customElementAttributes } = customElement; + + const request = httpServerMock.createKibanaRequest({ + method: 'put', + path: `api/canvas/custom-element/${id}`, + params: { + id, + }, + body: updatedCustomElement, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValueOnce({ + id, + type: CUSTOM_ELEMENT_TYPE, + attributes: customElementAttributes as any, + references: [], + }); + + mockRouteContext.core.savedObjects.client = savedObjectsClient; + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toEqual(okResponse); + expect(mockRouteContext.core.savedObjects.client.create).toBeCalledWith( + CUSTOM_ELEMENT_TYPE, + { + ...customElementAttributes, + ...updatedCustomElement, + '@timestamp': nowIso, + '@created': customElement['@created'], + }, + { + overwrite: true, + id, + } + ); + }); + + it(`returns not found if existing custom element is not found`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'put', + path: 'api/canvas/custom-element/some-id', + params: { + id: 'not-found', + }, + body: {}, + }); + + (mockRouteContext.core.savedObjects.client.get as jest.Mock).mockImplementationOnce(() => { + throw mockRouteContext.core.savedObjects.client.errors.createGenericNotFoundError( + 'not found' + ); + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + expect(response.status).toBe(404); + }); + + it(`returns bad request if the write fails`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'put', + path: 'api/canvas/custom-element/some-id', + params: { + id: 'some-id', + }, + body: {}, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'some-id', + type: CUSTOM_ELEMENT_TYPE, + attributes: {}, + references: [], + }); + + mockRouteContext.core.savedObjects.client = savedObjectsClient; + (mockRouteContext.core.savedObjects.client.create as jest.Mock).mockImplementationOnce(() => { + throw mockRouteContext.core.savedObjects.client.errors.createBadRequestError('bad request'); + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(400); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/update.ts b/x-pack/plugins/canvas/server/routes/custom_elements/update.ts new file mode 100644 index 0000000000000..51c363249dd79 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/custom_elements/update.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { omit } from 'lodash'; +import { RouteInitializerDeps } from '../'; +import { + CUSTOM_ELEMENT_TYPE, + API_ROUTE_CUSTOM_ELEMENT, +} from '../../../../../legacy/plugins/canvas/common/lib/constants'; +import { CustomElementUpdateSchema } from './custom_element_schema'; +import { CustomElementAttributes } from './custom_element_attributes'; +import { okResponse } from '../ok_response'; +import { catchErrorHandler } from '../catch_error_handler'; + +export function initializeUpdateCustomElementRoute(deps: RouteInitializerDeps) { + const { router } = deps; + router.put( + { + path: `${API_ROUTE_CUSTOM_ELEMENT}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: CustomElementUpdateSchema, + }, + options: { + body: { + maxBytes: 26214400, // 25MB payload limit + accepts: ['application/json'], + }, + }, + }, + catchErrorHandler(async (context, request, response) => { + const payload = request.body; + const id = request.params.id; + + const now = new Date().toISOString(); + + const customElementObject = await context.core.savedObjects.client.get< + CustomElementAttributes + >(CUSTOM_ELEMENT_TYPE, id); + + await context.core.savedObjects.client.create( + CUSTOM_ELEMENT_TYPE, + { + ...customElementObject.attributes, + ...omit(payload, 'id'), // never write the id property + '@timestamp': now, + '@created': customElementObject.attributes['@created'], // ensure created is not modified + }, + { overwrite: true, id } + ); + + return response.ok({ + body: okResponse, + }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/index.ts b/x-pack/plugins/canvas/server/routes/index.ts index 46873a6b32542..8b2d77d634760 100644 --- a/x-pack/plugins/canvas/server/routes/index.ts +++ b/x-pack/plugins/canvas/server/routes/index.ts @@ -6,6 +6,7 @@ import { IRouter, Logger } from 'src/core/server'; import { initWorkpadRoutes } from './workpad'; +import { initCustomElementsRoutes } from './custom_elements'; export interface RouteInitializerDeps { router: IRouter; @@ -14,4 +15,5 @@ export interface RouteInitializerDeps { export function initRoutes(deps: RouteInitializerDeps) { initWorkpadRoutes(deps); + initCustomElementsRoutes(deps); } diff --git a/x-pack/plugins/canvas/server/routes/workpad/ok_response.ts b/x-pack/plugins/canvas/server/routes/ok_response.ts similarity index 100% rename from x-pack/plugins/canvas/server/routes/workpad/ok_response.ts rename to x-pack/plugins/canvas/server/routes/ok_response.ts diff --git a/x-pack/plugins/canvas/server/routes/workpad/create.ts b/x-pack/plugins/canvas/server/routes/workpad/create.ts index be904356720b6..fc847d4816dbd 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/create.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/create.ts @@ -11,15 +11,11 @@ import { } from '../../../../../legacy/plugins/canvas/common/lib/constants'; import { CanvasWorkpad } from '../../../../../legacy/plugins/canvas/types'; import { getId } from '../../../../../legacy/plugins/canvas/public/lib/get_id'; +import { WorkpadAttributes } from './workpad_attributes'; import { WorkpadSchema } from './workpad_schema'; -import { okResponse } from './ok_response'; +import { okResponse } from '../ok_response'; import { catchErrorHandler } from '../catch_error_handler'; -export type WorkpadAttributes = Pick> & { - '@timestamp': string; - '@created': string; -}; - export function initializeCreateWorkpadRoute(deps: RouteInitializerDeps) { const { router } = deps; router.post( diff --git a/x-pack/plugins/canvas/server/routes/workpad/delete.ts b/x-pack/plugins/canvas/server/routes/workpad/delete.ts index 7adf11e7a887b..8de4ea0f9a27f 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/delete.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/delete.ts @@ -10,7 +10,7 @@ import { CANVAS_TYPE, API_ROUTE_WORKPAD, } from '../../../../../legacy/plugins/canvas/common/lib/constants'; -import { okResponse } from './ok_response'; +import { okResponse } from '../ok_response'; import { catchErrorHandler } from '../catch_error_handler'; export function initializeDeleteWorkpadRoute(deps: RouteInitializerDeps) { diff --git a/x-pack/plugins/canvas/server/routes/workpad/get.ts b/x-pack/plugins/canvas/server/routes/workpad/get.ts index 7a51006aa9f02..d7a5e77670f6e 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/get.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/get.ts @@ -10,14 +10,9 @@ import { CANVAS_TYPE, API_ROUTE_WORKPAD, } from '../../../../../legacy/plugins/canvas/common/lib/constants'; -import { CanvasWorkpad } from '../../../../../legacy/plugins/canvas/types'; +import { WorkpadAttributes } from './workpad_attributes'; import { catchErrorHandler } from '../catch_error_handler'; -export type WorkpadAttributes = Pick> & { - '@timestamp': string; - '@created': string; -}; - export function initializeGetWorkpadRoute(deps: RouteInitializerDeps) { const { router } = deps; router.get( diff --git a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts index 492a6c98d71ee..de098dd9717ed 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts @@ -20,7 +20,7 @@ import { loggingServiceMock, } from 'src/core/server/mocks'; import { workpads } from '../../../../../legacy/plugins/canvas/__tests__/fixtures/workpads'; -import { okResponse } from './ok_response'; +import { okResponse } from '../ok_response'; const mockRouteContext = ({ core: { diff --git a/x-pack/plugins/canvas/server/routes/workpad/update.ts b/x-pack/plugins/canvas/server/routes/workpad/update.ts index 460aa174038ae..74dedb605472c 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/update.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/update.ts @@ -15,16 +15,11 @@ import { API_ROUTE_WORKPAD_STRUCTURES, API_ROUTE_WORKPAD_ASSETS, } from '../../../../../legacy/plugins/canvas/common/lib/constants'; -import { CanvasWorkpad } from '../../../../../legacy/plugins/canvas/types'; +import { WorkpadAttributes } from './workpad_attributes'; import { WorkpadSchema, WorkpadAssetSchema } from './workpad_schema'; -import { okResponse } from './ok_response'; +import { okResponse } from '../ok_response'; import { catchErrorHandler } from '../catch_error_handler'; -export type WorkpadAttributes = Pick> & { - '@timestamp': string; - '@created': string; -}; - const AssetsRecordSchema = schema.recordOf(schema.string(), WorkpadAssetSchema); const AssetPayloadSchema = schema.object({ diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_attributes.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_attributes.ts new file mode 100644 index 0000000000000..2b7b6cca4ba2b --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_attributes.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { CanvasWorkpad } from '../../../../../legacy/plugins/canvas/types'; + +export type WorkpadAttributes = Pick> & { + '@timestamp': string; + '@created': string; +};