Skip to content

Commit

Permalink
[APM] migrate to io-ts (#42961) (#43674)
Browse files Browse the repository at this point in the history
* [APM] migrate to io-ts

* Migrate remaining routes to io-ts

* Infer response type for useFetcher()

* Review feedback

* Use createRangeType util

* Extract & test runtime types

* Simplify runtime types

* Tests for createApi and callApmApi

* Use more readable variable names in runtime types

* Remove UIFilters query param for API endpoints where it is not supported

* Fix issues w/ default parameters in create_api
  • Loading branch information
dgieselaar authored Aug 21, 2019
1 parent 98993f5 commit 3f48d83
Show file tree
Hide file tree
Showing 55 changed files with 1,769 additions and 1,463 deletions.
Original file line number Diff line number Diff line change
@@ -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 { dateAsStringRt } from './index';

describe('dateAsStringRt', () => {
it('validates whether a string is a valid date', () => {
expect(dateAsStringRt.decode(1566299881499).isLeft()).toBe(true);

expect(dateAsStringRt.decode('2019-08-20T11:18:31.407Z').isRight()).toBe(
true
);
});

it('returns the string it was given', () => {
const either = dateAsStringRt.decode('2019-08-20T11:18:31.407Z');

expect(either.value).toBe('2019-08-20T11:18:31.407Z');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 * as t from 'io-ts';
import { either } from 'fp-ts/lib/Either';

// Checks whether a string is a valid ISO timestamp,
// but doesn't convert it into a Date object when decoding

export const dateAsStringRt = new t.Type<string, string, unknown>(
'DateAsString',
t.string.is,
(input, context) =>
either.chain(t.string.validate(input, context), str => {
const date = new Date(str);
return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str);
}),
t.identity
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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 * as t from 'io-ts';
import { jsonRt } from './index';

describe('jsonRt', () => {
it('validates json', () => {
expect(jsonRt.decode('{}').isRight()).toBe(true);
expect(jsonRt.decode('[]').isRight()).toBe(true);
expect(jsonRt.decode('true').isRight()).toBe(true);
expect(jsonRt.decode({}).isLeft()).toBe(true);
expect(jsonRt.decode('foo').isLeft()).toBe(true);
});

it('returns parsed json when decoding', () => {
expect(jsonRt.decode('{}').value).toEqual({});
expect(jsonRt.decode('[]').value).toEqual([]);
expect(jsonRt.decode('true').value).toEqual(true);
});

it('is pipable', () => {
const piped = jsonRt.pipe(t.type({ foo: t.string }));

const validInput = { foo: 'bar' };
const invalidInput = { foo: null };

const valid = piped.decode(JSON.stringify(validInput));
const invalid = piped.decode(JSON.stringify(invalidInput));

expect(valid.isRight()).toBe(true);
expect(valid.value).toEqual(validInput);

expect(invalid.isLeft()).toBe(true);
});

it('returns strings when encoding', () => {
expect(jsonRt.encode({})).toBe('{}');
});
});
21 changes: 21 additions & 0 deletions x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 * as t from 'io-ts';
import { either } from 'fp-ts/lib/Either';

export const jsonRt = new t.Type<any, string, unknown>(
'JSON',
t.any.is,
(input, context) =>
either.chain(t.string.validate(input, context), str => {
try {
return t.success(JSON.parse(str));
} catch (e) {
return t.failure(input, context);
}
}),
a => JSON.stringify(a)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { transactionSampleRateRt } from './index';

describe('transactionSampleRateRt', () => {
it('accepts both strings and numbers as values', () => {
expect(transactionSampleRateRt.decode('0.5').isRight()).toBe(true);
expect(transactionSampleRateRt.decode(0.5).isRight()).toBe(true);
});

it('checks if the number falls within 0, 1', () => {
expect(transactionSampleRateRt.decode(0).isRight()).toBe(true);

expect(transactionSampleRateRt.decode(0.5).isRight()).toBe(true);

expect(transactionSampleRateRt.decode(-0.1).isRight()).toBe(false);
expect(transactionSampleRateRt.decode(1.1).isRight()).toBe(false);

expect(transactionSampleRateRt.decode(NaN).isRight()).toBe(false);
});

it('checks whether the number of decimals is 3', () => {
expect(transactionSampleRateRt.decode(1).isRight()).toBe(true);
expect(transactionSampleRateRt.decode(0.99).isRight()).toBe(true);
expect(transactionSampleRateRt.decode(0.999).isRight()).toBe(true);
expect(transactionSampleRateRt.decode(0.998).isRight()).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -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 * as t from 'io-ts';

export const transactionSampleRateRt = new t.Type<number, number, unknown>(
'TransactionSampleRate',
t.number.is,
(input, context) => {
const value = Number(input);
return value >= 0 && value <= 1 && Number(value.toFixed(3)) === value
? t.success(value)
: t.failure(input, context);
},
t.identity
);
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,14 @@ import styled from 'styled-components';
import { idx } from '@kbn/elastic-idx';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { useFetcher } from '../../../hooks/useFetcher';
import {
loadErrorDistribution,
loadErrorGroupDetails
} from '../../../services/rest/apm/error_groups';
import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables';
import { ApmHeader } from '../../shared/ApmHeader';
import { DetailView } from './DetailView';
import { ErrorDistribution } from './Distribution';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { useTrackPageview } from '../../../../../infra/public';
import { callApmApi } from '../../../services/rest/callApmApi';

const Titles = styled.div`
margin-bottom: ${px(units.plus)};
Expand Down Expand Up @@ -68,24 +65,38 @@ export function ErrorGroupDetails() {

const { data: errorGroupData } = useFetcher(() => {
if (serviceName && start && end && errorGroupId) {
return loadErrorGroupDetails({
serviceName,
start,
end,
errorGroupId,
uiFilters
return callApmApi({
pathname: '/api/apm/services/{serviceName}/errors/{groupId}',
params: {
path: {
serviceName,
groupId: errorGroupId
},
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters)
}
}
});
}
}, [serviceName, start, end, errorGroupId, uiFilters]);

const { data: errorDistributionData } = useFetcher(() => {
if (serviceName && start && end && errorGroupId) {
return loadErrorDistribution({
serviceName,
start,
end,
errorGroupId,
uiFilters
return callApmApi({
pathname: '/api/apm/services/{serviceName}/errors/distribution',
params: {
path: {
serviceName
},
query: {
start,
end,
groupId: errorGroupId,
uiFilters: JSON.stringify(uiFilters)
}
}
});
}
}, [serviceName, start, end, errorGroupId, uiFilters]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,13 @@ import {
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { useFetcher } from '../../../hooks/useFetcher';
import {
loadErrorDistribution,
loadErrorGroupList
} from '../../../services/rest/apm/error_groups';
import { ErrorDistribution } from '../ErrorGroupDetails/Distribution';
import { ErrorGroupList } from './List';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { useTrackPageview } from '../../../../../infra/public';
import { PROJECTION } from '../../../../common/projections/typings';
import { LocalUIFilters } from '../../shared/LocalUIFilters';
import { callApmApi } from '../../../services/rest/callApmApi';

const ErrorGroupOverview: React.SFC = () => {
const { urlParams, uiFilters } = useUrlParams();
Expand All @@ -32,24 +29,38 @@ const ErrorGroupOverview: React.SFC = () => {

const { data: errorDistributionData } = useFetcher(() => {
if (serviceName && start && end) {
return loadErrorDistribution({
serviceName,
start,
end,
uiFilters
return callApmApi({
pathname: '/api/apm/services/{serviceName}/errors/distribution',
params: {
path: {
serviceName
},
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters)
}
}
});
}
}, [serviceName, start, end, uiFilters]);

const { data: errorGroupListData } = useFetcher(() => {
if (serviceName && start && end) {
return loadErrorGroupList({
serviceName,
start,
end,
sortField,
sortDirection,
uiFilters
return callApmApi({
pathname: '/api/apm/services/{serviceName}/errors',
params: {
path: {
serviceName
},
query: {
start,
end,
sortField,
sortDirection,
uiFilters: JSON.stringify(uiFilters)
}
}
});
}
}, [serviceName, start, end, sortField, sortDirection, uiFilters]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { ErrorGroupOverview } from '../ErrorGroupOverview';
import { TransactionOverview } from '../TransactionOverview';
import { ServiceMetrics } from '../ServiceMetrics';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceAgentName } from '../../../services/rest/apm/services';
import { isRumAgentName } from '../../../../common/agent_name';
import { callApmApi } from '../../../services/rest/callApmApi';

interface Props {
urlParams: IUrlParams;
Expand All @@ -23,7 +23,13 @@ export function ServiceDetailTabs({ urlParams }: Props) {
const { serviceName, start, end } = urlParams;
const { data: agentName } = useFetcher(() => {
if (serviceName && start && end) {
return loadServiceAgentName({ serviceName, start, end });
return callApmApi({
pathname: '/api/apm/services/{serviceName}/agent_name',
params: {
path: { serviceName },
query: { start, end }
}
}).then(res => res.agentName);
}
}, [serviceName, start, end]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import React from 'react';
import { render, wait, waitForElement } from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import { toastNotifications } from 'ui/notify';
import * as apmRestServices from '../../../../services/rest/apm/services';
import * as callApmApi from '../../../../services/rest/callApmApi';
import { ServiceOverview } from '..';
import * as urlParamsHooks from '../../../../hooks/useUrlParams';
import * as coreHooks from '../../../../hooks/useCore';
Expand Down Expand Up @@ -66,7 +66,7 @@ describe('Service Overview -> View', () => {
it('should render services, when list is not empty', async () => {
// mock rest requests
const dataFetchingSpy = jest
.spyOn(apmRestServices, 'loadServiceList')
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: true,
Expand Down Expand Up @@ -101,7 +101,7 @@ describe('Service Overview -> View', () => {

it('should render getting started message, when list is empty and no historical data is found', async () => {
const dataFetchingSpy = jest
.spyOn(apmRestServices, 'loadServiceList')
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: false,
Expand All @@ -125,7 +125,7 @@ describe('Service Overview -> View', () => {

it('should render empty message, when list is empty and historical data is found', async () => {
const dataFetchingSpy = jest
.spyOn(apmRestServices, 'loadServiceList')
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: true,
Expand All @@ -145,7 +145,7 @@ describe('Service Overview -> View', () => {
// create spies
const toastSpy = jest.spyOn(toastNotifications, 'addWarning');
const dataFetchingSpy = jest
.spyOn(apmRestServices, 'loadServiceList')
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: true,
hasHistoricalData: true,
Expand All @@ -168,7 +168,7 @@ describe('Service Overview -> View', () => {
// create spies
const toastSpy = jest.spyOn(toastNotifications, 'addWarning');
const dataFetchingSpy = jest
.spyOn(apmRestServices, 'loadServiceList')
.spyOn(callApmApi, 'callApmApi')
.mockResolvedValue({
hasLegacyData: false,
hasHistoricalData: true,
Expand Down
Loading

0 comments on commit 3f48d83

Please sign in to comment.