implement support for string form human durations in config

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-11-17 18:54:56 +01:00
parent 994506952c
commit d52d7f9935
19 changed files with 515 additions and 163 deletions
+2 -1
View File
@@ -37,7 +37,8 @@
},
"dependencies": {
"@backstage/errors": "workspace:^",
"@backstage/types": "workspace:^"
"@backstage/types": "workspace:^",
"ms": "^2.1.3"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
@@ -15,114 +15,254 @@
*/
import {
readDurationFromConfig,
propsOfHumanDuration,
readDurationFromConfig,
} from './readDurationFromConfig';
import { ConfigReader } from './reader';
describe('readDurationFromConfig', () => {
it('reads all known keys', () => {
const config = new ConfigReader({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
describe('ISO form', () => {
it('parses the known forms', () => {
const config = new ConfigReader({
d1: 'P2DT6H',
d2: 'PT0.5S',
d3: 'PT3.1S',
d4: 'P1Y2M3W4DT5H6M7.8S',
});
expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({
days: 2,
hours: 6,
});
expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({
milliseconds: 500,
});
expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({
seconds: 3,
milliseconds: 100,
});
expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({
years: 1,
months: 2,
weeks: 3,
days: 4,
hours: 5,
minutes: 6,
seconds: 7,
milliseconds: 800,
});
});
expect(readDurationFromConfig(config)).toEqual({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
it('throws on errors', () => {
const config = new ConfigReader({
d1: 'P 1Y',
d2: 'P1L',
d3: 'P',
});
expect(() =>
readDurationFromConfig(config, { key: 'd1' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid duration 'P 1Y' in config at 'd1', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`,
);
expect(() =>
readDurationFromConfig(config, { key: 'd2' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid duration 'P1L' in config at 'd2', Error: Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)"`,
);
expect(() =>
readDurationFromConfig(config, { key: 'd3' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid duration 'P' in config at 'd3', Error: Invalid ISO format, no values given"`,
);
});
});
it('reads all known keys, for a subkey', () => {
const config = new ConfigReader({
sub: {
key: {
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
describe('ms form', () => {
it('parses the known units', () => {
// this is not exhaustive, but tests all supported units to ensure that
// our conversion to HumanDuration form works
const config = new ConfigReader({
d1: '1y',
d2: '2 years',
d3: '4w',
d4: '5h',
d5: '6 hrs',
d6: '7min',
d7: '9 minutes',
d8: '3.5 seconds',
d9: '25 ms',
d10: '1850ms',
});
expect(readDurationFromConfig(config, { key: 'd1' })).toEqual({
years: 1,
});
expect(readDurationFromConfig(config, { key: 'd2' })).toEqual({
years: 2,
});
expect(readDurationFromConfig(config, { key: 'd3' })).toEqual({
weeks: 4,
});
expect(readDurationFromConfig(config, { key: 'd4' })).toEqual({
hours: 5,
});
expect(readDurationFromConfig(config, { key: 'd5' })).toEqual({
hours: 6,
});
expect(readDurationFromConfig(config, { key: 'd6' })).toEqual({
minutes: 7,
});
expect(readDurationFromConfig(config, { key: 'd7' })).toEqual({
minutes: 9,
});
expect(readDurationFromConfig(config, { key: 'd8' })).toEqual({
seconds: 3,
milliseconds: 500,
});
expect(readDurationFromConfig(config, { key: 'd9' })).toEqual({
milliseconds: 25,
});
expect(readDurationFromConfig(config, { key: 'd10' })).toEqual({
seconds: 1,
milliseconds: 850,
});
});
it('throws on errors', () => {
const config = new ConfigReader({
d1: '1m 3s',
d2: '-3s',
d3: '',
});
expect(() =>
readDurationFromConfig(config, { key: 'd1' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid duration '1m 3s' in config at 'd1', Error: Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'"`,
);
expect(() =>
readDurationFromConfig(config, { key: 'd2' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid duration '-3s' in config at 'd2', Error: Negative durations are not allowed"`,
);
expect(() =>
readDurationFromConfig(config, { key: 'd3' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid type in config for key 'd3' in 'mock-config', got empty-string, wanted string"`,
);
});
});
describe('object form', () => {
it('reads all known keys', () => {
const config = new ConfigReader({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
});
expect(readDurationFromConfig(config)).toEqual({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
});
});
it('reads all known keys, for a subkey', () => {
const config = new ConfigReader({
sub: {
key: {
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
},
},
});
expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
});
});
it('rejects wrong type of target, for a subkey', () => {
const config = new ConfigReader({
sub: { key: 7 },
});
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
"Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object",
);
});
it('rejects no keys', () => {
const config = new ConfigReader({});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it('rejects no keys, for a subkey', () => {
const config = new ConfigReader({ sub: { key: {} } });
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
`Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it('rejects unknown keys', () => {
const config = new ConfigReader({
minutes: 3,
invalid: 'value',
});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it.each(propsOfHumanDuration)('rejects non-number %p', prop => {
const config = new ConfigReader({
[prop]: 'value',
});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`,
);
});
it.each(propsOfHumanDuration)(
'rejects non-number %p, for a subkey',
prop => {
const config = new ConfigReader({
sub: {
key: {
[prop]: 'value',
},
},
});
expect(() =>
readDurationFromConfig(config, { key: 'sub.key' }),
).toThrow(
`Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`,
);
},
});
expect(readDurationFromConfig(config, { key: 'sub.key' })).toEqual({
milliseconds: 1,
seconds: 2,
minutes: 3,
hours: 4,
days: 5,
weeks: 6,
months: 7,
years: 8,
});
});
it('rejects wrong type of target, for a subkey', () => {
const config = new ConfigReader({
sub: { key: 7 },
});
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
"Failed to read duration from config, TypeError: Invalid type in config for key 'sub.key' in 'mock-config', got number, wanted object",
);
});
it('rejects no keys', () => {
const config = new ConfigReader({});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it('rejects no keys, for a subkey', () => {
const config = new ConfigReader({ sub: { key: {} } });
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
`Failed to read duration from config at 'sub.key', Error: Needs one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it('rejects unknown keys', () => {
const config = new ConfigReader({
minutes: 3,
invalid: 'value',
});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Unknown property 'invalid'; expected one or more of 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds'`,
);
});
it.each(propsOfHumanDuration)('rejects non-number %p', prop => {
const config = new ConfigReader({
[prop]: 'value',
});
expect(() => readDurationFromConfig(config)).toThrow(
`Failed to read duration from config, Error: Unable to convert config value for key '${prop}' in 'mock-config' to a number`,
);
});
it.each(propsOfHumanDuration)('rejects non-number %p, for a subkey', prop => {
const config = new ConfigReader({
sub: {
key: {
[prop]: 'value',
},
},
});
expect(() => readDurationFromConfig(config, { key: 'sub.key' })).toThrow(
`Failed to read duration from config, Error: Unable to convert config value for key 'sub.key.${prop}' in 'mock-config' to a number`,
);
});
});
+214 -5
View File
@@ -15,8 +15,9 @@
*/
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { InputError, stringifyError } from '@backstage/errors';
import { HumanDuration } from '@backstage/types';
import ms from 'ms';
export const propsOfHumanDuration = [
'years',
@@ -30,18 +31,29 @@ export const propsOfHumanDuration = [
];
/**
* Reads a duration from a config object.
* Reads a duration from config.
*
* @public
* @remarks
*
* The supported formats are:
*
* - A string in the format of '1d', '2 seconds' etc. as supported by the `ms`
* library.
* - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M'.
* - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }`.
*
* The string forms are naturally only supported if the `options.key` argument
* is passed, since a `Config` argument always represents an object by its
* nature.
*
* This does not support optionality; if you want to support optional durations,
* you need to first check the presence of the target with `config.has(...)` and
* then call this function.
*
* @param config - A configuration object
* @param key - If specified, read the duration from the given subkey
* under the config object
* @param key - If specified, read the duration from the given subkey under the
* config object
* @returns A duration object
*/
export function readDurationFromConfig(
@@ -49,6 +61,33 @@ export function readDurationFromConfig(
options?: {
key?: string;
},
): HumanDuration {
if (options?.key && typeof config.getOptional(options.key) === 'string') {
const value = config.getString(options.key).trim();
try {
return value.startsWith('P')
? parseIsoDuration(value)
: parseMsDuration(value);
} catch (error) {
throw new InputError(
`Invalid duration '${value}' in config at '${
options.key
}', ${stringifyError(error)}`,
);
}
}
return parseObjectDuration(config, options);
}
/**
* Parses the object form of durations.
*/
export function parseObjectDuration(
config: Config,
options?: {
key?: string;
},
): HumanDuration {
let root: Config;
let found = false;
@@ -94,8 +133,178 @@ export function readDurationFromConfig(
if (options?.key) {
prefix += ` at '${options.key}'`;
}
throw new InputError(`${prefix}, ${error}`);
throw new Error(`${prefix}, ${error}`);
}
return result as HumanDuration;
}
/**
* Parses friendly string durations like '1d', '2 seconds' etc using the ms
* library.
*/
export function parseMsDuration(input: string): HumanDuration {
if (/^\d+$/.exec(input)) {
// We explicitly disallow the only-digits form of the ms library, because
// from a configuration perspective it's just confusing to even be able to
// specify that
throw new Error(
`The value cannot be a plain number; try adding a unit like 'ms' or 'seconds'`,
);
}
let milliseconds = ms(input);
if (!Number.isFinite(milliseconds)) {
throw new Error(
`Not a valid duration string, try a number followed by a unit such as '1d' or '2 seconds'`,
);
} else if (milliseconds < 0) {
throw new Error('Negative durations are not allowed');
} else if (milliseconds === 0) {
return { milliseconds: 0 };
}
// As used by the ms library
const s = 1000;
const m = s * 60;
const h = m * 60;
const d = h * 24;
const w = d * 7;
const y = d * 365.25;
const result: HumanDuration = {};
if (milliseconds >= y) {
const years = Math.floor(milliseconds / y);
milliseconds -= years * y;
result.years = years;
}
if (milliseconds >= w) {
const weeks = Math.floor(milliseconds / w);
milliseconds -= weeks * w;
result.weeks = weeks;
}
if (milliseconds >= d) {
const days = Math.floor(milliseconds / d);
milliseconds -= days * d;
result.days = days;
}
if (milliseconds >= h) {
const hours = Math.floor(milliseconds / h);
milliseconds -= hours * h;
result.hours = hours;
}
if (milliseconds >= m) {
const minutes = Math.floor(milliseconds / m);
milliseconds -= minutes * m;
result.minutes = minutes;
}
if (milliseconds >= s) {
const seconds = Math.floor(milliseconds / s);
milliseconds -= seconds * s;
result.seconds = seconds;
}
if (milliseconds > 0) {
result.milliseconds = milliseconds;
}
return result;
}
/**
* Parses an ISO formatted duration string.
*
* Implementation taken from luxon's Duration.fromISO to not force that
* dependency on everyone.
*/
export function parseIsoDuration(input: string): HumanDuration {
const match =
/^-?P(?:(?:(-?\d{1,20}(?:\.\d{1,20})?)Y)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20}(?:\.\d{1,20})?)W)?(?:(-?\d{1,20}(?:\.\d{1,20})?)D)?(?:T(?:(-?\d{1,20}(?:\.\d{1,20})?)H)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,20}))?S)?)?)$/.exec(
input,
);
if (!match) {
throw new Error(
`Invalid ISO format, expected a value similar to 'P2DT6H' (2 days 6 hours) or 'PT1M' (1 minute)`,
);
}
const [
s,
yearStr,
monthStr,
weekStr,
dayStr,
hourStr,
minuteStr,
secondStr,
millisecondsStr,
] = match;
const hasNegativePrefix = s[0] === '-';
const negativeSeconds = !!secondStr && secondStr[0] === '-';
const maybeNegate = (num: number | undefined, force = false) =>
num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num;
const parseFloating = (value: string) => {
if (typeof value === 'undefined' || value === null || value === '') {
return undefined;
}
return parseFloat(value);
};
const parseMillis = (fraction: string | undefined) => {
// Return undefined (instead of 0) in these cases, where fraction is not set
if (
typeof fraction === 'undefined' ||
fraction === null ||
fraction === ''
) {
return undefined;
}
const f = parseFloat(`0.${fraction}`) * 1000;
return Math.floor(f);
};
const years = maybeNegate(parseFloating(yearStr));
const months = maybeNegate(parseFloating(monthStr));
const weeks = maybeNegate(parseFloating(weekStr));
const days = maybeNegate(parseFloating(dayStr));
const hours = maybeNegate(parseFloating(hourStr));
const minutes = maybeNegate(parseFloating(minuteStr));
const seconds = maybeNegate(parseFloating(secondStr), secondStr === '-0');
const milliseconds = maybeNegate(
parseMillis(millisecondsStr),
negativeSeconds,
);
if (
years === undefined &&
months === undefined &&
weeks === undefined &&
days === undefined &&
hours === undefined &&
minutes === undefined &&
seconds === undefined &&
milliseconds === undefined
) {
throw new Error('Invalid ISO format, no values given');
}
return {
...(years ? { years } : {}),
...(months ? { months } : {}),
...(weeks ? { weeks } : {}),
...(days ? { days } : {}),
...(hours ? { hours } : {}),
...(minutes ? { minutes } : {}),
...(seconds ? { seconds } : {}),
...(milliseconds ? { milliseconds } : {}),
};
}