// (C) 2007-2020 GoodData Corporation import { ExecuteAFM as AFM, Execution } from "@gooddata/typings"; import { createHash } from "crypto"; import * as e from "express"; import * as HttpStatusCodes from "http-status-codes"; import { cloneDeep, get, isEmpty, zip, flattenDepth } from "lodash"; import { IConfig } from "../../model/Config"; import { IMockProject } from "../../model/MockProject"; import { ISchemaAfmExecution, isErrorResponse as isErrorSchemaResponse, isExecutionResponseWrapper, } from "../../schema/model/SchemaAfmExecution"; import { isHttpStatusCode } from "../../utils/http"; import * as log from "../../utils/log"; import { DataResultBuilder, IRandomDataResult, RandomMetricValueGenerator, Headers, } from "./executeAfm/dataResult"; import { generateDimensions } from "./executeAfm/dimensions"; import { isRelativelyEqual } from "./executeAfm/execution"; import { getSizeFromHeaders } from "./executeAfm/getDataSize"; import { createRandomGeneratorsPool, getRandomDataValue, IRandomDataGeneratorsHash, } from "./executeAfm/helpers/randomGenerator"; import { getDefaultResultSpec } from "./executeAfm/resultSpec"; import { SortDefinitionError } from "./executeAfm/sorting"; import { getTotals, TotalsResultRandomGenerator } from "./executeAfm/totals"; import MeasureValueFilters from "./executeAfm/measureValueFilters"; import RankingFilters from "./executeAfm/rankingFilters"; import IExecutionResponseWrapper = Execution.IExecutionResponseWrapper; import IExecutionResultWrapper = Execution.IExecutionResultWrapper; /** * Combination of both AFM executions responses. * * The local interface is used instead of the synonymous interface from `@gooddata/typings` for compatibility with * mock-js schemas that were created based on `@gooddata/typings@1.0.1` version. * * `null` value as executionResult means empty response (HTTP 204) */ export interface IExecutionResponses { executionResponse: IExecutionResponseWrapper; executionResult: IExecutionResultWrapper | null; } export interface IErrorResponse { error: { statusCode: number; message: string; }; } interface IPagingParameters { limit?: number[]; offset?: number[]; } // // Helpers // export function isErrorResponse(response: any): response is IErrorResponse { return (response as IErrorResponse).error !== undefined; } function resultSpecContainsMeasureGroup(resultSpec: AFM.IResultSpec) { const dimensions = resultSpec.dimensions; if (!dimensions) { return false; } return dimensions.some(dimension => dimension.itemIdentifiers.includes("measureGroup")); } function isAfmExecutionValid(execution: AFM.IExecution): boolean { return isExecutionValid(execution.execution.afm, execution.execution.resultSpec); } export function isExecutionValid(afm: AFM.IAfm, resultSpec: AFM.IResultSpec) { return !resultSpecContainsMeasureGroup(resultSpec) || (!!afm.measures && afm.measures.length > 0); } function executionKey(execution: AFM.IExecution): string { return createHash("md5") .update(JSON.stringify(execution)) .digest("hex"); } // // AFM sanitization / normalization // export function getNormalizedExecution(execution: AFM.IExecution): AFM.IExecution { return { execution: { afm: execution.execution.afm, resultSpec: getNormalizedResultSpec(execution), }, }; } function getNormalizedResultSpec(execution: AFM.IExecution): AFM.IResultSpec { const normalizedResultSpec = cloneDeep(execution.execution.resultSpec); if (isEmpty(normalizedResultSpec.dimensions)) { return { ...normalizedResultSpec, ...getDefaultResultSpec(execution.execution.afm), }; } return normalizedResultSpec; } export function sanitize(execution: AFM.IExecution): AFM.IExecution { const nativeTotalItems = get(execution, "execution.afm.nativeTotals"); const nativeTotals = nativeTotalItems ? { nativeTotals: nativeTotalItems } : {}; return { execution: { afm: { measures: get(execution, "execution.afm.measures", []), attributes: get(execution, "execution.afm.attributes", []), filters: get(execution, "execution.afm.filters", []), ...nativeTotals, }, resultSpec: { sorts: get(execution, "execution.resultSpec.sorts", []), dimensions: get(execution, "execution.resultSpec.dimensions", []), }, }, }; } // // Response caching // export type CachedResponse = IExecutionResultWrapper | number; export interface ICacheEntry { response: CachedResponse; createdAt: number; } /** * Store of the generated execution results * * Keys are represented by MD5 hashes of AFM.IExecution */ const cacheResult = new Map(); export function addToCache(key: string, response: CachedResponse): void { let entry = cacheResult.get(key); if (entry === undefined) { entry = { response, createdAt: Date.now(), }; cacheResult.set(key, entry); } else { entry.createdAt = Date.now(); } entry.response = response; } export function getFromCache(key: string): CachedResponse | undefined { const result = cacheResult.get(key); return result !== undefined ? result.response : undefined; } export function hasCachedResult(key: string): boolean { return cacheResult.has(key); } function deleteFromCache(key: string) { cacheResult.delete(key); } export function cleanOldCache(lifetime: number = 240000) { const now = Date.now(); const oldEntries: string[] = []; cacheResult.forEach((entry, key) => { if (entry.createdAt < now - lifetime) { oldEntries.push(key); } }); oldEntries.forEach(entry => deleteFromCache(entry)); } // // Result & response creation and generation // /** * We used to have different generation logic and seed when generating data for stacked charts. While having that * logic separate is no longer needed, the code needs to retain backward compatibility in regards to randomly * generated values (as they are used in screenshot tests). * * This function tests whether resultSpec dimensions are such that a non-default random generator (with different * seed) should be used. * * @param execution execution to test dimensions in */ function needDifferentSeedForBackwardCompatibility(execution: AFM.IExecution): boolean { const dimensions = execution.execution.resultSpec.dimensions; return ( dimensions.length === 2 && dimensions[0].itemIdentifiers.length === 1 && dimensions[0].itemIdentifiers[0] !== "measureGroup" && dimensions[1].itemIdentifiers.length === 2 && dimensions[1].itemIdentifiers[0] !== "measureGroup" && dimensions[1].itemIdentifiers[1] === "measureGroup" ); } function generateDataResult( project: IMockProject, execution: AFM.IExecution, seed: string, randomGeneratorPool: IRandomDataGeneratorsHash, ): IRandomDataResult | IErrorResponse { let randomGenerator: RandomMetricValueGenerator = params => { return getRandomDataValue(randomGeneratorPool, seed, `other-${params.localIdentifier}`); }; if (needDifferentSeedForBackwardCompatibility(execution)) { // see comments above for why this is here randomGenerator = () => { return getRandomDataValue(randomGeneratorPool, seed, "stacked"); }; } try { return DataResultBuilder.forProject(project) .forAfmAndResultSpec(execution) .atDate(new Date()) .withGenerator(randomGenerator) .build(); } catch (e) { if (e instanceof SortDefinitionError) { return { error: { statusCode: HttpStatusCodes.BAD_REQUEST, message: e.getMessage(), }, }; } throw e; } } function createExecutionResult( executionResponse: IExecutionResponseWrapper, dataResult: IRandomDataResult, totals?: Execution.IExecutionResult["totals"], ): IExecutionResponses { const isEmpty = dataResult.isEmpty; const total = getSizeFromHeaders(dataResult.headerItems); const dimensionsCount = total.length; const totalsEmpty = flattenDepth(totals, 2).length === 0; const totalsNormalized = totalsEmpty ? undefined : totals; const headerItems = hasSomeHeaderItems(dataResult.headerItems) ? { headerItems: dataResult.headerItems } : {}; return { executionResult: { executionResult: { data: isEmpty ? [] : dataResult.data, ...headerItems, totals: totalsNormalized, paging: { count: total, offset: Array(dimensionsCount).fill(0), total, }, }, }, executionResponse, }; } function hasSomeHeaderItems(array: Headers): boolean { return flattenDepth(array, Infinity).length > 0; } function getExecutionLink(project: IMockProject, execution: AFM.IExecution): string { const dimensions = get(execution, "execution.resultSpec.dimensions", []); const numOfDimensions = !isEmpty(dimensions) ? dimensions.length : "2"; return ( "/gdc/app/projects/" + project.project.meta.identifier + "/executionResults/" + executionKey(execution) + "?dimensions=" + numOfDimensions ); } function createExecutionResponse( project: IMockProject, execution: AFM.IExecution, totalTypes: AFM.TotalType[] = [], ): IExecutionResponseWrapper { const { afm } = execution.execution; const resultSpec = getNormalizedResultSpec(execution); const dimensions = generateDimensions(project, afm, resultSpec, totalTypes); return { executionResponse: { links: { executionResult: getExecutionLink(project, execution), }, dimensions, }, }; } function generateExecution( project: IMockProject, execution: AFM.IExecution, seed: string, randomGeneratorPool: IRandomDataGeneratorsHash, ): IExecutionResponses | IErrorResponse { const normalizedExecution = getNormalizedExecution(execution); if (!isAfmExecutionValid(normalizedExecution)) { return { error: { statusCode: HttpStatusCodes.BAD_REQUEST, message: "Specified 'measureGroup' but no measures found in AFM", }, }; } const dataResult = generateDataResult(project, normalizedExecution, seed, randomGeneratorPool); if (isErrorResponse(dataResult)) { return dataResult; } const totalsRandomGenerator: TotalsResultRandomGenerator = params => { // Generates deterministic sequence for every type and measure combination. -> Every total cell contains unique // value fixed to measure and total type no matter of `measures` bucket items order or totals order. return getRandomDataValue( randomGeneratorPool, seed, `totals-${params.totalType}-${params.localIdentifier}`, ); }; const totals = getTotals(normalizedExecution, dataResult, totalsRandomGenerator); if (isErrorResponse(totals)) { return totals; } const executionResponse = createExecutionResponse(project, execution, totals.types); return createExecutionResult(executionResponse, dataResult, totals.data); } function applyPaging( result: IExecutionResultWrapper, pagingParams: IPagingParameters, ): IExecutionResultWrapper { if (result.executionResult.headerItems === undefined) { return result; } const copyResult = cloneDeep(result); const start = [pagingParams.offset[0], pagingParams.offset[1]]; const end = [start[0] + pagingParams.limit[0], start[1] + pagingParams.limit[1]]; const data: any[][] = copyResult.executionResult.data as any[][]; const { headerItems, paging } = copyResult.executionResult; copyResult.executionResult.headerItems = headerItems.map((headers, dim) => { return headers.map(headerItems => { return headerItems.slice(start[dim], end[dim]); }); }); copyResult.executionResult.data = data.slice(start[0], end[0]).map(row => { if (Array.isArray(row)) { return row.slice(start[1], end[1]); } return row; }); const pagingApplied = copyResult.executionResult.data.length !== result.executionResult.data.length; if (pagingApplied) { paging.count = getSizeFromHeaders(copyResult.executionResult.headerItems); paging.offset = pagingParams.offset.slice(0, paging.count.length); } return copyResult; } // // executeAFM endpoint // function logSavedExecution(requestBody: AFM.IExecution, savedAfmExecution: ISchemaAfmExecution) { if (savedAfmExecution) { log.debug( "For request:\n", JSON.stringify(requestBody), "\nsaved execution found:\n", JSON.stringify(savedAfmExecution), ); } else { log.debug("For request:\n", JSON.stringify(requestBody), "\nNO saved execution found"); } } function getSavedAfmExecution(project: IMockProject, requestBody: AFM.IExecution): ISchemaAfmExecution { const savedAfmExecution: ISchemaAfmExecution = project.afmExecutions.find(execution => isRelativelyEqual(requestBody, execution.execution), ); logSavedExecution(requestBody, savedAfmExecution); return savedAfmExecution ? savedAfmExecution : null; } function toIntArray(param: any, defaultValue: number[]): number[] { if (param === undefined || typeof param !== "string") { return defaultValue; } return zip(param.split(",").map(str => parseInt(str, 10)), defaultValue).map(val => { return val[0] === undefined || isNaN(val[0]) ? val[1] : val[0]; }); } function extractPagingParameters(request: e.Request): IPagingParameters { return { limit: toIntArray(request.query.limit, [100, 100]), offset: toIntArray(request.query.offset, [0, 0]), }; } function getExecutionResultKey(executionResponse: IExecutionResponseWrapper): string { const executionResultLink = executionResponse.executionResponse.links.executionResult; const match = executionResultLink.match(/\/executionResults\/(.*)\?/); return match[match.length - 1]; } export function doExecuteAfm( project: IMockProject, execution: AFM.IExecution, config: IConfig, ): Execution.IExecutionResponseWrapper | IErrorResponse { const requestBody: AFM.IExecution = sanitize(execution); const savedExecution = getSavedAfmExecution(project, execution); if (savedExecution) { const { executionResult, executionResponse } = savedExecution; const response = executionResponse === undefined ? createExecutionResponse(project, requestBody) : executionResponse; if (isErrorSchemaResponse(executionResponse)) { return { error: { statusCode: executionResponse, message: "Mocked response Error", }, }; } const measureFilteredExecutionResult = MeasureValueFilters.filterExecutionResult( execution, executionResult, ); const rankingFilteredExecutionResult = RankingFilters.filterExecutionResult( execution, measureFilteredExecutionResult, ); if (isErrorResponse(rankingFilteredExecutionResult)) { return rankingFilteredExecutionResult; } if (isExecutionResponseWrapper(response)) { addToCache(getExecutionResultKey(response), rankingFilteredExecutionResult); return response; } } const randomGeneratorPool = createRandomGeneratorsPool(); const generatedResult = generateExecution(project, requestBody, config.randomSeed, randomGeneratorPool); if (isErrorResponse(generatedResult)) { return generatedResult; } const { executionResponse, executionResult } = generatedResult; addToCache(executionKey(requestBody), executionResult); return executionResponse; } export const executeAfm = { register(app: e.Application, project: IMockProject, config: IConfig) { app.post(`/gdc/app/projects/${project.project.meta.identifier}/executeAfm`, (req, res) => { const result = doExecuteAfm(project, req.body, config); if (isErrorResponse(result)) { const { error } = result; return res.status(error.statusCode).send(error.message); } return res.status(HttpStatusCodes.CREATED).send(result); }); app.get( `/gdc/app/projects/${project.project.meta.identifier}/executionResults/:executionId`, (req, res) => { if (hasCachedResult(req.params.executionId)) { const executionResult = getFromCache(req.params.executionId); cleanOldCache(); if (isHttpStatusCode(executionResult)) { return res.sendStatus(executionResult); } return res .status(HttpStatusCodes.OK) .send(applyPaging(executionResult, extractPagingParameters(req))); } return res.sendStatus(HttpStatusCodes.NOT_FOUND); }, ); return app; }, };