From da4ee8b8741491ba85981f55708e89ac812f0fb4 Mon Sep 17 00:00:00 2001 From: Rhys Arkins <rhys@arkins.net> Date: Tue, 8 Oct 2024 11:11:15 +0200 Subject: [PATCH] feat(jsonata): validation, caching, better logging (#31832) --- lib/config/validation.spec.ts | 9 ++++++ lib/config/validation.ts | 13 +++++++- lib/modules/datasource/custom/index.spec.ts | 33 ++++++++++++++++++--- lib/modules/datasource/custom/index.ts | 19 ++++++++---- lib/util/jsonata.spec.ts | 13 ++++++++ lib/util/jsonata.ts | 21 +++++++++++++ 6 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 lib/util/jsonata.spec.ts create mode 100644 lib/util/jsonata.ts diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index 4eb07aa8ca..e7dc924ca5 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -306,10 +306,19 @@ describe('config/validation', () => { defaultRegistryUrlTemplate: [], transformTemplates: [{}], }, + bar: { + description: 'foo', + defaultRegistryUrlTemplate: 'bar', + transformTemplates: ['foo = "bar"', 'bar[0'], + }, }, } as any; const { errors } = await configValidation.validateConfig('repo', config); expect(errors).toMatchObject([ + { + message: + 'Invalid JSONata expression for customDatasources: Expected "]" before end of expression', + }, { message: 'Invalid `customDatasources.defaultRegistryUrlTemplate` configuration: is a string', diff --git a/lib/config/validation.ts b/lib/config/validation.ts index fdcf487a20..b6218b84be 100644 --- a/lib/config/validation.ts +++ b/lib/config/validation.ts @@ -8,6 +8,7 @@ import type { } from '../modules/manager/custom/regex/types'; import type { CustomManager } from '../modules/manager/custom/types'; import type { HostRule } from '../types'; +import { getExpression } from '../util/jsonata'; import { regEx } from '../util/regex'; import { getRegexPredicate, @@ -745,7 +746,17 @@ export async function validateConfig( message: `Invalid \`${currentPath}.${subKey}\` configuration: key is not allowed`, }); } else if (subKey === 'transformTemplates') { - if (!is.array(subValue, is.string)) { + if (is.array(subValue, is.string)) { + for (const expression of subValue) { + const res = getExpression(expression); + if (res instanceof Error) { + errors.push({ + topic: 'Configuration Error', + message: `Invalid JSONata expression for ${currentPath}: ${res.message}`, + }); + } + } + } else { errors.push({ topic: 'Configuration Error', message: `Invalid \`${currentPath}.${subKey}\` configuration: is not an array of string`, diff --git a/lib/modules/datasource/custom/index.spec.ts b/lib/modules/datasource/custom/index.spec.ts index 2eb0f46212..62574bdf77 100644 --- a/lib/modules/datasource/custom/index.spec.ts +++ b/lib/modules/datasource/custom/index.spec.ts @@ -229,7 +229,7 @@ describe('modules/datasource/custom/index', () => { expect(result).toEqual(expected); }); - it('returns null if transformation using jsonata rules fail', async () => { + it('returns null if transformation compilation using jsonata fails', async () => { httpMock .scope('https://example.com') .get('/v1') @@ -248,9 +248,34 @@ describe('modules/datasource/custom/index', () => { }, }); expect(result).toBeNull(); - expect(logger.debug).toHaveBeenCalledWith( - { err: expect.any(Object), transformTemplate: '$[.name = "Alice" and' }, - 'Error while transforming response', + expect(logger.once.warn).toHaveBeenCalledWith( + { errorMessage: 'The symbol "." cannot be used as a unary operator' }, + 'Invalid JSONata expression: $[.name = "Alice" and', + ); + }); + + it('returns null if jsonata expression evaluation fails', async () => { + httpMock + .scope('https://example.com') + .get('/v1') + .reply(200, '1.0.0 \n2.0.0 \n 3.0.0 ', { + 'Content-Type': 'text/plain', + }); + const result = await getPkgReleases({ + datasource: `${CustomDatasource.id}.foo`, + packageName: 'myPackage', + customDatasources: { + foo: { + defaultRegistryUrlTemplate: 'https://example.com/v1', + transformTemplates: ['$notafunction()'], + format: 'plain', + }, + }, + }); + expect(result).toBeNull(); + expect(logger.once.warn).toHaveBeenCalledWith( + { err: expect.any(Object) }, + 'Error while evaluating JSONata expression: $notafunction()', ); }); diff --git a/lib/modules/datasource/custom/index.ts b/lib/modules/datasource/custom/index.ts index 0ecdc11bc8..372d15e97a 100644 --- a/lib/modules/datasource/custom/index.ts +++ b/lib/modules/datasource/custom/index.ts @@ -1,6 +1,6 @@ import is from '@sindresorhus/is'; -import jsonata from 'jsonata'; import { logger } from '../../../logger'; +import { getExpression } from '../../../util/jsonata'; import { Datasource } from '../datasource'; import type { DigestConfig, GetReleasesConfig, ReleaseResult } from '../types'; import { fetchers } from './formats'; @@ -46,13 +46,22 @@ export class CustomDatasource extends Datasource { logger.trace({ data }, `Custom manager fetcher '${format}' returned data.`); for (const transformTemplate of transformTemplates) { + const expression = getExpression(transformTemplate); + + if (expression instanceof Error) { + logger.once.warn( + { errorMessage: expression.message }, + `Invalid JSONata expression: ${transformTemplate}`, + ); + return null; + } + try { - const expression = jsonata(transformTemplate); data = await expression.evaluate(data); } catch (err) { - logger.debug( - { err, transformTemplate }, - 'Error while transforming response', + logger.once.warn( + { err }, + `Error while evaluating JSONata expression: ${transformTemplate}`, ); return null; } diff --git a/lib/util/jsonata.spec.ts b/lib/util/jsonata.spec.ts new file mode 100644 index 0000000000..f5a6651637 --- /dev/null +++ b/lib/util/jsonata.spec.ts @@ -0,0 +1,13 @@ +import { getExpression } from './jsonata'; + +describe('util/jsonata', () => { + describe('getExpression', () => { + it('should return an expression', () => { + expect(getExpression('foo')).not.toBeInstanceOf(Error); + }); + + it('should return an error', () => { + expect(getExpression('foo[')).toBeInstanceOf(Error); + }); + }); +}); diff --git a/lib/util/jsonata.ts b/lib/util/jsonata.ts new file mode 100644 index 0000000000..2811f4e407 --- /dev/null +++ b/lib/util/jsonata.ts @@ -0,0 +1,21 @@ +import jsonata from 'jsonata'; +import * as memCache from './cache/memory'; +import { toSha256 } from './hash'; + +export function getExpression(input: string): jsonata.Expression | Error { + const cacheKey = `jsonata:${toSha256(input)}`; + const cachedExpression = memCache.get<jsonata.Expression | Error>(cacheKey); + // istanbul ignore if: cannot test + if (cachedExpression) { + return cachedExpression; + } + let result: jsonata.Expression | Error; + try { + result = jsonata(input); + } catch (err) { + // JSONata errors aren't detected as TypeOf Error + result = new Error(err.message); + } + memCache.set(cacheKey, result); + return result; +} -- GitLab