frontend-plugin-api: add support for extension configuration schema

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Philipp Hugenroth <philipph@spotify.com>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-08-23 14:32:06 +02:00
parent d11e8e0e3d
commit a52d7f271b
9 changed files with 190 additions and 42 deletions
@@ -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 <GraphiQLPage />;
});
// 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' },
},
],
});
@@ -27,26 +27,45 @@ export interface ExtensionInstance {
/** @internal */
export function createExtensionInstance(options: {
id: string;
extension: Extension;
extension: Extension<unknown>;
config: unknown;
attachments: Record<string, ExtensionInstance[]>;
}): ExtensionInstance {
const { extension, config, attachments } = options;
const { id, extension, config, attachments } = options;
const extensionData = new Map<string, unknown>();
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',
};
}
+27 -6
View File
@@ -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<string, ExtensionDataRef<any>>;
@@ -41,7 +45,8 @@ export function createExtension<
extensionData: AnyExtensionDataMap;
}
>,
>(options: CreateExtensionOptions<TData, TPoint>): Extension;
TConfig = never,
>(options: CreateExtensionOptions<TData, TPoint, TConfig>): Extension<TConfig>;
// @public (undocumented)
export interface CreateExtensionOptions<
@@ -52,11 +57,14 @@ export interface CreateExtensionOptions<
extensionData: AnyExtensionDataMap;
}
>,
TConfig,
> {
// (undocumented)
configSchema?: PortableSchema<TConfig>;
// (undocumented)
factory(options: {
bind: ExtensionDataBind<TData>;
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<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput>;
// @public (undocumented)
export interface Extension<TConfig> {
// (undocumented)
$$type: 'extension';
// (undocumented)
configSchema?: PortableSchema<TConfig>;
// (undocumented)
factory(options: {
bind: ExtensionDataBind<AnyExtensionDataMap>;
config?: unknown;
config: TConfig;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
// (undocumented)
@@ -115,10 +130,16 @@ export interface ExtensionInstanceConfig {
// (undocumented)
at: string;
// (undocumented)
config: unknown;
config?: unknown;
// (undocumented)
extension: Extension;
extension: Extension<unknown>;
// (undocumented)
id: string;
}
// @public (undocumented)
export type PortableSchema<TOutput> = {
parse: (input: unknown) => TOutput;
schema: JsonObject;
};
```
+4 -1
View File
@@ -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"
}
}
@@ -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`);
});
});
@@ -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<TOutput> = {
parse: (input: unknown) => TOutput;
schema: JsonObject;
};
/** @public */
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput> {
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;
}
+5 -1
View File
@@ -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';
+15 -9
View File
@@ -15,6 +15,7 @@
*/
import { ComponentType } from 'react';
import { PortableSchema } from './createSchemaFromZod';
/** @public */
export type ExtensionDataRef<T> = {
@@ -51,12 +52,14 @@ export type ExtensionDataValue<TData extends AnyExtensionDataMap> = {
export interface CreateExtensionOptions<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
TConfig,
> {
inputs?: TPoint;
output: TData;
configSchema?: PortableSchema<TConfig>;
factory(options: {
bind: ExtensionDataBind<TData>;
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<TConfig> {
$$type: 'extension';
// TODO: will extensions have a default "at" as part of their contract, making it optional in the instance config?
inputs: Record<string, { extensionData: AnyExtensionDataMap }>;
output: AnyExtensionDataMap;
configSchema?: PortableSchema<TConfig>;
factory(options: {
bind: ExtensionDataBind<AnyExtensionDataMap>;
config?: unknown;
config: TConfig;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
}
@@ -81,29 +86,30 @@ export interface Extension {
export function createExtension<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
>(options: CreateExtensionOptions<TData, TPoint>): Extension {
TConfig = never,
>(options: CreateExtensionOptions<TData, TPoint, TConfig>): Extension<TConfig> {
return { ...options, $$type: 'extension', inputs: options.inputs ?? {} };
}
/** @public */
export interface ExtensionInstanceConfig {
export interface ExtensionInstanceParameters {
id: string;
at: string;
extension: Extension;
config: unknown;
extension: Extension<unknown>;
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 */
+3
View File
@@ -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