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
+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 */