[Security][Lists] Add API functions and react hooks for value list APIs (#69603)

* Add pure API functions and react hooks for value list APIs

This also adds a generic hook, useAsyncTask, that wraps an async
function to provide basic utilities:
  * loading state
  * error state
  * abort/cancel function

* Fix type errors in hook tests

These were not caught locally as I was accidentally running typescript
without the full project.

* Document current limitations of useAsyncTask

* Defines a new validation function that returns an Either instead of a tuple

This allows callers to further leverage fp-ts functions as needed.

* Remove duplicated copyright comment

* WIP: Perform request/response validations in the FP style

* leverages new validateEither fn which returns an Either
* constructs a pipeline that:
  * validates the payload
  * performs the API call
  * validates the response
and short-circuits if any of those produce a Left value.

It then converts the Either into a promise that either rejects with the
Left or resolves with the Right.

* Adds helper function to convert a TaskEither back to a Promise

This cleans up our validation pipeline considerably.

* Adds request/response validations to findLists

* refactors private API functions to accept the encoded request schema
(i.e. snake cased)
* refactors validateEither to use `schema.validate` instead of
`schema.decode` since we don't actually want the decoded value, we just
want to verify that it'll be able to be decoded on the backend.

* Refactor our API types

* Add request/response validation to import/export functions

* Fix type errors

* Continue to export decoded types without a qualifier
* pull types used by hooks from their new location
* Fix errors with usage of act()

* Attempting to reduce plugin bundle size

By pulling from the module directly instead of an index, we can
hopefully narrow down our dependencies until tree-shaking does this for
us.

* useAsyncFn's initiator does not return a promise

Rather than returning a promise and requiring the caller to handle a
rejection, we instead return nothing and require the user to watch the
hook's state.

* success can be handled with a useEffect on state.result
* errors can be handled with a useEffect on state.error

* Fix failing test

Assertion count wasn't updated following interface changes; we've now
got two inline expectations so this isn't needed.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Ryland Herrick 2020-06-29 20:02:39 -05:00 committed by GitHub
parent 771f3ae098
commit 590fc8d2ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1040 additions and 17 deletions

View file

@ -17,3 +17,4 @@ export const deleteListSchema = t.exact(
);
export type DeleteListSchema = t.TypeOf<typeof deleteListSchema>;
export type DeleteListSchemaEncoded = t.OutputOf<typeof deleteListSchema>;

View file

@ -18,3 +18,4 @@ export const exportListItemQuerySchema = t.exact(
);
export type ExportListItemQuerySchema = t.TypeOf<typeof exportListItemQuerySchema>;
export type ExportListItemQuerySchemaEncoded = t.OutputOf<typeof exportListItemQuerySchema>;

View file

@ -9,7 +9,6 @@
import * as t from 'io-ts';
import { cursor, filter, sort_field, sort_order } from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
import { StringToPositiveNumber } from '../types/string_to_positive_number';
export const findListSchema = t.exact(
@ -23,6 +22,5 @@ export const findListSchema = t.exact(
})
);
export type FindListSchemaPartial = t.TypeOf<typeof findListSchema>;
export type FindListSchema = RequiredKeepUndefined<t.TypeOf<typeof findListSchema>>;
export type FindListSchema = t.TypeOf<typeof findListSchema>;
export type FindListSchemaEncoded = t.OutputOf<typeof findListSchema>;

View file

@ -9,11 +9,11 @@
import * as t from 'io-ts';
import { list_id, type } from '../common/schemas';
import { Identity, RequiredKeepUndefined } from '../../types';
import { Identity } from '../../types';
export const importListItemQuerySchema = t.exact(t.partial({ list_id, type }));
export type ImportListItemQuerySchemaPartial = Identity<t.TypeOf<typeof importListItemQuerySchema>>;
export type ImportListItemQuerySchema = RequiredKeepUndefined<
t.TypeOf<typeof importListItemQuerySchema>
>;
export type ImportListItemQuerySchema = t.TypeOf<typeof importListItemQuerySchema>;
export type ImportListItemQuerySchemaEncoded = t.OutputOf<typeof importListItemQuerySchema>;

View file

@ -17,3 +17,4 @@ export const importListItemSchema = t.exact(
);
export type ImportListItemSchema = t.TypeOf<typeof importListItemSchema>;
export type ImportListItemSchemaEncoded = t.OutputOf<typeof importListItemSchema>;

