Add sonarqubeInfoProvider in sonarqube-backend plugin

Handle config and sonarqube api call

Heavily inspired from the jenkinsInfoProvider in the jenkins-backend plugin

Signed-off-by: Neemys <36508659+Neemys@users.noreply.github.com>
This commit is contained in:
Neemys
2022-07-21 13:19:55 +02:00
parent 4d0fc08d4b
commit 55a31cc753
3 changed files with 876 additions and 0 deletions
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './service/router';
export * from './service/sonarqubeInfoProvider';
@@ -0,0 +1,504 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '../../../../packages/config';
import {
DefaultSonarqubeInfoProvider,
SonarqubeConfig,
} from './sonarqubeInfoProvider';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '../../../../packages/test-utils';
import { rest, RestRequest } from 'msw';
describe('SonarqubeConfig', () => {
const SONARQUBE_DEFAULT_INSTANCE_NAME = 'default';
const DUMMY_SONAR_URL = 'https://sonarqube.example.com';
const DUMMY_SONAR_APIKEY = '123456789abcdef0123456789abcedf012';
const DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG = {
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
};
const DUMMY_SIMPLE_CONFIG = {
sonarqube: {
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
};
const DUMMY_NAMED_CONFIG = {
sonarqube: {
instances: [
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
],
},
};
describe('fromConfig', () => {
it('Reads simple config and annotation', async () => {
const config = SonarqubeConfig.fromConfig(
new ConfigReader(DUMMY_SIMPLE_CONFIG),
);
expect(config.instances).toEqual([
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
]);
});
it('Reads named default config and annotation', async () => {
const config = SonarqubeConfig.fromConfig(
new ConfigReader(DUMMY_NAMED_CONFIG),
);
expect(config.instances).toEqual([
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
]);
});
it('Parses named default config (amongst named other configs)', async () => {
const config = SonarqubeConfig.fromConfig(
new ConfigReader({
sonarqube: {
instances: [
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: 'abcdef0123456789abcedf0123456789abc',
},
],
},
}),
);
expect(config.instances).toEqual([
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: 'abcdef0123456789abcedf0123456789abc',
},
]);
});
it('Throw an error if both a named default config and top level config', async () => {
expect(() =>
SonarqubeConfig.fromConfig(
new ConfigReader({
sonarqube: {
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
instances: [
{
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
},
],
},
}),
),
).toThrowError(Error);
});
it('Throw an error if default config is partially provided', async () => {
expect(() =>
SonarqubeConfig.fromConfig(
new ConfigReader({
sonarqube: {
baseUrl: DUMMY_SONAR_URL,
},
}),
),
).toThrowError(Error);
});
});
describe('getInstanceConfig', () => {
it('Gets default instance when no parameter given', async () => {
const config = new SonarqubeConfig([
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
]);
expect(config.getInstanceConfig()).toEqual({
name: SONARQUBE_DEFAULT_INSTANCE_NAME,
baseUrl: DUMMY_SONAR_URL,
apiKey: DUMMY_SONAR_APIKEY,
});
});
it('Gets default instance when "default" given', async () => {
const config = new SonarqubeConfig([
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: 'abcdef0123456789abcedf0123456789abc',
},
]);
expect(config.getInstanceConfig('default')).toEqual({
name: 'default',
baseUrl: 'https://sonarqube.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
});
});
it('Gets named instance', async () => {
const config = new SonarqubeConfig([
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
]);
expect(config.getInstanceConfig('other')).toEqual({
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
});
});
it('Throw an error if default instance could not be found', async () => {
const config = new SonarqubeConfig([
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
]);
expect(() => config.getInstanceConfig('default')).toThrowError(Error);
});
it('Throw an error if named instance could not be found', async () => {
const config = new SonarqubeConfig([
DUMMY_SIMPLE_OBJECT_FOR_DEFAULT_SONARQUBE_CONFIG,
]);
expect(() => config.getInstanceConfig('other')).toThrowError(Error);
});
});
});
const server = setupServer();
describe('DefaultSonarqubeInfoProvider', () => {
function configureProvider(configData: any) {
const config = new ConfigReader(configData);
return DefaultSonarqubeInfoProvider.fromConfig(config);
}
describe('getBaseUrl', () => {
it('Provide base url for default from simple config and empty string', async () => {
const provider = configureProvider({
sonarqube: {
baseUrl: 'https://sonarqube.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
});
expect(provider.getBaseUrl('')).toEqual('https://sonarqube.example.com');
});
it('Provide base url for named default config and "default" string', async () => {
const provider = configureProvider({
sonarqube: {
instances: [
{
name: 'default',
baseUrl: 'https://sonarqube.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
});
expect(provider.getBaseUrl('default')).toEqual(
'https://sonarqube.example.com',
);
});
it('Provide base url for named config', async () => {
const provider = configureProvider({
sonarqube: {
instances: [
{
name: 'default',
baseUrl: 'https://sonarqube.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
{
name: 'other',
baseUrl: 'https://sonarqube-other.example.com',
apiKey: '123456789abcdef0123456789abcedf012',
},
],
},
});
expect(provider.getBaseUrl('other')).toEqual(
'https://sonarqube-other.example.com',
);
});
});
describe('getFindings', () => {
setupRequestMockHandlers(server);
const MOCK_BASE_URL = 'http://backstage:9191';
const DUMMY_COMPONENT_KEY = 'dummyComponentKey';
const DUMMY_ANALYSIS_DATE = '2022-01-01T00:00:00Z';
const DUMMY_API_KEY = '123456789abcdef0123456789abcedf012';
const checkBasicAuthToken = (req: RestRequest<never>) => {
if (req.headers && req.headers.has('Authorization')) {
expect(req.headers.get('Authorization')).toEqual(
`Basic MTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2VkZjAxMjo=`,
);
} else {
throw new Error('Basic auth token not provided');
}
};
const setupComponentHandler = () => {
server.use(
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}`,
);
return res(
ctx.json({
component: { analysisDate: DUMMY_ANALYSIS_DATE },
}),
);
}),
);
};
const setupMetricsHandler = () => {
server.use(
rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => {
checkBasicAuthToken(req);
return res(
ctx.json({
total: 4,
metrics: [
{ key: 'coverage' },
{ key: 'code_smells' },
{ key: 'vulnerabilities' },
{ key: 'unused_metric' },
],
}),
);
}),
);
};
const setupMeasureHandler = () => {
server.use(
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
);
return res(
ctx.json({
component: {
measures: [
{ metric: 'coverage', value: '86' },
{ metric: 'code_smells', value: '40' },
{ metric: 'vulnerabilities', value: '3' },
],
},
}),
);
}),
);
};
const setupHandlers = () => {
setupComponentHandler();
setupMetricsHandler();
setupMeasureHandler();
};
const DUMMY_SIMPLE_CONFIG_FOR_PROVIDER = {
sonarqube: {
baseUrl: MOCK_BASE_URL,
apiKey: DUMMY_API_KEY,
},
};
it('Provide findings when everything is ok', async () => {
setupHandlers();
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toEqual({
analysisDate: DUMMY_ANALYSIS_DATE,
measures: [
{ metric: 'coverage', value: '86' },
{ metric: 'code_smells', value: '40' },
{ metric: 'vulnerabilities', value: '3' },
],
});
});
it('Provide undefined as finding if component API answer code is not 200', async () => {
server.use(
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}`,
);
return res(ctx.status(500));
}),
);
const provider = configureProvider({
sonarqube: {
baseUrl: MOCK_BASE_URL,
apiKey: '123456789abcdef0123456789abcedf012',
},
});
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toBeUndefined();
});
it('Provide undefined as finding if component API answer incorrectly', async () => {
server.use(
rest.get(`${MOCK_BASE_URL}/api/components/show`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}`,
);
return res(
ctx.json({
invalid: true,
}),
);
}),
);
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toBeUndefined();
});
it('Provide findings when metrics API uses pages', async () => {
setupComponentHandler();
setupMeasureHandler();
// custom metrics handler that provide two pages
server.use(
rest.get(`${MOCK_BASE_URL}/api/metrics/search`, (req, res, ctx) => {
checkBasicAuthToken(req);
if (req.url.searchParams.get('p') === '1')
return res(
ctx.json({
total: 4,
metrics: [{ key: 'coverage' }, { key: 'code_smells' }],
}),
);
return res(
ctx.json({
total: 4,
metrics: [{ key: 'vulnerabilities' }, { key: 'unused_metric' }],
}),
);
}),
);
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toEqual({
analysisDate: DUMMY_ANALYSIS_DATE,
measures: [
{ metric: 'coverage', value: '86' },
{ metric: 'code_smells', value: '40' },
{ metric: 'vulnerabilities', value: '3' },
],
});
});
it('Provide undefined as findings when measure API answer code is not 200', async () => {
setupComponentHandler();
setupMetricsHandler();
// custom metrics handler that provide two pages
server.use(
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
);
return res(ctx.status(500));
}),
);
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toBeUndefined();
});
it('Provide findings with empty measures when metrics API answer incorrectly', async () => {
setupComponentHandler();
setupMetricsHandler();
// custom metrics handler that provide two pages
server.use(
rest.get(`${MOCK_BASE_URL}/api/measures/component`, (req, res, ctx) => {
checkBasicAuthToken(req);
expect(req.url.searchParams.toString()).toBe(
`component=${DUMMY_COMPONENT_KEY}&metricKeys=vulnerabilities%2Ccode_smells%2Ccoverage`,
);
return res(ctx.json({}));
}),
);
const provider = configureProvider(DUMMY_SIMPLE_CONFIG_FOR_PROVIDER);
expect(
await provider.getFindings(DUMMY_COMPONENT_KEY, 'default'),
).toEqual({
analysisDate: DUMMY_ANALYSIS_DATE,
measures: [],
});
});
});
});
@@ -0,0 +1,371 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import fetch from 'node-fetch';
/**
* Provide information about sonarqube instances and projects contained within
* @public
*/
export interface SonarqubeInfoProvider {
/**
* Get the sonarqube URL in configuration from a provided name.
*
* If name is omitted, default sonarqube instance is queried in config
*
* @param instanceName - Name of the sonarqube instance to get the info from
* @returns the url of the instance
*/
getBaseUrl(instanceName: string): string;
/**
* Query the sonarqube instance corresponding to the instanceName to get all
* measures for the component of key componentKey.
* @param componentKey - component key of the project we want to get measure from.
* @param instanceName - name of the instance (in config) where the project is hosted.
* @returns All measures with the analysis date. Will return undefined if we
* can't provide the full response
*/
getFindings(
componentKey: string,
instanceName: string,
): Promise<SonarqubeFindings | undefined>;
}
/**
* Information retrieved for a specific project in Sonarqube
* @public
*/
export interface SonarqubeFindings {
/**
* Last date of the analysis that have generated this finding
*/
analysisDate: string;
/**
* All measures pertaining to the findings
*/
measures: Measure[];
}
interface MeasuresWrapper {
component: { measures: Measure[] };
}
/**
* A specific measure on a project in Sonarqube
* @public
*/
export interface Measure {
/**
* Name of the measure
*/
metric: string;
/**
* Value of the measure
*/
value: string;
}
/**
* Information about a Sonarqube instance.
* @public
*/
export interface SonarqubeInstanceConfig {
/**
* Name of the instance. An instance name in configuration and catalog should match.
*/
name: string;
/**
* Base url to access the instance
*/
baseUrl: string;
/**
* Access token to access the sonarqube instance as generated in user profile.
*/
apiKey: string;
}
interface ComponentWrapper {
component: { analysisDate: string };
}
/**
* Holds multiple Sonarqube configurations.
* @public
*/
export class SonarqubeConfig {
/**
*
* @param instances - All information on all sonarqube instance from the config file
*/
constructor(public readonly instances: SonarqubeInstanceConfig[]) {}
/**
* Read all Sonarqube instance configurations.
* @param config - Root configuration
* @returns A SonarqubeConfig that contains all configured Sonarqube instances.
*/
static fromConfig(config: Config): SonarqubeConfig {
const DEFAULT_SONARQUBE_NAME = 'default';
const sonarqubeConfig = config.getConfig('sonarqube');
// load all named instance config
const namedInstanceConfig =
sonarqubeConfig.getOptionalConfigArray('instances')?.map(c => ({
name: c.getString('name'),
baseUrl: c.getString('baseUrl'),
apiKey: c.getString('apiKey'),
})) || [];
// load unnamed default config
const hasNamedDefault = namedInstanceConfig.some(
x => x.name === DEFAULT_SONARQUBE_NAME,
);
// Get these as optional strings and check to give a better error message
const baseUrl = sonarqubeConfig.getOptionalString('baseUrl');
const apiKey = sonarqubeConfig.getOptionalString('apiKey');
if (hasNamedDefault && (baseUrl || apiKey)) {
throw new Error(
`Found both a named sonarqube instance with name ${DEFAULT_SONARQUBE_NAME} and top level baseUrl or apiKey config. Use only one style of config.`,
);
}
const unnamedNonePresent = !baseUrl && !apiKey;
const unnamedAllPresent = baseUrl && apiKey;
if (!(unnamedAllPresent || unnamedNonePresent)) {
throw new Error(
`Found partial default sonarqube config. All (or none) of baseUrl and apiKey must be provided.`,
);
}
if (unnamedAllPresent) {
const unnamedInstanceConfig = [
{ name: DEFAULT_SONARQUBE_NAME, baseUrl, apiKey },
] as {
name: string;
baseUrl: string;
apiKey: string;
}[];
return new SonarqubeConfig([
...namedInstanceConfig,
...unnamedInstanceConfig,
]);
}
return new SonarqubeConfig(namedInstanceConfig);
}
/**
* Gets a Sonarqube instance configuration by name, or the default one if no name is provided.
* @param sonarqubeName - Optional name of the Sonarqube instance.
* @returns The requested Sonarqube instance.
* @throws Error when no default config could be found or the requested name couldn't be found in config.
*/
getInstanceConfig(sonarqubeName?: string): SonarqubeInstanceConfig {
const DEFAULT_SONARQUBE_NAME = 'default';
if (!sonarqubeName || sonarqubeName === DEFAULT_SONARQUBE_NAME) {
// no name provided, use default
const instanceConfig = this.instances.find(
c => c.name === DEFAULT_SONARQUBE_NAME,
);
if (!instanceConfig) {
throw new Error(
`Couldn't find a default sonarqube instance in the config. Either configure an instance with name ${DEFAULT_SONARQUBE_NAME} or add a prefix to your annotation value.`,
);
}
return instanceConfig;
}
// A name is provided, look it up.
const instanceConfig = this.instances.find(c => c.name === sonarqubeName);
if (!instanceConfig) {
throw new Error(
`Couldn't find a sonarqube instance in the config with name ${sonarqubeName}`,
);
}
return instanceConfig;
}
}
/**
* @public
*
* Use default config and annotations, build using fromConfig static function.
*/
export class DefaultSonarqubeInfoProvider implements SonarqubeInfoProvider {
private constructor(private readonly config: SonarqubeConfig) {}
/**
* Generate an instance from a Config instance
* @param config - Backend configuration
*/
static fromConfig(config: Config): DefaultSonarqubeInfoProvider {
return new DefaultSonarqubeInfoProvider(SonarqubeConfig.fromConfig(config));
}
/**
* Retrieve all supported metrics from a sonarqube instance.
*
* @param instanceUrl - URL of the sonarqube instance
* @param token - token to access the sonarqube instance
* @returns The list of supported metrics, if no metrics are supported an empty list is provided in the promise
* @private
*/
private static async getSupportedMetrics(
instanceUrl: string,
token: string,
): Promise<string[]> {
const metrics: string[] = [];
let nextPage: number = 1;
for (;;) {
const result = await DefaultSonarqubeInfoProvider.callApi<{
metrics: Array<{ key: string }>;
total: number;
}>(instanceUrl, 'api/metrics/search', token, { ps: 500, p: nextPage });
metrics.push(...(result?.metrics?.map(m => m.key) ?? []));
if (result && metrics.length < result.total) {
nextPage++;
continue;
}
return metrics;
}
}
/**
* Call an API with provided arguments
* @param url - URL of the API to call
* @param path - path to call
* @param authToken - token used as basic auth user without password
* @param query - parameters to provide to the call
* @returns A promise on the answer to the API call if the answer status code is 200, undefined otherwise.
* @private
*/
private static async callApi<T>(
url: string,
path: string,
authToken: string,
query: { [key in string]: any },
): Promise<T | undefined> {
// Sonarqube auth use basic with token as username and no password
// but standard dictate the colon (separator) need to stay here despite the
// lack of password
const encodedAuthToken = Buffer.from(`${authToken}:`).toString('base64');
const response = await fetch(
`${url}/${path}?${new URLSearchParams(query).toString()}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${encodedAuthToken}`,
},
},
);
if (response.status === 200) {
return (await response.json()) as T;
}
return undefined;
}
/**
* {@inheritDoc SonarqubeInfoProvider.getBaseUrl}
* @throws Error If configuration can't be retrieved.
*/
getBaseUrl(instanceName: string): string {
const instanceConfig = this.config.getInstanceConfig(instanceName ?? '');
return instanceConfig.baseUrl;
}
/**
* {@inheritDoc SonarqubeInfoProvider.getFindings}
* @throws Error If configuration can't be retrieved.
*/
async getFindings(
componentKey: string,
instanceName: string,
): Promise<SonarqubeFindings | undefined> {
const { baseUrl, apiKey } = this.config.getInstanceConfig(
instanceName ?? '',
);
// get component info to retrieve analysis date
const component =
await DefaultSonarqubeInfoProvider.callApi<ComponentWrapper>(
baseUrl,
'api/components/show',
apiKey,
{
component: componentKey,
},
);
if (!component || !component.component) {
return undefined;
}
// select the metrics that are supported by the SonarQube instance
const supportedMetrics =
await DefaultSonarqubeInfoProvider.getSupportedMetrics(baseUrl, apiKey);
const wantedMetrics: string[] = [
'alert_status',
'bugs',
'reliability_rating',
'vulnerabilities',
'security_rating',
'security_hotspots_reviewed',
'security_review_rating',
'code_smells',
'sqale_rating',
'coverage',
'duplicated_lines_density',
];
// only retrieve wanted metrics that are supported
const metricsToQuery = wantedMetrics.filter(el =>
supportedMetrics.includes(el),
);
// get all measures
const measures =
await DefaultSonarqubeInfoProvider.callApi<MeasuresWrapper>(
baseUrl,
'api/measures/component',
apiKey,
{
component: componentKey,
metricKeys: metricsToQuery.join(','),
},
);
if (!measures) {
return undefined;
}
return {
analysisDate: component.component.analysisDate,
measures: measures.component?.measures ?? [],
};
}
}