diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts b/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts new file mode 100644 index 0000000000000..22fccc0c85417 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts @@ -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'); + }); +}); diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.ts b/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.ts new file mode 100644 index 0000000000000..8320854e0c2a9 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/date_as_string_rt/index.ts @@ -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( + '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 +); diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.test.ts b/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.test.ts new file mode 100644 index 0000000000000..e5b97daec7cc8 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.test.ts @@ -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('{}'); + }); +}); diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.ts b/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.ts new file mode 100644 index 0000000000000..51c825ce68a60 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/json_rt/index.ts @@ -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( + '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) +); diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts b/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts new file mode 100644 index 0000000000000..b11e79780b968 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts @@ -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); + }); +}); diff --git a/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts b/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts new file mode 100644 index 0000000000000..d6df87e7a5fed --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts @@ -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( + '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 +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 475de028ecb52..d89e3ae48cf6a 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -20,10 +20,6 @@ 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'; @@ -31,6 +27,7 @@ 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)}; @@ -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]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index 57b7ea200ece7..330335e155323 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -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(); @@ -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]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx index a44d1b8313325..00195c6639d3c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx @@ -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; @@ -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]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index 58b9ae2d868d0..b29428cc555ed 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -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'; @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx index 208ae77806d36..69609752ffefa 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx @@ -11,7 +11,6 @@ import React, { useEffect, useMemo } from 'react'; import { toastNotifications } from 'ui/notify'; import url from 'url'; import { useFetcher } from '../../../hooks/useFetcher'; -import { loadServiceList } from '../../../services/rest/apm/services'; import { NoServicesMessage } from './NoServicesMessage'; import { ServiceList } from './ServiceList'; import { useUrlParams } from '../../../hooks/useUrlParams'; @@ -19,6 +18,7 @@ import { useCore } from '../../../hooks/useCore'; import { useTrackPageview } from '../../../../../infra/public'; import { PROJECTION } from '../../../../common/projections/typings'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { callApmApi } from '../../../services/rest/callApmApi'; const initalData = { items: [], @@ -36,7 +36,12 @@ export function ServiceOverview() { } = useUrlParams(); const { data = initalData, status } = useFetcher(() => { if (start && end) { - return loadServiceList({ start, end, uiFilters }); + return callApmApi({ + pathname: '/api/apm/services', + params: { + query: { start, end, uiFilters: JSON.stringify(uiFilters) } + } + }); } }, [start, end, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx index 1726c2233d404..1ad48ea925a4c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AddSettings/AddSettingFlyout.tsx @@ -22,17 +22,12 @@ import React, { useState } from 'react'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { transactionSampleRateRt } from '../../../../../common/runtime_types/transaction_sample_rate_rt'; import { AddSettingFlyoutBody } from './AddSettingFlyoutBody'; import { Config } from '../SettingsList'; import { useFetcher } from '../../../../hooks/useFetcher'; -import { - loadAgentConfigurationServices, - loadAgentConfigurationEnvironments, - deleteAgentConfiguration, - updateAgentConfiguration, - createAgentConfiguration -} from '../../../../services/rest/apm/settings'; import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { callApmApi } from '../../../../services/rest/callApmApi'; interface Props { onClose: () => void; @@ -60,30 +55,40 @@ export function AddSettingsFlyout({ ? selectedConfig.settings.transaction_sample_rate.toString() : '' ); - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher< - string[] - >(async () => (await loadAgentConfigurationServices()).sort(), [], { - preservePreviousResponse: false - }); - const { data: environments = [], status: environmentStatus } = useFetcher< - Array<{ name: string; available: boolean }> - >( + const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + () => + callApmApi({ + pathname: '/api/apm/settings/agent-configuration/services' + }), + [], + { + preservePreviousResponse: false + } + ); + const { data: environments = [], status: environmentStatus } = useFetcher( () => { if (serviceName) { - return loadAgentConfigurationEnvironments({ serviceName }); + return callApmApi({ + pathname: + '/api/apm/settings/agent-configuration/services/{serviceName}/environments', + params: { + path: { serviceName } + } + }); } }, [serviceName], { preservePreviousResponse: false } ); + + const isSampleRateValid = transactionSampleRateRt + .decode(sampleRate) + .isRight(); + const isSelectedEnvironmentValid = environments.some( env => env.name === environment && (Boolean(selectedConfig) || env.available) ); - const sampleRateFloat = parseFloat(sampleRate); - const hasCorrectDecimals = Number.isInteger(sampleRateFloat * 1000); - const isSampleRateValid = - sampleRateFloat >= 0 && sampleRateFloat <= 1 && hasCorrectDecimals; if (!isOpen) { return null; @@ -187,7 +192,7 @@ export function AddSettingsFlyout({ await saveConfig({ environment, serviceName, - sampleRate: sampleRateFloat, + sampleRate: parseFloat(sampleRate), configurationId: selectedConfig ? selectedConfig.id : undefined @@ -211,7 +216,13 @@ export function AddSettingsFlyout({ } async function deleteConfig(selectedConfig: Config) { try { - await deleteAgentConfiguration(selectedConfig.id); + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/{configurationId}', + method: 'DELETE', + params: { + path: { configurationId: selectedConfig.id } + } + }); toastNotifications.addSuccess({ title: i18n.translate( 'xpack.apm.settings.agentConf.deleteConfigSucceededTitle', @@ -279,7 +290,15 @@ async function saveConfig({ }; if (configurationId) { - await updateAgentConfiguration(configurationId, configuration); + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/{configurationId}', + method: 'PUT', + params: { + path: { configurationId }, + body: configuration + } + }); + toastNotifications.addSuccess({ title: i18n.translate( 'xpack.apm.settings.agentConf.editConfigSucceededTitle', @@ -298,7 +317,13 @@ async function saveConfig({ ) }); } else { - await createAgentConfiguration(configuration); + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/new', + method: 'POST', + params: { + body: configuration + } + }); toastNotifications.addSuccess({ title: i18n.translate( 'xpack.apm.settings.agentConf.createConfigSucceededTitle', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/SettingsList.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/SettingsList.tsx index aff6dcfeef375..c48a81ffdf590 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/SettingsList.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/SettingsList.tsx @@ -22,19 +22,22 @@ import { EuiLink } from '@elastic/eui'; import { isEmpty } from 'lodash'; -import { loadAgentConfigurationList } from '../../../services/rest/apm/settings'; import { useFetcher } from '../../../hooks/useFetcher'; import { ITableColumn, ManagedTable } from '../../shared/ManagedTable'; import { AgentConfigurationListAPIResponse } from '../../../../server/lib/settings/agent_configuration/list_configurations'; import { AddSettingsFlyout } from './AddSettings/AddSettingFlyout'; import { APMLink } from '../../shared/Links/apm/APMLink'; import { LoadingStatePrompt } from '../../shared/LoadingStatePrompt'; +import { callApmApi } from '../../../services/rest/callApmApi'; export type Config = AgentConfigurationListAPIResponse[0]; export function SettingsList() { const { data = [], status, refresh } = useFetcher( - loadAgentConfigurationList, + () => + callApmApi({ + pathname: `/api/apm/settings/agent-configuration` + }), [] ); const [selectedConfig, setSelectedConfig] = useState(null); diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx index 74819600d44a9..66e68262f477c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx @@ -7,19 +7,28 @@ import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useMemo } from 'react'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; -import { loadTraceList } from '../../../services/rest/apm/traces'; import { TraceList } from './TraceList'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { useTrackPageview } from '../../../../../infra/public'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { PROJECTION } from '../../../../common/projections/typings'; +import { callApmApi } from '../../../services/rest/callApmApi'; export function TraceOverview() { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; const { status, data = [] } = useFetcher(() => { if (start && end) { - return loadTraceList({ start, end, uiFilters }); + return callApmApi({ + pathname: '/api/apm/traces', + params: { + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); } }, [start, end, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index d22ba15ae92c7..d9618a043175f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -8,7 +8,6 @@ import { EuiSelect, EuiFormLabel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useFetcher } from '../../../hooks/useFetcher'; -import { loadEnvironmentsFilter } from '../../../services/rest/apm/ui_filters'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { history } from '../../../utils/history'; @@ -17,6 +16,7 @@ import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; +import { callApmApi } from '../../../services/rest/callApmApi'; function updateEnvironmentUrl( location: ReturnType, @@ -81,10 +81,15 @@ export const EnvironmentFilter: React.FC = () => { const { environment } = uiFilters; const { data: environments = [], status = 'loading' } = useFetcher(() => { if (start && end) { - return loadEnvironmentsFilter({ - start, - end, - serviceName + return callApmApi({ + pathname: '/api/apm/ui_filters/environments', + params: { + query: { + start, + end, + serviceName + } + } }); } }, [start, end, serviceName]); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx index a47df4886c40c..80cecd8ea7f4d 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx @@ -21,9 +21,7 @@ jest.mock('ui/kfetch'); jest .spyOn(savedObjects, 'getAPMIndexPattern') - .mockReturnValue( - Promise.resolve({ id: 'apm-index-pattern-id' } as savedObjects.ISavedObject) - ); + .mockResolvedValue({ id: 'apm-index-pattern-id' } as any); beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index 2bb4da88236ca..5f66cf8563260 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -26,9 +26,7 @@ jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); jest .spyOn(savedObjects, 'getAPMIndexPattern') - .mockReturnValue( - Promise.resolve({ id: 'apm-index-pattern-id' } as savedObjects.ISavedObject) - ); + .mockResolvedValue({ id: 'apm-index-pattern-id' } as any); beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index fdf0562c134d7..6078a2026dca1 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -18,8 +18,16 @@ export enum FETCH_STATUS { FAILURE = 'failure' } -export function useFetcher( - fn: () => Promise | undefined, +type Fetcher = (...args: any[]) => any; +type GetReturn = Exclude< + ReturnType, + undefined +> extends Promise + ? TReturn + : ReturnType; + +export function useFetcher( + fn: TFetcher, fnDeps: any[], options: { preservePreviousResponse?: boolean } = {} ) { @@ -27,7 +35,7 @@ export function useFetcher( const id = useComponentId(); const { dispatchStatus } = useContext(LoadingIndicatorContext); const [result, setResult] = useState<{ - data?: Response; + data?: GetReturn; status?: FETCH_STATUS; error?: Error; }>({}); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts b/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts index 4158c37c2a823..4d9f68b7d7b3a 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts @@ -53,8 +53,8 @@ export function useLocalUIFilters({ }); }; - const { data = initialData, status } = useFetcher(async () => { - const foo = await callApi({ + const { data = initialData, status } = useFetcher(() => { + return callApi({ method: 'GET', pathname: `/api/apm/ui_filters/local_filters/${projection}`, query: { @@ -65,7 +65,6 @@ export function useLocalUIFilters({ ...params } }); - return foo; }, [uiFilters, urlParams, params, filterNames, projection]); const filters = data.map(filter => ({ diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts index 0b1af3372b22a..05841f6b2d4fc 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts @@ -5,10 +5,10 @@ */ import { MetricsChartsByAgentAPIResponse } from '../../server/lib/metrics/get_metrics_chart_data_by_agent'; -import { loadMetricsChartData } from '../services/rest/apm/metrics'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; +import { callApmApi } from '../services/rest/callApmApi'; const INITIAL_DATA: MetricsChartsByAgentAPIResponse = { charts: [] @@ -20,16 +20,19 @@ export function useServiceMetricCharts( ) { const { serviceName, start, end } = urlParams; const uiFilters = useUiFilters(urlParams); - const { data = INITIAL_DATA, error, status } = useFetcher< - MetricsChartsByAgentAPIResponse - >(() => { + const { data = INITIAL_DATA, error, status } = useFetcher(() => { if (serviceName && start && end && agentName) { - return loadMetricsChartData({ - serviceName, - start, - end, - agentName, - uiFilters + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/metrics/charts', + params: { + path: { serviceName }, + query: { + start, + end, + agentName, + uiFilters: JSON.stringify(uiFilters) + } + } }); } }, [serviceName, start, end, agentName, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx b/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx index 8a144bb178b6f..f8756d719264a 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx @@ -4,17 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loadServiceTransactionTypes } from '../services/rest/apm/services'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; +import { callApmApi } from '../services/rest/callApmApi'; + +const INITIAL_DATA = { transactionTypes: [] }; export function useServiceTransactionTypes(urlParams: IUrlParams) { const { serviceName, start, end } = urlParams; - const { data: transactionTypes = [] } = useFetcher(() => { + const { data = INITIAL_DATA } = useFetcher(() => { if (serviceName && start && end) { - return loadServiceTransactionTypes({ serviceName, start, end }); + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/transaction_types', + params: { + path: { serviceName }, + query: { start, end } + } + }); } }, [serviceName, start, end]); - return transactionTypes; + return data.transactionTypes; } diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts index 22bfb1a1bc233..74319be10cc65 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts @@ -7,7 +7,7 @@ import { useRef } from 'react'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; -import { loadTransactionBreakdown } from '../services/rest/apm/transaction_groups'; +import { callApmApi } from '../services/rest/callApmApi'; export function useTransactionBreakdown() { const { @@ -21,13 +21,19 @@ export function useTransactionBreakdown() { status } = useFetcher(() => { if (serviceName && start && end && transactionType) { - return loadTransactionBreakdown({ - start, - end, - serviceName, - transactionName, - transactionType, - uiFilters + return callApmApi({ + pathname: + '/api/apm/services/{serviceName}/transaction_groups/breakdown', + params: { + path: { serviceName }, + query: { + start, + end, + transactionName, + transactionType, + uiFilters: JSON.stringify(uiFilters) + } + } }); } }, [serviceName, start, end, transactionType, transactionName, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts index 629f6bb60e1f8..ed85303209c55 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts @@ -5,10 +5,10 @@ */ import { useMemo } from 'react'; -import { loadTransactionCharts } from '../services/rest/apm/transaction_groups'; import { getTransactionCharts } from '../selectors/chartSelectors'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; +import { callApmApi } from '../services/rest/callApmApi'; export function useTransactionCharts() { const { @@ -18,13 +18,18 @@ export function useTransactionCharts() { const { data, error, status } = useFetcher(() => { if (serviceName && start && end) { - return loadTransactionCharts({ - serviceName, - start, - end, - transactionName, - transactionType, - uiFilters + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/transaction_groups/charts', + params: { + path: { serviceName }, + query: { + start, + end, + transactionType, + transactionName, + uiFilters: JSON.stringify(uiFilters) + } + } }); } }, [serviceName, start, end, transactionName, transactionType, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts index 500595dbf44b1..3964af19bcf6b 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loadTransactionDistribution } from '../services/rest/apm/transaction_groups'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; import { useUiFilters } from '../context/UrlParamsContext'; +import { callApmApi } from '../services/rest/callApmApi'; const INITIAL_DATA = { buckets: [], @@ -29,15 +29,23 @@ export function useTransactionDistribution(urlParams: IUrlParams) { const { data = INITIAL_DATA, status, error } = useFetcher(() => { if (serviceName && start && end && transactionType && transactionName) { - return loadTransactionDistribution({ - serviceName, - start, - end, - transactionType, - transactionName, - transactionId, - traceId, - uiFilters + return callApmApi({ + pathname: + '/api/apm/services/{serviceName}/transaction_groups/distribution', + params: { + path: { + serviceName + }, + query: { + start, + end, + transactionType, + transactionName, + transactionId, + traceId, + uiFilters: JSON.stringify(uiFilters) + } + } }); } }, [ diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts index fc3a828dfdf77..1d6b0bae58955 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts @@ -5,11 +5,11 @@ */ import { useMemo } from 'react'; -import { loadTransactionList } from '../services/rest/apm/transaction_groups'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; import { TransactionGroupListAPIResponse } from '../../server/lib/transaction_groups'; +import { callApmApi } from '../services/rest/callApmApi'; const getRelativeImpact = ( impact: number, @@ -45,12 +45,17 @@ export function useTransactionList(urlParams: IUrlParams) { const uiFilters = useUiFilters(urlParams); const { data = [], error, status } = useFetcher(() => { if (serviceName && start && end && transactionType) { - return loadTransactionList({ - serviceName, - start, - end, - transactionType, - uiFilters + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/transaction_groups', + params: { + path: { serviceName }, + query: { + start, + end, + transactionType, + uiFilters: JSON.stringify(uiFilters) + } + } }); } }, [serviceName, start, end, transactionType, uiFilters]); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts b/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts index fd2ed152c79f7..d3b636e3a4add 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts @@ -6,9 +6,9 @@ import { useMemo } from 'react'; import { getWaterfall } from '../components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; -import { loadTrace } from '../services/rest/apm/traces'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; +import { callApmApi } from '../services/rest/callApmApi'; const INITIAL_DATA = { root: undefined, @@ -20,7 +20,16 @@ export function useWaterfall(urlParams: IUrlParams) { const { traceId, start, end, transactionId } = urlParams; const { data = INITIAL_DATA, status, error } = useFetcher(() => { if (traceId && start && end) { - return loadTrace({ traceId, start, end }); + return callApmApi({ + pathname: '/api/apm/traces/{traceId}', + params: { + path: { traceId }, + query: { + start, + end + } + } + }); } }, [traceId, start, end]); diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts new file mode 100644 index 0000000000000..a5dd4ce569fb1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts @@ -0,0 +1,84 @@ +/* + * 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 callApiExports from '../rest/callApi'; +import { callApmApi } from '../rest/callApmApi'; + +jest.mock('ui/kfetch'); + +const callApi = jest + .spyOn(callApiExports, 'callApi') + .mockImplementation(() => Promise.resolve(null)); + +describe('callApmApi', () => { + afterEach(() => { + callApi.mockClear(); + }); + + it('should format the pathname with the given path params', async () => { + await callApmApi({ + pathname: '/api/apm/{param1}/to/{param2}', + params: { + path: { + param1: 'foo', + param2: 'bar' + } + } + } as never); + + expect(callApi).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: '/api/apm/foo/to/bar' + }) + ); + }); + + it('should add the query parameters to the options object', async () => { + await callApmApi({ + pathname: '/api/apm', + params: { + query: { + foo: 'bar', + bar: 'foo' + } + } + } as never); + + expect(callApi).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: '/api/apm', + query: { + foo: 'bar', + bar: 'foo' + } + }) + ); + }); + + it('should stringify the body and add it to the options object', async () => { + await callApmApi({ + pathname: '/api/apm', + method: 'POST', + params: { + body: { + foo: 'bar', + bar: 'foo' + } + } + } as never); + + expect(callApi).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: '/api/apm', + method: 'POST', + body: JSON.stringify({ + foo: 'bar', + bar: 'foo' + }) + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts deleted file mode 100644 index 2799e89070f35..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 { ErrorDistributionAPIResponse } from '../../../../server/lib/errors/distribution/get_distribution'; -import { ErrorGroupAPIResponse } from '../../../../server/lib/errors/get_error_group'; -import { ErrorGroupListAPIResponse } from '../../../../server/lib/errors/get_error_groups'; -import { callApi } from '../callApi'; -import { UIFilters } from '../../../../typings/ui-filters'; - -export async function loadErrorGroupList({ - serviceName, - start, - end, - uiFilters, - sortField, - sortDirection -}: { - serviceName: string; - start: string; - end: string; - uiFilters: UIFilters; - sortField?: string; - sortDirection?: string; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/errors`, - query: { - start, - end, - sortField, - sortDirection, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadErrorGroupDetails({ - serviceName, - start, - end, - uiFilters, - errorGroupId -}: { - serviceName: string; - start: string; - end: string; - errorGroupId: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/errors/${errorGroupId}`, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadErrorDistribution({ - serviceName, - start, - end, - uiFilters, - errorGroupId -}: { - serviceName: string; - start: string; - end: string; - uiFilters: UIFilters; - errorGroupId?: string; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/errors/distribution`, - query: { - start, - end, - groupId: errorGroupId, - uiFilters: JSON.stringify(uiFilters) - } - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts deleted file mode 100644 index a62f36478e084..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 { MetricsChartsByAgentAPIResponse } from '../../../../server/lib/metrics/get_metrics_chart_data_by_agent'; -import { callApi } from '../callApi'; -import { UIFilters } from '../../../../typings/ui-filters'; - -export async function loadMetricsChartData({ - serviceName, - agentName, - start, - end, - uiFilters -}: { - serviceName: string; - agentName: string; - start: string; - end: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/metrics/charts`, - query: { - start, - end, - agentName, - uiFilters: JSON.stringify(uiFilters) - } - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts deleted file mode 100644 index 045993d4fbeae..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { ServiceAgentNameAPIResponse } from '../../../../server/lib/services/get_service_agent_name'; -import { ServiceListAPIResponse } from '../../../../server/lib/services/get_services'; -import { callApi } from '../callApi'; -import { UIFilters } from '../../../../typings/ui-filters'; -import { ServiceTransactionTypesAPIResponse } from '../../../../server/lib/services/get_service_transaction_types'; - -export async function loadServiceList({ - start, - end, - uiFilters -}: { - start: string; - end: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services`, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadServiceAgentName({ - serviceName, - start, - end -}: { - serviceName: string; - start: string; - end: string; -}) { - const { agentName } = await callApi({ - pathname: `/api/apm/services/${serviceName}/agent_name`, - query: { - start, - end - } - }); - - return agentName; -} - -export async function loadServiceTransactionTypes({ - serviceName, - start, - end -}: { - serviceName: string; - start: string; - end: string; -}) { - const { transactionTypes } = await callApi< - ServiceTransactionTypesAPIResponse - >({ - pathname: `/api/apm/services/${serviceName}/transaction_types`, - query: { - start, - end - } - }); - return transactionTypes; -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/settings.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/settings.ts deleted file mode 100644 index 33b99e1d31761..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/settings.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 { UpdateAgentConfigurationAPIResponse } from '../../../../server/lib/settings/agent_configuration/update_configuration'; -import { callApi } from '../callApi'; -import { AgentConfigurationIntake } from '../../../../server/lib/settings/agent_configuration/configuration_types'; -import { AgentConfigurationServicesAPIResponse } from '../../../../server/lib/settings/agent_configuration/get_service_names'; -import { CreateAgentConfigurationAPIResponse } from '../../../../server/lib/settings/agent_configuration/create_configuration'; -import { AgentConfigurationListAPIResponse } from '../../../../server/lib/settings/agent_configuration/list_configurations'; -import { AgentConfigurationEnvironmentsAPIResponse } from '../../../../server/lib/settings/agent_configuration/get_environments'; - -export async function loadAgentConfigurationServices() { - return callApi({ - pathname: `/api/apm/settings/agent-configuration/services` - }); -} - -export async function loadAgentConfigurationEnvironments({ - serviceName -}: { - serviceName: string; -}) { - return callApi({ - pathname: `/api/apm/settings/agent-configuration/services/${serviceName}/environments` - }); -} - -export async function createAgentConfiguration( - configuration: AgentConfigurationIntake -) { - return callApi({ - pathname: `/api/apm/settings/agent-configuration/new`, - method: 'POST', - body: JSON.stringify(configuration) - }); -} - -export async function updateAgentConfiguration( - configurationId: string, - configuration: AgentConfigurationIntake -) { - return callApi({ - pathname: `/api/apm/settings/agent-configuration/${configurationId}`, - method: 'PUT', - body: JSON.stringify(configuration) - }); -} - -export async function deleteAgentConfiguration(configId: string) { - return callApi({ - pathname: `/api/apm/settings/agent-configuration/${configId}`, - method: 'DELETE' - }); -} - -export async function loadAgentConfigurationList() { - return callApi({ - pathname: `/api/apm/settings/agent-configuration` - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts deleted file mode 100644 index 4bcb379550279..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { TraceAPIResponse } from '../../../../server/lib/traces/get_trace'; -import { callApi } from '../callApi'; -import { UIFilters } from '../../../../typings/ui-filters'; -import { TransactionGroupListAPIResponse } from '../../../../server/lib/transaction_groups'; - -export async function loadTrace({ - traceId, - start, - end -}: { - traceId: string; - start: string; - end: string; -}) { - return callApi({ - pathname: `/api/apm/traces/${traceId}`, - query: { - start, - end - } - }); -} - -export async function loadTraceList({ - start, - end, - uiFilters -}: { - start: string; - end: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: '/api/apm/traces', - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts deleted file mode 100644 index 9791487609ff6..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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 { TransactionBreakdownAPIResponse } from '../../../../server/lib/transactions/breakdown'; -import { TimeSeriesAPIResponse } from '../../../../server/lib/transactions/charts'; -import { TransactionDistributionAPIResponse } from '../../../../server/lib/transactions/distribution'; -import { callApi } from '../callApi'; -import { UIFilters } from '../../../../typings/ui-filters'; -import { TransactionGroupListAPIResponse } from '../../../../server/lib/transaction_groups'; - -export async function loadTransactionList({ - serviceName, - start, - end, - uiFilters, - transactionType -}: { - serviceName: string; - start: string; - end: string; - transactionType: string; - uiFilters: UIFilters; -}) { - return await callApi({ - pathname: `/api/apm/services/${serviceName}/transaction_groups`, - query: { - start, - end, - transactionType, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadTransactionDistribution({ - serviceName, - start, - end, - transactionName, - transactionType, - transactionId, - traceId, - uiFilters -}: { - serviceName: string; - start: string; - end: string; - transactionType: string; - transactionName: string; - transactionId?: string; - traceId?: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/transaction_groups/distribution`, - query: { - start, - end, - transactionType, - transactionName, - transactionId, - traceId, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadTransactionCharts({ - serviceName, - start, - end, - uiFilters, - transactionType, - transactionName -}: { - serviceName: string; - start: string; - end: string; - transactionType?: string; - transactionName?: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/transaction_groups/charts`, - query: { - start, - end, - transactionType, - transactionName, - uiFilters: JSON.stringify(uiFilters) - } - }); -} - -export async function loadTransactionBreakdown({ - serviceName, - start, - end, - transactionName, - transactionType, - uiFilters -}: { - serviceName: string; - start: string; - end: string; - transactionName?: string; - transactionType: string; - uiFilters: UIFilters; -}) { - return callApi({ - pathname: `/api/apm/services/${serviceName}/transaction_groups/breakdown`, - query: { - start, - end, - transactionName, - transactionType, - uiFilters: JSON.stringify(uiFilters) - } - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/ui_filters.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/ui_filters.ts deleted file mode 100644 index c8c76292202e4..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/ui_filters.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 { EnvironmentUIFilterAPIResponse } from '../../../../server/lib/ui_filters/get_environments'; -import { callApi } from '../callApi'; - -export async function loadEnvironmentsFilter({ - serviceName, - start, - end -}: { - serviceName?: string; - start: string; - end: string; -}) { - return callApi({ - pathname: '/api/apm/ui_filters/environments', - query: { - start, - end, - serviceName - } - }); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/callApmApi.ts b/x-pack/legacy/plugins/apm/public/services/rest/callApmApi.ts new file mode 100644 index 0000000000000..5515728020949 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/services/rest/callApmApi.ts @@ -0,0 +1,27 @@ +/* + * 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 { callApi } from './callApi'; +import { APMAPI } from '../../../server/routes/create_apm_api'; +import { Client } from '../../../server/routes/typings'; + +export const callApmApi: Client = (options => { + const { pathname, params = {}, ...opts } = options; + + const path = (params.path || {}) as Record; + const body = params.body ? { body: JSON.stringify(params.body) } : undefined; + const query = params.query ? { query: params.query } : undefined; + + const formattedPathname = Object.keys(path).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, path[paramName]); + }, pathname); + + return callApi({ + ...opts, + pathname: formattedPathname, + ...body, + ...query + }) as any; +}) as Client; diff --git a/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts b/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts index ec0dfc12a7522..82396ca47c1dc 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts @@ -5,7 +5,7 @@ */ import { memoize } from 'lodash'; -import { callApi } from './callApi'; +import { callApmApi } from './callApmApi'; export interface ISavedObject { attributes: { @@ -18,9 +18,8 @@ export interface ISavedObject { export const getAPMIndexPattern = memoize(async () => { try { - return await callApi({ - method: 'GET', - pathname: `/api/apm/index_pattern` + return await callApmApi({ + pathname: '/api/apm/index_pattern' }); } catch (error) { return; diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/get_error_groups.ts b/x-pack/legacy/plugins/apm/server/lib/errors/get_error_groups.ts index df37568c770fc..95e88ef0a49cf 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/get_error_groups.ts +++ b/x-pack/legacy/plugins/apm/server/lib/errors/get_error_groups.ts @@ -29,8 +29,8 @@ export async function getErrorGroups({ setup }: { serviceName: string; - sortField: string; - sortDirection: string; + sortField?: string; + sortDirection?: string; setup: Setup; }) { const { client } = setup; diff --git a/x-pack/legacy/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/legacy/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index f5089f0508f45..e84334cb7db56 100644 --- a/x-pack/legacy/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/legacy/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -46,5 +46,5 @@ export async function getServiceNames({ setup }: { setup: Setup }) { const resp = await client.search(params); const buckets = idx(resp.aggregations, _ => _.services.buckets) || []; - return buckets.map(bucket => bucket.key); + return buckets.map(bucket => bucket.key).sort(); } diff --git a/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts b/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts index 90af3befcdfa3..89411dbabc493 100644 --- a/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts +++ b/x-pack/legacy/plugins/apm/server/new-platform/plugin.ts @@ -7,25 +7,12 @@ import { InternalCoreSetup } from 'src/core/server'; import { makeApmUsageCollector } from '../lib/apm_telemetry'; import { CoreSetupWithUsageCollector } from '../lib/apm_telemetry/make_apm_usage_collector'; -import { initErrorsApi } from '../routes/errors'; -import { initMetricsApi } from '../routes/metrics'; -import { initServicesApi } from '../routes/services'; -import { initTracesApi } from '../routes/traces'; -import { initTransactionGroupsApi } from '../routes/transaction_groups'; -import { initUIFiltersApi } from '../routes/ui_filters'; -import { initIndexPatternApi } from '../routes/index_pattern'; -import { initSettingsApi } from '../routes/settings'; +import { createApmApi } from '../routes/create_apm_api'; export class Plugin { public setup(core: InternalCoreSetup) { - initUIFiltersApi(core); - initTransactionGroupsApi(core); - initTracesApi(core); - initServicesApi(core); - initSettingsApi(core); - initErrorsApi(core); - initMetricsApi(core); - initIndexPatternApi(core); + createApmApi().init(core); + makeApmUsageCollector(core as CoreSetupWithUsageCollector); } } diff --git a/x-pack/legacy/plugins/apm/server/routes/__test__/routeFailures.test.ts b/x-pack/legacy/plugins/apm/server/routes/__test__/routeFailures.test.ts deleted file mode 100644 index b440b7a576b5e..0000000000000 --- a/x-pack/legacy/plugins/apm/server/routes/__test__/routeFailures.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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 { flatten } from 'lodash'; -import { InternalCoreSetup } from 'src/core/server'; -import { initErrorsApi } from '../errors'; -import { initServicesApi } from '../services'; -import { initTracesApi } from '../traces'; - -describe('route handlers should fail with a Boom error', () => { - let consoleErrorSpy: any; - - function testRouteFailures(init: (core: InternalCoreSetup) => void) { - const mockServer = { route: jest.fn() }; - const mockCore = ({ - http: { - server: mockServer - } - } as unknown) as InternalCoreSetup; - init(mockCore); - expect(mockServer.route).toHaveBeenCalled(); - - const mockCluster = { - callWithRequest: () => Promise.reject(new Error('request failed')) - }; - const mockConfig = { get: jest.fn() }; - const mockReq = { - params: {}, - query: {}, - server: { - config: () => mockConfig, - plugins: { - elasticsearch: { - getCluster: () => mockCluster - } - } - }, - getUiSettingsService: jest.fn(() => ({ - get: jest.fn() - })) - }; - - const routes = flatten(mockServer.route.mock.calls); - routes.forEach((route, i) => { - test(`${route.method} ${route.path}"`, async () => { - await expect(route.handler(mockReq)).rejects.toMatchObject({ - message: 'request failed', - isBoom: true - }); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - }); - }); - } - - beforeEach(() => { - consoleErrorSpy = jest - .spyOn(global.console, 'error') - .mockImplementation(undefined); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - describe('error routes', () => { - testRouteFailures(initErrorsApi); - }); - - describe('service routes', () => { - testRouteFailures(initServicesApi); - }); - - describe('trace routes', () => { - testRouteFailures(initTracesApi); - }); -}); diff --git a/x-pack/legacy/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/legacy/plugins/apm/server/routes/create_api/index.test.ts new file mode 100644 index 0000000000000..8c57b4e5f2848 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/create_api/index.test.ts @@ -0,0 +1,257 @@ +/* + * 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 { createApi } from './index'; +import { InternalCoreSetup } from 'src/core/server'; +import { Params } from '../typings'; + +const getCoreMock = () => + (({ + http: { + server: { + route: jest.fn() + } + } + } as unknown) as InternalCoreSetup & { + http: { server: { route: ReturnType } }; + }); + +describe('createApi', () => { + it('registers a route with the server', () => { + const coreMock = getCoreMock(); + + createApi() + .add(() => ({ + path: '/foo', + handler: async () => null + })) + .add(() => ({ + path: '/bar', + method: 'POST', + params: { + body: t.string + }, + handler: async () => null + })) + .init(coreMock); + + expect(coreMock.http.server.route).toHaveBeenCalledTimes(2); + + const firstRoute = coreMock.http.server.route.mock.calls[0][0]; + + expect(firstRoute).toEqual({ + method: 'GET', + options: { + tags: ['access:apm'] + }, + path: '/foo', + handler: expect.any(Function) + }); + + const secondRoute = coreMock.http.server.route.mock.calls[1][0]; + + expect(secondRoute).toEqual({ + method: 'POST', + options: { + tags: ['access:apm'] + }, + path: '/bar', + handler: expect.any(Function) + }); + }); + + describe('when validating', () => { + const initApi = (params: Params) => { + const core = getCoreMock(); + const handler = jest.fn(); + createApi() + .add(() => ({ + path: '/foo', + params, + handler + })) + .init(core); + + const route = core.http.server.route.mock.calls[0][0]; + + const routeHandler = route.handler; + + route.handler = (requestMock: any) => { + return routeHandler({ + // stub hapi's default values + params: {}, + query: {}, + payload: null, + ...requestMock + }); + }; + + return { route, handler }; + }; + + it('adds a _debug query parameter by default', () => { + const { handler, route } = initApi({}); + + expect(() => + route.handler({ + query: { + _debug: true + } + }) + ).not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + + const params = handler.mock.calls[0][1]; + + expect(params).toEqual({}); + + expect(() => + route.handler({ + query: { + _debug: 1 + } + }) + ).toThrow(); + }); + + it('throws if any parameters are used but no types are defined', () => { + const { route } = initApi({}); + + expect(() => + route.handler({ + query: { + _debug: true, + extra: '' + } + }) + ).toThrow(); + + expect(() => + route.handler({ + payload: { foo: 'bar' } + }) + ).toThrow(); + + expect(() => + route.handler({ + params: { + foo: 'bar' + } + }) + ).toThrow(); + }); + + it('validates path parameters', () => { + const { handler, route } = initApi({ path: t.type({ foo: t.string }) }); + + expect(() => + route.handler({ + params: { + foo: 'bar' + } + }) + ).not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + + const params = handler.mock.calls[0][1]; + + expect(params).toEqual({ + path: { + foo: 'bar' + } + }); + + handler.mockClear(); + + expect(() => + route.handler({ + params: { + bar: 'foo' + } + }) + ).toThrow(); + + expect(() => + route.handler({ + params: { + foo: 9 + } + }) + ).toThrow(); + + expect(() => + route.handler({ + params: { + foo: 'bar', + extra: '' + } + }) + ).toThrow(); + }); + + it('validates body parameters', () => { + const { handler, route } = initApi({ body: t.string }); + + expect(() => + route.handler({ + payload: '' + }) + ).not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + + const params = handler.mock.calls[0][1]; + + expect(params).toEqual({ + body: '' + }); + + handler.mockClear(); + + expect(() => + route.handler({ + payload: null + }) + ).toThrow(); + }); + + it('validates query parameters', () => { + const { handler, route } = initApi({ query: t.type({ bar: t.string }) }); + + expect(() => + route.handler({ + query: { + bar: '', + _debug: true + } + }) + ).not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + + const params = handler.mock.calls[0][1]; + + expect(params).toEqual({ + query: { + bar: '' + } + }); + + handler.mockClear(); + + expect(() => + route.handler({ + query: { + bar: '', + foo: '' + } + }) + ).toThrow(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/server/routes/create_api/index.ts b/x-pack/legacy/plugins/apm/server/routes/create_api/index.ts new file mode 100644 index 0000000000000..fbe5195673879 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/create_api/index.ts @@ -0,0 +1,113 @@ +/* + * 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 { merge, pick, omit } from 'lodash'; +import Boom from 'boom'; +import { InternalCoreSetup } from 'src/core/server'; +import { Request, ResponseToolkit } from 'hapi'; +import * as t from 'io-ts'; +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { + ServerAPI, + RouteFactoryFn, + HttpMethod, + Route, + Params +} from '../typings'; + +export function createApi() { + const factoryFns: Array> = []; + const api: ServerAPI<{}> = { + _S: {}, + add(fn) { + factoryFns.push(fn); + return this as any; + }, + init(core: InternalCoreSetup) { + const { server } = core.http; + factoryFns.forEach(fn => { + const { params = {}, ...route } = fn(core) as Route< + string, + HttpMethod, + Params, + any + >; + + const rts = { + // add _debug query parameter to all routes + query: params.query + ? t.exact( + t.intersection([params.query, t.partial({ _debug: t.boolean })]) + ) + : t.union([t.strict({}), t.strict({ _debug: t.boolean })]), + path: params.path || t.strict({}), + body: params.body || t.null + }; + + server.route( + merge( + { + options: { + tags: ['access:apm'] + }, + method: 'GET' + }, + route, + { + handler: (request: Request, h: ResponseToolkit) => { + const paramMap = { + path: request.params, + body: request.payload, + query: request.query + }; + + const parsedParams = (Object.keys(rts) as Array< + keyof typeof rts + >).reduce( + (acc, key) => { + let codec = rts[key]; + const value = paramMap[key]; + + // Use exact props where possible (only possible for types with props) + if ('props' in codec) { + codec = t.exact(codec); + } + + const result = codec.decode(value); + + if (result.isLeft()) { + throw Boom.badRequest(PathReporter.report(result)[0]); + } + + // hide _debug from route handlers + const parsedValue = + key === 'query' + ? omit(result.value, '_debug') + : result.value; + + return { + ...acc, + [key]: parsedValue + }; + }, + {} as Record + ); + + return route.handler( + request, + // only return values for parameters that have runtime types + pick(parsedParams, Object.keys(params)), + h + ); + } + } + ) + ); + }); + } + }; + + return api; +} diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts new file mode 100644 index 0000000000000..b4605032a0e0b --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts @@ -0,0 +1,81 @@ +/* + * 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 { indexPatternRoute } from './index_pattern'; +import { + errorDistributionRoute, + errorGroupsRoute, + errorsRoute +} from './errors'; +import { + serviceAgentNameRoute, + serviceTransactionTypesRoute, + servicesRoute +} from './services'; +import { + agentConfigurationRoute, + agentConfigurationSearchRoute, + createAgentConfigurationRoute, + deleteAgentConfigurationRoute, + listAgentConfigurationEnvironmentsRoute, + listAgentConfigurationServicesRoute, + updateAgentConfigurationRoute +} from './settings'; +import { metricsChartsRoute } from './metrics'; +import { tracesRoute, tracesByIdRoute } from './traces'; +import { + transactionGroupsBreakdownRoute, + transactionGroupsChartsRoute, + transactionGroupsDistributionRoute, + transactionGroupsRoute +} from './transaction_groups'; +import { + errorGroupsLocalFiltersRoute, + metricsLocalFiltersRoute, + servicesLocalFiltersRoute, + tracesLocalFiltersRoute, + transactionGroupsLocalFiltersRoute, + transactionsLocalFiltersRoute, + uiFiltersEnvironmentsRoute +} from './ui_filters'; +import { createApi } from './create_api'; + +const createApmApi = () => { + const api = createApi() + .add(indexPatternRoute) + .add(errorDistributionRoute) + .add(errorGroupsRoute) + .add(errorsRoute) + .add(metricsChartsRoute) + .add(serviceAgentNameRoute) + .add(serviceTransactionTypesRoute) + .add(servicesRoute) + .add(agentConfigurationRoute) + .add(agentConfigurationSearchRoute) + .add(createAgentConfigurationRoute) + .add(deleteAgentConfigurationRoute) + .add(listAgentConfigurationEnvironmentsRoute) + .add(listAgentConfigurationServicesRoute) + .add(updateAgentConfigurationRoute) + .add(tracesRoute) + .add(tracesByIdRoute) + .add(transactionGroupsBreakdownRoute) + .add(transactionGroupsChartsRoute) + .add(transactionGroupsDistributionRoute) + .add(transactionGroupsRoute) + .add(errorGroupsLocalFiltersRoute) + .add(metricsLocalFiltersRoute) + .add(servicesLocalFiltersRoute) + .add(tracesLocalFiltersRoute) + .add(transactionGroupsLocalFiltersRoute) + .add(transactionsLocalFiltersRoute) + .add(uiFiltersEnvironmentsRoute); + + return api; +}; + +export type APMAPI = ReturnType; + +export { createApmApi }; diff --git a/x-pack/legacy/plugins/apm/server/routes/create_route.ts b/x-pack/legacy/plugins/apm/server/routes/create_route.ts new file mode 100644 index 0000000000000..892f4ec40de72 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/create_route.ts @@ -0,0 +1,15 @@ +/* + * 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 { RouteFactoryFn, HttpMethod, Params } from './typings'; + +export function createRoute< + TName extends string, + TReturn, + TMethod extends HttpMethod = 'GET', + TParams extends Params = {} +>(fn: RouteFactoryFn) { + return fn; +} diff --git a/x-pack/legacy/plugins/apm/server/routes/default_api_types.ts b/x-pack/legacy/plugins/apm/server/routes/default_api_types.ts new file mode 100644 index 0000000000000..0316857357c02 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/default_api_types.ts @@ -0,0 +1,15 @@ +/* + * 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 { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; + +export const rangeRt = t.type({ + start: dateAsStringRt, + end: dateAsStringRt +}); + +export const uiFiltersRt = t.type({ uiFilters: t.string }); diff --git a/x-pack/legacy/plugins/apm/server/routes/errors.ts b/x-pack/legacy/plugins/apm/server/routes/errors.ts index 1afdca73299fd..73bb6e97b999b 100644 --- a/x-pack/legacy/plugins/apm/server/routes/errors.ts +++ b/x-pack/legacy/plugins/apm/server/routes/errors.ts @@ -4,88 +4,77 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import Joi from 'joi'; -import { InternalCoreSetup } from 'src/core/server'; +import * as t from 'io-ts'; +import { createRoute } from './create_route'; import { getErrorDistribution } from '../lib/errors/distribution/get_distribution'; import { getErrorGroup } from '../lib/errors/get_error_group'; import { getErrorGroups } from '../lib/errors/get_error_groups'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; +import { uiFiltersRt, rangeRt } from './default_api_types'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; +export const errorsRoute = createRoute(core => ({ + path: '/api/apm/services/{serviceName}/errors', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.partial({ + sortField: t.string, + sortDirection: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { query, path }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { sortField, sortDirection } = query; -export function initErrorsApi(core: InternalCoreSetup) { - const { server } = core.http; - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/errors`, - options: { - validate: { - query: withDefaultValidators({ - sortField: Joi.string(), - sortDirection: Joi.string() - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - const { sortField, sortDirection } = req.query as { - sortField: string; - sortDirection: string; - }; + return getErrorGroups({ + serviceName, + sortField, + sortDirection, + setup + }); + } +})); - return getErrorGroups({ - serviceName, - sortField, - sortDirection, - setup - }).catch(defaultErrorHandler); - } - }); +export const errorGroupsRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/errors/{groupId}', + params: { + path: t.type({ + serviceName: t.string, + groupId: t.string + }), + query: t.intersection([uiFiltersRt, rangeRt]) + }, + handler: async (req, { path }) => { + const setup = await setupRequest(req); + const { serviceName, groupId } = path; + return getErrorGroup({ serviceName, groupId, setup }); + } +})); - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/errors/{groupId}`, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName, groupId } = req.params; - return getErrorGroup({ serviceName, groupId, setup }).catch( - defaultErrorHandler - ); - } - }); - - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/errors/distribution`, - options: { - validate: { - query: withDefaultValidators({ - groupId: Joi.string() - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - const { groupId } = req.query as { groupId?: string }; - return getErrorDistribution({ serviceName, groupId, setup }).catch( - defaultErrorHandler - ); - } - }); -} +export const errorDistributionRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/errors/distribution', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.partial({ + groupId: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { groupId } = query; + return getErrorDistribution({ serviceName, groupId, setup }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts b/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts index 12499b833173e..e838fb1a4b526 100644 --- a/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts @@ -3,27 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import Boom from 'boom'; -import { InternalCoreSetup } from 'src/core/server'; import { getAPMIndexPattern } from '../lib/index_pattern'; +import { createRoute } from './create_route'; -const defaultErrorHandler = (err: Error & { status?: number }) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: err.status || 500 }); -}; - -export function initIndexPatternApi(core: InternalCoreSetup) { - const { server } = core.http; - server.route({ - method: 'GET', - path: '/api/apm/index_pattern', - options: { - tags: ['access:apm'] - }, - handler: async req => { - return await getAPMIndexPattern(server).catch(defaultErrorHandler); - } - }); -} +export const indexPatternRoute = createRoute(core => ({ + path: '/api/apm/index_pattern', + handler: async () => { + const { server } = core.http; + return await getAPMIndexPattern(server); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/metrics.ts b/x-pack/legacy/plugins/apm/server/routes/metrics.ts index 55b6bdbae3b37..3bd8939a1f7d2 100644 --- a/x-pack/legacy/plugins/apm/server/routes/metrics.ts +++ b/x-pack/legacy/plugins/apm/server/routes/metrics.ts @@ -4,43 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import Joi from 'joi'; -import { InternalCoreSetup } from 'src/core/server'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; +import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; - -export function initMetricsApi(core: InternalCoreSetup) { - const { server } = core.http; - - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/metrics/charts`, - options: { - validate: { - query: withDefaultValidators({ - agentName: Joi.string().required() - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - // casting approach recommended here: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25605 - const { agentName } = req.query as { agentName: string }; - return await getMetricsChartDataByAgent({ - setup, - serviceName, - agentName - }).catch(defaultErrorHandler); - } - }); -} +export const metricsChartsRoute = createRoute(() => ({ + path: `/api/apm/services/{serviceName}/metrics/charts`, + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.type({ + agentName: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { agentName } = query; + return await getMetricsChartDataByAgent({ + setup, + serviceName, + agentName + }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index c6143f522f382..0c1f54c661f32 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -4,80 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { InternalCoreSetup } from 'src/core/server'; +import * as t from 'io-ts'; import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, storeApmTelemetry } from '../lib/apm_telemetry'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; -const ROOT = '/api/apm/services'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; +export const servicesRoute = createRoute(core => ({ + path: '/api/apm/services', + params: { + query: t.intersection([uiFiltersRt, rangeRt]) + }, + handler: async req => { + const setup = await setupRequest(req); + const services = await getServices(setup); + const { server } = core.http; -export function initServicesApi(core: InternalCoreSetup) { - const { server } = core.http; - server.route({ - method: 'GET', - path: ROOT, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const services = await getServices(setup).catch(defaultErrorHandler); + // Store telemetry data derived from services + const agentNames = services.items.map( + ({ agentName }) => agentName as AgentName + ); + const apmTelemetry = createApmTelementry(agentNames); + storeApmTelemetry(server, apmTelemetry); - // Store telemetry data derived from services - const agentNames = services.items.map( - ({ agentName }) => agentName as AgentName - ); - const apmTelemetry = createApmTelementry(agentNames); - storeApmTelemetry(server, apmTelemetry); + return services; + } +})); - return services; - } - }); +export const serviceAgentNameRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/agent_name', + params: { + path: t.type({ + serviceName: t.string + }), + query: rangeRt + }, + handler: async (req, { path }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + return getServiceAgentName(serviceName, setup); + } +})); - server.route({ - method: 'GET', - path: `${ROOT}/{serviceName}/agent_name`, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - return getServiceAgentName(serviceName, setup).catch(defaultErrorHandler); - } - }); - - server.route({ - method: 'GET', - path: `${ROOT}/{serviceName}/transaction_types`, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - return getServiceTransactionTypes(serviceName, setup).catch( - defaultErrorHandler - ); - } - }); -} +export const serviceTransactionTypesRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_types', + params: { + path: t.type({ + serviceName: t.string + }), + query: rangeRt + }, + handler: async (req, { path }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + return getServiceTransactionTypes(serviceName, setup); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/settings.ts b/x-pack/legacy/plugins/apm/server/routes/settings.ts index c23ead9d4498c..d5557a39c1f70 100644 --- a/x-pack/legacy/plugins/apm/server/routes/settings.ts +++ b/x-pack/legacy/plugins/apm/server/routes/settings.ts @@ -4,220 +4,158 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { InternalCoreSetup } from 'src/core/server'; -import Joi from 'joi'; +import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceNames } from '../lib/settings/agent_configuration/get_service_names'; import { createConfiguration } from '../lib/settings/agent_configuration/create_configuration'; import { updateConfiguration } from '../lib/settings/agent_configuration/update_configuration'; -import { AgentConfigurationIntake } from '../lib/settings/agent_configuration/configuration_types'; import { searchConfigurations } from '../lib/settings/agent_configuration/search'; import { listConfigurations } from '../lib/settings/agent_configuration/list_configurations'; import { getEnvironments } from '../lib/settings/agent_configuration/get_environments'; import { deleteConfiguration } from '../lib/settings/agent_configuration/delete_configuration'; import { createApmAgentConfigurationIndex } from '../lib/settings/agent_configuration/create_agent_config_index'; - -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; - -export function initSettingsApi(core: InternalCoreSetup) { - const { server } = core.http; - - // get list of configurations - server.route({ - method: 'GET', - path: '/api/apm/settings/agent-configuration', - options: { - validate: { - query: { - _debug: Joi.bool() - } - }, - tags: ['access:apm'] - }, - handler: async req => { - await createApmAgentConfigurationIndex(server); - - const setup = await setupRequest(req); - return await listConfigurations({ - setup - }).catch(defaultErrorHandler); - } - }); - - // delete configuration - server.route({ - method: 'DELETE', - path: `/api/apm/settings/agent-configuration/{configurationId}`, - options: { - validate: { - query: { - _debug: Joi.bool() - } - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { configurationId } = req.params; - return await deleteConfiguration({ - configurationId, - setup - }).catch(defaultErrorHandler); - } - }); - - // get list of services - server.route({ - method: 'GET', - path: `/api/apm/settings/agent-configuration/services`, - options: { - validate: { - query: { - _debug: Joi.bool() - } - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - return await getServiceNames({ - setup - }).catch(defaultErrorHandler); - } - }); - - // get environments for service - server.route({ - method: 'GET', - path: `/api/apm/settings/agent-configuration/services/{serviceName}/environments`, - options: { - validate: { - query: { - _debug: Joi.bool() - } - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - return await getEnvironments({ - serviceName, - setup - }).catch(defaultErrorHandler); - } - }); - - const agentConfigPayloadValidation = { - settings: Joi.object({ - transaction_sample_rate: Joi.number() - .min(0) - .max(1) - .precision(3) - .required() - .options({ convert: false }) +import { createRoute } from './create_route'; +import { transactionSampleRateRt } from '../../common/runtime_types/transaction_sample_rate_rt'; + +// get list of configurations +export const agentConfigurationRoute = createRoute(core => ({ + path: '/api/apm/settings/agent-configuration', + handler: async req => { + await createApmAgentConfigurationIndex(core.http.server); + + const setup = await setupRequest(req); + return await listConfigurations({ + setup + }); + } +})); + +// delete configuration +export const deleteAgentConfigurationRoute = createRoute(() => ({ + method: 'DELETE', + path: '/api/apm/settings/agent-configuration/{configurationId}', + params: { + path: t.type({ + configurationId: t.string + }) + }, + handler: async (req, { path }) => { + const setup = await setupRequest(req); + const { configurationId } = path; + return await deleteConfiguration({ + configurationId, + setup + }); + } +})); + +// get list of services +export const listAgentConfigurationServicesRoute = createRoute(() => ({ + method: 'GET', + path: '/api/apm/settings/agent-configuration/services', + handler: async req => { + const setup = await setupRequest(req); + return await getServiceNames({ + setup + }); + } +})); + +const agentPayloadRt = t.type({ + settings: t.type({ + transaction_sample_rate: transactionSampleRateRt + }), + service: t.intersection([ + t.type({ + name: t.string }), - service: Joi.object({ - name: Joi.string().required(), - environment: Joi.string() + t.partial({ + environments: t.array(t.string) }) - }; - - // create configuration - server.route({ - method: 'POST', - path: `/api/apm/settings/agent-configuration/new`, - options: { - validate: { - query: { - _debug: Joi.bool() - }, - payload: agentConfigPayloadValidation - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const configuration = req.payload as AgentConfigurationIntake; - return await createConfiguration({ - configuration, - setup - }).catch(defaultErrorHandler); - } - }); + ]) +}); + +// get environments for service +export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({ + path: + '/api/apm/settings/agent-configuration/services/{serviceName}/environments', + params: { + path: t.type({ + serviceName: t.string + }) + }, + handler: async (req, { path }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + return await getEnvironments({ + serviceName, + setup + }); + } +})); + +export const createAgentConfigurationRoute = createRoute(() => ({ + method: 'POST', + path: '/api/apm/settings/agent-configuration/new', + params: { + body: agentPayloadRt + }, + handler: async (req, { body }) => { + const setup = await setupRequest(req); + return await createConfiguration({ + configuration: body, + setup + }); + } +})); + +export const updateAgentConfigurationRoute = createRoute(() => ({ + method: 'PUT', + path: `/api/apm/settings/agent-configuration/{configurationId}`, + params: { + path: t.type({ + configurationId: t.string + }), + body: agentPayloadRt + }, + handler: async (req, { path, body }) => { + const setup = await setupRequest(req); + const { configurationId } = path; + return await updateConfiguration({ + configurationId, + configuration: body, + setup + }); + } +})); + +// Lookup single configuration +export const agentConfigurationSearchRoute = createRoute(core => ({ + path: '/api/apm/settings/agent-configuration/search', + params: { + body: t.type({ + service: t.intersection([ + t.type({ name: t.string }), + t.partial({ environment: t.string }) + ]) + }) + }, + handler: async (req, { body }, h) => { + const [setup] = await Promise.all([ + setupRequest(req), + createApmAgentConfigurationIndex(core.http.server) + ]); + + const config = await searchConfigurations({ + serviceName: body.service.name, + environment: body.service.environment, + setup + }); - // update configuration - server.route({ - method: 'PUT', - path: `/api/apm/settings/agent-configuration/{configurationId}`, - options: { - validate: { - query: { - _debug: Joi.bool() - }, - payload: agentConfigPayloadValidation - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { configurationId } = req.params; - const configuration = req.payload as AgentConfigurationIntake; - return await updateConfiguration({ - configurationId, - configuration, - setup - }).catch(defaultErrorHandler); + if (!config) { + return h.response().code(404); } - }); - - // Lookup single configuration - [ - '/api/apm/settings/agent-configuration/search', - '/api/apm/settings/cm/search' // backward compatible api route for apm-server - ].forEach(path => { - server.route({ - method: 'POST', - path, - options: { - validate: { - query: { - _debug: Joi.bool() - } - }, - tags: ['access:apm'] - }, - handler: async (req, h) => { - interface Payload { - service: { - name: string; - environment?: string; - }; - } - - await createApmAgentConfigurationIndex(server); - const setup = await setupRequest(req); - const payload = req.payload as Payload; - const serviceName = payload.service.name; - const environment = payload.service.environment; - const config = await searchConfigurations({ - serviceName, - environment, - setup - }); - - if (!config) { - return h.response().code(404); - } - - return config; - } - }); - }); -} + return config; + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/traces.ts b/x-pack/legacy/plugins/apm/server/routes/traces.ts index 3b32179650e62..cd2c86a5c9ca3 100644 --- a/x-pack/legacy/plugins/apm/server/routes/traces.ts +++ b/x-pack/legacy/plugins/apm/server/routes/traces.ts @@ -4,55 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { InternalCoreSetup } from 'src/core/server'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; +import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTrace } from '../lib/traces/get_trace'; import { getTransactionGroupList } from '../lib/transaction_groups'; +import { createRoute } from './create_route'; +import { rangeRt, uiFiltersRt } from './default_api_types'; -const ROOT = '/api/apm/traces'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; +export const tracesRoute = createRoute(() => ({ + path: '/api/apm/traces', + params: { + query: t.intersection([rangeRt, uiFiltersRt]) + }, + handler: async req => { + const setup = await setupRequest(req); + return getTransactionGroupList({ type: 'top_traces' }, setup); + } +})); -export function initTracesApi(core: InternalCoreSetup) { - const { server } = core.http; - - // Get trace list - server.route({ - method: 'GET', - path: ROOT, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - return getTransactionGroupList({ type: 'top_traces' }, setup).catch( - defaultErrorHandler - ); - } - }); - - // Get individual trace - server.route({ - method: 'GET', - path: `${ROOT}/{traceId}`, - options: { - validate: { - query: withDefaultValidators() - }, - tags: ['access:apm'] - }, - handler: async req => { - const { traceId } = req.params; - const setup = await setupRequest(req); - return getTrace(traceId, setup).catch(defaultErrorHandler); - } - }); -} +export const tracesByIdRoute = createRoute(() => ({ + path: '/api/apm/traces/{traceId}', + params: { + path: t.type({ + traceId: t.string + }), + query: rangeRt + }, + handler: async (req, { path }) => { + const { traceId } = path; + const setup = await setupRequest(req); + return getTrace(traceId, setup); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts index 7fab69be1af6b..f0d395e23551e 100644 --- a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts @@ -4,146 +4,141 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import Joi from 'joi'; -import { InternalCoreSetup } from 'src/core/server'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; +import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTransactionCharts } from '../lib/transactions/charts'; import { getTransactionDistribution } from '../lib/transactions/distribution'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; import { getTransactionGroupList } from '../lib/transaction_groups'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; +export const transactionGroupsRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.type({ + transactionType: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const { serviceName } = path; + const { transactionType } = query; + const setup = await setupRequest(req); -export function initTransactionGroupsApi(core: InternalCoreSetup) { - const { server } = core.http; - - server.route({ - method: 'GET', - path: '/api/apm/services/{serviceName}/transaction_groups', - options: { - validate: { - query: withDefaultValidators({ - transactionType: Joi.string() - }) + return getTransactionGroupList( + { + type: 'top_transactions', + serviceName, + transactionType }, - tags: ['access:apm'] - }, - handler: async req => { - const { serviceName } = req.params; - const { transactionType } = req.query as { transactionType: string }; - const setup = await setupRequest(req); + setup + ); + } +})); - return getTransactionGroupList( - { - type: 'top_transactions', - serviceName, - transactionType - }, - setup - ).catch(defaultErrorHandler); - } - }); - - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/transaction_groups/charts`, - options: { - validate: { - query: withDefaultValidators({ - transactionType: Joi.string(), - transactionName: Joi.string() - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - const { transactionType, transactionName } = req.query as { - transactionType?: string; - transactionName?: string; - }; +export const transactionGroupsChartsRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/charts', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.partial({ + transactionType: t.string, + transactionName: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { transactionType, transactionName } = query; - return getTransactionCharts({ - serviceName, - transactionType, - transactionName, - setup - }).catch(defaultErrorHandler); - } - }); + return getTransactionCharts({ + serviceName, + transactionType, + transactionName, + setup + }); + } +})); - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/transaction_groups/distribution`, - options: { - validate: { - query: withDefaultValidators({ - transactionType: Joi.string(), - transactionName: Joi.string(), - transactionId: Joi.string().default(''), - traceId: Joi.string().default('') - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - const { - transactionType, - transactionName, - transactionId, - traceId - } = req.query as { - transactionType: string; - transactionName: string; - transactionId: string; - traceId: string; - }; +export const transactionGroupsDistributionRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/distribution', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.type({ + transactionType: t.string, + transactionName: t.string + }), + t.partial({ + transactionId: t.string, + traceId: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { + transactionType, + transactionName, + transactionId = '', + traceId = '' + } = query; - return getTransactionDistribution({ - serviceName, - transactionType, - transactionName, - transactionId, - traceId, - setup - }).catch(defaultErrorHandler); - } - }); + return getTransactionDistribution({ + serviceName, + transactionType, + transactionName, + transactionId, + traceId, + setup + }); + } +})); - server.route({ - method: 'GET', - path: `/api/apm/services/{serviceName}/transaction_groups/breakdown`, - options: { - validate: { - query: withDefaultValidators({ - transactionName: Joi.string(), - transactionType: Joi.string().required() - }) - } - }, - handler: async req => { - const setup = await setupRequest(req); - const { serviceName } = req.params; - const { transactionName, transactionType } = req.query as { - transactionName?: string; - transactionType: string; - }; +export const transactionGroupsBreakdownRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/breakdown', + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + t.type({ + transactionType: t.string + }), + t.partial({ + transactionName: t.string + }), + uiFiltersRt, + rangeRt + ]) + }, + handler: async (req, { path, query }) => { + const setup = await setupRequest(req); + const { serviceName } = path; + const { transactionName, transactionType } = query; - return getTransactionBreakdown({ - serviceName, - transactionName, - transactionType, - setup - }).catch(defaultErrorHandler); - } - }); -} + return getTransactionBreakdown({ + serviceName, + transactionName, + transactionType, + setup + }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/typings.ts b/x-pack/legacy/plugins/apm/server/routes/typings.ts new file mode 100644 index 0000000000000..d2989cf2b3128 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/typings.ts @@ -0,0 +1,124 @@ +/* + * 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 t from 'io-ts'; +import { Request, ResponseToolkit } from 'hapi'; +import { InternalCoreSetup } from 'src/core/server'; +import { KFetchOptions } from 'ui/kfetch'; +import { PickByValue, Optional } from 'utility-types'; + +export interface Params { + query?: t.HasProps; + path?: t.HasProps; + body?: t.Any; +} + +type DecodeParams = { + [key in keyof TParams]: TParams[key] extends t.Any + ? t.TypeOf + : never; +}; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export interface Route< + TPath extends string, + TMethod extends HttpMethod | undefined, + TParams extends Params | undefined, + TReturn +> { + path: TPath; + method?: TMethod; + params?: TParams; + handler: ( + req: Request, + params: DecodeParams, + h: ResponseToolkit + ) => Promise; +} + +export type RouteFactoryFn< + TPath extends string, + TMethod extends HttpMethod | undefined, + TParams extends Params, + TReturn +> = (core: InternalCoreSetup) => Route; + +export interface RouteState { + [key: string]: { + [key in HttpMethod]: { + params?: Params; + ret: any; + }; + }; +} + +export interface ServerAPI { + _S: TRouteState; + add< + TPath extends string, + TReturn, + // default params allow them to be optional in the route configuration object + TMethod extends HttpMethod = 'GET', + TParams extends Params = {} + >( + factoryFn: RouteFactoryFn + ): ServerAPI< + TRouteState & + { + [Key in TPath]: { + [key in TMethod]: { + ret: TReturn; + } & (TParams extends Params ? { params: TParams } : {}); + }; + } + >; + init: (core: InternalCoreSetup) => void; +} + +// without this, TS does not recognize possible existence of `params` in `options` below +interface NoParams { + params?: TParams; +} + +type GetOptionalParamKeys = keyof PickByValue< + { + [key in keyof TParams]: TParams[key] extends t.PartialType + ? false + : (TParams[key] extends t.Any ? true : false); + }, + false +>; + +// this type makes the params object optional if no required props are found +type GetParams = Exclude< + keyof TParams, + GetOptionalParamKeys +> extends never + ? NoParams>> + : { + params: Optional, GetOptionalParamKeys>; + }; + +export type Client = < + TPath extends keyof TRouteState & string, + TMethod extends keyof TRouteState[TPath], + TRouteDescription extends TRouteState[TPath][TMethod], + TParams extends TRouteDescription extends { params: Params } + ? TRouteDescription['params'] + : undefined, + TReturn extends TRouteDescription extends { ret: any } + ? TRouteDescription['ret'] + : undefined +>( + options: Omit & { + pathname: TPath; + } & (TMethod extends 'GET' ? { method?: TMethod } : { method: TMethod }) & + // Makes sure params can only be set when types were defined + (TParams extends Params + ? GetParams + : NoParams>) +) => Promise; diff --git a/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts b/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts index 28a44bd3e9e37..d1bbdf48e1da3 100644 --- a/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts +++ b/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts @@ -4,18 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import Joi, { Schema } from 'joi'; -import { InternalCoreSetup } from 'src/core/server'; +import * as t from 'io-ts'; import { omit } from 'lodash'; -import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest, Setup } from '../lib/helpers/setup_request'; import { getEnvironments } from '../lib/ui_filters/get_environments'; -import { PROJECTION, Projection } from '../../common/projections/typings'; -import { - LocalUIFilterName, - localUIFilterNames -} from '../lib/ui_filters/local_ui_filters/config'; +import { Projection } from '../../common/projections/typings'; +import { localUIFilterNames } from '../lib/ui_filters/local_ui_filters/config'; import { getUiFiltersES } from '../lib/helpers/convert_ui_filters/get_ui_filters_es'; import { getLocalUIFilters } from '../lib/ui_filters/local_ui_filters'; import { getServicesProjection } from '../../common/projections/services'; @@ -23,189 +17,185 @@ import { getTransactionGroupsProjection } from '../../common/projections/transac import { getMetricsProjection } from '../../common/projections/metrics'; import { getErrorGroupsProjection } from '../../common/projections/errors'; import { getTransactionsProjection } from '../../common/projections/transactions'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; +import { jsonRt } from '../../common/runtime_types/json_rt'; -const defaultErrorHandler = (err: Error) => { - // eslint-disable-next-line - console.error(err.stack); - throw Boom.boomify(err, { statusCode: 400 }); -}; - -export function initUIFiltersApi(core: InternalCoreSetup) { - const { server } = core.http; - server.route({ - method: 'GET', - path: '/api/apm/ui_filters/environments', - options: { - validate: { - query: withDefaultValidators({ - serviceName: Joi.string() - }) - }, - tags: ['access:apm'] +export const uiFiltersEnvironmentsRoute = createRoute(() => ({ + path: '/api/apm/ui_filters/environments', + params: { + query: t.intersection([ + t.partial({ + serviceName: t.string + }), + rangeRt + ]) + }, + handler: async (req, { query }) => { + const setup = await setupRequest(req); + const { serviceName } = query; + return getEnvironments(setup, serviceName); + } +})); + +const filterNamesRt = t.type({ + filterNames: jsonRt.pipe( + t.array(t.union(localUIFilterNames.map(name => t.literal(name)))) + ) +}); + +const localUiBaseQueryRt = t.intersection([ + filterNamesRt, + uiFiltersRt, + rangeRt +]); + +function createLocalFiltersRoute< + TPath extends string, + TProjection extends Projection, + TQueryRT extends t.HasProps +>({ + path, + getProjection, + queryRt +}: { + path: TPath; + getProjection: GetProjection; + queryRt: TQueryRT; +}) { + return createRoute(() => ({ + path, + params: { + query: queryRt + ? t.intersection([queryRt, localUiBaseQueryRt]) + : localUiBaseQueryRt }, - handler: async req => { + handler: async (req, { query }: { query: t.TypeOf }) => { const setup = await setupRequest(req); - const { serviceName } = req.query as { - serviceName?: string; - }; - return getEnvironments(setup, serviceName).catch(defaultErrorHandler); - } - }); - - const createLocalFiltersEndpoint = ({ - name, - getProjection, - validators - }: { - name: PROJECTION; - getProjection: ({ - setup, - query - }: { - setup: Setup; - query: Record; - }) => Projection; - validators?: Record; - }) => { - server.route({ - method: 'GET', - path: `/api/apm/ui_filters/local_filters/${name}`, - options: { - validate: { - query: withDefaultValidators({ - filterNames: Joi.array() - .items(localUIFilterNames) - .required(), - ...validators - }) - }, - tags: ['access:apm'] - }, - handler: async req => { - const setup = await setupRequest(req); - - const { uiFilters, filterNames } = (req.query as unknown) as { - uiFilters: string; - filterNames: LocalUIFilterName[]; - }; - - const parsedUiFilters = JSON.parse(uiFilters); - - const projection = getProjection({ - query: req.query as Record, - setup: { - ...setup, - uiFiltersES: await getUiFiltersES( - req.server, - omit(parsedUiFilters, filterNames) - ) - } - }); - - return getLocalUIFilters({ - server: req.server, - projection, - setup, - uiFilters: parsedUiFilters, - localFilterNames: filterNames - }).catch(defaultErrorHandler); - } - }); - }; - createLocalFiltersEndpoint({ - name: PROJECTION.SERVICES, - getProjection: ({ setup }) => { - return getServicesProjection({ setup }); - } - }); + const { uiFilters, filterNames } = query; - createLocalFiltersEndpoint({ - name: PROJECTION.TRACES, - getProjection: ({ setup }) => { - return getTransactionGroupsProjection({ - setup, - options: { type: 'top_traces' } + const parsedUiFilters = JSON.parse(uiFilters); + + const projection = getProjection({ + query, + setup: { + ...setup, + uiFiltersES: await getUiFiltersES( + req.server, + omit(parsedUiFilters, filterNames) + ) + } }); - } - }); - - createLocalFiltersEndpoint({ - name: PROJECTION.TRANSACTION_GROUPS, - getProjection: ({ setup, query }) => { - const { transactionType, serviceName, transactionName } = query as { - transactionType: string; - serviceName: string; - transactionName?: string; - }; - return getTransactionGroupsProjection({ + + return getLocalUIFilters({ + server: req.server, + projection, setup, - options: { - type: 'top_transactions', - transactionType, - serviceName, - transactionName - } + uiFilters: parsedUiFilters, + localFilterNames: filterNames }); - }, - validators: { - serviceName: Joi.string().required(), - transactionType: Joi.string().required(), - transactionName: Joi.string() } - }); - - createLocalFiltersEndpoint({ - name: PROJECTION.TRANSACTIONS, - getProjection: ({ setup, query }) => { - const { transactionType, serviceName, transactionName } = query as { - transactionType: string; - serviceName: string; - transactionName: string; - }; - return getTransactionsProjection({ - setup, + })); +} + +export const servicesLocalFiltersRoute = createLocalFiltersRoute({ + path: `/api/apm/ui_filters/local_filters/services`, + getProjection: ({ setup }) => getServicesProjection({ setup }), + queryRt: t.type({}) +}); + +export const transactionGroupsLocalFiltersRoute = createLocalFiltersRoute({ + path: '/api/apm/ui_filters/local_filters/transactionGroups', + getProjection: ({ setup, query }) => { + const { transactionType, serviceName, transactionName } = query; + return getTransactionGroupsProjection({ + setup, + options: { + type: 'top_transactions', transactionType, serviceName, transactionName - }); - }, - validators: { - serviceName: Joi.string().required(), - transactionType: Joi.string().required(), - transactionName: Joi.string().required() - } - }); - - createLocalFiltersEndpoint({ - name: PROJECTION.METRICS, - getProjection: ({ setup, query }) => { - const { serviceName } = query as { - serviceName: string; - }; - return getMetricsProjection({ - setup, - serviceName - }); - }, - validators: { - serviceName: Joi.string().required() - } - }); - - createLocalFiltersEndpoint({ - name: PROJECTION.ERROR_GROUPS, - getProjection: ({ setup, query }) => { - const { serviceName } = query as { - serviceName: string; - }; - return getErrorGroupsProjection({ - setup, - serviceName - }); - }, - validators: { - serviceName: Joi.string().required() - } - }); -} + } + }); + }, + queryRt: t.intersection([ + t.type({ + serviceName: t.string, + transactionType: t.string + }), + t.partial({ + transactionName: t.string + }) + ]) +}); + +export const tracesLocalFiltersRoute = createLocalFiltersRoute({ + path: '/api/apm/ui_filters/local_filters/traces', + getProjection: ({ setup }) => { + return getTransactionGroupsProjection({ + setup, + options: { type: 'top_traces' } + }); + }, + queryRt: t.type({}) +}); + +export const transactionsLocalFiltersRoute = createLocalFiltersRoute({ + path: '/api/apm/ui_filters/local_filters/transactions', + getProjection: ({ setup, query }) => { + const { transactionType, serviceName, transactionName } = query; + return getTransactionsProjection({ + setup, + transactionType, + serviceName, + transactionName + }); + }, + queryRt: t.type({ + transactionType: t.string, + transactionName: t.string, + serviceName: t.string + }) +}); + +export const metricsLocalFiltersRoute = createLocalFiltersRoute({ + path: '/api/apm/ui_filters/local_filters/metrics', + getProjection: ({ setup, query }) => { + const { serviceName } = query; + return getMetricsProjection({ + setup, + serviceName + }); + }, + queryRt: t.type({ + serviceName: t.string + }) +}); + +export const errorGroupsLocalFiltersRoute = createLocalFiltersRoute({ + path: '/api/apm/ui_filters/local_filters/errorGroups', + getProjection: ({ setup, query }) => { + const { serviceName } = query; + return getErrorGroupsProjection({ + setup, + serviceName + }); + }, + queryRt: t.type({ + serviceName: t.string + }) +}); + +type BaseQueryType = typeof localUiBaseQueryRt; + +type GetProjection< + TProjection extends Projection, + TQueryRT extends t.HasProps +> = ({ + query, + setup +}: { + query: t.TypeOf; + setup: Setup; +}) => TProjection; diff --git a/x-pack/test/api_integration/apis/apm/feature_controls.ts b/x-pack/test/api_integration/apis/apm/feature_controls.ts index 728e9f3837834..fd0184396586c 100644 --- a/x-pack/test/api_integration/apis/apm/feature_controls.ts +++ b/x-pack/test/api_integration/apis/apm/feature_controls.ts @@ -31,32 +31,32 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const endpoints = [ { - url: `/api/apm/services/foo/errors?start=${start}&end=${end}`, + url: `/api/apm/services/foo/errors?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/errors/bar?start=${start}&end=${end}`, + url: `/api/apm/services/foo/errors/bar?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/errors/distribution?start=${start}&end=${end}&groupId=bar`, + url: `/api/apm/services/foo/errors/distribution?start=${start}&end=${end}&groupId=bar&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/errors/distribution?start=${start}&end=${end}`, + url: `/api/apm/services/foo/errors/distribution?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/metrics/charts?start=${start}&end=${end}&agentName=cool-agent`, + url: `/api/apm/services/foo/metrics/charts?start=${start}&end=${end}&agentName=cool-agent&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services?start=${start}&end=${end}`, + url: `/api/apm/services?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, @@ -71,7 +71,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse: expect200, }, { - url: `/api/apm/traces?start=${start}&end=${end}`, + url: `/api/apm/traces?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, @@ -81,27 +81,27 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse: expect200, }, { - url: `/api/apm/services/foo/transaction_groups?start=${start}&end=${end}&transactionType=bar`, + url: `/api/apm/services/foo/transaction_groups?start=${start}&end=${end}&transactionType=bar&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}&transactionType=bar`, + url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}&transactionType=bar&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}`, + url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}&transactionType=bar&transactionName=baz`, + url: `/api/apm/services/foo/transaction_groups/charts?start=${start}&end=${end}&transactionType=bar&transactionName=baz&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, }, { - url: `/api/apm/services/foo/transaction_groups/distribution?start=${start}&end=${end}&transactionType=bar&transactionName=baz`, + url: `/api/apm/services/foo/transaction_groups/distribution?start=${start}&end=${end}&transactionType=bar&transactionName=baz&uiFilters=%7B%7D`, expectForbidden: expect404, expectResponse: expect200, },