View file

@ -9,5 +9,5 @@ export { DefaultUuid } from '../../security_solution/common/detection_engine/sch
export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array';
export { exactCheck } from '../../security_solution/common/exact_check';
export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils';
export { validate } from '../../security_solution/common/validate';
export { validate, validateEither } from '../../security_solution/common/validate';
export { formatErrors } from '../../security_solution/common/format_errors';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { tryCatch } from 'fp-ts/lib/TaskEither';
import { toPromise } from './fp_utils';
describe('toPromise', () => {
it('rejects with left if TaskEither is left', async () => {
const task = tryCatch(() => Promise.reject(new Error('whoops')), String);
await expect(toPromise(task)).rejects.toEqual('Error: whoops');
});
it('resolves with right if TaskEither is right', async () => {
const task = tryCatch(() => Promise.resolve('success'), String);
await expect(toPromise(task)).resolves.toEqual('success');
});
});

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import { TaskEither } from 'fp-ts/lib/TaskEither';
import { fold } from 'fp-ts/lib/Either';
export const toPromise = async <E, A>(taskEither: TaskEither<E, A>): Promise<A> =>
pipe(
await taskEither(),
fold(
(e) => Promise.reject(e),
(a) => Promise.resolve(a)
)
);

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useAsyncTask } from './use_async_task';
describe('useAsyncTask', () => {
let task: jest.Mock;
beforeEach(() => {
task = jest.fn().mockResolvedValue('resolved value');
});
it('does not invoke task if start was not called', () => {
renderHook(() => useAsyncTask(task));
expect(task).not.toHaveBeenCalled();
});
it('invokes the task when start is called', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task));
act(() => {
result.current.start({});
});
await waitForNextUpdate();
expect(task).toHaveBeenCalled();
});
it('invokes the task with a signal and start args', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task));
act(() => {
result.current.start({
arg1: 'value1',
arg2: 'value2',
});
});
await waitForNextUpdate();
expect(task).toHaveBeenCalledWith(expect.any(AbortController), {
arg1: 'value1',
arg2: 'value2',
});
});
it('populates result with the resolved value of the task', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task));
act(() => {
result.current.start({});
});
await waitForNextUpdate();
expect(result.current.result).toEqual('resolved value');
expect(result.current.error).toBeUndefined();
});
it('populates error if task rejects', async () => {
task.mockRejectedValue(new Error('whoops'));
const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task));
act(() => {
result.current.start({});
});
await waitForNextUpdate();
expect(result.current.result).toBeUndefined();
expect(result.current.error).toEqual(new Error('whoops'));
});
it('populates the loading state while the task is pending', async () => {
let resolve: () => void;
task.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve)));
const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task));
act(() => {
result.current.start({});
});
expect(result.current.loading).toBe(true);
act(() => resolve());
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useRef } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
// Params can be generalized to a ...rest parameter extending unknown[] once https://github.com/microsoft/TypeScript/pull/39094 is available.
// for now, the task must still receive unknown as a second argument, and an argument must be passed to start()
export type UseAsyncTask = <Result, Params extends unknown>(
task: (...args: [AbortController, Params]) => Promise<Result>
) => AsyncTask<Result, Params>;
export interface AsyncTask<Result, Params extends unknown> {
start: (params: Params) => void;
abort: () => void;
loading: boolean;
error: Error | undefined;
result: Result | undefined;
}
/**
*
* @param task Async function receiving an AbortController and optional arguments
*
* @returns An {@link AsyncTask} containing the underlying task's state along with start/abort helpers
*/
export const useAsyncTask: UseAsyncTask = (task) => {
const ctrl = useRef(new AbortController());
const abort = useCallback((): void => {
ctrl.current.abort();
}, []);
// @ts-ignore typings are incorrect, see: https://github.com/streamich/react-use/pull/589
const [state, initiator] = useAsyncFn(task, [task]);
const start = useCallback(
(args) => {
ctrl.current = new AbortController();
initiator(ctrl.current, args);
},
[initiator]
);
return { abort, error: state.error, loading: state.loading, result: state.value, start };
};

View file

