mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
771f3ae098
commit
590fc8d2ff
25 changed files with 1040 additions and 17 deletions
|
@ -17,3 +17,4 @@ export const deleteListSchema = t.exact(
|
|||
);
|
||||
|
||||
export type DeleteListSchema = t.TypeOf<typeof deleteListSchema>;
|
||||
export type DeleteListSchemaEncoded = t.OutputOf<typeof deleteListSchema>;
|
||||
|
|
|
@ -18,3 +18,4 @@ export const exportListItemQuerySchema = t.exact(
|
|||
);
|
||||
|
||||
export type ExportListItemQuerySchema = t.TypeOf<typeof exportListItemQuerySchema>;
|
||||
export type ExportListItemQuerySchemaEncoded = t.OutputOf<typeof exportListItemQuerySchema>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -17,3 +17,4 @@ export const importListItemSchema = t.exact(
|
|||
);
|
||||
|
||||
export type ImportListItemSchema = t.TypeOf<typeof importListItemSchema>;
|
||||
export type ImportListItemSchemaEncoded = t.OutputOf<typeof importListItemSchema>;
|
||||
|
|
|
@ -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';
|
||||
|
|
23
x-pack/plugins/lists/public/common/fp_utils.test.ts
Normal file
23
x-pack/plugins/lists/public/common/fp_utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
18
x-pack/plugins/lists/public/common/fp_utils.ts
Normal file
18
x-pack/plugins/lists/public/common/fp_utils.ts
Normal 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)
|
||||
)
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
48
x-pack/plugins/lists/public/common/hooks/use_async_task.ts
Normal file
48
x-pack/plugins/lists/public/common/hooks/use_async_task.ts
Normal 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 };
|
||||
};
|
|
@ -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,
|
||||
|
|
331
x-pack/plugins/lists/public/lists/api.test.ts
Normal file
331
x-pack/plugins/lists/public/lists/api.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
});
|
173
x-pack/plugins/lists/public/lists/api.ts
Normal file
173
x-pack/plugins/lists/public/lists/api.ts
Normal 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 };
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
19
x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts
Normal file
19
x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts
Normal 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);
|
|
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
19
x-pack/plugins/lists/public/lists/hooks/use_export_list.ts
Normal file
19
x-pack/plugins/lists/public/lists/hooks/use_export_list.ts
Normal 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);
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
19
x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts
Normal file
19
x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts
Normal 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);
|
|
@ -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'));
|
||||
});
|
||||
});
|
19
x-pack/plugins/lists/public/lists/hooks/use_import_list.ts
Normal file
19
x-pack/plugins/lists/public/lists/hooks/use_import_list.ts
Normal 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);
|
33
x-pack/plugins/lists/public/lists/types.ts
Normal file
33
x-pack/plugins/lists/public/lists/types.ts
Normal 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;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(','))
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue