From a52d7f271bab146b80c1962930be9fbf73d2984a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 23 Aug 2023 14:32:06 +0200 Subject: [PATCH] frontend-plugin-api: add support for extension configuration schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Camila Belo Co-authored-by: Philipp Hugenroth Co-authored-by: Fredrik Adelöw Co-authored-by: Vincenzo Scamporlino Co-authored-by: Johan Haals Signed-off-by: Patrik Oldsberg --- .../app-next/src/examples/graphiqlPlugin.tsx | 13 ++-- .../src/createExtensionInstance.ts | 55 +++++++++++------ packages/frontend-plugin-api/api-report.md | 33 +++++++++-- packages/frontend-plugin-api/package.json | 5 +- .../src/createSchemaFromZod.test.ts | 34 +++++++++++ .../src/createSchemaFromZod.ts | 59 +++++++++++++++++++ packages/frontend-plugin-api/src/index.ts | 6 +- packages/frontend-plugin-api/src/types.ts | 24 +++++--- yarn.lock | 3 + 9 files changed, 190 insertions(+), 42 deletions(-) create mode 100644 packages/frontend-plugin-api/src/createSchemaFromZod.test.ts create mode 100644 packages/frontend-plugin-api/src/createSchemaFromZod.ts 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