@ -3,11 +3,16 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// Exports to be shared with plugins
export { useApi } from './exceptions/hooks/use_api';
export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item';
export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list';
export { useExceptionList } from './exceptions/hooks/use_exception_list';
export { useFindLists } from './lists/hooks/use_find_lists';
export { useImportList } from './lists/hooks/use_import_list';
export { useDeleteList } from './lists/hooks/use_delete_list';
export { useExportList } from './lists/hooks/use_export_list';
export {
ExceptionList,
ExceptionIdentifiers,

View file

@ -0,0 +1,331 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpFetchOptions } from '../../../../../src/core/public';
import { httpServiceMock } from '../../../../../src/core/public/mocks';
import { getListResponseMock } from '../../common/schemas/response/list_schema.mock';
import { getFoundListSchemaMock } from '../../common/schemas/response/found_list_schema.mock';
import { deleteList, exportList, findLists, importList } from './api';
import {
ApiPayload,
DeleteListParams,
ExportListParams,
FindListsParams,
ImportListParams,
} from './types';
describe('Value Lists API', () => {
let httpMock: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
httpMock = httpServiceMock.createStartContract();
});
describe('deleteList', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getListResponseMock());
});
it('DELETEs specifying the id as a query parameter', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<DeleteListParams> = { id: 'list-id' };
await deleteList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists',
expect.objectContaining({
method: 'DELETE',
query: { id: 'list-id' },
})
);
});
it('rejects with an error if request payload is invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
const payload: Omit<ApiPayload<DeleteListParams>, 'id'> & {
id: number;
} = { id: 23 };
await expect(
deleteList({
http: httpMock,
...((payload as unknown) as ApiPayload<DeleteListParams>),
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "23" supplied to "id"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<DeleteListParams> = { id: 'list-id' };
const badResponse = { ...getListResponseMock(), id: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
await expect(
deleteList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "id"');
});
});
describe('findLists', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getFoundListSchemaMock());
});
it('GETs from the lists endpoint', async () => {
const abortCtrl = new AbortController();
await findLists({
http: httpMock,
pageIndex: 1,
pageSize: 10,
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/_find',
expect.objectContaining({
method: 'GET',
})
);
});
it('sends pagination as query parameters', async () => {
const abortCtrl = new AbortController();
await findLists({
http: httpMock,
pageIndex: 1,
pageSize: 10,
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/_find',
expect.objectContaining({
query: { page: 1, per_page: 10 },
})
);
});
it('rejects with an error if request payload is invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<FindListsParams> = { pageIndex: 10, pageSize: 0 };
await expect(
findLists({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "0" supplied to "per_page"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<FindListsParams> = { pageIndex: 1, pageSize: 10 };
const badResponse = { ...getFoundListSchemaMock(), cursor: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
await expect(
findLists({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "cursor"');
});
});
describe('importList', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getListResponseMock());
});
it('POSTs the file', async () => {
const abortCtrl = new AbortController();
const file = new File([], 'name');
await importList({
file,
http: httpMock,
listId: 'my_list',
signal: abortCtrl.signal,
type: 'keyword',
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/items/_import',
expect.objectContaining({
method: 'POST',
})
);
// httpmock's fetch signature is inferred incorrectly
const [[, { body }]] = (httpMock.fetch.mock.calls as unknown) as Array<
[unknown, HttpFetchOptions]
>;
const actualFile = (body as FormData).get('file');
expect(actualFile).toEqual(file);
});
it('sends type and id as query parameters', async () => {
const abortCtrl = new AbortController();
const file = new File([], 'name');
await importList({
file,
http: httpMock,
listId: 'my_list',
signal: abortCtrl.signal,
type: 'keyword',
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/items/_import',
expect.objectContaining({
query: { list_id: 'my_list', type: 'keyword' },
})
);
});
it('rejects with an error if request body is invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<ImportListParams> = {
file: (undefined as unknown) as File,
listId: 'list-id',
type: 'ip',
};
await expect(
importList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "file"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if request params are invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
const file = new File([], 'name');
const payload: ApiPayload<ImportListParams> = {
file,
listId: 'list-id',
type: 'other' as 'ip',
};
await expect(
importList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "other" supplied to "type"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
const file = new File([], 'name');
const payload: ApiPayload<ImportListParams> = {
file,
listId: 'list-id',
type: 'ip',
};
const badResponse = { ...getListResponseMock(), id: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
await expect(
importList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "id"');
});
});
describe('exportList', () => {
beforeEach(() => {
httpMock.fetch.mockResolvedValue(getListResponseMock());
});
it('POSTs to the export endpoint', async () => {
const abortCtrl = new AbortController();
await exportList({
http: httpMock,
listId: 'my_list',
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/items/_export',
expect.objectContaining({
method: 'POST',
})
);
});
it('sends type and id as query parameters', async () => {
const abortCtrl = new AbortController();
await exportList({
http: httpMock,
listId: 'my_list',
signal: abortCtrl.signal,
});
expect(httpMock.fetch).toHaveBeenCalledWith(
'/api/lists/items/_export',
expect.objectContaining({
query: { list_id: 'my_list' },
})
);
});
it('rejects with an error if request params are invalid (and does not make API call)', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<ExportListParams> = {
listId: (23 as unknown) as string,
};
await expect(
exportList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "23" supplied to "list_id"');
expect(httpMock.fetch).not.toHaveBeenCalled();
});
it('rejects with an error if response payload is invalid', async () => {
const abortCtrl = new AbortController();
const payload: ApiPayload<ExportListParams> = {
listId: 'list-id',
};
const badResponse = { ...getListResponseMock(), id: undefined };
httpMock.fetch.mockResolvedValue(badResponse);
await expect(
exportList({
http: httpMock,
...payload,
signal: abortCtrl.signal,
})
).rejects.toEqual('Invalid value "undefined" supplied to "id"');
});
});
});

View file

@ -0,0 +1,173 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { chain, fromEither, map, tryCatch } from 'fp-ts/lib/TaskEither';
import { flow } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import {
DeleteListSchemaEncoded,
ExportListItemQuerySchemaEncoded,
FindListSchemaEncoded,
FoundListSchema,
ImportListItemQuerySchemaEncoded,
ImportListItemSchemaEncoded,
ListSchema,
deleteListSchema,
exportListItemQuerySchema,
findListSchema,
foundListSchema,
importListItemQuerySchema,
importListItemSchema,
listSchema,
} from '../../common/schemas';
import { LIST_ITEM_URL, LIST_URL } from '../../common/constants';
import { validateEither } from '../../common/siem_common_deps';
import { toPromise } from '../common/fp_utils';
import {
ApiParams,
DeleteListParams,
ExportListParams,
FindListsParams,
ImportListParams,
} from './types';
const findLists = async ({
http,
cursor,
page,
per_page,
signal,
}: ApiParams & FindListSchemaEncoded): Promise<FoundListSchema> => {
return http.fetch(`${LIST_URL}/_find`, {
method: 'GET',
query: {
cursor,
page,
per_page,
},
signal,
});
};
const findListsWithValidation = async ({
http,
pageIndex,
pageSize,
signal,
}: FindListsParams): Promise<FoundListSchema> =>
pipe(
{
page: String(pageIndex),
per_page: String(pageSize),
},
(payload) => fromEither(validateEither(findListSchema, payload)),
chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), String)),
chain((response) => fromEither(validateEither(foundListSchema, response))),
flow(toPromise)
);
export { findListsWithValidation as findLists };
const importList = async ({
file,
http,
list_id,
type,
signal,
}: ApiParams & ImportListItemSchemaEncoded & ImportListItemQuerySchemaEncoded): Promise<
ListSchema
> => {
const formData = new FormData();
formData.append('file', file as Blob);
return http.fetch<ListSchema>(`${LIST_ITEM_URL}/_import`, {
body: formData,
headers: { 'Content-Type': undefined },
method: 'POST',
query: { list_id, type },
signal,
});
};
const importListWithValidation = async ({
file,
http,
listId,
type,
signal,
}: ImportListParams): Promise<ListSchema> =>
pipe(
{
list_id: listId,
type,
},
(query) => fromEither(validateEither(importListItemQuerySchema, query)),
chain((query) =>
pipe(
fromEither(validateEither(importListItemSchema, { file })),
map((body) => ({ ...body, ...query }))
)
),
chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), String)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);
export { importListWithValidation as importList };
const deleteList = async ({
http,
id,
signal,
}: ApiParams & DeleteListSchemaEncoded): Promise<ListSchema> =>
http.fetch<ListSchema>(LIST_URL, {
method: 'DELETE',
query: { id },
signal,
});
const deleteListWithValidation = async ({
http,
id,
signal,
}: DeleteListParams): Promise<ListSchema> =>
pipe(
{ id },
(payload) => fromEither(validateEither(deleteListSchema, payload)),
chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), String)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);
export { deleteListWithValidation as deleteList };
const exportList = async ({
http,
list_id,
signal,
}: ApiParams & ExportListItemQuerySchemaEncoded): Promise<Blob> =>
http.fetch<Blob>(`${LIST_ITEM_URL}/_export`, {
method: 'POST',
query: { list_id },
signal,
});
const exportListWithValidation = async ({
http,
listId,
signal,
}: ExportListParams): Promise<Blob> =>
pipe(
{ list_id: listId },
(payload) => fromEither(validateEither(exportListItemQuerySchema, payload)),
chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)),
chain((response) => fromEither(validateEither(listSchema, response))),
flow(toPromise)
);
export { exportListWithValidation as exportList };

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import * as Api from '../api';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock';
import { useDeleteList } from './use_delete_list';
jest.mock('../api');
describe('useDeleteList', () => {
let httpMock: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
httpMock = httpServiceMock.createStartContract();
(Api.deleteList as jest.Mock).mockResolvedValue(getListResponseMock());
});
it('invokes Api.deleteList', async () => {
const { result, waitForNextUpdate } = renderHook(() => useDeleteList());
act(() => {
result.current.start({ http: httpMock, id: 'list' });
});
await waitForNextUpdate();
expect(Api.deleteList).toHaveBeenCalledWith(
expect.objectContaining({ http: httpMock, id: 'list' })
);
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useAsyncTask } from '../../common/hooks/use_async_task';
import { DeleteListParams } from '../types';
import { deleteList } from '../api';
export type DeleteListTaskArgs = Omit<DeleteListParams, 'signal'>;
const deleteListsTask = (
{ signal }: AbortController,
args: DeleteListTaskArgs
): ReturnType<typeof deleteList> => deleteList({ signal, ...args });
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const useDeleteList = () => useAsyncTask(deleteListsTask);

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import * as Api from '../api';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { useExportList } from './use_export_list';
jest.mock('../api');
describe('useExportList', () => {
let httpMock: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
httpMock = httpServiceMock.createStartContract();
(Api.exportList as jest.Mock).mockResolvedValue(new Blob());
});
it('invokes Api.exportList', async () => {
const { result, waitForNextUpdate } = renderHook(() => useExportList());
act(() => {
result.current.start({ http: httpMock, listId: 'list' });
});
await waitForNextUpdate();
expect(Api.exportList).toHaveBeenCalledWith(
expect.objectContaining({ http: httpMock, listId: 'list' })
);
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useAsyncTask } from '../../common/hooks/use_async_task';
import { ExportListParams } from '../types';
import { exportList } from '../api';
export type ExportListTaskArgs = Omit<ExportListParams, 'signal'>;
const exportListTask = (
{ signal }: AbortController,
args: ExportListTaskArgs
): ReturnType<typeof exportList> => exportList({ signal, ...args });
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const useExportList = () => useAsyncTask(exportListTask);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import * as Api from '../api';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { getFoundListSchemaMock } from '../../../common/schemas/response/found_list_schema.mock';
import { useFindLists } from './use_find_lists';
jest.mock('../api');
describe('useFindLists', () => {
let httpMock: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
httpMock = httpServiceMock.createStartContract();
(Api.findLists as jest.Mock).mockResolvedValue(getFoundListSchemaMock());
});
it('invokes Api.findLists', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFindLists());
act(() => {
result.current.start({ http: httpMock, pageIndex: 1, pageSize: 10 });
});
await waitForNextUpdate();
expect(Api.findLists).toHaveBeenCalledWith(
expect.objectContaining({ http: httpMock, pageIndex: 1, pageSize: 10 })
);
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useAsyncTask } from '../../common/hooks/use_async_task';
import { FindListsParams } from '../types';
import { findLists } from '../api';
export type FindListsTaskArgs = Omit<FindListsParams, 'signal'>;
const findListsTask = (
{ signal }: AbortController,
args: FindListsTaskArgs
): ReturnType<typeof findLists> => findLists({ signal, ...args });
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const useFindLists = () => useAsyncTask(findListsTask);

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock';
import * as Api from '../api';
import { useImportList } from './use_import_list';
jest.mock('../api');
describe('useImportList', () => {
let httpMock: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
httpMock = httpServiceMock.createStartContract();
(Api.importList as jest.Mock).mockResolvedValue(getListResponseMock());
});
it('does not invoke importList if start was not called', () => {
renderHook(() => useImportList());
expect(Api.importList).not.toHaveBeenCalled();
});
it('invokes Api.importList', async () => {
const fileMock = ('my file' as unknown) as File;
const { result, waitForNextUpdate } = renderHook(() => useImportList());
act(() => {
result.current.start({
file: fileMock,
http: httpMock,
listId: 'my_list_id',
type: 'keyword',
});
});
await waitForNextUpdate();
expect(Api.importList).toHaveBeenCalledWith(
expect.objectContaining({
file: fileMock,
listId: 'my_list_id',
type: 'keyword',
})
);
});
it('populates result with the response of Api.importList', async () => {
const fileMock = ('my file' as unknown) as File;
const { result, waitForNextUpdate } = renderHook(() => useImportList());
act(() => {
result.current.start({
file: fileMock,
http: httpMock,
listId: 'my_list_id',
type: 'keyword',
});
});
await waitForNextUpdate();
expect(result.current.result).toEqual(getListResponseMock());
});
it('error is populated if importList rejects', async () => {
const fileMock = ('my file' as unknown) as File;
(Api.importList as jest.Mock).mockRejectedValue(new Error('whoops'));
const { result, waitForNextUpdate } = renderHook(() => useImportList());
act(() => {
result.current.start({
file: fileMock,
http: httpMock,
listId: 'my_list_id',
type: 'keyword',
});
});
await waitForNextUpdate();
expect(result.current.result).toBeUndefined();
expect(result.current.error).toEqual(new Error('whoops'));
});
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useAsyncTask } from '../../common/hooks/use_async_task';
import { ImportListParams } from '../types';
import { importList } from '../api';
export type ImportListTaskArgs = Omit<ImportListParams, 'signal'>;
const importListTask = (
{ signal }: AbortController,
args: ImportListTaskArgs
): ReturnType<typeof importList> => importList({ signal, ...args });
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export const useImportList = () => useAsyncTask(importListTask);

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpStart } from '../../../../../src/core/public';
import { Type } from '../../common/schemas';
export interface ApiParams {
http: HttpStart;
signal: AbortSignal;
}
export type ApiPayload<T extends ApiParams> = Omit<T, 'http' | 'signal'>;
export interface FindListsParams extends ApiParams {
pageSize: number | undefined;
pageIndex: number | undefined;
}
export interface ImportListParams extends ApiParams {
file: File;
listId: string | undefined;
type: Type | undefined;
}
export interface DeleteListParams extends ApiParams {
id: string;
}
export interface ExportListParams extends ApiParams {
listId: string;
}

