Server saved objects client through request context (#44143)

* Expose Saved Objects client in request context

* API Integration test for savedobjects in req context

* SavedObjectsClient docs

* SavedObjectsClient#find remove dependency on indexPatterns

And use the saved objects mappings instead

* Review comments

* Review comments, fixes and tests

* Use correct type for KQL syntax check
This commit is contained in:
Rudolf Meijering 2019-10-16 10:36:40 +02:00 committed by GitHub
parent 730ba21ed4
commit 3d28467d00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 743 additions and 585 deletions

View file

@ -9,5 +9,5 @@ Search for objects
<b>Signature:</b>
```typescript
find: <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>>;
find: <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "page" | "perPage" | "fields">) => Promise<SavedObjectsFindResponsePublic<T>>;
```

View file

@ -20,7 +20,7 @@ export declare class SavedObjectsClient
| [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | <code>(objects?: {</code><br/><code> id: string;</code><br/><code> type: string;</code><br/><code> }[]) =&gt; Promise&lt;SavedObjectsBatchResponse&lt;SavedObjectAttributes&gt;&gt;</code> | Returns an array of objects by id |
| [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <code>&lt;T extends SavedObjectAttributes&gt;(type: string, attributes: T, options?: SavedObjectsCreateOptions) =&gt; Promise&lt;SimpleSavedObject&lt;T&gt;&gt;</code> | Persists an object |
| [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | <code>(type: string, id: string) =&gt; Promise&lt;{}&gt;</code> | Deletes an object |
| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <code>&lt;T extends SavedObjectAttributes&gt;(options: Pick&lt;SavedObjectFindOptionsServer, &quot;search&quot; &#124; &quot;filter&quot; &#124; &quot;type&quot; &#124; &quot;page&quot; &#124; &quot;perPage&quot; &#124; &quot;sortField&quot; &#124; &quot;fields&quot; &#124; &quot;searchFields&quot; &#124; &quot;hasReference&quot; &#124; &quot;defaultSearchOperator&quot;&gt;) =&gt; Promise&lt;SavedObjectsFindResponsePublic&lt;T&gt;&gt;</code> | Search for objects |
| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <code>&lt;T extends SavedObjectAttributes&gt;(options: Pick&lt;SavedObjectFindOptionsServer, &quot;search&quot; &#124; &quot;filter&quot; &#124; &quot;type&quot; &#124; &quot;searchFields&quot; &#124; &quot;defaultSearchOperator&quot; &#124; &quot;hasReference&quot; &#124; &quot;sortField&quot; &#124; &quot;page&quot; &#124; &quot;perPage&quot; &#124; &quot;fields&quot;&gt;) =&gt; Promise&lt;SavedObjectsFindResponsePublic&lt;T&gt;&gt;</code> | Search for objects |
| [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <code>&lt;T extends SavedObjectAttributes&gt;(type: string, id: string) =&gt; Promise&lt;SimpleSavedObject&lt;T&gt;&gt;</code> | Fetches a single object |
## Methods

View file

@ -9,5 +9,5 @@ Indicates whether `401 Unauthorized` errors returned from the Elasticsearch API
<b>Signature:</b>
```typescript
wrap401Errors: boolean;
wrap401Errors?: boolean;
```

View file

@ -20,6 +20,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via <code>asScoped(...)</code>).<!-- -->See [ClusterClient](./kibana-plugin-server.clusterclient.md)<!-- -->. |
| [ElasticsearchErrorHelpers](./kibana-plugin-server.elasticsearcherrorhelpers.md) | Helpers for working with errors returned from the Elasticsearch service.Since the internal data of errors are subject to change, consumers of the Elasticsearch service should always use these helpers to classify errors instead of checking error internals such as <code>body.error.header[WWW-Authenticate]</code> |
| [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | Kibana specific abstraction for an incoming request. |
| [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) | |
| [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) | |
| [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md) | Serves the same purpose as "normal" <code>ClusterClient</code> but exposes additional <code>callAsCurrentUser</code> method that doesn't use credentials of the Kibana internal user (as <code>callAsInternalUser</code> does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API.<!-- -->See [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md)<!-- -->. |
@ -77,7 +78,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. |
| [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | |
| [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | |
| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler. |
| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.<!-- -->Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request |
| [RouteConfig](./kibana-plugin-server.routeconfig.md) | Route specific configuration. |
| [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Additional route options. |
| [SavedObject](./kibana-plugin-server.savedobject.md) | |
@ -159,6 +160,6 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [RouteMethod](./kibana-plugin-server.routemethod.md) | The set of common HTTP methods supported by Kibana routing. |
| [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) | Type definition for a Saved Object attribute value |
| [SavedObjectAttributeSingle](./kibana-plugin-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) |
| [SavedObjectsClientContract](./kibana-plugin-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.<!-- -->\#\# SavedObjectsClient errors<!-- -->Since the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:<!-- -->1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)<!-- -->Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the <code>isXYZError()</code> helpers exposed at <code>SavedObjectsErrorHelpers</code> should be used to understand and manage error responses from the <code>SavedObjectsClient</code>.<!-- -->Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for <code>error.body.error.type</code> or doing substring checks on <code>error.body.error.reason</code>, just use the helpers to understand the meaning of the error:<!-- -->\`\`\`<!-- -->js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }<!-- -->if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }<!-- -->// always rethrow the error unless you handle it throw error; \`\`\`<!-- -->\#\#\# 404s from missing index<!-- -->From the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.<!-- -->At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.<!-- -->From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.<!-- -->\#\#\# 503s from missing index<!-- -->Unlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's <code>action.auto_create_index</code> setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.<!-- -->See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) |
| [SavedObjectsClientContract](./kibana-plugin-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.<!-- -->\#\# SavedObjectsClient errors<!-- -->Since the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:<!-- -->1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)<!-- -->Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the <code>isXYZError()</code> helpers exposed at <code>SavedObjectsErrorHelpers</code> should be used to understand and manage error responses from the <code>SavedObjectsClient</code>.<!-- -->Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for <code>error.body.error.type</code> or doing substring checks on <code>error.body.error.reason</code>, just use the helpers to understand the meaning of the error:<!-- -->\`\`\`<!-- -->js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }<!-- -->if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }<!-- -->// always rethrow the error unless you handle it throw error; \`\`\`<!-- -->\#\#\# 404s from missing index<!-- -->From the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.<!-- -->At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.<!-- -->From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.<!-- -->\#\#\# 503s from missing index<!-- -->Unlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's <code>action.auto_create_index</code> setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.<!-- -->See [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) |
| [SavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. |

View file

@ -8,6 +8,9 @@
```typescript
core: {
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;

View file

@ -6,6 +6,8 @@
Plugin specific context passed to a route handler.
Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request
<b>Signature:</b>
```typescript
@ -16,5 +18,5 @@ export interface RequestHandlerContext
| Property | Type | Description |
| --- | --- | --- |
| [core](./kibana-plugin-server.requesthandlercontext.core.md) | <code>{</code><br/><code> elasticsearch: {</code><br/><code> dataClient: IScopedClusterClient;</code><br/><code> adminClient: IScopedClusterClient;</code><br/><code> };</code><br/><code> }</code> | |
| [core](./kibana-plugin-server.requesthandlercontext.core.md) | <code>{</code><br/><code> savedObjects: {</code><br/><code> client: SavedObjectsClientContract;</code><br/><code> };</code><br/><code> elasticsearch: {</code><br/><code> dataClient: IScopedClusterClient;</code><br/><code> adminClient: IScopedClusterClient;</code><br/><code> };</code><br/><code> }</code> | |

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [(constructor)](./kibana-plugin-server.savedobjectsclient._constructor_.md)
## SavedObjectsClient.(constructor)
Constructs a new instance of the `SavedObjectsClient` class
<b>Signature:</b>
```typescript
constructor(repository: SavedObjectsRepository);
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| repository | <code>SavedObjectsRepository</code> | |

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [bulkCreate](./kibana-plugin-server.savedobjectsclient.bulkcreate.md)
## SavedObjectsClient.bulkCreate() method
Persists multiple documents batched together as a single request
<b>Signature:</b>
```typescript
bulkCreate<T extends SavedObjectAttributes = any>(objects: Array<SavedObjectsBulkCreateObject<T>>, options?: SavedObjectsCreateOptions): Promise<SavedObjectsBulkResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>Array&lt;SavedObjectsBulkCreateObject&lt;T&gt;&gt;</code> | |
| options | <code>SavedObjectsCreateOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBulkResponse<T>>`

View file

@ -0,0 +1,29 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [bulkGet](./kibana-plugin-server.savedobjectsclient.bulkget.md)
## SavedObjectsClient.bulkGet() method
Returns an array of objects by id
<b>Signature:</b>
```typescript
bulkGet<T extends SavedObjectAttributes = any>(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>SavedObjectsBulkGetObject[]</code> | an array of ids, or an array of objects containing id, type and optionally fields |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBulkResponse<T>>`
## Example
bulkGet(\[ { id: 'one', type: 'config' }<!-- -->, { id: 'foo', type: 'index-pattern' } \])

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [create](./kibana-plugin-server.savedobjectsclient.create.md)
## SavedObjectsClient.create() method
Persists a SavedObject
<b>Signature:</b>
```typescript
create<T extends SavedObjectAttributes = any>(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise<SavedObject<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | |
| attributes | <code>T</code> | |
| options | <code>SavedObjectsCreateOptions</code> | |
<b>Returns:</b>
`Promise<SavedObject<T>>`

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [delete](./kibana-plugin-server.savedobjectsclient.delete.md)
## SavedObjectsClient.delete() method
Deletes a SavedObject
<b>Signature:</b>
```typescript
delete(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<{}>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | |
| id | <code>string</code> | |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<{}>`

View file

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

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [find](./kibana-plugin-server.savedobjectsclient.find.md)
## SavedObjectsClient.find() method
Find all SavedObjects matching the search query
<b>Signature:</b>
```typescript
find<T extends SavedObjectAttributes = any>(options: SavedObjectsFindOptions): Promise<SavedObjectsFindResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| options | <code>SavedObjectsFindOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsFindResponse<T>>`

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [get](./kibana-plugin-server.savedobjectsclient.get.md)
## SavedObjectsClient.get() method
Retrieves a single object
<b>Signature:</b>
```typescript
get<T extends SavedObjectAttributes = any>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObject<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | The type of SavedObject to retrieve |
| id | <code>string</code> | The ID of the SavedObject to retrieve |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObject<T>>`

View file

@ -0,0 +1,38 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md)
## SavedObjectsClient class
<b>Signature:</b>
```typescript
export declare class SavedObjectsClient
```
## Constructors
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(repository)](./kibana-plugin-server.savedobjectsclient._constructor_.md) | | Constructs a new instance of the <code>SavedObjectsClient</code> class |
## Properties
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [errors](./kibana-plugin-server.savedobjectsclient.errors.md) | | <code>typeof SavedObjectsErrorHelpers</code> | |
| [errors](./kibana-plugin-server.savedobjectsclient.errors.md) | <code>static</code> | <code>typeof SavedObjectsErrorHelpers</code> | |
## Methods
| Method | Modifiers | Description |
| --- | --- | --- |
| [bulkCreate(objects, options)](./kibana-plugin-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request |
| [bulkGet(objects, options)](./kibana-plugin-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id |
| [create(type, attributes, options)](./kibana-plugin-server.savedobjectsclient.create.md) | | Persists a SavedObject |
| [delete(type, id, options)](./kibana-plugin-server.savedobjectsclient.delete.md) | | Deletes a SavedObject |
| [find(options)](./kibana-plugin-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query |
| [get(type, id, options)](./kibana-plugin-server.savedobjectsclient.get.md) | | Retrieves a single object |
| [update(type, id, attributes, options)](./kibana-plugin-server.savedobjectsclient.update.md) | | Updates an SavedObject |

View file

@ -0,0 +1,27 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [update](./kibana-plugin-server.savedobjectsclient.update.md)
## SavedObjectsClient.update() method
Updates an SavedObject
<b>Signature:</b>
```typescript
update<T extends SavedObjectAttributes = any>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| type | <code>string</code> | |
| id | <code>string</code> | |
| attributes | <code>Partial&lt;T&gt;</code> | |
| options | <code>SavedObjectsUpdateOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsUpdateResponse<T>>`

View file

@ -34,7 +34,7 @@ From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The obje
Unlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's `action.auto_create_index` setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.
See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)
See [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)
<b>Signature:</b>

View file

@ -37,6 +37,6 @@ export function fromKueryExpression(
parseOptions?: KueryParseOptions
): KueryNode;
export function toElasticsearchQuery(node: KueryNode, indexPattern: any): JsonObject;
export function toElasticsearchQuery(node: KueryNode, indexPattern?: any): JsonObject;
export function doesKueryExpressionHaveLuceneSyntaxError(expression: string): boolean;

View file

@ -768,7 +768,7 @@ export class SavedObjectsClient {
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>>;
create: <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>>;
delete: (type: string, id: string) => Promise<{}>;
find: <T extends SavedObjectAttributes>(options: Pick<SavedObjectsFindOptions, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>>;
find: <T extends SavedObjectAttributes>(options: Pick<SavedObjectsFindOptions, "search" | "filter" | "type" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "page" | "perPage" | "fields">) => Promise<SavedObjectsFindResponsePublic<T>>;
get: <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>>;
update<T extends SavedObjectAttributes>(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
}

View file

@ -158,7 +158,7 @@ export interface CallAPIOptions {
* header that could have been returned by the API itself. If API didn't specify that
* then `Basic realm="Authorization Required"` is used as `WWW-Authenticate`.
*/
wrap401Errors: boolean;
wrap401Errors?: boolean;
/**
* A signal object that allows you to abort the request via an AbortController object.
*/

View file

@ -233,9 +233,6 @@ describe('http service', () => {
await kbnTestServer.request.get(root, '/new-platform/').expect(200);
// called twice by elasticsearch service in http route handler context provider
expect(clusterClientMock).toBeCalledTimes(2);
// admin client contains authHeaders for BWC with legacy platform.
const [adminClient, dataClient] = clusterClientMock.mock.calls;
const [, , adminClientHeaders] = adminClient;
@ -259,9 +256,6 @@ describe('http service', () => {
.set('Authorization', authorizationHeader)
.expect(200);
// called twice by elasticsearch service in http route handler context provider
expect(clusterClientMock).toBeCalledTimes(2);
const [adminClient, dataClient] = clusterClientMock.mock.calls;
const [, , adminClientHeaders] = adminClient;
expect(adminClientHeaders).toEqual({ authorization: authorizationHeader });

View file

@ -48,6 +48,7 @@ import { InternalHttpServiceSetup, HttpServiceSetup } from './http';
import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins';
import { ContextSetup } from './context';
import { SavedObjectsServiceStart } from './saved_objects';
import { SavedObjectsClientContract } from './saved_objects/types';
export { bootstrap } from './bootstrap';
export { ConfigPath, ConfigService, EnvironmentMode, PackageInfo } from './config';
@ -180,10 +181,22 @@ export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy';
/**
* Plugin specific context passed to a route handler.
*
* Provides the following clients:
* - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client
* which uses the credentials of the incoming request
* - {@link ScopedClusterClient | elasticsearch.dataClient} - Elasticsearch
* data client which uses the credentials of the incoming request
* - {@link ScopedClusterClient | elasticsearch.adminClient} - Elasticsearch
* admin client which uses the credentials of the incoming request
*
* @public
*/
export interface RequestHandlerContext {
core: {
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;

View file

@ -44,8 +44,12 @@ import { HttpServiceStart, BasePathProxyServer } from '../http';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { DiscoveredPlugin, DiscoveredPluginInternal } from '../plugins';
import { PluginsServiceSetup, PluginsServiceStart } from '../plugins/plugins_service';
import { SavedObjectsServiceStart } from 'src/core/server/saved_objects/saved_objects_service';
import {
SavedObjectsServiceStart,
SavedObjectsServiceSetup,
} from 'src/core/server/saved_objects/saved_objects_service';
import { KibanaMigrator } from '../saved_objects/migrations';
import { ISavedObjectsClientProvider } from '../saved_objects';
import { httpServiceMock } from '../http/http_service.mock';
const MockKbnServer: jest.Mock<KbnServer> = KbnServer as any;
@ -59,6 +63,7 @@ let setupDeps: {
elasticsearch: InternalElasticsearchServiceSetup;
http: any;
plugins: PluginsServiceSetup;
savedObjects: SavedObjectsServiceSetup;
};
plugins: Record<string, unknown>;
};
@ -99,6 +104,9 @@ beforeEach(() => {
internal: new Map([['plugin-id', {} as DiscoveredPluginInternal]]),
},
},
savedObjects: {
clientProvider: {} as ISavedObjectsClientProvider,
},
},
plugins: { 'plugin-id': 'plugin-value' },
};
@ -110,6 +118,7 @@ beforeEach(() => {
},
savedObjects: {
migrator: {} as KibanaMigrator,
clientProvider: {} as ISavedObjectsClientProvider,
},
plugins: { contracts: new Map() },
},

View file

@ -275,6 +275,7 @@ export class LegacyService implements CoreService<LegacyServiceSetup> {
kibanaMigrator: startDeps.core.savedObjects.migrator,
uiPlugins: setupDeps.core.plugins.uiPlugins,
elasticsearch: setupDeps.core.elasticsearch,
savedObjectsClientProvider: startDeps.core.savedObjects.clientProvider,
},
logger: this.coreContext.logger,
},

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { mockKibanaMigrator } from '../kibana_migrator.mock';
export const mockKibanaMigratorInstance = mockKibanaMigrator.create();
const mockConstructor = jest.fn().mockImplementation(() => mockKibanaMigratorInstance);
export const KibanaMigrator = mockConstructor;

View file

@ -17,16 +17,30 @@
* under the License.
*/
import { KibanaMigrator, mergeProperties } from './kibana_migrator';
import { KibanaMigrator } from './kibana_migrator';
import { buildActiveMappings } from '../core';
import { SavedObjectsMapping } from '../../mappings';
const { mergeProperties } = jest.requireActual('./kibana_migrator');
const defaultSavedObjectMappings = [
{
pluginId: 'testplugin',
properties: {
testtype: {
properties: {
name: { type: 'keyword' },
},
},
},
},
];
const createMigrator = (
{
savedObjectMappings,
}: {
savedObjectMappings: SavedObjectsMapping[];
} = { savedObjectMappings: [] }
} = { savedObjectMappings: defaultSavedObjectMappings }
) => {
const mockMigrator: jest.Mocked<PublicMethodsOf<KibanaMigrator>> = {
runMigrations: jest.fn(),

View file

@ -19,11 +19,13 @@
import { SavedObjectsService, SavedObjectsServiceStart } from './saved_objects_service';
import { mockKibanaMigrator } from './migrations/kibana/kibana_migrator.mock';
import { savedObjectsClientProviderMock } from './service/lib/scoped_client_provider.mock';
type SavedObjectsServiceContract = PublicMethodsOf<SavedObjectsService>;
const createStartContractMock = () => {
const startContract: jest.Mocked<SavedObjectsServiceStart> = {
clientProvider: savedObjectsClientProviderMock.create(),
migrator: mockKibanaMigrator.create(),
};
@ -37,7 +39,7 @@ const createsavedObjectsServiceMock = () => {
stop: jest.fn(),
};
mocked.setup.mockResolvedValue({});
mocked.setup.mockResolvedValue({ clientProvider: savedObjectsClientProviderMock.create() });
mocked.start.mockResolvedValue(createStartContractMock());
mocked.stop.mockResolvedValue();
return mocked;

View file

@ -21,11 +21,13 @@ jest.mock('./migrations/kibana/kibana_migrator');
import { SavedObjectsService, SavedObjectsSetupDeps } from './saved_objects_service';
import { mockCoreContext } from '../core_context.mock';
import { KibanaMigrator } from './migrations/kibana/kibana_migrator';
// @ts-ignore Typescript doesn't know about the jest mock
import { KibanaMigrator, mockKibanaMigratorInstance } from './migrations/kibana/kibana_migrator';
import { of } from 'rxjs';
import * as legacyElasticsearch from 'elasticsearch';
import { Env } from '../config';
import { configServiceMock } from '../mocks';
import { SavedObjectsClientProvider } from '.';
afterEach(() => {
jest.clearAllMocks();
@ -49,7 +51,7 @@ describe('SavedObjectsService', () => {
const soService = new SavedObjectsService(coreContext);
const coreSetup = ({
elasticsearch: { adminClient$: of(clusterClient) },
legacy: { uiExports: {}, pluginExtendedConfig: {} },
legacy: { uiExports: { savedObjectMappings: [] }, pluginExtendedConfig: {} },
} as unknown) as SavedObjectsSetupDeps;
await soService.setup(coreSetup);
@ -58,6 +60,18 @@ describe('SavedObjectsService', () => {
'success'
);
});
it('resolves with clientProvider', async () => {
const coreContext = mockCoreContext.create();
const soService = new SavedObjectsService(coreContext);
const coreSetup = ({
elasticsearch: { adminClient$: of({ callAsInternalUser: jest.fn() }) },
legacy: { uiExports: {}, pluginExtendedConfig: {} },
} as unknown) as SavedObjectsSetupDeps;
const savedObjectsSetup = await soService.setup(coreSetup);
expect(savedObjectsSetup.clientProvider).toBeInstanceOf(SavedObjectsClientProvider);
});
});
describe('#start()', () => {
@ -72,9 +86,8 @@ describe('SavedObjectsService', () => {
} as unknown) as SavedObjectsSetupDeps;
await soService.setup(coreSetup);
const migrator = (KibanaMigrator as jest.Mock<KibanaMigrator>).mock.instances[0];
await soService.start({});
expect(migrator.runMigrations).toHaveBeenCalledWith(true);
expect(mockKibanaMigratorInstance.runMigrations).toHaveBeenCalledWith(true);
});
it('skips KibanaMigrator migrations when migrations.skip=true', async () => {
@ -87,9 +100,8 @@ describe('SavedObjectsService', () => {
} as unknown) as SavedObjectsSetupDeps;
await soService.setup(coreSetup);
const migrator = (KibanaMigrator as jest.Mock<KibanaMigrator>).mock.instances[0];
await soService.start({});
expect(migrator.runMigrations).toHaveBeenCalledWith(true);
expect(mockKibanaMigratorInstance.runMigrations).toHaveBeenCalledWith(true);
});
it('resolves with KibanaMigrator after waiting for migrations to complete', async () => {
@ -102,12 +114,12 @@ describe('SavedObjectsService', () => {
} as unknown) as SavedObjectsSetupDeps;
await soService.setup(coreSetup);
const migrator = (KibanaMigrator as jest.Mock<KibanaMigrator>).mock.instances[0];
expect(migrator.runMigrations).toHaveBeenCalledTimes(0);
expect(mockKibanaMigratorInstance.runMigrations).toHaveBeenCalledTimes(0);
const startContract = await soService.start({});
expect(startContract.migrator).toBeInstanceOf(KibanaMigrator);
expect(migrator.runMigrations).toHaveBeenCalledWith(false);
expect(migrator.runMigrations).toHaveBeenCalledTimes(1);
expect(startContract.migrator).toBe(mockKibanaMigratorInstance);
expect(mockKibanaMigratorInstance.runMigrations).toHaveBeenCalledWith(false);
expect(mockKibanaMigratorInstance.runMigrations).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -19,6 +19,15 @@
import { CoreService } from 'src/core/types';
import { first } from 'rxjs/operators';
import {
SavedObjectsClient,
SavedObjectsSchema,
SavedObjectsRepository,
SavedObjectsSerializer,
SavedObjectsClientProvider,
ISavedObjectsClientProvider,
} from './';
import { getRootPropertiesObjects } from './mappings';
import { KibanaMigrator, IKibanaMigrator } from './migrations';
import { CoreContext } from '../core_context';
import { LegacyServiceSetup } from '../legacy/legacy_service';
@ -26,19 +35,22 @@ import { ElasticsearchServiceSetup } from '../elasticsearch';
import { KibanaConfigType } from '../kibana_config';
import { retryCallCluster } from '../elasticsearch/retry_call_cluster';
import { SavedObjectsConfigType } from './saved_objects_config';
import { KibanaRequest } from '../http';
import { Logger } from '..';
/**
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SavedObjectsServiceSetup {}
export interface SavedObjectsServiceSetup {
clientProvider: ISavedObjectsClientProvider;
}
/**
* @public
*/
export interface SavedObjectsServiceStart {
migrator: IKibanaMigrator;
clientProvider: ISavedObjectsClientProvider;
}
/** @internal */
@ -54,13 +66,14 @@ export interface SavedObjectsStartDeps {}
export class SavedObjectsService
implements CoreService<SavedObjectsServiceSetup, SavedObjectsServiceStart> {
private migrator: KibanaMigrator | undefined;
logger: Logger;
private logger: Logger;
private clientProvider: ISavedObjectsClientProvider<KibanaRequest> | undefined;
constructor(private readonly coreContext: CoreContext) {
this.logger = coreContext.logger.get('savedobjects-service');
}
public async setup(coreSetup: SavedObjectsSetupDeps) {
public async setup(coreSetup: SavedObjectsSetupDeps): Promise<SavedObjectsServiceSetup> {
this.logger.debug('Setting up SavedObjects service');
const {
@ -82,7 +95,7 @@ export class SavedObjectsService
.pipe(first())
.toPromise();
this.migrator = new KibanaMigrator({
const migrator = (this.migrator = new KibanaMigrator({
savedObjectSchemas,
savedObjectMappings,
savedObjectMigrations,
@ -93,12 +106,41 @@ export class SavedObjectsService
savedObjectsConfig,
kibanaConfig,
callCluster: retryCallCluster(adminClient.callAsInternalUser),
}));
const mappings = this.migrator.getActiveMappings();
const allTypes = Object.keys(getRootPropertiesObjects(mappings));
const schema = new SavedObjectsSchema(savedObjectSchemas);
const serializer = new SavedObjectsSerializer(schema);
const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type));
this.clientProvider = new SavedObjectsClientProvider<KibanaRequest>({
defaultClientFactory({ request }) {
const repository = new SavedObjectsRepository({
index: kibanaConfig.index,
config: coreSetup.legacy.pluginExtendedConfig,
migrator,
mappings,
schema,
serializer,
allowedTypes: visibleTypes,
callCluster: retryCallCluster(adminClient.asScoped(request).callAsCurrentUser),
});
return new SavedObjectsClient(repository);
},
});
return ({} as any) as Promise<SavedObjectsServiceSetup>;
return {
clientProvider: this.clientProvider,
};
}
public async start(core: SavedObjectsStartDeps): Promise<SavedObjectsServiceStart> {
if (!this.clientProvider) {
throw new Error('#setup() needs to be run first');
}
this.logger.debug('Starting SavedObjects service');
/**
@ -119,7 +161,10 @@ export class SavedObjectsService
const skipMigrations = cliArgs.optimize || savedObjectsConfig.skip;
await this.migrator!.runMigrations(skipMigrations);
return { migrator: this.migrator! };
return {
migrator: this.migrator!,
clientProvider: this.clientProvider,
};
}
public async stop() {}

View file

@ -18,7 +18,7 @@
*/
import { Readable } from 'stream';
import { ScopedSavedObjectsClientProvider } from './lib';
import { SavedObjectsClientProvider } from './lib';
import { SavedObjectsClient } from './saved_objects_client';
import { SavedObjectsExportOptions } from '../export';
import { SavedObjectsImportOptions, SavedObjectsImportResponse } from '../import';
@ -31,10 +31,10 @@ import { SavedObjectsResolveImportErrorsOptions } from '../import/types';
*/
export interface SavedObjectsLegacyService<Request = any> {
// ATTENTION: these types are incomplete
addScopedSavedObjectsClientWrapperFactory: ScopedSavedObjectsClientProvider<
addScopedSavedObjectsClientWrapperFactory: SavedObjectsClientProvider<
Request
>['addClientWrapperFactory'];
getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider<Request>['getClient'];
getScopedSavedObjectsClient: SavedObjectsClientProvider<Request>['getClient'];
SavedObjectsClient: typeof SavedObjectsClient;
types: string[];
schema: SavedObjectsSchema;
@ -51,12 +51,12 @@ export interface SavedObjectsLegacyService<Request = any> {
export {
SavedObjectsRepository,
ScopedSavedObjectsClientProvider,
SavedObjectsClientProvider,
ISavedObjectsClientProvider,
SavedObjectsClientProviderOptions,
SavedObjectsClientWrapperFactory,
SavedObjectsClientWrapperOptions,
SavedObjectsErrorHelpers,
SavedObjectsCacheIndexPatterns,
} from './lib';
export * from './saved_objects_client';

View file

@ -1,108 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObjectsCacheIndexPatterns } from './cache_index_patterns';
const mockGetFieldsForWildcard = jest.fn();
const mockIndexPatternsService: jest.Mock = jest.fn().mockImplementation(() => ({
getFieldsForWildcard: mockGetFieldsForWildcard,
getFieldsForTimePattern: jest.fn(),
}));
describe('SavedObjectsRepository', () => {
let cacheIndexPatterns: SavedObjectsCacheIndexPatterns;
const fields = [
{
aggregatable: true,
name: 'config.type',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'foo.type',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'bar.type',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'baz.type',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'dashboard.otherField',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'hiddenType.someField',
searchable: true,
type: 'string',
},
];
beforeEach(() => {
cacheIndexPatterns = new SavedObjectsCacheIndexPatterns();
jest.clearAllMocks();
});
it('setIndexPatterns should return an error object when indexPatternsService is undefined', async () => {
try {
await cacheIndexPatterns.setIndexPatterns('test-index');
} catch (error) {
expect(error.message).toMatch('indexPatternsService is not defined');
}
});
it('setIndexPatterns should return an error object if getFieldsForWildcard is not defined', async () => {
mockGetFieldsForWildcard.mockImplementation(() => {
throw new Error('something happen');
});
try {
cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService());
await cacheIndexPatterns.setIndexPatterns('test-index');
} catch (error) {
expect(error.message).toMatch('Index Pattern Error - something happen');
}
});
it('setIndexPatterns should return empty array when getFieldsForWildcard is returning null or undefined', async () => {
mockGetFieldsForWildcard.mockImplementation(() => null);
cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService());
await cacheIndexPatterns.setIndexPatterns('test-index');
expect(cacheIndexPatterns.getIndexPatterns()).toEqual(undefined);
});
it('setIndexPatterns should return index pattern when getFieldsForWildcard is returning fields', async () => {
mockGetFieldsForWildcard.mockImplementation(() => fields);
cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService());
await cacheIndexPatterns.setIndexPatterns('test-index');
expect(cacheIndexPatterns.getIndexPatterns()).toEqual({ fields, title: 'test-index' });
});
});

View file

@ -1,82 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FieldDescriptor } from 'src/legacy/server/index_patterns/service/index_patterns_service';
import { IndexPatternsService } from 'src/legacy/server/index_patterns';
export interface SavedObjectsIndexPatternField {
name: string;
type: string;
aggregatable: boolean;
searchable: boolean;
}
export interface SavedObjectsIndexPattern {
fields: SavedObjectsIndexPatternField[];
title: string;
}
export class SavedObjectsCacheIndexPatterns {
private _indexPatterns: SavedObjectsIndexPattern | undefined = undefined;
private _indexPatternsService: IndexPatternsService | undefined = undefined;
public setIndexPatternsService(indexPatternsService: IndexPatternsService) {
this._indexPatternsService = indexPatternsService;
}
public getIndexPatternsService() {
return this._indexPatternsService;
}
public getIndexPatterns(): SavedObjectsIndexPattern | undefined {
return this._indexPatterns;
}
public async setIndexPatterns(index: string) {
await this._getIndexPattern(index);
}
private async _getIndexPattern(index: string) {
try {
if (this._indexPatternsService == null) {
throw new TypeError('indexPatternsService is not defined');
}
const fieldsDescriptor: FieldDescriptor[] = await this._indexPatternsService.getFieldsForWildcard(
{
pattern: index,
}
);
this._indexPatterns =
fieldsDescriptor && Array.isArray(fieldsDescriptor) && fieldsDescriptor.length > 0
? {
fields: fieldsDescriptor.map(field => ({
aggregatable: field.aggregatable,
name: field.name,
searchable: field.searchable,
type: field.type,
})),
title: index,
}
: undefined;
} catch (err) {
throw new Error(`Index Pattern Error - ${err.message}`);
}
}
}

View file

@ -19,66 +19,51 @@
import { fromKueryExpression } from '@kbn/es-query';
import {
validateFilterKueryNode,
getSavedObjectTypeIndexPatterns,
validateConvertFilterToKueryNode,
} from './filter_utils';
import { SavedObjectsIndexPattern } from './cache_index_patterns';
import { validateFilterKueryNode, validateConvertFilterToKueryNode } from './filter_utils';
const mockIndexPatterns: SavedObjectsIndexPattern = {
fields: [
{
name: 'updatedAt',
const mockMappings = {
properties: {
updatedAt: {
type: 'date',
aggregatable: true,
searchable: true,
},
{
name: 'foo.title',
type: 'text',
aggregatable: true,
searchable: true,
foo: {
properties: {
title: {
type: 'text',
},
description: {
type: 'text',
},
bytes: {
type: 'number',
},
},
},
{
name: 'foo.description',
type: 'text',
aggregatable: true,
searchable: true,
bar: {
properties: {
foo: {
type: 'text',
},
description: {
type: 'text',
},
},
},
{
name: 'foo.bytes',
type: 'number',
aggregatable: true,
searchable: true,
hiddenType: {
properties: {
description: {
type: 'text',
},
},
},
{
name: 'bar.foo',
type: 'text',
aggregatable: true,
searchable: true,
},
{
name: 'bar.description',
type: 'text',
aggregatable: true,
searchable: true,
},
{
name: 'hiddentype.description',
type: 'text',
aggregatable: true,
searchable: true,
},
],
title: 'mock',
},
};
describe('Filter Utils', () => {
describe('#validateConvertFilterToKueryNode', () => {
test('Validate a simple filter', () => {
expect(
validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockIndexPatterns)
validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockMappings)
).toEqual(fromKueryExpression('foo.title: "best"'));
});
test('Assemble filter kuery node saved object attributes with one saved object type', () => {
@ -86,7 +71,7 @@ describe('Filter Utils', () => {
validateConvertFilterToKueryNode(
['foo'],
'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockIndexPatterns
mockMappings
)
).toEqual(
fromKueryExpression(
@ -100,7 +85,7 @@ describe('Filter Utils', () => {
validateConvertFilterToKueryNode(
['foo', 'bar'],
'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockIndexPatterns
mockMappings
)
).toEqual(
fromKueryExpression(
@ -114,7 +99,7 @@ describe('Filter Utils', () => {
validateConvertFilterToKueryNode(
['foo', 'bar'],
'(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)',
mockIndexPatterns
mockMappings
)
).toEqual(
fromKueryExpression(
@ -128,7 +113,7 @@ describe('Filter Utils', () => {
validateConvertFilterToKueryNode(
['foo', 'bar'],
'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)',
mockIndexPatterns
mockMappings
);
}).toThrowErrorMatchingInlineSnapshot(
`"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"`
@ -137,7 +122,7 @@ describe('Filter Utils', () => {
test('Lets make sure that we are throwing an exception if we are using hiddentype with types', () => {
expect(() => {
validateConvertFilterToKueryNode([], 'hiddentype.title: "title"', mockIndexPatterns);
validateConvertFilterToKueryNode([], 'hiddentype.title: "title"', mockMappings);
}).toThrowErrorMatchingInlineSnapshot(`"This type hiddentype is not allowed: Bad Request"`);
});
});
@ -149,7 +134,7 @@ describe('Filter Utils', () => {
'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
['foo'],
getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns)
mockMappings
);
expect(validationObject).toEqual([
@ -204,7 +189,7 @@ describe('Filter Utils', () => {
'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
['foo'],
getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns)
mockMappings
);
expect(validationObject).toEqual([
@ -259,7 +244,7 @@ describe('Filter Utils', () => {
'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)'
),
['foo'],
getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns)
mockMappings
);
expect(validationObject).toEqual([
@ -316,7 +301,7 @@ describe('Filter Utils', () => {
'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
['foo'],
getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns)
mockMappings
);
expect(validationObject).toEqual([
@ -371,7 +356,7 @@ describe('Filter Utils', () => {
'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)'
),
['foo'],
getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns)
mockMappings
);
expect(validationObject).toEqual([
@ -421,37 +406,4 @@ describe('Filter Utils', () => {
]);
});
});
describe('#getSavedObjectTypeIndexPatterns', () => {
test('Get index patterns related to your type', () => {
const indexPatternsFilterByType = getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns);
expect(indexPatternsFilterByType).toEqual([
{
name: 'updatedAt',
type: 'date',
aggregatable: true,
searchable: true,
},
{
name: 'foo.title',
type: 'text',
aggregatable: true,
searchable: true,
},
{
name: 'foo.description',
type: 'text',
aggregatable: true,
searchable: true,
},
{
name: 'foo.bytes',
type: 'number',
aggregatable: true,
searchable: true,
},
]);
});
});
});

View file

@ -19,23 +19,21 @@
import { fromKueryExpression, KueryNode, nodeTypes } from '@kbn/es-query';
import { get, set } from 'lodash';
import { SavedObjectsIndexPattern, SavedObjectsIndexPatternField } from './cache_index_patterns';
import { SavedObjectsErrorHelpers } from './errors';
import { IndexMapping } from '../../mappings';
export const validateConvertFilterToKueryNode = (
types: string[],
allowedTypes: string[],
filter: string,
indexPattern: SavedObjectsIndexPattern | undefined
indexMapping: IndexMapping
): KueryNode => {
if (filter && filter.length > 0 && indexPattern) {
if (filter && filter.length > 0 && indexMapping) {
const filterKueryNode = fromKueryExpression(filter);
const typeIndexPatterns = getSavedObjectTypeIndexPatterns(types, indexPattern);
const validationFilterKuery = validateFilterKueryNode(
filterKueryNode,
types,
typeIndexPatterns,
allowedTypes,
indexMapping,
filterKueryNode.type === 'function' && ['is', 'range'].includes(filterKueryNode.function)
);
@ -60,7 +58,7 @@ export const validateConvertFilterToKueryNode = (
path.length === 0 ? filterKueryNode : get(filterKueryNode, path);
if (item.isSavedObjectAttr) {
existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.split('.')[1];
const itemType = types.filter(t => t === item.type);
const itemType = allowedTypes.filter(t => t === item.type);
if (itemType.length === 1) {
set(
filterKueryNode,
@ -84,18 +82,6 @@ export const validateConvertFilterToKueryNode = (
return null;
};
export const getSavedObjectTypeIndexPatterns = (
types: string[],
indexPattern: SavedObjectsIndexPattern | undefined
): SavedObjectsIndexPatternField[] => {
return indexPattern != null
? indexPattern.fields.filter(
ip =>
!ip.name.includes('.') || (ip.name.includes('.') && types.includes(ip.name.split('.')[0]))
)
: [];
};
interface ValidateFilterKueryNode {
astPath: string;
error: string;
@ -107,7 +93,7 @@ interface ValidateFilterKueryNode {
export const validateFilterKueryNode = (
astFilter: KueryNode,
types: string[],
typeIndexPatterns: SavedObjectsIndexPatternField[],
indexMapping: IndexMapping,
storeValue: boolean = false,
path: string = 'arguments'
): ValidateFilterKueryNode[] => {
@ -119,7 +105,7 @@ export const validateFilterKueryNode = (
...validateFilterKueryNode(
ast,
types,
typeIndexPatterns,
indexMapping,
ast.type === 'function' && ['is', 'range'].includes(ast.function),
`${myPath}.arguments`
),
@ -131,8 +117,8 @@ export const validateFilterKueryNode = (
...kueryNode,
{
astPath: splitPath.slice(0, splitPath.length - 1).join('.'),
error: hasFilterKeyError(ast.value, types, typeIndexPatterns),
isSavedObjectAttr: isSavedObjectAttr(ast.value, typeIndexPatterns),
error: hasFilterKeyError(ast.value, types, indexMapping),
isSavedObjectAttr: isSavedObjectAttr(ast.value, indexMapping),
key: ast.value,
type: getType(ast.value),
},
@ -144,47 +130,55 @@ export const validateFilterKueryNode = (
const getType = (key: string) => (key.includes('.') ? key.split('.')[0] : null);
export const isSavedObjectAttr = (
key: string,
typeIndexPatterns: SavedObjectsIndexPatternField[]
) => {
const splitKey = key.split('.');
if (splitKey.length === 1 && typeIndexPatterns.some(tip => tip.name === splitKey[0])) {
/**
* Is this filter key referring to a a top-level SavedObject attribute such as
* `updated_at` or `references`.
*
* @param key
* @param indexMapping
*/
export const isSavedObjectAttr = (key: string, indexMapping: IndexMapping) => {
const keySplit = key.split('.');
if (keySplit.length === 1 && fieldDefined(indexMapping, keySplit[0])) {
return true;
} else if (splitKey.length > 1 && typeIndexPatterns.some(tip => tip.name === splitKey[1])) {
} else if (keySplit.length === 2 && fieldDefined(indexMapping, keySplit[1])) {
return true;
} else {
return false;
}
return false;
};
export const hasFilterKeyError = (
key: string,
types: string[],
typeIndexPatterns: SavedObjectsIndexPatternField[]
indexMapping: IndexMapping
): string | null => {
if (!key.includes('.')) {
return `This key '${key}' need to be wrapped by a saved object type like ${types.join()}`;
} else if (key.includes('.')) {
const keySplit = key.split('.');
if (keySplit.length <= 1 || !types.includes(keySplit[0])) {
return `This type ${keySplit[0]} is not allowed`;
}
if (
(keySplit.length === 2 && typeIndexPatterns.some(tip => tip.name === key)) ||
(keySplit.length > 2 && types.includes(keySplit[0]) && keySplit[1] !== 'attributes')
(keySplit.length === 2 && fieldDefined(indexMapping, key)) ||
(keySplit.length > 2 && keySplit[1] !== 'attributes')
) {
return `This key '${key}' does NOT match the filter proposition SavedObjectType.attributes.key`;
}
if (
(keySplit.length === 2 && !typeIndexPatterns.some(tip => tip.name === keySplit[1])) ||
(keySplit.length === 2 && !fieldDefined(indexMapping, keySplit[1])) ||
(keySplit.length > 2 &&
!typeIndexPatterns.some(
tip =>
tip.name === [...keySplit.slice(0, 1), ...keySplit.slice(2, keySplit.length)].join('.')
))
!fieldDefined(indexMapping, keySplit[0] + '.' + keySplit.slice(2, keySplit.length)))
) {
return `This key '${key}' does NOT exist in ${types.join()} saved object index patterns`;
}
}
return null;
};
const fieldDefined = (indexMappings: IndexMapping, key: string) => {
const mappingKey = 'properties.' + key.split('.').join('.properties.');
return get(indexMappings, mappingKey) != null;
};

View file

@ -21,10 +21,9 @@ export { SavedObjectsRepository, SavedObjectsRepositoryOptions } from './reposit
export {
SavedObjectsClientWrapperFactory,
SavedObjectsClientWrapperOptions,
ScopedSavedObjectsClientProvider,
ISavedObjectsClientProvider,
SavedObjectsClientProvider,
SavedObjectsClientProviderOptions,
} from './scoped_client_provider';
export { SavedObjectsErrorHelpers } from './errors';
export { SavedObjectsCacheIndexPatterns } from './cache_index_patterns';

View file

@ -273,10 +273,6 @@ describe('SavedObjectsRepository', () => {
savedObjectsRepository = new SavedObjectsRepository({
index: '.kibana-test',
cacheIndexPatterns: {
setIndexPatterns: jest.fn(),
getIndexPatterns: () => undefined,
},
mappings,
callCluster: callAdminCluster,
migrator,
@ -290,8 +286,6 @@ describe('SavedObjectsRepository', () => {
getSearchDslNS.getSearchDsl.mockReset();
});
afterEach(() => { });
describe('#create', () => {
beforeEach(() => {
callAdminCluster.mockImplementation((method, params) => ({
@ -302,9 +296,7 @@ describe('SavedObjectsRepository', () => {
});
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
await expect(
savedObjectsRepository.create(
@ -557,9 +549,7 @@ describe('SavedObjectsRepository', () => {
describe('#bulkCreate', () => {
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
callAdminCluster.mockReturnValue({
items: [
{ create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } },
@ -998,14 +988,12 @@ describe('SavedObjectsRepository', () => {
expect(onBeforeWrite).toHaveBeenCalledTimes(1);
});
it('should return objects in the same order regardless of type', () => { });
it('should return objects in the same order regardless of type', () => {});
});
describe('#delete', () => {
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
callAdminCluster.mockReturnValue({ result: 'deleted' });
await expect(
savedObjectsRepository.delete('index-pattern', 'logstash-*', {
@ -1119,9 +1107,7 @@ describe('SavedObjectsRepository', () => {
describe('#find', () => {
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
callAdminCluster.mockReturnValue(noNamespaceSearchResults);
await expect(savedObjectsRepository.find({ type: 'foo' })).resolves.toBeDefined();
@ -1159,13 +1145,6 @@ describe('SavedObjectsRepository', () => {
}
});
it('requires index pattern to be defined if filter is defined', async () => {
callAdminCluster.mockReturnValue(noNamespaceSearchResults);
expect(savedObjectsRepository.find({ type: 'foo', filter: 'foo.type: hello' }))
.rejects
.toThrowErrorMatchingInlineSnapshot('"options.filter is missing index pattern to work correctly: Bad Request"');
});
it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl',
async () => {
callAdminCluster.mockReturnValue(namespacedSearchResults);
@ -1190,6 +1169,75 @@ describe('SavedObjectsRepository', () => {
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, schema, relevantOpts);
});
it('accepts KQL filter and passes keuryNode to getSearchDsl', async () => {
callAdminCluster.mockReturnValue(namespacedSearchResults);
const findOpts = {
namespace: 'foo-namespace',
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
hasReference: {
type: 'foo',
id: '1',
},
indexPattern: undefined,
filter: 'dashboard.attributes.otherField: *',
};
await savedObjectsRepository.find(findOpts);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1);
const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2];
expect(kueryNode).toMatchInlineSnapshot(`
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "dashboard.otherField",
},
Object {
"type": "wildcard",
"value": "@kuery-wildcard@",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
}
`);
});
it('KQL filter syntax errors rejects with bad request', async () => {
callAdminCluster.mockReturnValue(namespacedSearchResults);
const findOpts = {
namespace: 'foo-namespace',
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
sortField: 'name',
sortOrder: 'desc',
defaultSearchOperator: 'AND',
hasReference: {
type: 'foo',
id: '1',
},
indexPattern: undefined,
filter: 'dashboard.attributes.otherField:<',
};
await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(`
[Error: KQLSyntaxError: Expected "(", value, whitespace but "<" found.
dashboard.attributes.otherField:<
--------------------------------^: Bad Request]
`);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(0);
});
it('merges output of getSearchDsl into es request body', async () => {
callAdminCluster.mockReturnValue(noNamespaceSearchResults);
getSearchDslNS.getSearchDsl.mockReturnValue({ query: 1, aggregations: 2 });
@ -1329,9 +1377,7 @@ describe('SavedObjectsRepository', () => {
};
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
callAdminCluster.mockResolvedValue(noNamespaceResult);
await expect(
@ -1422,9 +1468,7 @@ describe('SavedObjectsRepository', () => {
describe('#bulkGet', () => {
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
callAdminCluster.mockReturnValue({ docs: [] });
await expect(
@ -1676,9 +1720,7 @@ describe('SavedObjectsRepository', () => {
});
it('waits until migrations are complete before proceeding', async () => {
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled());
await expect(
savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, {
@ -1745,11 +1787,7 @@ describe('SavedObjectsRepository', () => {
});
it('does not pass references if omitted', async () => {
await savedObjectsRepository.update(
type,
id,
{ title: 'Testing' }
);
await savedObjectsRepository.update(type, id, { title: 'Testing' });
expect(callAdminCluster).toHaveBeenCalledTimes(1);
expect(callAdminCluster).not.toHaveBeenCalledWith(
@ -1758,19 +1796,14 @@ describe('SavedObjectsRepository', () => {
body: {
doc: expect.objectContaining({
references: [],
})
}
}),
},
})
);
});
it('passes references if they are provided', async () => {
await savedObjectsRepository.update(
type,
id,
{ title: 'Testing' },
{ references: ['foo'] }
);
await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: ['foo'] });
expect(callAdminCluster).toHaveBeenCalledTimes(1);
expect(callAdminCluster).toHaveBeenCalledWith(
@ -1779,19 +1812,14 @@ describe('SavedObjectsRepository', () => {
body: {
doc: expect.objectContaining({
references: ['foo'],
})
}
}),
},
})
);
});
it('passes empty references array if empty references array is provided', async () => {
await savedObjectsRepository.update(
type,
id,
{ title: 'Testing' },
{ references: [] }
);
await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: [] });
expect(callAdminCluster).toHaveBeenCalledTimes(1);
expect(callAdminCluster).toHaveBeenCalledWith(
@ -1800,8 +1828,8 @@ describe('SavedObjectsRepository', () => {
body: {
doc: expect.objectContaining({
references: [],
})
}
}),
},
})
);
});

View file

@ -25,7 +25,6 @@ import { getSearchDsl } from './search_dsl';
import { includedFields } from './included_fields';
import { decorateEsError } from './decorate_es_error';
import { SavedObjectsErrorHelpers } from './errors';
import { SavedObjectsCacheIndexPatterns } from './cache_index_patterns';
import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version';
import { SavedObjectsSchema } from '../../schema';
import { KibanaMigrator } from '../../migrations';
@ -77,7 +76,6 @@ export interface SavedObjectsRepositoryOptions {
serializer: SavedObjectsSerializer;
migrator: KibanaMigrator;
allowedTypes: string[];
cacheIndexPatterns: SavedObjectsCacheIndexPatterns;
onBeforeWrite?: (...args: Parameters<CallCluster>) => Promise<void>;
}
@ -95,13 +93,11 @@ export class SavedObjectsRepository {
private _onBeforeWrite: (...args: Parameters<CallCluster>) => Promise<void>;
private _unwrappedCallCluster: CallCluster;
private _serializer: SavedObjectsSerializer;
private _cacheIndexPatterns: SavedObjectsCacheIndexPatterns;
constructor(options: SavedObjectsRepositoryOptions) {
const {
index,
config,
cacheIndexPatterns,
mappings,
callCluster,
schema,
@ -123,7 +119,6 @@ export class SavedObjectsRepository {
this._config = config;
this._mappings = mappings;
this._schema = schema;
this._cacheIndexPatterns = cacheIndexPatterns;
if (allowedTypes.length === 0) {
throw new Error('Empty or missing types for saved object repository!');
}
@ -133,9 +128,6 @@ export class SavedObjectsRepository {
this._unwrappedCallCluster = async (...args: Parameters<CallCluster>) => {
await migrator.runMigrations();
if (this._cacheIndexPatterns.getIndexPatterns() == null) {
await this._cacheIndexPatterns.setIndexPatterns(index);
}
return callCluster(...args);
};
this._schema = schema;
@ -441,21 +433,20 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createBadRequestError('options.fields must be an array');
}
if (filter && filter !== '' && this._cacheIndexPatterns.getIndexPatterns() == null) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.filter is missing index pattern to work correctly'
);
let kueryNode;
try {
kueryNode =
filter && filter !== ''
? validateConvertFilterToKueryNode(allowedTypes, filter, this._mappings)
: null;
} catch (e) {
if (e.name === 'KQLSyntaxError') {
throw SavedObjectsErrorHelpers.createBadRequestError('KQLSyntaxError: ' + e.message);
} else {
throw e;
}
}
const kueryNode =
filter && filter !== ''
? validateConvertFilterToKueryNode(
allowedTypes,
filter,
this._cacheIndexPatterns.getIndexPatterns()
)
: null;
const esOptions = {
index: this.getIndicesForTypes(allowedTypes),
size: perPage,
@ -474,7 +465,6 @@ export class SavedObjectsRepository {
sortOrder,
namespace,
hasReference,
indexPattern: kueryNode != null ? this._cacheIndexPatterns.getIndexPatterns() : undefined,
kueryNode,
}),
},

View file

@ -0,0 +1,30 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ISavedObjectsClientProvider } from './scoped_client_provider';
const create = (): jest.Mocked<ISavedObjectsClientProvider> => ({
addClientWrapperFactory: jest.fn(),
getClient: jest.fn(),
setClientFactory: jest.fn(),
});
export const savedObjectsClientProviderMock = {
create,
};

View file

@ -17,14 +17,14 @@
* under the License.
*/
import { ScopedSavedObjectsClientProvider } from './scoped_client_provider';
import { SavedObjectsClientProvider } from './scoped_client_provider';
test(`uses default client factory when one isn't set`, () => {
const returnValue = Symbol();
const defaultClientFactoryMock = jest.fn().mockReturnValue(returnValue);
const request = Symbol();
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
const result = clientProvider.getClient(request);
@ -42,7 +42,7 @@ test(`uses custom client factory when one is set`, () => {
const returnValue = Symbol();
const customClientFactoryMock = jest.fn().mockReturnValue(returnValue);
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
clientProvider.setClientFactory(customClientFactoryMock);
@ -57,7 +57,7 @@ test(`uses custom client factory when one is set`, () => {
});
test(`throws error when more than one scoped saved objects client factory is set`, () => {
const clientProvider = new ScopedSavedObjectsClientProvider({});
const clientProvider = new SavedObjectsClientProvider({});
clientProvider.setClientFactory(() => {});
expect(() => {
clientProvider.setClientFactory(() => {});
@ -66,7 +66,7 @@ test(`throws error when more than one scoped saved objects client factory is set
test(`throws error when registering a wrapper with a duplicate id`, () => {
const defaultClientFactoryMock = jest.fn();
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
const firstClientWrapperFactoryMock = jest.fn();
@ -81,7 +81,7 @@ test(`throws error when registering a wrapper with a duplicate id`, () => {
test(`invokes and uses wrappers in specified order`, () => {
const defaultClient = Symbol();
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
const firstWrappedClient = Symbol('first client');
@ -108,7 +108,7 @@ test(`invokes and uses wrappers in specified order`, () => {
test(`does not invoke or use excluded wrappers`, () => {
const defaultClient = Symbol();
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
const firstWrappedClient = Symbol('first client');
@ -135,7 +135,7 @@ test(`does not invoke or use excluded wrappers`, () => {
test(`allows all wrappers to be excluded`, () => {
const defaultClient = Symbol();
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
const clientProvider = new ScopedSavedObjectsClientProvider({
const clientProvider = new SavedObjectsClientProvider({
defaultClientFactory: defaultClientFactoryMock,
});
const firstWrappedClient = Symbol('first client');

View file

@ -55,9 +55,24 @@ export interface SavedObjectsClientProviderOptions {
}
/**
* Provider for the Scoped Saved Object Client.
* @public
* See {@link SavedObjectsClientProvider}
*/
export class ScopedSavedObjectsClientProvider<Request = unknown> {
export type ISavedObjectsClientProvider<T = unknown> = Pick<
SavedObjectsClientProvider<T>,
keyof SavedObjectsClientProvider
>;
/**
* Provider for the Scoped Saved Objects Client.
*
* @internalRemarks Because `getClient` is synchronous the Client Provider does
* not support creating factories that react to new ES clients emitted from
* elasticsearch.adminClient$. The Client Provider therefore doesn't support
* configuration changes to the Elasticsearch client. TODO: revisit once we've
* closed https://github.com/elastic/kibana/pull/45796
*/
export class SavedObjectsClientProvider<Request = unknown> {
private readonly _wrapperFactories = new PriorityCollection<{
id: string;
factory: SavedObjectsClientWrapperFactory<Request>;

View file

@ -18,7 +18,6 @@
*/
import { schemaMock } from '../../../schema/schema.mock';
import { SavedObjectsIndexPattern } from '../cache_index_patterns';
import { getQueryParams } from './query_params';
const SCHEMA = schemaMock.create();
@ -62,41 +61,6 @@ const MAPPINGS = {
},
},
};
const INDEX_PATTERN: SavedObjectsIndexPattern = {
fields: [
{
aggregatable: true,
name: 'type',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'pending.title',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'saved.title',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'saved.obj.key1',
searchable: true,
type: 'string',
},
{
aggregatable: true,
name: 'global.name',
searchable: true,
type: 'string',
},
],
title: 'test',
};
// create a type clause to be used within the "should", if a namespace is specified
// the clause will ensure the namespace matches; otherwise, the clause will ensure
@ -1005,7 +969,6 @@ describe('searchDsl/queryParams', () => {
{ type: 'literal', value: false },
],
},
indexPattern: INDEX_PATTERN,
})
).toEqual({
query: {
@ -1121,7 +1084,6 @@ describe('searchDsl/queryParams', () => {
},
],
},
indexPattern: INDEX_PATTERN,
})
).toEqual({
query: {
@ -1240,7 +1202,6 @@ describe('searchDsl/queryParams', () => {
{ type: 'literal', value: false },
],
},
indexPattern: INDEX_PATTERN,
})
).toEqual({
query: {

View file

@ -20,7 +20,6 @@ import { toElasticsearchQuery, KueryNode } from '@kbn/es-query';
import { getRootPropertiesObjects, IndexMapping } from '../../../mappings';
import { SavedObjectsSchema } from '../../../schema';
import { SavedObjectsIndexPattern } from '../cache_index_patterns';
/**
* Gets the types based on the type. Uses mappings to support
@ -93,7 +92,6 @@ interface QueryParams {
defaultSearchOperator?: string;
hasReference?: HasReferenceQueryParams;
kueryNode?: KueryNode;
indexPattern?: SavedObjectsIndexPattern;
}
/**
@ -109,12 +107,11 @@ export function getQueryParams({
defaultSearchOperator,
hasReference,
kueryNode,
indexPattern,
}: QueryParams) {
const types = getTypes(mappings, type);
const bool: any = {
filter: [
...(kueryNode != null ? [toElasticsearchQuery(kueryNode, indexPattern)] : []),
...(kueryNode != null ? [toElasticsearchQuery(kueryNode)] : []),
{
bool: {
must: hasReference

View file

@ -24,7 +24,6 @@ import { IndexMapping } from '../../../mappings';
import { SavedObjectsSchema } from '../../../schema';
import { getQueryParams } from './query_params';
import { getSortingParams } from './sorting_params';
import { SavedObjectsIndexPattern } from '../cache_index_patterns';
interface GetSearchDslOptions {
type: string | string[];
@ -39,7 +38,6 @@ interface GetSearchDslOptions {
id: string;
};
kueryNode?: KueryNode;
indexPattern?: SavedObjectsIndexPattern;
}
export function getSearchDsl(
@ -57,7 +55,6 @@ export function getSearchDsl(
namespace,
hasReference,
kueryNode,
indexPattern,
} = options;
if (!type) {
@ -79,7 +76,6 @@ export function getSearchDsl(
defaultSearchOperator,
hasReference,
kueryNode,
indexPattern,
}),
...getSortingParams(mappings, type, sortField, sortOrder),
};

View file

@ -119,7 +119,7 @@ export interface SavedObjectsUpdateResponse<T extends SavedObjectAttributes = an
/**
*
* @internal
* @public
*/
export class SavedObjectsClient {
public static errors = SavedObjectsErrorHelpers;

View file

@ -208,6 +208,7 @@ export interface SavedObjectsBaseOptions {
* so we throw a special 503 with the intention of informing the user that their
* Elasticsearch settings need to be updated.
*
* See {@link SavedObjectsClient}
* See {@link SavedObjectsErrorHelpers}
*
* @public

View file

@ -50,7 +50,6 @@ import { GetSourceParams } from 'elasticsearch';
import { GetTemplateParams } from 'elasticsearch';
import { IncomingHttpHeaders } from 'http';
import { IndexDocumentParams } from 'elasticsearch';
import { IndexPatternsService } from 'src/legacy/server/index_patterns';
import { IndicesAnalyzeParams } from 'elasticsearch';
import { IndicesClearCacheParams } from 'elasticsearch';
import { IndicesCloseParams } from 'elasticsearch';
@ -465,7 +464,7 @@ export function bootstrap({ configs, cliArgs, applyConfigOverrides, features, }:
// @public
export interface CallAPIOptions {
signal?: AbortSignal;
wrap401Errors: boolean;
wrap401Errors?: boolean;
}
// @public
@ -1041,6 +1040,9 @@ export type RequestHandler<P extends ObjectType, Q extends ObjectType, B extends
export interface RequestHandlerContext {
// (undocumented)
core: {
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
@ -1163,7 +1165,7 @@ export interface SavedObjectsBulkResponse<T extends SavedObjectAttributes = any>
saved_objects: Array<SavedObject<T>>;
}
// @internal (undocumented)
// @public (undocumented)
export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsRepository" needs to be exported by the entry point index.d.ts
constructor(repository: SavedObjectsRepository);
@ -1180,8 +1182,6 @@ export class SavedObjectsClient {
update<T extends SavedObjectAttributes = any>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
}
// Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClientContract" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal
//
// @public
export type SavedObjectsClientContract = Pick<SavedObjectsClient, keyof SavedObjectsClient>;
@ -1407,14 +1407,14 @@ export interface SavedObjectsImportUnsupportedTypeError {
// @internal @deprecated (undocumented)
export interface SavedObjectsLegacyService<Request = any> {
// Warning: (ae-forgotten-export) The symbol "ScopedSavedObjectsClientProvider" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "SavedObjectsClientProvider" needs to be exported by the entry point index.d.ts
//
// (undocumented)
addScopedSavedObjectsClientWrapperFactory: ScopedSavedObjectsClientProvider<Request>['addClientWrapperFactory'];
addScopedSavedObjectsClientWrapperFactory: SavedObjectsClientProvider<Request>['addClientWrapperFactory'];
// (undocumented)
getSavedObjectsRepository(...rest: any[]): any;
// (undocumented)
getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider<Request>['getClient'];
getScopedSavedObjectsClient: SavedObjectsClientProvider<Request>['getClient'];
// (undocumented)
importExport: {
objectLimit: number;

View file

@ -21,7 +21,7 @@ import { take } from 'rxjs/operators';
import { Type } from '@kbn/config-schema';
import { ConfigService, Env, Config, ConfigPath } from './config';
import { ElasticsearchService } from './elasticsearch';
import { ElasticsearchService, ElasticsearchServiceSetup } from './elasticsearch';
import { HttpService, InternalHttpServiceSetup } from './http';
import { LegacyService } from './legacy';
import { Logger, LoggerFactory } from './logging';
@ -36,7 +36,8 @@ import { config as kibanaConfig } from './kibana_config';
import { config as savedObjectsConfig } from './saved_objects';
import { mapToObject } from '../utils/';
import { ContextService } from './context';
import { InternalCoreSetup } from './index';
import { SavedObjectsServiceSetup } from './saved_objects/saved_objects_service';
import { RequestHandlerContext } from '.';
const coreId = Symbol('core');
@ -94,7 +95,6 @@ export class Server {
http: httpSetup,
};
this.registerCoreContext(coreSetup);
const pluginsSetup = await this.plugins.setup(coreSetup);
const legacySetup = await this.legacy.setup({
@ -102,11 +102,13 @@ export class Server {
plugins: mapToObject(pluginsSetup.contracts),
});
await this.savedObjects.setup({
const savedObjectsSetup = await this.savedObjects.setup({
elasticsearch: elasticsearchServiceSetup,
legacy: legacySetup,
});
this.registerCoreContext({ ...coreSetup, savedObjects: savedObjectsSetup });
return coreSetup;
}
@ -147,17 +149,30 @@ export class Server {
);
}
private registerCoreContext(coreSetup: InternalCoreSetup) {
coreSetup.http.registerRouteHandlerContext(coreId, 'core', async (context, req) => {
const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise();
const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise();
return {
elasticsearch: {
adminClient: adminClient.asScoped(req),
dataClient: dataClient.asScoped(req),
},
};
});
private registerCoreContext(coreSetup: {
http: InternalHttpServiceSetup;
elasticsearch: ElasticsearchServiceSetup;
savedObjects: SavedObjectsServiceSetup;
}) {
coreSetup.http.registerRouteHandlerContext(
coreId,
'core',
async (context, req): Promise<RequestHandlerContext['core']> => {
const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise();
const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise();
return {
savedObjects: {
// Note: the client provider doesn't support new ES clients
// emitted from adminClient$
client: coreSetup.savedObjects.clientProvider.getClient(req),
},
elasticsearch: {
adminClient: adminClient.asScoped(req),
dataClient: dataClient.asScoped(req),
},
};
}
);
}
public async setupConfigSchemas() {

View file

@ -19,15 +19,11 @@
// Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete
/* eslint-disable @kbn/eslint/no-restricted-paths */
import { first } from 'rxjs/operators';
import { SavedObjectsSchema } from '../../../core/server/saved_objects/schema';
import { SavedObjectsSerializer } from '../../../core/server/saved_objects/serialization';
import {
SavedObjectsClient,
SavedObjectsRepository,
ScopedSavedObjectsClientProvider,
SavedObjectsCacheIndexPatterns,
getSortedObjectsForExport,
importSavedObjects,
resolveImportErrors,
@ -65,7 +61,6 @@ export async function savedObjectsMixin(kbnServer, server) {
const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas);
const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type));
const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes });
const cacheIndexPatterns = new SavedObjectsCacheIndexPatterns();
server.decorate('server', 'kibanaMigrator', migrator);
server.decorate(
@ -104,15 +99,6 @@ export async function savedObjectsMixin(kbnServer, server) {
const serializer = new SavedObjectsSerializer(schema);
if (cacheIndexPatterns.getIndexPatternsService() == null) {
const adminClient = await server.newPlatform.__internals.elasticsearch.adminClient$
.pipe(first())
.toPromise();
cacheIndexPatterns.setIndexPatternsService(
server.indexPatternsServiceFactory({ callCluster: adminClient.callAsInternalUser })
);
}
const createRepository = (callCluster, extraTypes = []) => {
if (typeof callCluster !== 'function') {
throw new TypeError('Repository requires a "callCluster" function to be provided.');
@ -131,7 +117,6 @@ export async function savedObjectsMixin(kbnServer, server) {
return new SavedObjectsRepository({
index: config.get('kibana.index'),
config,
cacheIndexPatterns,
migrator,
mappings,
schema,
@ -141,17 +126,7 @@ export async function savedObjectsMixin(kbnServer, server) {
});
};
const provider = new ScopedSavedObjectsClientProvider({
index: server.config().get('kibana.index'),
mappings,
defaultClientFactory({ request }) {
const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin');
const callCluster = (...args) => callWithRequest(request, ...args);
const repository = createRepository(callCluster);
return new SavedObjectsClient(repository);
},
});
const provider = kbnServer.newPlatform.__internals.savedObjectsClientProvider;
const service = {
types: visibleTypes,

View file

@ -20,6 +20,8 @@
import { savedObjectsMixin } from './saved_objects_mixin';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { mockKibanaMigrator } from '../../../core/server/saved_objects/migrations/kibana/kibana_migrator.mock';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { savedObjectsClientProviderMock } from '../../../core/server/saved_objects/service/lib/scoped_client_provider.mock';
const savedObjectMappings = [
{
@ -75,6 +77,7 @@ describe('Saved Objects Mixin', () => {
});
beforeEach(() => {
const clientProvider = savedObjectsClientProviderMock.create();
mockServer = {
log: jest.fn(),
route: jest.fn(),
@ -115,7 +118,7 @@ describe('Saved Objects Mixin', () => {
};
mockKbnServer = {
newPlatform: {
__internals: { kibanaMigrator: migrator },
__internals: { kibanaMigrator: migrator, savedObjectsClientProvider: clientProvider },
},
server: mockServer,
ready: () => {},
@ -290,9 +293,8 @@ describe('Saved Objects Mixin', () => {
});
describe('get client', () => {
it('should return a valid client object', () => {
const client = service.getScopedSavedObjectsClient();
expect(client).toBeDefined();
it('should have a method to get the client', () => {
expect(service).toHaveProperty('getScopedSavedObjectsClient');
});
it('should have a method to set the client factory', () => {
@ -314,21 +316,6 @@ describe('Saved Objects Mixin', () => {
service.addScopedSavedObjectsClientWrapperFactory({});
}).not.toThrowError();
});
it('should call underlining callCluster', async () => {
mockCallCluster.mockImplementation(method => {
if (method === 'indices.get') {
return { status: 404 };
} else if (method === 'indices.getAlias') {
return { status: 404 };
} else if (method === 'cat.templates') {
return [];
}
});
const client = await service.getScopedSavedObjectsClient();
await client.create('testtype');
expect(mockCallCluster).toHaveBeenCalled();
});
});
describe('#getSavedObjectsClient', () => {

View file

@ -19,7 +19,7 @@
import { httpServiceMock, httpServerMock } from '../../../../../src/core/server/mocks';
import { registerSearchRoute } from './routes';
import { IRouter, ScopedClusterClient } from 'kibana/server';
import { IRouter, ScopedClusterClient, RequestHandlerContext } from 'kibana/server';
describe('Search service', () => {
let routerMock: jest.Mocked<IRouter>;
@ -56,7 +56,7 @@ describe('Search service', () => {
registerSearchRoute(routerMock);
const handler = routerMock.post.mock.calls[0][1];
await handler(mockContext, mockRequest, mockResponse);
await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
expect(mockSearch).toBeCalled();
expect(mockSearch.mock.calls[0][0]).toStrictEqual(mockBody);
@ -88,7 +88,7 @@ describe('Search service', () => {
registerSearchRoute(routerMock);
const handler = routerMock.post.mock.calls[0][1];
await handler(mockContext, mockRequest, mockResponse);
await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);
expect(mockSearch).toBeCalled();
expect(mockSearch.mock.calls[0][0]).toStrictEqual(mockBody);

View file

@ -43,10 +43,21 @@ class Plugin {
);
const router = core.http.createRouter();
router.get({ path: '/ping', validate: false }, async (context, req, res) => {
const response = await context.core.elasticsearch.adminClient.callAsInternalUser('ping');
return res.ok({ body: `Pong: ${response}` });
});
router.get(
{ path: '/requestcontext/elasticsearch', validate: false },
async (context, req, res) => {
const response = await context.core.elasticsearch.adminClient.callAsInternalUser('ping');
return res.ok({ body: `Elasticsearch: ${response}` });
}
);
router.get(
{ path: '/requestcontext/savedobjectsclient', validate: false },
async (context, req, res) => {
const response = await context.core.savedObjects.client.find({ type: 'TYPE' });
return res.ok({ body: `SavedObjects client: ${JSON.stringify(response)}` });
}
);
return {
data$: this.initializerContext.config.create<ConfigType>().pipe(

View file

@ -20,11 +20,17 @@
export default function ({ getService }) {
const supertest = getService('supertest');
describe('core', () => {
it('provides access to request context', async () => (
describe('core request context', () => {
it('provides access to elasticsearch', async () => (
await supertest
.get('/testbed/ping')
.expect(200, 'Pong: true')
.get('/requestcontext/elasticsearch')
.expect(200, 'Elasticsearch: true')
));
it('provides access to SavedObjects client', async () => (
await supertest
.get('/requestcontext/savedobjectsclient')
.expect(200, 'SavedObjects client: {"page":1,"per_page":20,"total":0,"saved_objects":[]}')
));
});
}

View file

@ -19,6 +19,7 @@
export default function ({ loadTestFile }) {
describe('apis', () => {
loadTestFile(require.resolve('./core'));
loadTestFile(require.resolve('./elasticsearch'));
loadTestFile(require.resolve('./general'));
loadTestFile(require.resolve('./home'));

View file

@ -165,6 +165,22 @@ export default function ({ getService }) {
});
})
));
it('KQL syntax error should return 400 with Bad Request', async () => (
await supertest
.get('/api/saved_objects/_find?type=dashboard&filter=dashboard.attributes.title:foo<invalid')
.expect(400)
.then(resp => {
console.log('body', JSON.stringify(resp.body));
expect(resp.body).to.eql({
error: 'Bad Request',
message: 'KQLSyntaxError: Expected AND, OR, end of input, ' +
'whitespace but \"<\" found.\ndashboard.attributes.title:foo' +
'<invalid\n------------------------------^: Bad Request',
statusCode: 400,
});
})
));
});
});