diff --git a/packages/app-next/src/examples/graphiqlPlugin.tsx b/packages/app-next/src/examples/graphiqlPlugin.tsx
index 87f2bb242c..c40b78fc97 100644
--- a/packages/app-next/src/examples/graphiqlPlugin.tsx
+++ b/packages/app-next/src/examples/graphiqlPlugin.tsx
@@ -14,11 +14,11 @@
* limitations under the License.
*/
-import React from 'react';
import {
createExtension,
createPlugin,
coreExtensionData,
+ createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import { Router as GraphiQLPage } from '@backstage/plugin-graphiql';
@@ -27,12 +27,12 @@ export const GraphiqlPageExtension = createExtension({
component: coreExtensionData.reactComponent,
path: coreExtensionData.routePath,
},
+ configSchema: createSchemaFromZod(z =>
+ z.object({ path: z.string().default('/graphiql') }),
+ ),
factory({ bind, config }) {
- bind.component(() => {
- return ;
- });
- // TODO: In need of schemas and type safety
- bind.path((config as { path: string }).path);
+ bind.component(GraphiQLPage);
+ bind.path(config.path);
},
});
@@ -43,7 +43,6 @@ export const graphiqlPlugin = createPlugin({
id: 'graphiql.page',
at: 'core.router/routes',
extension: GraphiqlPageExtension,
- config: { path: '/graphiql' },
},
],
});
diff --git a/packages/frontend-app-api/src/createExtensionInstance.ts b/packages/frontend-app-api/src/createExtensionInstance.ts
index 0b62df74ae..de9a41f49f 100644
--- a/packages/frontend-app-api/src/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/createExtensionInstance.ts
@@ -27,26 +27,45 @@ export interface ExtensionInstance {
/** @internal */
export function createExtensionInstance(options: {
id: string;
- extension: Extension;
+ extension: Extension;
config: unknown;
attachments: Record;
}): ExtensionInstance {
- const { extension, config, attachments } = options;
+ const { id, extension, config, attachments } = options;
const extensionData = new Map();
- extension.factory({
- config,
- bind: mapValues(extension.output, ref => {
- return (value: unknown) => extensionData.set(ref.id, value);
- }),
- inputs: mapValues(
- extension.inputs,
- ({ extensionData: pointData }, inputName) => {
- // TODO: validation
- return (attachments[inputName] ?? []).map(attachment =>
- mapValues(pointData, ref => attachment.data.get(ref.id)),
- );
- },
- ),
- });
- return { id: options.id, data: extensionData, $$type: 'extension-instance' };
+
+ let parsedConfig: unknown;
+ try {
+ parsedConfig = extension.configSchema?.parse(config ?? {});
+ } catch (e) {
+ throw new Error(
+ `Invalid configuration for extension instance '${id}', ${e}`,
+ );
+ }
+
+ try {
+ extension.factory({
+ config: parsedConfig,
+ bind: mapValues(extension.output, ref => {
+ return (value: unknown) => extensionData.set(ref.id, value);
+ }),
+ inputs: mapValues(
+ extension.inputs,
+ ({ extensionData: pointData }, inputName) => {
+ // TODO: validation
+ return (attachments[inputName] ?? []).map(attachment =>
+ mapValues(pointData, ref => attachment.data.get(ref.id)),
+ );
+ },
+ ),
+ });
+ } catch (e) {
+ throw new Error(`Failed to instantiate extension instance '${id}', ${e}`);
+ }
+
+ return {
+ id: options.id,
+ data: extensionData,
+ $$type: 'extension-instance',
+ };
}
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 4337b0b8c2..a256a4e6e5 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -4,6 +4,10 @@
```ts
import { ComponentType } from 'react';
+import { JsonObject } from '@backstage/types';
+import { z } from 'zod';
+import { ZodSchema } from 'zod';
+import { ZodTypeDef } from 'zod';
// @public (undocumented)
export type AnyExtensionDataMap = Record>;
@@ -41,7 +45,8 @@ export function createExtension<
extensionData: AnyExtensionDataMap;
}
>,
->(options: CreateExtensionOptions): Extension;
+ TConfig = never,
+>(options: CreateExtensionOptions): Extension;
// @public (undocumented)
export interface CreateExtensionOptions<
@@ -52,11 +57,14 @@ export interface CreateExtensionOptions<
extensionData: AnyExtensionDataMap;
}
>,
+ TConfig,
> {
+ // (undocumented)
+ configSchema?: PortableSchema;
// (undocumented)
factory(options: {
bind: ExtensionDataBind;
- config?: unknown;
+ config: TConfig;
inputs: {
[pointName in keyof TPoint]: ExtensionDataValue<
TPoint[pointName]['extensionData']
@@ -73,13 +81,20 @@ export interface CreateExtensionOptions<
export function createPlugin(options: BackstagePluginOptions): BackstagePlugin;
// @public (undocumented)
-export interface Extension {
+export function createSchemaFromZod(
+ schemaCreator: (zImpl: typeof z) => ZodSchema,
+): PortableSchema;
+
+// @public (undocumented)
+export interface Extension {
// (undocumented)
$$type: 'extension';
// (undocumented)
+ configSchema?: PortableSchema;
+ // (undocumented)
factory(options: {
bind: ExtensionDataBind;
- config?: unknown;
+ config: TConfig;
inputs: Record>>;
}): void;
// (undocumented)
@@ -115,10 +130,16 @@ export interface ExtensionInstanceConfig {
// (undocumented)
at: string;
// (undocumented)
- config: unknown;
+ config?: unknown;
// (undocumented)
- extension: Extension;
+ extension: Extension;
// (undocumented)
id: string;
}
+
+// @public (undocumented)
+export type PortableSchema = {
+ parse: (input: unknown) => TOutput;
+ schema: JsonObject;
+};
```
diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json
index 731e66fed0..cf353e55a6 100644
--- a/packages/frontend-plugin-api/package.json
+++ b/packages/frontend-plugin-api/package.json
@@ -33,6 +33,9 @@
"react": "^16.13.1 || ^17.0.0"
},
"dependencies": {
- "lodash": "^4.17.21"
+ "@backstage/types": "workspace:^",
+ "lodash": "^4.17.21",
+ "zod": "^3.21.4",
+ "zod-to-json-schema": "^3.21.4"
}
}
diff --git a/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts b/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts
new file mode 100644
index 0000000000..90eba44249
--- /dev/null
+++ b/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2023 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.
+ */
+
+import { createSchemaFromZod } from './createSchemaFromZod';
+
+describe('createSchemaFromZod', () => {
+ it('should provide nice parse errors', () => {
+ const { parse } = createSchemaFromZod(z =>
+ z.object({
+ foo: z.union([z.string(), z.number()]),
+ derp: z.object({ bar: z.number() }),
+ }),
+ );
+
+ expect(() => parse({ derp: { bar: 'derp' } })).toThrow(
+ `Missing required value at 'foo'; Expected number, received string at 'derp.bar'`,
+ );
+ expect(() => parse(undefined)).toThrow(`Missing required value`);
+ expect(() => parse('derp')).toThrow(`Expected object, received string`);
+ });
+});
diff --git a/packages/frontend-plugin-api/src/createSchemaFromZod.ts b/packages/frontend-plugin-api/src/createSchemaFromZod.ts
new file mode 100644
index 0000000000..447cea8bbd
--- /dev/null
+++ b/packages/frontend-plugin-api/src/createSchemaFromZod.ts
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 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.
+ */
+
+import { JsonObject } from '@backstage/types';
+import { z, ZodSchema, ZodTypeDef } from 'zod';
+import zodToJsonSchema from 'zod-to-json-schema';
+
+/** @public */
+export type PortableSchema = {
+ parse: (input: unknown) => TOutput;
+ schema: JsonObject;
+};
+
+/** @public */
+export function createSchemaFromZod(
+ schemaCreator: (zImpl: typeof z) => ZodSchema,
+): PortableSchema {
+ const schema = schemaCreator(z);
+ return {
+ // TODO: Types allow z.array etc here but it will break stuff
+ parse: input => {
+ const result = schema.safeParse(input);
+ if (result.success) {
+ return result.data;
+ }
+
+ throw new Error(result.error.issues.map(formatIssue).join('; '));
+ },
+ // TODO: Verify why we are not compatible with the latest zodToJsonSchema.
+ schema: zodToJsonSchema(schema) as JsonObject,
+ };
+}
+
+function formatIssue(issue: z.ZodIssue): string {
+ if (issue.code === 'invalid_union') {
+ return formatIssue(issue.unionErrors[0].issues[0]);
+ }
+ let message = issue.message;
+ if (message === 'Required') {
+ message = `Missing required value`;
+ }
+ if (issue.path.length) {
+ message += ` at '${issue.path.join('.')}'`;
+ }
+ return message;
+}
diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts
index e36391131d..95083bcc73 100644
--- a/packages/frontend-plugin-api/src/index.ts
+++ b/packages/frontend-plugin-api/src/index.ts
@@ -24,7 +24,7 @@ export {
createExtension,
coreExtensionData,
createPlugin,
- type ExtensionInstanceConfig,
+ type ExtensionInstanceParameters as ExtensionInstanceConfig,
type BackstagePlugin,
type Extension,
type AnyExtensionDataMap,
@@ -34,3 +34,7 @@ export {
type ExtensionDataRef,
type ExtensionDataValue,
} from './types';
+export {
+ createSchemaFromZod,
+ type PortableSchema,
+} from './createSchemaFromZod';
diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts
index b2d131c62f..3d70a58a0f 100644
--- a/packages/frontend-plugin-api/src/types.ts
+++ b/packages/frontend-plugin-api/src/types.ts
@@ -15,6 +15,7 @@
*/
import { ComponentType } from 'react';
+import { PortableSchema } from './createSchemaFromZod';
/** @public */
export type ExtensionDataRef = {
@@ -51,12 +52,14 @@ export type ExtensionDataValue = {
export interface CreateExtensionOptions<
TData extends AnyExtensionDataMap,
TPoint extends Record,
+ TConfig,
> {
inputs?: TPoint;
output: TData;
+ configSchema?: PortableSchema;
factory(options: {
bind: ExtensionDataBind;
- config?: unknown;
+ config: TConfig;
inputs: {
[pointName in keyof TPoint]: ExtensionDataValue<
TPoint[pointName]['extensionData']
@@ -66,13 +69,15 @@ export interface CreateExtensionOptions<
}
/** @public */
-export interface Extension {
+export interface Extension {
$$type: 'extension';
+ // TODO: will extensions have a default "at" as part of their contract, making it optional in the instance config?
inputs: Record;
output: AnyExtensionDataMap;
+ configSchema?: PortableSchema;
factory(options: {
bind: ExtensionDataBind;
- config?: unknown;
+ config: TConfig;
inputs: Record>>;
}): void;
}
@@ -81,29 +86,30 @@ export interface Extension {
export function createExtension<
TData extends AnyExtensionDataMap,
TPoint extends Record,
->(options: CreateExtensionOptions): Extension {
+ TConfig = never,
+>(options: CreateExtensionOptions): Extension {
return { ...options, $$type: 'extension', inputs: options.inputs ?? {} };
}
/** @public */
-export interface ExtensionInstanceConfig {
+export interface ExtensionInstanceParameters {
id: string;
at: string;
- extension: Extension;
- config: unknown;
+ extension: Extension;
+ config?: unknown;
}
/** @public */
export interface BackstagePluginOptions {
id: string;
- defaultExtensionInstances?: ExtensionInstanceConfig[];
+ defaultExtensionInstances?: ExtensionInstanceParameters[];
}
/** @public */
export interface BackstagePlugin {
$$type: 'backstage-plugin';
id: string;
- defaultExtensionInstances: ExtensionInstanceConfig[];
+ defaultExtensionInstances: ExtensionInstanceParameters[];
}
/** @public */
diff --git a/yarn.lock b/yarn.lock
index 363d1b26cf..cc6ad40082 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4153,8 +4153,11 @@ __metadata:
resolution: "@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api"
dependencies:
"@backstage/cli": "workspace:^"
+ "@backstage/types": "workspace:^"
"@testing-library/jest-dom": ^5.10.1
lodash: ^4.17.21
+ zod: ^3.21.4
+ zod-to-json-schema: ^3.21.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
languageName: unknown