diff --git a/.changeset/odd-lemons-occur.md b/.changeset/odd-lemons-occur.md new file mode 100644 index 0000000000..695ed3c80c --- /dev/null +++ b/.changeset/odd-lemons-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Plugin IDs that do not match the standard format are deprecated (letters, digits, and dashes only, starting with a letter). Plugin IDs that do no match this format will be rejected in a future release. diff --git a/.changeset/slow-numbers-study.md b/.changeset/slow-numbers-study.md new file mode 100644 index 0000000000..fe7203e169 --- /dev/null +++ b/.changeset/slow-numbers-study.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': minor +--- + +Plugin IDs that do not match the standard format are deprecated (letters, digits, and dashes only, starting with a letter). Plugin IDs that do no match this format will be rejected in a future release. + +In addition, plugin IDs that don't match the legacy pattern that also allows underscores, with be rejected. diff --git a/docs/backend-system/architecture/08-naming-patterns.md b/docs/backend-system/architecture/08-naming-patterns.md index 585a9fe87c..d073f3382c 100644 --- a/docs/backend-system/architecture/08-naming-patterns.md +++ b/docs/backend-system/architecture/08-naming-patterns.md @@ -11,10 +11,10 @@ As a rule, all names should be camel case, with the exceptions of plugin and mod ### Plugins -| Description | Pattern | Examples | -| ----------- | ----------------- | ------------------------------------- | -| export | `Plugin` | `catalogPlugin`, `userSettingsPlugin` | -| ID | `''` | `'catalog'`, `'user-settings'` | +| Description | Pattern | Examples | Notes | +| ----------- | ----------------- | ------------------------------------- | --------------------------------------------------- | +| export | `Plugin` | `catalogPlugin`, `userSettingsPlugin` | | +| ID | `''` | `'catalog'`, `'user-settings'` | letters, digits, and dashes, starting with a letter | Example: @@ -27,10 +27,10 @@ export const userSettingsPlugin = createBackendPlugin({ ### Modules -| Description | Pattern | Examples | -| ----------- | ---------------------------- | ----------------------------------- | -| export | `Module` | `catalogModuleGithubEntityProvider` | -| ID | `''` | `'github-entity-provider'` | +| Description | Pattern | Examples | Notes | +| ----------- | ---------------------------- | ----------------------------------- | --------------------------------------------------- | +| export | `Module` | `catalogModuleGithubEntityProvider` | | +| ID | `''` | `'github-entity-provider'` | letters, digits, and dashes, starting with a letter | Example: diff --git a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts index d101487318..bb83898f8c 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts @@ -31,7 +31,7 @@ import { */ export const dynamicPluginsFrontendSchemas = createBackendModule({ pluginId: 'app', - moduleId: 'core.dynamicplugins.frontendSchemas', + moduleId: 'core-dynamicplugins-frontendSchemas', register(reg) { reg.registerInit({ deps: { diff --git a/packages/backend-plugin-api/src/wiring/constants.ts b/packages/backend-plugin-api/src/wiring/constants.ts new file mode 100644 index 0000000000..48f8e5a5c7 --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/constants.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NOTE: changing any of these constants need to be reflected in +// @backstage/frontend-plugin-api/src/wiring/constants.ts as well + +/** + * The pattern that IDs must match. + * + * @remarks + * ids must only contain the letters `a` through `z` and digits, in groups separated by + * dashes. Additionally, the very first character of the first group + * must be a letter, not a digit + * + * @public + */ +export const ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/i; +export const ID_PATTERN_OLD = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/i; diff --git a/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts index a59a945641..38be8d9d72 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts @@ -15,6 +15,7 @@ */ import { createServiceRef } from '../services'; +import { ID_PATTERN } from './constants'; import { createBackendModule } from './createBackendModule'; import { createExtensionPoint } from './createExtensionPoint'; import { InternalBackendRegistrations } from './types'; @@ -77,4 +78,20 @@ describe('createBackendModule', () => { expect(plugin.$$type).toEqual('@backstage/BackendFeature'); }); + it('should reject modules with invalid moduleId', async () => { + expect(() => + createBackendModule({ + pluginId: 'test', + moduleId: 'invalid:module&id', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ).toThrow( + `Invalid moduleId 'invalid:module&id' for plugin 'test', must match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + }); }); diff --git a/packages/backend-plugin-api/src/wiring/createBackendModule.ts b/packages/backend-plugin-api/src/wiring/createBackendModule.ts index 284125c676..e72341479d 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendModule.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendModule.ts @@ -15,6 +15,7 @@ */ import { BackendFeature } from '../types'; +import { ID_PATTERN, ID_PATTERN_OLD } from './constants'; import { BackendModuleRegistrationPoints, ExtensionPoint, @@ -55,6 +56,17 @@ export interface CreateBackendModuleOptions { export function createBackendModule( options: CreateBackendModuleOptions, ): BackendFeature { + if (!ID_PATTERN.test(options.moduleId)) { + console.warn( + `WARNING: The moduleId '${options.moduleId}' for plugin '${options.pluginId}', will be invalid soon, please change it to match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + } + if (!ID_PATTERN_OLD.test(options.moduleId)) { + throw new Error( + `Invalid moduleId '${options.moduleId}' for plugin '${options.pluginId}', must match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + } + function getRegistrations() { const extensionPoints: InternalBackendModuleRegistrationV1_1['extensionPoints'] = []; diff --git a/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts index 391b5e47e5..8267a74465 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts @@ -15,6 +15,7 @@ */ import { createServiceRef } from '../services'; +import { ID_PATTERN } from './constants'; import { createBackendPlugin } from './createBackendPlugin'; import { createExtensionPoint } from './createExtensionPoint'; import { InternalBackendRegistrations } from './types'; @@ -90,4 +91,19 @@ describe('createBackendPlugin', () => { expect(plugin.$$type).toEqual('@backstage/BackendFeature'); }); + it('should reject plugins with invalid pluginId', async () => { + expect(() => + createBackendPlugin({ + pluginId: 'test:invalid&id', + register(reg) { + reg.registerInit({ + deps: {}, + async init() {}, + }); + }, + }), + ).toThrow( + `Invalid pluginId 'test:invalid&id', must match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + }); }); diff --git a/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts b/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts index 7cdc985dd7..3378c17b01 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts @@ -22,6 +22,7 @@ import { InternalBackendPluginRegistrationV1_1, InternalBackendRegistrations, } from './types'; +import { ID_PATTERN, ID_PATTERN_OLD } from './constants'; /** * The configuration options passed to {@link createBackendPlugin}. @@ -50,6 +51,17 @@ export interface CreateBackendPluginOptions { export function createBackendPlugin( options: CreateBackendPluginOptions, ): BackendFeature { + if (!ID_PATTERN.test(options.pluginId)) { + console.warn( + `WARNING: The pluginId '${options.pluginId}' will be invalid soon, please change it to match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + } + if (!ID_PATTERN_OLD.test(options.pluginId)) { + throw new Error( + `Invalid pluginId '${options.pluginId}', must match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + } + function getRegistrations() { const extensionPoints: InternalBackendPluginRegistrationV1_1['extensionPoints'] = []; diff --git a/packages/frontend-plugin-api/src/wiring/constants.ts b/packages/frontend-plugin-api/src/wiring/constants.ts new file mode 100644 index 0000000000..5300cfc27f --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/constants.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NOTE: changing any of these constants need to be reflected in +// @backstage/backend-plugin-api/src/wiring/constants.ts as well + +/** + * The pattern that IDs must match. + * + * @remarks + * ids must only contain the letters `a` through `z` and digits, in groups separated by + * dashes. Additionally, the very first character of the first group + * must be a letter, not a digit + * + * @public + */ +export const ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/i; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index a3226721a1..1e1f7e1a84 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -142,6 +142,17 @@ describe('createFrontendPlugin', () => { expect(String(plugin)).toBe('Plugin{id=test}'); }); + it('should warn about invalid plugin IDs', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + createFrontendPlugin({ pluginId: 'invalid&id' }); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining("The pluginId 'invalid&id' will be invalid soon"), + ); + consoleWarn.mockRestore(); + }); + it('should create a plugin with extension instances', async () => { const plugin = createFrontendPlugin({ pluginId: 'test', diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 18fd52162a..62fd1e88ff 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -30,6 +30,7 @@ import { FeatureFlagConfig } from './types'; import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap'; import { JsonObject } from '@backstage/types'; import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; +import { ID_PATTERN } from './constants'; /** * Information about the plugin. @@ -208,6 +209,13 @@ export function createFrontendPlugin< > { const pluginId = options.pluginId; + if (!ID_PATTERN.test(pluginId)) { + // eslint-disable-next-line no-console + console.warn( + `WARNING: The pluginId '${pluginId}' will be invalid soon, please change it to match the pattern ${ID_PATTERN} (letters, digits, and dashes only, starting with a letter)`, + ); + } + const extensions = new Array>(); const extensionDefinitionsById = new Map< string,