View file

@ -47,7 +47,7 @@ export const exportListItemRoute = (router: IRouter): void => {
body: stream,
headers: {
'Content-Disposition': `attachment; filename="${fileName}"`,
'Content-Type': 'text/plain',
'Content-Type': 'application/ndjson',
},
});
}

View file

@ -3,15 +3,11 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { left, right } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { validate } from './validate';
import { validate, validateEither } from './validate';
describe('validate', () => {
test('it should do a validation correctly', () => {
@ -32,3 +28,21 @@ describe('validate', () => {
expect(errors).toEqual('Invalid value "some other value" supplied to "a"');
});
});
describe('validateEither', () => {
it('returns the ORIGINAL payload as right if valid', () => {
const schema = t.exact(t.type({ a: t.number }));
const payload = { a: 1 };
const result = validateEither(schema, payload);
expect(result).toEqual(right(payload));
});
it('returns an error string if invalid', () => {
const schema = t.exact(t.type({ a: t.number }));
const payload = { a: 'some other value' };
const result = validateEither(schema, payload);
expect(result).toEqual(left('Invalid value "some other value" supplied to "a"'));
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { fold } from 'fp-ts/lib/Either';
import { fold, Either, mapLeft } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import * as t from 'io-ts';
import { exactCheck } from './exact_check';
@ -23,3 +23,13 @@ export const validate = <T extends t.Mixed>(
const right = (output: T): [T | null, string | null] => [output, null];
return pipe(checked, fold(left, right));
};
export const validateEither = <T extends t.Mixed, A extends unknown>(
schema: T,
obj: A
): Either<string, A> =>
pipe(
obj,
(a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())),
mapLeft((errors) => formatErrors(errors).join(','))
);