feat: add parsing configuration option for ConfigSources

FileConfigSource and RemoteConfigSource can now be optionally configured to operate on more than yaml files

Signed-off-by: Tim Klever <tim.v.klever@aexp.com>
This commit is contained in:
Tim Klever
2024-06-18 16:12:52 -07:00
parent 27c314f732
commit 274428fd9d
9 changed files with 126 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': minor
---
Add configuration key to File and Remote `ConfigSource`s that enables configuration of parsing logic. Previously limited to yaml, these `ConfigSource`s now allow for a multitude of parsing options (e.g. JSON).
+7
View File
@@ -141,6 +141,7 @@ export class FileConfigSource implements ConfigSource {
// @public
export interface FileConfigSourceOptions {
parser?: Parser;
path: string;
substitutionFunc?: EnvFunc;
watch?: boolean;
@@ -218,6 +219,11 @@ export interface MutableConfigSourceOptions {
data?: JsonObject;
}
// @public
export type Parser = ({ contents }: { contents: string }) => Promise<{
result?: JsonObject;
}>;
// @public
export interface ReadConfigDataOptions {
// (undocumented)
@@ -242,6 +248,7 @@ export class RemoteConfigSource implements ConfigSource {
// @public
export interface RemoteConfigSourceOptions {
parser?: Parser;
reloadInterval?: HumanDuration;
substitutionFunc?: EnvFunc;
url: string;
@@ -59,6 +59,19 @@ describe('FileConfigSource', () => {
]);
});
it('should read a config file with optional parser', async () => {
const tmp = await tmpFiles({ 'a.json': JSON.stringify({ a: 1 }) });
const source = FileConfigSource.create({
path: tmp.resolve('a.json'),
parser: async ({ contents }) => ({ result: JSON.parse(contents) }),
});
await expect(readN(source, 1)).resolves.toEqual([
[{ data: { a: 1 }, context: 'a.json', path: tmp.resolve('a.json') }],
]);
});
it('should watch config files', async () => {
const tmp = await tmpFiles({ 'a.yaml': 'a: 1' });
@@ -17,16 +17,17 @@
import chokidar, { FSWatcher } from 'chokidar';
import fs from 'fs-extra';
import { basename, dirname, isAbsolute, resolve as resolvePath } from 'path';
import yaml from 'yaml';
import {
AsyncConfigSourceGenerator,
ConfigSource,
ConfigSourceData,
SubstitutionFunc,
Parser,
ReadConfigDataOptions,
} from './types';
import { createConfigTransformer } from './transform';
import { NotFoundError } from '@backstage/errors';
import { parseYamlContent } from './utils';
/**
* Options for {@link FileConfigSource.create}.
@@ -48,6 +49,11 @@ export interface FileConfigSourceOptions {
* A substitution function to use instead of the default environment substitution.
*/
substitutionFunc?: SubstitutionFunc;
/**
* A content parsing function to transform string content to configuration values.
*/
parser?: Parser;
}
async function readFile(path: string): Promise<string | undefined> {
@@ -95,11 +101,13 @@ export class FileConfigSource implements ConfigSource {
readonly #path: string;
readonly #substitutionFunc?: SubstitutionFunc;
readonly #watch?: boolean;
readonly #parser: Parser;
private constructor(options: FileConfigSourceOptions) {
this.#path = options.path;
this.#substitutionFunc = options.substitutionFunc;
this.#watch = options.watch ?? true;
this.#parser = options.parser ?? parseYamlContent;
}
// Work is duplicated across each read, in practice that should not
@@ -154,12 +162,12 @@ export class FileConfigSource implements ConfigSource {
watchedPaths.push(this.#path);
}
const content = await readFile(this.#path);
if (content === undefined) {
const contents = await readFile(this.#path);
if (contents === undefined) {
throw new NotFoundError(`Config file "${this.#path}" does not exist`);
}
const parsed = yaml.parse(content);
if (parsed === null) {
const { result: parsed } = await this.#parser({ contents });
if (parsed === undefined) {
return [];
}
try {
@@ -59,6 +59,45 @@ app:
]);
});
it('should load and parse config from a remote URL', async () => {
worker.use(
rest.get('http://localhost/config.json', (_req, res, ctx) =>
res(
ctx.body(
JSON.stringify({
app: {
title: 'Example App',
substituted: 'x',
escaped: '$${VALUE}',
},
}),
),
),
),
);
const source = RemoteConfigSource.create({
url: 'http://localhost/config.json',
substitutionFunc: async () => 'x',
parser: async ({ contents }) => ({ result: JSON.parse(contents) }),
});
await expect(readN(source, 1)).resolves.toEqual([
[
{
context: 'http://localhost/config.json',
data: {
app: {
title: 'Example App',
substituted: 'x',
escaped: '${VALUE}',
},
},
},
],
]);
});
it('should reload config from a remote URL', async () => {
let fetched = false;
@@ -22,14 +22,15 @@ import {
} from '@backstage/types';
import isEqual from 'lodash/isEqual';
import fetch from 'node-fetch';
import yaml from 'yaml';
import { ConfigTransformer, createConfigTransformer } from './transform';
import {
AsyncConfigSourceGenerator,
ConfigSource,
SubstitutionFunc,
ReadConfigDataOptions,
Parser,
} from './types';
import { parseYamlContent } from './utils';
const DEFAULT_RELOAD_INTERVAL = { seconds: 60 };
@@ -55,6 +56,11 @@ export interface RemoteConfigSourceOptions {
* A substitution function to use instead of the default environment substitution.
*/
substitutionFunc?: SubstitutionFunc;
/**
* A content parsing function to transform string content to configuration values.
*/
parser?: Parser;
}
/**
@@ -84,6 +90,7 @@ export class RemoteConfigSource implements ConfigSource {
readonly #url: string;
readonly #reloadIntervalMs: number;
readonly #transformer: ConfigTransformer;
readonly #parser: Parser;
private constructor(options: RemoteConfigSourceOptions) {
this.#url = options.url;
@@ -93,6 +100,7 @@ export class RemoteConfigSource implements ConfigSource {
this.#transformer = createConfigTransformer({
substitutionFunc: options.substitutionFunc,
});
this.#parser = options.parser ?? parseYamlContent;
}
async *readConfigData(
@@ -135,11 +143,21 @@ export class RemoteConfigSource implements ConfigSource {
throw await ResponseError.fromResponse(res);
}
const content = await res.text();
const data = await this.#transformer(yaml.parse(content));
if (data === null) {
const contents = await res.text();
const { result: rawData } = await this.#parser({ contents });
if (rawData === undefined) {
/**
* This error message is/was coupled to the implementation and with refactoring it is no longer truly accurate
* This behavior is also inconsistent with {@link FileConfigSource}, which doesn't error on unparseable or empty
* content
*
* Preserving to not make a breaking change
*/
throw new Error('configuration data is null');
} else if (typeof data !== 'object') {
}
const data = await this.#transformer(rawData);
if (typeof data !== 'object') {
throw new Error('configuration data is not an object');
} else if (Array.isArray(data)) {
throw new Error(
@@ -38,4 +38,5 @@ export type {
ConfigSourceData,
ReadConfigDataOptions,
AsyncConfigSourceGenerator,
Parser,
} from './types';
@@ -15,6 +15,7 @@
*/
import { AppConfig } from '@backstage/config';
import { JsonObject } from '@backstage/types';
/**
* The data returned by {@link ConfigSource.readConfigData}.
@@ -89,3 +90,18 @@ export interface ConfigSource {
* @public
*/
export type SubstitutionFunc = (name: string) => Promise<string | undefined>;
/**
* A custom function to be used for parsing configuration content.
*
* @remarks
*
* The default parsing function will parse configuration content as yaml.
*
* @public
*/
export type Parser = ({
contents,
}: {
contents: string;
}) => Promise<{ result?: JsonObject }>;
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import { Parser } from './types';
import yaml from 'yaml';
/** @internal */
export interface SimpleDeferred<T> {
promise: Promise<T>;
@@ -55,3 +58,9 @@ export async function waitOrAbort<T>(
signals.forEach(s => s.addEventListener('abort', onAbort));
});
}
/** @internal */
export const parseYamlContent: Parser = async ({ contents }) => {
const parsed = yaml.parse(contents);
return { result: parsed === null ? undefined : parsed };
};