diff --git a/docs/api/saved-objects/bulk_get.asciidoc b/docs/api/saved-objects/bulk_get.asciidoc index 3d081affcebc..564698c9f00c 100644 --- a/docs/api/saved-objects/bulk_get.asciidoc +++ b/docs/api/saved-objects/bulk_get.asciidoc @@ -23,6 +23,9 @@ contains the following properties: `id` (required):: (string) ID of object to retrieve +`fields` (optional):: + (array) The fields to return in the object's response + ==== Response body The response body will have a top level `saved_objects` property that contains diff --git a/src/legacy/server/saved_objects/import/extract_errors.test.ts b/src/legacy/server/saved_objects/import/extract_errors.test.ts index 24f1dee80b7c..6f21c7b54647 100644 --- a/src/legacy/server/saved_objects/import/extract_errors.test.ts +++ b/src/legacy/server/saved_objects/import/extract_errors.test.ts @@ -45,18 +45,36 @@ describe('extractErrors()', () => { message: 'Conflict', }, }, + { + id: '3', + type: 'dashboard', + attributes: {}, + references: [], + error: { + statusCode: 400, + message: 'Bad Request', + }, + }, ]; const result = extractErrors(savedObjects); expect(result).toMatchInlineSnapshot(` Array [ Object { "error": Object { - "message": "Conflict", - "statusCode": 409, + "type": "conflict", }, "id": "2", "type": "dashboard", }, + Object { + "error": Object { + "message": "Bad Request", + "statusCode": 400, + "type": "unknown", + }, + "id": "3", + "type": "dashboard", + }, ] `); }); diff --git a/src/legacy/server/saved_objects/import/extract_errors.ts b/src/legacy/server/saved_objects/import/extract_errors.ts index 01917ce6b1db..5418198c058b 100644 --- a/src/legacy/server/saved_objects/import/extract_errors.ts +++ b/src/legacy/server/saved_objects/import/extract_errors.ts @@ -18,24 +18,29 @@ */ import { SavedObject } from '../service'; - -export interface CustomError { - id: string; - type: string; - error: { - message: string; - statusCode: number; - }; -} +import { ImportError } from './types'; export function extractErrors(savedObjects: SavedObject[]) { - const errors: CustomError[] = []; + const errors: ImportError[] = []; for (const savedObject of savedObjects) { if (savedObject.error) { + if (savedObject.error.statusCode === 409) { + errors.push({ + id: savedObject.id, + type: savedObject.type, + error: { + type: 'conflict', + }, + }); + continue; + } errors.push({ id: savedObject.id, type: savedObject.type, - error: savedObject.error, + error: { + ...savedObject.error, + type: 'unknown', + }, }); } } diff --git a/src/legacy/server/saved_objects/import/import_saved_objects.test.ts b/src/legacy/server/saved_objects/import/import_saved_objects.test.ts index 176c9e31d04c..2dccb2960167 100644 --- a/src/legacy/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/legacy/server/saved_objects/import/import_saved_objects.test.ts @@ -76,6 +76,7 @@ describe('importSavedObjects()', () => { this.push(null); }, }); + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: savedObjects, }); @@ -143,6 +144,7 @@ Object { this.push(null); }, }); + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: savedObjects, }); @@ -210,6 +212,7 @@ Object { this.push(null); }, }); + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: savedObjects.map(savedObject => ({ type: savedObject.type, @@ -231,32 +234,28 @@ Object { "errors": Array [ Object { "error": Object { - "message": "conflict", - "statusCode": 409, + "type": "conflict", }, "id": "1", "type": "index-pattern", }, Object { "error": Object { - "message": "conflict", - "statusCode": 409, + "type": "conflict", }, "id": "2", "type": "search", }, Object { "error": Object { - "message": "conflict", - "statusCode": 409, + "type": "conflict", }, "id": "3", "type": "visualization", }, Object { "error": Object { - "message": "conflict", - "statusCode": 409, + "type": "conflict", }, "id": "4", "type": "dashboard", diff --git a/src/legacy/server/saved_objects/import/import_saved_objects.ts b/src/legacy/server/saved_objects/import/import_saved_objects.ts index 6dc27a3b4a62..01324071eb31 100644 --- a/src/legacy/server/saved_objects/import/import_saved_objects.ts +++ b/src/legacy/server/saved_objects/import/import_saved_objects.ts @@ -20,7 +20,9 @@ import { Readable } from 'stream'; import { SavedObjectsClient } from '../service'; import { collectSavedObjects } from './collect_saved_objects'; -import { CustomError, extractErrors } from './extract_errors'; +import { extractErrors } from './extract_errors'; +import { ImportError } from './types'; +import { validateReferences } from './validate_references'; interface ImportSavedObjectsOptions { readStream: Readable; @@ -32,7 +34,7 @@ interface ImportSavedObjectsOptions { interface ImportResponse { success: boolean; successCount: number; - errors?: CustomError[]; + errors?: ImportError[]; } export async function importSavedObjects({ @@ -41,23 +43,29 @@ export async function importSavedObjects({ overwrite, savedObjectsClient, }: ImportSavedObjectsOptions): Promise { - const objectsToImport = await collectSavedObjects(readStream, objectLimit); + const objectsFromStream = await collectSavedObjects(readStream, objectLimit); - if (objectsToImport.length === 0) { + const { filteredObjects, errors: validationErrors } = await validateReferences( + objectsFromStream, + savedObjectsClient + ); + + if (filteredObjects.length === 0) { return { - success: true, + success: validationErrors.length === 0, successCount: 0, + ...(validationErrors.length ? { errors: validationErrors } : {}), }; } - const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToImport, { + const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, { overwrite, }); - const errors = extractErrors(bulkCreateResult.saved_objects); + const errors = [...validationErrors, ...extractErrors(bulkCreateResult.saved_objects)]; return { success: errors.length === 0, - successCount: objectsToImport.length - errors.length, + successCount: objectsFromStream.length - errors.length, ...(errors.length ? { errors } : {}), }; } diff --git a/src/legacy/server/saved_objects/import/resolve_import_errors.ts b/src/legacy/server/saved_objects/import/resolve_import_errors.ts index 2e69d9edb7b8..827d73655308 100644 --- a/src/legacy/server/saved_objects/import/resolve_import_errors.ts +++ b/src/legacy/server/saved_objects/import/resolve_import_errors.ts @@ -21,7 +21,8 @@ import { Readable } from 'stream'; import { SavedObjectsClient } from '../service'; import { collectSavedObjects } from './collect_saved_objects'; import { createObjectsFilter } from './create_objects_filter'; -import { CustomError, extractErrors } from './extract_errors'; +import { extractErrors } from './extract_errors'; +import { ImportError } from './types'; interface ResolveImportErrorsOptions { readStream: Readable; @@ -45,7 +46,7 @@ interface ResolveImportErrorsOptions { interface ImportResponse { success: boolean; successCount: number; - errors?: CustomError[]; + errors?: ImportError[]; } export async function resolveImportErrors({ @@ -56,7 +57,7 @@ export async function resolveImportErrors({ savedObjectsClient, replaceReferences, }: ResolveImportErrorsOptions): Promise { - let errors: CustomError[] = []; + let errors: ImportError[] = []; const filter = createObjectsFilter(skips, overwrites, replaceReferences); const objectsToResolve = await collectSavedObjects(readStream, objectLimit, filter); diff --git a/src/legacy/server/saved_objects/import/types.ts b/src/legacy/server/saved_objects/import/types.ts new file mode 100644 index 000000000000..b3933966a32d --- /dev/null +++ b/src/legacy/server/saved_objects/import/types.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +interface ConflictError { + type: 'conflict'; +} + +interface UnknownError { + type: 'unknown'; + message: string; + statusCode: number; +} + +interface MissingReferencesError { + type: 'missing_references'; + references: Array<{ + type: string; + id: string; + }>; +} + +export interface ImportError { + id: string; + type: string; + error: ConflictError | MissingReferencesError | UnknownError; +} diff --git a/src/legacy/server/saved_objects/import/validate_references.test.ts b/src/legacy/server/saved_objects/import/validate_references.test.ts new file mode 100644 index 000000000000..159f40b4cf2b --- /dev/null +++ b/src/legacy/server/saved_objects/import/validate_references.test.ts @@ -0,0 +1,586 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references'; + +describe('getNonExistingReferenceAsKeys()', () => { + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('returns empty response when no objects exist', async () => { + const result = await getNonExistingReferenceAsKeys([], savedObjectsClient); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); + + test('removes references that exist within savedObjects', async () => { + const savedObjects = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); + + test('removes references that exist within es', async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + ], + }); + const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "1", + "type": "index-pattern", + }, + ], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test(`doesn't handle saved object types outside of ENFORCED_TYPES`, async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'foo', + id: '1', + }, + ], + }, + ]; + const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + expect(result).toEqual([]); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); + + test('returns references within ENFORCED_TYPES when they are missing', async () => { + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + { + name: 'ref_1', + type: 'search', + id: '3', + }, + { + name: 'ref_2', + type: 'foo', + id: '4', + }, + ], + }, + ]; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'index-pattern', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: '3', + type: 'search', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + ], + }); + const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient); + expect(result).toEqual(['index-pattern:1', 'search:3']); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "1", + "type": "index-pattern", + }, + Object { + "fields": Array [ + "id", + ], + "id": "3", + "type": "search", + }, + ], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); + +describe('validateReferences()', () => { + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('returns empty when no objects are passed in', async () => { + const result = await validateReferences([], savedObjectsClient); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "filteredObjects": Array [], +} +`); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); + + test('returns errors when references are missing', async () => { + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + type: 'index-pattern', + id: '3', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + type: 'index-pattern', + id: '5', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + type: 'index-pattern', + id: '6', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + type: 'search', + id: '7', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: '8', + type: 'search', + attributes: {}, + references: [], + }, + ], + }); + const savedObjects = [ + { + id: '1', + type: 'visualization', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '3', + }, + ], + }, + { + id: '4', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '5', + }, + { + name: 'ref_1', + type: 'index-pattern', + id: '6', + }, + { + name: 'ref_2', + type: 'search', + id: '7', + }, + { + name: 'ref_3', + type: 'search', + id: '8', + }, + ], + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [ + Object { + "error": Object { + "references": Array [ + Object { + "id": "3", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "id": "2", + "type": "visualization", + }, + Object { + "error": Object { + "references": Array [ + Object { + "id": "5", + "type": "index-pattern", + }, + Object { + "id": "6", + "type": "index-pattern", + }, + Object { + "id": "7", + "type": "search", + }, + ], + "type": "missing_references", + }, + "id": "4", + "type": "visualization", + }, + ], + "filteredObjects": Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "visualization", + }, + ], +} +`); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "3", + "type": "index-pattern", + }, + Object { + "fields": Array [ + "id", + ], + "id": "5", + "type": "index-pattern", + }, + Object { + "fields": Array [ + "id", + ], + "id": "6", + "type": "index-pattern", + }, + Object { + "fields": Array [ + "id", + ], + "id": "7", + "type": "search", + }, + Object { + "fields": Array [ + "id", + ], + "id": "8", + "type": "search", + }, + ], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test(`doesn't return errors when references exist in Elasticsearch`, async () => { + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + ], + }); + const savedObjects = [ + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "filteredObjects": Array [ + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "type": "visualization", + }, + ], +} +`); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + }); + + test(`doesn't return errors when references exist within the saved objects`, async () => { + const savedObjects = [ + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'visualization', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: '1', + }, + ], + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "filteredObjects": Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "type": "visualization", + }, + ], +} +`); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); + + test(`doesn't validate references on types not part of ENFORCED_TYPES`, async () => { + const savedObjects = [ + { + id: '1', + type: 'dashboard', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'visualization', + id: '2', + }, + { + name: 'ref_1', + type: 'other-type', + id: '3', + }, + ], + }, + ]; + const result = await validateReferences(savedObjects, savedObjectsClient); + expect(result).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "filteredObjects": Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [ + Object { + "id": "2", + "name": "ref_0", + "type": "visualization", + }, + Object { + "id": "3", + "name": "ref_1", + "type": "other-type", + }, + ], + "type": "dashboard", + }, + ], +} +`); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/legacy/server/saved_objects/import/validate_references.ts b/src/legacy/server/saved_objects/import/validate_references.ts new file mode 100644 index 000000000000..3b5570949467 --- /dev/null +++ b/src/legacy/server/saved_objects/import/validate_references.ts @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObject, SavedObjectsClient } from '../service'; +import { ImportError } from './types'; + +const ENFORCED_TYPES = ['index-pattern', 'search']; + +export async function getNonExistingReferenceAsKeys( + savedObjects: SavedObject[], + savedObjectsClient: SavedObjectsClient +) { + const collector = new Map(); + for (const savedObject of savedObjects) { + for (const { type, id } of savedObject.references || []) { + if (!ENFORCED_TYPES.includes(type)) { + continue; + } + collector.set(`${type}:${id}`, { type, id }); + } + } + for (const savedObject of savedObjects) { + collector.delete(`${savedObject.type}:${savedObject.id}`); + } + if (collector.size) { + const bulkGetOpts = Array.from(collector.values()).map(obj => ({ ...obj, fields: ['id'] })); + const bulkGetResponse = await savedObjectsClient.bulkGet(bulkGetOpts); + for (const savedObject of bulkGetResponse.saved_objects) { + if (savedObject.error) { + continue; + } + collector.delete(`${savedObject.type}:${savedObject.id}`); + } + } + return [...collector.keys()]; +} + +export async function validateReferences( + savedObjects: SavedObject[], + savedObjectsClient: SavedObjectsClient +) { + const errors: ImportError[] = []; + + const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( + savedObjects, + savedObjectsClient + ); + + // Filter out objects with missing references, add to error object + const filteredObjects = savedObjects.filter(savedObject => { + const missingReferences = []; + for (const { type: refType, id: refId } of savedObject.references || []) { + if (!ENFORCED_TYPES.includes(refType)) { + continue; + } + if (nonExistingReferenceKeys.includes(`${refType}:${refId}`)) { + missingReferences.push({ type: refType, id: refId }); + } + } + if (missingReferences.length) { + errors.push({ + id: savedObject.id, + type: savedObject.type, + error: { + type: 'missing_references', + references: missingReferences, + }, + }); + } + return missingReferences.length === 0; + }); + + return { + errors, + filteredObjects, + }; +} diff --git a/src/legacy/server/saved_objects/routes/bulk_get.ts b/src/legacy/server/saved_objects/routes/bulk_get.ts index a0951852ec2f..c3cb3ab4da40 100644 --- a/src/legacy/server/saved_objects/routes/bulk_get.ts +++ b/src/legacy/server/saved_objects/routes/bulk_get.ts @@ -29,6 +29,7 @@ interface BulkGetRequest extends Hapi.Request { payload: Array<{ type: string; id: string; + fields?: string[]; }>; } @@ -42,6 +43,7 @@ export const createBulkGetRoute = (prereqs: Prerequisites) => ({ Joi.object({ type: Joi.string().required(), id: Joi.string().required(), + fields: Joi.array().items(Joi.string()), }).required() ), }, diff --git a/src/legacy/server/saved_objects/routes/import.test.ts b/src/legacy/server/saved_objects/routes/import.test.ts index 3e2b28e6232c..1d87f4c7157c 100644 --- a/src/legacy/server/saved_objects/routes/import.test.ts +++ b/src/legacy/server/saved_objects/routes/import.test.ts @@ -72,6 +72,7 @@ describe('POST /api/saved_objects/_import', () => { 'content-Type': 'multipart/form-data; boundary=BOUNDARY', }, }; + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); const { payload, statusCode } = await server.inject(request); const response = JSON.parse(payload); expect(statusCode).toBe(200); @@ -100,6 +101,7 @@ describe('POST /api/saved_objects/_import', () => { 'content-Type': 'multipart/form-data; boundary=EXAMPLE', }, }; + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -145,6 +147,7 @@ describe('POST /api/saved_objects/_import', () => { 'content-Type': 'multipart/form-data; boundary=EXAMPLE', }, }; + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); savedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [ { @@ -178,8 +181,7 @@ describe('POST /api/saved_objects/_import', () => { id: 'my-pattern', type: 'index-pattern', error: { - statusCode: 409, - message: 'version conflict, document already exists', + type: 'conflict', }, }, ], diff --git a/src/legacy/server/saved_objects/service/lib/repository.js b/src/legacy/server/saved_objects/service/lib/repository.js index d87423aeb77f..c132643cffa9 100644 --- a/src/legacy/server/saved_objects/service/lib/repository.js +++ b/src/legacy/server/saved_objects/service/lib/repository.js @@ -414,7 +414,7 @@ export class SavedObjectsRepository { /** * Returns an array of objects by id * - * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {array} objects - an array of objects containing id, type and optionally fields * @param {object} [options={}] * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } @@ -436,10 +436,11 @@ export class SavedObjectsRepository { const response = await this._callCluster('mget', { index: this._index, body: { - docs: objects.reduce((acc, { type, id }) => { + docs: objects.reduce((acc, { type, id, fields }) => { if (this._isTypeAllowed(type)) { acc.push({ _id: this._serializer.generateRawId(namespace, type, id), + _source: includedFields(type, fields), }); } else { unsupportedTypes.push({ diff --git a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts index 41340f844857..cb9ace7bcb1b 100644 --- a/src/legacy/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/legacy/server/saved_objects/service/saved_objects_client.d.ts @@ -68,6 +68,7 @@ export interface UpdateOptions extends BaseOptions { export interface BulkGetObject { id: string; type: string; + fields?: string[]; } export type BulkGetObjects = BulkGetObject[]; diff --git a/test/api_integration/apis/saved_objects/import.js b/test/api_integration/apis/saved_objects/import.js index dc62c4c6bd93..b38109d32e7e 100644 --- a/test/api_integration/apis/saved_objects/import.js +++ b/test/api_integration/apis/saved_objects/import.js @@ -57,7 +57,7 @@ export default function ({ getService }) { }); }); - it('should return 409 when conflicts exist', async () => { + it('should return errors when conflicts exist', async () => { await supertest .post('/api/saved_objects/_import') .attach('file', join(__dirname, '../../fixtures/import.ndjson')) @@ -71,24 +71,21 @@ export default function ({ getService }) { id: '91200a00-9efd-11e7-acb3-3dab96693fab', type: 'index-pattern', error: { - statusCode: 409, - message: 'version conflict, document already exists', + type: 'conflict', } }, { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { - statusCode: 409, - message: 'version conflict, document already exists', + type: 'conflict', } }, { id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', type: 'dashboard', error: { - statusCode: 409, - message: 'version conflict, document already exists', + type: 'conflict', } }, ], @@ -129,6 +126,57 @@ export default function ({ getService }) { }); }); }); + + it('should return errors when index patterns or search are missing', async () => { + const objectsToImport = [ + JSON.stringify({ + type: 'visualization', + id: '1', + attributes: {}, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'non-existing', + }, + { + name: 'ref_1', + type: 'search', + id: 'non-existing-search', + }, + ], + }), + ]; + await supertest + .post('/api/saved_objects/_import') + .attach('file', Buffer.from(objectsToImport.join('\n'), 'utf8'), 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + success: false, + successCount: 0, + errors: [ + { + type: 'visualization', + id: '1', + error: { + type: 'missing_references', + references: [ + { + type: 'index-pattern', + id: 'non-existing', + }, + { + type: 'search', + id: 'non-existing-search', + }, + ], + }, + }, + ], + }); + }); + }); }); }); }); diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index 82e1ce379325..9f45044031e5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -65,6 +65,7 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe message: `Unsupported saved object type: 'wigwags': Bad Request`, statusCode: 400, error: 'Bad Request', + type: 'unknown', }, }, ], diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index e75e889f3d59..c13a5bf15afe 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -69,6 +69,7 @@ export function resolveImportErrorsTestSuiteFactory( message: `Unsupported saved object type: 'wigwags': Bad Request`, statusCode: 400, error: 'Bad Request', + type: 'unknown', }, }, ],