diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index 269159aab4..6a02a5632f 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -21,6 +21,7 @@ export type ConfigSchemaProcessingOptions = { visibility?: ConfigVisibility[]; valueTransform?: TransformFunc; withFilteredKeys?: boolean; + withDeprecatedKeys?: boolean; }; // Warning: (ae-missing-release-tag) "ConfigTarget" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index d909cde58c..2ecea8e843 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -41,6 +41,7 @@ "json-schema": "^0.4.0", "json-schema-merge-allof": "^0.8.1", "json-schema-traverse": "^1.0.0", + "lodash": "^4.17.21", "node-fetch": "^2.6.1", "typescript-json-schema": "^0.52.0", "yaml": "^1.9.2", diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index 8e7f582e44..aa3202a1b5 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -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 is doc comments { required: true, - validationKeywords: ['visibility'], + validationKeywords: ['visibility', 'deprecated'], }, [path.split(sep).join('/')], // Unix paths are expected for all OSes here ) as JsonObject | null; diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts index 1e63f3cbf8..7615fa80f5 100644 --- a/packages/config-loader/src/lib/schema/compile.test.ts +++ b/packages/config-loader/src/lib/schema/compile.test.ts @@ -40,6 +40,7 @@ describe('compileConfigSchemas', () => { ], visibilityByDataPath: new Map(), visibilityBySchemaPath: new Map(), + deprecationBySchemaPath: new Map(), }); expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({ errors: [ @@ -53,6 +54,7 @@ describe('compileConfigSchemas', () => { ], visibilityByDataPath: new Map(), visibilityBySchemaPath: new Map(), + deprecationBySchemaPath: new Map(), }); }); @@ -112,6 +114,7 @@ describe('compileConfigSchemas', () => { '/properties/d/items': 'frontend', }), ), + deprecationBySchemaPath: 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({ + deprecationBySchemaPath: new Map( + Object.entries({ + '/properties/a': 'deprecation reason for a', + '/properties/b': 'deprecation reason for b', + }), + ), + visibilityByDataPath: new Map(), + visibilityBySchemaPath: new Map(), + }); + }); }); diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts index 1cbf5a404d..6dcd4713f5 100644 --- a/packages/config-loader/src/lib/schema/compile.ts +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -81,6 +81,13 @@ export function compileConfigSchemas( const merged = mergeConfigSchemas(schemas.map(_ => _.value)); const validate = ajv.compile(merged); + const deprecationBySchemaPath = new Map(); + traverse(merged, (schema, path) => { + if ('deprecated' in schema) { + deprecationBySchemaPath.set(path, schema.deprecated); + } + }); + const visibilityBySchemaPath = new Map(); traverse(merged, (schema, path) => { if (schema.visibility && schema.visibility !== 'backend') { @@ -99,12 +106,14 @@ export function compileConfigSchemas( errors: validate.errors ?? [], visibilityByDataPath: new Map(visibilityByDataPath), visibilityBySchemaPath, + deprecationBySchemaPath, }; } return { visibilityByDataPath: new Map(visibilityByDataPath), visibilityBySchemaPath, + deprecationBySchemaPath, }; }; } diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index be07b915ed..2324bebc9d 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -16,7 +16,11 @@ import { JsonObject } from '@backstage/types'; import { ConfigVisibility } from './types'; -import { filterByVisibility, filterErrorsByVisibility } from './filtering'; +import { + filterByVisibility, + filterErrorsByVisibility, + filterByDeprecated, +} from './filtering'; const data = { arr: ['f', 'b', 's'], @@ -64,6 +68,13 @@ const visibility = new Map( }), ); +const deprecations = new Map( + Object.entries({ + '/properties/arr': 'deprecated array', + '/properties/objB/properties/never': 'deprecated nested property', + }), +); + describe('filterByVisibility', () => { test.each<[ConfigVisibility[], JsonObject]>([ [ @@ -381,3 +392,17 @@ describe('filterErrorsByVisibility', () => { ]); }); }); + +describe('filterByDeprecated', () => { + it('should allow empty list of deprecations', () => { + expect(filterByDeprecated(data, new Map())).toEqual({ deprecatedKeys: [] }); + }); + it('should return a list of deprecated properties', () => { + expect(filterByDeprecated(data, deprecations)).toEqual({ + deprecatedKeys: [ + { key: 'arr', description: 'deprecated array' }, + { key: 'objB.never', description: 'deprecated nested property' }, + ], + }); + }); +}); diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index b2f39d3cc4..f955343066 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -15,6 +15,7 @@ */ import { JsonObject, JsonValue } from '@backstage/types'; +import { has } from 'lodash'; import { ConfigVisibility, DEFAULT_CONFIG_VISIBILITY, @@ -22,6 +23,36 @@ import { ValidationError, } from './types'; +/** + * This filters data by deprecation status to only include those which have been deprecated + * + * @param data - configuration data + * @param deprecationBySchemaPath - mapping of schema path to deprecation description + * @returns deprecated configuration keys with their deprecation description + */ +export function filterByDeprecated( + data: JsonObject, + deprecationBySchemaPath: Map, + withDeprecatedKeys: boolean = true, +): { deprecatedKeys: { key: string; description: string }[] } { + const deprecatedKeys = new Array<{ key: string; description: string }>(); + if (!withDeprecatedKeys) { + return { deprecatedKeys }; + } + + deprecationBySchemaPath.forEach((desc, schemaPath) => { + // convert schema path to object path (/properties/techdocs/properties/storageUrl -> techdocs.storageUrl) + const propertyPath = schemaPath + .replace(/^\/properties\//, '') // remove leading `/properties/` entirely + .replace(/\/properties\//g, '.'); // replace all other `/properties/` with a `.` + if (has(data, propertyPath)) { + deprecatedKeys.push({ key: propertyPath, description: desc }); + } + }); + + return { deprecatedKeys }; +} + /** * This filters data by visibility by discovering the visibility of each * value, and then only keeping the ones that are specified in `includeVisibilities`. diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index e2ae3cc2c7..a2f1df8d5f 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -62,7 +62,11 @@ 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', + deprecatedKeys: [], + }, ]); expect( schema.process(configs, { @@ -71,7 +75,12 @@ describe('loadConfigSchema', () => { withFilteredKeys: true, }), ).toEqual([ - { data: { key1: 'X' }, context: 'test', filteredKeys: ['key2'] }, + { + data: { key1: 'X' }, + context: 'test', + filteredKeys: ['key2'], + deprecatedKeys: [], + }, ]); expect( schema.process(configs, { @@ -79,14 +88,23 @@ describe('loadConfigSchema', () => { withFilteredKeys: true, }), ).toEqual([ - { data: { key1: 'X', key2: 'X' }, context: 'test', filteredKeys: [] }, + { + data: { key1: 'X', key2: 'X' }, + context: 'test', + filteredKeys: [], + deprecatedKeys: [], + }, ]); 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', + deprecatedKeys: [], + }, ]); expect(() => schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]), @@ -131,7 +149,11 @@ 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', + deprecatedKeys: [], + }, ]); expect(() => schema.process(configs, { visibility: ['secret'] })).toThrow( 'Config validation failed, Config should be number { type=number } at /key2', @@ -179,7 +201,13 @@ describe('loadConfigSchema', () => { ]; expect( schema.process(mkConfig({ x: 1 }), { visibility: ['frontend'] }), - ).toEqual([{ data: { nested: [{}] }, context: 'test' }]); + ).toEqual([ + { + data: { nested: [{}] }, + context: 'test', + deprecatedKeys: [], + }, + ]); expect(() => schema.process(mkConfig({ y: 1 }))).toThrow( 'Config validation failed, Config should be string { type=string } at /nested/0/y', ); @@ -190,10 +218,22 @@ describe('loadConfigSchema', () => { ); expect( schema.process(mkConfig({ x: 'a' }), { visibility: ['frontend'] }), - ).toEqual([{ data: { nested: [{}] }, context: 'test' }]); + ).toEqual([ + { + data: { nested: [{}] }, + context: 'test', + deprecatedKeys: [], + }, + ]); expect( schema.process(mkConfig({ y: 'aaa' }), { visibility: ['frontend'] }), - ).toEqual([{ data: { nested: [{ y: 'aaa' }] }, context: 'test' }]); + ).toEqual([ + { + data: { nested: [{ y: 'aaa' }] }, + context: 'test', + deprecatedKeys: [], + }, + ]); expect(() => schema.process(mkConfig({ y: 'aaaa' }), { visibility: ['frontend'] }), ).toThrow( diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts index 2d78785304..303fdfade9 100644 --- a/packages/config-loader/src/lib/schema/load.ts +++ b/packages/config-loader/src/lib/schema/load.ts @@ -18,7 +18,11 @@ import { AppConfig } from '@backstage/config'; import { JsonObject } from '@backstage/types'; import { compileConfigSchemas } from './compile'; import { collectConfigSchemas } from './collect'; -import { filterByVisibility, filterErrorsByVisibility } from './filtering'; +import { + filterByVisibility, + filterErrorsByVisibility, + filterByDeprecated, +} from './filtering'; import { ValidationError, ConfigSchema, @@ -82,7 +86,7 @@ export async function loadConfigSchema( return { process( configs: AppConfig[], - { visibility, valueTransform, withFilteredKeys } = {}, + { visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {}, ): AppConfig[] { const result = validate(configs); @@ -108,6 +112,11 @@ export async function loadConfigSchema( valueTransform, withFilteredKeys, ), + ...filterByDeprecated( + data, + result.deprecationBySchemaPath, + withDeprecatedKeys, + ), })); } else if (valueTransform) { processedConfigs = processedConfigs.map(({ data, context }) => ({ @@ -119,6 +128,11 @@ export async function loadConfigSchema( valueTransform, withFilteredKeys, ), + ...filterByDeprecated( + data, + result.deprecationBySchemaPath, + withDeprecatedKeys, + ), })); } diff --git a/packages/config-loader/src/lib/schema/types.ts b/packages/config-loader/src/lib/schema/types.ts index 53f4dec88e..76cc337f83 100644 --- a/packages/config-loader/src/lib/schema/types.ts +++ b/packages/config-loader/src/lib/schema/types.ts @@ -81,6 +81,13 @@ type ValidationResult = { * The path in the key uses the form `/properties//items/additionalProperties/` */ visibilityBySchemaPath: Map; + + /** + * The deprecated options that were discovered during validation. + * + * The path in the key uses the form `/properties//items/additionalProperties/` + */ + deprecationBySchemaPath: Map; }; /** @@ -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; }; /** diff --git a/packages/config/api-report.md b/packages/config/api-report.md index efa6b85559..df9baeb4cd 100644 --- a/packages/config/api-report.md +++ b/packages/config/api-report.md @@ -13,6 +13,10 @@ export type AppConfig = { context: string; data: JsonObject_2; filteredKeys?: string[]; + deprecatedKeys?: { + key: string; + description: string; + }[]; }; // @public diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 8c0c4140a5..5c34b215ab 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -83,9 +83,27 @@ export class ConfigReader implements Config { // Merge together all configs into a single config with recursive fallback // readers, giving the first config object in the array the lowest priority. return configs.reduce( - (previousReader, { data, context, filteredKeys }) => { + (previousReader, { data, context, filteredKeys, deprecatedKeys }) => { const reader = new ConfigReader(data, context, previousReader); reader.filteredKeys = filteredKeys; + + // TODO(cmpadden) introduce `deprecatedKeys` and `notifiedDeprecatedKeys` & use logger + if (deprecatedKeys) { + for (const { key, description } of deprecatedKeys) { + if (description) { + // eslint-disable-next-line no-console + console.warn( + `The configuration key '${key}' is deprecated and may be removed soon. (reason: ${description})`, + ); + } else { + // eslint-disable-next-line no-console + console.warn( + `The configuration key '${key}' is deprecated and may be removed soon.`, + ); + } + } + } + return reader; }, undefined!, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index a543233277..37c9595565 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -36,6 +36,12 @@ export type AppConfig = { * This can be used to warn the user if they try to read any of these keys. */ filteredKeys?: string[]; + /** + * A list of deprecated keys that were found in the configuration when it was loaded. + * + * This can be used to warn the user if they are using deprecated properties. + */ + deprecatedKeys?: { key: string; description: string }[]; }; /**