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:
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user