Skip to content

Commit 0c31158

Browse files
authored
feat(cli): generate plugin specific schema for dynamic plugins (#912)
Signed-off-by: Tomas Coufal <[email protected]>
1 parent 8e18fbe commit 0c31158

File tree

7 files changed

+318
-3
lines changed

7 files changed

+318
-3
lines changed

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@
3838
"@backstage/errors": "^1.2.3",
3939
"@backstage/eslint-plugin": "^0.1.3",
4040
"@backstage/types": "^1.1.1",
41-
"@openshift/dynamic-plugin-sdk-webpack": "^3.0.0",
4241
"@manypkg/get-packages": "^1.1.3",
42+
"@openshift/dynamic-plugin-sdk-webpack": "^3.0.0",
4343
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
4444
"@rollup/plugin-commonjs": "^25.0.4",
4545
"@rollup/plugin-json": "^6.0.0",
@@ -83,6 +83,7 @@
8383
"semver": "^7.5.4",
8484
"style-loader": "^3.3.1",
8585
"swc-loader": "^0.2.3",
86+
"typescript-json-schema": "^0.62.0",
8687
"webpack": "^5.89.0",
8788
"webpack-dev-server": "^4.15.1",
8889
"yml-loader": "^2.1.0",

packages/cli/src/commands/export-dynamic-plugin/command.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,18 @@ import { OptionValues } from 'commander';
2020
import fs from 'fs-extra';
2121

2222
import { paths } from '../../lib/paths';
23+
import { getConfigSchema } from '../../lib/schema/collect';
2324
import { backend } from './backend';
2425
import { frontend } from './frontend';
2526

27+
const saveSchema = async (packageName: string, path: string) => {
28+
const configSchema = await getConfigSchema(packageName);
29+
await fs.writeJson(paths.resolveTarget(path), configSchema, {
30+
encoding: 'utf8',
31+
spaces: 2,
32+
});
33+
};
34+
2635
export async function command(opts: OptionValues): Promise<void> {
2736
const rawPkg = await fs.readJson(paths.resolveTarget('package.json'));
2837
const role = PackageRoles.getRoleFromPackage(rawPkg);
@@ -33,11 +42,19 @@ export async function command(opts: OptionValues): Promise<void> {
3342
const roleInfo = PackageRoles.getRoleInfo(role);
3443

3544
if (role === 'backend-plugin' || role === 'backend-plugin-module') {
36-
return backend(roleInfo, opts);
45+
await backend(roleInfo, opts);
46+
47+
await saveSchema(rawPkg.name, 'dist-dynamic/dist/configSchema.json');
48+
49+
return;
3750
}
3851

3952
if (role === 'frontend-plugin') {
40-
return frontend(roleInfo, opts);
53+
await frontend(roleInfo, opts);
54+
55+
await saveSchema(rawPkg.name, 'dist-scalprum/configSchema.json');
56+
57+
return;
4158
}
4259

4360
throw new Error(

packages/cli/src/commands/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export function registerScriptCommand(program: Command) {
100100
'Allow testing/debugging a backend plugin dynamic loading locally. This installs the dynamic plugin content (symlink) into the dynamic plugins root folder configured in the app config. This also creates a link from the dynamic plugin content to the plugin package `src` folder, to enable the use of source maps (backend plugin only).',
101101
)
102102
.action(lazy(() => import('./export-dynamic-plugin').then(m => m.command)));
103+
104+
command
105+
.command('schema')
106+
.description('Print configuration schema for a package')
107+
.action(lazy(() => import('./schema').then(m => m.default)));
103108
}
104109

105110
export function registerCommands(program: Command) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import fs from 'fs-extra';
2+
3+
import { paths } from '../../lib/paths';
4+
import { getConfigSchema } from '../../lib/schema/collect';
5+
6+
export default async () => {
7+
const { name } = await fs.readJson(paths.resolveTarget('package.json'));
8+
const configSchema = await getConfigSchema(name);
9+
10+
process.stdout.write(`${JSON.stringify(configSchema, null, 2)}\n`);
11+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './command';
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { mergeConfigSchemas } from '@backstage/config-loader';
2+
import { assertError } from '@backstage/errors';
3+
import { JsonObject } from '@backstage/types';
4+
5+
import fs from 'fs-extra';
6+
7+
import { EOL } from 'os';
8+
import {
9+
dirname,
10+
relative as relativePath,
11+
resolve as resolvePath,
12+
sep,
13+
} from 'path';
14+
15+
type ConfigSchemaPackageEntry = {
16+
/**
17+
* The configuration schema itself.
18+
*/
19+
value: JsonObject;
20+
/**
21+
* The relative path that the configuration schema was discovered at.
22+
*/
23+
path: string;
24+
};
25+
26+
type Item = {
27+
name?: string;
28+
parentPath?: string;
29+
packagePath?: string;
30+
};
31+
32+
/**
33+
* Filter out Backstage core packages from crawled dependencies based on their package name
34+
* @param depName Package name to crawl
35+
* @returns True if package is useful to crawl, false otherwise
36+
*/
37+
const filterPackages = (depName: string) => {
38+
// reject all core dependencies
39+
if (depName.startsWith('@backstage')) {
40+
// make an exception for Backstage core plugins (used in plugin wrappers) unless they are common to all Backstage instances
41+
if (depName.startsWith('@backstage/plugin-')) {
42+
if (
43+
depName.startsWith('@backstage/plugin-catalog-') ||
44+
depName.startsWith('@backstage/plugin-permission-') ||
45+
depName.startsWith('@backstage/plugin-search-') ||
46+
depName.startsWith('@backstage/plugin-scaffolder-')
47+
) {
48+
return false;
49+
}
50+
return true;
51+
}
52+
return false;
53+
} else if (depName === '@janus-idp/cli') {
54+
// reject CLI schema
55+
return false;
56+
}
57+
// all other packages should be included in the schema
58+
return true;
59+
};
60+
61+
const req =
62+
typeof __non_webpack_require__ === 'undefined'
63+
? require
64+
: __non_webpack_require__;
65+
66+
/**
67+
* This collects all known config schemas across all dependencies of the app.
68+
* Inspired by https://github.com/backstage/backstage/blob/a957d4654f35fb5ba6cc3450bcdb2634dcbb7724/packages/config-loader/src/schema/collect.ts#L43
69+
* All unrelated logic removed from ^, only collection code is left
70+
*
71+
* @param packageName Package name to collect schema for
72+
*/
73+
async function collectConfigSchemas(
74+
packageName: string,
75+
): Promise<ConfigSchemaPackageEntry[]> {
76+
const schemas = new Array<ConfigSchemaPackageEntry>();
77+
const tsSchemaPaths = new Array<string>();
78+
const visitedPackageVersions = new Map<string, Set<string>>(); // pkgName: [versions...]
79+
80+
const currentDir = await fs.realpath(process.cwd());
81+
82+
async function processItem(item: Item) {
83+
let pkgPath = item.packagePath;
84+
85+
if (pkgPath) {
86+
const pkgExists = await fs.pathExists(pkgPath);
87+
if (!pkgExists) {
88+
return;
89+
}
90+
} else if (item.name) {
91+
const { name, parentPath } = item;
92+
93+
try {
94+
pkgPath = req.resolve(
95+
`${name}/package.json`,
96+
parentPath && {
97+
paths: [parentPath],
98+
},
99+
);
100+
} catch {
101+
// We can somewhat safely ignore packages that don't export package.json,
102+
// as they are likely not part of the Backstage ecosystem anyway.
103+
}
104+
}
105+
if (!pkgPath) {
106+
return;
107+
}
108+
109+
const pkg = await fs.readJson(pkgPath);
110+
111+
// Ensures that we only process the same version of each package once.
112+
let versions = visitedPackageVersions.get(pkg.name);
113+
if (versions?.has(pkg.version)) {
114+
return;
115+
}
116+
if (!versions) {
117+
versions = new Set();
118+
visitedPackageVersions.set(pkg.name, versions);
119+
}
120+
versions.add(pkg.version);
121+
122+
const depNames = [
123+
...Object.keys(pkg.dependencies ?? {}),
124+
...Object.keys(pkg.devDependencies ?? {}),
125+
...Object.keys(pkg.optionalDependencies ?? {}),
126+
...Object.keys(pkg.peerDependencies ?? {}),
127+
];
128+
129+
const hasSchema = 'configSchema' in pkg;
130+
if (hasSchema) {
131+
if (typeof pkg.configSchema === 'string') {
132+
const isJson = pkg.configSchema.endsWith('.json');
133+
const isDts = pkg.configSchema.endsWith('.d.ts');
134+
if (!isJson && !isDts) {
135+
throw new Error(
136+
`Config schema files must be .json or .d.ts, got ${pkg.configSchema}`,
137+
);
138+
}
139+
if (isDts) {
140+
tsSchemaPaths.push(
141+
relativePath(
142+
currentDir,
143+
resolvePath(dirname(pkgPath), pkg.configSchema),
144+
),
145+
);
146+
} else {
147+
const path = resolvePath(dirname(pkgPath), pkg.configSchema);
148+
const value = await fs.readJson(path);
149+
schemas.push({
150+
value,
151+
path: relativePath(currentDir, path),
152+
});
153+
}
154+
} else {
155+
schemas.push({
156+
value: pkg.configSchema,
157+
path: relativePath(currentDir, pkgPath),
158+
});
159+
}
160+
}
161+
162+
await Promise.all(
163+
depNames
164+
.filter(filterPackages)
165+
.map(depName => processItem({ name: depName, parentPath: pkgPath })),
166+
);
167+
}
168+
169+
await processItem({ name: packageName, parentPath: currentDir });
170+
171+
const tsSchemas = await compileTsSchemas(tsSchemaPaths);
172+
173+
return schemas.concat(tsSchemas);
174+
}
175+
176+
// This handles the support of TypeScript .d.ts config schema declarations.
177+
// We collect all typescript schema definition and compile them all in one go.
178+
// This is much faster than compiling them separately.
179+
// Copy-pasted from: https://github.com/backstage/backstage/blob/a957d4654f35fb5ba6cc3450bcdb2634dcbb7724/packages/config-loader/src/schema/collect.ts#L160
180+
async function compileTsSchemas(paths: string[]) {
181+
if (paths.length === 0) {
182+
return [];
183+
}
184+
185+
// Lazy loaded, because this brings up all of TypeScript and we don't
186+
// want that eagerly loaded in tests
187+
const { getProgramFromFiles, buildGenerator } = await import(
188+
'typescript-json-schema'
189+
);
190+
191+
const program = getProgramFromFiles(paths, {
192+
incremental: false,
193+
isolatedModules: true,
194+
lib: ['ES5'], // Skipping most libs speeds processing up a lot, we just need the primitive types anyway
195+
noEmit: true,
196+
noResolve: true,
197+
skipLibCheck: true, // Skipping lib checks speeds things up
198+
skipDefaultLibCheck: true,
199+
strict: true,
200+
typeRoots: [], // Do not include any additional types
201+
types: [],
202+
});
203+
204+
const tsSchemas = paths.map(path => {
205+
let value;
206+
try {
207+
const generator = buildGenerator(
208+
program,
209+
// This enables the use of these tags in TSDoc comments
210+
{
211+
required: true,
212+
validationKeywords: ['visibility', 'deepVisibility', 'deprecated'],
213+
},
214+
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
215+
);
216+
217+
// All schemas should export a `Config` symbol
218+
value = generator?.getSchemaForSymbol('Config') as JsonObject | null;
219+
220+
// This makes sure that no additional symbols are defined in the schema. We don't allow
221+
// this because they share a global namespace and will be merged together, leading to
222+
// unpredictable behavior.
223+
const userSymbols = new Set(generator?.getUserSymbols());
224+
userSymbols.delete('Config');
225+
if (userSymbols.size !== 0) {
226+
const names = Array.from(userSymbols).join("', '");
227+
throw new Error(
228+
`Invalid configuration schema in ${path}, additional symbol definitions are not allowed, found '${names}'`,
229+
);
230+
}
231+
232+
// This makes sure that no unsupported types are used in the schema, for example `Record<,>`.
233+
// The generator will extract these as a schema reference, which will in turn be broken for our usage.
234+
const reffedDefs = Object.keys(generator?.ReffedDefinitions ?? {});
235+
if (reffedDefs.length !== 0) {
236+
const lines = reffedDefs.join(`${EOL} `);
237+
throw new Error(
238+
`Invalid configuration schema in ${path}, the following definitions are not supported:${EOL}${EOL} ${lines}`,
239+
);
240+
}
241+
} catch (error) {
242+
assertError(error);
243+
if (error.message !== 'type Config not found') {
244+
throw error;
245+
}
246+
}
247+
248+
if (!value) {
249+
throw new Error(`Invalid schema in ${path}, missing Config export`);
250+
}
251+
return { path, value };
252+
});
253+
254+
return tsSchemas;
255+
}
256+
257+
/**
258+
* Collect JSON schema for given plugin package (without core Backstage schema)
259+
* @param packageName Name of the package for which it is needed to collect schema
260+
* @returns JSON Schema object
261+
*/
262+
export const getConfigSchema = async (packageName: string) => {
263+
const schemas = await collectConfigSchemas(packageName);
264+
265+
return mergeConfigSchemas((schemas as JsonObject[]).map(_ => _.value as any));
266+
};

yarn.lock

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25153,6 +25153,20 @@ typescript-json-schema@^0.61.0:
2515325153
typescript "~5.1.0"
2515425154
yargs "^17.1.1"
2515525155

25156+
typescript-json-schema@^0.62.0:
25157+
version "0.62.0"
25158+
resolved "https://registry.yarnpkg.com/typescript-json-schema/-/typescript-json-schema-0.62.0.tgz#774b06b0c9d86d7f3580ea9136363a6eafae1470"
25159+
integrity sha512-qRO6pCgyjKJ230QYdOxDRpdQrBeeino4v5p2rYmSD72Jf4rD3O+cJcROv46sQukm46CLWoeusqvBgKpynEv25g==
25160+
dependencies:
25161+
"@types/json-schema" "^7.0.9"
25162+
"@types/node" "^16.9.2"
25163+
glob "^7.1.7"
25164+
path-equal "^1.2.5"
25165+
safe-stable-stringify "^2.2.0"
25166+
ts-node "^10.9.1"
25167+
typescript "~5.1.0"
25168+
yargs "^17.1.1"
25169+
2515625170
2515725171
version "5.2.2"
2515825172
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"

0 commit comments

Comments
 (0)