diff --git a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js index 9da97f40f5ec6..4f4cd0f6ef1c9 100644 --- a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js @@ -17,10 +17,8 @@ import { parseInterval } from '../../../../../common/util/parse_interval'; import { replaceTokensInUrlValue, isValidLabel } from '../../../util/custom_url_utils'; import { getIndexPatternIdFromName } from '../../../util/index_utils'; import { ml } from '../../../services/ml_api_service'; -import { mlJobService } from '../../../services/job_service'; import { escapeForElasticsearchQuery } from '../../../util/string_utils'; import { getSavedObjectsClient, getGetUrlGenerator } from '../../../util/dependency_cache'; -import { getProcessedFields } from '../../../components/data_grid'; export function getNewCustomUrlDefaults(job, dashboards, indexPatterns) { // Returns the settings object in the format used by the custom URL editor @@ -266,8 +264,7 @@ function buildAppStateQueryParam(queryFieldNames) { // Builds the full URL for testing out a custom URL configuration, which // may contain dollar delimited partition / influencer entity tokens and // drilldown time range settings. -export function getTestUrl(job, customUrl) { - const urlValue = customUrl.url_value; +export async function getTestUrl(job, customUrl) { const bucketSpanSecs = parseInterval(job.analysis_config.bucket_span).asSeconds(); // By default, return configured url_value. Look to substitute any dollar-delimited @@ -289,64 +286,55 @@ export function getTestUrl(job, customUrl) { sort: [{ record_score: { order: 'desc' } }], }; - return new Promise((resolve, reject) => { - ml.results - .anomalySearch( - { - body, - }, - [job.job_id] - ) - .then((resp) => { - if (resp.hits.total.value > 0) { - const record = resp.hits.hits[0]._source; - testUrl = replaceTokensInUrlValue(customUrl, bucketSpanSecs, record, 'timestamp'); - resolve(testUrl); - } else { - // No anomalies yet for this job, so do a preview of the search - // configured in the job datafeed to obtain sample docs. - mlJobService.searchPreview(job).then((response) => { - let testDoc; - const docTimeFieldName = job.data_description.time_field; - - // Handle datafeeds which use aggregations or documents. - if (response.aggregations) { - // Create a dummy object which contains the fields necessary to build the URL. - const firstBucket = response.aggregations.buckets.buckets[0]; - testDoc = { - [docTimeFieldName]: firstBucket.key, - }; - - // Look for bucket aggregations which match the tokens in the URL. - urlValue.replace(/\$([^?&$\'"]{1,40})\$/g, (match, name) => { - if (name !== 'earliest' && name !== 'latest' && firstBucket[name] !== undefined) { - const tokenBuckets = firstBucket[name]; - if (tokenBuckets.buckets) { - testDoc[name] = tokenBuckets.buckets[0].key; - } - } - }); - } else { - if (response.hits.total.value > 0) { - testDoc = getProcessedFields(response.hits.hits[0].fields); - } - } - - if (testDoc !== undefined) { - testUrl = replaceTokensInUrlValue( - customUrl, - bucketSpanSecs, - testDoc, - docTimeFieldName - ); - } + let resp; + try { + resp = await ml.results.anomalySearch( + { + body, + }, + [job.job_id] + ); + } catch (error) { + // search may fail if the job doesn't already exist + // ignore this error as the outer function call will raise a toast + } - resolve(testUrl); - }); - } - }) - .catch((resp) => { - reject(resp); - }); - }); + if (resp && resp.hits.total.value > 0) { + const record = resp.hits.hits[0]._source; + testUrl = replaceTokensInUrlValue(customUrl, bucketSpanSecs, record, 'timestamp'); + return testUrl; + } else { + // No anomalies yet for this job, so do a preview of the search + // configured in the job datafeed to obtain sample docs. + + let { datafeed_config: datafeedConfig, ...jobConfig } = job; + try { + // attempt load the non-combined job and datafeed so they can be used in the datafeed preview + const [{ jobs }, { datafeeds }] = await Promise.all([ + ml.getJobs({ jobId: job.job_id }), + ml.getDatafeeds({ datafeedId: job.datafeed_config.datafeed_id }), + ]); + datafeedConfig = datafeeds[0]; + jobConfig = jobs[0]; + } catch (error) { + // jobs may not exist as this might be called from the AD job wizards + // ignore this error as the outer function call will raise a toast + } + + if (jobConfig === undefined || datafeedConfig === undefined) { + return testUrl; + } + + const preview = await ml.jobs.datafeedPreview(undefined, jobConfig, datafeedConfig); + + const docTimeFieldName = job.data_description.time_field; + + // Create a dummy object which contains the fields necessary to build the URL. + const firstBucket = preview[0]; + if (firstBucket !== undefined) { + testUrl = replaceTokensInUrlValue(customUrl, bucketSpanSecs, firstBucket, docTimeFieldName); + } + + return testUrl; + } } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js index e5fdae201eb04..c24d8df3909fe 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js @@ -10,7 +10,7 @@ import React, { Component } from 'react'; import { EuiSpacer, EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; -import { mlJobService } from '../../../../services/job_service'; +import { ml } from '../../../../services/ml_api_service'; import { checkPermission } from '../../../../capabilities/check_capabilities'; import { ML_DATA_PREVIEW_COUNT } from '../../../../../../common/util/job_utils'; import { MLJobEditor } from '../ml_job_editor'; @@ -88,8 +88,8 @@ DatafeedPreviewPane.propTypes = { function updateDatafeedPreview(job, canPreviewDatafeed) { return new Promise((resolve, reject) => { if (canPreviewDatafeed) { - mlJobService - .getDatafeedPreview(job.datafeed_config.datafeed_id) + ml.jobs + .datafeedPreview(job.datafeed_config.datafeed_id) .then((resp) => { if (Array.isArray(resp)) { resolve(JSON.stringify(resp.slice(0, ML_DATA_PREVIEW_COUNT), null, 2)); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx index a4d9293e9369d..c6d6e6789bd95 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx @@ -19,15 +19,15 @@ import { import { CombinedJob } from '../../../../../../../../common/types/anomaly_detection_jobs'; import { MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor'; -import { mlJobService } from '../../../../../../services/job_service'; -import { ML_DATA_PREVIEW_COUNT } from '../../../../../../../../common/util/job_utils'; -import { isPopulatedObject } from '../../../../../../../../common/util/object_utils'; -import { isMultiBucketAggregate } from '../../../../../../../../common/types/es_client'; +import { useMlApiContext } from '../../../../../../contexts/kibana'; export const DatafeedPreview: FC<{ combinedJob: CombinedJob | null; heightOffset?: number; }> = ({ combinedJob, heightOffset = 0 }) => { + const { + jobs: { datafeedPreview }, + } = useMlApiContext(); // the ace editor requires a fixed height const editorHeight = useMemo(() => `${window.innerHeight - 230 - heightOffset}px`, [ heightOffset, @@ -63,18 +63,17 @@ export const DatafeedPreview: FC<{ if (combinedJob.datafeed_config && combinedJob.datafeed_config.indices.length) { try { - const resp = await mlJobService.searchPreview(combinedJob); - let data = resp.hits.hits; - // the first item under aggregations can be any name - if (isPopulatedObject(resp.aggregations)) { - const accessor = Object.keys(resp.aggregations)[0]; - const aggregate = resp.aggregations[accessor]; - if (isMultiBucketAggregate(aggregate)) { - data = aggregate.buckets.slice(0, ML_DATA_PREVIEW_COUNT); - } + const { datafeed_config: datafeed, ...job } = combinedJob; + if (job.analysis_config.detectors.length === 0) { + setPreviewJsonString( + i18n.translate('xpack.ml.newJob.wizard.datafeedPreviewFlyout.noDetectors', { + defaultMessage: 'No detectors configured', + }) + ); + } else { + const preview = await datafeedPreview(undefined, job, datafeed); + setPreviewJsonString(JSON.stringify(preview, null, 2)); } - - setPreviewJsonString(JSON.stringify(data, null, 2)); } catch (error) { setPreviewJsonString(JSON.stringify(error, null, 2)); } diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index ceadca12f8757..667f23da34aa0 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -41,7 +41,6 @@ declare interface JobService { ): Promise; createResultsUrl(jobId: string[], start: number, end: number, location: string): string; getJobAndGroupIds(): Promise; - searchPreview(job: CombinedJob): Promise>; getJob(jobId: string): CombinedJob; loadJobsWrapper(): Promise; } diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 2fa60b8db83a7..3c93c8a1ae85a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -347,11 +347,6 @@ class JobService { return job; } - searchPreview(combinedJob) { - const { datafeed_config: datafeed, ...job } = combinedJob; - return ml.jobs.datafeedPreview(job, datafeed); - } - openJob(jobId) { return ml.openJob({ jobId }); } @@ -435,10 +430,6 @@ class JobService { return datafeedId; } - getDatafeedPreview(datafeedId) { - return ml.datafeedPreview({ datafeedId }); - } - // get the list of job group ids as well as how many jobs are in each group getJobGroups() { const groups = []; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 144492eacc247..7cd08bc1fd15c 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -330,8 +330,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); }, - datafeedPreview(job: Job, datafeed: Datafeed) { - const body = JSON.stringify({ job, datafeed }); + datafeedPreview(datafeedId?: string, job?: Job, datafeed?: Datafeed) { + const body = JSON.stringify({ datafeedId, job, datafeed }); return httpService.http<{ total: number; categories: Array<{ count?: number; category: Category }>; diff --git a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts index 5dfe1b5934fe9..8b3f7f4b0b0ee 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts @@ -10,12 +10,8 @@ import { i18n } from '@kbn/i18n'; import { IScopedClusterClient } from 'kibana/server'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; -import { Datafeed, DatafeedStats, Job } from '../../../common/types/anomaly_detection_jobs'; -import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; -import { fieldsServiceProvider } from '../fields_service'; +import { Datafeed, DatafeedStats } from '../../../common/types/anomaly_detection_jobs'; import type { MlClient } from '../../lib/ml_client'; -import { parseInterval } from '../../../common/util/parse_interval'; -import { isPopulatedObject } from '../../../common/util/object_utils'; export interface MlDatafeedsResponse { datafeeds: Datafeed[]; @@ -235,154 +231,6 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie } } - async function datafeedPreview(job: Job, datafeed: Datafeed) { - let query: any = { match_all: {} }; - if (datafeed.query) { - query = datafeed.query; - } - const { getTimeFieldRange } = fieldsServiceProvider(client); - const { start } = await getTimeFieldRange( - datafeed.indices, - job.data_description.time_field, - query, - datafeed.runtime_mappings, - datafeed.indices_options - ); - - // Get bucket span - // Get first doc time for datafeed - // Create a new query - must user query and must range query. - // Time range 'to' first doc time plus < 10 buckets - - // Do a preliminary search to get the date of the earliest doc matching the - // query in the datafeed. This will be used to apply a time range criteria - // on the datafeed search preview. - // This time filter is required for datafeed searches using aggregations to ensure - // the search does not create too many buckets (default 10000 max_bucket limit), - // but apply it to searches without aggregations too for consistency. - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - if (bucketSpan === null) { - return; - } - const earliestMs = start.epoch; - const latestMs = +start.epoch + 10 * bucketSpan.asMilliseconds(); - - const body: any = { - query: { - bool: { - must: [ - { - range: { - [job.data_description.time_field]: { - gte: earliestMs, - lt: latestMs, - format: 'epoch_millis', - }, - }, - }, - query, - ], - }, - }, - }; - - // if aggs or aggregations is set, add it to the search - const aggregations = datafeed.aggs ?? datafeed.aggregations; - if (isPopulatedObject(aggregations)) { - body.size = 0; - body.aggregations = aggregations; - - // add script_fields if present - const scriptFields = datafeed.script_fields; - if (isPopulatedObject(scriptFields)) { - body.script_fields = scriptFields; - } - - // add runtime_mappings if present - const runtimeMappings = datafeed.runtime_mappings; - if (isPopulatedObject(runtimeMappings)) { - body.runtime_mappings = runtimeMappings; - } - } else { - // if aggregations is not set and retrieveWholeSource is not set, add all of the fields from the job - body.size = ML_DATA_PREVIEW_COUNT; - - // add script_fields if present - const scriptFields = datafeed.script_fields; - if (isPopulatedObject(scriptFields)) { - body.script_fields = scriptFields; - } - - // add runtime_mappings if present - const runtimeMappings = datafeed.runtime_mappings; - if (isPopulatedObject(runtimeMappings)) { - body.runtime_mappings = runtimeMappings; - } - - const fields = new Set(); - - // get fields from detectors - if (job.analysis_config.detectors) { - job.analysis_config.detectors.forEach((dtr) => { - if (dtr.by_field_name) { - fields.add(dtr.by_field_name); - } - if (dtr.field_name) { - fields.add(dtr.field_name); - } - if (dtr.over_field_name) { - fields.add(dtr.over_field_name); - } - if (dtr.partition_field_name) { - fields.add(dtr.partition_field_name); - } - }); - } - - // get fields from influencers - if (job.analysis_config.influencers) { - job.analysis_config.influencers.forEach((inf) => { - fields.add(inf); - }); - } - - // get fields from categorizationFieldName - if (job.analysis_config.categorization_field_name) { - fields.add(job.analysis_config.categorization_field_name); - } - - // get fields from summary_count_field_name - if (job.analysis_config.summary_count_field_name) { - fields.add(job.analysis_config.summary_count_field_name); - } - - // get fields from time_field - if (job.data_description.time_field) { - fields.add(job.data_description.time_field); - } - - // add runtime fields - if (runtimeMappings) { - Object.keys(runtimeMappings).forEach((fieldName) => { - fields.add(fieldName); - }); - } - - const fieldsList = [...fields]; - if (fieldsList.length) { - body.fields = fieldsList; - body._source = false; - } - } - const data = { - index: datafeed.indices, - body, - ...(datafeed.indices_options ?? {}), - }; - - return (await client.asCurrentUser.search(data)).body; - } - return { forceStartDatafeeds, stopDatafeeds, @@ -390,6 +238,5 @@ export function datafeedsProvider(client: IScopedClusterClient, mlClient: MlClie getDatafeedIdsByJobId, getJobIdsByDatafeedId, getDatafeedByJobId, - datafeedPreview, }; } diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 2dd85a8772f92..992822f6d6eb8 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; @@ -806,23 +807,21 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }, routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { try { - // @ts-ignore schema mismatch const { datafeedId, job, datafeed } = request.body; - if (datafeedId !== undefined) { - const { body } = await mlClient.previewDatafeed( - { - datafeed_id: datafeedId, - }, - getAuthorizationHeader(request) - ); - return response.ok({ - body, - }); - } - - const { datafeedPreview } = jobServiceProvider(client, mlClient); - const body = await datafeedPreview(job, datafeed); + const payload = + datafeedId !== undefined + ? { + datafeed_id: datafeedId, + } + : ({ + body: { + job_config: job, + datafeed_config: datafeed, + }, + } as estypes.MlPreviewDatafeedRequest); + + const { body } = await mlClient.previewDatafeed(payload, getAuthorizationHeader(request)); return response.ok({ body, }); diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index 392c0d3514d64..ec39d08ee357d 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -123,7 +123,7 @@ export const anomalyDetectionJobSchema = { job_id: schema.string(), job_type: schema.maybe(schema.string()), job_version: schema.maybe(schema.string()), - groups: schema.arrayOf(schema.maybe(schema.string())), + groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), model_plot_config: schema.maybe(schema.any()), model_plot: schema.maybe(schema.any()), model_size_stats: schema.maybe(schema.any()), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index 45ef3e3f73b6e..df91dea101c7c 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -109,15 +109,32 @@ export const revertModelSnapshotSchema = schema.object({ ), }); -export const datafeedPreviewSchema = schema.oneOf([ - schema.object({ - job: schema.object(anomalyDetectionJobSchema), - datafeed: datafeedConfigSchema, - }), - schema.object({ - datafeedId: schema.string(), - }), -]); +export const datafeedPreviewSchema = schema.object( + { + job: schema.maybe(schema.object(anomalyDetectionJobSchema)), + datafeed: schema.maybe(datafeedConfigSchema), + datafeedId: schema.maybe(schema.string()), + }, + { + validate: (v) => { + const msg = 'supply either a datafeed_id for an existing job or a job and datafeed config'; + if (v.datafeedId !== undefined && (v.job !== undefined || v.datafeed !== undefined)) { + // datafeed_id is supplied but job and datafeed configs are also supplied + return msg; + } + + if (v.datafeedId === undefined && (v.job === undefined || v.datafeed === undefined)) { + // datafeed_id is not supplied but job or datafeed configs are missing + return msg; + } + + if (v.datafeedId === undefined && v.job === undefined && v.datafeed === undefined) { + // everything is missing + return msg; + } + }, + } +); export const jobsExistSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), diff --git a/x-pack/test/api_integration/apis/ml/jobs/datafeed_preview.ts b/x-pack/test/api_integration/apis/ml/jobs/datafeed_preview.ts index d299795826c26..4a0545049a76e 100644 --- a/x-pack/test/api_integration/apis/ml/jobs/datafeed_preview.ts +++ b/x-pack/test/api_integration/apis/ml/jobs/datafeed_preview.ts @@ -72,13 +72,10 @@ export default ({ getService }: FtrProviderContext) => { .send({ job, datafeed }) .expect(200); - expect(body.hits.total.value).to.eql(3207, 'Response body total hits should be 3207'); - expect(Array.isArray(body.hits?.hits[0]?.fields?.airline)).to.eql( - true, - 'Response body airlines should be an array' - ); + expect(body.length).to.eql(1000, 'Response body total hits should be 1000'); + expect(typeof body[0]?.airline).to.eql('string', 'Response body airlines should be a string'); - const airlines: string[] = body.hits.hits.map((a: any) => a.fields.airline[0]); + const airlines: string[] = body.map((a: any) => a.airline); expect(airlines.length).to.not.eql(0, 'airlines length should not be 0'); expect(airlines.every((a) => isUpperCase(a))).to.eql( true, @@ -112,13 +109,10 @@ export default ({ getService }: FtrProviderContext) => { .send({ job, datafeed }) .expect(200); - expect(body.hits.total.value).to.eql(300, 'Response body total hits should be 300'); - expect(Array.isArray(body.hits?.hits[0]?.fields?.airline)).to.eql( - true, - 'Response body airlines should be an array' - ); + expect(body.length).to.eql(1000, 'Response body total hits should be 1000'); + expect(typeof body[0]?.airline).to.eql('string', 'Response body airlines should be a string'); - const airlines: string[] = body.hits.hits.map((a: any) => a.fields.airline[0]); + const airlines: string[] = body.map((a: any) => a.airline); expect(airlines.length).to.not.eql(0, 'airlines length should not be 0'); expect(airlines.every((a) => a === 'AAL')).to.eql( true, @@ -157,13 +151,10 @@ export default ({ getService }: FtrProviderContext) => { .send({ job, datafeed }) .expect(200); - expect(body.hits.total.value).to.eql(3207, 'Response body total hits should be 3207'); - expect(Array.isArray(body.hits?.hits[0]?.fields?.lowercase_airline)).to.eql( - true, - 'Response body airlines should be an array' - ); + expect(body.length).to.eql(1000, 'Response body total hits should be 1000'); + expect(typeof body[0]?.airline).to.eql('string', 'Response body airlines should be a string'); - const airlines: string[] = body.hits.hits.map((a: any) => a.fields.lowercase_airline[0]); + const airlines: string[] = body.map((a: any) => a.lowercase_airline); expect(airlines.length).to.not.eql(0, 'airlines length should not be 0'); expect(isLowerCase(airlines[0])).to.eql( true, @@ -205,13 +196,10 @@ export default ({ getService }: FtrProviderContext) => { .send({ job, datafeed }) .expect(200); - expect(body.hits.total.value).to.eql(300, 'Response body total hits should be 300'); - expect(Array.isArray(body.hits?.hits[0]?.fields?.airline)).to.eql( - true, - 'Response body airlines should be an array' - ); + expect(body.length).to.eql(1000, 'Response body total hits should be 1000'); + expect(typeof body[0]?.airline).to.eql('string', 'Response body airlines should be a string'); - const airlines: string[] = body.hits.hits.map((a: any) => a.fields.airline[0]); + const airlines: string[] = body.map((a: any) => a.airline); expect(airlines.length).to.not.eql(0, 'airlines length should not be 0'); expect(isLowerCase(airlines[0])).to.eql( true,