[7.x] Add SavedObjectsClient.bulkResolve (#112025) (#112486)

* Add SavedObjectsClient.bulkResolve (#112025)

* Fix type error

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-09-16 19:54:56 -04:00 committed by GitHub
parent de32f3c79e
commit b34f98695a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 3065 additions and 901 deletions

View file

@ -16,6 +16,8 @@ The following saved objects APIs are available:
* <<saved-objects-api-bulk-get, Bulk get objects API>> to retrieve multiple {kib} saved objects by ID
* <<saved-objects-api-bulk-resolve, Bulk resolve objects API>> to retrieve multiple {kib} saved objects by ID, using any legacy URL aliases if they exist
* <<saved-objects-api-find, Find objects API>> to retrieve a paginated set of {kib} saved objects by various conditions
* <<saved-objects-api-create, Create saved object API>> to create {kib} saved objects
@ -45,4 +47,5 @@ include::saved-objects/export.asciidoc[]
include::saved-objects/import.asciidoc[]
include::saved-objects/resolve_import_errors.asciidoc[]
include::saved-objects/resolve.asciidoc[]
include::saved-objects/bulk_resolve.asciidoc[]
include::saved-objects/rotate_encryption_key.asciidoc[]

View file

@ -0,0 +1,176 @@
[[saved-objects-api-bulk-resolve]]
=== Bulk resolve objects API
++++
<titleabbrev>Bulk resolve objects</titleabbrev>
++++
experimental[] Retrieve multiple {kib} saved objects by ID, using any legacy URL aliases if they exist.
Under certain circumstances, when Kibana is upgraded, saved object migrations may necessitate regenerating some object IDs to enable new
features. When an object's ID is regenerated, a legacy URL alias is created for that object, preserving its old ID. In such a scenario, that
object can be retrieved via the Bulk Resolve API using either its new ID or its old ID.
[[saved-objects-api-bulk-resolve-request]]
==== Request
`POST <kibana host>:<port>/api/saved_objects/_bulk_resolve`
`POST <kibana host>:<port>/s/<space_id>/api/saved_objects/_bulk_resolve`
[[saved-objects-api-bulk-resolve-path-params]]
==== Path parameters
`space_id`::
(Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used.
[[saved-objects-api-bulk-resolve-request-body]]
==== Request Body
`type`::
(Required, string) Valid options include `visualization`, `dashboard`, `search`, `index-pattern`, `config`.
`id`::
(Required, string) ID of the retrieved object. The ID includes the {kib} unique identifier or a custom identifier.
[[saved-objects-api-bulk-resolve-response-body]]
==== Response body
`resolved_objects`::
(array) Top-level property containing objects that represent the response for each of the requested objects. The order of the objects in the response is identical to the order of the objects in the request.
Saved objects that {kib} fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is
that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to `_bulk_resolve`; the
<<saved-objects-api-resolve,regular `resolve` API>> will return only an HTTP error instead.
[[saved-objects-api-bulk-resolve-body-codes]]
==== Response code
`200`::
Indicates a successful call.
[[saved-objects-api-bulk-resolve-body-example]]
==== Example
Retrieve an index pattern with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID:
[source,sh]
--------------------------------------------------
$ curl -X POST api/saved_objects/_bulk_resolve
[
{
"type": "index-pattern",
"id": "my-pattern"
},
{
"type": "dashboard",
"id": "be3733a0-9efe-11e7-acb3-3dab96693fab"
}
]
--------------------------------------------------
// KIBANA
The API returns the following:
[source,sh]
--------------------------------------------------
{
"resolved_objects": [
{
"saved_object": {
"id": "my-pattern",
"type": "index-pattern",
"version": 1,
"attributes": {
"title": "my-pattern-*"
}
},
"outcome": "exactMatch"
},
{
"saved_object": {
"id": "my-dashboard",
"type": "dashboard",
"error": {
"statusCode": 404,
"message": "Not found"
}
},
"outcome": "exactMatch"
}
]
}
--------------------------------------------------
Only the index pattern exists, the dashboard was not found.
The `outcome` field may be any of the following:
* `"exactMatch"` -- One document exactly matched the given ID, *or* {kib} failed to find this object.
* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID.
* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID.
If the outcome is `"aliasMatch"` or `"conflict"`, the response will also include an `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID.
Retrieve a dashboard object in the `testspace` by ID:
[source,sh]
--------------------------------------------------
$ curl -X GET s/testspace/api/saved_objects/resolve/dashboard/7adfa750-4c81-11e8-b3d7-01146121b73d
--------------------------------------------------
// KIBANA
The API returns the following:
[source,sh]
--------------------------------------------------
{
"resolved_objects": [
{
"saved_object": {
"id": "7adfa750-4c81-11e8-b3d7-01146121b73d",
"type": "dashboard",
"updated_at": "2019-07-23T00:11:07.059Z",
"version": "WzQ0LDFd",
"attributes": {
"title": "[Flights] Global Flight Dashboard",
"hits": 0,
"description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats",
"panelsJSON": "[ . . . ]",
"optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}",
"version": 1,
"timeRestore": true,
"timeTo": "now",
"timeFrom": "now-24h",
"refreshInterval": {
"display": "15 minutes",
"pause": false,
"section": 2,
"value": 900000
},
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}"
}
},
"references": [
{
"name": "panel_0",
"type": "visualization",
"id": "aeb212e0-4c84-11e8-b3d7-01146121b73d"
},
. . .
{
"name": "panel_18",
"type": "visualization",
"id": "ed78a660-53a0-11e8-acbd-0be0ad9d822b"
}
],
"migrationVersion": {
"dashboard": "7.0.0"
}
},
"outcome": "conflict",
"alias_target_id": "05becb88-e214-439a-a2ac-15fc783b5d01"
}
]
}
--------------------------------------------------

View file

@ -412,7 +412,7 @@ deprecate and remove them.
[[sharing-saved-objects-faq-resolve-outcomes]]
==== 5. Why are there three different resolve outcomes?
The `resolve()` function first checks if an object with the given ID exists, and then it checks if an object has an alias with the given ID.
The `resolve()` function checks both if an object with the given ID exists, _and_ if an object has an alias with the given ID.
1. If only the former is true, the outcome is an `'exactMatch'` -- we found the exact object we were looking for.
2. If only the latter is true, the outcome is an `'aliasMatch'` -- we found an alias with this ID, that pointed us to an object with a

View file

@ -107,6 +107,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsBatchResponse](./kibana-plugin-core-public.savedobjectsbatchresponse.md) | |
| [SavedObjectsBulkCreateObject](./kibana-plugin-core-public.savedobjectsbulkcreateobject.md) | |
| [SavedObjectsBulkCreateOptions](./kibana-plugin-core-public.savedobjectsbulkcreateoptions.md) | |
| [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) | |
| [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md) | |
| [SavedObjectsBulkUpdateObject](./kibana-plugin-core-public.savedobjectsbulkupdateobject.md) | |
| [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-public.savedobjectsbulkupdateoptions.md) | |
| [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md) | The response when object references are collected. |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) &gt; [id](./kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md)
## SavedObjectsBulkResolveObject.id property
<b>Signature:</b>
```typescript
id: string;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md)
## SavedObjectsBulkResolveObject interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkResolveObject
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [id](./kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md) | <code>string</code> | |
| [type](./kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) &gt; [type](./kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md)
## SavedObjectsBulkResolveObject.type property
<b>Signature:</b>
```typescript
type: string;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md)
## SavedObjectsBulkResolveResponse interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkResolveResponse<T = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [resolved\_objects](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md) | <code>Array&lt;SavedObjectsResolveResponse&lt;T&gt;&gt;</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md) &gt; [resolved\_objects](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md)
## SavedObjectsBulkResolveResponse.resolved\_objects property
<b>Signature:</b>
```typescript
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
```

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) &gt; [bulkResolve](./kibana-plugin-core-public.savedobjectsclient.bulkresolve.md)
## SavedObjectsClient.bulkResolve property
Resolves an array of objects by id, using any legacy URL aliases if they exist
<b>Signature:</b>
```typescript
bulkResolve: <T = unknown>(objects?: Array<{
id: string;
type: string;
}>) => Promise<{
resolved_objects: ResolvedSimpleSavedObject<T>[];
}>;
```
## Example
bulkResolve(\[ { id: 'one', type: 'config' }<!-- -->, { id: 'foo', type: 'index-pattern' } \])
Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. The `resolve` method in the public client uses `bulkResolve` under the hood, so it behaves the same way.

View file

@ -22,6 +22,7 @@ The constructor for this class is marked as internal. Third-party code should no
| --- | --- | --- | --- |
| [bulkCreate](./kibana-plugin-core-public.savedobjectsclient.bulkcreate.md) | | <code>(objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) =&gt; Promise&lt;SavedObjectsBatchResponse&lt;unknown&gt;&gt;</code> | Creates multiple documents at once |
| [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | <code>(objects?: Array&lt;{</code><br/><code> id: string;</code><br/><code> type: string;</code><br/><code> }&gt;) =&gt; Promise&lt;SavedObjectsBatchResponse&lt;unknown&gt;&gt;</code> | Returns an array of objects by id |
| [bulkResolve](./kibana-plugin-core-public.savedobjectsclient.bulkresolve.md) | | <code>&lt;T = unknown&gt;(objects?: Array&lt;{</code><br/><code> id: string;</code><br/><code> type: string;</code><br/><code> }&gt;) =&gt; Promise&lt;{</code><br/><code> resolved_objects: ResolvedSimpleSavedObject&lt;T&gt;[];</code><br/><code> }&gt;</code> | Resolves an array of objects by id, using any legacy URL aliases if they exist |
| [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <code>&lt;T = unknown&gt;(type: string, attributes: T, options?: SavedObjectsCreateOptions) =&gt; Promise&lt;SimpleSavedObject&lt;T&gt;&gt;</code> | Persists an object |
| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | <code>(type: string, id: string, options?: SavedObjectsDeleteOptions &#124; undefined) =&gt; ReturnType&lt;SavedObjectsApi['delete']&gt;</code> | Deletes an object |
| [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <code>&lt;T = unknown, A = unknown&gt;(options: SavedObjectsFindOptions) =&gt; Promise&lt;SavedObjectsFindResponsePublic&lt;T, unknown&gt;&gt;</code> | Search for objects |

View file

@ -27,6 +27,7 @@ async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecations
const deprecations: DeprecationsDetails[] = [];
const count = await getFooCount(savedObjectsClient);
if (count > 0) {
// Example of a manual correctiveAction
deprecations.push({
title: i18n.translate('xpack.foo.deprecations.title', {
defaultMessage: `Foo's are deprecated`

View file

@ -146,6 +146,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsBaseOptions](./kibana-plugin-core-server.savedobjectsbaseoptions.md) | |
| [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) | |
| [SavedObjectsBulkGetObject](./kibana-plugin-core-server.savedobjectsbulkgetobject.md) | |
| [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) | |
| [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md) | |
| [SavedObjectsBulkResponse](./kibana-plugin-core-server.savedobjectsbulkresponse.md) | |
| [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) | |
| [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md) | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) &gt; [id](./kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md)
## SavedObjectsBulkResolveObject.id property
<b>Signature:</b>
```typescript
id: string;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md)
## SavedObjectsBulkResolveObject interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkResolveObject
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [id](./kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md) | <code>string</code> | |
| [type](./kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) &gt; [type](./kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md)
## SavedObjectsBulkResolveObject.type property
<b>Signature:</b>
```typescript
type: string;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md)
## SavedObjectsBulkResolveResponse interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkResolveResponse<T = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [resolved\_objects](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md) | <code>Array&lt;SavedObjectsResolveResponse&lt;T&gt;&gt;</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md) &gt; [resolved\_objects](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md)
## SavedObjectsBulkResolveResponse.resolved\_objects property
<b>Signature:</b>
```typescript
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
```

View file

@ -0,0 +1,31 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) &gt; [bulkResolve](./kibana-plugin-core-server.savedobjectsclient.bulkresolve.md)
## SavedObjectsClient.bulkResolve() method
Resolves an array of objects by id, using any legacy URL aliases if they exist
<b>Signature:</b>
```typescript
bulkResolve<T = unknown>(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResolveResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>SavedObjectsBulkResolveObject[]</code> | an array of objects containing id, type |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBulkResolveResponse<T>>`
## Example
bulkResolve(\[ { id: 'one', type: 'config' }<!-- -->, { id: 'foo', type: 'index-pattern' } \])
Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to `bulkResolve`<!-- -->; the regular `resolve` API will throw an error instead.

View file

