@@ -40,8 +40,7 @@
|
||||
"@backstage/plugin-events-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@google-cloud/pubsub": "^4.10.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"lodash": "^4.17.21"
|
||||
"@opentelemetry/api": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-defaults": "workspace:^",
|
||||
|
||||
+1
-41
@@ -17,10 +17,7 @@
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Message } from '@google-cloud/pubsub';
|
||||
import {
|
||||
createPatternResolver,
|
||||
readSubscriptionTasksFromConfig,
|
||||
} from './config';
|
||||
import { readSubscriptionTasksFromConfig } from './config';
|
||||
|
||||
function makeMessage(
|
||||
data: JsonObject,
|
||||
@@ -195,40 +192,3 @@ describe('readSubscriptionTasksFromConfig', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPatternResolver', () => {
|
||||
it('resolves patterns as expected', () => {
|
||||
expect(createPatternResolver('foo')({})).toEqual('foo');
|
||||
expect(createPatternResolver('{{a.b}}')({ a: { b: 'test' } })).toEqual(
|
||||
'test',
|
||||
);
|
||||
expect(createPatternResolver('{{a.b}}')({ a: { b: '7' } })).toEqual('7');
|
||||
expect(
|
||||
createPatternResolver('{{a.b-c}}')({ a: { 'b-c': 'test' } }),
|
||||
).toEqual('test');
|
||||
expect(
|
||||
createPatternResolver("{{a['b-c']}}")({ a: { 'b-c': 'test' } }),
|
||||
).toEqual('test');
|
||||
expect(
|
||||
createPatternResolver(' {{ a.b }} ')({ a: { b: ' test ' } }),
|
||||
).toEqual(' test ');
|
||||
});
|
||||
|
||||
it('throws on bad / missing context values', () => {
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.b}}')({ a: { b: [7] } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Expected string or number value for selector 'a.b', got object"`,
|
||||
);
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.b}}')({ a: {} }),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"No value for selector 'a.b'"`);
|
||||
});
|
||||
|
||||
it('just passes down broken parts of patterns', () => {
|
||||
expect(createPatternResolver('-{{a}}-}}')({ a: 1 })).toEqual('-1-}}');
|
||||
expect(createPatternResolver('{{-{{a}}-')({ a: 1 })).toEqual('{{-1-');
|
||||
expect(createPatternResolver('{{-{{a}}-}}')({ a: 1 })).toEqual('{{-1-}}');
|
||||
expect(createPatternResolver('{{-{{}}-}}')({ a: 1 })).toEqual('{{--}}');
|
||||
});
|
||||
});
|
||||
|
||||
+3
-51
@@ -18,8 +18,8 @@ import { RootConfigService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { Message } from '@google-cloud/pubsub';
|
||||
import { createPatternResolver } from '../util/createPatternResolver';
|
||||
import { SubscriptionTask } from './types';
|
||||
import lodash from 'lodash';
|
||||
|
||||
export function readSubscriptionTasksFromConfig(
|
||||
rootConfig: RootConfigService,
|
||||
@@ -34,7 +34,7 @@ export function readSubscriptionTasksFromConfig(
|
||||
return subscriptionsConfig.keys().map(subscriptionId => {
|
||||
if (!subscriptionId.match(/^[-_\w]+$/)) {
|
||||
throw new InputError(
|
||||
`Expected Googoe Pub/Sub subscription ID to consist of letters, numbers, dashes and lodashes, but got '${subscriptionId}'`,
|
||||
`Expected Googoe Pub/Sub subscription ID to consist of letters, numbers, dashes and underscores, but got '${subscriptionId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function readMetadataMapper(
|
||||
metadata[key] = value;
|
||||
}
|
||||
} catch {
|
||||
// ignore silently, keep original unchanged
|
||||
// ignore silently, keep original
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -128,51 +128,3 @@ function readMetadataMapper(
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a pattern string that may contain `{{ path.to.value }}` placeholders,
|
||||
* and returns a function that accepts a context object and returns strings that
|
||||
* have had its placeholders filled in by using `lodash.get` accordingly on the
|
||||
* context.
|
||||
*/
|
||||
export function createPatternResolver(
|
||||
pattern: string,
|
||||
): (context: unknown) => string {
|
||||
// This split results in an array where even elements are static strings
|
||||
// between placeholders, and odd elements are the contents inside
|
||||
// placeholders.
|
||||
//
|
||||
// For example, the pattern:
|
||||
// "{{ foo }}-{{bar}}{{baz}}."
|
||||
// will result in:
|
||||
// ['', 'foo', '-', 'bar', '', 'baz', .']
|
||||
const patternParts = pattern.split(/\{{\s*([\w\[\]'_.-]*)\s*}}/g);
|
||||
|
||||
const resolvers = new Array<(context: unknown) => string>();
|
||||
|
||||
for (let i = 0; i < patternParts.length; i += 2) {
|
||||
const staticPart = patternParts[i];
|
||||
const placeholderPart = patternParts[i + 1];
|
||||
|
||||
if (staticPart) {
|
||||
resolvers.push(() => staticPart);
|
||||
}
|
||||
|
||||
if (placeholderPart) {
|
||||
resolvers.push(context => {
|
||||
const value = lodash.get(context, placeholderPart);
|
||||
if (typeof value === 'string' || Number.isFinite(value)) {
|
||||
return String(value);
|
||||
} else if (!value) {
|
||||
throw new InputError(`No value for selector '${placeholderPart}'`);
|
||||
} else {
|
||||
throw new InputError(
|
||||
`Expected string or number value for selector '${placeholderPart}', got ${typeof value}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return context => resolvers.map(resolver => resolver(context)).join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2025 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 { createPatternResolver } from './createPatternResolver';
|
||||
|
||||
describe('createPatternResolver', () => {
|
||||
it('resolves patterns as expected', () => {
|
||||
expect(createPatternResolver('foo')({})).toEqual('foo');
|
||||
expect(createPatternResolver('{{a.b}}')({ a: { b: 'test' } })).toEqual(
|
||||
'test',
|
||||
);
|
||||
expect(createPatternResolver('{{a.b}}')({ a: { b: '7' } })).toEqual('7');
|
||||
expect(
|
||||
createPatternResolver('{{a.b-c}}')({ a: { 'b-c': 'test' } }),
|
||||
).toEqual('test');
|
||||
expect(
|
||||
createPatternResolver("{{a['b-c']}}")({ a: { 'b-c': 'test' } }),
|
||||
).toEqual('test');
|
||||
expect(
|
||||
createPatternResolver('{{a["b-c"]}}')({ a: { 'b-c': 'test' } }),
|
||||
).toEqual('test');
|
||||
expect(
|
||||
createPatternResolver(' {{ a.b }} ')({ a: { b: ' test ' } }),
|
||||
).toEqual(' test ');
|
||||
expect(
|
||||
createPatternResolver('-{{ a.b[1] }}-')({
|
||||
a: { b: ['first', 'second'] },
|
||||
}),
|
||||
).toEqual('-second-');
|
||||
expect(
|
||||
createPatternResolver('-{{ a.b.0 }}-')({
|
||||
a: { b: ['first', 'second'] },
|
||||
}),
|
||||
).toEqual('-first-');
|
||||
});
|
||||
|
||||
it('throws on bad / missing context values', () => {
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.b}}')({ a: { b: [7] } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Expected string or number value for selector 'a.b', got object"`,
|
||||
);
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.b}}')({ a: {} }),
|
||||
).toThrowErrorMatchingInlineSnapshot(`"No value for selector 'a.b'"`);
|
||||
expect(() =>
|
||||
createPatternResolver("-{{ a.b['length'] }}-")({
|
||||
a: { b: ['first', 'second'] },
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"No value for selector 'a.b['length']'"`,
|
||||
);
|
||||
expect(() =>
|
||||
createPatternResolver('-{{ a.b.length }}-')({
|
||||
a: { b: ['first', 'second'] },
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"No value for selector 'a.b.length'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('just passes down broken parts of patterns', () => {
|
||||
expect(createPatternResolver('-{{a}}-}}')({ a: 1 })).toEqual('-1-}}');
|
||||
expect(createPatternResolver('{{-{{a}}-')({ a: 1 })).toEqual('{{-1-');
|
||||
expect(createPatternResolver('{{-{{a}}-}}')({ a: 1 })).toEqual('{{-1-}}');
|
||||
expect(createPatternResolver('{{-{{}}-}}')({ a: 1 })).toEqual('{{--}}');
|
||||
});
|
||||
|
||||
it('only follows own properties', () => {
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.constructor}}')({ a: { b: 'test' } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"No value for selector 'a.constructor'"`,
|
||||
);
|
||||
expect(() =>
|
||||
createPatternResolver('{{a.__proto__}}')({ a: { b: 'test' } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"No value for selector 'a.__proto__'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2025 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 { InputError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* Takes a pattern string that may contain `{{ path.to.value }}` placeholders,
|
||||
* and returns a function that accepts a context object and returns strings that
|
||||
* have had its placeholders filled in by following the dot separated path of
|
||||
* properties accordingly on the context.
|
||||
*/
|
||||
export function createPatternResolver<TContext extends object = object>(
|
||||
pattern: string,
|
||||
): (context: TContext) => string {
|
||||
// This split results in an array where even elements are static strings
|
||||
// between placeholders, and odd elements are the contents inside
|
||||
// placeholders.
|
||||
//
|
||||
// For example, the pattern:
|
||||
// "{{ foo }}-{{bar}}{{baz}}."
|
||||
// will result in:
|
||||
// ['', 'foo', '-', 'bar', '', 'baz', '.']
|
||||
const patternParts = pattern.split(/{{\s*([\w\[\]'"_.-]*)\s*}}/g);
|
||||
|
||||
const resolvers = new Array<(context: TContext) => string>();
|
||||
|
||||
for (let i = 0; i < patternParts.length; i += 2) {
|
||||
const staticPart = patternParts[i];
|
||||
const placeholderPart = patternParts[i + 1];
|
||||
|
||||
if (staticPart) {
|
||||
resolvers.push(() => staticPart);
|
||||
}
|
||||
|
||||
if (placeholderPart) {
|
||||
const getter = createGetter<TContext>(placeholderPart);
|
||||
resolvers.push(context => {
|
||||
const value = getter(context);
|
||||
if (typeof value === 'string' || Number.isFinite(value)) {
|
||||
return String(value);
|
||||
} else if (!value) {
|
||||
throw new InputError(`No value for selector '${placeholderPart}'`);
|
||||
} else {
|
||||
throw new InputError(
|
||||
`Expected string or number value for selector '${placeholderPart}', got ${typeof value}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return context => resolvers.map(resolver => resolver(context)).join('');
|
||||
}
|
||||
|
||||
function createGetter<TContext extends object = object>(
|
||||
path: string,
|
||||
): (context: TContext) => unknown | undefined {
|
||||
// The resulti of the split contains quads:
|
||||
//
|
||||
// - any "regular" part
|
||||
// - pure digits that were within brackets, if applicable
|
||||
// - contents of a single quoted string that was within brackets, if applicable
|
||||
// - contents of a double quoted string that was within brackets, if applicable
|
||||
//
|
||||
// For example, the path:
|
||||
// foo.bar[0].baz["qux.e"]a
|
||||
// will result in:
|
||||
// [
|
||||
// 'foo', undefined, undefined, undefined,
|
||||
// 'bar', '0', undefined, undefined,
|
||||
// 'baz', undefined, 'qux.e', undefined,
|
||||
// 'a'
|
||||
// ]
|
||||
// and then the empty elements are stripped away
|
||||
const parts = path
|
||||
.split(/\.|\[(?:(\d+)|'([^']+)'|"([^"]+)")\]\.?/g)
|
||||
.filter(Boolean);
|
||||
|
||||
return (context: TContext): unknown | undefined => {
|
||||
let current = context;
|
||||
for (const part of parts) {
|
||||
if (typeof current !== 'object' || !current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (!part.match(/^\d+$/)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as any[])[Number(part)];
|
||||
} else {
|
||||
if (!Object.hasOwn(current, part)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as any)[part];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user