Merge pull request #8719 from cmpadden/warn-app-config-deprecations

Warn users of deprecated keys in app configurations
This commit is contained in:
Patrik Oldsberg
2022-01-13 23:25:01 +01:00
committed by GitHub
20 changed files with 253 additions and 34 deletions
+1
View File
@@ -21,6 +21,7 @@ export type ConfigSchemaProcessingOptions = {
visibility?: ConfigVisibility[];
valueTransform?: TransformFunc<any>;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
};
// @public (undocumented)
@@ -182,10 +182,10 @@ function compileTsSchemas(paths: string[]) {
program,
// All schemas should export a `Config` symbol
'Config',
// This enables usage of @visibility is doc comments
// This enables usage of @visibility and @deprecated in doc comments
{
required: true,
validationKeywords: ['visibility'],
validationKeywords: ['visibility', 'deprecated'],
},
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
) as JsonObject | null;
@@ -40,6 +40,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
errors: [
@@ -53,6 +54,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
});
@@ -112,6 +114,7 @@ describe('compileConfigSchemas', () => {
'/properties/d/items': 'frontend',
}),
),
deprecationByDataPath: new Map(),
});
});
@@ -137,4 +140,34 @@ describe('compileConfigSchemas', () => {
"Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility",
);
});
it('should discover deprecations', () => {
const validate = compileConfigSchemas([
{
path: 'a1',
value: {
type: 'object',
properties: {
a: { type: 'string', deprecated: 'deprecation reason for a' },
b: { type: 'string', deprecated: 'deprecation reason for b' },
c: { type: 'string' },
},
},
},
]);
expect(
validate([
{ data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' },
]),
).toEqual({
deprecationByDataPath: new Map(
Object.entries({
'/a': 'deprecation reason for a',
'/b': 'deprecation reason for b',
}),
),
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
});
});
});
@@ -40,6 +40,7 @@ export function compileConfigSchemas(
// output during validation. We work around this by having this extra piece
// of state that we reset before each validation.
const visibilityByDataPath = new Map<string, ConfigVisibility>();
const deprecationByDataPath = new Map<string, string>();
const ajv = new Ajv({
allErrors: true,
@@ -47,28 +48,48 @@ export function compileConfigSchemas(
schemas: {
'https://backstage.io/schema/config-v1': true,
},
}).addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
})
.addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
})
.removeKeyword('deprecated') // remove `deprecated` keyword so that we can implement our own compiler
.addKeyword({
keyword: 'deprecated',
metaSchema: { type: 'string' },
compile(deprecationDescription: string) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
});
// create mapping of deprecation description and data path of property
deprecationByDataPath.set(normalizedPath, deprecationDescription);
return true;
};
},
});
for (const schema of schemas) {
try {
@@ -79,6 +100,7 @@ export function compileConfigSchemas(
}
const merged = mergeConfigSchemas(schemas.map(_ => _.value));
const validate = ajv.compile(merged);
const visibilityBySchemaPath = new Map<string, ConfigVisibility>();
@@ -94,17 +116,20 @@ export function compileConfigSchemas(
visibilityByDataPath.clear();
const valid = validate(config);
if (!valid) {
return {
errors: validate.errors ?? [],
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
}
return {
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
};
}
@@ -64,6 +64,13 @@ const visibility = new Map<string, ConfigVisibility>(
}),
);
const deprecations = new Map<string, string>(
Object.entries({
'/arr': 'deprecated array',
'/objB/never': 'deprecated nested property',
}),
);
describe('filterByVisibility', () => {
test.each<[ConfigVisibility[], JsonObject]>([
[
@@ -185,9 +192,34 @@ describe('filterByVisibility', () => {
[['frontend', 'backend', 'secret'], { data, filteredKeys: [] }],
])('should filter correctly with %p', (filter, expected) => {
expect(
filterByVisibility(data, filter, visibility, undefined, true),
filterByVisibility(
data,
filter,
visibility,
deprecations,
undefined,
true,
false,
),
).toEqual(expected);
});
it('should include deprecated keys regardless of visibility', () => {
expect(
filterByVisibility(
data,
[],
visibility,
deprecations,
undefined,
true,
true,
).deprecatedKeys,
).toEqual([
{ key: 'arr', description: 'deprecated array' },
{ key: 'objB.never', description: 'deprecated nested property' },
]);
});
});
describe('filterErrorsByVisibility', () => {
@@ -30,10 +30,17 @@ export function filterByVisibility(
data: JsonObject,
includeVisibilities: ConfigVisibility[],
visibilityByDataPath: Map<string, ConfigVisibility>,
deprecationByDataPath: Map<string, string>,
transformFunc?: TransformFunc<number | string | boolean>,
withFilteredKeys?: boolean,
): { data: JsonObject; filteredKeys?: string[] } {
withDeprecatedKeys?: boolean,
): {
data: JsonObject;
filteredKeys?: string[];
deprecatedKeys?: { key: string; description: string }[];
} {
const filteredKeys = new Array<string>();
const deprecatedKeys = new Array<{ key: string; description: string }>();
function transform(
jsonVal: JsonValue,
@@ -44,6 +51,12 @@ export function filterByVisibility(
visibilityByDataPath.get(visibilityPath) ?? DEFAULT_CONFIG_VISIBILITY;
const isVisible = includeVisibilities.includes(visibility);
// deprecated keys are added regardless of visibility indicator
const deprecation = deprecationByDataPath.get(visibilityPath);
if (deprecation) {
deprecatedKeys.push({ key: filterPath, description: deprecation });
}
if (typeof jsonVal !== 'object') {
if (isVisible) {
if (transformFunc) {
@@ -109,6 +122,7 @@ export function filterByVisibility(
return {
filteredKeys: withFilteredKeys ? filteredKeys : undefined,
deprecatedKeys: withDeprecatedKeys ? deprecatedKeys : undefined,
data: (transform(data, '', '') as JsonObject) ?? {},
};
}
@@ -62,7 +62,10 @@ describe('loadConfigSchema', () => {
expect(schema.process(configs)).toEqual(configs);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(
schema.process(configs, {
@@ -71,7 +74,11 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X' }, context: 'test', filteredKeys: ['key2'] },
{
data: { key1: 'X' },
context: 'test',
filteredKeys: ['key2'],
},
]);
expect(
schema.process(configs, {
@@ -79,14 +86,21 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X', key2: 'X' }, context: 'test', filteredKeys: [] },
{
data: { key1: 'X', key2: 'X' },
context: 'test',
filteredKeys: [],
},
]);
const serialized = schema.serialize();
const schema2 = await loadConfigSchema({ serialized });
expect(schema2.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() =>
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
@@ -131,7 +145,10 @@ describe('loadConfigSchema', () => {
'Config validation failed, Config should be number { type=number } at /key2',
);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() => schema.process(configs, { visibility: ['secret'] })).toThrow(
'Config validation failed, Config should be number { type=number } at /key2',
@@ -179,7 +196,12 @@ describe('loadConfigSchema', () => {
];
expect(
schema.process(mkConfig({ x: 1 }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(() => schema.process(mkConfig({ y: 1 }))).toThrow(
'Config validation failed, Config should be string { type=string } at /nested/0/y',
);
@@ -190,10 +212,20 @@ describe('loadConfigSchema', () => {
);
expect(
schema.process(mkConfig({ x: 'a' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(
schema.process(mkConfig({ y: 'aaa' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{ y: 'aaa' }] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{ y: 'aaa' }] },
context: 'test',
},
]);
expect(() =>
schema.process(mkConfig({ y: 'aaaa' }), { visibility: ['frontend'] }),
).toThrow(
@@ -82,7 +82,7 @@ export async function loadConfigSchema(
return {
process(
configs: AppConfig[],
{ visibility, valueTransform, withFilteredKeys } = {},
{ visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {},
): AppConfig[] {
const result = validate(configs);
@@ -105,8 +105,10 @@ export async function loadConfigSchema(
data,
visibility,
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
} else if (valueTransform) {
@@ -116,8 +118,10 @@ export async function loadConfigSchema(
data,
Array.from(CONFIG_VISIBILITIES),
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
}
@@ -81,6 +81,13 @@ type ValidationResult = {
* The path in the key uses the form `/properties/<key>/items/additionalProperties/<leaf-key>`
*/
visibilityBySchemaPath: Map<string, ConfigVisibility>;
/**
* The deprecated options that were discovered during validation.
*
* The path in the key uses the form `/<key>/<sub-key>/<array-index>/<leaf-key>`
*/
deprecationByDataPath: Map<string, string>;
};
/**
@@ -124,6 +131,13 @@ export type ConfigSchemaProcessingOptions = {
* Default: `false`.
*/
withFilteredKeys?: boolean;
/**
* Whether or not to include the `deprecatedKeys` property in the output `AppConfig`s.
*
* Default: `true`.
*/
withDeprecatedKeys?: boolean;
};
/**