@ -27,6 +27,7 @@ The constructor for this class is marked as internal. Third-party code should no
| --- | --- | --- |
| [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request |
| [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id |
| [bulkResolve(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkresolve.md) | | Resolves an array of objects by id, using any legacy URL aliases if they exist |
| [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once |
| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. |
| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md)<!-- -->.<!-- -->Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. |

View file

@ -0,0 +1,31 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) &gt; [bulkResolve](./kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md)
## SavedObjectsRepository.bulkResolve() method
Resolves an array of objects by id, using any legacy URL aliases if they exist
<b>Signature:</b>
```typescript
bulkResolve<T = unknown>(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResolveResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>SavedObjectsBulkResolveObject[]</code> | |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBulkResolveResponse<T>>`
{<!-- -->promise<!-- -->} - { resolved\_objects: \[{ saved\_object, outcome }<!-- -->\] }
## Example
bulkResolve(\[ { id: 'one', type: 'config' }<!-- -->, { id: 'foo', type: 'index-pattern' } \])

View file

@ -17,6 +17,7 @@ export declare class SavedObjectsRepository
| --- | --- | --- |
| [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once |
| [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id |
| [bulkResolve(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md) | | Resolves an array of objects by id, using any legacy URL aliases if they exist |
| [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk |
| [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. |
| [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using <code>openPointInTimeForType</code>.<!-- -->Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. |

View file

@ -103,6 +103,8 @@ export type {
SavedObjectsBatchResponse,
SavedObjectsBulkCreateObject,
SavedObjectsBulkCreateOptions,
SavedObjectsBulkResolveObject,
SavedObjectsBulkResolveResponse,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkUpdateOptions,
SavedObjectsCreateOptions,

View file

@ -1254,6 +1254,20 @@ export interface SavedObjectsBulkCreateOptions {
overwrite?: boolean;
}
// @public (undocumented)
export interface SavedObjectsBulkResolveObject {
// (undocumented)
id: string;
// (undocumented)
type: string;
}
// @public (undocumented)
export interface SavedObjectsBulkResolveResponse<T = unknown> {
// (undocumented)
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateObject<T = unknown> {
// (undocumented)
@ -1283,6 +1297,12 @@ export class SavedObjectsClient {
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>>;
bulkResolve: <T = unknown>(objects?: Array<{
id: string;
type: string;
}>) => Promise<{
resolved_objects: ResolvedSimpleSavedObject<T>[];
}>;
bulkUpdate<T = unknown>(objects?: SavedObjectsBulkUpdateObject[]): Promise<SavedObjectsBatchResponse<unknown>>;
create: <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>>;
// Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts

View file

@ -17,7 +17,6 @@ export type {
SavedObjectsCreateOptions,
SavedObjectsFindResponsePublic,
SavedObjectsUpdateOptions,
SavedObjectsResolveResponse,
SavedObjectsBulkUpdateOptions,
} from './saved_objects_client';
export { SimpleSavedObject } from './simple_saved_object';
@ -43,7 +42,10 @@ export type {
SavedObjectsImportWarning,
SavedObjectReferenceWithContext,
SavedObjectsCollectMultiNamespaceReferencesResponse,
} from '../../server/types';
SavedObjectsBulkResolveObject,
SavedObjectsBulkResolveResponse,
SavedObjectsResolveResponse,
} from '../../server';
export type {
SavedObject,

View file

@ -6,8 +6,6 @@
* Side Public License, v 1.
*/
import type { SavedObjectsResolveResponse } from 'src/core/server';
import { SavedObjectsClient } from './saved_objects_client';
import { SimpleSavedObject } from './simple_saved_object';
import { httpServiceMock } from '../http/http_service.mock';
@ -151,36 +149,61 @@ describe('SavedObjectsClient', () => {
describe('#resolve', () => {
beforeEach(() => {
beforeEach(() => {
http.fetch.mockResolvedValue({
saved_object: doc,
outcome: 'conflict',
alias_target_id: 'another-id',
} as SavedObjectsResolveResponse);
http.fetch.mockResolvedValue({
resolved_objects: [
{ saved_object: doc, outcome: 'conflict', alias_target_id: 'another-id' },
],
});
});
test('rejects if `type` is undefined', async () => {
expect(savedObjectsClient.resolve(undefined as any, doc.id)).rejects.toMatchInlineSnapshot(
`[Error: requires type and id]`
test('rejects if `type` parameter is undefined', () => {
return expect(
savedObjectsClient.resolve(undefined as any, undefined as any)
).rejects.toMatchInlineSnapshot(`[Error: requires type and id]`);
});
test('rejects if `id` parameter is undefined', () => {
return expect(
savedObjectsClient.resolve('index-pattern', undefined as any)
).rejects.toMatchInlineSnapshot(`[Error: requires type and id]`);
});
test('rejects when HTTP call fails', () => {
http.fetch.mockRejectedValue(new Error('Request failed'));
return expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot(
`[Error: Request failed]`
);
});
test('rejects if `id` is undefined', async () => {
expect(savedObjectsClient.resolve(doc.type, undefined as any)).rejects.toMatchInlineSnapshot(
`[Error: requires type and id]`
);
test('makes HTTP call', async () => {
await savedObjectsClient.resolve(doc.type, doc.id);
expect(http.fetch.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/saved_objects/_bulk_resolve",
Object {
"body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\"}]",
"method": "POST",
"query": undefined,
},
]
`);
});
test('makes HTTP call', () => {
savedObjectsClient.resolve(doc.type, doc.id);
test('batches several #resolve calls into a single HTTP call', async () => {
// Await #resolve call to ensure batchQueue is empty and throttle has reset
await savedObjectsClient.resolve('type2', doc.id);
http.fetch.mockClear();
// Make two #resolve calls right after one another
savedObjectsClient.resolve('type1', doc.id);
await savedObjectsClient.resolve('type0', doc.id);
expect(http.fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/saved_objects/resolve/config/AVwSwFxtcMV38qjDZoQg",
"/api/saved_objects/_bulk_resolve",
Object {
"body": undefined,
"method": undefined,
"body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"type1\\"},{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"type0\\"}]",
"method": "POST",
"query": undefined,
},
],
@ -188,11 +211,55 @@ describe('SavedObjectsClient', () => {
`);
});
test('rejects when HTTP call fails', async () => {
http.fetch.mockRejectedValueOnce(new Error('Request failed'));
await expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot(
`[Error: Request failed]`
);
test('removes duplicates when calling `_bulk_resolve`', async () => {
// Await #resolve call to ensure batchQueue is empty and throttle has reset
await savedObjectsClient.resolve('type2', doc.id);
http.fetch.mockClear();
savedObjectsClient.resolve(doc.type, doc.id);
savedObjectsClient.resolve('some-type', 'some-id');
await savedObjectsClient.resolve(doc.type, doc.id);
expect(http.fetch).toHaveBeenCalledTimes(1);
expect(http.fetch.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/saved_objects/_bulk_resolve",
Object {
"body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\"},{\\"id\\":\\"some-id\\",\\"type\\":\\"some-type\\"}]",
"method": "POST",
"query": undefined,
},
]
`);
});
test('resolves with correct object when there are duplicates present', async () => {
// Await #resolve call to ensure batchQueue is empty and throttle has reset
await savedObjectsClient.resolve('type2', doc.id);
http.fetch.mockClear();
const call1 = savedObjectsClient.resolve(doc.type, doc.id);
const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id);
const objFromCall1 = await call1;
expect(objFromCall1.saved_object.type).toBe(doc.type);
expect(objFromCall1.saved_object.id).toBe(doc.id);
expect(objFromCall2.saved_object.type).toBe(doc.type);
expect(objFromCall2.saved_object.id).toBe(doc.id);
});
test('do not share instances or references between duplicate callers', async () => {
// Await #resolve call to ensure batchQueue is empty and throttle has reset
await savedObjectsClient.resolve('type2', doc.id);
http.fetch.mockClear();
const call1 = savedObjectsClient.resolve(doc.type, doc.id);
const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id);
const objFromCall1 = await call1;
objFromCall1.saved_object.set('title', 'new title');
expect(objFromCall2.saved_object.get('title')).toEqual('Example title');
});
test('resolves with ResolvedSimpleSavedObject instance', async () => {

View file

@ -13,6 +13,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
import {
SavedObject,
SavedObjectReference,
SavedObjectsBulkResolveResponse,
SavedObjectsClientContract as SavedObjectsApi,
SavedObjectsFindOptions as SavedObjectFindOptionsServer,
SavedObjectsMigrationVersion,
@ -23,8 +24,6 @@ import { SimpleSavedObject } from './simple_saved_object';
import type { ResolvedSimpleSavedObject } from './types';
import { HttpFetchOptions, HttpSetup } from '../http';
export type { SavedObjectsResolveResponse };
type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
type SavedObjectsFindOptions = Omit<
@ -114,12 +113,18 @@ export interface SavedObjectsFindResponsePublic<T = unknown, A = unknown>
page: number;
}
interface BatchQueueEntry {
interface BatchGetQueueEntry {
type: string;
id: string;
resolve: <T = unknown>(value: SimpleSavedObject<T> | SavedObject<T>) => void;
reject: (reason?: any) => void;
}
interface BatchResolveQueueEntry {
type: string;
id: string;
resolve: <T = unknown>(value: ResolvedSimpleSavedObject<T>) => void;
reject: (reason?: any) => void;
}
const join = (...uriComponents: Array<string | undefined>) =>
uriComponents
@ -147,7 +152,9 @@ interface ObjectTypeAndId {
type: string;
}
const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => {
const getObjectsToFetch = (
queue: Array<BatchGetQueueEntry | BatchResolveQueueEntry>
): ObjectTypeAndId[] => {
const objects: ObjectTypeAndId[] = [];
const inserted = new Set<string>();
queue.forEach(({ id, type }) => {
@ -169,15 +176,16 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => {
*/
export class SavedObjectsClient {
private http: HttpSetup;
private batchQueue: BatchQueueEntry[];
private batchGetQueue: BatchGetQueueEntry[];
private batchResolveQueue: BatchResolveQueueEntry[];
/**
* Throttled processing of get requests into bulk requests at 100ms interval
*/
private processBatchQueue = throttle(
private processBatchGetQueue = throttle(
async () => {
const queue = [...this.batchQueue];
this.batchQueue = [];
const queue = [...this.batchGetQueue];
this.batchGetQueue = [];
try {
const objectsToFetch = getObjectsToFetch(queue);
@ -208,10 +216,53 @@ export class SavedObjectsClient {
{ leading: false }
);
/**
* Throttled processing of resolve requests into bulk requests at 100ms interval
*/
private processBatchResolveQueue = throttle(
async () => {
const queue = [...this.batchResolveQueue];
this.batchResolveQueue = [];
try {
const objectsToFetch = getObjectsToFetch(queue);
const { resolved_objects: savedObjects } = await this.performBulkResolve(objectsToFetch);
queue.forEach((queueItem) => {
const foundObject = savedObjects.find((resolveResponse) => {
return (
resolveResponse.saved_object.id === queueItem.id &&
resolveResponse.saved_object.type === queueItem.type
);
});
if (foundObject) {
// multiple calls may have been requested the same object.
// we need to clone to avoid sharing references between the instances
queueItem.resolve(this.createResolvedSavedObject(cloneDeep(foundObject)));
} else {
queueItem.resolve(
this.createResolvedSavedObject({
saved_object: pick(queueItem, ['id', 'type']),
} as SavedObjectsResolveResponse)
);
}
});
} catch (err) {
queue.forEach((queueItem) => {
queueItem.reject(err);
});
}
},
BATCH_INTERVAL,
{ leading: false }
);
/** @internal */
constructor(http: HttpSetup) {
this.http = http;
this.batchQueue = [];
this.batchGetQueue = [];
this.batchResolveQueue = [];
}
/**
@ -388,8 +439,8 @@ export class SavedObjectsClient {
}
return new Promise((resolve, reject) => {
this.batchQueue.push({ type, id, resolve, reject } as BatchQueueEntry);
this.processBatchQueue();
this.batchGetQueue.push({ type, id, resolve, reject } as BatchGetQueueEntry);
this.processBatchGetQueue();
});
};
@ -431,6 +482,11 @@ export class SavedObjectsClient {
* @param {string} type
* @param {string} id
* @returns The resolve result for the saved object for the given type and id.
*
* @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the
* outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior for the `resolve`
* API is unique to the public client, which batches individual calls with `bulkResolve` under the hood. We don't throw an error in that
* case for legacy compatibility reasons.
*/
public resolve = <T = unknown>(
type: string,
@ -440,18 +496,47 @@ export class SavedObjectsClient {
return Promise.reject(new Error('requires type and id'));
}
const path = `${this.getPath(['resolve'])}/${type}/${id}`;
const request: Promise<SavedObjectsResolveResponse<T>> = this.savedObjectsFetch(path, {});
return request.then((resolveResponse) => {
const simpleSavedObject = new SimpleSavedObject<T>(this, resolveResponse.saved_object);
return {
saved_object: simpleSavedObject,
outcome: resolveResponse.outcome,
alias_target_id: resolveResponse.alias_target_id,
};
return new Promise((resolve, reject) => {
this.batchResolveQueue.push({ type, id, resolve, reject } as BatchResolveQueueEntry);
this.processBatchResolveQueue();
});
};
/**
* Resolves an array of objects by id, using any legacy URL aliases if they exist
*
* @param objects - an array of objects containing id, type
* @returns The bulk resolve result for the saved objects for the given types and ids.
* @example
*
* bulkResolve([
* { id: 'one', type: 'config' },
* { id: 'foo', type: 'index-pattern' }
* ])
*
* @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the
* outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. The `resolve` method in the
* public client uses `bulkResolve` under the hood, so it behaves the same way.
*/
public bulkResolve = async <T = unknown>(objects: Array<{ id: string; type: string }> = []) => {
const filteredObjects = objects.map(({ type, id }) => ({ type, id }));
const response = await this.performBulkResolve<T>(filteredObjects);
return {
resolved_objects: response.resolved_objects.map((resolveResponse) =>
this.createResolvedSavedObject<T>(resolveResponse)
),
};
};
private async performBulkResolve<T>(objects: ObjectTypeAndId[]) {
const path = this.getPath(['_bulk_resolve']);
const request: Promise<SavedObjectsBulkResolveResponse<T>> = this.savedObjectsFetch(path, {
method: 'POST',
body: JSON.stringify(objects),
});
return request;
}
/**
* Updates an object
*
@ -514,6 +599,17 @@ export class SavedObjectsClient {
return new SimpleSavedObject(this, options);
}
private createResolvedSavedObject<T = unknown>(
resolveResponse: SavedObjectsResolveResponse<T>
): ResolvedSimpleSavedObject<T> {
const simpleSavedObject = new SimpleSavedObject<T>(this, resolveResponse.saved_object);
return {
saved_object: simpleSavedObject,
outcome: resolveResponse.outcome,
alias_target_id: resolveResponse.alias_target_id,
};
}
private getPath(path: Array<string | undefined>): string {
return resolveUrl(API_BASE_URL, join(...path));
}

View file

@ -13,6 +13,7 @@ const createStartContractMock = () => {
client: {
create: jest.fn(),
bulkCreate: jest.fn(),
bulkResolve: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),

View file

@ -13,6 +13,7 @@ const createUsageStatsClientMock = () =>
getUsageStats: jest.fn().mockResolvedValue({}),
incrementSavedObjectsBulkCreate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkGet: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkResolve: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkUpdate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsCreate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null),

View file

@ -27,6 +27,7 @@ import {
EXPORT_STATS_PREFIX,
LEGACY_DASHBOARDS_IMPORT_STATS_PREFIX,
LEGACY_DASHBOARDS_EXPORT_STATS_PREFIX,
BULK_RESOLVE_STATS_PREFIX,
} from './core_usage_stats_client';
import { CoreUsageStatsClient } from '.';
import { DEFAULT_NAMESPACE_STRING } from '../saved_objects/service/lib/utils';
@ -222,6 +223,81 @@ describe('CoreUsageStatsClient', () => {
});
});
describe('#incrementSavedObjectsBulkResolve', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
const request = httpServerMock.createKibanaRequest();
await expect(
usageStatsClient.incrementSavedObjectsBulkResolve({
request,
} as BaseIncrementOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalled();
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsBulkResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_RESOLVE_STATS_PREFIX}.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.default.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.no`,
],
incrementOptions
);
});
it('handles truthy options and the default namespace string appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING);
const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders });
await usageStatsClient.incrementSavedObjectsBulkResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_RESOLVE_STATS_PREFIX}.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.default.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
],
incrementOptions
);
});
it('handles a non-default space appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup('foo');
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsBulkResolve({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_RESOLVE_STATS_PREFIX}.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.custom.total`,
`${BULK_RESOLVE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`,
],
incrementOptions
);
});
});
describe('#incrementSavedObjectsBulkUpdate', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();

View file

@ -35,6 +35,7 @@ export type IncrementSavedObjectsExportOptions = BaseIncrementOptions & {
export const BULK_CREATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkCreate';
export const BULK_GET_STATS_PREFIX = 'apiCalls.savedObjectsBulkGet';
export const BULK_RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsBulkResolve';
export const BULK_UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkUpdate';
export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate';
export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete';
@ -59,6 +60,7 @@ const ALL_COUNTER_FIELDS = [
// Saved Objects Client APIs
...getFieldsForCounter(BULK_CREATE_STATS_PREFIX),
...getFieldsForCounter(BULK_GET_STATS_PREFIX),
...getFieldsForCounter(BULK_RESOLVE_STATS_PREFIX),
...getFieldsForCounter(BULK_UPDATE_STATS_PREFIX),
...getFieldsForCounter(CREATE_STATS_PREFIX),
...getFieldsForCounter(DELETE_STATS_PREFIX),
@ -123,6 +125,10 @@ export class CoreUsageStatsClient {
await this.updateUsageStats([], BULK_GET_STATS_PREFIX, options);
}
public async incrementSavedObjectsBulkResolve(options: BaseIncrementOptions) {
await this.updateUsageStats([], BULK_RESOLVE_STATS_PREFIX, options);
}
public async incrementSavedObjectsBulkUpdate(options: BaseIncrementOptions) {
await this.updateUsageStats([], BULK_UPDATE_STATS_PREFIX, options);
}

View file

@ -31,6 +31,13 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsBulkGet.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkGet.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkGet.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkResolve.total'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.default.total'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkUpdate.total'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.default.total'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.default.kibanaRequest.yes'?: number;

View file

@ -311,6 +311,8 @@ export type {
SavedObjectUnsanitizedDoc,
SavedObjectsRepositoryFactory,
SavedObjectsResolveImportErrorsOptions,
SavedObjectsBulkResolveObject,
SavedObjectsBulkResolveResponse,
SavedObjectsResolveResponse,
SavedObjectsUpdateOptions,
SavedObjectsUpdateResponse,

View file

@ -0,0 +1,39 @@
/*
* 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 { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import { catchAndReturnBoomErrors } from './utils';
interface RouteDependencies {
coreUsageData: CoreUsageDataSetup;
}
export const registerBulkResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => {
router.post(
{
path: '/_bulk_resolve',
validate: {
body: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
})
),
},
},
catchAndReturnBoomErrors(async (context, req, res) => {
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsBulkResolve({ request: req }).catch(() => {});
const result = await context.core.savedObjects.client.bulkResolve(req.body);
return res.ok({ body: result });
})
);
};

View file

@ -27,6 +27,7 @@ import { registerResolveImportErrorsRoute } from './resolve_import_errors';
import { registerMigrateRoute } from './migrate';
import { registerLegacyImportRoute } from './legacy_import_export/import';
import { registerLegacyExportRoute } from './legacy_import_export/export';
import { registerBulkResolveRoute } from './bulk_resolve';
import { registerDeleteUnknownTypesRoute } from './deprecations';
import { KibanaConfigType } from '../../kibana_config';
@ -57,6 +58,7 @@ export function registerRoutes({
registerUpdateRoute(router, { coreUsageData });
registerBulkGetRoute(router, { coreUsageData });
registerBulkCreateRoute(router, { coreUsageData });
registerBulkResolveRoute(router, { coreUsageData });
registerBulkUpdateRoute(router, { coreUsageData });
registerLogLegacyImportRoute(router, logger);
registerExportRoute(router, { config, coreUsageData });

View file

@ -0,0 +1,97 @@
/*
* 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 supertest from 'supertest';
import { UnwrapPromise } from '@kbn/utility-types';
import { registerBulkResolveRoute } from '../bulk_resolve';
import { savedObjectsClientMock } from '../../../../../core/server/mocks';
import { CoreUsageStatsClient } from '../../../core_usage_data';
import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock';
import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock';
import { setupServer } from '../test_utils';
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
describe('POST /api/saved_objects/_bulk_resolve', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let handlerContext: SetupServerReturn['handlerContext'];
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let coreUsageStatsClient: jest.Mocked<CoreUsageStatsClient>;
beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.bulkResolve.mockResolvedValue({
resolved_objects: [],
});
const router = httpSetup.createRouter('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsBulkResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerBulkResolveRoute(router, { coreUsageData });
await server.start();
});
afterEach(async () => {
await server.stop();
});
it('formats successful response and records usage stats', async () => {
const clientResponse = {
resolved_objects: [
{
saved_object: {
id: 'abc123',
type: 'index-pattern',
title: 'logstash-*',
version: 'foo',
references: [],
attributes: {},
},
outcome: 'exactMatch' as const,
},
],
};
savedObjectsClient.bulkResolve.mockImplementation(() => Promise.resolve(clientResponse));
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_resolve')
.send([
{
id: 'abc123',
type: 'index-pattern',
},
])
.expect(200);
expect(result.body).toEqual(clientResponse);
expect(coreUsageStatsClient.incrementSavedObjectsBulkResolve).toHaveBeenCalledWith({
request: expect.anything(),
});
});
it('calls upon savedObjectClient.bulkResolve', async () => {
const docs = [
{
id: 'abc123',
type: 'index-pattern',
},
];
await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_resolve')
.send(docs)
.expect(200);
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkResolve).toHaveBeenCalledWith(docs);
});
});

View file

@ -245,6 +245,15 @@ describe('404s from proxies', () => {
expect(docsFound.saved_objects.length).toBeGreaterThan(0);
});
it('handles `bulkResolve` requests that are successful when the proxy passes through the product header', async () => {
const docsToGet = myOtherTypeDocs;
const docsFound = await repository.bulkResolve(
docsToGet.map((doc) => ({ id: doc.id, type: 'my_other_type' }))
);
expect(docsFound.resolved_objects.length).toBeGreaterThan(0);
expect(docsFound.resolved_objects[0].outcome).toBe('exactMatch');
});
it('handles `resolve` requests that are successful with an exact match', async () => {
const resolvedExactMatch = await repository.resolve('my_other_type', `${myOtherType.id}`);
expect(resolvedExactMatch.outcome).toBe('exactMatch');
@ -399,14 +408,27 @@ describe('404s from proxies', () => {
expect(genericNotFoundEsUnavailableError(deleteErr, 'my_type', 'myTypeId1'));
});
it('returns an EsUnavailable error on `bulkResolve` requests with a 404 proxy response and wrong product header for an exact match', async () => {
const docsToGet = myTypeDocs;
let testBulkResolveErr: any;
setProxyInterrupt('internalBulkResolve');
try {
await repository.bulkGet(docsToGet.map((doc) => ({ id: doc.id, type: 'my_type' })));
} catch (err) {
testBulkResolveErr = err;
}
expect(genericNotFoundEsUnavailableError(testBulkResolveErr));
});
it('returns an EsUnavailable error on `resolve` requests with a 404 proxy response and wrong product header for an exact match', async () => {
setProxyInterrupt('internalBulkResolve');
let testResolveErr: any;
try {
await repository.resolve('my_type', 'myTypeId1');
} catch (err) {
testResolveErr = err;
}
expect(genericNotFoundEsUnavailableError(testResolveErr, 'my_type', 'myTypeId1'));
expect(genericNotFoundEsUnavailableError(testResolveErr));
});
it('returns an EsUnavailable error on `bulkGet` requests with a 404 proxy response and wrong product header', async () => {

View file

@ -27,6 +27,7 @@ export const setProxyInterrupt = (
| 'find'
| 'openPit'
| 'deleteByNamespace'
| 'internalBulkResolve'
| null
) => (proxyInterrupt = testArg);
@ -118,7 +119,11 @@ export const declarePostMgetRoute = (hapiServer: Hapi.Server, hostname: string,
parse: false,
},
handler: (req, h) => {
if (proxyInterrupt === 'bulkGetMyType' || proxyInterrupt === 'checkConficts') {
if (
proxyInterrupt === 'bulkGetMyType' ||
proxyInterrupt === 'checkConficts' ||
proxyInterrupt === 'internalBulkResolve'
) {
return proxyResponseHandler(h, hostname, port);
} else {
return relayHandler(h, hostname, port);

View file

@ -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.
*/
import type * as InternalUtils from './internal_utils';
import type { isNotFoundFromUnsupportedServer } from '../../../elasticsearch';
export const mockGetSavedObjectFromSource = jest.fn() as jest.MockedFunction<
typeof InternalUtils['getSavedObjectFromSource']
>;
export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction<
typeof InternalUtils['rawDocExistsInNamespace']
>;
jest.mock('./internal_utils', () => {
const actual = jest.requireActual('./internal_utils');
return {
...actual,
getSavedObjectFromSource: mockGetSavedObjectFromSource,
rawDocExistsInNamespace: mockRawDocExistsInNamespace,
};
});
export const mockIsNotFoundFromUnsupportedServer = jest.fn() as jest.MockedFunction<
typeof isNotFoundFromUnsupportedServer
>;
jest.mock('../../../elasticsearch', () => {
const actual = jest.requireActual('../../../elasticsearch');
return {
...actual,
isNotFoundFromUnsupportedServer: mockIsNotFoundFromUnsupportedServer,
};
});

View file

@ -0,0 +1,315 @@
/*
* 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 {
mockGetSavedObjectFromSource,
mockRawDocExistsInNamespace,
mockIsNotFoundFromUnsupportedServer,
} from './internal_bulk_resolve.test.mock';
import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
import type { ElasticsearchClient } from 'src/core/server/elasticsearch';
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
import { LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { typeRegistryMock } from '../../saved_objects_type_registry.mock';
import { SavedObjectsSerializer } from '../../serialization';
import { SavedObjectsErrorHelpers } from './errors';
import { SavedObjectsBulkResolveObject } from '../saved_objects_client';
import { SavedObject, SavedObjectsBaseOptions } from '../../types';
import { internalBulkResolve, InternalBulkResolveParams } from './internal_bulk_resolve';
const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 };
const OBJ_TYPE = 'obj-type';
const UNSUPPORTED_TYPE = 'unsupported-type';
beforeEach(() => {
mockGetSavedObjectFromSource.mockReset();
mockGetSavedObjectFromSource.mockImplementation(
(_registry, _type, id) => (`mock-obj-for-${id}` as unknown) as SavedObject
);
mockRawDocExistsInNamespace.mockReset();
mockRawDocExistsInNamespace.mockReturnValue(true); // return true by default
mockIsNotFoundFromUnsupportedServer.mockReset();
mockIsNotFoundFromUnsupportedServer.mockReturnValue(false);
});
describe('internalBulkResolve', () => {
let client: DeeplyMockedKeys<ElasticsearchClient>;
let serializer: SavedObjectsSerializer;
let incrementCounterInternal: jest.Mock<any, any>;
/** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `internalBulkResolve` */
function setup(
objects: SavedObjectsBulkResolveObject[],
options: SavedObjectsBaseOptions = {}
): InternalBulkResolveParams {
const registry = typeRegistryMock.create();
client = elasticsearchClientMock.createElasticsearchClient();
serializer = new SavedObjectsSerializer(registry);
incrementCounterInternal = jest.fn().mockRejectedValue(new Error('increment error')); // mock error to implicitly test that it is caught and swallowed
return {
registry: typeRegistryMock.create(), // doesn't need additional mocks for this test suite
allowedTypes: [OBJ_TYPE],
client,
serializer,
getIndexForType: (type: string) => `index-for-${type}`,
incrementCounterInternal,
objects,
options,
};
}
/** Mocks the elasticsearch client so it returns the expected results for a bulk operation */
function mockBulkResults(
...results: Array<{ found: boolean; targetId?: string; disabled?: boolean }>
) {
client.bulk.mockReturnValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise({
items: results.map(({ found, targetId, disabled }) => ({
update: {
_index: 'doesnt-matter',
status: 0,
get: {
found,
_source: {
...((targetId || disabled) && {
[LEGACY_URL_ALIAS_TYPE]: { targetId, disabled },
}),
},
...VERSION_PROPS,
},
},
})),
errors: false,
took: 0,
})
);
}
/** Mocks the elasticsearch client so it returns the expected results for an mget operation*/
function mockMgetResults(...results: Array<{ found: boolean }>) {
client.mget.mockReturnValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise({
docs: results.map((x) => {
return x.found
? {
_id: 'doesnt-matter',
_index: 'doesnt-matter',
_source: {
foo: 'bar',
},
...VERSION_PROPS,
found: true,
}
: {
_id: 'doesnt-matter',
_index: 'doesnt-matter',
found: false,
};
}),
})
);
}
/** Asserts that bulk is called for the given aliases */
function expectBulkArgs(namespace: string, aliasIds: string[]) {
expect(client.bulk).toHaveBeenCalledTimes(1);
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({
body: aliasIds
.map((id) => [
{
update: {
_id: `legacy-url-alias:${namespace}:${OBJ_TYPE}:${id}`,
_index: `index-for-${LEGACY_URL_ALIAS_TYPE}`,
_source: true,
},
},
{ script: expect.any(Object) },
])
.flat(),
})
);
}
/** Asserts that mget is called for the given objects */
function expectMgetArgs(namespace: string | undefined, objectIds: string[]) {
expect(client.mget).toHaveBeenCalledTimes(1);
expect(client.mget).toHaveBeenCalledWith(
{
body: {
docs: objectIds.map((id) => ({
_id: serializer.generateRawId(namespace, OBJ_TYPE, id),
_index: `index-for-${OBJ_TYPE}`,
})),
},
},
expect.anything()
);
}
function expectUnsupportedTypeError(id: string) {
const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(UNSUPPORTED_TYPE);
return { type: UNSUPPORTED_TYPE, id, error };
}
function expectNotFoundError(id: string) {
const error = SavedObjectsErrorHelpers.createGenericNotFoundError(OBJ_TYPE, id);
return { type: OBJ_TYPE, id, error };
}
function expectExactMatchResult(id: string) {
return { saved_object: `mock-obj-for-${id}`, outcome: 'exactMatch' };
}
function expectAliasMatchResult(id: string) {
return { saved_object: `mock-obj-for-${id}`, outcome: 'aliasMatch', alias_target_id: id };
}
// eslint-disable-next-line @typescript-eslint/naming-convention
function expectConflictResult(id: string, alias_target_id: string) {
return { saved_object: `mock-obj-for-${id}`, outcome: 'conflict', alias_target_id };
}
it('throws if mget call results in non-ES-originated 404 error', async () => {
const objects = [{ type: OBJ_TYPE, id: '1' }];
const params = setup(objects, { namespace: 'space-x' });
mockBulkResults(
{ found: false } // fetch alias for obj 1
);
mockMgetResults(
{ found: false } // fetch obj 1 (actual result body doesn't matter, just needs statusCode and headers)
);
mockIsNotFoundFromUnsupportedServer.mockReturnValue(true);
await expect(() => internalBulkResolve(params)).rejects.toThrow(
SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError()
);
expect(client.bulk).toHaveBeenCalledTimes(1);
expect(client.mget).toHaveBeenCalledTimes(1);
});
it('returns an empty array if no object args are passed in', async () => {
const params = setup([], { namespace: 'space-x' });
const result = await internalBulkResolve(params);
expect(client.bulk).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
expect(result.resolved_objects).toEqual([]);
});
it('returns errors for unsupported object types', async () => {
const objects = [{ type: UNSUPPORTED_TYPE, id: '1' }];
const params = setup(objects, { namespace: 'space-x' });
const result = await internalBulkResolve(params);
expect(client.bulk).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
expect(result.resolved_objects).toEqual([expectUnsupportedTypeError('1')]);
});
it('returns errors for objects that are not found', async () => {
const objects = [
{ type: OBJ_TYPE, id: '1' }, // does not have an alias, and is not found
{ type: OBJ_TYPE, id: '2' }, // has an alias, but the object _and_ the alias target are not found
{ type: OBJ_TYPE, id: '3' }, // has an alias, and the object and alias target are both found, but the object _and_ the alias target do not exist in this space
];
const params = setup(objects, { namespace: 'space-x' });
mockBulkResults(
{ found: false }, // fetch alias for obj 1
{ found: true, targetId: '2-newId' }, // fetch alias for obj 2
{ found: true, targetId: '3-newId' } // fetch alias for obj 3
);
mockMgetResults(
{ found: false }, // fetch obj 1
{ found: false }, // fetch obj 2
{ found: false }, // fetch obj 2-newId
{ found: true }, // fetch obj 3
{ found: true } // fetch obj 3-newId
);
mockRawDocExistsInNamespace.mockReturnValue(false); // for objs 3 and 3-newId
const result = await internalBulkResolve(params);
expectBulkArgs('space-x', ['1', '2', '3']);
expectMgetArgs('space-x', ['1', '2', '2-newId', '3', '3-newId']);
expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(2); // for objs 3 and 3-newId
expect(result.resolved_objects).toEqual([
expectNotFoundError('1'),
expectNotFoundError('2'),
expectNotFoundError('3'),
]);
});
it('does not call bulk update in the Default space', async () => {
// Aliases cannot exist in the Default space, so we skip the alias check part of the alogrithm in that case (e.g., bulk update)
for (const namespace of [undefined, 'default']) {
const params = setup([{ type: OBJ_TYPE, id: '1' }], { namespace });
mockMgetResults(
{ found: true } // fetch obj 1
);
await internalBulkResolve(params);
expect(client.bulk).not.toHaveBeenCalled();
// 'default' is normalized to undefined
expectMgetArgs(undefined, ['1']);
}
});
it('ignores aliases that are disabled', async () => {
const objects = [{ type: OBJ_TYPE, id: '1' }];
const params = setup(objects, { namespace: 'space-x' });
mockBulkResults(
{ found: true, targetId: '1-newId', disabled: true } // fetch alias for obj 1
);
mockMgetResults(
{ found: true } // fetch obj 1
// does not attempt to fetch obj 1-newId, because that alias is disabled
);
const result = await internalBulkResolve(params);
expectBulkArgs('space-x', ['1']);
expectMgetArgs('space-x', ['1']);
expect(result.resolved_objects).toEqual([
expectExactMatchResult('1'), // result for obj 1
]);
});
it('returns a mix of results and increments the usage stats counter correctly', async () => {
const objects = [
{ type: UNSUPPORTED_TYPE, id: '1' }, // unsupported type error
{ type: OBJ_TYPE, id: '2' }, // not found error
{ type: OBJ_TYPE, id: '3' }, // exactMatch outcome
{ type: OBJ_TYPE, id: '4' }, // aliasMatch outcome
{ type: OBJ_TYPE, id: '5' }, // conflict outcome
];
const params = setup(objects, { namespace: 'space-x' });
mockBulkResults(
// does not attempt to fetch alias for obj 1, because that is an unsupported type
{ found: false }, // fetch alias for obj 2
{ found: false }, // fetch alias for obj 3
{ found: true, targetId: '4-newId' }, // fetch alias for obj 4
{ found: true, targetId: '5-newId' } // fetch alias for obj 5
);
mockMgetResults(
{ found: false }, // fetch obj 2
{ found: true }, // fetch obj 3
{ found: false }, // fetch obj 4
{ found: true }, // fetch obj 4-newId
{ found: true }, // fetch obj 5
{ found: true } // fetch obj 5-newId
);
const result = await internalBulkResolve(params);
expectBulkArgs('space-x', ['2', '3', '4', '5']);
expectMgetArgs('space-x', ['2', '3', '4', '4-newId', '5', '5-newId']);
expect(result.resolved_objects).toEqual([
expectUnsupportedTypeError('1'),
expectNotFoundError('2'),
expectExactMatchResult('3'),
expectAliasMatchResult('4-newId'),
expectConflictResult('5', '5-newId'),
]);
});
});

View file

@ -0,0 +1,322 @@
/*
* 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 { MgetHit } from '@elastic/elasticsearch/api/types';
import {
CORE_USAGE_STATS_ID,
CORE_USAGE_STATS_TYPE,
REPOSITORY_RESOLVE_OUTCOME_STATS,
} from '../../../core_usage_data';
import { isNotFoundFromUnsupportedServer } from '../../../elasticsearch';
import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import type { SavedObjectsRawDocSource, SavedObjectsSerializer } from '../../serialization';
import type { SavedObject, SavedObjectsBaseOptions } from '../../types';
import type {
SavedObjectsBulkResolveObject,
SavedObjectsResolveResponse,
} from '../saved_objects_client';
import { DecoratedError, SavedObjectsErrorHelpers } from './errors';
import {
getCurrentTime,
getSavedObjectFromSource,
normalizeNamespace,
rawDocExistsInNamespace,
Either,
Right,
isLeft,
isRight,
} from './internal_utils';
import {
SavedObjectsIncrementCounterField,
SavedObjectsIncrementCounterOptions,
} from './repository';
import type { RepositoryEsClient } from './repository_es_client';
/**
* Parameters for the internal bulkResolve function.
*
* @internal
*/
export interface InternalBulkResolveParams {
registry: ISavedObjectTypeRegistry;
allowedTypes: string[];
client: RepositoryEsClient;
serializer: SavedObjectsSerializer;
getIndexForType: (type: string) => string;
incrementCounterInternal: <T = unknown>(
type: string,
id: string,
counterFields: Array<string | SavedObjectsIncrementCounterField>,
options?: SavedObjectsIncrementCounterOptions<T>
) => Promise<SavedObject<T>>;
objects: SavedObjectsBulkResolveObject[];
options?: SavedObjectsBaseOptions;
}
/**
* The response when objects are resolved.
*
* @public
*/
export interface InternalSavedObjectsBulkResolveResponse<T = unknown> {
resolved_objects: Array<SavedObjectsResolveResponse<T> | InternalBulkResolveError>;
}
/**
* Error result for the internal bulkResolve function.
*
* @internal
*/
export interface InternalBulkResolveError {
type: string;
id: string;
error: DecoratedError;
}
export async function internalBulkResolve<T>(
params: InternalBulkResolveParams
): Promise<InternalSavedObjectsBulkResolveResponse<T>> {
const {
registry,
allowedTypes,
client,
serializer,
getIndexForType,
incrementCounterInternal,
objects,
options = {},
} = params;
if (objects.length === 0) {
return { resolved_objects: [] };
}
const allObjects = validateObjectTypes(objects, allowedTypes);
const validObjects = allObjects.filter(isRight);
const namespace = normalizeNamespace(options.namespace);
const requiresAliasCheck = namespace !== undefined;
const aliasDocs = requiresAliasCheck
? await fetchAndUpdateAliases(validObjects, client, serializer, getIndexForType, namespace)
: [];
const docsToBulkGet: Array<{ _id: string; _index: string }> = [];
const aliasTargetIds: Array<string | undefined> = [];
validObjects.forEach(({ value: { type, id } }, i) => {
const objectIndex = getIndexForType(type);
docsToBulkGet.push({
// attempt to find an exact match for the given ID
_id: serializer.generateRawId(namespace, type, id),
_index: objectIndex,
});
if (requiresAliasCheck) {
const aliasDoc = aliasDocs[i];
if (aliasDoc?.found) {
const legacyUrlAlias: LegacyUrlAlias = aliasDoc._source[LEGACY_URL_ALIAS_TYPE];
if (!legacyUrlAlias.disabled) {
docsToBulkGet.push({
// also attempt to find a match for the legacy URL alias target ID
_id: serializer.generateRawId(namespace, type, legacyUrlAlias.targetId),
_index: objectIndex,
});
aliasTargetIds.push(legacyUrlAlias.targetId);
return;
}
}
}
aliasTargetIds.push(undefined);
});
const bulkGetResponse = docsToBulkGet.length
? await client.mget<SavedObjectsRawDocSource>(
{ body: { docs: docsToBulkGet } },
{ ignore: [404] }
)
: undefined;
// exit early if a 404 isn't from elasticsearch
if (
bulkGetResponse &&
isNotFoundFromUnsupportedServer({
statusCode: bulkGetResponse.statusCode,
headers: bulkGetResponse.headers,
})
) {
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError();
}
let getResponseIndex = 0;
let aliasTargetIndex = 0;
const resolveCounter = new ResolveCounter();
const resolvedObjects = allObjects.map<SavedObjectsResolveResponse<T> | InternalBulkResolveError>(
(either) => {
if (isLeft(either)) {
return either.value;
}
const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
let aliasMatchDoc: MgetHit<SavedObjectsRawDocSource> | undefined;
const aliasTargetId = aliasTargetIds[aliasTargetIndex++];
if (aliasTargetId !== undefined) {
aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
}
const foundExactMatch =
// @ts-expect-error MultiGetHit._source is optional
exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace);
const foundAliasMatch =
// @ts-expect-error MultiGetHit._source is optional
aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace);
const { type, id } = either.value;
let result: SavedObjectsResolveResponse<T> | null = null;
if (foundExactMatch && foundAliasMatch) {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc),
outcome: 'conflict',
alias_target_id: aliasTargetId!,
};
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT);
} else if (foundExactMatch) {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc),
outcome: 'exactMatch',
};
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
} else if (foundAliasMatch) {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(registry, type, aliasTargetId!, aliasMatchDoc),
outcome: 'aliasMatch',
alias_target_id: aliasTargetId,
};
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH);
}
if (result !== null) {
return result;
}
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
return {
type,
id,
error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id),
};
}
);
await incrementCounterInternal(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
resolveCounter.getCounterFields(),
{ refresh: false }
).catch(() => {}); // if the call fails for some reason, intentionally swallow the error
return { resolved_objects: resolvedObjects };
}
/** Separates valid and invalid object types */
function validateObjectTypes(objects: SavedObjectsBulkResolveObject[], allowedTypes: string[]) {
return objects.map<Either<InternalBulkResolveError, SavedObjectsBulkResolveObject>>((object) => {
const { type, id } = object;
if (!allowedTypes.includes(type)) {
return {
tag: 'Left',
value: {
type,
id,
error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type),
},
};
}
return {
tag: 'Right',
value: object,
};
});
}
async function fetchAndUpdateAliases(
validObjects: Array<Right<SavedObjectsBulkResolveObject>>,
client: RepositoryEsClient,
serializer: SavedObjectsSerializer,
getIndexForType: (type: string) => string,
namespace: string | undefined
) {
if (validObjects.length === 0) {
return [];
}
const time = getCurrentTime();
const bulkUpdateDocs = validObjects
.map(({ value: { type, id } }) => [
{
update: {
_id: serializer.generateRawLegacyUrlAliasId(namespace!, type, id),
_index: getIndexForType(LEGACY_URL_ALIAS_TYPE),
_source: true,
},
},
{
script: {
source: `
if (ctx._source[params.type].disabled != true) {
if (ctx._source[params.type].resolveCounter == null) {
ctx._source[params.type].resolveCounter = 1;
}
else {
ctx._source[params.type].resolveCounter += 1;
}
ctx._source[params.type].lastResolved = params.time;
ctx._source.updated_at = params.time;
}
`,
lang: 'painless',
params: {
type: LEGACY_URL_ALIAS_TYPE,
time,
},
},
},
])
.flat();
const bulkUpdateResponse = await client.bulk({
refresh: false,
require_alias: true,
body: bulkUpdateDocs,
});
return bulkUpdateResponse.body.items.map((item) => {
// Map the bulk update response to the `_source` fields that were returned for each document
return item.update?.get;
});
}
class ResolveCounter {
private record = new Map<string, number>();
public recordOutcome(outcome: string) {
const val = this.record.get(outcome) ?? 0;
this.record.set(outcome, val + 1);
}
public getCounterFields() {
const counterFields: SavedObjectsIncrementCounterField[] = [];
let total = 0;
for (const [fieldName, incrementBy] of this.record.entries()) {
total += incrementBy;
counterFields.push({ fieldName, incrementBy });
}
if (total > 0) {
counterFields.push({ fieldName: REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL, incrementBy: total });
}
return counterFields;
}
}

View file

@ -11,7 +11,9 @@ import type { SavedObjectsRawDoc } from '../../serialization';
import { encodeHitVersion } from '../../version';
import {
getBulkOperationError,
getCurrentTime,
getSavedObjectFromSource,
normalizeNamespace,
rawDocExistsInNamespace,
rawDocExistsInNamespaces,
} from './internal_utils';
@ -326,3 +328,34 @@ describe('#rawDocExistsInNamespaces', () => {
});
});
});
describe('#normalizeNamespace', () => {
it('throws an error for * (All namespaces string)', () => {
expect(() => normalizeNamespace(ALL_NAMESPACES_STRING)).toThrowErrorMatchingInlineSnapshot(
`"\\"options.namespace\\" cannot be \\"*\\": Bad Request"`
);
});
it('returns undefined for undefined or "default" namespace inputs', () => {
[undefined, 'default'].forEach((namespace) => {
expect(normalizeNamespace(namespace)).toBeUndefined();
});
});
it('returns namespace string for other namespace string inputs', () => {
['foo', 'bar'].forEach((namespace) => {
expect(normalizeNamespace(namespace)).toBe(namespace);
});
});
});
describe('#getCurrentTime', () => {
let dateNowSpy: jest.SpyInstance<number, []>;
beforeAll(() => (dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => 1631307600000)));
afterAll(() => dateNowSpy.mockRestore());
it('returns the current time', () => {
expect(getCurrentTime()).toEqual('2021-09-10T21:00:00.000Z');
});
});

View file

@ -14,6 +14,38 @@ import { decodeRequestVersion, encodeHitVersion } from '../../version';
import { SavedObjectsErrorHelpers } from './errors';
import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from './utils';
/**
* Discriminated union (TypeScript approximation of an algebraic data type); this design pattern used for internal repository operations.
* @internal
*/
export type Either<L = unknown, R = L> = Left<L> | Right<R>;
/**
* Left part of discriminated union ({@link Either}).
* @internal
*/
export interface Left<L> {
tag: 'Left';
value: L;
}
/**
* Right part of discriminated union ({@link Either}).
* @internal
*/
export interface Right<R> {
tag: 'Right';
value: R;
}
/**
* Type guard for left part of discriminated union ({@link Left}, {@link Either}).
* @internal
*/
export const isLeft = <L, R>(either: Either<L, R>): either is Left<L> => either.tag === 'Left';
/**
* Type guard for right part of discriminated union ({@link Right}, {@link Either}).
* @internal
*/
export const isRight = <L, R>(either: Either<L, R>): either is Right<R> => either.tag === 'Right';
/**
* Checks the raw response of a bulk operation and returns an error if necessary.
*
@ -121,6 +153,8 @@ export function getSavedObjectFromSource<T>(
* @param registry
* @param raw
* @param namespace
*
* @internal
*/
export function rawDocExistsInNamespace(
registry: ISavedObjectTypeRegistry,
@ -153,6 +187,8 @@ export function rawDocExistsInNamespace(
* @param registry
* @param raw
* @param namespaces
*
* @internal
*/
export function rawDocExistsInNamespaces(
registry: ISavedObjectTypeRegistry,
@ -179,3 +215,30 @@ export function rawDocExistsInNamespaces(
return existingNamespaces.some((x) => x === ALL_NAMESPACES_STRING || namespacesToCheck.has(x));
}
/**
* Ensure that a namespace is always in its namespace ID representation.
* This allows `'default'` to be used interchangeably with `undefined`.
*
* @param namespace
*
* @internal
*/
export function normalizeNamespace(namespace?: string) {
if (namespace === ALL_NAMESPACES_STRING) {
throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"');
} else if (namespace === undefined) {
return namespace;
} else {
return SavedObjectsUtils.namespaceStringToId(namespace);
}
}
/**
* Returns the current time. For use in Elasticsearch operations.
*
* @internal
*/
export function getCurrentTime() {
return new Date(Date.now()).toISOString();
}

View file

@ -22,6 +22,7 @@ const create = () => {
closePointInTime: jest.fn(),
createPointInTimeFinder: jest.fn(),
openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }),
bulkResolve: jest.fn(),
resolve: jest.fn(),
update: jest.fn(),
deleteByNamespace: jest.fn(),

View file

@ -10,10 +10,11 @@ import {
pointInTimeFinderMock,
mockCollectMultiNamespaceReferences,
mockGetBulkOperationError,
mockInternalBulkResolve,
mockUpdateObjectsSpaces,
mockGetCurrentTime,
} from './repository.test.mock';
import { CORE_USAGE_STATS_TYPE, REPOSITORY_RESOLVE_OUTCOME_STATS } from '../../../core_usage_data';
import { SavedObjectsRepository } from './repository';
import * as getSearchDslNS from './search_dsl/search_dsl';
import { SavedObjectsErrorHelpers } from './errors';
@ -23,7 +24,6 @@ import { loggerMock } from '../../../logging/logger.mock';
import { SavedObjectsSerializer } from '../../serialization';
import { encodeHitVersion } from '../../version';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { DocumentMigrator } from '../../migrations/core/document_migrator';
import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock';
import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks';
@ -33,6 +33,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch';
const { nodeTypes } = esKuery;
jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() }));
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
@ -298,7 +299,7 @@ describe('SavedObjectsRepository', () => {
logger,
});
savedObjectsRepository._getCurrentTime = jest.fn(() => mockTimestamp);
mockGetCurrentTime.mockReturnValue(mockTimestamp);
getSearchDslNS.getSearchDsl.mockClear();
});
@ -1282,6 +1283,59 @@ describe('SavedObjectsRepository', () => {
});
});
describe('#bulkResolve', () => {
afterEach(() => {
mockInternalBulkResolve.mockReset();
});
it('passes arguments to the internalBulkResolve module and returns the expected results', async () => {
mockInternalBulkResolve.mockResolvedValue({
resolved_objects: [
{ saved_object: 'mock-object', outcome: 'exactMatch' },
{
type: 'obj-type',
id: 'obj-id-2',
error: SavedObjectsErrorHelpers.createGenericNotFoundError('obj-type', 'obj-id-2'),
},
],
});
const objects = [
{ type: 'obj-type', id: 'obj-id-1' },
{ type: 'obj-type', id: 'obj-id-2' },
];
await expect(savedObjectsRepository.bulkResolve(objects)).resolves.toEqual({
resolved_objects: [
{
saved_object: 'mock-object',
outcome: 'exactMatch',
},
{
saved_object: {
type: 'obj-type',
id: 'obj-id-2',
error: {
error: 'Not Found',
message: 'Saved object [obj-type/obj-id-2] not found',
statusCode: 404,
},
},
outcome: 'exactMatch',
},
],
});
expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1);
expect(mockInternalBulkResolve).toHaveBeenCalledWith(expect.objectContaining({ objects }));
});
it('throws when internalBulkResolve throws', async () => {
const error = new Error('Oh no!');
mockInternalBulkResolve.mockRejectedValue(error);
await expect(savedObjectsRepository.resolve()).rejects.toEqual(error);
});
});
describe('#bulkUpdate', () => {
const obj1 = {
type: 'config',
@ -3582,266 +3636,36 @@ describe('SavedObjectsRepository', () => {
});
describe('#resolve', () => {
const type = 'index-pattern';
const id = 'logstash-*';
const aliasTargetId = 'some-other-id'; // only used for 'aliasMatch' and 'conflict' outcomes
const namespace = 'foo-namespace';
const getMockAliasDocument = (resolveCounter) => ({
body: {
get: {
_source: {
[LEGACY_URL_ALIAS_TYPE]: {
targetId: aliasTargetId,
...(resolveCounter && { resolveCounter }),
// other fields are not used by the repository
},
},
},
},
afterEach(() => {
mockInternalBulkResolve.mockReset();
});
/** Each time resolve is called, usage stats are incremented depending upon the outcome. */
const expectIncrementCounter = (n, outcomeStatString) => {
expect(client.update).toHaveBeenNthCalledWith(
n,
expect.objectContaining({
body: expect.objectContaining({
upsert: expect.objectContaining({
[CORE_USAGE_STATS_TYPE]: {
[outcomeStatString]: 1,
[REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL]: 1,
},
}),
}),
}),
expect.anything()
it('passes arguments to the internalBulkResolve module and returns the result', async () => {
const expectedResult = { saved_object: 'mock-object', outcome: 'exactMatch' };
mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] });
await expect(savedObjectsRepository.resolve('obj-type', 'obj-id')).resolves.toEqual(
expectedResult
);
};
expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1);
expect(mockInternalBulkResolve).toHaveBeenCalledWith(
expect.objectContaining({ objects: [{ type: 'obj-type', id: 'obj-id' }] })
);
});
describe('outcomes', () => {
describe('error', () => {
const expectNotFoundError = async (type, id, options) => {
await expect(savedObjectsRepository.resolve(type, id, options)).rejects.toThrowError(
createGenericNotFoundError(type, id)
);
};
it('throws when internalBulkResolve result is an error', async () => {
const error = new Error('Oh no!');
const expectedResult = { type: 'obj-type', id: 'obj-id', error };
mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] });
it('because type is invalid', async () => {
await expectNotFoundError('unknownType', id);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
});
await expect(savedObjectsRepository.resolve()).rejects.toEqual(error);
});
it('because type is hidden', async () => {
await expectNotFoundError(HIDDEN_TYPE, id);
expect(client.update).not.toHaveBeenCalled();
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).not.toHaveBeenCalled();
});
it('throws when internalBulkResolve throws', async () => {
const error = new Error('Oh no!');
mockInternalBulkResolve.mockRejectedValue(error);
it('because alias is not used and actual object is not found', async () => {
const options = { namespace: undefined };
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(
{ found: false },
undefined
) // for actual target
);
await expectNotFoundError(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
});
it('because actual object and alias object are both not found', async () => {
const options = { namespace };
const objectResults = [
{ type, id, found: false },
{ type, id: aliasTargetId, found: false },
];
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
await expectNotFoundError(type, id, options);
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
});
});
describe('exactMatch', () => {
it('because namespace is undefined', async () => {
const options = { namespace: undefined };
const response = getMockGetResponse({ type, id });
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(1); // incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
});
describe('because alias is not used', () => {
const expectExactMatchResult = async (aliasResult) => {
const options = { namespace };
if (!aliasResult.body) {
client.update.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise({}, { ...aliasResult })
);
} else {
client.update.mockResolvedValueOnce(aliasResult); // for alias object
}
const response = getMockGetResponse({ type, id }, options.namespace);
client.get.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise({ ...response }) // for actual target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target
expect(client.mget).not.toHaveBeenCalled();
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
};
it('since alias call resulted in 404', async () => {
await expectExactMatchResult({ statusCode: 404 });
});
it('since alias is not found', async () => {
await expectExactMatchResult({ body: { get: { found: false } } });
});
it('since alias is disabled', async () => {
await expectExactMatchResult({
body: { get: { _source: { [LEGACY_URL_ALIAS_TYPE]: { disabled: true } } } },
});
});
});
describe('because alias is used', () => {
const expectExactMatchResult = async (objectResults) => {
const options = { namespace };
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'exactMatch',
});
};
it('but alias target is not found', async () => {
const objects = [
{ type, id },
{ type, id: aliasTargetId, found: false },
];
await expectExactMatchResult(objects);
});
it('but alias target does not exist in this namespace', async () => {
const objects = [
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse
{
type: MULTI_NAMESPACE_ISOLATED_TYPE,
id: aliasTargetId,
namespace: `not-${namespace}`,
}, // overrides namespace field that would otherwise be added by getMockMgetResponse
];
await expectExactMatchResult(objects);
});
});
});
describe('aliasMatch', () => {
const expectAliasMatchResult = async (objectResults) => {
const options = { namespace };
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id: aliasTargetId }),
outcome: 'aliasMatch',
alias_target_id: aliasTargetId,
});
};
it('because actual target is not found', async () => {
const objects = [
{ type, id, found: false },
{ type, id: aliasTargetId },
];
await expectAliasMatchResult(objects);
});
it('because actual target does not exist in this namespace', async () => {
const objects = [
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse
{ type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse
];
await expectAliasMatchResult(objects);
});
});
describe('conflict', () => {
it('because actual target and alias target are both found', async () => {
const options = { namespace };
const objectResults = [
{ type, id }, // correct namespace field is added by getMockMgetResponse
{ type, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse
];
client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object
const response = getMockMgetResponse(objectResults, options.namespace);
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target
);
const result = await savedObjectsRepository.resolve(type, id, options);
expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats
expect(client.get).not.toHaveBeenCalled();
expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target
expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT);
expect(result).toEqual({
saved_object: expect.objectContaining({ type, id }),
outcome: 'conflict',
alias_target_id: aliasTargetId,
});
});
});
await expect(savedObjectsRepository.resolve()).rejects.toEqual(error);
});
});

View file

@ -7,6 +7,7 @@
*/
import type { collectMultiNamespaceReferences } from './collect_multi_namespace_references';
import type { internalBulkResolve } from './internal_bulk_resolve';
import type * as InternalUtils from './internal_utils';
import type { updateObjectsSpaces } from './update_objects_spaces';
@ -18,15 +19,25 @@ jest.mock('./collect_multi_namespace_references', () => ({
collectMultiNamespaceReferences: mockCollectMultiNamespaceReferences,
}));
export const mockInternalBulkResolve = jest.fn() as jest.MockedFunction<typeof internalBulkResolve>;
jest.mock('./internal_bulk_resolve', () => ({
internalBulkResolve: mockInternalBulkResolve,
}));
export const mockGetBulkOperationError = jest.fn() as jest.MockedFunction<
typeof InternalUtils['getBulkOperationError']
>;
export const mockGetCurrentTime = jest.fn() as jest.MockedFunction<
typeof InternalUtils['getCurrentTime']
>;
jest.mock('./internal_utils', () => {
const actual = jest.requireActual('./internal_utils');
return {
...actual,
getBulkOperationError: mockGetBulkOperationError,
getCurrentTime: mockGetCurrentTime,
};
});

View file

@ -8,11 +8,6 @@
import { omit, isObject } from 'lodash';
import type { estypes } from '@elastic/elasticsearch';
import {
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
REPOSITORY_RESOLVE_OUTCOME_STATS,
} from '../../../core_usage_data';
import type { ElasticsearchClient } from '../../../elasticsearch/';
import { isSupportedEsServer, isNotFoundFromUnsupportedServer } from '../../../elasticsearch';
import type { Logger } from '../../../logging';
@ -57,6 +52,8 @@ import {
SavedObjectsRemoveReferencesToOptions,
SavedObjectsRemoveReferencesToResponse,
SavedObjectsResolveResponse,
SavedObjectsBulkResolveObject,
SavedObjectsBulkResolveResponse,
} from '../saved_objects_client';
import {
SavedObject,
@ -65,16 +62,21 @@ import {
SavedObjectsMigrationVersion,
MutatingOperationRefreshSetting,
} from '../../types';
import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types';
import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { internalBulkResolve, InternalBulkResolveError } from './internal_bulk_resolve';
import { validateConvertFilterToKueryNode } from './filter_utils';
import { validateAndConvertAggregations } from './aggregations';
import {
getBulkOperationError,
getCurrentTime,
getExpectedVersionProperties,
getSavedObjectFromSource,
normalizeNamespace,
rawDocExistsInNamespace,
rawDocExistsInNamespaces,
Either,
isLeft,
isRight,
} from './internal_utils';
import {
ALL_NAMESPACES_STRING,
@ -97,20 +99,6 @@ import { getIndexForType } from './get_index_for_type';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
interface Left {
tag: 'Left';
error: Record<string, any>;
}
interface Right {
tag: 'Right';
value: Record<string, any>;
}
type Either = Left | Right;
const isLeft = (either: Either): either is Left => either.tag === 'Left';
const isRight = (either: Either): either is Right => either.tag === 'Right';
export interface SavedObjectsRepositoryOptions {
index: string;
mappings: IndexMapping;
@ -297,7 +285,7 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
}
const time = this._getCurrentTime();
const time = getCurrentTime();
let savedObjectNamespace: string | undefined;
let savedObjectNamespaces: string[] | undefined;
@ -373,45 +361,47 @@ export class SavedObjectsRepository {
): Promise<SavedObjectsBulkResponse<T>> {
const { overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options;
const namespace = normalizeNamespace(options.namespace);
const time = this._getCurrentTime();
const time = getCurrentTime();
let bulkGetRequestIndexCounter = 0;
const expectedResults: Either[] = objects.map((object) => {
const { type, id, initialNamespaces } = object;
let error: DecoratedError | undefined;
if (!this._allowedTypes.includes(type)) {
error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
} else {
try {
this.validateInitialNamespaces(type, initialNamespaces);
} catch (e) {
error = e;
const expectedResults: Array<Either<Record<string, any>, Record<string, any>>> = objects.map(
(object) => {
const { type, id, initialNamespaces } = object;
let error: DecoratedError | undefined;
if (!this._allowedTypes.includes(type)) {
error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
} else {
try {
this.validateInitialNamespaces(type, initialNamespaces);
} catch (e) {
error = e;
}
}
if (error) {
return {
tag: 'Left',
value: { id, type, error: errorContent(error) },
};
}
const method = id && overwrite ? 'index' : 'create';
const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type);
if (id == null) {
object.id = SavedObjectsUtils.generateId();
}
}
if (error) {
return {
tag: 'Left' as 'Left',
error: { id, type, error: errorContent(error) },
tag: 'Right',
value: {
method,
object,
...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }),
},
};
}
const method = id && overwrite ? 'index' : 'create';
const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type);
if (id == null) {
object.id = SavedObjectsUtils.generateId();
}
return {
tag: 'Right' as 'Right',
value: {
method,
object,
...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }),
},
};
});
);
const bulkGetDocs = expectedResults
.filter(isRight)
@ -443,7 +433,9 @@ export class SavedObjectsRepository {
}
let bulkRequestIndexCounter = 0;
const bulkCreateParams: object[] = [];
const expectedBulkResults: Either[] = expectedResults.map((expectedBulkGetResult) => {
const expectedBulkResults: Array<
Either<Record<string, any>, Record<string, any>>
> = expectedResults.map((expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return expectedBulkGetResult;
}
@ -470,8 +462,8 @@ export class SavedObjectsRepository {
) {
const { id, type } = object;
return {
tag: 'Left' as 'Left',
error: {
tag: 'Left',
value: {
id,
type,
error: {
@ -527,7 +519,7 @@ export class SavedObjectsRepository {
expectedResult.rawMigratedDoc._source
);
return { tag: 'Right' as 'Right', value: expectedResult };
return { tag: 'Right', value: expectedResult };
});
const bulkResponse = bulkCreateParams.length
@ -541,7 +533,7 @@ export class SavedObjectsRepository {
return {
saved_objects: expectedBulkResults.map((expectedResult) => {
if (isLeft(expectedResult)) {
return expectedResult.error as any;
return expectedResult.value as any;
}
const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value;
@ -578,13 +570,15 @@ export class SavedObjectsRepository {
const namespace = normalizeNamespace(options.namespace);
let bulkGetRequestIndexCounter = 0;
const expectedBulkGetResults: Either[] = objects.map((object) => {
const expectedBulkGetResults: Array<
Either<Record<string, any>, Record<string, any>>
> = objects.map((object) => {
const { type, id } = object;
if (!this._allowedTypes.includes(type)) {
return {
tag: 'Left' as 'Left',
error: {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)),
@ -593,7 +587,7 @@ export class SavedObjectsRepository {
}
return {
tag: 'Right' as 'Right',
tag: 'Right',
value: {
type,
id,
@ -630,7 +624,7 @@ export class SavedObjectsRepository {
const errors: SavedObjectsCheckConflictsResponse['errors'] = [];
expectedBulkGetResults.forEach((expectedResult) => {
if (isLeft(expectedResult)) {
errors.push(expectedResult.error as any);
errors.push(expectedResult.value as any);
return;
}
@ -979,7 +973,9 @@ export class SavedObjectsRepository {
}
let bulkGetRequestIndexCounter = 0;
const expectedBulkGetResults: Either[] = objects.map((object) => {
const expectedBulkGetResults: Array<
Either<Record<string, any>, Record<string, any>>
> = objects.map((object) => {
const { type, id, fields, namespaces } = object;
let error: DecoratedError | undefined;
@ -995,13 +991,13 @@ export class SavedObjectsRepository {
if (error) {
return {
tag: 'Left' as 'Left',
error: { id, type, error: errorContent(error) },
tag: 'Left',
value: { id, type, error: errorContent(error) },
};
}
return {
tag: 'Right' as 'Right',
tag: 'Right',
value: {
type,
id,
@ -1044,7 +1040,7 @@ export class SavedObjectsRepository {
return {
saved_objects: expectedBulkGetResults.map((expectedResult) => {
if (isLeft(expectedResult)) {
return expectedResult.error as any;
return expectedResult.value as any;
}
const {
@ -1070,6 +1066,49 @@ export class SavedObjectsRepository {
};
}
/**
* Resolves an array of objects by id, using any legacy URL aliases if they exist
*
* @param {array} objects - an array of objects containing id, type
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise} - { resolved_objects: [{ saved_object, outcome }] }
* @example
*
* bulkResolve([
* { id: 'one', type: 'config' },
* { id: 'foo', type: 'index-pattern' }
* ])
*/
async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options: SavedObjectsBaseOptions = {}
): Promise<SavedObjectsBulkResolveResponse<T>> {
const { resolved_objects: bulkResults } = await internalBulkResolve<T>({
registry: this._registry,
allowedTypes: this._allowedTypes,
client: this.client,
serializer: this._serializer,
getIndexForType: this.getIndexForType.bind(this),
incrementCounterInternal: this.incrementCounterInternal.bind(this),
objects,
options,
});
const resolvedObjects = bulkResults.map<SavedObjectsResolveResponse<T>>((result) => {
// extract payloads from saved object errors
if ((result as InternalBulkResolveError).error) {
const errorResult = result as InternalBulkResolveError;
const { type, id, error } = errorResult;
return {
saved_object: ({ type, id, error: errorContent(error) } as unknown) as SavedObject<T>,
outcome: 'exactMatch',
};
}
return result as SavedObjectsResolveResponse<T>;
});
return { resolved_objects: resolvedObjects };
}
/**
* Gets a single object
*
@ -1125,148 +1164,21 @@ export class SavedObjectsRepository {
id: string,
options: SavedObjectsBaseOptions = {}
): Promise<SavedObjectsResolveResponse<T>> {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
const { resolved_objects: bulkResults } = await internalBulkResolve<T>({
registry: this._registry,
allowedTypes: this._allowedTypes,
client: this.client,
serializer: this._serializer,
getIndexForType: this.getIndexForType.bind(this),
incrementCounterInternal: this.incrementCounterInternal.bind(this),
objects: [{ type, id }],
options,
});
const [result] = bulkResults;
if ((result as InternalBulkResolveError).error) {
throw (result as InternalBulkResolveError).error;
}
const namespace = normalizeNamespace(options.namespace);
if (namespace === undefined) {
// legacy URL aliases cannot exist for the default namespace; just attempt to get the object
return this.resolveExactMatch(type, id, options);
}
const rawAliasId = this._serializer.generateRawLegacyUrlAliasId(namespace, type, id);
const time = this._getCurrentTime();
// retrieve the alias, and if it is not disabled, update it
const aliasResponse = await this.client.update<{ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias }>(
{
id: rawAliasId,
index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE),
refresh: false,
_source: 'true',
body: {
script: {
source: `
if (ctx._source[params.type].disabled != true) {
if (ctx._source[params.type].resolveCounter == null) {
ctx._source[params.type].resolveCounter = 1;
}
else {
ctx._source[params.type].resolveCounter += 1;
}
ctx._source[params.type].lastResolved = params.time;
ctx._source.updated_at = params.time;
}
`,
lang: 'painless',
params: {
type: LEGACY_URL_ALIAS_TYPE,
time,
},
},
},
},
{ ignore: [404] }
);
if (
isNotFoundFromUnsupportedServer({
statusCode: aliasResponse.statusCode,
headers: aliasResponse.headers,
})
) {
// throw if we cannot verify the response is from Elasticsearch
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(
LEGACY_URL_ALIAS_TYPE,
rawAliasId
);
}
if (
aliasResponse.statusCode === 404 ||
aliasResponse.body.get?.found === false ||
aliasResponse.body.get?._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true
) {
// no legacy URL alias exists, or one exists but it's disabled; just attempt to get the object
return this.resolveExactMatch(type, id, options);
}
const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get!._source[LEGACY_URL_ALIAS_TYPE];
const objectIndex = this.getIndexForType(type);
const bulkGetResponse = await this.client.mget<SavedObjectsRawDocSource>(
{
body: {
docs: [
{
// attempt to find an exact match for the given ID
_id: this._serializer.generateRawId(namespace, type, id),
_index: objectIndex,
},
{
// also attempt to find a match for the legacy URL alias target ID
_id: this._serializer.generateRawId(namespace, type, legacyUrlAlias.targetId),
_index: objectIndex,
},
],
},
},
{ ignore: [404] }
);
// exit early if a 404 isn't from elasticsearch
if (
isNotFoundFromUnsupportedServer({
statusCode: bulkGetResponse.statusCode,
headers: bulkGetResponse.headers,
})
) {
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id);
}
const exactMatchDoc = bulkGetResponse?.body.docs[0];
const aliasMatchDoc = bulkGetResponse?.body.docs[1];
const foundExactMatch =
// @ts-expect-error MultiGetHit._source is optional
exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace);
const foundAliasMatch =
// @ts-expect-error MultiGetHit._source is optional
aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace);
let result: SavedObjectsResolveResponse<T> | null = null;
let outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND;
if (foundExactMatch && foundAliasMatch) {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc),
outcome: 'conflict',
alias_target_id: legacyUrlAlias.targetId,
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT;
} else if (foundExactMatch) {
result = {
// @ts-expect-error MultiGetHit._source is optional
saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc),
outcome: 'exactMatch',
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH;
} else if (foundAliasMatch) {
result = {
saved_object: getSavedObjectFromSource(
this._registry,
type,
legacyUrlAlias.targetId,
// @ts-expect-error MultiGetHit._source is optional
aliasMatchDoc
),
outcome: 'aliasMatch',
alias_target_id: legacyUrlAlias.targetId,
};
outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH;
}
await this.incrementResolveOutcomeStats(outcomeStatString);
if (result !== null) {
return result;
}
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
return result as SavedObjectsResolveResponse<T>;
}
/**
@ -1298,7 +1210,7 @@ export class SavedObjectsRepository {
preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace);
}
const time = this._getCurrentTime();
const time = getCurrentTime();
let rawUpsert: SavedObjectsRawDoc | undefined;
if (upsert) {
@ -1436,17 +1348,19 @@ export class SavedObjectsRepository {
objects: Array<SavedObjectsBulkUpdateObject<T>>,
options: SavedObjectsBulkUpdateOptions = {}
): Promise<SavedObjectsBulkUpdateResponse<T>> {
const time = this._getCurrentTime();
const time = getCurrentTime();
const namespace = normalizeNamespace(options.namespace);
let bulkGetRequestIndexCounter = 0;
const expectedBulkGetResults: Either[] = objects.map((object) => {
const expectedBulkGetResults: Array<
Either<Record<string, any>, Record<string, any>>
> = objects.map((object) => {
const { type, id } = object;
if (!this._allowedTypes.includes(type)) {
return {
tag: 'Left' as 'Left',
error: {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
@ -1458,8 +1372,8 @@ export class SavedObjectsRepository {
if (objectNamespace === ALL_NAMESPACES_STRING) {
return {
tag: 'Left' as 'Left',
error: {
tag: 'Left',
value: {
id,
type,
error: errorContent(
@ -1480,7 +1394,7 @@ export class SavedObjectsRepository {
const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type);
return {
tag: 'Right' as 'Right',
tag: 'Right',
value: {
type,
id,
@ -1531,78 +1445,78 @@ export class SavedObjectsRepository {
}
let bulkUpdateRequestIndexCounter = 0;
const bulkUpdateParams: object[] = [];
const expectedBulkUpdateResults: Either[] = expectedBulkGetResults.map(
(expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return expectedBulkGetResult;
}
const {
esRequestIndex,
id,
type,
version,
documentToSave,
objectNamespace,
} = expectedBulkGetResult.value;
let namespaces;
let versionProperties;
if (esRequestIndex !== undefined) {
const indexFound = bulkGetResponse?.statusCode !== 404;
const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined;
const docFound = indexFound && actualResult?.found === true;
if (
!docFound ||
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
!this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace))
) {
return {
tag: 'Left' as 'Left',
error: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
},
};
}
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
namespaces = actualResult!._source.namespaces ?? [
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace),
];
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
versionProperties = getExpectedVersionProperties(version, actualResult!);
} else {
if (this._registry.isSingleNamespace(type)) {
// if `objectNamespace` is undefined, fall back to `options.namespace`
namespaces = [getNamespaceString(objectNamespace)];
}
versionProperties = getExpectedVersionProperties(version);
}
const expectedResult = {
type,
id,
namespaces,
esRequestIndex: bulkUpdateRequestIndexCounter++,
documentToSave: expectedBulkGetResult.value.documentToSave,
};
bulkUpdateParams.push(
{
update: {
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
_index: this.getIndexForType(type),
...versionProperties,
},
},
{ doc: documentToSave }
);
return { tag: 'Right' as 'Right', value: expectedResult };
const expectedBulkUpdateResults: Array<
Either<Record<string, any>, Record<string, any>>
> = expectedBulkGetResults.map((expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return expectedBulkGetResult;
}
);
const {
esRequestIndex,
id,
type,
version,
documentToSave,
objectNamespace,
} = expectedBulkGetResult.value;
let namespaces;
let versionProperties;
if (esRequestIndex !== undefined) {
const indexFound = bulkGetResponse?.statusCode !== 404;
const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined;
const docFound = indexFound && actualResult?.found === true;
if (
!docFound ||
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
!this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace))
) {
return {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
},
};
}
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
namespaces = actualResult!._source.namespaces ?? [
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace),
];
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
versionProperties = getExpectedVersionProperties(version, actualResult!);
} else {
if (this._registry.isSingleNamespace(type)) {
// if `objectNamespace` is undefined, fall back to `options.namespace`
namespaces = [getNamespaceString(objectNamespace)];
}
versionProperties = getExpectedVersionProperties(version);
}
const expectedResult = {
type,
id,
namespaces,
esRequestIndex: bulkUpdateRequestIndexCounter++,
documentToSave: expectedBulkGetResult.value.documentToSave,
};
bulkUpdateParams.push(
{
update: {
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
_index: this.getIndexForType(type),
...versionProperties,
},
},
{ doc: documentToSave }
);
return { tag: 'Right', value: expectedResult };
});
const { refresh = DEFAULT_REFRESH_SETTING } = options;
const bulkUpdateResponse = bulkUpdateParams.length
@ -1617,7 +1531,7 @@ export class SavedObjectsRepository {
return {
saved_objects: expectedBulkUpdateResults.map((expectedResult) => {
if (isLeft(expectedResult)) {
return expectedResult.error as any;
return expectedResult.value as any;
}
const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value;
@ -1837,7 +1751,7 @@ export class SavedObjectsRepository {
});
const namespace = normalizeNamespace(options.namespace);
const time = this._getCurrentTime();
const time = getCurrentTime();
let savedObjectNamespace;
let savedObjectNamespaces: string[] | undefined;
@ -2120,10 +2034,6 @@ export class SavedObjectsRepository {
return unique(types.map((t) => this.getIndexForType(t)));
}
private _getCurrentTime() {
return new Date().toISOString();
}
private _rawToSavedObject<T = unknown>(raw: SavedObjectsRawDoc): SavedObject<T> {
const savedObject = this._serializer.rawToSavedObject(raw);
const { namespace, type } = savedObject;
@ -2230,33 +2140,6 @@ export class SavedObjectsRepository {
return body;
}
private async resolveExactMatch<T>(
type: string,
id: string,
options: SavedObjectsBaseOptions
): Promise<SavedObjectsResolveResponse<T>> {
try {
const object = await this.get<T>(type, id, options);
await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
return { saved_object: object, outcome: 'exactMatch' };
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
// 404 responses already confirmed to be valid Elasticsearch responses
await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
}
throw err;
}
}
private async incrementResolveOutcomeStats(outcomeStatString: string) {
await this.incrementCounterInternal(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[outcomeStatString, REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL],
{ refresh: false }
).catch(() => {}); // if the call fails for some reason, intentionally swallow the error
}
/** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */
private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) {
if (!initialNamespaces) {
@ -2322,20 +2205,6 @@ function getSavedObjectNamespaces(
return [SavedObjectsUtils.namespaceIdToString(namespace)];
}
/**
* Ensure that a namespace is always in its namespace ID representation.
* This allows `'default'` to be used interchangeably with `undefined`.
*/
const normalizeNamespace = (namespace?: string) => {
if (namespace === ALL_NAMESPACES_STRING) {
throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"');
} else if (namespace === undefined) {
return namespace;
} else {
return SavedObjectsUtils.namespaceStringToId(namespace);
}
};
/**
* Extracts the contents of a decorated error to return the attributes for bulk operations.
*/

View file

@ -22,6 +22,9 @@ import {
getBulkOperationError,
getExpectedVersionProperties,
rawDocExistsInNamespace,
Either,
isLeft,
isRight,
} from './internal_utils';
import { DEFAULT_REFRESH_SETTING } from './repository';
import type { RepositoryEsClient } from './repository_es_client';
@ -86,14 +89,6 @@ export interface SavedObjectsUpdateObjectsSpacesResponseObject {
error?: SavedObjectError;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Left = { tag: 'Left'; error: SavedObjectsUpdateObjectsSpacesResponseObject };
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Right = { tag: 'Right'; value: Record<string, any> };
type Either = Left | Right;
const isLeft = (either: Either): either is Left => either.tag === 'Left';
const isRight = (either: Either): either is Right => either.tag === 'Right';
/**
* Parameters for the updateObjectsSpaces function.
*
@ -140,14 +135,16 @@ export async function updateObjectsSpaces({
const { namespace } = options;
let bulkGetRequestIndexCounter = 0;
const expectedBulkGetResults: Either[] = objects.map((object) => {
const expectedBulkGetResults: Array<
Either<SavedObjectsUpdateObjectsSpacesResponseObject, Record<string, any>>
> = objects.map((object) => {
const { type, id, spaces, version } = object;
if (!allowedTypes.includes(type)) {
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
return {
tag: 'Left' as 'Left',
error: { id, type, spaces: [], error },
tag: 'Left',
value: { id, type, spaces: [], error },
};
}
if (!registry.isShareable(type)) {
@ -157,13 +154,13 @@ export async function updateObjectsSpaces({
)
);
return {
tag: 'Left' as 'Left',
error: { id, type, spaces: [], error },
tag: 'Left',
value: { id, type, spaces: [], error },
};
}
return {
tag: 'Right' as 'Right',
tag: 'Right',
value: {
type,
id,
@ -204,71 +201,71 @@ export async function updateObjectsSpaces({
const time = new Date().toISOString();
let bulkOperationRequestIndexCounter = 0;
const bulkOperationParams: estypes.BulkOperationContainer[] = [];
const expectedBulkOperationResults: Either[] = expectedBulkGetResults.map(
(expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return expectedBulkGetResult;
}
const expectedBulkOperationResults: Array<
Either<SavedObjectsUpdateObjectsSpacesResponseObject, Record<string, any>>
> = expectedBulkGetResults.map((expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return expectedBulkGetResult;
}
const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value;
const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value;
let currentSpaces: string[] = spaces;
let versionProperties;
if (esRequestIndex !== undefined) {
const doc = bulkGetResponse?.body.docs[esRequestIndex];
// @ts-expect-error MultiGetHit._source is optional
if (!doc?.found || !rawDocExistsInNamespace(registry, doc, namespace)) {
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
return {
tag: 'Left' as 'Left',
error: { id, type, spaces: [], error },
};
}
currentSpaces = doc._source?.namespaces ?? [];
// @ts-expect-error MultiGetHit._source is optional
versionProperties = getExpectedVersionProperties(version, doc);
} else if (spaces?.length === 0) {
// A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found.
let currentSpaces: string[] = spaces;
let versionProperties;
if (esRequestIndex !== undefined) {
const doc = bulkGetResponse?.body.docs[esRequestIndex];
// @ts-expect-error MultiGetHit._source is optional
if (!doc?.found || !rawDocExistsInNamespace(registry, doc, namespace)) {
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
return {
tag: 'Left' as 'Left',
error: { id, type, spaces: [], error },
tag: 'Left',
value: { id, type, spaces: [], error },
};
} else {
versionProperties = getExpectedVersionProperties(version);
}
const { newSpaces, isUpdateRequired } = getNewSpacesArray(
currentSpaces,
spacesToAdd,
spacesToRemove
);
const expectedResult = {
type,
id,
newSpaces,
...(isUpdateRequired && { esRequestIndex: bulkOperationRequestIndexCounter++ }),
currentSpaces = doc._source?.namespaces ?? [];
// @ts-expect-error MultiGetHit._source is optional
versionProperties = getExpectedVersionProperties(version, doc);
} else if (spaces?.length === 0) {
// A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found.
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
return {
tag: 'Left',
value: { id, type, spaces: [], error },
};
if (isUpdateRequired) {
const documentMetadata = {
_id: serializer.generateRawId(undefined, type, id),
_index: getIndexForType(type),
...versionProperties,
};
if (newSpaces.length) {
const documentToSave = { updated_at: time, namespaces: newSpaces };
// @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional
bulkOperationParams.push({ update: documentMetadata }, { doc: documentToSave });
} else {
// @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional
bulkOperationParams.push({ delete: documentMetadata });
}
}
return { tag: 'Right' as 'Right', value: expectedResult };
} else {
versionProperties = getExpectedVersionProperties(version);
}
);
const { newSpaces, isUpdateRequired } = getNewSpacesArray(
currentSpaces,
spacesToAdd,
spacesToRemove
);
const expectedResult = {
type,
id,
newSpaces,
...(isUpdateRequired && { esRequestIndex: bulkOperationRequestIndexCounter++ }),
};
if (isUpdateRequired) {
const documentMetadata = {
_id: serializer.generateRawId(undefined, type, id),
_index: getIndexForType(type),
...versionProperties,
};
if (newSpaces.length) {
const documentToSave = { updated_at: time, namespaces: newSpaces };
// @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional
bulkOperationParams.push({ update: documentMetadata }, { doc: documentToSave });
} else {
// @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional
bulkOperationParams.push({ delete: documentMetadata });
}
}
return { tag: 'Right', value: expectedResult };
});
const { refresh = DEFAULT_REFRESH_SETTING } = options;
const bulkOperationResponse = bulkOperationParams.length
@ -279,7 +276,7 @@ export async function updateObjectsSpaces({
objects: expectedBulkOperationResults.map<SavedObjectsUpdateObjectsSpacesResponseObject>(
(expectedResult) => {
if (isLeft(expectedResult)) {
return expectedResult.error;
return expectedResult.value;
}
const { type, id, newSpaces, esRequestIndex } = expectedResult.value;

View file

@ -24,6 +24,7 @@ const create = () => {
closePointInTime: jest.fn(),
createPointInTimeFinder: jest.fn(),
openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }),
bulkResolve: jest.fn(),
resolve: jest.fn(),
update: jest.fn(),
removeReferencesTo: jest.fn(),

View file

@ -184,6 +184,21 @@ test(`#closePointInTime`, async () => {
expect(result).toBe(returnValue);
});
test(`#bulkResolve`, async () => {
const returnValue = Symbol();
const mockRepository = {
bulkResolve: jest.fn().mockResolvedValue(returnValue),
};
const client = new SavedObjectsClient(mockRepository);
const objects = Symbol();
const options = Symbol();
const result = await client.bulkResolve(objects, options);
expect(mockRepository.bulkResolve).toHaveBeenCalledWith(objects, options);
expect(result).toBe(returnValue);
});
test(`#resolve`, async () => {
const returnValue = Symbol();
const mockRepository = {

View file

@ -317,6 +317,23 @@ export interface SavedObjectsUpdateResponse<T = unknown>
references: SavedObjectReference[] | undefined;
}
/**
*
* @public
*/
export interface SavedObjectsBulkResolveObject {
id: string;
type: string;
}
/**
*
* @public
*/
export interface SavedObjectsBulkResolveResponse<T = unknown> {
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
}
/**
*
* @public
@ -503,6 +520,28 @@ export class SavedObjectsClient {
return await this._repository.get(type, id, options);
}
/**
* Resolves an array of objects by id, using any legacy URL aliases if they exist
*
* @param objects - an array of objects containing id, type
* @example
*
* bulkResolve([
* { id: 'one', type: 'config' },
* { id: 'foo', type: 'index-pattern' }
* ])
*
* @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the
* outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to
* `bulkResolve`; the regular `resolve` API will throw an error instead.
*/
async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options?: SavedObjectsBaseOptions
): Promise<SavedObjectsBulkResolveResponse<T>> {
return await this._repository.bulkResolve(objects, options);
}
/**
* Resolves a single object, using any legacy URL alias if it exists
*

View file

@ -525,6 +525,20 @@ export interface CoreUsageStats {
// (undocumented)
'apiCalls.savedObjectsBulkGet.total'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.namespace.default.total'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkResolve.total'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.no'?: number;
// (undocumented)
'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.yes'?: number;
@ -1933,6 +1947,20 @@ export interface SavedObjectsBulkGetObject {
type: string;
}
// @public (undocumented)
export interface SavedObjectsBulkResolveObject {
// (undocumented)
id: string;
// (undocumented)
type: string;
}
// @public (undocumented)
export interface SavedObjectsBulkResolveResponse<T = unknown> {
// (undocumented)
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
}
// @public (undocumented)
export interface SavedObjectsBulkResponse<T = unknown> {
// (undocumented)
@ -1988,6 +2016,7 @@ export class SavedObjectsClient {
constructor(repository: ISavedObjectsRepository);
bulkCreate<T = unknown>(objects: Array<SavedObjectsBulkCreateObject<T>>, options?: SavedObjectsCreateOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkGet<T = unknown>(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkResolve<T = unknown>(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResolveResponse<T>>;
bulkUpdate<T = unknown>(objects: Array<SavedObjectsBulkUpdateObject<T>>, options?: SavedObjectsBulkUpdateOptions): Promise<SavedObjectsBulkUpdateResponse<T>>;
checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsCheckConflictsResponse>;
closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise<SavedObjectsClosePointInTimeResponse>;
@ -2586,6 +2615,7 @@ export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBase
export class SavedObjectsRepository {
bulkCreate<T = unknown>(objects: Array<SavedObjectsBulkCreateObject<T>>, options?: SavedObjectsCreateOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkGet<T = unknown>(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkResolve<T = unknown>(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResolveResponse<T>>;
bulkUpdate<T = unknown>(objects: Array<SavedObjectsBulkUpdateObject<T>>, options?: SavedObjectsBulkUpdateOptions): Promise<SavedObjectsBulkUpdateResponse<T>>;
checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsCheckConflictsResponse>;
closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise<SavedObjectsClosePointInTimeResponse>;

View file

@ -495,6 +495,46 @@ export function getCoreUsageCollector(
'How many times this API has been called by a non-Kibana client in a custom space.',
},
},
'apiCalls.savedObjectsBulkResolve.total': {
type: 'long',
_meta: { description: 'How many times this API has been called.' },
},
'apiCalls.savedObjectsBulkResolve.namespace.default.total': {
type: 'long',
_meta: { description: 'How many times this API has been called in the Default space.' },
},
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes': {
type: 'long',
_meta: {
description:
'How many times this API has been called by the Kibana client in the Default space.',
},
},
'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no': {
type: 'long',
_meta: {
description:
'How many times this API has been called by a non-Kibana client in the Default space.',
},
},
'apiCalls.savedObjectsBulkResolve.namespace.custom.total': {
type: 'long',
_meta: { description: 'How many times this API has been called in a custom space.' },
},
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes': {
type: 'long',
_meta: {
description:
'How many times this API has been called by the Kibana client in a custom space.',
},
},
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no': {
type: 'long',
_meta: {
description:
'How many times this API has been called by a non-Kibana client in a custom space.',
},
},
'apiCalls.savedObjectsBulkUpdate.total': {
type: 'long',
_meta: { description: 'How many times this API has been called.' },

View file

@ -6394,6 +6394,48 @@
"description": "How many times this API has been called by a non-Kibana client in a custom space."
}
},
"apiCalls.savedObjectsBulkResolve.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.default.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called in the Default space."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by the Kibana client in the Default space."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by a non-Kibana client in the Default space."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.custom.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called in a custom space."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by the Kibana client in a custom space."
}
},
"apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by a non-Kibana client in a custom space."
}
},
"apiCalls.savedObjectsBulkUpdate.total": {
"type": "long",
"_meta": {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { SavedObjectsClientContract } from 'src/core/server';
import type { SavedObjectsBulkResolveResponse, SavedObjectsClientContract } from 'src/core/server';
import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock';
@ -1454,6 +1454,241 @@ describe('#get', () => {
});
});
describe('#bulkResolve', () => {
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
const mockedResponse = {
resolved_objects: [
{
saved_object: {
id: 'some-id',
type: 'unknown-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
references: [],
},
},
{
saved_object: {
id: 'some-id-2',
type: 'unknown-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
references: [],
},
},
],
};
mockBaseClient.bulkResolve.mockResolvedValue(
(mockedResponse as unknown) as SavedObjectsBulkResolveResponse
);
const bulkResolveParams = [
{ type: 'unknown-type', id: 'some-id' },
{ type: 'unknown-type', id: 'some-id-2' },
];
const options = { namespace: 'some-ns' };
await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual(mockedResponse);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options);
});
it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => {
const mockedResponse = {
resolved_objects: [
{
saved_object: {
id: 'some-id',
type: 'unknown-type',
attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
namespaces: ['some-ns'],
references: [],
},
},
{
saved_object: {
id: 'some-id-2',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
namespaces: ['some-ns'],
references: [],
},
},
],
};
mockBaseClient.bulkResolve.mockResolvedValue(
(mockedResponse as unknown) as SavedObjectsBulkResolveResponse
);
const bulkResolveParams = [
{ type: 'unknown-type', id: 'some-id' },
{ type: 'known-type', id: 'some-id-2' },
];
const options = { namespace: 'some-ns' };
await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({
resolved_objects: [
mockedResponse.resolved_objects[0],
{
saved_object: {
...mockedResponse.resolved_objects[1].saved_object,
attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' },
},
},
],
});
expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options);
expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes(
1
);
expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id-2', namespace: 'some-ns' },
{
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
undefined,
{ user: mockAuthenticatedUser() }
);
});
it('includes both attributes and error if decryption fails.', async () => {
const mockedResponse = {
resolved_objects: [
{
saved_object: {
id: 'some-id',
type: 'unknown-type',
attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
namespaces: ['some-ns'],
references: [],
},
},
{
saved_object: {
id: 'some-id-2',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
namespaces: ['some-ns'],
references: [],
},
},
],
};
mockBaseClient.bulkResolve.mockResolvedValue(
(mockedResponse as unknown) as SavedObjectsBulkResolveResponse
);
const decryptionError = new EncryptionError(
'something failed',
'attrNotSoSecret',
EncryptionErrorOperation.Decryption
);
encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({
attributes: { attrOne: 'one', attrThree: 'three' },
error: decryptionError,
});
const bulkResolveParams = [
{ type: 'unknown-type', id: 'some-id' },
{ type: 'known-type', id: 'some-id-2' },
];
const options = { namespace: 'some-ns' };
await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({
resolved_objects: [
mockedResponse.resolved_objects[0],
{
saved_object: {
...mockedResponse.resolved_objects[1].saved_object,
attributes: { attrOne: 'one', attrThree: 'three' },
error: decryptionError,
},
},
],
});
expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options);
expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes(
1
);
expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id-2', namespace: 'some-ns' },
{
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
undefined,
{ user: mockAuthenticatedUser() }
);
});
it('fails if base client fails', async () => {
const failureReason = new Error('Something bad happened...');
mockBaseClient.bulkResolve.mockRejectedValue(failureReason);
await expect(wrapper.bulkResolve([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError(
failureReason
);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(
[{ type: 'known-type', id: 'some-id' }],
undefined
);
});
it('redirects request to underlying base client and return errors result if type is registered', async () => {
const mockedResponse = {
resolved_objects: [
{
saved_object: {
id: 'bad',
type: 'known-type',
error: { statusCode: 404, message: 'Not found' },
},
},
],
};
mockBaseClient.bulkResolve.mockResolvedValue(
(mockedResponse as unknown) as SavedObjectsBulkResolveResponse
);
const bulkGetParams = [{ type: 'known-type', id: 'bad' }];
const options = { namespace: 'some-ns' };
await expect(wrapper.bulkResolve(bulkGetParams, options)).resolves.toEqual(mockedResponse);
expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1);
});
});
describe('#resolve', () => {
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
const mockedResponse = {

View file

@ -11,6 +11,7 @@ import type {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkUpdateResponse,
@ -190,6 +191,28 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
);
}
public async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options?: SavedObjectsBaseOptions
) {
const bulkResolveResult = await this.options.baseClient.bulkResolve<T>(objects, options);
for (const resolved of bulkResolveResult.resolved_objects) {
const savedObject = resolved.saved_object;
await this.handleEncryptedAttributesInResponse(
savedObject,
undefined as unknown,
getDescriptorNamespace(
this.options.baseTypeRegistry,
savedObject.type,
savedObject.namespaces ? savedObject.namespaces[0] : undefined
)
);
}
return bulkResolveResult;
}
public async resolve<T>(type: string, id: string, options?: SavedObjectsBaseOptions) {
const resolveResult = await this.options.baseClient.resolve<T>(type, id, options);
const object = await this.handleEncryptedAttributesInResponse(

View file

@ -12,6 +12,7 @@ import type {
SavedObject,
SavedObjectReferenceWithContext,
SavedObjectsClientContract,
SavedObjectsResolveResponse,
SavedObjectsUpdateObjectsSpacesResponseObject,
} from 'src/core/server';
import { httpServerMock, savedObjectsClientMock } from 'src/core/server/mocks';
@ -465,6 +466,103 @@ describe('#bulkGet', () => {
});
});
describe('#bulkResolve', () => {
const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' });
const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' });
const namespace = 'some-ns';
test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => {
const objects = [obj1];
await expectGeneralError(client.bulkResolve, { objects });
});
test(`throws decorated ForbiddenError when unauthorized`, async () => {
const objects = [obj1, obj2];
const options = { namespace };
await expectForbiddenError(client.bulkResolve, { objects, options }, 'bulk_resolve');
});
test(`returns result of baseClient.bulkResolve when authorized`, async () => {
const apiCallReturnValue = { resolved_objects: [] };
clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue);
const objects = [obj1, obj2];
const options = { namespace };
const result = await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve');
expect(result).toEqual(apiCallReturnValue);
});
test(`checks privileges for user, actions, and namespace`, async () => {
const objects = [obj1, obj2];
const options = { namespace };
await expectPrivilegeCheck(client.bulkResolve, { objects, options }, namespace);
});
test(`filters namespaces that the user doesn't have access to`, async () => {
const objects = [obj1, obj2];
const options = { namespace };
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce(
getMockCheckPrivilegesSuccess // privilege check for authorization
);
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
getMockCheckPrivilegesFailure // privilege check for namespace filtering
);
clientOpts.baseClient.bulkResolve.mockResolvedValue({
resolved_objects: [
// omit other fields from the SavedObjectsResolveResponse such as outcome, as they are not needed for this test case
({ saved_object: { namespaces: ['*'] } } as unknown) as SavedObjectsResolveResponse,
({ saved_object: { namespaces: [namespace] } } as unknown) as SavedObjectsResolveResponse,
({
saved_object: { namespaces: ['some-other-namespace', namespace] },
} as unknown) as SavedObjectsResolveResponse,
],
});
const result = await client.bulkResolve(objects, options);
expect(result).toEqual({
resolved_objects: [
{ saved_object: { namespaces: ['*'] } },
{ saved_object: { namespaces: [namespace] } },
{ saved_object: { namespaces: [namespace, '?'] } },
],
});
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2);
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith(
'login:',
['some-other-namespace']
// when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs
// we don't check privileges for authorizedNamespaces either, as that was already checked earlier in the operation
);
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = {
resolved_objects: [
({ saved_object: obj1 } as unknown) as SavedObjectsResolveResponse,
({ saved_object: obj2 } as unknown) as SavedObjectsResolveResponse,
],
};
clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue);
const objects = [obj1, obj2];
const options = { namespace };
await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve');
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_resolve', 'success', obj1);
expectAuditEvent('saved_object_resolve', 'success', obj2);
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.bulkResolve([obj1, obj2], { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_resolve', 'failure', obj1);
expectAuditEvent('saved_object_resolve', 'failure', obj2);
});
});
describe('#bulkUpdate', () => {
const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } });
const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } });

View file

@ -11,6 +11,7 @@ import type {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkUpdateObject,
SavedObjectsCheckConflictsObject,
SavedObjectsClientContract,
@ -356,6 +357,78 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]);
}
public async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options: SavedObjectsBaseOptions = {}
) {
try {
const args = { objects, options };
await this.legacyEnsureAuthorized(
this.getUniqueObjectTypes(objects),
'bulk_get',
options.namespace,
{ args, auditAction: 'bulk_resolve' }
);
} catch (error) {
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.RESOLVE,
savedObject: { type, id },
error,
})
)
);
throw error;
}
const response = await this.baseClient.bulkResolve<T>(objects, options);
response.resolved_objects.forEach(({ saved_object: { error, type, id } }) => {
if (!error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.RESOLVE,
savedObject: { type, id },
})
);
}
});
// the generic redactSavedObjectsNamespaces function cannot be used here due to the nested structure of the
// resolved objects, so we handle redaction in a bespoke manner for bulkResolve
if (this.getSpacesService() === undefined) {
return response;
}
const previouslyAuthorizedSpaceIds = [
this.getSpacesService()!.namespaceToSpaceId(options.namespace),
];
// all users can see the "all spaces" ID, and we don't need to recheck authorization for any namespaces that we just checked earlier
const namespaces = uniq(
response.resolved_objects.flatMap((resolved) => resolved.saved_object.namespaces || [])
).filter((x) => x !== ALL_SPACES_ID && !previouslyAuthorizedSpaceIds.includes(x));
const privilegeMap = await this.getNamespacesPrivilegeMap(
namespaces,
previouslyAuthorizedSpaceIds
);
return {
...response,
resolved_objects: response.resolved_objects.map((resolved) => ({
...resolved,
saved_object: {
...resolved.saved_object,
namespaces:
resolved.saved_object.namespaces &&
this.redactAndSortNamespaces(resolved.saved_object.namespaces, privilegeMap),
},
})),
};
}
public async resolve<T = unknown>(
type: string,
id: string,
@ -1030,6 +1103,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
response: T,
previouslyAuthorizedNamespaces: Array<string | undefined>
): Promise<T> {
// WARNING: the bulkResolve function has a bespoke implementation of this; any changes here should be applied there too.
if (this.getSpacesService() === undefined) {
return response;
}

View file

@ -93,6 +93,32 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
});
});
describe('#bulkResolve', () => {
test(`throws error if options.namespace is specified`, async () => {
const { client } = createSpacesSavedObjectsClient();
await expect(client.bulkResolve([], { namespace: 'bar' })).rejects.toThrow(
ERROR_NAMESPACE_SPECIFIED
);
});
test(`supplements options with the current namespace`, async () => {
const { client, baseClient } = createSpacesSavedObjectsClient();
const expectedReturnValue = { resolved_objects: [] };
baseClient.bulkResolve.mockReturnValue(Promise.resolve(expectedReturnValue));
const options = Object.freeze({ foo: 'bar' });
// @ts-expect-error
const actualReturnValue = await client.bulkResolve([], options);
expect(actualReturnValue).toBe(expectedReturnValue);
expect(baseClient.bulkResolve).toHaveBeenCalledWith([], {
foo: 'bar',
namespace: currentSpace.expectedNamespace,
});
});
});
describe('#resolve', () => {
test(`throws error if options.namespace is specified`, async () => {
const { client } = createSpacesSavedObjectsClient();

View file

@ -13,6 +13,7 @@ import type {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkUpdateObject,
SavedObjectsCheckConflictsObject,
SavedObjectsClientContract,
@ -92,14 +93,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
this.errors = baseClient.errors;
}
/**
* Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are
* multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten.
*
* @param objects
* @param options
*/
public async checkConflicts(
async checkConflicts(
objects: SavedObjectsCheckConflictsObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
@ -111,18 +105,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Persists an object
*
* @param {string} type
* @param {object} attributes
* @param {object} [options={}]
* @property {string} [options.id] - force id on creation, not recommended
* @property {boolean} [options.overwrite=false]
* @property {string} [options.namespace]
* @returns {promise} - { id, type, version, attributes }
*/
public async create<T = unknown>(
async create<T = unknown>(
type: string,
attributes: T = {} as T,
options: SavedObjectsCreateOptions = {}
@ -135,16 +118,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Creates multiple documents at once
*
* @param {array} objects - [{ type, id, attributes }]
* @param {object} [options={}]
* @property {boolean} [options.overwrite=false] - overwrites existing documents
* @property {string} [options.namespace]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]}
*/
public async bulkCreate<T = unknown>(
async bulkCreate<T = unknown>(
objects: Array<SavedObjectsBulkCreateObject<T>>,
options: SavedObjectsBaseOptions = {}
) {
@ -156,16 +130,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Deletes an object
*
* @param {string} type
* @param {string} id
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise}
*/
public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
throwErrorIfNamespaceSpecified(options);
return await this.client.delete(type, id, {
@ -174,23 +139,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* @param {object} [options={}]
* @property {(string|Array<string>)} [options.type]
* @property {string} [options.search]
* @property {string} [options.defaultSearchOperator]
* @property {Array<string>} [options.searchFields] - see Elasticsearch Simple Query String
* Query field argument for more information
* @property {integer} [options.page=1]
* @property {integer} [options.perPage=20]
* @property {string} [options.sortField]
* @property {string} [options.sortOrder]
* @property {Array<string>} [options.fields]
* @property {string} [options.namespaces]
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
public async find<T = unknown, A = unknown>(options: SavedObjectsFindOptions) {
async find<T = unknown, A = unknown>(options: SavedObjectsFindOptions) {
let namespaces: string[];
try {
namespaces = await this.getSearchableSpaces(options.namespaces);
@ -215,21 +164,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Returns an array of objects by id
*
* @param {array} objects - an array ids, or an array of objects containing id and optionally type
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
* @example
*
* bulkGet([
* { id: 'one', type: 'config' },
* { id: 'foo', type: 'index-pattern' }
* ])
*/
public async bulkGet<T = unknown>(
async bulkGet<T = unknown>(
objects: SavedObjectsBulkGetObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
@ -292,16 +227,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
};
}
/**
* Gets a single object
*
* @param {string} type
* @param {string} id
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise} - { id, type, version, attributes }
*/
public async get<T = unknown>(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
async get<T = unknown>(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
throwErrorIfNamespaceSpecified(options);
return await this.client.get<T>(type, id, {
@ -310,39 +236,28 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Resolves a single object, using any legacy URL alias if it exists
*
* @param type - The type of SavedObject to retrieve
* @param id - The ID of the SavedObject to retrieve
* @param {object} [options={}]
* @property {string} [options.namespace]
* @returns {promise} - { saved_object, outcome }
*/
public async resolve<T = unknown>(
type: string,
id: string,
async bulkResolve<T = unknown>(
objects: SavedObjectsBulkResolveObject[],
options: SavedObjectsBaseOptions = {}
) {
throwErrorIfNamespaceSpecified(options);
return await this.client.bulkResolve<T>(objects, {
...options,
namespace: spaceIdToNamespace(this.spaceId),
});
}
async resolve<T = unknown>(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
throwErrorIfNamespaceSpecified(options);
return await this.client.resolve<T>(type, id, {
...options,
namespace: spaceIdToNamespace(this.spaceId),
});
}
/**
* Updates an object
*
* @param {string} type
* @param {string} id
* @param {object} [options={}]
* @property {string} options.version - ensures version matches that of persisted object
* @property {string} [options.namespace]
* @returns {promise}
*/
public async update<T = unknown>(
async update<T = unknown>(
type: string,
id: string,
attributes: Partial<T>,
@ -356,19 +271,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Updates an array of objects by id
*
* @param {array} objects - an array ids, or an array of objects containing id, type, attributes and optionally version, references and namespace
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
* @example
*
* bulkUpdate([
* { id: 'one', type: 'config', attributes: { title: 'My new title'}, version: 'd7rhfk47d=' },
* { id: 'foo', type: 'index-pattern', attributes: {} }
* ])
*/
public async bulkUpdate<T = unknown>(
async bulkUpdate<T = unknown>(
objects: Array<SavedObjectsBulkUpdateObject<T>> = [],
options: SavedObjectsBaseOptions = {}
) {
@ -379,14 +282,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Remove outward references to given object.
*
* @param type
* @param id
* @param options
*/
public async removeReferencesTo(
async removeReferencesTo(
type: string,
id: string,
options: SavedObjectsRemoveReferencesToOptions = {}
@ -398,13 +294,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type.
*
* @param objects
* @param options
*/
public async collectMultiNamespaceReferences(
async collectMultiNamespaceReferences(
objects: SavedObjectsCollectMultiNamespaceReferencesObject[],
options: SavedObjectsCollectMultiNamespaceReferencesOptions = {}
): Promise<SavedObjectsCollectMultiNamespaceReferencesResponse> {
@ -415,15 +305,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Updates one or more objects to add and/or remove them from specified spaces.
*
* @param objects
* @param spacesToAdd
* @param spacesToRemove
* @param options
*/
public async updateObjectsSpaces(
async updateObjectsSpaces(
objects: SavedObjectsUpdateObjectsSpacesObject[],
spacesToAdd: string[],
spacesToRemove: string[],
@ -436,16 +318,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Opens a Point In Time (PIT) against the indices for the specified Saved Object types.
* The returned `id` can then be passed to `SavedObjects.find` to search against that PIT.
*
* @param {string|Array<string>} type
* @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions}
* @property {string} [options.keepAlive]
* @property {string} [options.preference]
* @returns {promise} - { id: string }
*/
async openPointInTimeForType(
type: string | string[],
options: SavedObjectsOpenPointInTimeOptions = {}
@ -471,15 +343,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Closes a Point In Time (PIT) by ID. This simply proxies the request to ES
* via the Elasticsearch client, and is included in the Saved Objects Client
* as a convenience for consumers who are using `openPointInTimeForType`.
*
* @param {string} id - ID returned from `openPointInTimeForType`
* @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions}
* @returns {promise} - { succeeded: boolean; num_freed: number }
*/
async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) {
throwErrorIfNamespaceSpecified(options);
return await this.client.closePointInTime(id, {
@ -488,17 +351,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
/**
* Returns a generator to help page through large sets of saved objects.
*
* The generator wraps calls to `SavedObjects.find` and iterates over
* multiple pages of results using `_pit` and `search_after`. This will
* open a new Point In Time (PIT), and continue paging until a set of
* results is received that's smaller than the designated `perPage`.
*
* @param {object} findOptions - {@link SavedObjectsCreatePointInTimeFinderOptions}
* @param {object} [dependencies] - {@link SavedObjectsCreatePointInTimeFinderDependencies}
*/
createPointInTimeFinder<T = unknown, A = unknown>(
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
dependencies?: SavedObjectsCreatePointInTimeFinderDependencies

View file

@ -115,6 +115,7 @@ export function bulkCreateTestSuiteFactory(esArchiver: any, supertest: SuperTest
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces);
expect(object.namespaces).to.eql(redactedNamespaces);
// TODO: improve assertions for redacted namespaces? (#112455)
}
}
}

View file

@ -51,7 +51,7 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest<an
const object = savedObjects[i];
const testCase = testCaseArray[i];
await expectResponses.permitted(object, testCase);
// TODO: add assertions for redacted namespaces (this already exists in the bulkCreate test suite)
// TODO: add assertions for redacted namespaces (#112455)
}
}
};

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { SuperTest } from 'supertest';
import { TEST_CASES } from './resolve';
import { SPACES } from '../lib/spaces';
import {
createRequest,
expectResponses,
getUrlPrefix,
getTestTitle,
} from '../lib/saved_object_test_utils';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types';
export interface BulkResolveTestDefinition extends TestDefinition {
request: Array<{ type: string; id: string }>;
}
export type BulkResolveTestSuite = TestSuite<BulkResolveTestDefinition>;
export interface BulkResolveTestCase extends TestCase {
expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict';
expectedId?: string;
expectedAliasTargetId?: string;
}
export { TEST_CASES }; // re-export the (non-bulk) resolve test cases
export function bulkResolveTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_get');
const expectResponseBody = (
testCases: BulkResolveTestCase | BulkResolveTestCase[],
statusCode: 200 | 403
): ExpectResponseBody => async (response: Record<string, any>) => {
const testCaseArray = Array.isArray(testCases) ? testCases : [testCases];
if (statusCode === 403) {
const types = testCaseArray.map((x) => x.type);
await expectSavedObjectForbidden(types)(response);
} else {
// permitted
const resolvedObjects = response.body.resolved_objects;
expect(resolvedObjects).length(testCaseArray.length);
for (let i = 0; i < resolvedObjects.length; i++) {
const resolvedObject = resolvedObjects[i];
const testCase = testCaseArray[i];
const { expectedId: id, expectedOutcome, expectedAliasTargetId } = testCase;
await expectResponses.permitted(resolvedObject.saved_object, {
...testCase,
...(!testCase.failure && id && { id }), // use expected ID instead of the requested ID iff the case was *not* a failure
});
if (!testCase.failure) {
expect(resolvedObject.outcome).to.eql(expectedOutcome);
if (expectedOutcome === 'conflict' || expectedOutcome === 'aliasMatch') {
expect(resolvedObject.alias_target_id).to.eql(expectedAliasTargetId);
} else {
expect(resolvedObject.alias_target_id).to.eql(undefined);
}
// TODO: add assertions for redacted namespaces (#112455)
}
}
}
};
const createTestDefinitions = (
testCases: BulkResolveTestCase | BulkResolveTestCase[],
forbidden: boolean,
options?: {
spaceId?: string;
singleRequest?: boolean;
responseBodyOverride?: ExpectResponseBody;
}
): BulkResolveTestDefinition[] => {
const cases = Array.isArray(testCases) ? testCases : [testCases];
const responseStatusCode = forbidden ? 403 : 200;
if (!options?.singleRequest) {
// if we are testing cases that should result in a forbidden response, we can do each case individually
// this ensures that multiple test cases of a single type will each result in a forbidden error
return cases.map((x) => ({
title: getTestTitle(x, responseStatusCode),
request: [createRequest(x)],
responseStatusCode,
responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode),
}));
}
// batch into a single request to save time during test execution
return [
{
title: getTestTitle(cases, responseStatusCode),
request: cases.map((x) => createRequest(x)),
responseStatusCode,
responseBody:
options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode),
},
];
};
const makeBulkResolveTest = (describeFn: Mocha.SuiteFunction) => (
description: string,
definition: BulkResolveTestSuite
) => {
const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition;
describeFn(description, () => {
before(() =>
esArchiver.load(
'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
)
);
after(() =>
esArchiver.unload(
'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
)
);
for (const test of tests) {
it(`should return ${test.responseStatusCode} ${test.title}`, async () => {
await supertest
.post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_resolve`)
.auth(user?.username, user?.password)
.send(test.request)
.expect(test.responseStatusCode)
.then(test.responseBody);
});
}
});
};
const addTests = makeBulkResolveTest(describe);
// @ts-ignore
addTests.only = makeBulkResolveTest(describe.only);
return {
addTests,
createTestDefinitions,
};
}

View file

@ -56,6 +56,7 @@ export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest
await expectResponses.permitted(object, testCase);
if (!testCase.failure) {
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
// TODO: add assertions for redacted namespaces (#112455)
}
}
}

View file

@ -99,6 +99,7 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces);
expect(object.namespaces).to.eql(redactedNamespaces);
// TODO: improve assertions for redacted namespaces? (#112455)
}
}
};

View file

@ -246,6 +246,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
expect(object.id).to.eql(expected.id);
expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(object.namespaces).to.eql(expectedNamespaces);
// TODO: improve assertions for redacted namespaces? (#112455)
// don't test attributes, version, or references
}
}

View file

@ -36,6 +36,7 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>)
// permitted
const object = response.body;
await expectResponses.permitted(object, testCase);
// TODO: add assertions for redacted namespaces (#112455)
}
};
const createTestDefinitions = (

View file

@ -40,14 +40,14 @@ export const TEST_CASES = Object.freeze({
type: 'resolvetype',
id: 'exact-match',
expectedNamespaces: EACH_SPACE,
expectedOutcome: 'exactMatch' as 'exactMatch',
expectedOutcome: 'exactMatch' as const,
expectedId: 'exact-match',
}),
ALIAS_MATCH: Object.freeze({
type: 'resolvetype',
id: 'alias-match',
expectedNamespaces: EACH_SPACE,
expectedOutcome: 'aliasMatch' as 'aliasMatch',
expectedOutcome: 'aliasMatch' as const,
expectedId: 'alias-match-newid',
expectedAliasTargetId: 'alias-match-newid',
}),
@ -55,7 +55,7 @@ export const TEST_CASES = Object.freeze({
type: 'resolvetype',
id: 'conflict',
expectedNamespaces: EACH_SPACE,
expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists
expectedOutcome: 'conflict' as const, // only in space 1, where the alias exists
expectedId: 'conflict',
expectedAliasTargetId: 'conflict-newid',
}),
@ -89,6 +89,7 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest<an
} else {
expect(response.body.alias_target_id).to.eql(undefined);
}
// TODO: add assertions for redacted namespaces (#112455)
}
}
};

View file

@ -47,6 +47,7 @@ export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
await expectResponses.permitted(object, testCase);
if (!testCase.failure) {
expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL);
// TODO: add assertions for redacted namespaces (#112455)
}
}
};

View file

@ -0,0 +1,83 @@
/*
* 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 { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
bulkResolveTestSuiteFactory,
TEST_CASES as CASES,
BulkResolveTestDefinition,
} from '../../common/suites/bulk_resolve';
const {
SPACE_1: { spaceId: SPACE_1_ID },
} = SPACES;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const normalTypes = [
CASES.EXACT_MATCH,
{ ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) },
{
...CASES.CONFLICT,
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }),
},
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
const allTypes = [...normalTypes, ...hiddenType];
return { normalTypes, hiddenType, allTypes };
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest);
const createTests = (spaceId: string) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId);
// use singleRequest to reduce execution time and/or test combined cases
return {
unauthorized: createTestDefinitions(allTypes, true),
authorized: [
createTestDefinitions(normalTypes, false),
createTestDefinitions(hiddenType, true),
].flat(),
superuser: createTestDefinitions(allTypes, false),
};
};
describe('_bulk_resolve', () => {
getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => {
const suffix = ` within the ${spaceId} space`;
const { unauthorized, authorized, superuser } = createTests(spaceId);
const _addTests = (user: TestUser, tests: BulkResolveTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, spaceId, tests });
};
[users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => {
_addTests(user, unauthorized);
});
[
users.dualAll,
users.dualRead,
users.allGlobally,
users.readGlobally,
users.allAtSpace,
users.readAtSpace,
].forEach((user) => {
_addTests(user, authorized);
});
_addTests(users.superuser, superuser);
});
});
}

View file

@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bulk_create'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./bulk_resolve'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));

View file

@ -18,7 +18,7 @@ import {
const {
SPACE_1: { spaceId: SPACE_1_ID },
} = SPACES;
const { fail404 } = testCaseFailures;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
@ -28,13 +28,13 @@ const createTestCases = (spaceId: string) => {
{ ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) },
{
...CASES.CONFLICT,
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }),
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }),
},
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }];
const allTypes = normalTypes.concat(hiddenType);
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
const allTypes = [...normalTypes, ...hiddenType];
return { normalTypes, hiddenType, allTypes };
};

View file

@ -0,0 +1,74 @@
/*
* 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
bulkResolveTestSuiteFactory,
TEST_CASES as CASES,
BulkResolveTestDefinition,
} from '../../common/suites/bulk_resolve';
const { fail400, fail404 } = testCaseFailures;
const createTestCases = () => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const normalTypes = [
{ ...CASES.EXACT_MATCH },
{ ...CASES.ALIAS_MATCH, ...fail404() },
{ ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as const },
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
const allTypes = [...normalTypes, ...hiddenType];
return { normalTypes, hiddenType, allTypes };
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest);
const createTests = () => {
const { normalTypes, hiddenType, allTypes } = createTestCases();
return {
unauthorized: createTestDefinitions(allTypes, true),
authorized: [
createTestDefinitions(normalTypes, false, { singleRequest: true }),
createTestDefinitions(hiddenType, true),
].flat(),
superuser: createTestDefinitions(allTypes, false, { singleRequest: true }),
};
};
describe('_bulk_resolve', () => {
getTestScenarios().security.forEach(({ users }) => {
const { unauthorized, authorized, superuser } = createTests();
const _addTests = (user: TestUser, tests: BulkResolveTestDefinition[]) => {
addTests(user.description, { user, tests });
};
[
users.noAccess,
users.legacyAll,
users.allAtDefaultSpace,
users.readAtDefaultSpace,
users.allAtSpace1,
users.readAtSpace1,
].forEach((user) => {
_addTests(user, unauthorized);
});
[users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => {
_addTests(user, authorized);
});
_addTests(users.superuser, superuser);
});
});
}

View file

@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bulk_create'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_resolve'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));

View file

@ -14,7 +14,7 @@ import {
ResolveTestDefinition,
} from '../../common/suites/resolve';
const { fail404 } = testCaseFailures;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = () => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
@ -22,12 +22,12 @@ const createTestCases = () => {
const normalTypes = [
{ ...CASES.EXACT_MATCH },
{ ...CASES.ALIAS_MATCH, ...fail404() },
{ ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as 'exactMatch' },
{ ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as const },
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }];
const allTypes = normalTypes.concat(hiddenType);
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }];
const allTypes = [...normalTypes, ...hiddenType];
return { normalTypes, hiddenType, allTypes };
};

View file

@ -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 { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { bulkResolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_resolve';
const {
SPACE_1: { spaceId: SPACE_1_ID },
} = SPACES;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => [
// for each outcome, if failure !== undefined then we expect to receive
// an error; otherwise, we expect to receive a success result
CASES.EXACT_MATCH,
{ ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) },
{
...CASES.CONFLICT,
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }),
},
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.HIDDEN, ...fail400() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest);
const createTests = (spaceId: string) => {
const testCases = createTestCases(spaceId);
return createTestDefinitions(testCases, false, { spaceId, singleRequest: true });
};
describe('_bulk_resolve', () => {
getTestScenarios().spaces.forEach(({ spaceId }) => {
const tests = createTests(spaceId);
addTests(`within the ${spaceId} space`, { spaceId, tests });
});
});
}

View file

@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bulk_create'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_resolve'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));

View file

@ -13,7 +13,7 @@ import { resolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suite
const {
SPACE_1: { spaceId: SPACE_1_ID },
} = SPACES;
const { fail404 } = testCaseFailures;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => [
// for each outcome, if failure !== undefined then we expect to receive
@ -22,10 +22,10 @@ const createTestCases = (spaceId: string) => [
{ ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) },
{
...CASES.CONFLICT,
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }),
...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }),
},
{ ...CASES.DISABLED, ...fail404() },
{ ...CASES.HIDDEN, ...fail404() },
{ ...CASES.HIDDEN, ...fail400() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];